02 - The Mandelbrot Set (compute)

The first tutorial drew a triangle with the graphics pipeline. This one computes an image with the compute pipeline: a [compute_shader] runs one invocation per pixel, maps the pixel to a complex number c, iterates z = z*z + c until it escapes or hits an iteration cap, and writes a colour into a storage image. Every line is daslang, lowered to SPIR-V at compile time by dasSpirv – the escape-time loop exercises the compute tier’s control flow (while + break) and float arithmetic. Two newer dasSpirv rails show up here: imageSize (the shader sizes its pixel→complex mapping off the bound image) and module-scope let constants (the view rectangle and iteration cap live at module scope and are shared with the host).

The clip above is the animated zoom viewer (window/show_mandelbrot_blit.das) rendered headlessly into an APNG and muxed with a strudel music bed – one [vulkan_compute_shader] dispatch per frame, a single time push constant driving both zoom and rotation about the seahorse-valley point.

The shader

A single [compute_shader] with an 8x8 local size. The output storage image is a var @binding = 0 out_img : image2D; imageStore writes one texel per invocation. The escape-time loop is ordinary daslang.

module mandelbrot_tut_shaders shared public

require spirv/spirv_shader
require spirv/spirv_builtins public

// the output storage image (binding 0); the render path creates it Rgba8 and reads it back.
var @binding = 0 out_img : image2D

//! the square render resolution, used by the render path (image extent) and the test (oracle). The
//! shader sizes its pixel->complex mapping off the bound image via imageSize, so it does not reference
//! this -- the host just needs one number. Must be a multiple of 8 (the compute local size).
let public MAND_DIM = 512

// the view rectangle in the complex plane. Square and symmetric in the imaginary axis so the set's
// reflection symmetry (c and its conjugate escape identically) lands as exact pixel mirror symmetry --
// the test's float-robust oracle. Centered at real = -0.5 (inside the main cardioid).
let REAL_MIN = -2.0f
let REAL_MAX = 1.0f
let IMAG_MIN = -1.5f
let IMAG_MAX = 1.5f
let MAX_ITER = 100

[compute_shader(local_size_x=8, local_size_y=8, name="mandelbrot_spv")]
def mandelbrot {
    let gid = gl_GlobalInvocationID
    let dim = imageSize(out_img)
    let cx = REAL_MIN + (float(gid.x) + 0.5f) / float(dim.x) * (REAL_MAX - REAL_MIN)
    let cy = IMAG_MIN + (float(gid.y) + 0.5f) / float(dim.y) * (IMAG_MAX - IMAG_MIN)
    var zx = 0.0f
    var zy = 0.0f
    var n = 0
    while (n < MAX_ITER) {
        let x2 = zx * zx
        let y2 = zy * zy
        if (x2 + y2 > 4.0f) {
            break
        }
        let nzx = x2 - y2 + cx
        zy = 2.0f * zx * zy + cy
        zx = nzx
        n++
    }
    let coord = int2(int(gid.x), int(gid.y))
    if (n == MAX_ITER) {
        // inside the set: black
        imageStore(out_img, coord, float4(0.0f, 0.0f, 0.0f, 1.0f))
    } else {
        // outside: a simple arithmetic ramp on the escape count (no trig, so no math intrinsics)
        let t = float(n) / float(MAX_ITER)
        imageStore(out_img, coord, float4(t, t * 0.5f, 1.0f - t, 1.0f))
    }
}

The render (headless)

render_mandelbrot() feeds the emitted mandelbrot_spv blob to dasVulkan’s compute_to_storage_image boost helper: it creates the storage image, the descriptor set and the compute pipeline, dispatches DIM/8 x DIM/8, and reads the result back to RGBA8 pixels on the host – a pure () -> image. This is what the CI test checks; no window required.

def public render_mandelbrot() : array<uint8> {
    return <- compute_to_storage_image(mandelbrot_spv, MAND_DIM, MAND_DIM, true)
}

//! Sample the RGB of pixel (x,y) from a MAND_DIM*MAND_DIM RGBA8 buffer.
def public px(pixels : array<uint8>; x, y : int) : int3 {
    let p = (y * MAND_DIM + x) * 4
    return int3(int(pixels[p]), int(pixels[p + 1]), int(pixels[p + 2]))
}

Self-verifying

The test is the CI regression gate (lavapipe in CI, a real GPU locally). Instead of a golden image it uses an analytic oracle: points inside the set never escape (black), points well outside escape (coloured), and because c and its conjugate escape identically, the image is mirror-symmetric across the real axis – a float-robust check on deep in/out pairs and reflected coordinates.

[test]
def test_mandelbrot(t : T?) {
    var p <- render_mandelbrot()

    // interior -> black (n hits MAX_ITER, imageStore writes 0,0,0)
    let center = px(p, 256, 256)            // c ~ (-0.5, 0): inside the main cardioid
    t |> success(center.x == 0 && center.y == 0 && center.z == 0, "center is inside the set -> black")
    let bulb = px(p, 170, 256)              // c ~ (-1, 0): inside the period-2 bulb
    t |> success(bulb.x == 0 && bulb.y == 0 && bulb.z == 0, "c ~ -1 is inside the period-2 bulb -> black")

    // exterior -> escape-count ramp, never black
    let tl = px(p, 0, 0)                     // |c| > 2: escapes immediately, ramp is blue-dominant
    t |> success(tl.z > 200, "top-left corner is outside the set -> colored (blue-dominant)")
    let br = px(p, 511, 511)                 // outside on the other diagonal
    t |> success(!(br.x == 0 && br.y == 0 && br.z == 0), "bottom-right corner is outside -> not black")

    // reflection symmetry about the real axis: pixel(x,y) == pixel(x, DIM-1-y)
    let ext_top = px(p, 0, 100)             // far-left column escapes at n=1 for both mirror rows
    let ext_bot = px(p, 0, 411)
    t |> success(ext_top.x == ext_bot.x && ext_top.y == ext_bot.y && ext_top.z == ext_bot.z,
        "exterior mirror pair is identical (conjugate c escapes identically)")
    let in_top = px(p, 256, 250)            // center column, both rows deep inside -> both black
    let in_bot = px(p, 256, 261)
    t |> success(in_top.x == in_bot.x && in_top.y == in_bot.y && in_top.z == in_bot.z,
        "interior mirror pair is identical")

    delete p
}

See it live – an animated zoom, two ways to present it

The static render above is the verified core. The live viewers are livelier: an animated, antialiased zoom – a port of Inigo Quilez’s smooth-coloured Mandelbrot (ldf3DN). The whole animation is a single @push_constant float – a time the viewer feeds from wall-clock each frame – from which the shader derives an oscillating zoom and a slow rotation about the seahorse-valley point (-0.745, 0.186). Crucially the fractal is recomputed every frame, so the zoom reveals real new detail rather than scaling a static texture; 2x2 supersampling antialiases it. It is its own viewer-only dasSpirv shader, so the verified static core stays untouched, and it exercises imageSize, module-scope let constants, a compute @push_constant and the GLSL.std.450 math rail (cos / sin / pow / log2):

[vulkan_compute_shader(local_size_x=8, local_size_y=8, name="mandelbrot_zoom_spv")]
def mandelbrot_zoom {
    let gid = gl_GlobalInvocationID
    let dim = imageSize(out_img)
    // animated zoom (oscillates) + slow rotation, both derived from time, shared by all subsamples
    let t = pc.time
    let zoo0 = 0.62f + 0.38f * cos(0.07f * t)
    let ang = 0.15f * (1.0f - zoo0) * t
    let coa = cos(ang)
    let sia = sin(ang)
    let zoo = pow(zoo0, 8.0f)
    // average AA*AA subpixel colour samples (supersampling antialiasing). Interior samples are zero
    // and contribute nothing, so set-boundary pixels soften correctly.
    var col = float3(0.0f, 0.0f, 0.0f)
    for (s in range(AA * AA)) {
        // subpixel offset within the pixel: a centred AAxAA grid
        let fx = float(gid.x) + (float(s % AA) + 0.5f) / float(AA)
        let fy = float(gid.y) + (float(s / AA) + 0.5f) / float(AA)
        // screen-normalised coords, y in [-1,1] (iq's p = (-res + 2*frag)/res.y), then zoom + rotate
        let px = (2.0f * fx - float(dim.x)) / float(dim.y)
        let py = (2.0f * fy - float(dim.y)) / float(dim.y)
        let xr = px * coa - py * sia
        let yr = px * sia + py * coa
        let c = float2(-0.745f + xr * zoo, 0.186f + yr * zoo)
        col = col + sample_at(c)
    }
    let coord = int2(int(gid.x), int(gid.y))
    let avg = col * (1.0f / float(AA * AA))
    imageStore(out_img, coord, float4(avg, 1.0f))
}

The compute result still lives in a storage image, off-screen, and still has to reach the swapchain – a triangle draws straight into it through a render pass, a compute result does not. There are two standard ways, and a runnable viewer for each. Both build the resident compute resources once (build_mandel_compute) and re-dispatch them each frame with the new time (record_compute); they live in a window/ subfolder the CI gate skips (CI is headless and built without GLFW). record_compute writes pc.time = time and calls the macro-generated mandelbrot_zoom_push_constants(cmd, mc.pipe_layout); [vulkan_compute_shader] synthesised that helper from the shader’s @push_constant pc : Push declaration.

def public record_compute(cmd : CommandBuffer; mc : MandelCompute; time : float) {
    cmd_bind_pipeline(cmd, mc.pipeline, VkPipelineBindPoint.COMPUTE)
    var sets : array<DescriptorSet>
    sets |> push(vk_value_to_boost(mc.set))
    var no_dyn : array<uint>
    cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.COMPUTE, mc.pipe_layout, 0u, sets, no_dyn)
    pc.time = time
    mandelbrot_zoom_push_constants(cmd, mc.pipe_layout)
    cmd_dispatch(cmd, uint(mc.width / 8), uint(mc.height / 8), 1u)
}

Method 1 – blit

The most direct route: vkCmdBlitImage copies the storage image straight onto the acquired swapchain image (scaled to the window, linear filter) – no graphics pipeline, no render pass, no fragment shader. Both the per-frame compute dispatch and the blit are recorded into vulkan_window’s present_frame (the non-render-pass sibling of draw_frame):

        let ok = present_frame(device, queue, swap, pool, sync) $(cmd; target; _idx) {
            let none : VkAccessFlags
            var shader_write : VkAccessFlags
            shader_write.shader_write = true
            var transfer_read : VkAccessFlags
            transfer_read.transfer_read = true
            var transfer_write : VkAccessFlags
            transfer_write.transfer_write = true
            var top : VkPipelineStageFlags
            top.top_of_pipe = true
            var comp : VkPipelineStageFlags
            comp.compute_shader = true
            var xfer : VkPipelineStageFlags
            xfer.transfer = true
            var bottom : VkPipelineStageFlags
            bottom.bottom_of_pipe = true
            // recompute the fractal into the storage image (GENERAL) for this frame's time
            transition_image(cmd, mc.image, VkImageLayout.UNDEFINED, VkImageLayout.GENERAL,
                none, shader_write, top, comp)
            record_compute(cmd, mc, t)
            // make the compute writes available to the blit (image stays GENERAL, a legal blit source)
            transition_image(cmd, mc.image, VkImageLayout.GENERAL, VkImageLayout.GENERAL,
                shader_write, transfer_read, comp, xfer)
            // swapchain image: UNDEFINED -> TRANSFER_DST (we overwrite the whole image)
            let dst = vk_value_to_boost(target)
            transition_image(cmd, dst, VkImageLayout.UNDEFINED, VkImageLayout.TRANSFER_DST_OPTIMAL,
                none, transfer_write, top, xfer)
            // blit the fractal onto the swapchain image, scaling ZOOM_DIM -> window, linear filter
            var region : VkImageBlit
            region.srcSubresource.aspectMask.color = true
            region.srcSubresource.layerCount = 1u
            region.srcOffsets[1].x = mc.width
            region.srcOffsets[1].y = mc.height
            region.srcOffsets[1].z = 1
            region.dstSubresource.aspectMask.color = true
            region.dstSubresource.layerCount = 1u
            region.dstOffsets[1].x = swap.width
            region.dstOffsets[1].y = swap.height
            region.dstOffsets[1].z = 1
            unsafe {
                vkCmdBlitImage(boost_value_to_vk(cmd), boost_value_to_vk(mc.image), VkImageLayout.GENERAL,
                    target, VkImageLayout.TRANSFER_DST_OPTIMAL, 1u, addr(region), VkFilter.LINEAR)
            }
            // TRANSFER_DST -> PRESENT_SRC for the present
            transition_image(cmd, dst, VkImageLayout.TRANSFER_DST_OPTIMAL, VkImageLayout.PRESENT_SRC_KHR,
                transfer_write, none, xfer, bottom)
        }

Method 2 – sample as a texture

The other route reuses the entire graphics path: draw one full-screen triangle whose fragment shader samples the storage image as a texture – the canonical “compute writes, graphics reads” pattern, through the ordinary render pass, reusing draw_frame, the framebuffers and the graphics pipeline wholesale. Because a compute dispatch cannot run inside a render pass, each frame runs the compute as its own submit first, then draw_frame samples. The two view shaders:

var @out @location = 0 v_uv : float2
[vertex_shader(name="fsq_vert_spv")]
def fsq_vert {
    let clip = fixed_array(float2(-1.0, -1.0), float2(3.0, -1.0), float2(-1.0, 3.0))
    let p = clip[gl_VertexIndex]
    gl_Position = float4(p, 0.0f, 1.0f)
    v_uv = p * 0.5f + float2(0.5, 0.5)      // clip -> [0,1] texture coordinates
}

// sample the compute result (a combined image sampler at binding 0) and write it to the framebuffer.
var @binding = 0 src : sampler2D
var @in @location = 0 fi_uv : float2
var @out @location = 0 frag_color : float4
[fragment_shader(name="fsq_frag_spv")]
def fsq_frag {
    frag_color = texture(src, fi_uv)
}

Blit is fewer moving parts; sample-as-texture is the one to grow from (a real post-process or UI pass samples the same way).

Running it

# the CI pixel-oracle gate (lavapipe in CI, real GPU locally)
daslang -load_module <dasVulkan> <daslang>/dastest/dastest.das -- \
    --test <dasVulkan>/tutorials/02_mandelbrot

# watch it live -- method 1: blit straight to the swapchain
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/02_mandelbrot/window/show_mandelbrot_blit.das

# watch it live -- method 2: sample the result as a texture
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/02_mandelbrot/window/show_mandelbrot_sampled.das

Next

03 - SDF raymarch (compute, no geometry) stays on the compute pipeline but trades the 2D fractal for a real 3D scene: a ray-marched signed distance field rendering primitives, soft shadows, and ambient occlusion from a fragment-style shader.