I'm developing a fractal explorer in C++ using SFML and std::thread
to render the Mandelbrot set on the CPU with progressive display. The goal is to leverage multiple cores by dividing the image into horizontal strips, with each strip rendered by a separate thread. User responsiveness is important, meaning interactions like zoom or pan should ideally interrupt the current render and start a new one.
While implementing the progressive rendering logic, I've encountered a visual artifact that is consistently reproducible in a minimal example.
The Observed Artifact:
During the rendering process, which is intended to fill the image strip by strip, the output doesn't show solid bands of computed pixels. Instead, within each thread's assigned horizontal region, the fractal structure appears as thin, disconnected horizontal lines, separated by black gaps.
This is not the expected smooth filling of the horizontal work area. A screenshot illustrating this specific, consistent artifact can be seen here:
https://i.sstatic.net/82LBOVUT.png
What I find particularly perplexing is how consistently this artifact manifests. It doesn't seem to be a transient "tearing" or a typical race condition; the pattern is reliably present every time I run the code with the same parameters.
#include <SFML/Graphics.hpp>
#include <iostream>
#include <vector>
#include <thread>
#include <chrono>
#include <cstring>
struct render_target {
unsigned int x_start, y_start, x_end, y_end;
render_target(unsigned int xs, unsigned int ys, unsigned int xe, unsigned int ye)
: x_start(xs), y_start(ys), x_end(xe), y_end(ye) {}
};
unsigned char* pixels = nullptr;
const unsigned int render_width = 800;
const unsigned int render_height = 600;
const unsigned int buffer_width = render_width;
const unsigned int buffer_height = render_height;
const double zoom_x = 240.0;
const double zoom_y = 240.0;
const double x_offset = 2.25;
const double y_offset = 1.25;
const unsigned int max_iterations = 300;
std::vector<unsigned char> thread_stop_flags; // 0: running, 1: stop requested, 2: stopped
sf::Texture texture;
sf::Sprite sprite(texture);
sf::Image image;
void cpu_render_minimal(render_target target, unsigned char* pixels, unsigned int width_param, unsigned int height_param,
double zoom_x, double zoom_y, double x_offset, double y_offset,
unsigned int max_iterations,
unsigned char& finish_flag)
{
finish_flag = 0;
for(unsigned int y = target.y_start; y < target.y_end; ++y){
for(unsigned int x = target.x_start; x < target.x_end; ++x){
double zr = 0.0;
double zi = 0.0;
double cr = x / zoom_x - x_offset;
double ci = y / zoom_y - y_offset;
unsigned int curr_iter = 0;
while (curr_iter < max_iterations && zr * zr + zi * zi < 4.0) {
double tmp_zr = zr;
zr = zr * zr - zi * zi + cr;
zi = 2.0 * tmp_zr * zi + ci;
++curr_iter;
if(finish_flag == 1) {
finish_flag = 2;
return;
}
}
unsigned char color_val;
if (curr_iter == max_iterations) {
color_val = 255;
} else {
color_val = static_cast<unsigned char>((curr_iter % 255) + 1);
}
const unsigned int index = (y * width_param + x) * 4;
if (index + 3 < buffer_width * buffer_height * 4) {
pixels[index] = color_val;
pixels[index + 1] = color_val;
pixels[index + 2] = color_val;
pixels[index + 3] = 255;
}
}
}
finish_flag = 1;
}
void post_processing_minimal() {
if (!pixels) return;
image = sf::Image({render_width, render_height}, pixels);
texture = sf::Texture(image, true);
sprite = sf::Sprite(texture);
}
void start_render_job()
{
if (pixels != nullptr) {
delete[] pixels;
pixels = nullptr;
}
pixels = new unsigned char[buffer_width * buffer_height * 4];
if (!pixels) {
std::cerr << "Error: Could not allocate pixel buffer!" << std::endl;
return;
}
memset(pixels, 0, buffer_width * buffer_height * 4);
unsigned int max_threads = std::thread::hardware_concurrency();
if (max_threads == 0) max_threads = 1;
thread_stop_flags.assign(max_threads, 0);
std::vector<render_target> render_targets;
unsigned int strip_height = render_height / max_threads;
for(unsigned int i = 0; i < max_threads; ++i) {
unsigned int x_start = 0;
unsigned int x_end = render_width;
unsigned int y_start = strip_height * i;
unsigned int y_end = (i == max_threads - 1) ? render_height : strip_height * (i + 1);
if (y_start >= y_end) continue;
render_targets.emplace_back(x_start, y_start, x_end, y_end);
}
for(size_t i = 0; i < render_targets.size(); ++i) {
std::thread t(cpu_render_minimal, render_targets[i], pixels,
render_width, render_height,
zoom_x, zoom_y, x_offset, y_offset,
max_iterations, std::ref(thread_stop_flags[i]));
t.detach();
}
std::cout << "Started render job with " << render_targets.size() << " threads." << std::endl;
std::cout << "Buffer dimensions: " << buffer_width << "x" << buffer_height << std::endl;
std::cout << "Render dimensions passed to threads: " << render_width << "x" << render_height << std::endl;
}
int main() {
sf::RenderWindow window(sf::VideoMode({render_width, render_height}), "Mandelbrot MRE");
window.setFramerateLimit(60);
image = sf::Image({render_width, render_height}, sf::Color::Black);
texture = sf::Texture(image);
sprite.setTexture(texture);
start_render_job();
while(window.isOpen()){
while(const auto event = window.pollEvent()) {
if(event->is<sf::Event::Closed>())
window.close();
if (event->is<sf::Event::KeyPressed>() && event->getIf<sf::Event::KeyPressed>()->scancode == sf::Keyboard::Scancode::Space) {
std::cout << "Space pressed. Simulating render restart attempt..." << std::endl;
start_render_job();
if (pixels) memset(pixels, 0, buffer_width * buffer_height * 4);
}
}
post_processing_minimal();
window.clear(sf::Color::Black);
window.draw(sprite);
window.display();
static bool render_finished = false;
if (!render_finished && !thread_stop_flags.empty() &&
std::all_of(thread_stop_flags.begin(), thread_stop_flags.end(),
[](unsigned char state){ return state == 1 || state == 2; }))
{
std::cout << "All threads finished (or stopped)." << std::endl;
render_finished = true;
}
}
if (pixels) {
delete[] pixels;
pixels = nullptr;
}
return 0;
}
Associated CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(MandelbrotMRE LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_TOOLCHAIN_FILE "$ENV{HOME}/vcpkg/scripts/buildsystems/vcpkg.cmake" CACHE STRING "Vcpkg toolchain file")
find_package(SFML 3 COMPONENTS Graphics Window System REQUIRED)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
add_compile_options(-Wall -Wextra -pedantic -fPIE)
elseif(MSVC)
add_compile_options(/W4 /MP /std:c++latest)
endif()
set(CPP_SOURCES main.cpp)
add_executable(${PROJECT_NAME} ${CPP_SOURCES})
target_link_libraries(${PROJECT_NAME} PRIVATE
SFML::Graphics
SFML::Window
SFML::System
)
Environment Details:
Specific Questions:
Why does this specific, consistent fragmented artifact appear during progressive rendering in this multithreaded CPU code?
How can this artifact be reliably fixed?
your first problem is
start_render_job();
if (pixels) memset(pixels, 0, buffer_width * buffer_height * 4);
memset
is erasing the work done by the threads, simply removing memset
here fixes your code.
your second problem is that your threads are working with globals, which causes crashes if the user holds down space, and some graphical glitches, the solution here is to use std::shared_ptr for sending shared resources to a thread, and never accessing global non-const state in a thread.
void cpu_render_minimal(render_target target, std::shared_ptr<unsigned char[]> pixels, std::shared_ptr<std::vector<unsigned char>> thread_stop_flags, ...
full corrected code.
#include <SFML/Graphics.hpp>
#include <iostream>
#include <vector>
#include <thread>
#include <chrono>
#include <memory>
#include <cstring>
struct render_target {
unsigned int x_start, y_start, x_end, y_end;
render_target(unsigned int xs, unsigned int ys, unsigned int xe, unsigned int ye)
: x_start(xs), y_start(ys), x_end(xe), y_end(ye) {
}
};
const unsigned int render_width = 1280;
const unsigned int render_height = 720;
const unsigned int buffer_width = render_width;
const unsigned int buffer_height = render_height;
const double zoom_x = 500.0;
const double zoom_y = 500.0;
const double x_offset = 2.25;
const double y_offset = 1.25;
const unsigned int max_iterations = 300;
sf::Texture texture;
sf::Sprite sprite(texture);
sf::Image image;
void cpu_render_minimal(render_target target, std::shared_ptr<unsigned char[]> pixels, std::shared_ptr<std::vector<unsigned char>> thread_stop_flags, unsigned int width_param, unsigned int height_param,
double zoom_x, double zoom_y, double x_offset, double y_offset,
unsigned int max_iterations,
unsigned char& finish_flag)
{
finish_flag = 0;
for (unsigned int y = target.y_start; y < target.y_end; ++y) {
for (unsigned int x = target.x_start; x < target.x_end; ++x) {
double zr = 0.0;
double zi = 0.0;
double cr = x / zoom_x - x_offset;
double ci = y / zoom_y - y_offset;
unsigned int curr_iter = 0;
while (curr_iter < max_iterations && zr * zr + zi * zi < 4.0) {
double tmp_zr = zr;
zr = zr * zr - zi * zi + cr;
zi = 2.0 * tmp_zr * zi + ci;
++curr_iter;
if (finish_flag == 1) {
finish_flag = 2;
return;
}
}
unsigned char color_val;
if (curr_iter == max_iterations) {
color_val = 255;
}
else {
color_val = static_cast<unsigned char>((curr_iter % 255) + 1);
}
const unsigned int index = (y * width_param + x) * 4;
if (index + 3 < buffer_width * buffer_height * 4) {
pixels[index] = color_val;
pixels[index + 1] = color_val;
pixels[index + 2] = color_val;
pixels[index + 3] = 255;
}
}
}
finish_flag = 1;
}
void post_processing_minimal(std::shared_ptr<unsigned char[]>& pixels) {
if (!pixels) return;
image = sf::Image({ render_width, render_height }, pixels.get());
texture = sf::Texture(image, true);
sprite = sf::Sprite(texture);
}
void start_render_job(std::shared_ptr<unsigned char[]>& pixels, std::shared_ptr<std::vector<unsigned char>>& thread_stop_flags)
{
const size_t buff_size = buffer_width * buffer_height * 4;
pixels = std::shared_ptr<unsigned char[]>(new unsigned char[buff_size]);
memset(pixels.get(), 0, buff_size);
unsigned int max_threads = std::thread::hardware_concurrency();
if (max_threads == 0) max_threads = 1;
if (thread_stop_flags)
{
for (auto& entry : *thread_stop_flags)
{
entry = 1;
}
}
thread_stop_flags = std::make_shared<std::vector<unsigned char>>();
thread_stop_flags->resize(max_threads);
std::vector<render_target> render_targets;
unsigned int strip_height = render_height / max_threads;
for (unsigned int i = 0; i < max_threads; ++i) {
unsigned int x_start = 0;
unsigned int x_end = render_width;
unsigned int y_start = strip_height * i;
unsigned int y_end = (i == max_threads - 1) ? render_height : strip_height * (i + 1);
if (y_start >= y_end) continue;
render_targets.emplace_back(x_start, y_start, x_end, y_end);
}
for (size_t i = 0; i < render_targets.size(); ++i) {
std::thread t(cpu_render_minimal, render_targets[i], pixels, thread_stop_flags,
render_width, render_height,
zoom_x, zoom_y, x_offset, y_offset,
max_iterations, std::ref((*thread_stop_flags)[i]));
t.detach();
}
std::cout << "Started render job with " << render_targets.size() << " threads." << std::endl;
std::cout << "Buffer dimensions: " << buffer_width << "x" << buffer_height << std::endl;
std::cout << "Render dimensions passed to threads: " << render_width << "x" << render_height << std::endl;
}
int main() {
sf::RenderWindow window(sf::VideoMode({ render_width, render_height }), "Mandelbrot MRE");
window.setFramerateLimit(60);
image = sf::Image({ render_width, render_height }, sf::Color::Black);
texture = sf::Texture(image);
sprite.setTexture(texture);
std::shared_ptr<unsigned char[]> pixels;
std::shared_ptr<std::vector<unsigned char>> thread_stop_flags;
start_render_job(pixels, thread_stop_flags);
while (window.isOpen()) {
while (const auto event = window.pollEvent()) {
if (event->is<sf::Event::Closed>())
window.close();
if (event->is<sf::Event::KeyPressed>() && event->getIf<sf::Event::KeyPressed>()->scancode == sf::Keyboard::Scancode::Space) {
std::cout << "Space pressed. Simulating render restart attempt..." << std::endl;
start_render_job(pixels, thread_stop_flags);
}
}
post_processing_minimal(pixels);
window.clear(sf::Color::Black);
window.draw(sprite);
window.display();
static bool render_finished = false;
if (!render_finished && !thread_stop_flags->empty() &&
std::all_of(thread_stop_flags->begin(), thread_stop_flags->end(),
[](unsigned char state) { return state == 1 || state == 2; }))
{
std::cout << "All threads finished (or stopped)." << std::endl;
render_finished = true;
}
}
return 0;
}
lastly, accessing finish_flag
in the loop without synchronization is undefined behavior, you should instead use std::atomic<unsigned char>
, but this likely won't cause a crash either way.