14 - GPU Teapot: Mesh-Shader Tessellation

Tutorial 13 introduced mesh shaders with a flat coloured grid. This one puts them to real work: the classic Newell Utah teapot, tessellated entirely on the GPU. Its 32 bicubic Bezier patches are uploaded once as control points – there is no vertex buffer and no index buffer – and the mesh-shader pipeline amplifies them into a smooth surface every frame, on a grounded stage with a reflective floor, an environment sky, and a mesh-shader-cast shadow.

The headline rails:

  • On-GPU Bezier tessellation. Each mesh workgroup owns one patch, reads its 16 control points from a UBO, and evaluates the bicubic Bezier surface on a TESS x TESS (7x7) grid – emitting (TESS+1)^2 = 64 vertices and 2*TESS^2 = 98 triangles, with a finite-difference surface normal per vertex. This is the flagship mesh-shader technique: parametric geometry expanded on the GPU, no fixed-function tessellator and no vertex buffer.

  • Task shader = patch backface cull. One 32-wide task workgroup tests each patch’s facing as the teapot rotates and writes only the front-facing patch indices into the @task_payload. EmitMeshTasksEXT(count, 1, 1) then dispatches one mesh workgroup per surviving patch – the GPU-side proof that the task stage removed geometry (roughly half the patches at any angle).

  • One patch = one mesh workgroup. The fragment shader carries an optional per-patch debug tint that colours each patch distinctly, making the workgroup-to-patch mapping visible; the recording auto-toggles it against the lit ceramic glaze.

  • Grounded stage. The teapot doesn’t float in a void: a second mesh-shader pass renders the teapot’s depth from the light’s point of view into a shadow map, a reflective checkerboard floor samples that shadow (and reflects an analytic environment), and a fullscreen environment sky fills the background – so the GPU-tessellated teapot reads as a solid object on a surface.

Every line of every shader is daslang, lowered to SPIR-V at compile time by dasSpirv (no glslang). The control points live in teapot_data.das, generated from the original Newell teapotCGA.bpt.

The clip above is the headless recording: 30 seconds, 30 fps, captured into an APNG and ffmpeg-muxed with a daStrudel music bed + voiceover. The teapot spins through two full turns while the per-patch debug tint auto-toggles – watch the smooth glazed ceramic dissolve into the rainbow “1 patch = 1 mesh workgroup” decomposition and back. The [test] pixel-oracle checks the grounded scene at a fixed frame: a lit ceramic teapot in the centre, sky in the corners, floor below, and a box-averaged shadow darker than the open floor. To see it live, run the windowed viewer (see See it live below).

Note

Mesh shaders require VK_EXT_mesh_shader. The tutorial soft-skips on devices without it (lavapipe in CI lacks the extension, so the test passes as skipped) – the recording here is from a mesh-shader-capable GPU.

The shaders

No vertex shader. The task stage culls back-facing patches; the mesh stage evaluates the Bezier surface and emits the tessellated grid; the fragment stage shades it (view-facing Lambert + rim + the optional per-patch tint). The same module also carries the ground-floor and env-sky shaders.

module teapot_tut_shaders public

require vulkan/vulkan_boost public  // Device + DeviceMemory used by the generated bind_uniform_* helpers
require vulkan/spirv_vulkan_shader public
require spirv/spirv_builtins public
require math

let PATCH_COUNT = 32
let TESS  = 7    // TESS x TESS quad grid per patch
let VERTS = 64   // (TESS+1)^2  -- emitter needs literal-constant globals (SetMeshOutputsEXT operand)
let TRIS  = 98   // 2*TESS^2

// ===== UBO =====

//! One UBO (binding 0), shared by every shader in this module (teapot task/mesh/frag, ground floor,
//! env sky). generate_bind_uniform writes the WHOLE struct from any one reachable shader, so the host
//! calls a single *_bind_uniform per frame and every stage sees the same allocation.
struct TeapotUBO {
    mvp      : float4x4     //!< proj*view*model -- teapot positions
    model    : float4x4     //!< world rotation -- normals + cull facing
    vp       : float4x4     //!< proj*view; the ground floor carries its own world coords, so no model
    light_vp : float4x4     //!< the light's proj*view, for the shadow-map pass
    camera   : float4       //!< .xyz = world eye, .w = debug-tint flag (0/1)
    light    : float4       //!< .xyz = world light direction (the sun; shared by env + shadow)
    cp       : float4[512]  //!< 32 patches x 16 control points (xyz; w pad), patch p = cp[p*16 .. p*16+15], row-major 4x4
}
var @uniform @set = 0 @binding = 0 ubo : TeapotUBO

// ===== task -> mesh payload =====

struct PatchPayload {
    count   : uint
    visible : uint[32]
}
var @task_payload payload : PatchPayload

// ===== mesh -> fragment varyings =====

var @out @location = 0 v_world_normal : array<float3>
var @out @location = 1 v_world_pos    : array<float3>
var @out @location = 2 v_patch        : array<float>

// ===== bicubic Bezier evaluation (helpers shared by task + mesh) =====

//! Evaluate the bicubic Bezier patch at (u,v) in [0,1]^2. base = patch*16. Fully unrolled so the only
//! dynamic index is into the control-point UBO array (base varies per patch).
def eval_patch(base : int; u, v : float) : float3 {
    let su = 1.0 - u
    let sv = 1.0 - v
    let bu0 = su * su * su; let bu1 = 3.0 * su * su * u; let bu2 = 3.0 * su * u * u; let bu3 = u * u * u
    let bv0 = sv * sv * sv; let bv1 = 3.0 * sv * sv * v; let bv2 = 3.0 * sv * v * v; let bv3 = v * v * v
    let r0 = bv0 * ubo.cp[base +  0].xyz + bv1 * ubo.cp[base +  1].xyz + bv2 * ubo.cp[base +  2].xyz + bv3 * ubo.cp[base +  3].xyz
    let r1 = bv0 * ubo.cp[base +  4].xyz + bv1 * ubo.cp[base +  5].xyz + bv2 * ubo.cp[base +  6].xyz + bv3 * ubo.cp[base +  7].xyz
    let r2 = bv0 * ubo.cp[base +  8].xyz + bv1 * ubo.cp[base +  9].xyz + bv2 * ubo.cp[base + 10].xyz + bv3 * ubo.cp[base + 11].xyz
    let r3 = bv0 * ubo.cp[base + 12].xyz + bv1 * ubo.cp[base + 13].xyz + bv2 * ubo.cp[base + 14].xyz + bv3 * ubo.cp[base + 15].xyz
    return bu0 * r0 + bu1 * r1 + bu2 * r2 + bu3 * r3
}

//! Surface normal at (u,v) via central finite differences -- robust and simple (no analytic
//! derivative basis). Model space.
def eval_normal(base : int; u, v : float) : float3 {
    let e = 0.012
    let du = eval_patch(base, min(u + e, 1.0), v) - eval_patch(base, max(u - e, 0.0), v)
    let dv = eval_patch(base, u, min(v + e, 1.0)) - eval_patch(base, u, max(v - e, 0.0))
    let nn = cross(du, dv)
    let len2 = dot(nn, nn)
    // A collapsed control-net row (the lid-knob apex rows are single points) makes one finite-
    // difference zero, so cross() is the zero vector and normalize() would be NaN -- a NaN vertex
    // attribute makes the driver discard the whole primitive (the knob vanished). Fall back instead.
    if (len2 < 1.0e-10) {
        return float3(0.0, 0.0, 1.0)
    }
    return normalize(nn)
}

// ===== TASK: per-patch cull =====

[vulkan_task_shader(local_size_x=1, name="teapot_task_spv")]
def teapot_task {
    // Per-patch backface cull -- the GPU-driven cluster cull: a patch whose world normal faces away is
    // hidden by the front of the teapot, so it is dropped before any mesh workgroup runs and the image
    // is unchanged. A small negative threshold keeps grazing patches so the silhouette stays solid.
    var n = 0u
    for (p in range(PATCH_COUNT)) {
        let base = p * 16
        var c = float3(0.0, 0.0, 0.0)
        for (i in range(16)) {
            c += ubo.cp[base + i].xyz
        }
        c *= 1.0 / 16.0                       // patch center = mean of the control net
        let world_c = (ubo.model * float4(c, 1.0)).xyz
        let outward = normalize(world_c)      // teapot is centered at the origin
        // Patch normal from the control net. A collapsed row (the lid-knob apex is a single point) zeroes
        // one span -> cross() is zero -> normalize() is NaN, and `dot(NaN,..) > t` is false for ANY t, so
        // the knob got culled: fall back to `outward` there, and orient cross(du,dv) outward (winding varies).
        let du = ubo.cp[base + 3].xyz - ubo.cp[base + 0].xyz
        let dv = ubo.cp[base + 12].xyz - ubo.cp[base + 0].xyz
        let raw = cross(du, dv)
        var world_n = outward
        if (dot(raw, raw) > 1.0e-10) {
            world_n = normalize((ubo.model * float4(raw, 0.0)).xyz)
            if (dot(world_n, outward) < 0.0) {
                world_n = -world_n
            }
        }
        let to_cam = normalize(ubo.camera.xyz - world_c)
        if (dot(world_n, to_cam) > -0.92) {
            payload.visible[n] = uint(p)
            n ++
        }
    }
    payload.count = n
    EmitMeshTasksEXT(n, 1u, 1u)
}

// ===== MESH: tessellate one patch =====

[vulkan_mesh_shader(local_size_x=1, max_vertices=64, max_primitives=98, name="teapot_mesh_spv")]
def teapot_mesh {
    SetMeshOutputsEXT(uint(VERTS), uint(TRIS))
    let patch = payload.visible[gl_WorkGroupID.x]
    let base = int(patch) * 16
    // evaluate the (TESS+1)^2 grid of surface points + normals
    for (iu in range(TESS + 1)) {
        for (iv in range(TESS + 1)) {
            let u = float(iu) / float(TESS)
            let v = float(iv) / float(TESS)
            let pos = eval_patch(base, u, v)
            let nrm = eval_normal(base, u, v)
            let idx = iu * (TESS + 1) + iv
            gl_MeshVerticesEXT[idx].gl_Position = ubo.mvp * float4(pos, 1.0)
            v_world_normal[idx] = normalize((ubo.model * float4(nrm, 0.0)).xyz)
            v_world_pos[idx] = (ubo.model * float4(pos, 1.0)).xyz
            v_patch[idx] = float(patch)
        }
    }
    // emit two triangles per grid quad
    var t = 0
    for (iu in range(TESS)) {
        for (iv in range(TESS)) {
            let a = iu * (TESS + 1) + iv
            let b = a + 1
            let c = a + (TESS + 1)
            let d = c + 1
            gl_PrimitiveTriangleIndicesEXT[t] = uint3(uint(a), uint(b), uint(c))
            t ++
            gl_PrimitiveTriangleIndicesEXT[t] = uint3(uint(b), uint(d), uint(c))
            t ++
        }
    }
}

// ===== FRAGMENT =====

var @in @location = 0 f_world_normal : float3
var @in @location = 1 f_world_pos    : float3
var @in @location = 2 f_patch        : float
var @out @location = 0 frag_color    : float4

//! Procedural environment sampled by a direction -- what the glazed teapot reflects. Warm horizon ->
//! cool zenith, a darker ground hemisphere, plus a sun disc + glow along the light dir, so the reflected
//! sun rides the surface as it turns and lines up with the specular. Analytic; no cubemap needed.
def env_color(dir : float3) : float3 {
    let up = clamp(dir.y, -1.0, 1.0)
    let t = clamp(up * 0.5 + 0.5, 0.0, 1.0)
    var c = lerp(float3(0.90, 0.80, 0.70), float3(0.32, 0.48, 0.82), float3(t, t, t))
    if (up < 0.0) {
        let g = clamp(-up * 1.5, 0.0, 1.0)
        c = lerp(c, float3(0.18, 0.15, 0.13), float3(g, g, g))
    }
    let sun_dir = normalize(ubo.light.xyz)
    let sd = max(dot(dir, sun_dir), 0.0)
    c = c + float3(1.0, 0.92, 0.78) * (pow(sd, 250.0) * 3.0)   // bright sun disc
    c = c + float3(1.0, 0.88, 0.70) * (pow(sd, 8.0) * 0.4)     // soft glow
    return c
}

[vulkan_fragment_shader(name="teapot_frag_spv")]
def teapot_fs {
    var n = normalize(f_world_normal)
    let view = normalize(ubo.camera.xyz - f_world_pos)
    if (dot(n, view) < 0.0) {            // view-facing: robust to per-patch winding
        n = -n
    }
    let l = normalize(ubo.light.xyz)

    // reflect the view about the surface normal and sample the procedural environment
    let inc = float3(-view.x, -view.y, -view.z)
    let rdir = inc - n * (2.0 * dot(n, inc))
    let env = env_color(rdir)

    // glazed ceramic: warm diffuse base, Fresnel-weighted environment reflection, tight glossy
    // highlight. Fresnel makes the rim mirror-bright; the centre keeps a base sheen.
    let key = max(dot(n, l), 0.0) * 0.7 + 0.3
    let base = float3(0.82, 0.50, 0.26) * key
    let fres = pow(1.0 - max(dot(n, view), 0.0), 4.0)
    let reflectivity = 0.18 + 0.62 * fres
    let half = normalize(l + view)
    let spec = pow(max(dot(n, half), 0.0), 120.0) * 1.3
    var col = lerp(base, env, float3(reflectivity, reflectivity, reflectivity))
    col = col + float3(spec, spec, spec)

    if (ubo.camera.w > 0.5) {             // per-patch debug tint
        let h = fract(f_patch * 0.137)
        let tint = float3(fract(h + 0.0), fract(h + 0.33), fract(h + 0.66))
        col = lerp(col, tint, float3(0.55, 0.55, 0.55))   // splat t: dasSpirv FMix needs a vec t
    }
    frag_color = float4(col, 1.0)
}

// ===== SHADOW MAP SAMPLER =====

// The depth-compare combined sampler. dasSpirv emits OpTypeImage with the Depth=1 flag (the
// `sampler2DShadow` marker) and textureCompare lowers to OpImageSampleDrefImplicitLod -> scalar PCF
// in [0,1] (1 = lit, 0 = shadowed). The floor reads it to receive the teapot's cast silhouette.
var @uniform @set = 0 @binding = 1 teapot_shadow_map : sampler2DShadow

let SHADOW_MAP_PX = 1024.0

//! 3x3 PCF: each textureCompare is already hardware-PCF'd over its 2x2 footprint, so 9 taps give a
//! soft, stable shadow edge cheaply.
def private pcf_shadow(uv : float2; ref : float) : float {
    let texel = 1.0 / SHADOW_MAP_PX
    var sum = 0.0
    for (j in range(-1, 2)) {
        for (i in range(-1, 2)) {
            let off = float2(float(i), float(j)) * texel
            sum += textureCompare(teapot_shadow_map, uv + off, ref)
        }
    }
    return sum * (1.0 / 9.0)
}

// ===== GROUND FLOOR =====

// A world-space XZ plane the teapot sits on: its own regular vertex+fragment pipeline, drawn in the
// same color pass as the mesh-shader teapot. It reads `ubo.vp` (no model — the quad is already in
// world space), reflects the SAME env the teapot does, and receives its shadow via sampler2DShadow.

var @in  @location = 0 fl_pos   : float3
var @out @location = 0 fl_world : float3

[vulkan_vertex_shader(name="floor_vert_spv")]
def floor_vs {
    fl_world = fl_pos
    gl_Position = ubo.vp * float4(fl_pos, 1.0)
}

var @in  @location = 0 ff_world : float3
var @out @location = 0 floor_color : float4

[vulkan_fragment_shader(name="floor_frag_spv")]
def floor_fs {
    let n = float3(0.0, 1.0, 0.0)
    // checkerboard via cell parity (gx+gz) mod 2 — bigger cells (x0.6) keep far-field aliasing tame.
    let gx = floor(ff_world.x * 0.6 + 1000.0)
    let gz = floor(ff_world.z * 0.6 + 1000.0)
    let cell = gx + gz
    let parity = cell - floor(cell * 0.5) * 2.0
    let checker = parity < 0.5 ? 0.58 : 0.20
    let l = normalize(ubo.light.xyz)
    let ndotl = max(dot(n, l), 0.0)

    // shadow lookup: project the floor fragment into the light's clip space, remap NDC.xy [-1,1] to
    // the [0,1] shadow-map UV (Vulkan NDC.z is already [0,1], used as the compare reference).
    let lc = ubo.light_vp * float4(ff_world, 1.0)
    let lw = max(lc.w, 0.0001)
    let lndc = float3(lc.x / lw, lc.y / lw, lc.z / lw)
    let suv = float2(lndc.x * 0.5 + 0.5, lndc.y * 0.5 + 0.5)
    let ref = clamp(lndc.z - 0.0015, 0.0, 1.0)        // small constant receiver bias
    var lit = pcf_shadow(suv, ref)
    if (ndotl < 0.05) { lit = 0.0 }

    // ambient + shadowed key light
    var col = float3(checker, checker, checker) * (0.45 + ndotl * 0.7 * lit)
    // polished sheen: Fresnel-weighted reflection of the env about the up normal
    let view = normalize(ubo.camera.xyz - ff_world)
    let inc = float3(-view.x, -view.y, -view.z)
    let rdir = inc - n * (2.0 * dot(n, inc))
    let env = env_color(rdir)
    let fres = pow(1.0 - max(dot(n, view), 0.0), 4.0) * 0.6
    col = lerp(col, env, float3(fres, fres, fres))
    // dissolve the far floor into the sky so the slab has no hard edge and the checker never reaches
    // the aliasing-prone horizon. Horizon colour = env sampled along the (flattened) view ray.
    let horizon = env_color(normalize(float3(-view.x, 0.04, -view.z)))
    let d = length(float2(ff_world.x, ff_world.z))
    let fade = clamp((d - 4.5) * 0.14, 0.0, 1.0)
    col = lerp(col, horizon, float3(fade, fade, fade))
    floor_color = float4(col, 1.0)
}

// ===== ENV SKY =====

// A fullscreen triangle drawn first (depth test off) so the background shows the same procedural env
// the teapot + floor reflect — a scene, not a void. The host uploads the three corner view-rays from
// the camera basis as a per-vertex attribute; the fragment normalizes it and evaluates env_color.

var @in  @location = 0 sky_clip : float2
var @in  @location = 1 sky_ray  : float3
var @out @location = 0 sky_v_ray : float3

[vulkan_vertex_shader(name="sky_vert_spv")]
def sky_vs {
    sky_v_ray = sky_ray
    gl_Position = float4(sky_clip.x, sky_clip.y, 1.0, 1.0)   // z = w = 1 -> far plane
}

var @in  @location = 0 sky_f_ray : float3
var @out @location = 0 sky_color : float4

[vulkan_fragment_shader(name="sky_frag_spv")]
def sky_fs {
    sky_color = float4(env_color(normalize(sky_f_ray)), 1.0)
}

// ===== SHADOW CASTER (mesh-shader, depth-only) =====

// The teapot casts its TRUE silhouette: the same on-GPU Bezier tessellation, rendered depth-only from
// the light's view into the shadow map (pass 1). The task emits ALL patches — a caster must keep the
// camera-facing-away ones, they still occlude — and the mesh writes only clip = light_vp*model*pos.

[vulkan_task_shader(local_size_x=1, name="teapot_shadow_task_spv")]
def teapot_shadow_task {
    for (p in range(PATCH_COUNT)) {
        payload.visible[p] = uint(p)
    }
    payload.count = uint(PATCH_COUNT)
    EmitMeshTasksEXT(uint(PATCH_COUNT), 1u, 1u)
}

[vulkan_mesh_shader(local_size_x=1, max_vertices=64, max_primitives=98, name="teapot_shadow_mesh_spv")]
def teapot_shadow_mesh {
    SetMeshOutputsEXT(uint(VERTS), uint(TRIS))
    let patch = payload.visible[gl_WorkGroupID.x]
    let base = int(patch) * 16
    for (iu in range(TESS + 1)) {
        for (iv in range(TESS + 1)) {
            let u = float(iu) / float(TESS)
            let v = float(iv) / float(TESS)
            let pos = eval_patch(base, u, v)
            let idx = iu * (TESS + 1) + iv
            gl_MeshVerticesEXT[idx].gl_Position = ubo.light_vp * ubo.model * float4(pos, 1.0)
        }
    }
    var t = 0
    for (iu in range(TESS)) {
        for (iv in range(TESS)) {
            let a = iu * (TESS + 1) + iv
            let b = a + 1
            let c = a + (TESS + 1)
            let d = c + 1
            gl_PrimitiveTriangleIndicesEXT[t] = uint3(uint(a), uint(b), uint(c))
            t ++
            gl_PrimitiveTriangleIndicesEXT[t] = uint3(uint(b), uint(d), uint(c))
            t ++
        }
    }
}

The render (headless)

record_teapot_render_pass records two passes per frame: a depth-only mesh-shader shadow pass (the teapot’s silhouette from the light’s view), then the colour pass – env sky, the reflective floor (sampling the shadow map), and the tessellated teapot.

def public record_teapot_render_pass(res : TeapotResources; cmd : CommandBuffer) {
    var sets <- [vk_value_to_boost(res.desc_set)]
    var no_dyn : array<uint>

    // ===== Pass 1: shadow map — the teapot's depth from the light's POV (mesh-shader, depth-only).
    // The pass's finalLayout = DEPTH_STENCIL_READ_ONLY_OPTIMAL hands the image to pass 2 ready to sample.
    var shadow_clears <- [clear_depth(1.0f)]
    record_render_pass(cmd, res.rp_shadow, res.fb_shadow, full_area(SHADOW_W, SHADOW_H), shadow_clears) {
        cmd_bind_pipeline(cmd, res.pipeline_shadow)
        cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.pipe_layout, 0u, sets, no_dyn)
        cmd_draw_mesh_tasks_e_x_t(cmd, 1u, 1u, 1u)
    }
    delete shadow_clears

    // ===== Pass 2: color — sky, then the floor (which samples the shadow map), then the teapot.
    var clears <- [clear_color(0.06f, 0.07f, 0.10f, 1.0f), clear_depth(1.0f)]
    record_render_pass(cmd, res.render_pass, res.framebuffer, full_area(TEAPOT_W, TEAPOT_H), clears) {
        cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.pipe_layout, 0u, sets, no_dyn)
        // 1. env sky (depth off) paints the background, so the corners are the environment, not a void
        cmd_bind_pipeline(cmd, res.pipeline_sky)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.sky_vb.buffer))
        cmd_draw(cmd, 3u)
        // 2. reflective checkerboard ground the teapot sits on (receives the teapot's shadow)
        cmd_bind_pipeline(cmd, res.pipeline_floor)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.floor_vb.buffer))
        cmd_draw(cmd, 6u)
        // 3. the GPU-tessellated teapot (mesh-shader pipeline)
        cmd_bind_pipeline(cmd, res.pipeline)
        cmd_draw_mesh_tasks_e_x_t(cmd, 1u, 1u, 1u)
    }
    delete clears
    delete sets
    delete no_dyn
}

Self-verifying

The pixel-oracle (the CI regression gate) renders one frame and asserts the grounded scene: a lit ceramic teapot in the centre, environment sky in the corners, a checkerboard floor below, and a box-averaged shadow region darker than the open floor. On a device without VK_EXT_mesh_shader it skips cleanly.

[test]
def test_teapot_render(t : T?) {
    if (!mesh_shader_available()) {
        feint("VK_EXT_mesh_shader not advertised by this device; skipping (CI lavapipe may predate 24.1)\n")
        return
    }
    var pixels <- render_teapot(0.7)
    // body / upper body / spout / handle are lit with the ceramic material -- proves the patches
    // tessellate + shade and that the backface cull did not punch holes in the visible surface.
    t |> success(is_ceramic_lit(px(pixels, 256, 290)), "teapot body is lit ceramic ({px(pixels, 256, 290)})")
    t |> success(is_ceramic_lit(px(pixels, 256, 255)), "teapot upper body is lit ceramic ({px(pixels, 256, 255)})")
    t |> success(is_ceramic_lit(px(pixels, 360, 235)), "teapot spout is lit ceramic ({px(pixels, 360, 235)})")
    t |> success(is_ceramic_lit(px(pixels, 110, 275)), "teapot handle is lit ceramic ({px(pixels, 110, 275)})")
    // lid knob present (collapsed-patch regression guard): count ceramic pixels in the knob box. The
    // env sky now sits behind the knob, so "not background" no longer isolates it -- count ceramic.
    var knob_ceramic = 0
    for (y in range(185, 212)) {
        for (x in range(238, 274)) {
            if (is_ceramic_lit(px(pixels, x, y))) {
                knob_ceramic ++
            }
        }
    }
    t |> success(knob_ceramic > 120, "lid knob renders (ceramic px = {knob_ceramic}, expect ~230)")
    // grounding: env sky fills the corners + above; the ground floor sits below.
    t |> success(is_sky(px(pixels, 10, 10)), "top-left corner is env sky ({px(pixels, 10, 10)})")
    t |> success(is_sky(px(pixels, 502, 10)), "top-right corner is env sky ({px(pixels, 502, 10)})")
    t |> success(is_sky(px(pixels, 256, 140)), "above the teapot is env sky ({px(pixels, 256, 140)})")
    t |> success(is_floor(px(pixels, 40, 500)), "ground floor below-left ({px(pixels, 40, 500)})")
    t |> success(is_floor(px(pixels, 256, 500)), "ground floor below-center ({px(pixels, 256, 500)})")
    // the teapot casts a shadow: the floor box on the shadow side (left of the teapot) is markedly
    // darker than the symmetric box on the lit side (right). Box averages cancel the checker so the
    // drop is the shadow, not the tiling.
    let shadow_box = box_luma3(pixels, 70, 170, 360, 430)
    let lit_box = box_luma3(pixels, 350, 450, 360, 430)
    t |> success(lit_box > shadow_box + 100, "teapot casts a floor shadow (lit {lit_box} vs shadow {shadow_box})")
    delete pixels
}

See it live

window/show_teapot.das opens a GLFW window and runs the same two-pass mesh-shader pipeline per frame with time from wall-clock: the teapot spins in place while the camera holds, so the GPU-tessellated surface is the visual story.

require glfw/glfw_boost
require vulkan
require vulkan/vulkan_boost
require vulkan/vulkan_window
require ../teapot_tut.das
require daslib/defer

[export]
def main {   // nolint:STYLE038 - flat viewer lifecycle scaffold
    if (volkInitialize() != 0) {
        panic("no vulkan loader")
    }
    glfwInitVulkanLoader(vk_get_instance_proc_addr())   // must precede glfwInit (macOS loader discovery)
    if (glfwInit() == 0) {
        panic("can't init glfw")
    }
    defer() { glfwTerminate() }
    if (glfwVulkanSupported() == 0) {
        panic("glfw reports no vulkan support")
    }
    glfwWindowHint(int(GLFW_CLIENT_API), GLFW_NO_API)
    glfwWindowHint(int(GLFW_RESIZABLE), GLFW_TRUE)
    var window = glfwCreateWindow(TEAPOT_W, TEAPOT_H, "dasVulkan tutorial 14 - Utah teapot (mesh shaders)", null, null)
    if (window == null) {
        panic("can't create window")
    }
    defer() { glfwDestroyWindow(window) }

    var ext_count = 0u
    let glfw_exts = glfwGetRequiredInstanceExtensions(unsafe(addr(ext_count)))
    var inst_exts : array<string>
    inst_exts |> reserve(int(ext_count))
    for (i in range(int(ext_count))) {
        unsafe {
            inst_exts |> push(glfw_exts[i])
        }
    }
    var inscope instance <- create_instance("dasVulkan tutorial 14 (window)", make_api_version(1u, 3u, 0u), inst_exts)
    volkLoadInstance(boost_value_to_vk(instance))

    var inscope surface <- create_surface(instance, glfwGetNativeWindow(window), glfwGetNativeDisplay())

    let phys = select_physical_device(instance)
    let gfx = select_graphics_queue_family(phys)
    if (!queue_family_supports_present(phys, gfx, surface)) {
        panic("graphics queue family does not support presentation")
    }
    if (!mesh_shader_supported(phys)) {
        panic("this device does not support VK_EXT_mesh_shader (meshShader+taskShader); cannot run tutorial 14")
    }
    // swapchain + mesh-shader extension & features in one device
    var mf : VkPhysicalDeviceMeshShaderFeaturesEXT
    mf.meshShader = 1u
    mf.taskShader = 1u
    var inscope device <- create_device(phys, gfx, ["VK_KHR_swapchain", "VK_EXT_mesh_shader"], mf)
    volkLoadDevice(boost_value_to_vk(device))
    let queue = get_device_queue(device, gfx, 0u)

    var poolci : CommandPoolCreateInfo
    poolci.queueFamilyIndex = gfx
    var inscope pool <- create_command_pool(device, poolci)

    var inscope res <- build_teapot_resources(device, phys)
    var inscope swap <- create_swapchain(device, phys, surface, TEAPOT_W, TEAPOT_H)
    var inscope sync <- create_frame_sync(device)

    while (glfwWindowShouldClose(window) == 0) {
        glfwPollEvents()
        var fbw = 0
        var fbh = 0
        glfwGetFramebufferSize(window, fbw, fbh)
        if (fbw == 0 || fbh == 0) {
            glfwWaitEvents()
            continue
        }
        if (fbw != swap.width || fbh != swap.height) {
            vkDeviceWaitIdle(boost_value_to_vk(device))
            delete swap
            swap <- create_swapchain(device, phys, surface, fbw, fbh)   // nolint:PERF030 - deleted just above
        }
        let t = float(glfwGetTime())
        update_teapot_uniforms(res, device, t, false)

        let ok = present_frame(device, queue, swap, pool, sync) $(cmd; target; _idx) {
            // record_teapot_render_pass leaves res.color.image in TRANSFER_SRC_OPTIMAL (render-pass finalLayout)
            record_teapot_render_pass(res, cmd)

            let none : VkAccessFlags
            var transfer_write : VkAccessFlags
            transfer_write.transfer_write = true
            var top : VkPipelineStageFlags
            top.top_of_pipe = true
            var xfer : VkPipelineStageFlags
            xfer.transfer = true
            var bottom : VkPipelineStageFlags
            bottom.bottom_of_pipe = true
            let dst = vk_value_to_boost(target)
            transition_image(cmd, dst, VkImageLayout.UNDEFINED, VkImageLayout.TRANSFER_DST_OPTIMAL,
                none, transfer_write, top, xfer)
            var region : VkImageBlit
            region.srcSubresource.aspectMask.color = true
            region.srcSubresource.layerCount = 1u
            region.srcOffsets[1].x = TEAPOT_W
            region.srcOffsets[1].y = TEAPOT_H
            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(res.color.image), VkImageLayout.TRANSFER_SRC_OPTIMAL,
                    target, VkImageLayout.TRANSFER_DST_OPTIMAL, 1u, addr(region), VkFilter.LINEAR)
            }
            transition_image(cmd, dst, VkImageLayout.TRANSFER_DST_OPTIMAL, VkImageLayout.PRESENT_SRC_KHR,
                transfer_write, none, xfer, bottom)
        }
        if (!ok) {
            vkDeviceWaitIdle(boost_value_to_vk(device))
            delete swap
            swap <- create_swapchain(device, phys, surface, fbw, fbh)   // nolint:PERF030 - deleted just above
        }
    }
    vkDeviceWaitIdle(boost_value_to_vk(device))
}

Running it

# the CI pixel-oracle gate (skips cleanly without VK_EXT_mesh_shader)
daslang -load_module <dasVulkan> <daslang>/dastest/dastest.das -- \
    --test <dasVulkan>/tutorials/14_teapot

# watch it live in a window (needs the glfw module + a mesh-shader GPU)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/14_teapot/window/show_teapot.das

# regenerate the recording (needs stbimage + audio + ffmpeg locally)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/14_teapot/recording/record_teapot.das

15 - Hardware Ray Tracing leaves the rasterizer behind entirely: the scene goes into acceleration structures and a VK_KHR_ray_tracing_pipeline traces one camera ray per pixel – raygen, miss, and closest-hit shaders all authored in daslang, with a real traced shadow ray per hit.