gpu playground / Mandelbrot Zoom
01

Mandelbrot Zoom

The escape-time fractal, live. Each frame runs two GPU passes — one integrates the pan/zoom view from your pointer, the next iterates z² + c for all 921,600 pixels and writes a smooth-shaded RGBA surface. The view state ping-pongs between two device buffers, so no pass ever reads the buffer it is writing.

LIVE · GPU
This demo needs WebGPU
drag to pan · scroll to zoom fps
mandelbrot.mi MIRI → WEBGPU
use system.collections.array
use system.math
use system.io

// Canvas: 1280×720 pixels (16:9, matching the frame it is drawn in, so the set
// is never stretched); RGBA paint output. The wider axis carries the extra span,
// scaled by the aspect ratio, exactly as a widescreen viewport should.
const CW = 1280
const CH = 720
const PIXELS = CW * CH
const PAINT = PIXELS * 4

// View state: [center_x, center_y, scale, _pad] — ping-ponged across frames.
gpu var view_a = Array<f32, 4>()
gpu var view_b = Array<f32, 4>()

// Paint output (RGBA: 4 floats per pixel).
gpu var paint = Array<f32, PAINT>()

// Five-stop palette, evaluated per channel. Stops: navy, blue, cyan, yellow,
// white — matching the reference shader's `palette(t)`.
fn pal_r(t f32) f32
    if t < 0.35: return mix(0.016, 0.13, t / 0.35) as f32
    if t < 0.62: return mix(0.13, 0.35, (t - 0.35) / 0.27) as f32
    if t < 0.85: return mix(0.35, 1.0, (t - 0.62) / 0.23) as f32
    return mix(1.0, 1.0, (t - 0.85) / 0.15) as f32

fn pal_g(t f32) f32
    if t < 0.35: return mix(0.027, 0.27, t / 0.35) as f32
    if t < 0.62: return mix(0.27, 0.62, (t - 0.35) / 0.27) as f32
    if t < 0.85: return mix(0.62, 0.85, (t - 0.62) / 0.23) as f32
    return mix(0.85, 0.97, (t - 0.85) / 0.15) as f32

fn pal_b(t f32) f32
    if t < 0.35: return mix(0.059, 0.75, t / 0.35) as f32
    if t < 0.62: return mix(0.75, 1.0, (t - 0.35) / 0.27) as f32
    if t < 0.85: return mix(1.0, 0.24, (t - 0.62) / 0.23) as f32
    return mix(0.24, 0.85, (t - 0.85) / 0.15) as f32

// Seed: initial viewport centred near the main cardioid at full extent.
forall i in 0..4
    view_a[0] = -0.6
    view_a[1] = 0.0
    view_a[2] = 3.0
    view_a[3] = 0.0

// Clear the canvas to the interior colour. This 2-D pass over the exact display
// extent also tells the web-gpu backend the canvas is 1280×720; a flat paint
// buffer carries no shape of its own and would otherwise read as square.
forall px, py in 0..CW, 0..CH
    let base = (py * CW + px) * 4
    paint[base] = 0.008
    paint[base + 1] = 0.012
    paint[base + 2] = 0.03
    paint[base + 3] = 1.0

gpu frame
    // Pass 1: integrate pan/zoom from pointer state into the next view.
    forall i in 0..4
        let cx = view_a[0]
        let cy = view_a[1]
        let scale = view_a[2]

        // Drag pans the view under the pointer. The delta arrives in canvas
        // CSS pixels, so dividing by the canvas height turns it into a fraction
        // of the vertical span and multiplying by `scale` into plane units.
        // `drag_dy` points up while the pointer's travel points down, hence both
        // axes subtract.
        let pan_x = cx - frame.drag_dx * scale / (CH as f32)
        let pan_y = cy - frame.drag_dy * scale / (CH as f32)

        // Wheel zooms about the pointer rather than the centre, so the point
        // under the cursor stays under it. The step is clamped per frame so one
        // flick of a high-resolution wheel cannot leap the whole depth range.
        let zoom = clamp(exp(frame.wheel * 0.0014) as f32, 0.6, 1.6) as f32
        let next_scale = clamp(scale * zoom, 0.00002, 4.0) as f32
        // Fraction of the distance from the centre to the cursor that the view
        // must travel to keep the cursor's point fixed. Zero when the scale did
        // not change, so a frame with no wheel input never shifts the view.
        let anchor = 1.0 - next_scale / scale
        let ux = (frame.mouse_x - 0.5) * (CW as f32) / (CH as f32)
        let uy = 0.5 - frame.mouse_y

        view_b[0] = pan_x + ux * scale * anchor
        view_b[1] = pan_y + uy * scale * anchor
        view_b[2] = next_scale
        view_b[3] = 0.0

    // Pass 2: render the fractal with smooth escape coloring.
    forall idx in 0..PIXELS
        let px = idx % CW
        let py = idx / CW
        let cx = view_b[0]
        let cy = view_b[1]
        let scale = view_b[2]

        // Map pixel to the complex plane around the current center. `scale` is
        // the vertical span, so the horizontal one is widened by the aspect
        // ratio. Row 0 is the top of the canvas, so the imaginary axis is
        // flipped to point up the screen, as the plane is conventionally drawn.
        let uu = ((px as f32) / (CW as f32) - 0.5) * (CW as f32) / (CH as f32)
        let vv = 0.5 - (py as f32) / (CH as f32)
        let cre = cx + uu * scale
        let cim = cy + vv * scale

        // Dynamic iteration budget grows as the view zooms in (smaller scale).
        let budget = 120.0 + 60.0 * (log2(3.0 / scale + 1.0) as f32) * 2.2
        let max_iter = min(budget, 880.0) as int

        var zx = 0.0
        var zy = 0.0
        var n = -1
        var dotz = 0.0
        var i = 0
        while i < max_iter
            let nx = zx * zx - zy * zy + cre
            let ny = 2.0 * zx * zy + cim
            zx = nx
            zy = ny
            dotz = zx * zx + zy * zy
            if dotz > 64.0
                n = i
                i = max_iter
            else
                i = i + 1

        let base = idx * 4
        if n < 0
            // Interior of the set: near-black.
            paint[base] = 0.008
            paint[base + 1] = 0.012
            paint[base + 2] = 0.03
            paint[base + 3] = 1.0
        else
            // Smooth iteration count removes the escape-time banding.
            let sn = (n as f32) - (log2(log2(dotz) as f32) as f32) + 4.0
            let pt0 = sn * 0.022 + 0.62
            let t = pt0 - (floor(pt0) as f32)
            // Darken low escape counts so filaments glow out of the void.
            let bright = 0.55 + 0.45 * (smoothstep(0.0, 12.0, sn) as f32)
            paint[base] = pal_r(t) * bright
            paint[base + 1] = pal_g(t) * bright
            paint[base + 2] = pal_b(t) * bright
            paint[base + 3] = 1.0
RUN IT YOURSELF

From this page to your own GPU

Four steps. You'll need a WebGPU-capable browser (Chrome or Edge 113+, or Safari 18+).

  1. 1

    Install Miri

    Build the compiler from source (full install guide):

    git clone https://github.com/miri-lang/miri.git
    cd miri && cargo build --release

    The binary lands at target/release/miri.

  2. 2

    Grab the program

    Hit copy program above and save it as mandelbrot.mi.

  3. 3

    Compile it to WebGPU

    miri build mandelbrot.mi --target web-gpu --out mandelbrot-web

    Out comes a self-contained index.html — the runtime and every compiled WGSL kernel are inlined.

  4. 4

    Open it

    Double-click mandelbrot-web/index.html. It runs straight from file:// — same interaction as the preview above.

No browser needed to try it: miri run mandelbrot.mi runs the same kernels on your local GPU through Metal, Vulkan or DX12. Same language, same code, three backends and a browser — that's the point.