A Multithreaded Mandelbrot Renderer in Pure Rust

This is a command-line Mandelbrot set renderer written entirely with the Rust standard library — no external crates at all. It splits the output image into contiguous horizontal bands, renders each band on its own worker thread using std::thread, applies smooth (continuous) escape-time coloring via an HSV sweep, and writes a binary PPM (P6) image to disk. A live progress bar on stderr tracks rows completed through an AtomicUsize shared across threads, and the whole render is configurable from the command line.

The point of the exercise is to show how far you can get with the standard library alone: argument parsing, manual fixed-point iteration of z = z2 + c, thread spawning and joining, atomics for coordination, buffered file I/O, and a small built-in test module — all without pulling in a single dependency. Start by creating the project with cargo new mandelbrot, then replace src/main.rs with the code below.

Code

Replace src/main.rs with the following:

//! A multithreaded Mandelbrot set renderer.
//!
//! Pure standard library — no external crates. It splits the image into
//! horizontal bands, renders each band on its own worker thread, applies
//! smooth (continuous) escape-time coloring, and writes a binary PPM (P6)
//! image to disk.
//!
//! Run `mandelbrot --help` for usage.

use std::env;
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::process;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Instant;

/// All the knobs that control a render, parsed from the command line.
#[derive(Clone, Debug)]
struct Config {
    width: usize,
    height: usize,
    max_iter: u32,
    center_re: f64,
    center_im: f64,
    zoom: f64,
    threads: usize,
    output: String,
}

impl Default for Config {
    fn default() -> Self {
        Config {
            width: 1200,
            height: 800,
            max_iter: 1000,
            center_re: -0.75,
            center_im: 0.0,
            zoom: 1.0,
            // Default to the number of logical CPUs, falling back to 4.
            threads: thread::available_parallelism()
                .map(|n| n.get())
                .unwrap_or(4),
            output: "mandelbrot.ppm".to_string(),
        }
    }
}

/// A single RGB pixel.
#[derive(Clone, Copy)]
struct Rgb {
    r: u8,
    g: u8,
    b: u8,
}

/// Parse `--key value` style arguments into a `Config`.
///
/// Returns `Err` with a human-readable message on bad input, and signals a
/// clean help-exit by returning `Ok(None)`.
fn parse_args() -> Result<Option<Config>, String> {
    let mut cfg = Config::default();
    let mut args = env::args().skip(1);

    while let Some(arg) = args.next() {
        // Helper to pull the next token and parse it, with a tidy error.
        let mut next = |name: &str| -> Result<String, String> {
            args.next()
                .ok_or_else(|| format!("flag `{name}` expects a value"))
        };

        match arg.as_str() {
            "-h" | "--help" => {
                print_help();
                return Ok(None);
            }
            "-w" | "--width" => {
                cfg.width = parse_field(&next("--width")?, "width")?;
            }
            "-H" | "--height" => {
                cfg.height = parse_field(&next("--height")?, "height")?;
            }
            "-i" | "--iter" => {
                cfg.max_iter = parse_field(&next("--iter")?, "iter")?;
            }
            "--re" => {
                cfg.center_re = parse_field(&next("--re")?, "re")?;
            }
            "--im" => {
                cfg.center_im = parse_field(&next("--im")?, "im")?;
            }
            "-z" | "--zoom" => {
                cfg.zoom = parse_field(&next("--zoom")?, "zoom")?;
            }
            "-t" | "--threads" => {
                cfg.threads = parse_field(&next("--threads")?, "threads")?;
            }
            "-o" | "--output" => {
                cfg.output = next("--output")?;
            }
            other => {
                return Err(format!("unknown argument: `{other}` (try --help)"));
            }
        }
    }

    if cfg.width == 0 || cfg.height == 0 {
        return Err("width and height must be greater than zero".to_string());
    }
    if cfg.threads == 0 {
        return Err("threads must be greater than zero".to_string());
    }
    if cfg.zoom <= 0.0 {
        return Err("zoom must be positive".to_string());
    }

    Ok(Some(cfg))
}

/// Parse a single field, attaching the field name to any error message.
fn parse_field<T: std::str::FromStr>(raw: &str, name: &str) -> Result<T, String> {
    raw.parse::<T>()
        .map_err(|_| format!("invalid value for `{name}`: `{raw}`"))
}

fn print_help() {
    println!(
        "mandelbrot — a multithreaded Mandelbrot renderer\n\n\
         USAGE:\n    mandelbrot [OPTIONS]\n\n\
         OPTIONS:\n\
         \x20   -w, --width   <px>     Image width        (default 1200)\n\
         \x20   -H, --height  <px>     Image height       (default 800)\n\
         \x20   -i, --iter    <n>      Max iterations     (default 1000)\n\
         \x20       --re      <f>      Center real part   (default -0.75)\n\
         \x20       --im      <f>      Center imag part   (default 0.0)\n\
         \x20   -z, --zoom    <f>      Zoom factor        (default 1.0)\n\
         \x20   -t, --threads <n>      Worker threads     (default: # CPUs)\n\
         \x20   -o, --output  <path>   Output PPM file    (default mandelbrot.ppm)\n\
         \x20   -h, --help             Show this help\n\n\
         EXAMPLES:\n\
         \x20   mandelbrot\n\
         \x20   mandelbrot -w 1920 -H 1080 -i 2000 -o big.ppm\n\
         \x20   mandelbrot --re -0.743643887 --im 0.131825904 -z 4000 -i 3000"
    );
}

/// Compute the smooth escape-time value for a point `c = re + im*i`.
///
/// Returns `None` if the point is (probably) in the set, otherwise a
/// fractional iteration count used for continuous coloring.
fn escape_time(re: f64, im: f64, max_iter: u32) -> Option<f64> {
    let mut zr = 0.0_f64;
    let mut zi = 0.0_f64;
    let mut iter = 0u32;

    // Iterate z = z^2 + c until |z| escapes a generous radius or we run out.
    while iter < max_iter {
        let zr2 = zr * zr;
        let zi2 = zi * zi;
        if zr2 + zi2 > 4.0 {
            // Normalized iteration count (renormalizing the escape radius)
            // for smooth coloring, avoiding visible iteration banding.
            let log_zn = (zr2 + zi2).ln() / 2.0;
            let nu = (log_zn / std::f64::consts::LN_2).log2();
            return Some(iter as f64 + 1.0 - nu);
        }
        zi = 2.0 * zr * zi + im;
        zr = zr2 - zi2 + re;
        iter += 1;
    }
    None
}

/// Map a smooth iteration value to an RGB color using an HSV sweep.
fn color(smooth: Option<f64>, max_iter: u32) -> Rgb {
    match smooth {
        // Inside the set: solid black.
        None => Rgb { r: 0, g: 0, b: 0 },
        Some(t) => {
            // Normalize to [0, 1) and rotate the hue for a pleasing palette.
            let frac = (t / max_iter as f64).clamp(0.0, 1.0);
            let hue = 360.0 * frac.powf(0.5);
            let sat = 0.85;
            let val = if frac < 1.0 { 1.0 } else { 0.0 };
            hsv_to_rgb(hue, sat, val)
        }
    }
}

/// Standard HSV → RGB conversion. `h` in degrees, `s`/`v` in [0, 1].
fn hsv_to_rgb(h: f64, s: f64, v: f64) -> Rgb {
    let c = v * s;
    let hp = (h % 360.0) / 60.0;
    let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
    let (r1, g1, b1) = match hp as i32 {
        0 => (c, x, 0.0),
        1 => (x, c, 0.0),
        2 => (0.0, c, x),
        3 => (0.0, x, c),
        4 => (x, 0.0, c),
        _ => (c, 0.0, x),
    };
    let m = v - c;
    Rgb {
        r: (((r1 + m) * 255.0).round()) as u8,
        g: (((g1 + m) * 255.0).round()) as u8,
        b: (((b1 + m) * 255.0).round()) as u8,
    }
}

/// Render the full image across `cfg.threads` worker threads.
///
/// The image is divided into contiguous horizontal bands. Each thread renders
/// its band into a private buffer, then the bands are stitched together in
/// order. An atomic counter tracks rows completed for a live progress bar.
fn render(cfg: &Config) -> Vec<Rgb> {
    let Config {
        width,
        height,
        max_iter,
        center_re,
        center_im,
        zoom,
        threads,
        ..
    } = *cfg;

    // The complex-plane window. We keep a fixed vertical span and derive the
    // horizontal span from the aspect ratio so pixels stay square.
    let base_height = 3.0 / zoom;
    let aspect = width as f64 / height as f64;
    let base_width = base_height * aspect;

    let min_re = center_re - base_width / 2.0;
    let min_im = center_im - base_height / 2.0;
    let scale_re = base_width / width as f64;
    let scale_im = base_height / height as f64;

    let rows_done = Arc::new(AtomicUsize::new(0));

    // Compute band boundaries (start row, end row) for each thread.
    let band = height.div_ceil(threads);
    let bounds: Vec<(usize, usize)> = (0..threads)
        .map(|t| {
            let start = t * band;
            let end = ((t + 1) * band).min(height);
            (start, end)
        })
        .filter(|(s, e)| s < e)
        .collect();

    let mut handles = Vec::with_capacity(bounds.len());

    for (start, end) in bounds {
        let rows_done = Arc::clone(&rows_done);
        handles.push(thread::spawn(move || {
            let mut band_buf = vec![Rgb { r: 0, g: 0, b: 0 }; (end - start) * width];
            for y in start..end {
                let im = min_im + y as f64 * scale_im;
                for x in 0..width {
                    let re = min_re + x as f64 * scale_re;
                    let t = escape_time(re, im, max_iter);
                    band_buf[(y - start) * width + x] = color(t, max_iter);
                }
                rows_done.fetch_add(1, Ordering::Relaxed);
            }
            (start, band_buf)
        }));
    }

    // Drive a progress bar on the main thread while workers run.
    let total = height;
    loop {
        let done = rows_done.load(Ordering::Relaxed);
        draw_progress(done, total);
        if done >= total {
            break;
        }
        thread::sleep(std::time::Duration::from_millis(80));
    }
    eprintln!();

    // Stitch the bands back together in row order.
    let mut image = vec![Rgb { r: 0, g: 0, b: 0 }; width * height];
    for handle in handles {
        let (start, band_buf) = handle.join().expect("worker thread panicked");
        let offset = start * width;
        image[offset..offset + band_buf.len()].copy_from_slice(&band_buf);
    }

    image
}

/// Draw a single-line progress bar to stderr (so stdout stays clean).
fn draw_progress(done: usize, total: usize) {
    let frac = done as f64 / total as f64;
    let filled = (frac * 40.0) as usize;
    let bar: String = (0..40)
        .map(|i| if i < filled { '#' } else { '-' })
        .collect();
    eprint!("\r[{bar}] {:3.0}% ({done}/{total} rows)", frac * 100.0);
    let _ = io::stderr().flush();
}

/// Write the image to a binary PPM (P6) file.
fn write_ppm(path: &str, width: usize, height: usize, pixels: &[Rgb]) -> io::Result<()> {
    let file = File::create(path)?;
    let mut w = BufWriter::new(file);
    write!(w, "P6\n{width} {height}\n255\n")?;
    let mut raw = Vec::with_capacity(pixels.len() * 3);
    for p in pixels {
        raw.push(p.r);
        raw.push(p.g);
        raw.push(p.b);
    }
    w.write_all(&raw)?;
    w.flush()
}

fn main() {
    let cfg = match parse_args() {
        Ok(Some(cfg)) => cfg,
        Ok(None) => return, // --help was printed
        Err(msg) => {
            eprintln!("error: {msg}");
            process::exit(2);
        }
    };

    eprintln!(
        "Rendering {}x{} @ {} iter, center ({}, {}), zoom {}x, on {} thread(s)…",
        cfg.width, cfg.height, cfg.max_iter, cfg.center_re, cfg.center_im, cfg.zoom, cfg.threads
    );

    let start = Instant::now();
    let image = render(&cfg);
    let elapsed = start.elapsed();

    match write_ppm(&cfg.output, cfg.width, cfg.height, &image) {
        Ok(()) => {
            eprintln!(
                "Done in {:.2}s → wrote {} ({} pixels)",
                elapsed.as_secs_f64(),
                cfg.output,
                cfg.width * cfg.height
            );
        }
        Err(e) => {
            eprintln!("error: failed to write `{}`: {e}", cfg.output);
            process::exit(1);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn origin_is_in_set() {
        // c = 0 never escapes.
        assert!(escape_time(0.0, 0.0, 1000).is_none());
    }

    #[test]
    fn far_point_escapes_fast() {
        // c = 2 + 2i escapes almost immediately.
        let t = escape_time(2.0, 2.0, 1000);
        assert!(t.is_some());
        assert!(t.unwrap() < 5.0);
    }

    #[test]
    fn hsv_primaries() {
        let red = hsv_to_rgb(0.0, 1.0, 1.0);
        assert_eq!((red.r, red.g, red.b), (255, 0, 0));
        let green = hsv_to_rgb(120.0, 1.0, 1.0);
        assert_eq!((green.r, green.g, green.b), (0, 255, 0));
        let blue = hsv_to_rgb(240.0, 1.0, 1.0);
        assert_eq!((blue.r, blue.g, blue.b), (0, 0, 255));
    }

    #[test]
    fn inside_point_is_black() {
        let c = color(None, 1000);
        assert_eq!((c.r, c.g, c.b), (0, 0, 0));
    }
}
Cargo.toml

Because the renderer relies only on the standard library, the manifest needs no dependencies at all — the generated Cargo.toml from cargo new works as-is:

[package]
name = "mandelbrot"
version = "0.1.0"
edition = "2021"

[dependencies]
Repo layout

This is Cargo’s default binary-package layout, where Cargo.toml defines the package and src/main.rs is the executable entry point. The tests live in the same file under a #[cfg(test)] module, so there is nothing extra to wire up.

mandelbrot/
├── Cargo.toml
├── Cargo.lock
├── .gitignore
├── README.md
└── src/
    └── main.rs
Create the repo

From a terminal, create the project with Cargo, then move into it and drop the code into src/main.rs:

cargo new mandelbrot
cd mandelbrot
# replace src/main.rs with the code above
Run

Build and run with Cargo. Because the per-pixel iteration is CPU-bound, the --release profile makes a dramatic difference here — expect it to be many times faster than a debug build. Arguments after -- are passed through to the program rather than to Cargo:

# default 1200x800 render → mandelbrot.ppm
cargo run --release

# a larger, deeper render to a custom file
cargo run --release -- -w 1920 -H 1080 -i 2000 -o big.ppm

# a deep zoom into a seahorse-valley point
cargo run --release -- --re -0.743643887 --im 0.131825904 -z 4000 -i 3000

# see all options
cargo run --release -- --help

The output is a binary PPM (P6) file. Most image viewers on Linux open it directly; on other platforms you can convert it with ImageMagick (magick mandelbrot.ppm mandelbrot.png) or open it in tools like GIMP. To run the built-in test module, use cargo test.

How it works

Each pixel maps to a complex number c, and escape_time iterates z = z2 + c from z = 0 until the magnitude exceeds the escape radius or the iteration cap is reached. Points that never escape are part of the set and drawn black; for points that do escape, a renormalized (fractional) iteration count feeds an HSV-to-RGB sweep, which removes the visible banding you get from integer iteration counts.

The image is divided into contiguous horizontal bands, one per worker thread. Each thread renders its band into a private buffer with no shared mutable state, so there is no locking on the hot path; the only coordination is an AtomicUsize that counts completed rows, which the main thread polls to draw the progress bar. When the workers finish, their bands are joined and copied into the final image in row order, then written out through a BufWriter as a single P6 PPM. A short checklist for the README:

  • Install Rust and Cargo.
  • Clone the repo.
  • Run cargo run --release to render the default image.
  • Pass options after -- (for example -w, -H, -i, --re, --im, -z, -t, -o) to control resolution, depth, location, zoom, threads, and output path.
  • Run cargo test to exercise the escape-time and color routines.