08 - Shadow Mapping: Two Passes, One Depth Image

04 - The Synthwave Cube (graphics + depth) introduced the depth attachment as a back-face hider; 07 - Particles: A Compute-Driven Swarm introduced two pipelines sharing one VkBuffer with a single barrier between them. This tutorial composes both: it runs two render passes per frame and uses one image in two roles — depth attachment in pass 1, sampled depth texture in pass 2. Concretely: Pass 1 renders the scene’s depth from the light’s point of view into an offscreen D32_SFLOAT image; pass 2 renders the scene from the camera, and at every fragment projects the world position into light space to ask the shadow map “is anything closer to the light here than I am?” If yes, the fragment is in shadow. The headline rails:

  • ``sampler2DShadow`` + ``textureCompare(s, uv, ref) : float`` – dasSpirv emits OpTypeImage with the Depth=1 flag (depth-comparison image) and lowers the call to OpImageSampleDrefImplicitLod. With a compareEnable=true + LINEAR filter sampler the result is the hardware-PCF average over the 2x2 footprint, returned in [0, 1] (1 = fully lit, 0 = fully shadowed). create_sampler_shadow is the host-side preset (compareOp=LESS, white border so off-shadow-map fragments default to lit).

  • Depth-only render pass + one image, two roles – the shadow map is created USAGE_DEPTH_STENCIL_ATTACHMENT | USAGE_SAMPLED; create_render_pass_depth_only makes a pass with no color attachments and finalLayout = DEPTH_STENCIL_READ_ONLY_OPTIMAL. The same image is the depth attachment in pass 1 and the sampler2DShadow descriptor in pass 2, with no explicit barrier between the passes.

  • Vertex-only graphics pipeline for the shadow passstageCount = 1 (no fragment shader), cullMode = back flips for casters, and colorBlendStateCreateInfo.attachmentCount = 0 because the pass has no color attachments. Valid Vulkan; the rasterizer drives depth from fixed-function.

  • Slope-scaled depth bias + N.L-scaled fragment bias – the pipeline enables depthBiasEnable with slopeFactor = 3.0. The fragment shader adds a per-pixel (1 - N.L)-scaled bias on top of that and gates on N.L < 0.05 to avoid the bias artifacts at grazing angles. Without these the cube self-shadows (“acne”) on every face nearly parallel to the sun.

  • 5x5 PCF soft shadows – single textureCompare is binary; averaging 25 taps over a small radius gives a soft penumbra. Each tap is already hardware-PCF’d, so the effective sample count is ~100. Silky shadow edges with minimal cost.

  • Procedural floor + tri-planar brushed cube – the floor branches by world_pos.y < 0.5 and gets a procedural checker-on-tile pattern, a fresnel-like grazing-angle sheen, and a warm bounce tint near the cube’s footprint. The cube gets a tri-planar brushed-metal micro-texture (projection blended by abs(n)) plus Blinn-Phong specular. No textures uploaded; pure shader math.

  • Hemisphere ambientlerp(ground_color, sky_color, n.y * 0.5 + 0.5). Surfaces facing up pick up warm sky, surfaces facing down pick up the cool ground bounce. Reads as if the scene is actually lit by an environment, not a single bulb.

Every line of every shader is daslang, lowered to SPIR-V at compile time.

The clip above is the headless recording: 30 seconds, 30 fps, captured into an APNG and ffmpeg-muxed with a daStrudel music bed. The camera orbits while the sun rotates faster, so the cube’s shadow sweeps over the floor and the cube’s lit-face glint rotates around the geometry. The [test] checks shadow contrast on the floor at a fixed time – the CI regression gate. See skills/recording.md. To watch the scene live on your own GPU, run the windowed viewer (see See it live below).

The shaders

Two vertex shaders share one push-constant model matrix + the shared UBO (camera view/proj + light view-projection + light direction + camera position). The shadow vertex shader has no fragment shader – the pipeline is built with stageCount = 1 against the depth-only render pass.

module shadow_tut_shaders public

require vulkan/vulkan_boost public
require vulkan/spirv_vulkan_shader public
require spirv/spirv_builtins public
require math

// ===== UBO + push constant =====

//! Shared uniforms used by both passes' vertex shaders + the main fragment shader. ``light_vp`` is
//! the directional light's view-projection (ortho * look-at); ``light_dir`` is the direction *to*
//! the light (normalised). std140-aligned: float4x4 fields land at 16-byte boundaries, float4s pad
//! the trailing scalar slots.
struct Camera {
    view       : float4x4
    proj       : float4x4
    light_vp   : float4x4
    light_dir  : float4    // xyz = direction TO the light; w unused (std140 pad)
    camera_pos : float4    // xyz = world camera position; w unused
}
var @uniform @set = 0 @binding = 0 cam : Camera

//! Per-draw model matrix. 64 bytes fits comfortably in Vulkan's guaranteed-128-byte push-constant
//! window. The shadow pass and main pass both read this -- one push covers both stage updates per
//! object (we declare the same struct, daslang emits one PC block per stage flag).
struct ObjectPush {
    model : float4x4
}
var @push_constant op : ObjectPush

// ===== shared geometry attributes =====

//! Position + world-space normal. Cube uses split vertices so each face has its own face-normal;
//! floor is a single quad with normal = (0, 1, 0). Stride = 24 bytes; the host's vertex-input
//! attribute offsets match (a_pos at +0, a_normal at +12).
var @in @location = 0 a_pos    : float3
var @in @location = 1 a_normal : float3

// ===== shadow pass =====

//! Pass 1: render every caster's depth from the LIGHT'S view. No color attachment, no fragment
//! work -- just `gl_Position = light_vp * model * pos`. After this pass the offscreen D32_SFLOAT
//! image holds, for each light-space (x, y), the depth of the closest caster.
[vulkan_vertex_shader(name="shadow_vert_spv")]
def shadow_vs {
    gl_Position = cam.light_vp * op.model * float4(a_pos, 1.0)
}

// (no fragment shader in pass 1 -- pipeline runs with `pStages = vertex-only`; rasterizer
//  produces depth without a fragment stage when there are no color attachments.)

// ===== main pass =====

var @out @location = 0 v_world_pos     : float3
var @out @location = 1 v_world_normal  : float3
var @out @location = 2 v_light_space   : float4    // pre-perspective-divide
var @out @location = 3 v_obj_pos       : float3    // object-space pos/normal for tri-planar brick
var @out @location = 4 v_obj_normal    : float3

//! Pass 2 vertex shader: pass world-space pos + normal forward to the fragment shader, plus the
//! position projected into light space for the shadow-map lookup. The perspective divide happens
//! in the fragment shader so v_light_space is varying-correct across primitive interior.
[vulkan_vertex_shader(name="main_vert_spv")]
def main_vs {
    let world4 = op.model * float4(a_pos, 1.0)
    v_world_pos = float3(world4.x, world4.y, world4.z)
    let normal_world4 = op.model * float4(a_normal, 0.0)   // model has no non-uniform scale -> safe
    v_world_normal = normalize(float3(normal_world4.x, normal_world4.y, normal_world4.z))
    v_obj_pos = a_pos
    v_obj_normal = a_normal
    v_light_space = cam.light_vp * world4
    gl_Position = cam.proj * cam.view * world4
}

// ===== shadow sampler =====

//! The depth-compare combined sampler. dasSpirv emits the OpTypeImage with the Depth=1 flag (per
//! the `sampler2DShadow` marker). textureCompare lowers to OpImageSampleDrefImplicitLod and
//! returns the scalar PCF result.
var @uniform @set = 0 @binding = 1 shadow_map : sampler2DShadow

//! Brick PBR maps (ambientCG Bricks031, CC0), sampled tri-planar on the cube. Albedo is sRGB; the
//! OpenGL-convention normal map (linear) perturbs the geometric normal for relief; the AO map (linear)
//! darkens the mortar/pits in the ambient term for depth.
var @uniform @set = 0 @binding = 2 brick_albedo : sampler2D
var @uniform @set = 0 @binding = 3 brick_normal : sampler2D
var @uniform @set = 0 @binding = 4 brick_ao     : sampler2D
var @uniform @set = 0 @binding = 5 brick_roughness : sampler2D

var @in @location = 0 f_world_pos    : float3
var @in @location = 1 f_world_normal : float3
var @in @location = 2 f_light_space  : float4
var @in @location = 3 f_obj_pos      : float3
var @in @location = 4 f_obj_normal   : float3
var @out @location = 0 frag_color    : float4

//! Pass 2 fragment shader. Four lighting rails, each written up in doc/source/tutorials/08_shadow.rst:
//! 5x5 PCF soft shadows, hemisphere ambient, procedural polished tiles on the floor, Blinn-Phong specular
//! on the cube. Material branch is `f_world_pos.y < 0.5` — floor sits at y = 0, cube hovers at y ~ 1-2.

let SHADOW_MAP_PX = 1024.0

def private pcf_shadow(uv : float2; ref : float) : float {
    // 5x5 grid of hardware-PCF'd taps; each textureCompare already PCFs the 2x2 around the tap.
    let texel = 1.0 / SHADOW_MAP_PX
    var sum = 0.0
    for (j in range(-2, 3)) {
        for (i in range(-2, 3)) {
            let off = float2(float(i), float(j)) * texel
            sum += textureCompare(shadow_map, uv + off, ref)
        }
    }
    return sum * (1.0 / 25.0)
}

// Object-space tiling for the tri-planar brick projection. The cube spans [-0.5, 0.5] in object
// space, so scale 1.0 maps one full brick tile across each face (legible courses + mortar).
let BRICK_SCALE = 1.0

// Bricks031 relief is shallow; boost the tangent xy so the brick reads bumpy even face-on (the cube's
// top face, seen like a small floor, is otherwise nearly flat). Decode + boost + renormalize.
let BRICK_NORMAL_STRENGTH = 3.0
def private unpack_brick_normal(rgb : float3) : float3 {
    let t = rgb * 2.0 - float3(1.0, 1.0, 1.0)
    return normalize(float3(t.x * BRICK_NORMAL_STRENGTH, t.y * BRICK_NORMAL_STRENGTH, t.z))
}

//! Tri-planar brick albedo: sample the brick color on each axis-aligned plane and blend by the
//! normal weights `wn`. Replaces the old `sin(uv*84)` micro-texture that aliased into banding.
def private tri_albedo(p : float3; wn : float3) : float3 {
    let ax = texture(brick_albedo, float2(p.z, p.y) * BRICK_SCALE).rgb
    let ay = texture(brick_albedo, float2(p.x, p.z) * BRICK_SCALE).rgb
    let az = texture(brick_albedo, float2(p.x, p.y) * BRICK_SCALE).rgb
    return ax * wn.x + ay * wn.y + az * wn.z
}

//! Tri-planar normal map (Golus "whiteout" blend): unpack the tangent-space brick normal on each
//! plane, reorient it into world space using the geometric normal `n`, and blend by `wn`. Gives the
//! bricks real relief under the moving sun without per-vertex tangents.
def private tri_normal(p : float3; n : float3; wn : float3) : float3 {
    let tx = unpack_brick_normal(texture(brick_normal, float2(p.z, p.y) * BRICK_SCALE).rgb)
    let ty = unpack_brick_normal(texture(brick_normal, float2(p.x, p.z) * BRICK_SCALE).rgb)
    let tz = unpack_brick_normal(texture(brick_normal, float2(p.x, p.y) * BRICK_SCALE).rgb)
    // reorient each plane's tangent normal into world space (whiteout), then triblend
    let cx = float3(abs(tx.z) * n.x, tx.y + n.y, tx.x + n.z)   // X plane -> .zyx
    let cy = float3(ty.x + n.x, abs(ty.z) * n.y, ty.y + n.z)   // Y plane -> .xzy
    let cz = float3(tz.x + n.x, tz.y + n.y, abs(tz.z) * n.z)   // Z plane -> .xyz
    return normalize(cx * wn.x + cy * wn.y + cz * wn.z)
}

//! Tri-planar AO scalar (grayscale, sampled from .r), blended by `wn`. 1 = open, 0 = occluded crevice.
def private tri_ao(p : float3; wn : float3) : float {
    let ax = texture(brick_ao, float2(p.z, p.y) * BRICK_SCALE).x
    let ay = texture(brick_ao, float2(p.x, p.z) * BRICK_SCALE).x
    let az = texture(brick_ao, float2(p.x, p.y) * BRICK_SCALE).x
    return ax * wn.x + ay * wn.y + az * wn.z
}

// Tri-planar roughness scalar (grayscale .r), blended by `wn`. Mortar rough, brick faces smoother.
def private tri_rough(p : float3; wn : float3) : float {
    let ax = texture(brick_roughness, float2(p.z, p.y) * BRICK_SCALE).x
    let ay = texture(brick_roughness, float2(p.x, p.z) * BRICK_SCALE).x
    let az = texture(brick_roughness, float2(p.x, p.y) * BRICK_SCALE).x
    return ax * wn.x + ay * wn.y + az * wn.z
}

[vulkan_fragment_shader(name="main_frag_spv")]
def main_fs {   // nolint:STYLE038 - shader body - phases are pipeline-coupled
    let n = normalize(f_world_normal)
    let l = normalize(float3(cam.light_dir.x, cam.light_dir.y, cam.light_dir.z))
    let ndotl = max(dot(n, l), 0.0)
    let view_dir = normalize(float3(cam.camera_pos.x - f_world_pos.x,
                                    cam.camera_pos.y - f_world_pos.y,
                                    cam.camera_pos.z - f_world_pos.z))

    // Light-space NDC = clip.xyz / clip.w; Vulkan NDC.z is already in [0,1] so the depth ref is
    // used as-is, but xy is in [-1, 1] and the shadow-map UV is [0, 1].
    let w = max(f_light_space.w, 0.0001)
    let ndc = float3(f_light_space.x / w, f_light_space.y / w, f_light_space.z / w)
    let shadow_uv = float2(ndc.x * 0.5 + 0.5, ndc.y * 0.5 + 0.5)
    let ref_depth = clamp(ndc.z, 0.0, 1.0)

    // N·L-scaled fragment-side bias on top of the pipeline's slope-scaled bias. Grazing faces
    // (low N·L) push the reference depth further out so the comparison stops mis-firing on the
    // lit side of the cube, with a floor so even N·L≈1 surfaces get a small bias.
    let frag_bias = max(0.0008 * (1.0 - ndotl), 0.00015)
    let lit_ref = clamp(ref_depth - frag_bias, 0.0, 1.0)
    var lit = pcf_shadow(shadow_uv, lit_ref)
    // Anything facing significantly away from the light is shadow regardless of the depth test;
    // bypassing the lookup avoids the bias artifacts at N·L ≈ 0 where small numerical noise flips
    // the depth comparison.
    if (ndotl < 0.05) { lit = 0.0 }

    // Hemisphere ambient: warm sky overhead, cool dim under. n.y -> [-1, 1]; mix on (n.y+1)/2.
    let sky_color = float3(0.22, 0.26, 0.36)
    let ground_color = float3(0.08, 0.06, 0.05)
    let amb_t = n.y * 0.5 + 0.5
    let ambient = lerp(ground_color, sky_color, float3(amb_t, amb_t, amb_t))

    // Warm sun.
    let sun = float3(1.25, 1.05, 0.78)

    // Material branch: y < 0.5 -> floor (procedural tile + polish); else cube (Blinn-Phong).
    if (f_world_pos.y < 0.5) {
        // ===== floor =====
        // 1-unit tile, with a tighter inner checker for visual interest.
        let gx = f_world_pos.x * 0.5 + 100.0   // +100 keeps fract positive on negative coords
        let gz = f_world_pos.z * 0.5 + 100.0
        let cell_x = floor(gx)
        let cell_z = floor(gz)
        let checker = (sin(cell_x * 12.9898 + cell_z * 78.233) * 0.5 + 0.5) > 0.5 ? 1.0 : 0.0
        let tile_base = lerp(float3(0.34, 0.30, 0.26), float3(0.20, 0.18, 0.16),
                             float3(checker, checker, checker))

        // Tile edge lines (darker grout).
        let fx = gx - cell_x
        let fz = gz - cell_z
        let edge_x = smoothstep(0.0, 0.03, fx) * smoothstep(0.0, 0.03, 1.0 - fx)
        let edge_z = smoothstep(0.0, 0.03, fz) * smoothstep(0.0, 0.03, 1.0 - fz)
        let edge = edge_x * edge_z                    // 1 in interior, 0 on edges
        let tile_albedo = tile_base * (0.55 + edge * 0.45)

        // Fake "reflection" pickup: warm tint near the cube's XZ footprint — the floor reads as if
        // it's catching a touch of the cube's color. Pure shader math, no second pass.
        let cube_xz_dist = sqrt(f_world_pos.x * f_world_pos.x + f_world_pos.z * f_world_pos.z)
        let bounce = exp(-cube_xz_dist * 1.2) * 0.35
        let bounce_tint = float3(0.95, 0.55, 0.30) * bounce

        // Fresnel-like grazing-angle brighten — polished/wet-floor look.
        let view_dot = max(dot(n, view_dir), 0.0)
        let fresnel = pow(1.0 - view_dot, 4.0)
        let sheen = float3(0.85, 0.95, 1.10) * fresnel * 0.45

        let diffuse = sun * ndotl * lit
        let col = ambient * tile_albedo + tile_albedo * diffuse * 0.9 + bounce_tint + sheen
        frag_color = float4(col.x, col.y, col.z, 1.0)
    } else {
        // ===== cube =====

        // OBJECT-space tri-planar brick: the cube is rotated, so sampling in object space keeps the
        // courses aligned to the faces (world-space tri-planar would swirl across the seams). The
        // perturbed object normal is rotated into world space via the model matrix for lighting.
        let on = normalize(f_obj_normal)
        let abs_on = float3(abs(on.x), abs(on.y), abs(on.z))
        let wsum = max(abs_on.x + abs_on.y + abs_on.z, 0.001)
        let wn = abs_on / float3(wsum, wsum, wsum)
        let cube_albedo = tri_albedo(f_obj_pos, wn)
        let obj_bn = tri_normal(f_obj_pos, on, wn)
        let bn4 = op.model * float4(obj_bn, 0.0)
        let bn = normalize(float3(bn4.x, bn4.y, bn4.z))

        // Diffuse + Blinn-Phong off the PERTURBED world normal (brick relief); shadow `lit` stays
        // from the geometric depth pass. Brick is matte: wide specular lobe, low intensity.
        let ao = tri_ao(f_obj_pos, wn)
        let rough = tri_rough(f_obj_pos, wn)
        let bndotl = max(dot(bn, l), 0.0)
        let h = normalize(l + view_dir)
        let ndoth = max(dot(bn, h), 0.0)
        // roughness drives the highlight: smoother brick faces -> tighter, brighter spec than rough mortar
        let shininess = 8.0 + 56.0 * (1.0 - rough)
        let spec = pow(ndoth, shininess) * lit
        let diffuse = sun * bndotl * lit
        let specular = sun * spec * (1.0 - rough) * 0.5
        // AO darkens the ambient (indirect) term in the mortar/pits; a light cavity term on the direct
        // diffuse keeps the recesses reading as recessed even under the sun.
        let col = ambient * cube_albedo * ao + cube_albedo * diffuse * (0.65 + 0.35 * ao) + specular
        frag_color = float4(col.x, col.y, col.z, 1.0)
    }
}

The render (headless)

The host builds two render passes, two pipelines (shadow + main), the shadow map (D32_SFLOAT, attachment + sampled), one shared descriptor set (UBO + shadow sampler), and the geometry buffers for the cube + floor.

def public build_shadow_resources(device : Device; phys : VkPhysicalDevice;   // nolint:STYLE038 - flat one-call-per-item Vulkan setup run
                                  queue : VkQueue; pool : CommandPool) : ShadowResources {
    var inscope color <- build_offscreen_target(device, phys, SCENE_W, SCENE_H, SCENE_COLOR_FMT)
    var inscope depth <- build_offscreen_depth(device, phys, SCENE_W, SCENE_H, SCENE_DEPTH_FMT)

    // Shadow map: same allocator as the main depth, but with `sampled` so the main fragment can
    // bind it as a sampler2DShadow descriptor.
    var sm_usage : VkImageUsageFlags
    sm_usage.sampled = true
    var inscope shadow_map <- build_offscreen_depth(device, phys, SHADOW_W, SHADOW_H, SHADOW_FMT, sm_usage)

    var inscope rp_color  <- create_render_pass_color_depth(device, SCENE_COLOR_FMT, SCENE_DEPTH_FMT)
    var inscope rp_shadow <- create_render_pass_depth_only(device, SHADOW_FMT)

    var inscope fb_color <- create_framebuffer(device, FramebufferCreateInfo(
        renderPass = weak_copy(rp_color),
        pAttachments <- [weak_copy(color.view), weak_copy(depth.view)],
        width = uint(SCENE_W), height = uint(SCENE_H), layers = 1u))
    var inscope fb_shadow <- create_framebuffer(device, FramebufferCreateInfo(
        renderPass = weak_copy(rp_shadow),
        pAttachments <- [weak_copy(shadow_map.view)],
        width = uint(SHADOW_W), height = uint(SHADOW_H), layers = 1u))

    var vb_usage : VkBufferUsageFlags
    vb_usage.vertex_buffer = true
    var verts_copy := cube_vertices
    var inscope cube_vb <- create_host_buffer_from_bytes(device, phys, vb_usage, verts_copy)
    delete verts_copy

    var ib_usage : VkBufferUsageFlags
    ib_usage.index_buffer = true
    var ci_copy := cube_indices
    var inscope cube_ib <- create_host_buffer_from_bytes(device, phys, ib_usage, ci_copy)
    delete ci_copy

    var fv_copy := floor_vertices
    var inscope floor_vb <- create_host_buffer_from_bytes(device, phys, vb_usage, fv_copy)
    delete fv_copy

    var fi_copy := floor_indices
    var inscope floor_ib <- create_host_buffer_from_bytes(device, phys, ib_usage, fi_copy)
    delete fi_copy

    var ubo_usage : VkBufferUsageFlags
    ubo_usage.uniform_buffer = true
    var inscope ubo <- create_host_buffer(device, phys, UBO_SIZE, ubo_usage)

    var inscope shadow_sampler <- create_sampler_shadow(device)

    // Brick PBR maps (ambientCG Bricks031, CC0) from tutorials/_assets/brick, tri-planar on the cube.
    // tutorial_asset_dir resolves relative to the (absolutely-mounted) vulkan_assets module, so it
    // works from any CWD / CI checkout.
    let brick_dir = tutorial_asset_dir("brick")
    var inscope brick_albedo <- load_texture_2d(device, phys, queue, pool,
        path_join(brick_dir, "Bricks031_1K-JPG_Color.jpg"), true)        // sRGB albedo
    var inscope brick_normal <- load_texture_2d(device, phys, queue, pool,
        path_join(brick_dir, "Bricks031_1K-JPG_NormalGL.jpg"), false)    // linear normal
    var inscope brick_ao <- load_texture_2d(device, phys, queue, pool,
        path_join(brick_dir, "Bricks031_1K-JPG_AmbientOcclusion.jpg"), false)   // linear AO
    var inscope brick_roughness <- load_texture_2d(device, phys, queue, pool,
        path_join(brick_dir, "Bricks031_1K-JPG_Roughness.jpg"), false)          // linear roughness
    let tsci = SamplerCreateInfo(
        magFilter = VkFilter.LINEAR,
        minFilter = VkFilter.LINEAR,
        mipmapMode = VkSamplerMipmapMode.LINEAR,
        addressModeU = VkSamplerAddressMode.REPEAT,
        addressModeV = VkSamplerAddressMode.REPEAT,
        addressModeW = VkSamplerAddressMode.REPEAT,
        maxLod = 16.0f)                 // sample the full mip chain (kills minification moiré)
    var inscope tex_sampler <- create_sampler(device, tsci)

    // Descriptors: set 0 binding 0 = UBO, binding 1 = sampler2DShadow, binding 2/3 = brick maps. We reflect off the MAIN
    // fragment shader (the only one that sees both bindings) -- the shadow vertex doesn't see the
    // sampler, but the layout it ships with covers a strict subset.
    var reflections <- [decode_reflection(main_vert_spv_reflect), decode_reflection(main_frag_spv_reflect)]
    var inscope set_layouts <- build_descriptor_set_layouts(device, reflections)

    var dpci : DescriptorPoolCreateInfo
    dpci.maxSets = 1u
    let ps0 = DescriptorPoolSize(type_ = VkDescriptorType.UNIFORM_BUFFER, descriptorCount = 1u)
    // shadow_map + brick_albedo + brick_normal + brick_ao + brick_roughness
    let ps1 = DescriptorPoolSize(type_ = VkDescriptorType.COMBINED_IMAGE_SAMPLER, descriptorCount = 5u)
    dpci.pPoolSizes <- [ps0, ps1]
    var inscope desc_pool <- create_descriptor_pool(device, dpci)

    var dsai = VkDescriptorSetAllocateInfo()
    dsai.descriptorPool = boost_value_to_vk(desc_pool)
    dsai.descriptorSetCount = 1u
    var raw_set_layout = boost_value_to_vk(set_layouts[0])
    var raw_set : VkDescriptorSet
    unsafe {
        dsai.pSetLayouts = addr(raw_set_layout)
        vk_check(vkAllocateDescriptorSets(boost_value_to_vk(device), dsai, addr(raw_set)), null)
    }

    var writes : array<WriteDescriptorSet>
    var w0 = WriteDescriptorSet(dstSet = vk_value_to_boost(raw_set), dstBinding = 0u,
        descriptorType = VkDescriptorType.UNIFORM_BUFFER, descriptorCount = 1u)
    let ubo_info = DescriptorBufferInfo(buffer = weak_copy(ubo.buffer), range_ = UBO_SIZE)
    w0.pBufferInfo |> push(ubo_info)
    writes |> emplace(w0)

    var w1 = WriteDescriptorSet(dstSet = vk_value_to_boost(raw_set), dstBinding = 1u,
        descriptorType = VkDescriptorType.COMBINED_IMAGE_SAMPLER, descriptorCount = 1u)
    let shadow_info = DescriptorImageInfo(sampler = weak_copy(shadow_sampler),
        imageView = weak_copy(shadow_map.view), imageLayout = VkImageLayout.DEPTH_STENCIL_READ_ONLY_OPTIMAL)
    w1.pImageInfo |> push(shadow_info)
    writes |> emplace(w1)

    var w2 = WriteDescriptorSet(dstSet = vk_value_to_boost(raw_set), dstBinding = 2u,
        descriptorType = VkDescriptorType.COMBINED_IMAGE_SAMPLER, descriptorCount = 1u)
    let albedo_info = DescriptorImageInfo(sampler = weak_copy(tex_sampler),
        imageView = weak_copy(brick_albedo.view), imageLayout = VkImageLayout.SHADER_READ_ONLY_OPTIMAL)
    w2.pImageInfo |> push(albedo_info)
    writes |> emplace(w2)

    var w3 = WriteDescriptorSet(dstSet = vk_value_to_boost(raw_set), dstBinding = 3u,
        descriptorType = VkDescriptorType.COMBINED_IMAGE_SAMPLER, descriptorCount = 1u)
    let normal_info = DescriptorImageInfo(sampler = weak_copy(tex_sampler),
        imageView = weak_copy(brick_normal.view), imageLayout = VkImageLayout.SHADER_READ_ONLY_OPTIMAL)
    w3.pImageInfo |> push(normal_info)
    writes |> emplace(w3)

    var w4 = WriteDescriptorSet(dstSet = vk_value_to_boost(raw_set), dstBinding = 4u,
        descriptorType = VkDescriptorType.COMBINED_IMAGE_SAMPLER, descriptorCount = 1u)
    let ao_info = DescriptorImageInfo(sampler = weak_copy(tex_sampler),
        imageView = weak_copy(brick_ao.view), imageLayout = VkImageLayout.SHADER_READ_ONLY_OPTIMAL)
    w4.pImageInfo |> push(ao_info)
    writes |> emplace(w4)

    var w5 = WriteDescriptorSet(dstSet = vk_value_to_boost(raw_set), dstBinding = 5u,
        descriptorType = VkDescriptorType.COMBINED_IMAGE_SAMPLER, descriptorCount = 1u)
    let rough_info = DescriptorImageInfo(sampler = weak_copy(tex_sampler),
        imageView = weak_copy(brick_roughness.view), imageLayout = VkImageLayout.SHADER_READ_ONLY_OPTIMAL)
    w5.pImageInfo |> push(rough_info)
    writes |> emplace(w5)

    let no_copies : array<CopyDescriptorSet>
    update_descriptor_sets(device, writes, no_copies)

    var inscope pipe_layout <- build_pipeline_layout(device, set_layouts, reflections)
    delete reflections

    // Shaders are the compile-time-emitted SPIR-V globals from shadow_tut_shaders.
    var inscope shadow_vert <- create_shader_module(device, shadow_vert_spv)
    var inscope main_vert   <- create_shader_module(device, main_vert_spv)
    var inscope main_frag   <- create_shader_module(device, main_frag_spv)

    var inscope pipeline_shadow <- build_shadow_pipeline(device, rp_shadow, pipe_layout, shadow_vert)
    var inscope pipeline_main   <- build_main_pipeline(device, rp_color, pipe_layout, main_vert, main_frag)

    let buf_size = uint64(SCENE_W) * uint64(SCENE_H) * 4ul
    var inscope readback <- create_host_buffer(device, phys, buf_size)

    return <- ShadowResources(
        color <- color,
        depth <- depth,
        shadow_map <- shadow_map,
        rp_color <- rp_color,
        rp_shadow <- rp_shadow,
        fb_color <- fb_color,
        fb_shadow <- fb_shadow,
        cube_vb <- cube_vb,
        cube_ib <- cube_ib,
        floor_vb <- floor_vb,
        floor_ib <- floor_ib,
        ubo <- ubo,
        shadow_sampler <- shadow_sampler,
        tex_sampler <- tex_sampler,
        brick_albedo <- brick_albedo,
        brick_normal <- brick_normal,
        brick_ao <- brick_ao,
        brick_roughness <- brick_roughness,
        set_layouts <- set_layouts,
        desc_pool <- desc_pool,
        desc_set = raw_set,
        pipe_layout <- pipe_layout,
        pipeline_shadow <- pipeline_shadow,
        pipeline_main <- pipeline_main,
        readback <- readback,
        buf_size = buf_size)
}

record_shadow_render_pass is the per-frame work: pass 1 records cube + floor depth into the shadow map; pass 2 records cube + floor color, with the fragment reading the shadow map.

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

    // ===== Pass 1: shadow map =====
    var shadow_clears <- [clear_depth(1.0)]
    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_bind_index_buffer(cmd, weak_copy(res.cube_ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.cube_vb.buffer))
        // Cube hovers at (0, 1.5, 0), rotates so the shadow has shape.
        var cube_model = translate_m4(float3(0.0, 1.5, 0.0)) * rotate_y_m4(0.6)
        var vstage : VkShaderStageFlags
        vstage.vertex = true
        vstage.fragment = true   // main_fs reads op.model to rotate the brick normal into world space
        cmd_push_constants(cmd, res.pipe_layout, vstage, 0u, cube_model)
        cmd_draw_indexed(cmd, uint(CUBE_INDEX_COUNT), 1u, 0u, 0, 0u)

        // Floor scaled 5x5, lying flat at y=0.
        cmd_bind_index_buffer(cmd, weak_copy(res.floor_ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.floor_vb.buffer))
        var floor_model = scale_m4(float3(5.0, 1.0, 5.0))
        cmd_push_constants(cmd, res.pipe_layout, vstage, 0u, floor_model)
        cmd_draw_indexed(cmd, uint(FLOOR_INDEX_COUNT), 1u, 0u, 0, 0u)
    }
    delete shadow_clears

    // ===== Pass 2: main color, sampling the shadow map =====
    var clears <- [clear_color(0.04f, 0.04f, 0.08f, 1.0f), clear_depth(1.0)]
    record_render_pass(cmd, res.rp_color, res.fb_color,
                       full_area(SCENE_W, SCENE_H), clears) {
        cmd_bind_pipeline(cmd, res.pipeline_main)
        cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.pipe_layout, 0u, sets, no_dyn)
        cmd_bind_index_buffer(cmd, weak_copy(res.cube_ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.cube_vb.buffer))
        var cube_model = translate_m4(float3(0.0, 1.5, 0.0)) * rotate_y_m4(0.6)
        var vstage : VkShaderStageFlags
        vstage.vertex = true
        vstage.fragment = true   // main_fs reads op.model to rotate the brick normal into world space
        cmd_push_constants(cmd, res.pipe_layout, vstage, 0u, cube_model)
        cmd_draw_indexed(cmd, uint(CUBE_INDEX_COUNT), 1u, 0u, 0, 0u)

        cmd_bind_index_buffer(cmd, weak_copy(res.floor_ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.floor_vb.buffer))
        var floor_model = scale_m4(float3(5.0, 1.0, 5.0))
        cmd_push_constants(cmd, res.pipe_layout, vstage, 0u, floor_model)
        cmd_draw_indexed(cmd, uint(FLOOR_INDEX_COUNT), 1u, 0u, 0, 0u)
    }
    delete clears
}

//! Per-frame work: update UBO, record both passes, copy color → readback, clone RGBA8.
def public render_shadow_frame(var ctx : ShadowContext; time, camera_t : float) : array<uint8> {
    update_shadow_uniforms(ctx.res, ctx.device, time, camera_t)
    var pixels : array<uint8>
    run_cmd_sync(ctx.device, ctx.pool, ctx.queue) $(cmd) {
        record_shadow_render_pass(ctx.res, cmd)
        copy_image_to_buffer(cmd, ctx.res.color.image, ctx.res.readback, SCENE_W, SCENE_H)
    }
    map_memory_to_array(ctx.device, ctx.res.readback.memory, ctx.res.buf_size) $(m) {
        pixels := m
    }
    return <- pixels
}

//! One-shot: build a context and render one frame.
def public render_shadow_scene(time, camera_t : float) : array<uint8> {
    var inscope ctx <- build_shadow_context()
    return <- render_shadow_frame(ctx, time, camera_t)
}

Self-verifying

The test is the CI regression gate (lavapipe in CI, real GPU locally). It renders one frame at fixed time = 0 + camera_t = 0.5 and checks the floor has a clear shadowed pixel and a clear lit pixel – shadow contrast on the floor is the load-bearing visual element.

[test]
def test_shadow_oracle(t : T?) {
    t |> run("shadow_tut: shadow visible on floor + cube lit, sky clear") <| @(t : T?) {
        var pixels <- render_shadow_scene(0.0f, 0.5f)

        // Sky pixel (top-left corner) — clear color (10/10/20 dark blue).
        let sky = px(pixels, 16, 16)
        t |> success(luminance(sky) < 80, "sky is dark clear color")

        // Find a SHADOWED floor point. At time=0 (sun_yaw = 0) the sun is roughly along +x, so the
        // shadow falls toward -x of the cube, and the floor covers a large area near the frame bottom.
        // Sample around where the shadow is expected and require at least one dark hit.
        var any_shadow = false
        var min_floor = 255
        var max_floor = 0
        for (j in range(int(float(SCENE_H) * 0.62), int(float(SCENE_H) * 0.85))) {
            for (i in range(SCENE_W / 4, 3 * SCENE_W / 4)) {
                let p = px(pixels, i, j)
                let l = luminance(p)
                // Skip the cube (very saturated) by demanding ~grey-ish.
                if (abs(p.x - p.y) < 60 && abs(p.y - p.z) < 60) {
                    if (l < min_floor) { min_floor = l }
                    if (l > max_floor) { max_floor = l }
                    if (l < 140) { any_shadow = true }
                }
            }
        }
        t |> success(any_shadow, "found at least one shadowed floor pixel (luminance < 140)")
        t |> success(max_floor > 100, "found at least one lit floor pixel (luminance > 100, max = {max_floor})")
        t |> success(max_floor - min_floor > 40, "floor has shadow contrast: max {max_floor} - min {min_floor} > 40")

        delete pixels
    }
}

See it live

window/show_shadow.das opens a GLFW window with a Vulkan swapchain and runs both render passes every frame with time derived from wall-clock. It owns its own instance (with surface extensions) + device (with VK_KHR_swapchain), then calls build_shadow_resources to share the two-pass setup with the headless oracle. Each frame it runs update_shadow_uniforms + record_shadow_render_pass into the present command buffer, then blits the color attachment onto the swapchain image.

require glfw/glfw_boost
require vulkan
require vulkan/vulkan_boost
require vulkan/vulkan_window
require ../shadow_tut.das
require daslib/defer
require math

[export]
def main {   // nolint:STYLE038 - flat viewer lifecycle scaffold
    if (volkInitialize() != 0) {
        panic("no vulkan loader")
    }
    glfwInitVulkanLoader(vk_get_instance_proc_addr())
    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(SCENE_W, SCENE_H, "dasVulkan tutorial 08 - shadow mapping (blit)", 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 08 (window)", make_api_version(1u, 1u, 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")
    }
    var inscope device <- create_device(phys, gfx, ["VK_KHR_swapchain"])
    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_shadow_resources(device, phys, queue, pool)
    var inscope swap <- create_swapchain(device, phys, surface, SCENE_W, SCENE_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())
        // 24-second camera orbit; sun rotates at its own rate inside update_shadow_uniforms.
        let raw_cam = t * 0.042f
        let camera_t = (raw_cam - floor(raw_cam)) * 2.0f * PI
        update_shadow_uniforms(res, device, t, camera_t)

        let ok = present_frame(device, queue, swap, pool, sync) $(cmd; target; _idx) {
            record_shadow_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 = SCENE_W
            region.srcOffsets[1].y = SCENE_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 (lavapipe in CI, real GPU locally)
daslang -load_module <dasVulkan> <daslang>/dastest/dastest.das -- \
    --test <dasVulkan>/tutorials/08_shadow

# watch it live in a window (needs the glfw module + a display)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/08_shadow/window/show_shadow.das

# regenerate the recording (needs stbimage + audio + ffmpeg locally)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/08_shadow/recording/record_shadow.das

Next

09 - MSAA + Dynamic Rendering: No Render Pass, Smooth Edges drops VkRenderPass and VkFramebuffer entirely in favour of Vulkan 1.3 dynamic rendering (cmd_begin_rendering + VkPipelineRenderingCreateInfo), and turns on 4× MSAA with an auto-resolve attachment so the cube’s silhouette stops being jaggy. A runtime indicator strip + AUTO-toggle lets the recording show 4× MSAA and 1× rasterization side-by-side in one clip.