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 = 64vertices and2*TESS^2 = 98triangles, 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.