02 - The Mandelbrot Set

The dasVulkan Mandelbrot rung computes the set in a compute shader – one invocation per pixel, writing into a storage image. WebGL2 has neither compute shaders nor storage images, so the portable OpenGL idiom is a fullscreen fragment shader: draw two triangles covering the whole viewport and let the fragment stage run the escape-time loop once per pixel. Same math, same daslang shader language – a different backend, and a different “one thread per pixel” vehicle. Drawing that substitution explicitly is part of the harmonization story.

The shader is a faithful port of the Vulkan tutorial’s animated viewer, itself a port of Inigo Quilez’s smooth-coloured Mandelbrot zoom: a single u_time uniform drives an oscillating zoom and a slow rotation about the seahorse-valley point (-0.745, 0.186). A large bailout radius plus a continuous (smooth) iteration count kills the banding, an IQ cosine palette colours it, and 4×4 supersampling antialiases the boundary.

var @in @location v_position : float2
var @uniform u_time : float
var @uniform u_aspect : float
var @uniform u_res : float2
var @inout f_uv : float2
var @out f_FragColor : float4

let MAX_ITER = 512
let ESCAPE2 = 65536.0       // B*B with the IQ bailout radius B = 256
let AA = 4                  // 4x4 supersampling: 16 colour samples per pixel

// complex multiply -- (a.x + i a.y) * (b.x + i b.y)
def private cmul(a, b : float2) : float2 {
    return float2(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x)
}

// IQ's cheap interior tests: skip iteration inside the main cardioid and the
// period-2 bulb.
def private is_interior(c : float2) : bool {
    let c2 = c.x * c.x + c.y * c.y
    let in_cardioid = 256.0 * c2 * c2 - 96.0 * c2 + 32.0 * c.x - 3.0 < 0.0
    let in_bulb = 16.0 * (c2 + 2.0 * c.x + 1.0) - 1.0 < 0.0
    return in_cardioid || in_bulb
}

// Escape-time iteration with a large bailout radius (needed for smooth colouring).
// Returns (iteration_count, exit_magnitude |z|^2). n == MAX_ITER with d <= ESCAPE2
// means the point did not escape (treat as interior).
def private mandel_smooth(c : float2) : float2 {
    var z = float2(0.0, 0.0)
    var n = 0
    while (n < MAX_ITER) {
        z = cmul(z, z) + c
        if (z.x * z.x + z.y * z.y > ESCAPE2) {
            break
        }
        n++
    }
    return float2(float(n), z.x * z.x + z.y * z.y)
}

// IQ's cosine palette evaluated at a continuous-iteration phase t.
def private palette(t : float) : float3 {
    let phase = 3.0 + t * 0.15
    return float3(0.5 + 0.5 * cos(phase),
                  0.5 + 0.5 * cos(phase + 0.6),
                  0.5 + 0.5 * cos(phase + 1.0))
}

// One supersample at complex point c: interior -> black, else continuous
// iteration count -> palette.
def private sample_at(c : float2) : float3 {
    if (is_interior(c)) {
        return float3(0.0, 0.0, 0.0)
    }
    let nd = mandel_smooth(c)
    if (nd.y > ESCAPE2) {
        let sn = nd.x - log2(log2(nd.y)) + 4.0
        return palette(sn)
    }
    return float3(0.0, 0.0, 0.0)
}

[vertex_program]
def vs_main {
    f_uv = v_position
    gl_Position = float4(v_position, 0.0, 1.0)
}

[fragment_program]
def fs_main {
    let t = u_time
    // animated zoom (oscillates) + slow rotation, both derived from time
    let zoo0 = 0.62 + 0.38 * cos(0.07 * t)
    let ang = 0.15 * (1.0 - zoo0) * t
    let coa = cos(ang)
    let sia = sin(ang)
    let zoo = pow(zoo0, 8.0)
    // base screen coords: x widened by aspect, y flipped to match the Vulkan
    // image orientation (y down). One screen pixel spans 2/res.y in this space.
    let base_px = f_uv.x * u_aspect
    let base_py = -f_uv.y
    let pix = 2.0 / u_res.y
    // average AA*AA subpixel colour samples; interior samples contribute black, so
    // the set boundary softens correctly.
    var col = float3(0.0, 0.0, 0.0)
    for (s in range(AA * AA)) {
        let ox = (float(s % AA) + 0.5) / float(AA) - 0.5
        let oy = (float(s / AA) + 0.5) / float(AA) - 0.5
        let px = base_px + ox * pix
        let py = base_py + oy * pix
        let xr = px * coa - py * sia
        let yr = px * sia + py * coa
        let c = float2(-0.745 + xr * zoo, 0.186 + yr * zoo)
        col += sample_at(c)
    }
    let avg = col * (1.0 / float(AA * AA))
    f_FragColor = float4(avg, 1.0)
}

var program : uint
var vao : uint
var vbo : uint
var ebo : uint
var window : GLFWwindow?
var time : float = 0.0

[vertex_buffer]
struct Vertex {
    xy : float2
}

// A fullscreen quad in clip space; the fragment shader does all the work.
let vertices = [Vertex(
    xy=float2(-1.0, -1.0)), Vertex(
    xy=float2(1.0, -1.0)), Vertex(
    xy=float2(1.0, 1.0)), Vertex(
    xy=float2(-1.0, 1.0)
)];

let indices = fixed_array<int>(0, 1, 2, 2, 3, 0)

def create_gl_objects {
    program = create_shader_program(@@vs_main, @@fs_main)
    glGenVertexArrays(1, safe_addr(vao))
    glBindVertexArray(vao)
    glGenBuffers(1, safe_addr(vbo))
    glBindBuffer(GL_ARRAY_BUFFER, vbo)
    glBufferData(GL_ARRAY_BUFFER, vertices, GL_STATIC_DRAW)
    bind_vertex_buffer(null, type<Vertex>)
    glGenBuffers(1, safe_addr(ebo))
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices, GL_STATIC_DRAW)
}

[export]
def init {
    if (glfwInit() == 0) {
        panic("can't init glfw")
    }
    glfwInitOpenGL(3, 3)
    window = glfwCreateWindow(640, 480, "OpenGL - 02 mandelbrot", null, null)
    if (window == null) {
        panic("can't create window")
    }
    glfwMakeContextCurrent(window)
    create_gl_objects()
}

[export]
def update : bool {
    time += 1.0 / 60.0
    var display_w, display_h : int
    glfwGetFramebufferSize(window, display_w, display_h)
    let h = max(display_h, 1)
    u_time = time
    u_aspect = float(display_w) / float(h)
    u_res = float2(float(display_w), float(h))
    glViewport(0, 0, display_w, display_h)
    glClearColor(0.0, 0.0, 0.0, 1.0)
    glClear(GL_COLOR_BUFFER_BIT)
    glUseProgram(program)
    vs_main_bind_uniform(program)
    fs_main_bind_uniform(program)
    glBindVertexArray(vao)
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
    glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, null)
    glfwPollEvents()
    glfwSwapBuffers(window)
    return glfwWindowShouldClose(window) == 0
}

[export]
def shutdown {
    glfwDestroyWindow(window)
    glfwTerminate()
}

// Desktop driver. On the web this is never called -- the run path drives the
// three lifecycle functions directly and persists the Context across frames.
[export]
def main {
    init()
    while (update()) {
    }
    shutdown()
}

The fullscreen quad

There is no geometry to speak of: four clip-space corners (±1, ±1) and two triangles’ worth of indices cover the viewport edge to edge. The vertex stage does nothing but pass the clip-space position through as a varying f_uv and write gl_Position; all the work happens per fragment.

Smooth colouring

Each fragment reconstructs the complex point c from f_uv (widened by u_aspect, then zoomed and rotated about the seahorse valley), and – after the cheap cardioid/bulb interior tests – runs the escape loop with a large bailout radius. The continuous iteration count n - log2(log2(|z|²)) + 4 removes the integer banding, and an IQ cosine palette maps it to colour. This is the first rung whose shader body is real control flow, scalar float arithmetic, and a fistful of user-defined helper functions (cmul / is_interior / mandel_smooth / palette / sample_at) – all lowered to GLSL by dasGlsl. It is also what surfaced a GLSL-emitter gap: integer-valued float literals (0.0, 4.0, 256.0) must keep their decimal point, because GLSL ES 3.00 (WebGL2) is strict about float vs int in scalar and binary-operator positions where the desktop driver had been lenient.

The loop

update() advances a time value, reads the live framebuffer size (so the aspect correction and supersample spacing follow window/canvas resizes), clears, binds the program and its uniforms, and draws the two triangles. The whole animation – the breathing zoom and the slow rotation – is derived inside the shader from u_time alone, so the loop has one real GPU parameter.

Run it

Locally, in a window:

daslang tutorials/opengl/02_mandelbrot/02_mandelbrot.das

In the browser, it runs live in the daslang playground – the same .das, lowered to WebGL2, the smooth-coloured zoom running on your GPU.