Archive

Julia Set Parallel Renderer

C++ exercise adapting a supplied fractal renderer to generate a Julia set sequentially and across CPU threads, with direct TGA output and execution timing.

  • C++
  • std::thread
  • std::chrono
  • Complex Numbers
  • TGA
On this page

Adapting a supplied fractal renderer

This exercise began with a Mandelbrot renderer supplied and credited in the source to Adam Sampson. I adapted it to calculate a Julia set and added a multithreaded path so I could compare sequential and parallel execution using the same image dimensions, iteration limit, colour calculation, and file writer.

The programme renders a 1920 by 1200 image with a maximum of 2,000 iterations per pixel. It writes the result directly as an uncompressed 24-bit TGA rather than using an image library.

Mapping pixels into the complex plane

Each pixel is mapped from image coordinates into a point in the complex plane. The renderer starts with that point as z and uses a fixed complex constant c:

complex<double> z(
    left + (x * (right - left) / WIDTH),
    top + (y * (bottom - top) / HEIGHT)
);

complex<double> c(c_real, c_imaginary);

It then repeats the Julia-set recurrence:

z = z² + c

The loop stops when the point escapes a radius of two or reaches the iteration limit. Points that do not escape are coloured black. Escaped points use their iteration count to produce red and blue channel values with sine functions.

The result is held in a global uint32_t image array. Each entry stores the packed red, green, and blue values for one pixel.

Writing the TGA file

The TGA writer constructs the 18-byte header manually. It identifies the file as an uncompressed 24-bit image and writes the width, height, and pixel depth in the format expected by TGA readers.

The image array stores colour channels in a packed integer, while the file requires blue, green, and red bytes in sequence. The writer extracts each channel with masks and shifts:

uint8_t pixel[3] = {
    image[y][x] & 0xFF,
    (image[y][x] >> 8) & 0xFF,
    (image[y][x] >> 16) & 0xFF,
};

Writing the format directly exposed the difference between an in-memory representation and a file-format contract. Dimensions, byte order, channel order, and error handling all had to be correct before an image viewer could open the output.

Dividing rows across CPU threads

The sequential path passes the full row range into compute_julia(). The multithreaded path asks std::thread::hardware_concurrency() for the available logical-core count and divides the image height into contiguous row blocks.

Each worker receives:

  • the shared image bounds
  • the same Julia constant
  • a starting row
  • an exclusive ending row

The final worker takes any remainder when the image height is not evenly divisible by the number of threads.

int rowsPerThread = HEIGHT / numThreads;

for (int i = 0; i < numThreads; ++i) {
    int yStart = i * rowsPerThread;
    int yEnd = (i == numThreads - 1)
        ? HEIGHT
        : (i + 1) * rowsPerThread;

    threads.push_back(std::thread(
        compute_julia,
        left, right, top, bottom,
        cReal, cImaginary,
        yStart, yEnd
    ));
}

The workers can write to the same image array without a lock because their row ranges do not overlap. The main thread joins every worker before writing the output file, so no pixel is read while it is still being calculated.

Comparing the two execution paths

Both paths are timed with std::chrono::steady_clock and report elapsed milliseconds. The sequential result is written to output_sequential.tga; the threaded result is written to output_multithreaded.tga.

No saved benchmark output accompanies the source, so I do not claim a particular speed-up. A useful benchmark would need repeated runs, a fixed test system, release compiler settings, and checks that both images are identical.

The source also leaves some edge cases open. hardware_concurrency() is allowed to return zero, workload cost can vary between rows, and creating one thread per reported logical core is not always the fastest choice. A later implementation could use a fallback thread count, a task queue, smaller work blocks, and repeated timing statistics.

The exercise still captures the main concurrency decision clearly: split the image into independent regions, prevent overlapping writes, wait for all workers, and compare the parallel result against an unchanged sequential path.