8.15.9. 09 - Multisample Anti-Aliasing

The second GL-idiom-substitution rung. dasVulkan’s MSAA tutorial draws into a 4x multi-sample colour attachment and names a 1x “resolve attachment” in the render pass; the GPU averages the samples down at the end. WebGL2 has no resolve attachments, so this rung uses the canonical GL substitute: render into a multisample framebuffer, then resolve with a blit (glBlitFramebuffer from a multisample read framebuffer averages the N samples per pixel into one).

MSAA’s edge smoothing is nearly invisible on a cube (12 long edges) and dramatic on a dense thin silhouette – so the test mesh is a spiky ball: a smooth UV-sphere core studded with 150 flat-shaded pyramidal spikes in Fibonacci-distributed directions, a sea-urchin whose every spike is a high-frequency silhouette edge. And to make “is MSAA on?” unmistakable, the same ball is rendered twice – once 1x, once 4x – and the screen shows the left half of the 1x image beside the right half of the resolved 4x image, split by a bright divider. Left of the line the spikes stair-step and crawl as the ball spins; right of it they are smooth.

// ===== GL objects =====

var program : uint
var vao : uint
var vbo : uint
var ebo : uint
var texture : uint
var index_count = 0
// two full-size render targets: a 1x path and an N-sample MSAA path
var fbo_1x, color_1x, depth_1x : uint
var fbo_msaa, color_msaa, depth_msaa : uint
// single-sample landing pad the MSAA target resolves INTO, before anything reaches
// the window. See the blit sequence in update() for why the resolve cannot go
// straight to the default framebuffer.
var fbo_resolve, color_resolve : uint
var target_w = 0
var target_h = 0
var samples = 4
var window : GLFWwindow?
var time : float = 0.0

let FBW = 640
let FBH = 480

[vertex_buffer]
struct Vertex {
    xyz : float3
    normal : float3
    uv : float2
}

let SPIKE_COUNT = 150
let CORE_R = 0.5
let SPIKE_TIP = 0.95
let SPIKE_BASE_AT = 0.46
let SPIKE_BASE_R = 0.05
let SPHERE_STACKS = 18
let SPHERE_SECTORS = 26

// Build the spiky ball: a smooth indexed UV-sphere core (per-vertex normal = direction)
// plus 150 flat-shaded square pyramids in Fibonacci directions. The thin base + far tip
// make every spike a sharp silhouette edge -- the high-frequency content MSAA exists to
// tame.
def build_spiky_geometry(var verts : array<Vertex>; var indices : array<int>) {
    verts |> reserve(2400)
    indices |> reserve(4800)
    var nv = 0
    let sector_step = 2.0 * PI / float(SPHERE_SECTORS)
    let stack_step = PI / float(SPHERE_STACKS)
    for (i in range(SPHERE_STACKS + 1)) {
        let stack_a = PI * 0.5 - float(i) * stack_step
        let xy = cos(stack_a)
        let zz = sin(stack_a)
        for (j in range(SPHERE_SECTORS + 1)) {
            let sector_a = float(j) * sector_step
            let dir = float3(xy * cos(sector_a), zz, xy * sin(sector_a))
            let uv = float2(float(j) / float(SPHERE_SECTORS), float(i) / float(SPHERE_STACKS))
            verts |> push(Vertex(xyz = dir * CORE_R, normal = dir, uv = uv))
            nv ++
        }
    }
    for (i in range(SPHERE_STACKS)) {
        var k1 = i * (SPHERE_SECTORS + 1)
        var k2 = k1 + SPHERE_SECTORS + 1
        for (_j in range(SPHERE_SECTORS)) {
            if (i != 0) {
                indices |> push(k1)
                indices |> push(k2)
                indices |> push(k1 + 1)
            }
            if (i != SPHERE_STACKS - 1) {
                indices |> push(k1 + 1)
                indices |> push(k2)
                indices |> push(k2 + 1)
            }
            k1 ++
            k2 ++
        }
    }
    let golden = PI * (3.0 - sqrt(5.0))
    for (s in range(SPIKE_COUNT)) {
        let yy = 1.0 - (float(s) + 0.5) / float(SPIKE_COUNT) * 2.0
        let rr = sqrt(max(1.0 - yy * yy, 0.0))
        let theta = float(s) * golden
        let dir = float3(rr * cos(theta), yy, rr * sin(theta))
        let up = abs(dir.y) > 0.95 ? float3(1.0, 0.0, 0.0) : float3(0.0, 1.0, 0.0)
        let tan0 = normalize(cross(up, dir))
        let bit0 = cross(dir, tan0)
        let tip = dir * SPIKE_TIP
        var ring : float3[4]
        for (k in range(4)) {
            let a = float(k) * (PI * 0.5)
            ring[k] = dir * SPIKE_BASE_AT + (tan0 * cos(a) + bit0 * sin(a)) * SPIKE_BASE_R
        }
        for (k in range(4)) {
            var v0 = ring[k]
            var v1 = ring[(k + 1) % 4]
            var fn = normalize(cross(v1 - v0, tip - v0))
            // force the normal + winding outward so back-face culling keeps a clean silhouette
            if (dot(fn, dir) < 0.0) {
                let tmp = v0
                v0 = v1
                v1 = tmp
                fn = float3(-fn.x, -fn.y, -fn.z)
            }
            verts |> push(Vertex(xyz = v0, normal = fn, uv = float2(0.5, 0.92)))
            nv ++
            verts |> push(Vertex(xyz = v1, normal = fn, uv = float2(0.5, 0.92)))
            nv ++
            verts |> push(Vertex(xyz = tip, normal = fn, uv = float2(0.5, 0.06)))
            nv ++
            indices |> push(nv - 3)
            indices |> push(nv - 2)
            indices |> push(nv - 1)
        }
    }
}

let TEX_DIM = 256

// 256x256 RGBA8 synthwave horizon, generated CPU-side -- the same texture the cube
// tutorials wear, here wrapping the spiky ball.
def gen_synthwave_texture(w, h : int) : array<uint8> {
    var pixels : array<uint8>
    pixels |> resize(w * h * 4)
    let horizon = h / 2 - 8
    let sun_cx = w / 2
    let sun_cy = horizon - 26
    let sun_r = 42
    for (y in range(h)) {
        for (x in range(w)) {
            var r = 0
            var g = 0
            var b = 0
            if (y < horizon) {
                let t = float(y) / float(horizon)
                r = int(40.0 + t * 215.0)
                g = int(6.0 + t * 64.0)
                b = int(80.0 + t * 175.0)
                let dx = x - sun_cx
                let dy = y - sun_cy
                if (dx * dx + dy * dy < sun_r * sun_r) {
                    let band = (y / 4) % 2
                    if (band == 0) {
                        let glow = clamp(1.0 - float(dy + sun_r) / float(2 * sun_r), 0.0, 1.0)
                        r = 255
                        g = int(120.0 + glow * 100.0)
                        b = int(60.0 + glow * 80.0)
                    }
                }
            } elif (y < horizon + 4) {
                r = 255
                g = 90
                b = 210
            } else {
                r = 6
                g = 6
                b = 26
                let dist = y - horizon - 4
                let max_dist = h - horizon - 4
                let scale = float(dist) / float(max_dist)
                let spacing = max(2, int(2.0 + scale * 18.0))
                if (dist % spacing < 1) {
                    r = 0
                    g = 220
                    b = 255
                }
                let dx = abs(x - w / 2)
                let v_spacing = max(2, int(4.0 + scale * 40.0))
                if (dx % v_spacing < 2 && scale > 0.02) {
                    r = (r + 0) / 2
                    g = (g + 200) / 2
                    b = (b + 255) / 2
                }
            }
            let idx = (y * w + x) * 4
            pixels[idx + 0] = uint8(clamp(r, 0, 255))
            pixels[idx + 1] = uint8(clamp(g, 0, 255))
            pixels[idx + 2] = uint8(clamp(b, 0, 255))
            pixels[idx + 3] = 255u8
        }
    }
    return <- pixels
}

// (Re)create the render targets at w x h: a 1x path (single-sample colour + depth), an
// N-sample MSAA path (multisample colour + depth), and the single-sample target the MSAA
// path resolves into. All are renderbuffers -- never sampled, only blitted out. The
// multisample-resolve blit requires source and destination rectangles to match in size,
// so the targets track the live display size.
def setup_targets(w, h : int) {
    if (fbo_1x != 0u) {
        glDeleteFramebuffers(1, safe_addr(fbo_1x))
        glDeleteFramebuffers(1, safe_addr(fbo_msaa))
        glDeleteRenderbuffers(1, safe_addr(color_1x))
        glDeleteRenderbuffers(1, safe_addr(depth_1x))
        glDeleteRenderbuffers(1, safe_addr(color_msaa))
        glDeleteRenderbuffers(1, safe_addr(depth_msaa))
        glDeleteFramebuffers(1, safe_addr(fbo_resolve))
        glDeleteRenderbuffers(1, safe_addr(color_resolve))
    }
    // 1x path
    glGenRenderbuffers(1, safe_addr(color_1x))
    glBindRenderbuffer(GL_RENDERBUFFER, color_1x)
    glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, w, h)
    glGenRenderbuffers(1, safe_addr(depth_1x))
    glBindRenderbuffer(GL_RENDERBUFFER, depth_1x)
    glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT16, w, h)
    glGenFramebuffers(1, safe_addr(fbo_1x))
    glBindFramebuffer(GL_FRAMEBUFFER, fbo_1x)
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, color_1x)
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depth_1x)
    if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
        panic("1x framebuffer incomplete")
    }
    // N-sample MSAA path
    glGenRenderbuffers(1, safe_addr(color_msaa))
    glBindRenderbuffer(GL_RENDERBUFFER, color_msaa)
    glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, GL_RGBA8, w, h)
    glGenRenderbuffers(1, safe_addr(depth_msaa))
    glBindRenderbuffer(GL_RENDERBUFFER, depth_msaa)
    glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, GL_DEPTH_COMPONENT16, w, h)
    glGenFramebuffers(1, safe_addr(fbo_msaa))
    glBindFramebuffer(GL_FRAMEBUFFER, fbo_msaa)
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, color_msaa)
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depth_msaa)
    if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
        panic("MSAA framebuffer incomplete")
    }
    // Resolve target: single-sample, and the SAME internal format as the MSAA colour
    // buffer. Both requirements come from the resolve blit below.
    glGenRenderbuffers(1, safe_addr(color_resolve))
    glBindRenderbuffer(GL_RENDERBUFFER, color_resolve)
    glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, w, h)
    glGenFramebuffers(1, safe_addr(fbo_resolve))
    glBindFramebuffer(GL_FRAMEBUFFER, fbo_resolve)
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, color_resolve)
    if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
        panic("resolve framebuffer incomplete")
    }
    glBindFramebuffer(GL_FRAMEBUFFER, 0u)
    target_w = w
    target_h = h
}

def create_gl_objects {
    program = create_shader_program(@@vs_main, @@fs_main)
    var verts : array<Vertex>
    var indices : array<int>
    build_spiky_geometry(verts, indices)
    index_count = length(indices)
    glGenVertexArrays(1, safe_addr(vao))
    glBindVertexArray(vao)
    glGenBuffers(1, safe_addr(vbo))
    glBindBuffer(GL_ARRAY_BUFFER, vbo)
    glBufferData(GL_ARRAY_BUFFER, verts, 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)
    delete verts
    delete indices

    var pixels <- gen_synthwave_texture(TEX_DIM, TEX_DIM)
    texture = load_image_from_bytes(TEX_DIM, TEX_DIM, unsafe(addr(pixels[0])))
    delete pixels

    var max_samples = 1
    glGetIntegerv(GL_MAX_SAMPLES, safe_addr(max_samples))
    samples = min(4, max_samples)
    setup_targets(FBW, FBH)
}

[export]
def init {
    if (glfwInit() == 0) {
        panic("can't init glfw")
    }
    glfwInitOpenGL(3, 3)
    window = glfwCreateWindow(FBW, FBH, "OpenGL - 09 MSAA", null, null)
    if (window == null) {
        panic("can't create window")
    }
    glfwMakeContextCurrent(window)
    create_gl_objects()
}

// draw the spiky ball into the currently-bound framebuffer at target_w x target_h
def draw_ball {
    glViewport(0, 0, target_w, target_h)
    glClearColor(0.02, 0.01, 0.05, 1.0)
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
    glEnable(GL_DEPTH_TEST)
    glDepthFunc(GL_LEQUAL)
    glEnable(GL_CULL_FACE)
    glCullFace(GL_BACK)
    glUseProgram(program)
    u_tex := texture
    vs_main_bind_uniform(program)
    fs_main_bind_uniform(program)
    glBindVertexArray(vao)
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
    glDrawElements(GL_TRIANGLES, index_count, GL_UNSIGNED_INT, null)
}

[export]
def update : bool {
    time += 1.0 / 60.0
    let t = time
    var display_w, display_h : int
    glfwGetFramebufferSize(window, display_w, display_h)
    let dw = max(display_w, 2)
    let dh = max(display_h, 1)
    let aspect = float(dw) / float(dh)
    if (dw != target_w || dh != target_h) {
        setup_targets(dw, dh)
    }

    // spinning ball, slowly orbiting camera -- the spin makes the 1x silhouette crawl
    let cam_pos = float3(cos(t * 0.25) * 2.8, 0.9, sin(t * 0.25) * 2.8)
    let rot = quat_from_unit_vec_ang(normalize(float3(0.2, 1.0, 0.1)), t * 0.5)
    u_model = compose(float3(0, 0, 0), rot, float3(1.0))
    u_view = look_at_rh(cam_pos, float3(0, 0, 0), float3(0, 1, 0))
    u_proj = perspective_rh_opengl(45.0 * PI / 180.0, aspect, 0.1, 50.0)
    u_cam_pos = cam_pos
    u_time = t

    // Render the SAME ball twice: once into the 1x target, once into the MSAA target.
    glBindFramebuffer(GL_FRAMEBUFFER, fbo_1x)
    draw_ball()
    glBindFramebuffer(GL_FRAMEBUFFER, fbo_msaa)
    draw_ball()

    // RESOLVE FIRST, COMPOSE SECOND. A blit whose READ framebuffer is multisampled is
    // the resolve, and GL constrains it hard: source and destination rectangles must be
    // identical, and the two colour buffers must have the SAME FORMAT. Resolving straight
    // to the window satisfies neither reliably -- the window's format is chosen by the
    // host, not by us. A page that creates the WebGL context with `alpha: false` gets an
    // RGB8 default framebuffer, our RGBA8 multisample buffer does not match it, and the
    // blit fails with GL_INVALID_OPERATION: the MSAA half stays BLACK while the 1x half
    // still draws, because a NON-multisampled blit is allowed to convert formats.
    //
    // So resolve into our own single-sample RGBA8 target, where both rules hold by
    // construction. After that both halves are ordinary single-sample sources and the
    // final blits to the window may convert freely.
    glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo_msaa)
    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo_resolve)
    glBlitFramebuffer(0, 0, target_w, target_h, 0, 0, target_w, target_h, GL_COLOR_BUFFER_BIT, GL_NEAREST)

    // Compose the comparison: LEFT half of the screen from the 1x image, RIGHT half from
    // the resolved MSAA image.
    let half = target_w / 2
    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0u)
    glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo_1x)
    glBlitFramebuffer(0, 0, half, target_h, 0, 0, half, target_h, GL_COLOR_BUFFER_BIT, GL_NEAREST)
    glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo_resolve)
    glBlitFramebuffer(half, 0, target_w, target_h, half, 0, target_w, target_h, GL_COLOR_BUFFER_BIT, GL_NEAREST)

    // a bright divider down the seam: left of it is 1x (jaggy), right is 4x MSAA (smooth)
    glBindFramebuffer(GL_FRAMEBUFFER, 0u)
    glEnable(GL_SCISSOR_TEST)
    glScissor(half - 1, 0, 3, target_h)
    glClearColor(0.95, 0.85, 0.2, 1.0)
    glClear(GL_COLOR_BUFFER_BIT)
    glDisable(GL_SCISSOR_TEST)

    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()) {
        maybe_collect_gc()
    }
    shutdown()
}

8.15.9.1. Two render targets

setup_targets builds two full-size framebuffers, both with renderbuffer attachments (never sampled, only blitted out):

  • the 1x target – glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, w, h) colour plus a depth renderbuffer;

  • the MSAA target – glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, GL_RGBA8, w, h) colour plus a multisample depth renderbuffer;

  • the resolve target – single-sample GL_RGBA8 colour, no depth. The MSAA target resolves into this before anything reaches the window; the next section says why.

The sample count is min(4, GL_MAX_SAMPLES). With N samples per pixel each triangle edge is rasterised against N sub-pixel sample points, so coverage along the edge is graded instead of binary – that graded coverage becomes a smooth edge after the resolve.

8.15.9.2. The resolve blit, and the comparison

The same ball is drawn into both targets. The MSAA target is then resolved into the single-sample target, and only after that is the comparison composed onto the screen:

glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo_msaa)
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo_resolve)
glBlitFramebuffer(0, 0, target_w, target_h, 0, 0, target_w, target_h, GL_COLOR_BUFFER_BIT, GL_NEAREST)

let half = target_w / 2
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0u)
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo_1x)
glBlitFramebuffer(0, 0, half, target_h, 0, 0, half, target_h, GL_COLOR_BUFFER_BIT, GL_NEAREST)
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo_resolve)
glBlitFramebuffer(half, 0, target_w, target_h, half, 0, target_w, target_h, GL_COLOR_BUFFER_BIT, GL_NEAREST)

Blitting from the multisample read framebuffer averages its N colour samples per pixel into one – that blit is the resolve.

Note

Resolve into your own buffer, not into the window. A resolve blit carries two requirements that an ordinary blit does not: the source and destination rectangles must be identical, and the two colour buffers must have the same format. The window’s format is chosen by whoever created the GL context, not by the program – a page that requests alpha: false gets an RGB8 default framebuffer, which an RGBA8 multisample buffer does not match. The blit then fails with GL_INVALID_OPERATION and writes nothing, while a non-multisampled blit beside it succeeds, because that one is allowed to convert formats. The symptom is one half of a comparison rendering and the other staying black.

Resolving into a single-sample target the program owns satisfies both rules by construction, and leaves the final blits free to convert.

A scissored clear then paints the divider over the seam.

8.15.9.3. MSAA is invisible to the shader

The vertex and fragment programs are byte-for-byte tutorial 04’s. Multisampling happens entirely at the framebuffer level – the fragment shader still runs once per pixel (not once per sample), and the hardware handles the per-edge coverage. Nothing about the spiky ball’s draw call knows whether it is going into the 1x or the 4x target.

8.15.9.4. Run it

Locally, in a window:

daslang tutorials/opengl/09_msaa/09_msaa.das

In the browser, it runs live in the daslang playground – the same .das, lowered to WebGL2: a spinning spiky ball split down the middle, its silhouette stair-stepped on the 1x left and smooth on the 4x-MSAA right.