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
OpTypeImagewith theDepth=1flag (depth-comparison image) and lowers the call toOpImageSampleDrefImplicitLod. With acompareEnable=true+LINEARfilter sampler the result is the hardware-PCF average over the 2x2 footprint, returned in[0, 1](1 = fully lit, 0 = fully shadowed).create_sampler_shadowis 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_onlymakes a pass with no color attachments andfinalLayout = DEPTH_STENCIL_READ_ONLY_OPTIMAL. The same image is the depth attachment in pass 1 and thesampler2DShadowdescriptor in pass 2, with no explicit barrier between the passes.Vertex-only graphics pipeline for the shadow pass –
stageCount = 1(no fragment shader),cullMode = backflips for casters, andcolorBlendStateCreateInfo.attachmentCount = 0because 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
depthBiasEnablewithslopeFactor = 3.0. The fragment shader adds a per-pixel(1 - N.L)-scaled bias on top of that and gates onN.L < 0.05to 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
textureCompareis 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.5and 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 byabs(n)) plus Blinn-Phong specular. No textures uploaded; pure shader math.Hemisphere ambient –
lerp(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.