10 - Deferred Shading: Putting It All Together
Tutorials 1-9 each introduced one rail (triangle, mandelbrot SDF, cube, instancing, skybox, particles, shadow map, MSAA + dynamic rendering). This one is the payoff scene: a cube on a brick floor, lit by ONE shadowed directional sun plus THREE orbiting coloured point lights, composed via deferred shading – all in pure daslang lowered to SPIR-V at compile time.
The headline rails:
G-buffer + multi-subpass render pass. ONE render pass with SIX attachments and THREE subpasses. Subpass 0 (the G-buffer pass) writes THREE colour outputs simultaneously – albedo (RGBA8 with specular intensity in alpha), perturbed world-space normal (RGBA16F with Blinn shininess in alpha), and world position (RGBA16F with material tag in alpha) – plus depth. Subpass 1 (SSAO) reads the G-buffer normal + world-pos as
subpassInputand writes a single-channel R8 AO factor. Subpass 2 (lighting) reads ALL FOUR G-buffer attachments assubpassInputand writes the final composed colour. Two explicitVkSubpassDependencyarcs order the writes-then-reads. The lighting pass additionally samples the shadow map and reads a UBO of point lights.MRT (multiple render targets) in one shader. The G-buffer fragment shader declares three
@out @locationoutputs and writes them all in onemain– the dasSpirv emitter rail PR #3195 enables this. The three colour-blend attachments on the pipeline match the three render pass attachment indices.``subpassInput`` + ``subpassLoad``. The SSAO + lighting fragment shaders declare opaque
subpassInputglobals and sample them withsubpassLoad– the dasSpirv emitter rail PR #3196 enables this. EachsubpassInputlowers toOpTypeImage Dim=SubpassData(no format), decorated withInputAttachmentIndex, requiring theInputAttachmentSPIR-V capability. The descriptor type isVK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT.Shadow map from :doc:`08_shadow`. The shadow pass + sampler2DShadow + 5×5 PCF rail is lifted verbatim from tutorial 8. The 1024² shadow map is rendered ONCE per frame at the start, transitions to
DEPTH_STENCIL_READ_ONLY_OPTIMALat end-of-pass, and is sampled in the lighting subpass via a regularCOMBINED_IMAGE_SAMPLERdescriptor.Three orbiting coloured point lights. Packed in the scene UBO (six float4 fields: position+range, colour+falloff exponent, ×3). The lighting fragment evaluates them in a small helper function with a branchless attenuation curve. The point lights are the WHY of deferred: per-pixel cost is O(lights), independent of scene geometry. Adding more lights doesn’t make the cube fragments any more expensive.
Per-fragment normal mapping (derivative-based TBN). No vertex tangents required. The G-buffer fragment computes a tangent basis on the fly via
dFdx/dFdyof world-position and procedural-UV, then rotates a tangent-space bump-derived normal into world space. The result feeds the G-bufferg_normalattachment and propagates through SSAO + lighting – the floor’s brick-mortar grooves and the cube’s brushed micro-grain emerge from one procedural bump field per material.6-mode debug toggle. A push constant on the lighting fragment flips between the composed lit result (mode 0) and the raw G-buffer visualisations (1 = albedo, 2 = normal, 3 = world-pos, 4 = SSAO, 5 = lighting-only – accumulated light contribution without the albedo multiply). The window viewer’s M-key cycles through 7 toggle states (AUTO + 6 forced); the recording auto-rotates every 2.5 seconds.
Curvature-based AO (a noted simplification). True screen-space AO needs to sample neighbouring fragments, which input attachments cannot do – they only see the current fragment. The SSAO subpass uses a
|n · view|curvature darkening as a stand-in. The pedagogical clarity is the multi-subpass + input-attachment rail; a production SSAO would attach the G-buffer as sampled images alongside, sampling a hemisphere of offset depth samples around the world position.
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 16-row mode strip at the top cycles every 2.5 s – watch the scene
decompose itself into albedo, normal, world-pos, SSAO, and lighting-only,
then back to the lit composition. The [test] checks the structural
deferred signal at a fixed frame – the CI regression gate. To watch the
same scene live on your own GPU with M-key control, run the windowed
viewer (see See it live below).
The shaders
Seven shader entry points across four pairs: shadow_vs (depth-only),
gbuffer_vs + gbuffer_fs (MRT writes), ssao_vs + ssao_fs
(fullscreen tri + curvature AO), lighting_vs + lighting_fs
(fullscreen tri + subpass-input compose). All share one xform UBO
(view/proj/light_vp) for the geometry passes and one scene UBO
(camera_pos + light_dir + light_vp + three packed point lights) for the
post-geometry passes.
module deferred_tut_shaders public
require vulkan/vulkan_boost public
require vulkan/spirv_vulkan_shader public
require spirv/spirv_builtins public
require math
let NUM_LIGHTS = 3
// ===== shared UBO data structures =====
// Point lights are unrolled into pos0/col0..pos2/col2 float4 fields rather than a struct-array,
// because dasSpirv's compute_block_layout currently rejects fixed-array and nested-struct fields
// in @uniform/@push_constant blocks (only scalars/vectors/matrices on the leaves).
struct XformCam {
view : float4x4
proj : float4x4
light_vp : float4x4
}
var @uniform @set = 0 @binding = 0 xform : XformCam
struct SceneCam {
light_vp : float4x4
light_dir : float4 // xyz = direction TO the sun (normalised); w unused
camera_pos : float4 // xyz = world camera position; w = time (seconds)
// Point lights: each takes two float4 -- pos (xyz=pos, w=range) + col (xyz=color, w=falloff).
pos0 : float4
col0 : float4
pos1 : float4
col1 : float4
pos2 : float4
col2 : float4
}
var @uniform @set = 1 @binding = 1 scene : SceneCam
//! Per-draw push constant: 64-byte model matrix + 4-byte material tag (0 = cube, 1 = floor).
//! Material drives the gbuffer fragment's albedo+normal branch and is mirrored into the G-buffer
//! world-pos.w so the lighting pass can read it without a 5th attachment.
struct ObjectPush {
model : float4x4
material : int
}
var @push_constant op : ObjectPush
// ===== shared vertex inputs (cube + floor share pos+normal, stride 24, no UV) =====
var @in @location = 0 a_pos : float3
var @in @location = 1 a_normal : float3
var @in @location = 2 a_uv : float2
// ===== shadow pass =====
//! Pass 1: depth-only render from the sun's POV. No color attachment, no fragment shader -- just
//! gl_Position = light_vp * model * pos. After this pass the D32_SFLOAT image holds, per light-
//! space (x, y), the depth of the closest caster.
[vulkan_vertex_shader(name="shadow_vert_spv")]
def shadow_vs {
gl_Position = xform.light_vp * op.model * float4(a_pos, 1.0)
}
// ===== G-buffer pass =====
var @out @location = 0 v_world_pos : float3
var @out @location = 1 v_world_normal : float3
var @flat @out @location = 2 v_material : int
var @out @location = 3 v_uv : float2
[vulkan_vertex_shader(name="gbuffer_vert_spv")]
def gbuffer_vs {
let world4 = op.model * float4(a_pos, 1.0)
v_world_pos = float3(world4.x, world4.y, world4.z)
let n_world4 = op.model * float4(a_normal, 0.0)
v_world_normal = normalize(float3(n_world4.x, n_world4.y, n_world4.z))
v_material = op.material
v_uv = a_uv
gl_Position = xform.proj * xform.view * world4
}
// Cat statue PBR maps (concrete_cat_statue, CC0): diff = sRGB albedo, nor_gl = OpenGL-convention
// tangent normal, arm = AO(r) / Roughness(g) / Metallic(b). UV-mapped (the cat carries OBJ UVs).
var @uniform @set = 0 @binding = 1 cat_albedo : sampler2D
var @uniform @set = 0 @binding = 2 cat_normal : sampler2D
var @uniform @set = 0 @binding = 3 cat_arm : sampler2D
// Floor brick maps (ambientCG Bricks031, CC0): Color = sRGB albedo, NormalGL = tangent normal,
// AmbientOcclusion = AO, Roughness drives the spec. World-XZ tiled.
var @uniform @set = 0 @binding = 4 floor_albedo : sampler2D
var @uniform @set = 0 @binding = 5 floor_normal : sampler2D
var @uniform @set = 0 @binding = 6 floor_ao : sampler2D
var @uniform @set = 0 @binding = 7 floor_roughness : sampler2D
var @in @location = 0 f_world_pos : float3
var @in @location = 1 f_world_normal : float3
var @flat @in @location = 2 f_material : int
var @in @location = 3 f_uv : float2
var @out @location = 0 g_albedo_out : float4 // rgb = albedo, a = specular intensity
var @out @location = 1 g_normal_out : float4 // rgb = perturbed world-space normal, a = shininess (Blinn exp)
var @out @location = 2 g_worldpos_out : float4 // rgb = world pos, a = material tag
//! Derivative-based TBN reconstruction (Schueler 2010). NO per-vertex tangent buffer required.
def private perturb_normal(n : float3; p : float3; uv : float2; tn : float3) : float3 {
let dp1 = dFdx(p)
let dp2 = dFdy(p)
let duv1 = dFdx(uv)
let duv2 = dFdy(uv)
let dp2perp = cross(dp2, n)
let dp1perp = cross(n, dp1)
let T = dp2perp * duv1.x + dp1perp * duv2.x
let B = dp2perp * duv1.y + dp1perp * duv2.y
let invmax = 1.0 / sqrt(max(dot(T, T), dot(B, B)))
return normalize(T * (tn.x * invmax) + B * (tn.y * invmax) + n * tn.z)
}
[vulkan_fragment_shader(name="gbuffer_frag_spv")]
def gbuffer_fs {
let n = normalize(f_world_normal)
var albedo = float3(0.0, 0.0, 0.0)
var spec_intensity = 0.0
var perturbed_normal = float3(0.0, 1.0, 0.0)
// Material branch on the per-draw tag: f_material is the @flat int varying routed from the push
// constant (0 = cat statue, 1 = floor), reliable because dasSpirv emits the Flat decoration. Unlike
// the old `world_pos.y < 0.5` hack, the cat can sit ON the floor without picking up brick material.
if (f_material == 1) {
// Floor: real ambientCG Bricks031 maps, tiled by world XZ. Texture AO baked into the albedo;
// the normal map gives mortar relief via the derivative TBN. Brick is matte -> low spec.
let uv = float2(f_world_pos.x, f_world_pos.z) * 0.35
let fb_ao = texture(floor_ao, uv).x
albedo = texture(floor_albedo, uv).rgb * (0.5 + 0.5 * fb_ao)
// brick is matte: mortar (rough) ~0 spec, brick faces (smoother) a touch more
spec_intensity = (1.0 - texture(floor_roughness, uv).x) * 0.4
// Bricks031 has shallow relief; boost the tangent xy so the mortar reads, then renormalize
// (the raw `*2-1` decode is NOT unit-length after bilinear/JPEG, and the xy is weak).
let tn_raw = texture(floor_normal, uv).rgb * 2.0 - float3(1.0, 1.0, 1.0)
let tn = normalize(float3(tn_raw.x * 3.0, tn_raw.y * 3.0, tn_raw.z))
perturbed_normal = perturb_normal(n, f_world_pos, uv, tn)
} else {
// Cat: real concrete-statue PBR maps, UV-mapped. ARM packs AO/Roughness/Metallic, diff is the
// albedo, nor_gl perturbs the geometric normal via the derivative TBN. AO bakes into the albedo;
// roughness drives specular intensity, so the HDR env reflection is stronger where it's smoother.
let alb = texture(cat_albedo, f_uv).rgb
let arm = texture(cat_arm, f_uv).rgb
let tex_ao = arm.x
let rough = arm.y
albedo = alb * (0.55 + 0.45 * tex_ao)
spec_intensity = (1.0 - rough) * 0.6
let tn = normalize(texture(cat_normal, f_uv).rgb * 2.0 - float3(1.0, 1.0, 1.0))
perturbed_normal = perturb_normal(n, f_world_pos, f_uv, tn)
}
g_albedo_out = float4(albedo, spec_intensity)
g_normal_out = float4(perturbed_normal, 32.0) // .a = Blinn shininess
g_worldpos_out = float4(f_world_pos, float(f_material))
}
// ===== SSAO pass (real screen-space ambient occlusion) =====
// The G-buffer normal + world-position are SAMPLED images here, not subpass inputs, so this pass can read
// NEIGHBOUR texels -- what input attachments cannot do. Per fragment: sample a hemisphere around its world
// position, reproject each, count points hidden behind geometry; crevices + the cat-floor contact darken.
// A production AO adds a noise texture + separable blur; this keeps a per-pixel hash rotation + 16-sample spiral.
var @out @location = 0 v_uv_ssao : float2
[vulkan_vertex_shader(name="ssao_vert_spv")]
def ssao_vs {
let xs = fixed_array(-1.0, 3.0, -1.0)
let ys = fixed_array(-1.0, -1.0, 3.0)
let idx = gl_VertexIndex
let x = xs[idx]
let y = ys[idx]
gl_Position = float4(x, y, 0.0, 1.0)
v_uv_ssao = float2((x + 1.0) * 0.5, (y + 1.0) * 0.5)
}
// G-buffer as sampled textures (NEAREST). xform (set 0 / binding 0, shared with the gbuffer pass)
// supplies view + proj so we can project hemisphere sample points back to screen space.
var @uniform @set = 0 @binding = 1 ssao_g_normal : sampler2D
var @uniform @set = 0 @binding = 2 ssao_g_worldpos : sampler2D
var @in @location = 0 f_uv_ssao : float2
var @out @location = 0 ssao_out : float4
let SSAO_K = 16
let SSAO_RADIUS = 0.6
let SSAO_BIAS = 0.03
let SSAO_STRENGTH = 1.7 // scales raw occlusion so the cat-floor crease reads in the composite
//! Per-pixel rotation hash -- breaks the 16-sample spiral pattern so the AO doesn't band.
def private hash21(p : float2) : float {
return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453)
}
[vulkan_fragment_shader(name="ssao_frag_spv")]
def ssao_fs {
let uv = f_uv_ssao
let n_raw = texture(ssao_g_normal, uv).xyz
if (dot(n_raw, n_raw) < 0.01) {
ssao_out = float4(1.0, 1.0, 1.0, 1.0) // background -> no darkening
return
}
let n = normalize(n_raw)
let p = texture(ssao_g_worldpos, uv).xyz
let cur_vz = (xform.view * float4(p, 1.0)).z
// Tangent basis around the normal (pick a non-parallel up vector).
var up = float3(0.0, 1.0, 0.0)
if (abs(n.y) > 0.95) {
up = float3(1.0, 0.0, 0.0)
}
let tangent = normalize(cross(up, n))
let bitangent = cross(n, tangent)
let ang0 = hash21(uv * 1024.0) * 6.2831853
var occ = 0.0
for (i in range(SSAO_K)) {
let fi = (float(i) + 0.5) / float(SSAO_K)
let ang = ang0 + float(i) * 2.3998277 // golden angle, radians
let rr = SSAO_RADIUS * sqrt(fi)
// Hemisphere-biased offset: spiral in the tangent plane, lifted along the normal.
let off = (cos(ang) * tangent + sin(ang) * bitangent) * rr + n * (rr * 0.5)
let sp = p + off
let clip = xform.proj * xform.view * float4(sp, 1.0)
let w = max(clip.w, 0.0001)
let suv = float2(clip.x / w * 0.5 + 0.5, clip.y / w * 0.5 + 0.5)
let stored_n = texture(ssao_g_normal, suv).xyz
let stored_p = texture(ssao_g_worldpos, suv).xyz
let stored_vz = (xform.view * float4(stored_p, 1.0)).z
let sample_vz = (xform.view * float4(sp, 1.0)).z
let range_check = smoothstep(0.0, 1.0, SSAO_RADIUS / max(abs(cur_vz - stored_vz), 0.0001))
// View looks down -Z: a stored surface CLOSER to the camera has a GREATER (less negative) z,
// so it occludes the sample point. Guard against off-screen + background samples.
if (clip.w > 0.0 && suv.x >= 0.0 && suv.x <= 1.0 && suv.y >= 0.0 && suv.y <= 1.0
&& dot(stored_n, stored_n) > 0.01 && stored_vz >= sample_vz + SSAO_BIAS) {
occ += range_check
}
}
let ao = clamp(1.0 - SSAO_STRENGTH * occ / float(SSAO_K), 0.0, 1.0)
ssao_out = float4(ao, ao, ao, 1.0)
}
// ===== lighting pass (subpass 2) =====
var @out @location = 0 v_uv_lit : float2
[vulkan_vertex_shader(name="lighting_vert_spv")]
def lighting_vs {
let xs = fixed_array(-1.0, 3.0, -1.0)
let ys = fixed_array(-1.0, -1.0, 3.0)
let idx = gl_VertexIndex
let x = xs[idx]
let y = ys[idx]
gl_Position = float4(x, y, 0.0, 1.0)
v_uv_lit = float2((x + 1.0) * 0.5, (y + 1.0) * 0.5)
}
struct DebugPC {
debug_mode : int
}
var @push_constant dbg : DebugPC
// G-buffer + SSAO as sampled textures (NEAREST, set 0). The fullscreen-triangle uv maps 1:1 to the
// G-buffer texels, so a NEAREST fetch reads each fragment's own deferred values exactly.
var @uniform @set = 0 @binding = 0 lit_albedo : sampler2D
var @uniform @set = 0 @binding = 1 lit_normal : sampler2D
var @uniform @set = 0 @binding = 2 lit_worldpos : sampler2D
var @uniform @set = 0 @binding = 3 lit_ssao : sampler2D
var @uniform @set = 1 @binding = 0 shadow_map : sampler2DShadow
//! Equirectangular HDR environment map (Cannon, Poly Haven, CC0). RGBA32F. Sampled by direction for
//! image-based lighting: the env colour along the surface normal tints the diffuse ambient, and along
//! the reflected view gives a real specular reflection.
var @uniform @set = 1 @binding = 2 env_map : sampler2D
let SHADOW_MAP_PX = 1024.0
def private pcf_shadow(uv : float2; ref : float) : float {
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)
}
//! Direction -> equirectangular UV. atan2/acos confirmed working in dasSpirv (the "no inverse trig"
//! gap was a phantom). u wraps the azimuth, v maps the polar angle top(0) -> bottom(1).
def private equirect_uv(d : float3) : float2 {
let u = atan2(d.z, d.x) * 0.15915494 + 0.5 // 1 / (2*pi)
let v = acos(clamp(d.y, -1.0, 1.0)) * 0.31830989 // 1 / pi
return float2(u, v)
}
var @in @location = 0 f_uv_lit : float2
var @out @location = 0 frag_color : float4
//! One point-light's contribution. Branchless: when distance > range, atten clamps to 0 so the
//! light naturally drops off. `pos.w` = range; `col.w` = falloff exponent on (1 - d/range).
def private point_light_contrib(pos : float4; col : float4; p : float3; n : float3; view_dir : float3;
shininess : float; spec_intensity : float) : float3 {
let lvec = pos.xyz - p
let ld = max(length(lvec), 0.0001)
let ldir = lvec / float3(ld, ld, ld)
let ndotl = max(dot(n, ldir), 0.0)
let atten = pow(max(1.0 - ld / pos.w, 0.0), col.w)
let hp = normalize(ldir + view_dir)
let spec_pt = pow(max(dot(n, hp), 0.0), shininess) * spec_intensity
return col.xyz * (ndotl + spec_pt) * atten
}
[vulkan_fragment_shader(name="lighting_frag_spv")]
def lighting_fs { // nolint:STYLE038 - shader body - phases are pipeline-coupled
// 16-row mode-indicator strip (drawn in shader, so window + recording + test all see it).
if (gl_FragCoord.y < 16.0) {
if (dbg.debug_mode == 1) {
frag_color = float4(0.95, 0.45, 0.45, 1.0) // albedo = warm red
} elif (dbg.debug_mode == 2) {
frag_color = float4(0.45, 0.95, 0.55, 1.0) // normal = green
} elif (dbg.debug_mode == 3) {
frag_color = float4(0.45, 0.55, 0.95, 1.0) // world-pos = blue
} elif (dbg.debug_mode == 4) {
frag_color = float4(0.80, 0.80, 0.80, 1.0) // ssao = grey
} elif (dbg.debug_mode == 5) {
frag_color = float4(0.95, 0.55, 0.95, 1.0) // lighting-only = magenta
} else {
frag_color = float4(0.95, 0.85, 0.45, 1.0) // lit = yellow
}
return
}
let albedo_spec = texture(lit_albedo, f_uv_lit)
let normal_mat = texture(lit_normal, f_uv_lit)
let worldpos_mt = texture(lit_worldpos, f_uv_lit)
let ssao_factor = texture(lit_ssao, f_uv_lit).x
let albedo = albedo_spec.rgb
let spec_intensity = albedo_spec.a
let n = normalize(normal_mat.xyz)
let shininess = normal_mat.a
let p = worldpos_mt.xyz
if (dbg.debug_mode == 1) {
frag_color = float4(albedo, 1.0)
return
}
if (dbg.debug_mode == 2) {
frag_color = float4(n * 0.5 + float3(0.5, 0.5, 0.5), 1.0)
return
}
if (dbg.debug_mode == 3) {
frag_color = float4(p * 0.15 + float3(0.5, 0.5, 0.5), 1.0)
return
}
if (dbg.debug_mode == 4) {
frag_color = float4(ssao_factor, ssao_factor, ssao_factor, 1.0)
return
}
// Background pixels (normal is zero) -> sunset gradient.
if (dot(n, n) < 0.01) {
let sky_top = float3(0.06, 0.08, 0.18)
let sky_bot = float3(0.36, 0.22, 0.30)
let t = clamp(f_uv_lit.y, 0.0, 1.0)
let bg = lerp(sky_top, sky_bot, float3(t, t, t))
frag_color = float4(bg, 1.0)
return
}
let view_dir = normalize(scene.camera_pos.xyz - p)
// ----- Sun (shadowed directional) -----
let sun_dir = normalize(scene.light_dir.xyz)
let sun_color = float3(1.25, 1.10, 0.80)
let ndotl_sun = max(dot(n, sun_dir), 0.0)
let lc = scene.light_vp * float4(p, 1.0)
let lw = max(lc.w, 0.0001)
let ndc = float3(lc.x / lw, lc.y / lw, lc.z / lw)
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)
// Larger bias than tutorial 8 -- the deferred path's normal-mapped surfaces vary normal at
// sub-pixel scale, which amplifies depth-bias quantization into the banding you saw.
let frag_bias = max(0.003 * (1.0 - ndotl_sun), 0.001)
let lit_ref = clamp(ref_depth - frag_bias, 0.0, 1.0)
var shadow = pcf_shadow(shadow_uv, lit_ref)
if (ndotl_sun < 0.05) { shadow = 0.0 }
let half_sun = normalize(sun_dir + view_dir)
let spec_sun = pow(max(dot(n, half_sun), 0.0), shininess) * spec_intensity
let sun_contrib = sun_color * (ndotl_sun + spec_sun) * shadow
// ----- Point lights ----- (unrolled because UBO field array isn't supported by dasSpirv)
var pt_contrib = float3(0.0, 0.0, 0.0)
pt_contrib = pt_contrib + point_light_contrib(scene.pos0, scene.col0, p, n, view_dir, shininess, spec_intensity)
pt_contrib = pt_contrib + point_light_contrib(scene.pos1, scene.col1, p, n, view_dir, shininess, spec_intensity)
pt_contrib = pt_contrib + point_light_contrib(scene.pos2, scene.col2, p, n, view_dir, shininess, spec_intensity)
// ----- Hemisphere ambient + HDR environment IBL (modulated by SSAO) -----
let sky_color = float3(0.22, 0.24, 0.36)
let ground_color = float3(0.08, 0.06, 0.05)
let amb_t = n.y * 0.5 + 0.5
let hemi_ambient = lerp(ground_color, sky_color, float3(amb_t, amb_t, amb_t))
// env colour along the normal tints the diffuse ambient (cheap irradiance approximation); env
// colour along the reflected view gives a real specular reflection, weighted by Fresnel + material.
let env_amb = texture(env_map, equirect_uv(n)).rgb
let ambient = lerp(hemi_ambient, env_amb, float3(0.4, 0.4, 0.4)) // splat t — dasSpirv FMix needs vec t
let inc = float3(-view_dir.x, -view_dir.y, -view_dir.z)
let rdir = inc - n * (2.0 * dot(n, inc))
let env_refl = texture(env_map, equirect_uv(rdir)).rgb
let fres = pow(1.0 - max(dot(n, view_dir), 0.0), 4.0)
let env_spec = env_refl * (spec_intensity * (0.10 + 0.7 * fres))
let ao = clamp(ssao_factor, 0.0, 1.0)
let lighting = (ambient * ao) + (sun_contrib + pt_contrib) * (0.55 + 0.45 * ao) + env_spec * ao
if (dbg.debug_mode == 5) {
frag_color = float4(lighting, 1.0)
return
}
let col = albedo * lighting
frag_color = float4(col, 1.0)
}
The render (headless)
The host builds FOUR pipelines (shadow / gbuffer / ssao / lighting), TWO
render passes (single-subpass shadow / three-subpass deferred), ONE
framebuffer per render pass, six descriptor sets, and an internal helper
that draws the cube + floor with per-object push constants. The per-frame
record_deferred_render records the shadow pass, then the multi-subpass
deferred pass with cmd_next_subpass advancing through the three
subpasses.
def public record_deferred_render(res : DeferredResources; cmd : CommandBuffer) {
// ---- shadow pass (depth-only) ----
var sh_clears <- [clear_depth(1.0f)]
record_render_pass(cmd, res.rp_shadow, res.fb_shadow, full_area(SHADOW_W, SHADOW_H), sh_clears) {
cmd_bind_pipeline(cmd, res.shadow_pipeline)
let s_sets <- [vk_value_to_boost(res.shadow_set)]
var no_dyn : array<uint>
cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.shadow_pipe_layout, 0u, s_sets, no_dyn)
draw_casters(res, cmd, res.shadow_pipe_layout)
}
delete sh_clears
// ---- G-buffer pass (3 MRT color + depth) ----
var gb_clears <- [
clear_color(0.0f, 0.0f, 0.0f, 1.0f), // 0: albedo bg
clear_color(0.0f, 0.0f, 0.0f, 0.0f), // 1: normal bg (zero vec = background marker)
clear_color(0.0f, 0.0f, 0.0f, 0.0f), // 2: world-pos bg
clear_depth(1.0f) // 3: depth
]
record_render_pass(cmd, res.rp_gbuffer, res.fb_gbuffer, full_area(DEFERRED_W, DEFERRED_H), gb_clears) {
cmd_bind_pipeline(cmd, res.gbuffer_pipeline)
let g_sets <- [vk_value_to_boost(res.gbuffer_set)]
var no_dyn : array<uint>
cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.gbuffer_pipe_layout, 0u, g_sets, no_dyn)
draw_casters(res, cmd, res.gbuffer_pipe_layout)
}
delete gb_clears
// ---- SSAO pass (samples the G-buffer normal + world-pos; writes the occlusion factor) ----
var ssao_clears <- [clear_color(1.0f, 1.0f, 1.0f, 1.0f)] // 1 = no darkening
record_render_pass(cmd, res.rp_ssao, res.fb_ssao, full_area(DEFERRED_W, DEFERRED_H), ssao_clears) {
cmd_bind_pipeline(cmd, res.ssao_pipeline)
let ssao_sets <- [vk_value_to_boost(res.ssao_set)]
var no_dyn : array<uint>
cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.ssao_pipe_layout, 0u, ssao_sets, no_dyn)
cmd_draw(cmd, 3u)
}
delete ssao_clears
// ---- lighting pass (samples G-buffer + ssao + shadow + env; composites the final colour) ----
var lit_clears <- [clear_color(0.05f, 0.05f, 0.08f, 1.0f)]
record_render_pass(cmd, res.rp_lighting, res.fb_lighting, full_area(DEFERRED_W, DEFERRED_H), lit_clears) {
cmd_bind_pipeline(cmd, res.lighting_pipeline)
let l_sets0 <- [vk_value_to_boost(res.lighting_set0)]
var no_dyn : array<uint>
cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.lighting_pipe_layout, 0u, l_sets0, no_dyn)
let l_sets1 <- [vk_value_to_boost(res.lighting_set1)]
cmd_bind_descriptor_sets(cmd, VkPipelineBindPoint.GRAPHICS, res.lighting_pipe_layout, 1u, l_sets1, no_dyn)
lighting_fs_push_constants(cmd, res.lighting_pipe_layout)
cmd_draw(cmd, 3u)
}
delete lit_clears
}
Self-verifying
The test is the CI regression gate (lavapipe in CI, real GPU locally). It renders frames in LIT, ALBEDO, and SSAO modes and checks the structural properties: indicator-strip colour per mode, sky background at top, lit cube area, orange-brick pixels on the floor in BOTH lit and albedo modes (proving the G-buffer wrote albedo, not just the final colour).
[test]
def test_deferred_oracle(t : T?) { // nolint:STYLE038 - flat test cell
var lit_pixels <- render_deferred_cube(TEST_TIME, TEST_CAM, DBG_LIT)
// ----- top-strip indicator (sub-indicator-strip rows) -----
// The shader paints rows 0..15 as a flat mode-distinct colour. Lit = warm yellow (~0.95, 0.85, 0.45).
let cx = DEFERRED_W / 2
let strip_off = (8 * DEFERRED_W + cx) * 4
let r0 = int(lit_pixels[strip_off])
let g0 = int(lit_pixels[strip_off + 1])
let b0 = int(lit_pixels[strip_off + 2])
t |> success(r0 > 220 && g0 > 180 && b0 < 210,
"lit indicator strip is warm yellow (got {r0},{g0},{b0})")
// ----- background (top-quarter sky) -----
// Above the cube/floor horizon the lighting shader writes a dark blue->magenta gradient.
let bg_y = DEFERRED_H / 4
let bg_off = (bg_y * DEFERRED_W + cx) * 4
let bg_r = int(lit_pixels[bg_off])
let bg_g = int(lit_pixels[bg_off + 1])
let bg_b = int(lit_pixels[bg_off + 2])
// sky_top = (0.06, 0.08, 0.18) -> roughly (15, 20, 46)
t |> success(bg_r < 50 && bg_g < 60 && bg_b < 90,
"top-quarter sky is dark blue (got {bg_r},{bg_g},{bg_b})")
// ----- centre: the cat should be visible + lit -----
// A single hardcoded sample is fragile under camera motion + the contact-AO darkening, so scan a
// box around the centre where the cat sits and require its brightest pixel to be clearly lit.
var cat_bright = 0
let cy = DEFERRED_H / 2 + 20
for (dy in range(-80, 81)) {
for (dx in range(-70, 71)) {
let o = ((cy + dy) * DEFERRED_W + (cx + dx)) * 4
let m = max(int(lit_pixels[o]), max(int(lit_pixels[o + 1]), int(lit_pixels[o + 2])))
cat_bright = max(cat_bright, m)
}
}
t |> success(cat_bright > 90,
"cat area has a clearly lit pixel (brightest in centre box = {cat_bright})")
// ----- floor: scan a row below the cube; expect to see orange-warm bricks somewhere -----
var floor_warm_hits = 0
let floor_y = DEFERRED_H - 80
for (x in range(DEFERRED_W / 8)) {
let xx = x * 8
let off = (floor_y * DEFERRED_W + xx) * 4
let r = int(lit_pixels[off])
let g = int(lit_pixels[off + 1])
let b = int(lit_pixels[off + 2])
// a brick pixel: red > green > blue (warm)
if (r > 80 && r > g + 5 && g > b) {
floor_warm_hits++
}
}
t |> success(floor_warm_hits >= 5,
"floor row at y={floor_y} has {floor_warm_hits} warm brick pixels (>=5)")
delete lit_pixels
// ----- albedo mode should also show orange bricks on the floor -----
var albedo_pixels <- render_deferred_cube(TEST_TIME, TEST_CAM, DBG_ALBEDO)
var albedo_brick_hits = 0
for (x in range(DEFERRED_W / 8)) {
let xx = x * 8
let off = (floor_y * DEFERRED_W + xx) * 4
let r = int(albedo_pixels[off])
let g = int(albedo_pixels[off + 1])
let b = int(albedo_pixels[off + 2])
if (r > 80 && r > g + 10 && g > b) {
albedo_brick_hits++
}
}
t |> success(albedo_brick_hits >= 5,
"ALBEDO mode floor row at y={floor_y} has {albedo_brick_hits} orange-brick pixels (proves the G-buffer wrote albedo, not just the final-colour)")
// ALBEDO mode indicator strip should be warm red (0.95, 0.45, 0.45).
let alb_strip = (8 * DEFERRED_W + cx) * 4
let ar = int(albedo_pixels[alb_strip])
let ag = int(albedo_pixels[alb_strip + 1])
let ab = int(albedo_pixels[alb_strip + 2])
t |> success(ar > 220 && ag < 210 && ab < 210,
"ALBEDO indicator strip is warm red (got {ar},{ag},{ab})")
delete albedo_pixels
// ----- SSAO mode: indicator strip should be light grey (0.80, 0.80, 0.80) -----
var ssao_pixels <- render_deferred_cube(TEST_TIME, TEST_CAM, DBG_SSAO)
let ssao_strip = (8 * DEFERRED_W + cx) * 4
let sr = int(ssao_pixels[ssao_strip])
let sg = int(ssao_pixels[ssao_strip + 1])
let sb = int(ssao_pixels[ssao_strip + 2])
t |> success(sr > 180 && sg > 180 && sb > 180 && abs(sr - sg) < 15,
"SSAO indicator strip is grey (got {sr},{sg},{sb})")
delete ssao_pixels
}
See it live
window/show_deferred.das opens a GLFW window with a Vulkan swapchain
and runs the scene per frame with time derived from wall-clock. The
window title shows the current toggle state (e.g. M = AUTO | active:
lit); pressing M cycles through 7 states: AUTO (rotates every
2.5 s), FORCE LIT, FORCE ALBEDO, FORCE NORMAL, FORCE
WORLD-POS, FORCE SSAO, FORCE LIGHTING-ONLY. The 16-row
indicator strip at the top of the frame is the same one the recording
uses.
require glfw/glfw_boost
require vulkan
require vulkan/vulkan_boost
require vulkan/vulkan_window
require ../deferred_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(DEFERRED_W, DEFERRED_H, "dasVulkan tutorial 10 - deferred shading", 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 10 (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")
}
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_deferred_resources(device, phys, queue, pool)
var inscope swap <- create_swapchain(device, phys, surface, DEFERRED_W, DEFERRED_H)
var inscope sync <- create_frame_sync(device)
var toggle = DeferredToggle.auto_toggle
var m_was_down = false
var title_label = ""
while (glfwWindowShouldClose(window) == 0) {
glfwPollEvents()
let m_down = glfwGetKey(window, GLFW_KEY_M) == GLFW_PRESS
if (m_down && !m_was_down) {
toggle = next_deferred_toggle(toggle)
}
m_was_down = m_down
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())
let raw_cam = t * 0.042f
let camera_t = raw_cam - floor(raw_cam)
let mode = deferred_active_mode(toggle, t)
update_deferred_uniforms(res, device, t, camera_t)
dbg.debug_mode = mode
let new_label = "M = {deferred_toggle_label(toggle)} | active: {debug_mode_label(mode)} - press M to cycle"
if (new_label != title_label) {
glfwSetWindowTitle(window, "dasVulkan tutorial 10 - {new_label}")
title_label = new_label
}
let ok = present_frame(device, queue, swap, pool, sync) $(cmd; target; _idx) {
record_deferred_render(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 = DEFERRED_W
region.srcOffsets[1].y = DEFERRED_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.final_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/10_deferred
# watch it live in a window with the M-key cycle (needs the glfw module + a display)
daslang -load_module <dasVulkan> \
<dasVulkan>/tutorials/10_deferred/window/show_deferred.das
# regenerate the recording (needs stbimage + audio + ffmpeg locally)
daslang -load_module <dasVulkan> \
<dasVulkan>/tutorials/10_deferred/recording/record_deferred.das
Next
11 - HDR + Bloom: Karis Pyramid + ACES switches the offscreen target to 16-bit float HDR, adds a Karis-style five-level bloom pyramid (downsample + tent upsample with additive blend), and tone-maps the result back to LDR with the ACES fitted approximation. Same “render scene → post-process chain → present” structure as deferred, but with eleven render-pass instances chained through three render-pass objects and dynamic viewport so the same pipeline runs at every mip resolution.