Chapter 8: Shadows
Shadows are essential for spatial perception. Taos implements two integrated shadow techniques — cascaded shadow maps (CSM) for the directional sun light, and variance shadow maps (VSM) for point and spot lights — plus a pair of standalone depth-only passes for shadowing a single light in isolation. The sun's cascades are filtered with PCSS (percentage-closer soft shadows) by default, and can optionally be filtered with EVSM2 (exponential variance shadow maps, §8.9) for constant-cost soft edges. The directional sun can also use an opt-in clipmap (virtual-shadow-map) layout (§8.10) instead of frustum cascades, for tilt-stable, distance-continuous resolution.
8.1 Shadow Map Theory#
The fundamental idea of shadow mapping is simple: render the scene from the light's perspective into a depth buffer (the shadow map), then during shading, compare each surface point's depth against the shadow map at the corresponding light-space coordinate. If the surface point is farther from the light than the closest surface recorded in the shadow map, it is in shadow.
In WGSL, this comparison is:
// ── from the shadow shader ──
let shadowUV = lightViewProj * worldPos;
shadowUV.xyz = shadowUV.xyz / shadowUV.w; // perspective divide
shadowUV.xy = shadowUV.xy * 0.5 + 0.5; // NDC to UV
shadowUV.y = 1.0 - shadowUV.y; // flip Y for WebGPU
let shadowDepth = textureSample(shadowMap, shadowSampler, shadowUV.xy).r;
let fragmentDepth = shadowUV.z;
let shadowFactor = fragmentDepth > shadowDepth + bias ? 0.0 : 1.0;
Shadow Bias#
A depth bias prevents shadow acne — self-shadowing artifacts caused by the limited precision of the depth buffer. WebGPU supports hardware depth bias:
// ── from the shadow render pipeline ──
depthStencil: {
format: 'depth32float',
depthWriteEnabled: true,
depthCompare: 'less',
depthBias: 1, // Constant depth offset
depthBiasSlopeScale: 1.5, // Slope-dependent offset (reduces peter-panning)
}
The depthBiasSlopeScale term adds bias proportional to the polygon's slope relative to the light direction, preventing "Peter Panning" (shadows detaching from the caster) on flat surfaces while avoiding excessive bias on steep ones.
8.2 Cascade Shadow Maps (CSM)#
A single shadow map for a directional light covers too large an area to be useful — texels near the camera appear blocky. Cascade shadow maps split the view frustum into multiple depth ranges, each rendered into its own shadow map with texel density matched to that range.
Cascade Setup#
The ShadowPass (src/renderer/render_graph/passes/shadow_pass.ts) imports a persistent depth32float 2D array texture (keyed by "shadow:directional" in the resource cache) with up to 4 layers:
// ── from src/renderer/render_graph/passes/shadow_pass.ts ──
const SHADOW_SIZE = 2048;
const MAX_CASCADES = 4;
const shadowMap = device.createTexture({
label: 'ShadowMap',
size: { width: SHADOW_SIZE, height: SHADOW_SIZE, depthOrArrayLayers: MAX_CASCADES },
format: 'depth32float',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
});
const shadowMapView = shadowMap.createView({ dimension: '2d-array' });
const shadowMapArrayViews = Array.from({ length: MAX_CASCADES }, (_, i) =>
shadowMap.createView({ dimension: '2d', baseArrayLayer: i, arrayLayerCount: 1 }),
);
Each cascade has a single GPUTextureView into one layer of the array, allowing render passes to target individual cascades.
Cascade Partitioning#
The camera's view frustum is split using the practical logarithmic split scheme, which blends between logarithmic (better for far cascades) and uniform (better for near cascades):
split[i] = near * (far / near)^(i / N) // logarithmic
split[i] = near + (far - near) * (i / N) // uniform
split[i] = lerp(logSplit, uniformSplit, lambda) // blended (lambda ≈ 0.7)
Fitting and Stabilizing Cascades#
Knowing the split distances is only half the job — each cascade still needs an orthographic view-projection that decides which region of the world it covers. The obvious choice is to fit each cascade tightly to its slice of the view frustum. That works, but it has two problems that are very visible while playing.
Off-screen casters. A frustum-fit shadow map only contains geometry inside the camera's frustum. But a shadow can be cast into the view by an object that is itself outside the view — a cliff just off-screen, or one behind the camera with the sun at its back. With a tight frustum fit those casters are never drawn into the shadow map, so their shadows simply do not exist. Rotating the camera a few degrees moves casters in and out of the frustum, and their shadows pop in and out with them.
Shadow swimming. Fitting to the frustum means the cascade bounds shift continuously as the camera moves or rotates. Each frame the shadow texels land on slightly different world positions, and shadow edges crawl and shimmer.
Taos avoids both by fitting each cascade to a bounding sphere centered on the camera position rather than the frustum. This happens in DirectionalLight.computeCascadeMatrices (src/engine/components/directional_light.ts):
// ── from src/engine/components/directional_light.ts ──
const camPos = camera.position();
const lightDir = this.direction.normalize();
// Light view anchored at the camera — shared by every cascade.
const lightView = Mat4.lookAt(camPos.sub(lightDir), camPos, Vec3.UP);
// Per cascade: radius reaches the farthest frustum corner of this slice,
// so the sphere still contains every visible receiver in this depth range.
let radius = 0;
for (const c of corners) {
radius = Math.max(radius, c.sub(camPos).length());
}
Anchoring on the camera position has a powerful consequence: rotating the camera does not change the cascades at all. The sphere's center (the camera) and its radius are both independent of view direction, so every cascade matrix is identical frame-to-frame while the player turns in place — shadows cannot swim or pop when only the view rotates. And because the sphere extends radius in every direction, casters beside and behind the camera are inside the shadow map and cast correctly. The depth range spans the full sphere (±radius along the light axis), so every receiver in the cascade is inside the box.
Caster clipping and depth pancaking. Bounding the receivers is not the whole story — a shadow caster can stand far outside the box. A caster always shares its receiver's light-space xy (the shadow projects straight down the light axis), so the lateral fit is never the problem; the near plane is. A tall pillar or cliff whose base is in view but whose top rises past the box's near plane has that top clipped out of the shadow map, and the slice of shadow it would have cast simply disappears — a band of missing shadow that slides back into place as the camera moves and the box re-centers. The fix is depth clamping, usually called pancaking: with unclippedDepth enabled, fragments in front of the near plane clamp to depth 0 instead of being discarded. They still occlude every receiver behind them, which is exactly correct — anything closer to the light than the near plane is closer than every receiver in the cascade.
// ── from src/renderer/render_graph/passes/shadow_pass.ts ──
primitive: {
topology: 'triangle-list',
cullMode: 'back',
unclippedDepth: device.features.has('depth-clip-control'),
},
Texel snapping. Anchoring on the camera removes rotation-induced swimming, but translation still moves the sphere. To stop shadows crawling as the player walks, the orthographic box is snapped so its edges always land on a fixed world-space texel grid:
// ── from src/engine/components/directional_light.ts ──
const texelWorldSize = (2 * radius) / shadowMapSize;
const offX = right.dot(camPos); // camera position along the light's lateral axes
const offY = up.dot(camPos);
const left = Math.round((offX - radius) / texelWorldSize) * texelWorldSize - offX;
const bottom = Math.round((offY - radius) / texelWorldSize) * texelWorldSize - offY;
right and up are the light's lateral axes, which depend only on the light direction, so the grid they define is fixed in world space. Rounding the camera's projected position onto that grid means the shadow map only ever moves in whole-texel steps — a given world point always falls in the same texel, so shadow edges stay stable as the camera translates. (Snapping the camera's projected position is essential here: because lightView looks straight at camPos, the camera always lands on the light-space origin, so the box edges — not the center — are what gets snapped.)
The trade-off is resolution: a camera-centered sphere also covers the area behind the camera, so it is somewhat larger than a tight frustum fit (near shadows are roughly 30% softer). For shadows that never pop or swim, that is a cheap price.
Rendering Cascades#
ShadowPass.addToGraph registers one render pass per cascade, each named ShadowPass.cascade{i}. A pass declares a depth-attachment write into a single array layer of the persistent shadow map; the render graph builds the GPURenderPassDescriptor, and the pass's execute callback only records draw calls:
// ── from src/renderer/render_graph/passes/shadow_pass.ts ──
let shadowMap = graph.importPersistentTexture(SHADOW_MAP_KEY, SHADOW_MAP_DESC);
for (let c = 0; c < N; c++) {
let nextShadow: ResourceHandle;
graph.addPass(`ShadowPass.cascade${c}`, 'render', (b) => {
nextShadow = b.write(shadowMap, 'depth-attachment', {
depthLoadOp: 'clear', depthStoreOp: 'store', depthClearValue: 1.0,
view: { dimension: '2d', baseArrayLayer: c, arrayLayerCount: 1 },
});
b.setExecute((pctx) => {
// draw scene meshes from this cascade's light view-projection
});
});
shadowMap = nextShadow; // thread the handle to the next cascade
}
The persistent shadow-map handle is threaded from one cascade pass to the next — each b.write returns a new handle version — so the graph sees the correct write-ordering and the BlockShadowPass can later append voxel-chunk geometry to the same layers with depthLoadOp: 'load'.
Cascade Selection in the Lighting Pass#
During the deferred lighting pass, each fragment determines which cascade to sample based on its view-space depth:
// ── from src/shaders/deferred_lighting.wgsl ──
let viewDepth = -camera.view[3].z; // negative for right-handed
// Select cascade based on view depth
var cascadeIndex = 0;
for (var i = 0; i < cascadeCount - 1; i++) {
if (viewDepth > cascadeSplits[i]) {
cascadeIndex = i + 1;
}
}
// Sample the selected cascade
let shadowUV = cascadeViewProj[cascadeIndex] * worldPos;
// ... compare depth with shadowMapArray at cascadeIndex ...
The cascade count, split depths, and per-cascade view-projection matrices are uploaded to the light uniform buffer each frame.
8.3 Variance Shadow Maps (VSM)#
Taos uses VSM for point and spot light shadows. VSM stores the depth and depth-squared (the first two moments) in the rg channels of an rgba16float texture, and uses variance-based filtering to produce soft, pre-filtered shadows without hardware PCF:
// ── from src/shaders/point_spot_shadow.wgsl ──
// The moment pair (E[z], E[z²]) is written into the rg channels of rgba16float.
// Point faces store *linear* depth (distance to the light / range); spot slices
// store NDC depth (clip.z / clip.w).
let d = stored_depth;
return vec4<f32>(d, d * d, 0.0, 1.0);
In the lighting shader, the shadow test uses Chebyshev's inequality to estimate the probability that the fragment is occluded:
// ── from src/shaders/point_spot_lighting.wgsl ──
fn vsm_shadow(moments: vec2<f32>, compare: f32) -> f32 {
if (compare <= moments.x + 0.001) { return 1.0; } // closer than the occluder → lit
let variance = max(moments.y - moments.x * moments.x, 1e-5);
let d = compare - moments.x;
return variance / (variance + d * d); // Chebyshev upper bound
}
The early-out and the 1e-5 variance floor are VSM's first defense against its characteristic artifact — light bleeding, where a bright background leaks through a thin occluder because the variance bound over-estimates the lit fraction. On top of the floor, vsm_shadow routes through the shared vsm.wgsl module's vsm_chebyshev, which applies the classic light-bleed reduction: it remaps the probability so its low tail clips to zero (clamp((pMax − amount) / (1 − amount), 0, 1)). The bleed always sits in the low part of the [0, 1] range, so cutting it removes the leak at a small cost in penumbra darkness. The same module is shared with the directional EVSM2 path of §8.9.
VSM's real advantage over a plain depth map is that the moments can be sampled with ordinary hardware bilinear filtering — that is what gives point and spot shadows their soft edges, with no multi-tap PCF kernel — and the penumbra widens naturally with depth variance. The PointSpotShadowPass (src/renderer/render_graph/passes/point_spot_shadow_pass.ts) bakes all active point and spot light shadows inside a single graph node. A shadowing point light renders its scene into the six faces of one slice of a moment cube-array (VSM_POINT_SIZE = 256 per face); a shadowing spot renders a single slice of a 2D moment array (VSM_SPOT_SIZE = 512). The moment arrays are fixed-size, so only a subset of the active lights can cast shadows — MAX_SHADOW_POINT_LIGHTS = 4 (4 cubes × 6 faces = 24 face renders) and MAX_SHADOW_SPOT_LIGHTS = 8.
A point light's shadow is resolved in the lighting shader by sampling its cube-array slice along the light-to-surface direction (rather than a projected UV) and feeding the recovered moments to the same Chebyshev bound, with the compare value being the light-to-surface distance normalized by the light's range.
8.4 Spot Light Shadows#
The integrated renderer routes all point and spot shadows through the single VSM
PointSpotShadowPassof §8.3. TheSpotShadowPass(§8.4) andPointShadowPass(§8.5) described next are simpler standalone passes — exported in the render-graph API for driving one light's shadow in isolation — and are not wired into the default pipeline. They use classic depth-compare shadow maps rather than VSM.
The SpotShadowPass (src/renderer/render_graph/passes/spot_shadow_pass.ts) renders a single spot light's shadow into a 2D depth texture. It is a minimal depth-only pass that exposes the shadow map as a graph output handle:
// ── from src/renderer/render_graph/passes/spot_shadow_pass.ts ──
export class SpotShadowPass extends Pass<SpotShadowDeps, SpotShadowOutputs> {
readonly name = 'SpotShadowPass';
updateLight(ctx: RenderContext, light: SpotLight): void {
// Compute view-projection from light's position, direction, cone angle
const vp = light.computeLightViewProj();
ctx.queue.writeBuffer(this._cameraBuffer, 0, vp.data);
}
addToGraph(graph: RenderGraph, deps: SpotShadowDeps): SpotShadowOutputs {
let out!: ResourceHandle;
graph.addPass(this.name, 'render', (b) => {
const shadow = deps.shadowMap ?? b.createTexture({ label: 'SpotShadow', format: 'depth32float',
/* ... */ });
out = b.write(shadow, 'depth-attachment', {
depthLoadOp: 'clear', depthStoreOp: 'store', depthClearValue: 1.0,
});
b.setExecute((pctx) => {
// Draw all registered meshes from the light's perspective.
});
});
return { shadowMap: out };
}
}
The vertex shader for shadow mapping only needs the position attribute:
// ── from the shadow render pipeline ──
vertex: {
buffers: [{
arrayStride: VERTEX_STRIDE,
attributes: [VERTEX_ATTRIBUTES[0]], // Only position (location 0)
}],
},
No fragment shader output is needed (depth-only), so targets: [] is used in the pipeline.
8.5 Point Light (Omnidirectional) Shadows#
Point light shadows use a cube-map depth texture — 6 faces rendered from the light position, each covering a 90° field of view:
// ── from src/renderer/render_graph/passes/point_shadow_pass.ts ──
for (let face = 0; face < 6; face++) {
const view = cubeFaceViewMatrix(light.position, face);
const proj = Mat4.perspective(90° * Math.PI / 180, 1.0, near, light.range);
// Render scene from this face's view
}
The cube map is sampled directly in the lighting shader using the direction from the light to the surface point:
// ── from the lighting shader ──
let shadowDir = surfacePos - light.position; // Direction to sample
let shadowDepth = textureSample(shadowCube, sampler, shadowDir).r;
let fragDist = length(shadowDir);
let shadow = fragDist > shadowDepth + bias ? 0.0 : 1.0;
Cube shadow maps are expensive — they require 6 draw calls per light per frame. Taos limits point light shadows to a small number of active lights.
8.6 Shadow Sampling and Filtering#
Percentage-Closer Filtering (PCF)#
For directional CSM shadows, Taos uses hardware PCF with a depth32float texture and a comparison sampler:
// ── from the shadow render pipeline ──
const shadowSampler = device.createSampler({
compare: 'less', // Enables PCF
magFilter: 'linear',
minFilter: 'linear',
});
A Poisson-disk kernel distributes samples within the filter radius to reduce banding:
// ── from src/shaders/deferred_lighting.wgsl ──
let shadow = 0.0;
let kernelSize = 16;
for (var i = 0u; i < kernelSize; i++) {
let offset = poissonDisk[i] * shadowTexelSize * shadowSoftness;
shadow += textureSampleCompare(shadowMap, shadowSampler,
uv + offset, fragmentDepth);
}
shadow /= f32(kernelSize);
VSM Filtering#
Because VSM stores filterable moments rather than raw depth, the moment texture is sampled with ordinary hardware bilinear filtering, which is what gives point and spot shadows their soft edges — no multi-tap PCF kernel is needed — and the penumbra widens with depth variance through the Chebyshev bound. A separable Gaussian pre-blur of the moment texture is the classic way to widen VSM penumbrae further, but PointSpotShadowPass does not currently run one; its softness comes from bilinear filtering and the variance bound alone.
8.7 Shadow Acne and Peter Panning#
Taos addresses shadow artifacts mainly through depth bias; the second technique below is the standard bias-free alternative for VSM:
Depth bias (hardware, applied during shadow map rasterization) prevents acne on surfaces facing the light:
// ── from the shadow render pipeline ──
depthBias: 1,
depthBiasSlopeScale: 1.5,
Second-depth shadow mapping is the standard bias-free alternative for VSM: instead of the closest surface it stores the depth of the second-closest (front-face-culled) surface, eliminating bias entirely at the cost of a second render. Taos's VSM pass stores first-depth moments and leans on the variance bound and floor (§8.3) rather than this scheme.
Taos's CSM uses a cascade border — each cascade is rendered slightly larger than its theoretical frustum, and a blend region between cascades smooths the transition:
// ── from src/shaders/deferred_lighting.wgsl ──
// Blend between adjacent cascades near the split boundary
let blend = smoothstep(cascadeSplits[cascadeIndex] - blendRegion,
cascadeSplits[cascadeIndex], viewDepth);
shadow = mix(shadowCascade, shadowNextCascade, blend);
8.8 Percentage-Closer Soft Shadows (PCSS)#
Standard PCF uses a fixed kernel radius, producing shadows that are equally soft everywhere. In reality, shadows are sharper near the occluder and softer farther away. Percentage-Closer Soft Shadows (PCSS) approximate this by estimating the penumbra width per-fragment and scaling the PCF kernel accordingly.
PCSS adds two steps before the standard PCF sample loop:
1. Blocker search. A small fixed-radius search (8 taps, 0.3 world units) samples the shadow map around the fragment and averages the depth of all texels that are closer than the fragment. If no blockers are found, the fragment is fully lit and we skip PCF entirely:
// ── from src/shaders/deferred_lighting.wgsl ──
fn pcss_blocker_search(cascade: u32, sc: vec3f, search_radius: f32) -> f32 {
var total = 0.0; var count = 0.0;
for (var i = 0u; i < 8u; i++) {
let offset = poissonDisk[i] * search_radius / SHADOW_MAP_SIZE;
let d = textureLoad(shadowMap, tc, i32(cascade), 0);
if (d < sc.z) { total += d; count += 1.0; }
}
if (count == 0.0) { return -1.0; }
return total / count;
}
2. Penumbra estimation. The penumbra width is proportional to the distance from the fragment to the average blocker, multiplied by a configurable shadowSoftness factor. This width is converted from world units to texels in the selected cascade:
// ── from src/shaders/deferred_lighting.wgsl ──
let avg_blocker = pcss_blocker_search(cascade, sc0, search_tex);
if (avg_blocker >= 0.0) {
let occluder_dist = (sc0.z - avg_blocker) * depth_world;
let penumbra = min(shadowSoftness * occluder_dist, KERNEL_MAX_WORLD);
kernel = clamp(penumbra / texel_world, 1.0, 16.0);
}
3. PCF with variable kernel. The standard 16-tap Poisson-disk PCF loop runs with the per-fragment kernel radius:
// ── from src/shaders/deferred_lighting.wgsl ──
let s = pcf_shadow(cascade, sc0, bias, kernel, screen_pos);
The penumbra estimation runs in world units and is converted to per-cascade texels. This keeps the visual softness consistent across cascade boundaries — without this, a sudden change in texel size at the split would reveal a hard transition in shadow appearance.
Taos applies PCSS to the directional sun light's cascaded shadow maps. The shadowSoftness parameter is exposed through the settings UI (effect_settings.ts), giving the player control over how quickly shadows transition from sharp to soft.
8.9 Exponential Variance Shadow Maps (EVSM2) for the Sun#
PCSS (§8.8) produces excellent contact-hardening soft shadows, but its cost scales with the kernel: a 16-tap blocker search plus a variable-radius 16-tap PCF, per pixel, every frame. EVSM2 is an opt-in alternative for the directional cascades that trades that per-pixel kernel for a one-time bake. It carries VSM's filterable-moments idea (§8.3) over to the sun, with two changes: the moments are derived from the existing depth cascade map by a separate pass (rather than written at raster time), and what gets stored is an exponential warp of depth instead of raw depth. The softness is baked into a blur of the moment map, so the runtime shadow test is a single bilinear fetch whose cost is constant regardless of penumbra width — and the moment map is MSAA- and mip-friendly like any color texture.
The Bake: depth → warped moments → blur#
DirectionalVsmPass runs entirely off the depth cascade array that ShadowPass already produced — it adds no extra shadow rasterization. For each cascade layer it runs a fullscreen pass that warps the stored depth and writes the two moments, then a separable Gaussian blur (horizontal then vertical, sharing a single-layer scratch texture) pre-filters them:
// ── from src/shaders/shadow_vsm_moments.wgsl ──
// One fullscreen pass per cascade layer: read depth, warp, store the two moments.
let d = textureLoad(depthMap, coord, i32(params.layer), 0);
let w = evsm_warp(d, params.exponent); // w = exp(c · d)
return vec4<f32>(w, w * w, 0.0, 1.0); // (E[w], E[w²]) into the rg channels
Because the moments come out of a color attachment, the blur is an ordinary separable Gaussian — exactly what makes VSM "filterable like color." Pre-blurring the moments is what gives EVSM2 its soft edge; widening the blur widens every shadow uniformly at no per-pixel cost.
Why the exponential warp#
Plain VSM (§8.3) bleeds when a filter footprint straddles a large depth discontinuity: the variance between a near occluder and a far one is large, and Chebyshev's bound pMax = σ²/(σ² + d²) over-estimates the lit fraction for a receiver sitting in the umbra. The exponential warp attacks exactly this. Since exp grows ever faster, two surfaces a fixed depth apart map to an ever-larger warped gap the deeper they are:
In warped space the Chebyshev distance d = w_receiver − E[w] is pushed up far faster than the variance σ² grows, so the bound collapses toward zero and the umbra stays dark. Taos uses the positive warp only — this is "EVSM2"; the full "EVSM4" also stores a negative warp −exp(−c·d) and takes the minimum of the two bounds, catching the opposite bleed direction at double the storage. The shadow test, shared with the point/spot path through vsm.wgsl, warps the receiver depth the same way and applies the §8.3 light-bleed reduction:
// ── from src/shaders/modules/vsm.wgsl ──
fn evsm2_shadow(moments: vec2<f32>, depth: f32, c: f32, min_variance: f32, bleed: f32) -> f32 {
let w = evsm_warp(depth, c); // warp the receiver into moment space
let dw = c * w; // warp derivative → scale the variance floor
return vsm_chebyshev(moments, w, min_variance * dw * dw, bleed);
}
Precision: a memory ↔ quality knob#
The warp's strength is the exponent c, and c is capped by what the moment texture can hold — the second moment reaches exp(2c). That makes precision a direct memory-vs-quality trade, chosen with DirectionalVsmPass's momentPrecision option (the CPU reference vsm_math.ts owns the format→exponent mapping, unit-tested):
momentPrecision |
format | bytes/texel | exponent c |
quality |
|---|---|---|---|---|
fp32 |
rg32float |
8 | ≈ 40 | strong warp, little bleed — needs the float32-filterable feature |
fp16 |
rg16float |
4 | ≈ 5.5 (exp(11) < 65504) |
≈ plain VSM, half the memory |
auto (default) |
picks fp32 when filterable, else fp16 |
— | — | best available |
fp32 textures are only filterable — blurrable and linearly sampleable — with the optional float32-filterable adapter feature, which is why auto falls back to rg16float when it is absent. At 2048² × 4 cascades the moment array is ≈ 134 MB in fp32 versus ≈ 67 MB in fp16.
A compile-time variant that swaps bindings#
Sampling moments needs a filtering sampler and a float texture, where PCSS needs a depth texture and a comparison sampler. Carrying both would add a sampled texture — and the deferred fragment stage was already at WebGPU's 16-sampled-texture-per-stage limit. So EVSM2 is a SHADOW_FILTER_VSM compile-time shader variant that swaps the shadow binding in place rather than adding one: the depth-map + comparison-sampler bindings become a moment-map + linear sampler, plus a small uniform carrying the exponent, bleed amount, and variance floor. The swap is net-zero textures, so EVSM2 runs on exactly the hardware PCSS does. Forward+ shares one lighting/shadow bind-group layout across all four of its surface shaders (forward_plus.wgsl and the skinned / OpenPBR variants), so all four carry the variant. Cross-cascade blending mirrors the PCSS path — select the cascade, normal-bias the receiver, sample that cascade's moment layer, and smoothstep-blend into the next across the split band.
Enabling it#
EVSM2 is off by default in the engine presets and on by default in Crafty:
// Deferred (runtime-toggleable filter)
deferredPreset({ shadowFilter: 'vsm', lighting: { shadowVsmPrecision: 'auto' } });
// Forward+ (a construction-time choice — it selects the pipeline + layout)
forwardPlusPreset({ shadowFilter: 'vsm', shadowVsmPrecision: 'fp16' });
The shadows_test sample exposes a live PCSS ↔ EVSM2 toggle (plus blur radius and bleed sliders, or ?vsm to start in EVSM2). In Crafty it is the "Variance Shadows (EVSM2)" switch in the Lighting settings group; because the variant is compile-time, toggling it rebuilds the lighting pass through the normal renderer-rebuild path.
8.10 Clipmap Shadow Maps (Virtual Shadow Maps)#
Cascaded shadow maps (§8.2) spread a fixed texel budget across frustum-fit slices. Fitting those slices to the visible depth slab (SDSM) sharpens them, but it makes each cascade's size view-dependent: as the camera tilts, the cascade resizes, the texel-scaled bias and penumbra change with it, and shadows visibly stretch and shimmer. Taos's stable answer is a fixed, tilt-invariant cascade range — crisp, but it spends a fixed budget over a fixed distance.
The clipmap is an opt-in alternative directional layout that gets high-near-resolution and tilt-stability and continuous distance falloff with no per-view fitting. It is selected per shadow source — ShadowFeature.mode: 'cascade' | 'clipmap', default 'cascade' — and reuses the entire cascade render-and-sample pipeline; only the layout of the levels and how a fragment picks one change.
Concentric, camera-centered levels#
Instead of frustum slices, a clipmap is N concentric square ortho levels, all centered on the camera position and each covering 2× the world extent of the level below. Level 0 is small and dense (crisp right at the camera); each higher level is coarser but covers more, so resolution falls off with distance automatically and continuously — no split planes, no seams.
DirectionalLight.computeClipmapMatrices builds them, reusing §8.2's camera-centered fit, light-direction latch, and texel snap — just with fixed power-of-two box sizes instead of frustum-corner-fit spheres:
// ── from src/engine/components/directional_light.ts ──
for (let i = 0; i < numLevels; i++) {
const extent = this.clipmapLevel0Extent * Math.pow(2, i); // level 0 = 64 m, ×2 per level
const radius = extent * 0.5;
const texelWorldSize = extent / mapSize; // mapSize = 2048
// Camera-centered, texel-snapped box in light space (snap only while the light
// is static — see §8.2). minZ/maxZ span the box so casters above/below are kept.
// ... identical snap + ortho construction as computeCascadeMatrices ...
levels.push({ lightViewProj, splitFar: radius, depthRange: maxZ - minZ, texelWorldSize });
}
Because every level is camera-position-centered and texel-snapped, rotating or tilting the camera changes nothing — no level's size or texel density moves; the snapped window just slides as the camera translates. That is exactly the tilt-stability the view-dependent SDSM fit gives up, but here it comes without sacrificing near resolution.
Selecting a level by containment#
Cascades select by view-space depth split (§8.2). The clipmap instead picks the finest level whose box contains the receiver — the smallest, densest level that still covers the point. A single select_shadow_level serves both modes, branching on a shadowMode flag, and blends toward the next coarser level across a thin border band so the resolution step never shows:
// ── from src/shaders/deferred_lighting.wgsl ──
fn select_shadow_level(world_pos: vec3<f32>, view_depth: f32) -> LevelSel {
var sel: LevelSel;
if (light.shadowMode == 1u) { // clipmap
var lvl = light.cascadeCount - 1u;
for (var i = 0u; i < light.cascadeCount; i++) {
if (in_cascade(cascade_coords(i, world_pos))) { lvl = i; break; } // finest containing
}
sel.level = lvl;
// blend toward lvl+1 over the outer ~15% border of this level's box
let sc = cascade_coords(lvl, world_pos);
let edge = max(abs(sc.x * 2.0 - 1.0), abs(sc.y * 2.0 - 1.0));
sel.blend = smoothstep(0.85, 1.0, edge);
// ... sel.next = lvl+1 ...
} else { // cascade — select by depth split
sel.level = select_cascade(view_depth);
// ... split-band blend ...
}
return sel;
}
The tilt-stability now pays off in the filtering, not just the layout: because the levels are concentric and camera-centered, a given world point stays in the same level as the camera rotates, so the texel-scaled bias and PCSS penumbra (which depend on the selected level's texel size) hold steady instead of stretching.
Shared infrastructure#
computeClipmapMatrices returns the same CascadeData[] the cascade path produces, so the shadow render pass, the lighting bind groups, and the sampling code are reused wholesale. Three small changes carry it:
- The level cap rose 4 → 8 (
MAX_SHADOW_LEVELS). Cascade mode still uses ≤ 4; the clipmap renders up to 8 levels into a separately keyed deeper shadow-map array, so cascade-only scenes keep their 4-layer / 2048² footprint. - The lit shaders' uniform was re-laid-out to hold 8 layers:
cascadeMatricesbecamearray<mat4x4,8>and the per-layer splits / depth-ranges / texel-sizes becamearray<vec4,2>, plus ashadowModeflag. Indexing is[i>>2][i&3]via small accessors. - The unified
select_shadow_levellives in the deferred shader and all four Forward+ shaders, so clipmap composes with both PCSS (§8.8) and EVSM2 (§8.9) exactly as cascades do.
The mode is wired through ShadowFeature and read by the lighting features off frame.extras:
// ── enabling the clipmap (deferred or forward+) ──
engine.addFeature(new ShadowFeature({ mode: 'clipmap', clipmapLevels: 6, clipmapLevel0Extent: 64 }));
planet_explorer exposes a live "Clipmap shadows" toggle (or ?clipmap=1), mutually exclusive with SDSM (which is meaningless for a view-independent layout).
Virtualization — experimental#
The clipmap above renders every level in full each frame. Unreal's Virtual Shadow Maps goes further and virtualizes: each level is divided into fixed-size pages, and only the pages something actually samples are allocated and rendered, into a shared physical page pool, with a page-table indirection at sample time — a large VRAM win and, in principle, a render win. Taos has a GPU-driven implementation of this (mark → allocate → render → sample, all same-frame on the GPU, so residency tracks the live camera with no readback latency), exposed as ShadowFeature.virtualize / planet_explorer's ?vsmpages=1.
It is off by default and experimental. Without GPU per-triangle/cluster culling (Unreal relies on Nanite for this), a caster's whole mesh must be redrawn for every page it covers, so large casters — terrain tiles spanning a level — are far more expensive than the non-virtual path, and rendering artifacts remain. The page table + pool + GPU allocation are in place as the foundation; per-page cluster culling (the real render win) is tracked in TODO/virtual-shadow-maps.md. For now the non-virtual clipmap above is the practical default.
8.11 Contact Shadows (Screen-Space)#
A cascaded shadow map (§8.2) spreads a fixed texel budget across the whole view frustum, so the near cascade still resolves the world in centimeter-or-larger texels. That is too coarse for the fine occlusion where two surfaces meet — a tyre touching tarmac, a chair leg on the floor, a bolt head against a panel. Without it the object reads as floating, the contact line lit through. Contact shadows fill exactly that gap with a short screen-space ray-march toward the sun, run per-pixel in the lighting shader:
The March and the Depth Test#
For a lit pixel, the shader steps a few samples from the surface point along the toward-sun direction and asks the depth buffer whether anything sits between the surface and the sun. Each sample is projected to its screen pixel; the depth there is reconstructed to a world point; and the sample is occluded when that scene surface is closer to the camera than the sample (diff > 0) but within a thickness tolerance (diff < thickness) — close enough to be a real occluder rather than the distant background:
// ── from src/shaders/modules/contact_shadow.wgsl ──
var t = step_len * (1.0 + jitter); // jittered start — step past self
for (var i = 0u; i < steps; i++) {
let p = world_pos + sun_dir * t; // march toward the sun
let clip = view_proj * vec4<f32>(p, 1.0); // project this sample to screen
let uv = /* clip.xy / clip.w → [0,1]²; break if off-screen */;
let d = textureLoad(depth_tex, coord, 0); // scene depth there
let h = inv_view_proj * vec4<f32>(uv.x*2-1, 1-uv.y*2, d, 1); // → world point
let scene_world = h.xyz / h.w;
// scene closer to the camera than the sample → an occluder is in front
let diff = length(p - cam_pos) - length(scene_world - cam_pos);
if (diff > 0.02 && diff < thickness) { return 1.0 - intensity; }
t += step_len;
}
return 1.0; // never occluded → fully lit
The thickness window is what stops the sky or a far wall from casting a false shadow: a sample that reprojects onto distant background reconstructs far away, so diff blows past thickness and is ignored. The march starts one jittered step out — interleaved-gradient noise (cs_ign) per pixel — both to skip the surface's own depth (self-occlusion) and to dissolve the discrete steps into fine noise that TAA resolves, instead of visible banding.
Reconstruction, Reuse, and the Self-Contained Module#
The march lives in a shared module (contact_shadow.wgsl) that the deferred and Forward+ lighting shaders #import. Two deliberate design choices make it portable:
- It reconstructs the occluder with
inv_view_projrather than a linear-depth helper. Because that matrix encodes the projection, the same code is correct for both standard and reversed-Z depth — and a far-plane (sky) sample reconstructs far away and fails thethicknesstest on its own, so there is no separate background check and noREVERSED_Zvariant. - It imports nothing. The book's
#importsystem re-expands blocks textually rather than deduplicating them, so importingdepth_utilinside this module would double-declare it in any host (like the deferred shader) that also imports it. Keeping the module self-contained avoids the clash.
The host supplies the depth as a sampled texture_depth_2d. In the deferred path that is the G-buffer depth the lighting pass already binds; in Forward+ it is the depth pre-pass — and because the march only reads that depth (never depth-tests against it), the sub-pixel mismatch a pre-pass intentionally carries is absorbed by thickness. So contact shadows add no new pass, attachment, or binding — they reuse depth and the camera/light uniforms the lighting pass already has.
The result folds straight into the sun's shadow factor, so every direct lobe (diffuse, specular, clearcoat, sheen) is darkened consistently — it is not a separate AO term:
// ── from src/shaders/deferred_lighting.wgsl ──
shad = shad * contact_shadow_march(
depthTex, viewProj, invViewProj, cameraPos,
world_pos, normalize(-light.direction), light.contactShadow, in.clip_pos.xy);
Tuning#
The four parameters ride a vec4 appended to the deferred light uniform (contactShadow); steps == 0 disables the march, so a scene that never opts in is byte-identical to the old path. They are exposed as ContactShadowParams and set with DeferredLightingPass.setContactShadows(...) / DeferredLightingFeature.contactShadows / deferredPreset({ contactShadows }):
| Field | Meaning | Typical |
|---|---|---|
distance |
World-space length of the toward-sun march | 1–3 (fine contact AO) |
thickness |
Occluder thickness for the depth-crossing test | 0.2–0.5 |
steps |
March sample count (0 disables) |
8–16 |
intensity |
Darkening when occluded, [0, 1] |
— |
A longer distance reaches softer, broader contact; too large reads as a hard mid-range shadow the cascade should own instead. The contact_shadows_test sample (spheres and thin posts on a plane under a grazing sun) exposes all four as live sliders with an on/off toggle; garage_car_materialx enables them to tighten where the tyres and body meet the floor.
8.12 Summary#
| Technique | Used for | Texture | Resolution | Filtering |
|---|---|---|---|---|
| CSM + PCSS | Directional light (default) | depth32float 2D array |
2048 × 2048 × 4 | PCSS (blocker search + variable PCF) |
| CSM + EVSM2 | Directional light (opt-in, §8.9) | rg32float / rg16float 2D array of moments |
2048 × 2048 × 4 | warped Chebyshev bound + pre-blurred bilinear |
| Clipmap | Directional light (opt-in, §8.10) | depth32float 2D array, up to 8 levels |
2048² per level | shares PCSS / EVSM2; tilt-stable, distance-continuous |
| VSM | Point and spot lights | rgba16float cube-array (point) / 2D array (spot) |
256 (point face) / 512 (spot) | Chebyshev variance bound + bilinear filtering |
| Contact (screen-space) | Directional light fine contact | none — marches the scene depth buffer | — | jittered march + TAA |
| Depth-only | Standalone single-light passes (not in default pipeline) | depth32float |
configurable | Hardware PCF |
Further reading:
src/engine/components/directional_light.ts— camera-centered cascade fitting and texel snapping, pluscomputeClipmapMatrices(§8.10)src/renderer/render_graph/passes/shadow_pass.ts— CSM for directional light (and the clipmap level render)src/renderer/vsm_pages.ts— clipmap page-index / pool-slot / NDC-sub-rect math (§8.10 virtualization)src/renderer/render_graph/passes/vsm_page_mark_pass.ts/vsm_page_alloc_pass.ts/vsm_clipmap_shadow_pass.ts— GPU-driven page mark / allocate / render (experimental §8.10)src/shaders/deferred_lighting.wgsl— PCSS blocker search and variable-kernel PCF, and the EVSM2 sampling variantsrc/renderer/render_graph/passes/directional_vsm_pass.ts— bakes the depth cascades into EVSM2 moments (§8.9)src/shaders/modules/vsm.wgsl— shared Chebyshev bound + EVSM warp + light-bleed reduction (point/spot and directional)src/renderer/vsm_math.ts— CPU reference for the VSM/EVSM2 math + format→exponent selection (unit-tested)src/renderer/render_graph/passes/block_shadow_pass.ts— Chunk shadow maps (appends to CSM)src/renderer/render_graph/passes/point_shadow_pass.ts— Point light cube shadowssrc/renderer/render_graph/passes/spot_shadow_pass.ts— Spot light depth shadowssrc/renderer/render_graph/passes/point_spot_shadow_pass.ts— VSM for point + spot lights (the integrated path)src/renderer/render_graph/passes/terrain_point_spot_shadow_pass.ts— extends the point/spot VSM path to chunked terrainsrc/shaders/shadow.wgsl— Depth-only vertex/fragment shadow shadersrc/shaders/point_spot_shadow.wgsl— VSM shadow shadersrc/shaders/modules/contact_shadow.wgsl— screen-space contact-shadow march (deferred + Forward+)