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}
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, 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.
A refit caused by the camera, 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.
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; 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.
Cascade matrices, the receiver UBO, and shadow-camera versions change only on refit
frames; dynamic-only frames reuse the last refit's 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, per receive
material, its terminal caster material and that material's _csmGen. Every ensure
reconciles them with the live casters (_reconcileCsmCasters):
- Requeue. Casters that left the set or were re-capped, 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 through the
cached view of its material. A view is stale when the caster's material was rebuilt
(
_csmGenbump) or 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_csmGen, 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/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.