Module: Cascaded Shadow Maps (CSM)
Package paths:
packages/babylon-lite/src/shadow/csm-directional-shadow-generator.tspackages/babylon-lite/src/shadow/csm-shadow-task-hooks.tspackages/babylon-lite/src/shadow/csm-shadow-cache.tspackages/babylon-lite/src/shadow/csm-refit-gate.tspackages/babylon-lite/src/shader/fragments/csm-shadow-fragment-core.tspackages/babylon-lite/src/material/standard/fragments/std-csm-shadow-fragment.ts
Purpose
Cascaded Shadow Maps for a directional light, matching Babylon.js
CascadedShadowGenerator with the default 5×5 PCF filter (computeShadowWithCSMPCF5).
The camera view frustum is split into N depth slices (cascades); each cascade gets
its own orthographic shadow map fit tightly to that slice, rendered into one layer
of a depth32float texture_2d_array. The receiver selects a cascade per fragment
from the camera-view-space depth and samples that array layer with PCF5, optionally
cross-fading into the next cascade near the slice boundary.
All substantive CSM code lives in the four modules above plus a byte-minimal set of shared edits (see Bundle Discipline), so ESM/PCF scenes are byte-unaffected.
Public API Surface
interface CsmDirectionalShadowGeneratorConfig { mapSize?: number; // per-cascade square resolution, default 1024 numCascades?: number; // default 4 (max 4) lambda?: number; // log/uniform split blend 0..1, default 0.5 cascadeBlendPercentage?: number; // cross-fade fraction, default 0.1 (0 disables) stabilizeCascades?: boolean; // bounding-sphere fit (no shimmer), default false shadowMaxZ?: number; // max shadow distance, default = camera far plane bias?: number; // depth bias, default 0.00005 worldSpaceBias?: number; // caster depth offset in world units; supplied non-positive/non-finite values disable bias darkness?: number; // 0 = black shadow, 1 = no shadow, default 0 frustumEdgeFalloff?: number; // soft cascade-edge fade 0..1, default 0 forceRefreshEveryFrame?: boolean; // default false}
function createCsmDirectionalShadowGenerator(engine: EngineContext, light: DirectionalLight, cfg?: CsmDirectionalShadowGeneratorConfig): ShadowGenerator;
interface CsmStaticCacheOptions { refitAngle: number; refitMaxIntervalMs?: number; staticCascadesPerFrame?: number; // 0 or omitted: every cascade re-renders in the refit frame cascadeCoveragePadding?: number; // 0 or omitted: off; otherwise 2 / mapSize through 1, stabilizeCascades only}
function enableCsmStaticCache(engine: EngineContext, shadowGenerator: ShadowGenerator, options: CsmStaticCacheOptions): Promise<void>;
function getCsmReceiverTexture(shadowGenerator: ShadowGenerator): Texture2D;
function onCsmReceiverUpdate(shadowGenerator: ShadowGenerator, callback: (data: Float32Array) => void): () => void;
function setShadowCasterMaxCascade(mesh: Mesh, maxCascade: number): void;
interface CsmRefitCaster { readonly worldMatrixVersion: number; readonly thinInstances?: { readonly _version: number } | null;}
interface CsmRefitGateOptions { refitAngle: number; refitMaxIntervalMs: number; demoteQuietFrames?: number;}
interface CsmRefitDecision { refit: boolean; renderDynamic: boolean;}
interface CsmRefitGate<M extends CsmRefitCaster> { syncCasters(casters: readonly M[]): void; markDynamic(caster: M): void; isDynamic(caster: M): boolean; update( lightDirX: number, lightDirY: number, lightDirZ: number, nowMs: number, cameraChanged: boolean, force: boolean, onPromote: (caster: M) => void, onDemote: (caster: M) => void ): CsmRefitDecision;}
function createCsmRefitGate<M extends CsmRefitCaster>(options: CsmRefitGateOptions): CsmRefitGate<M>;Usage mirrors the other directional generators:
const light = createDirectionalLight([0, -1, -1], 0.8);addToScene(scene, light);light.shadowGenerator = createCsmDirectionalShadowGenerator(engine, light, { mapSize: 1024 });setShadowTaskCasterMeshes(light.shadowGenerator, casterMeshes);// receivers: mesh.receiveShadows = trueawait registerSceneWithShadowSupport(scene);setShadowCasterMaxCascade(mesh, maxCascade) limits a caster to cascade layers
0..maxCascade (0 is nearest). The default is all cascades; pass Infinity to
restore it. Values must be non-negative integer indexes or Infinity. The cap is
snapshotted when setShadowTaskCasterMeshes supplies the caster set, so changing a
live cap requires re-supplying a new caster-array instance. CSM updates only the
changed caster's per-cascade task membership; ESM and single-map PCF ignore the cap.
Custom ShaderMaterial receivers use the same public generator without reading its
internal WebGPU resources:
const material = createShaderMaterial({ // ...sources, attributes, and uniforms... samplers: [{ name: "csmShadow", sampleType: "depth", viewDimension: "2d-array", comparison: true }],});setShaderTexture(material, "csmShadow", getCsmReceiverTexture(light.shadowGenerator));onCsmReceiverUpdate(light.shadowGenerator, (data) => { // Mirror the documented 80-float receiver layout into the custom material.});getCsmReceiverTexture accepts only a generator created by
createCsmDirectionalShadowGenerator; other shadow techniques throw. It returns a
borrowed Texture2D whose view is explicitly "2d-array", whose sampler is the
generator's comparison sampler, and whose sample type is depth. The same wrapper
object is returned for every call on one generator. It shares the generator's
lifetime and must not be released or disposed independently.
A custom ShaderMaterial that both receives CSM shadows and casts them must not cast through
itself: the depth-only caster view shares the source material's bind group, so the caster pass would
sample the cascade array it is rendering into. Give it a sampler-free caster material (same vertex
stage, and it may share the same storage buffers so GPU-deformed geometry casts its real silhouette)
and wire it with the public setShadowCasterMaterial(visibleMaterial, casterMaterial).
Internal Architecture
ShadowGenerator extensions (shared interface, type-only)
_shadowTypeunion widened"esm" | "pcf"→"esm" | "pcf" | "csm"._csmCascadeCount?: number— number of cascades, read by the receiver renderable to bake the cascade-select loop bound._csmReceiverTexture?: Texture2D— lazily created borrowed public wrapper for custom receivers. It creates exactly one explicit 2d-array view, is cached on the generator rather than in module state, and establishes one generator-owned texture reference so ShaderMaterial acquire/release cycles cannot destroy the shared shadow map.
Static cache and refit gate
Awaiting enableCsmStaticCache(engine, generator, options) enables the static
shadow cache before scene registration or receiver-texture access. Casters begin in the dynamic
overlay, become static after 120 quiet frames, and return to the dynamic set
immediately when their world or thin-instance version changes. A refit recomputes
the cascades and refreshes the private static depth array after the light drifts by
options.refitAngle, the camera changes (with options.cascadeCoveragePadding, only the
changes listed under Camera-coverage refit below), caster membership changes, a static
caster moves, or options.refitMaxIntervalMs expires. The interval remains active while the
light is paused so GPU-clock-animated static casters continue refreshing. Generators
that are not explicitly enabled preserve the original single-task path, allocate no
cache texture, and do not load the cache implementation.
With options.staticCascadesPerFrame above zero, a refit whose only cause is light drift
re-renders at most that many static cascades per frame, round-robin, so the periodic refresh
costs a slice of every frame instead of one long frame. Each selected cascade advances
atomically: its shadow camera and receiver transform are updated together, its static layer is
rendered, only that array layer is copied to the live shadow map, and only that layer's dynamic
overlay is rendered. Cascades still waiting retain both their previous depth and previous
receiver transform, so the sampled data remains coherent throughout the spread.
Published cascades keep owned copies of their light view and caster view-projection. A shadow camera holds references to the arrays it is given, and a refit recomputes the shared fit scratch for every cascade while some stay published: a spread publishes them over several frames, and a coverage refit (below) republishes only the cascades that fail. With references, a waiting cascade's camera (and so the culling of its dynamic overlay) would take the new fit before its static layer is rendered with it.
A refit caused by the camera (without cascadeCoveragePadding; see below; a change of active
camera counts too, by identity, since the camera change key is a per-camera counter sum that two
cameras can share), scene content,
caster set, promotion or demotion updates every cascade in its own frame, as without the option. A drift refit that lands while a spread is
still draining also updates every cascade immediately, preventing a fast-moving light from
continually replacing the pending generation. If a dynamic caster changes during a spread,
the cache is copied and the dynamic overlay is redrawn for every cascade, while each static
layer remains paired with its currently published transform.
Camera-coverage refit (cascadeCoveragePadding)
Off by default (0 or omitted): every camera change refits and re-renders all cascades,
as described above. A positive value must lie between 2 / mapSize and 1 and requires
stabilizeCascades: true; enableCsmStaticCache rejects any other value. The floor exists
because texel snapping shifts a fit by up to sqrt(2) / mapSize of its radius: a fresh fit
then reaches at most 1 / (1 + padding) + sqrt(2) / mapSize of its own coverage disc, which
stays at or below 1 from 2 / mapSize. It is a worst-case bound, not a measured threshold.
- Padded fit. Every fit of the cached generator passes
radiusScale = 1 + paddingto_computeCsmCascades, which widens each stabilized bounding sphere by that factor before the 1/16 rounding. Texels grow by about the same factor. - Coverage test. On each frame where the camera's transform version changed, the
current slice of every cascade is tested against that cascade's published map: its
eight corners (from
_csmSliceCornersInto, the slice geometry shared with the fit, using the published split distances) must lie inside the unit disc of the cascade's shadow clip space (read from the receiver UBO) and inside the light-depth range[0, 2R](read from the published light view, before the caster-bound depth tightening). - Same-frame refit. The cascades that fail are refit and re-rendered in that frame, alone: a fresh fit is computed, and each failed cascade's camera, receiver UBO slot, static layer, array-layer copy and dynamic overlay advance together, as in a spread. Cascades that still pass keep their map, camera and transform.
- Full refit. A change of active camera (identity, read from the camera the cascades were
last fit for,
CsmTaskState._lastCamera:_projRevis a per-camera counter, so two cameras can share a revision), of its projection revision (fov, near/far, orthographic bounds) or of the effective aspect refits every cascade, because the splits or slice shapes change. The light, content, caster and interval causes are unchanged. - Spread publication. A cascade a spread refit publishes one or two frames after its fit is refit first when the camera moved since that fit, so every cascade published while the camera moves is fit to the pose it is shown with.
- Scheduler. A cascade that fails while a spread still waits to render it is re-rendered
now, outside the per-frame budget, and removed from the pending set with the internal
scheduler's
claim(), so the spread does not render it a second time. - Floating origin. Under a floating origin the camera view-projection is eye-relative
(
getViewMatrix), so a pure translation moves no slice corner; the option then falls back to refitting every cascade on every camera change. - Receiver callbacks.
onCsmReceiverUpdatecallbacks keep the per-frame cadence they have without the option: they are invoked on every frame the camera's change key moves, with the current receiver data, even when that frame redraws no cascade (consumers re-upload their own camera-dependent terms from them). A still camera invokes them only on the frames that publish cascades, as without the option.
What this covers: after every frame, each cascade's map contains the eight corners of the
camera's current slice for that cascade by the rule above, so the whole (convex) slice. That
is the level a fresh unpadded stabilized fit already reaches (texel snapping takes it to about
1.0005 of the disc), not a stronger one. It does not cover the 5×5 PCF footprint at the disc
edge, nor the cross-fade band: fragments in the last cascadeBlendPercentage of a slice
sample the next cascade, outside that cascade's slice, and with the option that band reaches
further into the next cascade's map than with a fresh fit.
createCsmRefitGate exposes the CPU-only partition/refit state machine for consumers
that need the same policy without engine or WebGPU dependencies. The spread scheduler and its
drift classification are internal cache-orchestration details. Re-supplying a new array with
identical caster membership does not invalidate the cache.
Receiver UBO layout (_shadowUBO, 320 bytes / 80 f32)
| offset (f32) | field | type |
|---|---|---|
| 0..63 | cascadeTransforms | array<mat4x4, 4> |
| 64..67 | viewFrustumZ | vec4<f32> |
| 68..71 | frustumLengths | vec4<f32> |
| 72..75 | shadowsInfo | vec4<f32> (darkness, mapSize, 1/mapSize, frustumEdgeFalloff) |
| 76..79 | csmParams | vec4<f32> (cascadeCount, cascadeBlendFactor, 0, 0) |
cascadeBlendFactor = cascadeBlendPercentage === 0 ? 10000 : 1 / cascadeBlendPercentage.
Unused cascade slots (when numCascades < 4) are never read — the WGSL loop bound is
the baked cascade count.
Shadow map texture
depth32float, size mapSize × mapSize × numCascades,
RENDER_ATTACHMENT | TEXTURE_BINDING plus COPY_DST when static caching is enabled.
The private static cache uses RENDER_ATTACHMENT | COPY_SRC. Receiver view:
dimension:"2d-array". Per-cascade
caster render targets use a single-layer view
(createView({dimension:"2d", baseArrayLayer:i, arrayLayerCount:1})). Comparison
sampler compare:"less", linear filtering.
Built-in material receivers bind the generator's internal texture and sampler through
their material pipeline. Custom ShaderMaterial receivers obtain the equivalent
borrowed Texture2D only through getCsmReceiverTexture; raw GPUTexture,
GPUTextureView, and GPUSampler handles never cross that public boundary.
Pipeline Configuration
- Caster pass: N depth-only render tasks (one per cascade layer), each rendering
every caster through the material family's no-color view, clearing the layer to
depth 1.0 with
depthCompare:"less-equal". The per-cascade camera facade carries the cascade view matrix + bias-adjusted ortho·view transform. Legacybiassupplies the existing normalized projection offset.worldSpaceBias, when present, extends the fitted far plane by that distance, then converts the authored world-space distance into a per-cascade clip offsetworldSpaceBias / (paddedFar-near). The physical separation stays constant while a moving light or caster changes the fitted cascade depth range, and far-bound casters remain inside the clip volume after the offset. - Static-cache caster passes: after
enableCsmStaticCache, every cascade owns a clearing static task targeting the private cache and a non-clearing dynamic task targeting the live map. Refit frames render static tasks, copy the complete cache array to the live map, then depth-test the dynamic overlay against that copy. Overlay-only frames repeat the copy before drawing dynamic casters so moved shadows cannot leave stale depth. Frames with no refit and no dynamic change issue neither passes nor copies. - Receiver pass: group-2 bind group per CSM light =
[arrayDepthView, comparisonSampler, csmUBO](binding order 0,1,2). The 2d-array view dimension is produced by the shader composer (bglEntrymaps_textureTypecontaining"array"→viewDimension:"2d-array").
Shader Logic (WGSL outline)
Receiver, per CSM light (suffix _<lightIndex>, N = baked cascade count):
// cascade select from camera-view-space depth, LHlet viewZ = (scene.view * vec4(vp, 1.0)).z;var idx = -1; var diff = 0.0;for (var i = 0; i < N; i++) { diff = csmInfo.viewFrustumZ[i] - viewZ; if (diff >= 0.0) { idx = i; break; }}if (idx < 0) { idx = N - 1; }
var shadow = csmSample(idx, vec4(vp, 1.0)); // PCF5 on layer idx// optional cross-fade into next cascadelet ratio = clamp(diff / csmInfo.frustumLengths[idx], 0.0, 1.0) * csmInfo.csmParams.y;if (idx < N - 1 && ratio < 1.0) { shadow = mix(csmSample(idx + 1, vec4(vp, 1.0)), shadow, ratio);}shadowFactors[lightIndex] = shadow;csmSample(layer, worldPos):
let p = csmInfo.cascadeTransforms[layer] * worldPos;let clip = p.xyz / p.w;let uv = vec2(0.5*clip.x + 0.5, 0.5 - 0.5*clip.y); // Lite Y-flip conventionlet depthRef = clamp(clip.z, 0.0, 0.99999994); // GREATEST_LESS_THAN_ONE// 5×5 PCF (9 textureSampleCompareLevel taps, /144 weighting)// textureSampleCompareLevel(csmTex, csmComp, base + offset, layer, depthRef)return computeFallOff(mix(darkness, 1.0, sh), clip.xy, frustumEdgeFalloff);The 0.99999994 clamp is critical: fragments projecting beyond a cascade's far plane
must compare strictly less than the cleared shadow-map value (1.0) so they read as
lit, not shadowed.
vp is the existing world-position varying. Standard CSM derives camera-view-space
depth from scene.view × vec4(vp, 1) in the fragment shader. It must not depend on
the fog-only vf varying, so a non-fog CSM material composes without retaining fog
WGSL or requiring fog-generated vertex output. CSM still avoids emitting N
per-cascade light-space varyings.
CSM Math (csm-shadow-task-hooks.ts)
Splits (_computeCsmCascades)
near = camera.near, far = camera.far, cameraRange = far - near,
maxDistance = shadowMaxZ < far && shadowMaxZ >= near ? min((shadowMaxZ-near)/cameraRange, 1) : 1,
minZ = near, maxZ = near + maxDistance*cameraRange, range = maxZ-minZ, ratio = maxZ/minZ.
For p = (i+1)/N: log = minZ*ratio^p, uniform = minZ + range*p,
d = lambda*(log-uniform) + uniform.
viewFrustumZ[i] = d; breakDist[i] = (d-near)/cameraRange;
frustumLengths[i] = (breakDist[i]-prevBreak)*cameraRange.
Per-cascade matrix
- Invert the reverse-Z camera view-projection (
getViewProjectionMatrix). Transform the 8 reverse-Z NDC frustum corners (near z=1, far z=0) to world space. - Slice [prevSplit, split]:
corner[k] = near + ray*prevSplit,corner[k+4] = near + ray*splitwhereray = far - nearper side. - Centroid = mean of the 8 slice corners.
- Fit a light-space AABB: temp
LookAtLHfrom centroid alonglightDir(buildLightViewMatrix), transform corners, take min/max extents. (stabilizeCascadesinstead uses aceil(radius*16)/16bounding sphere.) - Shadow camera eye =
centroid + lightDir * minExtents.z; cascade view =buildLightViewMatrix(lightDir, eye). - Z range:
viewMinZ = 0,viewMaxZ = extents.z, then tightened to the casters' world-AABB Z in cascade view space (depthClamp-false behaviour:viewMinZ = min(0, castersMinZ),viewMaxZ = min(extents.z, castersMaxZ)whencastersMinZ <= viewMaxZ). v1 uses depthClamp = false so no GPU depth-clip feature is required. A positiveworldSpaceBiasthen extendsviewMaxZby the same distance so the farthest fitted caster is not clipped after biasing. ortho = OrthoOffCenterLH(minX,maxX,minY,maxY, viewMinZ, viewMaxZ)(column-major, half-z, near→0 far→1 — same convention as the PCF generator's shadow ortho).transform = ortho · view. Texel snap (always applied): project the world origin (transform[12], transform[13]),× mapSize/2, round, build an XY translation of the rounded offset,transform = (T·ortho) · view.- Receiver
cascadeTransforms[i] = transform(unbiased). Caster camera view-projection addsclipOffset·wto its Z row, whereclipOffset = bias·0.5for the legacy normalized bias, orworldSpaceBias / (paddedViewMaxZ-viewMinZ)for a world-space bias. The latter is invariant in world units even when caster-AABB fitting changes the range.
State Machine / Lifecycle
createShadowTask (scene-owned) drives the generic hooks:
_preloadShadowTask → loads the no-color material-view factories.
_ensureShadowTaskState → builds N per-layer render targets + cameras + tasks once;
they live as long as the generator, and later caster changes are reconciled into them
(see the caster reconcile below).
_renderShadowMap → per frame, dirty-checked on casterVersion + lightVersion + cameraVersion, the aspect and the camera's identity (CsmTaskState._lastCamera, since two
cameras can share a change key); recomputes splits + matrices, writes the 320-byte UBO (bumping
_version), updates each cascade camera, executes all cascade tasks.
With static caching enabled, the dynamically imported csm-shadow-cache.ts owns the
GPU task split and csm-refit-gate.ts owns the caster partition and refit decision.
A cascade's matrix, receiver UBO slot and shadow-camera version change only in a frame that
re-renders that cascade's static layer and redraws its overlay: every cascade on a refit
frame (spread over the following frames for a drift refit), and, with
cascadeCoveragePadding, the cascades whose map stopped covering the camera on frames where
the gate decided no refit. Dynamic-only frames reuse the published cameras so cached and
overlay depth stay in the same coordinate system. Quiet dynamic casters demote only during a
refit. A changed static caster promotes immediately and forces that refit. Resolved
renderables transfer between task sets without rebuilding their per-mesh packets.
The cache state (tasks, cache texture, gate) is created on the first ensure (or when it
replaces a default state built before the cache chunk loaded) and kept for the generator's
lifetime; its initial camera key forces the new cache texture to be populated before use.
Caster changes are reconciled into it like the default state. The cache also drops the
removed and held casters from its static tasks and moves each requeued caster the gate
holds static from the dynamic overlay back into the static tasks, so a static caster whose
material was rebuilt or re-pointed, or whose cascade cap changed, stays static. A caster
that leaves the set and returns later is new to the gate, which forgets departed casters,
so it starts dynamic like any new caster. Any drop or requeue forces a full refit, so every static cascade
re-renders without the old depth (a re-capped caster's depth leaves the cascades above its
new cap at once); when a caster was queued, the scheduler's record also re-binds every
static task. Besides the requeued casters' own packets, that refit and that re-bind are
the whole cost of a caster change: no task, target, cache texture or kept caster packet is
recreated.
The custom-receiver texture wrapper is lazy and generator-scoped. The first
getCsmReceiverTexture call validates the CSM technique, creates the array view, and
caches the wrapper. It also anchors the generator's texture ownership in the shared
ref-count pool; receiver materials may acquire and release the wrapper without
destroying the generator-owned depth array. Later calls preserve object/view identity.
Shadow-map recreation is not supported by the current fixed generator configuration;
therefore the wrapper remains valid until the generator's GPU resources are disposed
with the scene.
onCsmReceiverUpdate immediately replays the most recently published 80-float UBO to
a late subscriber once at least one cascade update has completed. This is required in
cache mode because the next refit may be far in the future.
Each CSM task state also snapshots every caster's maximum cascade and renderable
generation (Mesh._renderableGen) and, per receive material, its terminal caster
material, that material's _csmGen and its _renderFeatures object. Every ensure
reconciles them with the live casters (_reconcileCsmCasters):
- Requeue. Casters that left the set or were re-capped, casters whose own renderable
was rebuilt, and casters whose no-colour view is stale, are removed from every cascade
task in one pass per task, their packets retiring behind the frame fence. The ones
still in the set are queued again: a caster with a stale view through a fresh one, a
caster that was only re-capped or rebuilt through the cached view of its material. A
caster's renderable is rebuilt when its
_renderableGenmoved: every path that rebuilds a mesh's renderable and retires its per-mesh resources moves it (the material-swap drain, a runtime build, a material or group rebuild), and the caster's packets may reference those resources. Only that caster is requeued: adding one mesh with a shared material, or replacing one mesh's geometry, leaves the packets of the material's other casters alone. A view is stale when the caster's material was rebuilt (_csmGenbump), has recomputed_renderFeatures(material plugins drop them and rebuild the meshes one by one; dropped features not recomputed yet keep the current view) or was re-pointed (setShadowCasterMaterial) since the snapshot, or when the caster's material is missing from it, because it switched material, got its first one, or joins with an unseen material. A joining caster counts too: another caster's chain may have cached a view on its chain before the terminal was rebuilt, and that caster may leave or be re-pointed in the same ensure, so the prune would keep that view (below). A caster whose family has no no-colour view is queued with its own material, as the first build queues it. All other packets are kept, and so are the tasks, targets and cameras (with static caching, the cache texture and refit gate). - Hold. A changed caster waits while its view cannot be built yet: the no-colour
view factory of its family is not imported (the generator is parked once while it
imports, as for a re-supplied set), or the material it casts through itself has no
completed group build in this scene (its swap drain or runtime build provides one).
The hold is per caster: other changed casters are requeued at once (one whose
material was only rebuilt, through a fresh view), and caster-set changes apply
meanwhile. A
setShadowCasterMaterialoverride is not held for its group; recording it throws as before. The reconcile keeps the held casters (_held): while they are unchanged, an ensure with the same caster array and no stale material returns without touching any task. It runs again when the array changes, a caster material is rebuilt or re-pointed, or the hold changes or lifts, also without a scene version bump (a factory import lands, a change is reverted). A renderable version or material epoch move alone changes nothing: a rebuilt caster material moves its_csmGenand a rebuilt caster its_renderableGen, which the scan reads. - What a held caster draws. Nothing. It leaves every cascade task (and static-cache task), its packets retired once behind the frame fence. Those packets were built through its previous material and may reference per-mesh resources retired with it, possibly in a frame the shadow task did not see because the generator was parked. A registered held caster keeps its cap entry, and its material is not snapshotted, so it still reads as changed: it is requeued through a fresh view once the hold lifts, or rejoins if its change is reverted. A new one stays out until it can join. The hold is decided per material, so every caster of a held material waits: queueing a new one would snapshot the material, and a registered caster sharing it would then never be requeued. The drop forces a redraw, so the depth a held caster last rendered leaves the map with the next draw. It is dropped once, when it becomes held: a later reconcile that still holds it has nothing to drop.
- A caster without a material. One with no packet waits like a held caster: one
whose hold lifted to no material, or one that joined, or was in the first build,
without a material (it gets no cap entry until it is queued; the first build starts
_heldwith them). One re-capped without a material waits too: its packets are dropped then, since its cap changed. It is queued once a material is assigned, also one the snapshot already knows, which would otherwise read as unchanged and leave it out for good while the same caster array is re-supplied. A caster that has packets keeps them when its material becomesnulland its cap does not change (below). - Not covered. Forgetting a stale chain's views can leave two views of an unchanged
link: another live caster drawing through it (the re-pointed material's new target, or
a link of a joining caster's chain) keeps its packets on the old view while the stale
chain's casters get a new one. Both are valid. A registered caster whose material
becomes
nullkeeps casting through its old packets. A group that never builds keeps the caster held, and out of the cascades, until its material is reassigned or it leaves the set: after a failed runtime build, which reports its own error, or for a caster mesh outside the scene, which gets no build. A caster that switches material and back while the generator is parked, with the switch back still queued behind an in-flight runtime build, keeps its packets: the scan sees the same material and_csmGen(this also happens without a hold).
The shadow scheduler resolves the queued casters in its next record, one transaction per
cascade that re-binds the kept packets once without rebuilding them. Because neither a new
caster array nor a re-point bumps a scene version, the reconcile forces a redraw
(_lastCasterVersion = -1) whenever a caster was dropped or queued, and the record
(_recordedVersion = -1) only when one was queued: a drop alone has already filtered the
binding lists. If that record throws, the replaced casters are missing from the cascade
until a later record succeeds. On the same change path the material snapshots and views are
pruned to what the live casters reach (their receive materials and every link of their
_shadowCasterMaterial chains), so the state does not retain materials of departed casters.
The prune runs after the queue and keeps every view a live chain reaches, so fresh views
rest on the staleness rule: a material that casts again later, possibly rebuilt meanwhile
through a non-caster mesh, is missing from the snapshot, so its chain's views are forgotten
first. A new array with the same members, caps and unchanged materials, also while a caster
stays held, is only adopted: no binding list changes, so every cascade bundle and binding
version is kept, and a rebuilt material no caster uses changes nothing.
Babylon.js Equivalence Map
| Babylon.js | Babylon Lite |
|---|---|
CascadedShadowGenerator._splitFrustum | _computeCsmCascades (split section) |
_computeFrustumInWorldSpace | reverse-Z frustum corner extraction + slice |
_computeCascadeFrustum | centroid + light-space AABB / sphere fit |
_computeMatrices (ortho + snap) | orthoOffCenterLH + texel-snap block |
computeShadowWithCSMPCF5 | csmSample_<i> (PCF5, array layer) |
cascade select in lightFragment.fx | computeShadowCSM_<i> loop + blend |
GREATEST_LESS_THAN_ONE | 0.99999994 depthRef clamp |
Two deliberate deviations from default BJS, applied symmetrically to the BJS oracle so
parity holds: reverse-Z NDC (Lite's projection) and depthClamp = false
(avoids the optional depth-clip-control WebGPU feature). Both are reflected in the
reference scene (sg.depthClamp = false). Result: full-image MAD = 0.000.
Bundle Discipline (no movement for unrelated scenes)
Shared edits are byte-minimal:
- TS union widenings
"esm" | "pcf"→ add"csm"(type-only, 0 runtime bytes) inshadow-generator.ts,standard-renderable.ts; PBR/Node renderables filter out CSM lights (they ignore CSM in v1). _depthViewfield swap in the three receiver renderables (call → field read).- One
hasCsm-gated dynamic import ofstd-csm-shadow-fragment.tsinstandard-group-builder.ts. shader-composer.tsbglEntrygains"array"→"2d-array"view-dimension support (a few bytes on the shared material chunk; well within ceilings).
All cascade math + WGSL live in the four new modules, dynamically imported only by scenes that create a CSM generator.
The static-cache orchestration and refit gate live behind the dynamic import performed
by enableCsmStaticCache. Default CSM scenes therefore do not fetch the cache task
split, transfer helper, or gate.
Dependencies
shadow-base (buildLightViewMatrix, multiply4x4, createShadowCamera,
updateShadowCameraBase, createShadowParamsUBO, casterVersionSum),
pcf-shadow-task-hooks (getNoColorView, preloadPcfShadowTaskState),
math/invert-mat4, camera (getViewProjectionMatrix), frame-graph/render-task,
csm-refit-gate.
Test Specification
tests/lite/parity/scenes/scene214-cascaded-shadows.spec.ts — captures the BJS CSM
oracle (captureGolden({ force: true })) and compares the Lite render of
scene214.html (6×6 Standard box casters + Standard ground receiver, 4-cascade CSM).
Threshold maxMad in scene-config.json (achieved MAD = 0.000).
tests/lite/unit/csm-world-space-bias.test.ts proves that per-cascade clip offsets
map back to the same authored world-space distance across changing fitted depth
ranges, preserve a tightly fitted far caster after the projection reserves bias
headroom, and produce no bias for invalid or collapsed inputs.
tests/lite/unit/csm-receiver-texture.test.ts proves that the public custom-receiver
adapter creates one explicit 2d-array depth wrapper, reuses the generator texture and
comparison sampler without exposing them in its signature, preserves wrapper identity,
survives a ShaderMaterial acquire/release cycle, and rejects ESM/PCF generators.
tests/lite/unit/shadow-caster-max-cascade.test.ts validates cap input, default and
reset behavior, and in-place reassignment of an existing caster across cascade tasks
after a live cap change (queued, with one forced record).
tests/lite/unit/csm-caster-material-switch.test.ts drives the real shadow task,
mesh.material setter, swap drain, rebuildMaterial and task transaction, with fresh
modules per case so no view factory leaks between cases. For the default and
static-cache hooks it proves that a registered caster switching to (or getting) an
unseen material is requeued through it in the same cascade tasks, retiring only its
old packets and keeping the cache texture and refit gate, that a new caster with an
unseen material is simply queued, that a held caster neither throws nor creates
anything and parks the generator once, that set changes and the requeue of other
casters apply during a hold while held casters stay out of every task with their caps
kept, and that held casters join when the hold lifts with or without a version bump, or
when their change is reverted (with an unchanged cap, back on the cached view of a
material another caster still draws). An override whose group is not built still throws. With
packets that own resources and per-mesh resources retired by rebuildMaterial or the
swap drain, it proves that a caster rebuilt while another caster is held retires its old
packets once behind the fence and casts again at once through a fresh view, also over
many frames of a hold whose build never lands, with no draw submitting a released
resource and no bundle touched; that a held caster whose previous material was rebuilt
is dropped too; that a caster switched and reverted during a hold casts its old
material again; and that no draw submits a released resource of a held caster whose
material was switched while the generator was parked, whether it then waits for its
view factory (the pass whose ensure parks the generator again still draws) or for its
group. Frames of an unresolved hold leave every task's bundles and binding version
alone and retire nothing, and a pending caster or cap is applied once when the hold
lifts by a group build or, without a version bump, by a factory import.
tests/lite/unit/csm-caster-reconcile.test.ts proves that a rebuilt or re-pointed caster
material keeps the cascade tasks (and, with the static cache, the cache texture), requeues
only that material's casters with one fresh view shared through override chains, queues
casters added to recorded cascades instead of rebinding the task per caster, requeues a
rebuilt caster in the same reconcile that holds a joining caster whose group is not built
(and queues that one once the build lands, with the same caster array), removes stale
casters with one batch selection per task (leaving tasks without them untouched), forces
only a redraw for a drop, forgets the materials and views no live caster reaches (so a
former override terminal rebuilt after a re-point gets a fresh view when a caster joins with
it), gives a caster joining with a rebuilt override terminal a fresh view when the chain
that cached the terminal's view loses its caster or is re-pointed in the same ensure (in
both hook sets), ignores non-caster materials and same-member re-supplies (also while a
joining or registered caster stays held: no record, redraw or static refit, in both hook
sets), keeps a re-pointed caster whose family factory is missing out of every task (the
other packets kept, and no task touched again while the hold is unchanged), requeues a
static-cache caster (rebuilt or re-capped) into the task set of its current class while the
other packets are kept, requeues a caster whose family has no no-colour view through its own
material, goes quiet again for the same caster array once the last held caster has joined or
its change was reverted (no bundle clear, retirement or new view, in both hook sets), and,
through the real shadow scheduler, publishes the replacement packet before the shadow map
renders.
tests/lite/unit/csm-refit-gate.test.ts validates stable re-supply, version-sum
collision handling, promotion/demotion timing, angular drift, and interval refits.
tests/lite/unit/csm-static-refit-scheduler.test.ts validates the spread budget, round-robin
order and maximum lag, and that claim() removes only a pending cascade and leaves the next
spread budgeted.
tests/lite/unit/enable-csm-static-cache.test.ts additionally validates that
cascadeCoveragePadding defaults to 0, is stored when valid, and is rejected outside [0, 1],
below 2 / mapSize, or without stabilizeCascades.
tests/lite/unit/csm-camera-coverage-refit.test.ts drives the real cached render, cascade fit,
arc-rotate camera and directional light (only the GPU tasks are stubbed) and counts static
re-renders. It proves that without the option an orbit re-renders every cascade every frame,
and that with it the same orbit re-renders far fewer; that after every frame the published
maps satisfy the coverage rule (checked by re-implementing that rule from the receiver UBO and
shadow cameras, so it catches a bypassed test, not a wrong rule), with and without the
interval floor, for a camera sliding along the light direction, and for seeded back-and-forth
yaw jumps that publish spread cascades late; that texels widen by the padding factor; that a
still camera costs only the interval floor; that a coverage miss re-renders only the missed
cascades, once, while the others keep their camera matrices; and that a field-of-view change,
a swap to a camera sharing the previous one's projection revision, or a floating origin
refits every cascade, and that without the option a swap to a camera sharing the previous one's
change key still refits every cascade, once. It also checks that the receiver callbacks fire on every frame the camera
key changes, as without the option, and that a still camera adds none beyond the default path.
tests/lite/unit/csm-default-camera-swap.test.ts drives the real default (uncached)
renderCsmShadowMap and proves that a swap to a camera sharing the previous one's change key and
aspect refits every cascade to the new camera's splits, once.
tests/lite/unit/csm-static-spread-camera-ownership.test.ts drives the real cache through a
drift refit spread over several frames and proves that a cascade not yet re-rendered keeps the
camera matrix its static layer was rendered with, and that every dynamic overlay is drawn with
its static layer's matrix while the spread drains.
tests/lite/unit/light-version.test.ts validates that direct scalar light mutations
can bump the light UBO version without dirtying the world matrix.
File Manifest
shadow/csm-directional-shadow-generator.ts— public factory, custom-receiverTexture2Dadapter, update subscription, and texture/UBO/sampler ownership.shadow/csm-shadow-task-hooks.ts— cascade math + N-layer caster render hooks.shadow/csm-shadow-cache.ts— dynamically loaded static-cache GPU orchestration.shadow/csm-refit-gate.ts— GPU-free static/dynamic partition and refit policy.shader/fragments/csm-shadow-fragment-core.ts— receiver WGSL codegen.material/standard/fragments/std-csm-shadow-fragment.ts— Standard-family wrapper.lab/lite/src/lite/scene214.ts,lab/lite/scene214.html— Lite demo scene.lab/lite/src/bjs/scene214.ts,lab/lite/babylon-ref-scene214.html— BJS oracle.reference/lite/scene214-cascaded-shadows/babylon-ref-golden.png— golden.