API

Module: Cascaded Shadow Maps (CSM)

Package paths: packages/babylon-lite/src/shadow/csm-directional-shadow-generator.ts packages/babylon-lite/src/shadow/csm-shadow-task-hooks.ts packages/babylon-lite/src/shadow/csm-shadow-cache.ts packages/babylon-lite/src/shadow/csm-refit-gate.ts packages/babylon-lite/src/shader/fragments/csm-shadow-fragment-core.ts packages/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 = true
await 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)

  • _shadowType union 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)fieldtype
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. Legacy bias supplies 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 offset worldSpaceBias / (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 (bglEntry maps _textureType containing "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, LH
let 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 cascade
let 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 convention
let 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

  1. 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.
  2. Slice [prevSplit, split]: corner[k] = near + ray*prevSplit, corner[k+4] = near + ray*split where ray = far - near per side.
  3. Centroid = mean of the 8 slice corners.
  4. Fit a light-space AABB: temp LookAtLH from centroid along lightDir (buildLightViewMatrix), transform corners, take min/max extents. (stabilizeCascades instead uses a ceil(radius*16)/16 bounding sphere.)
  5. Shadow camera eye = centroid + lightDir * minExtents.z; cascade view = buildLightViewMatrix(lightDir, eye).
  6. 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) when castersMinZ <= viewMaxZ). v1 uses depthClamp = false so no GPU depth-clip feature is required. A positive worldSpaceBias then extends viewMaxZ by the same distance so the farthest fitted caster is not clipped after biasing.
  7. 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).
  8. 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.
  9. Receiver cascadeTransforms[i] = transform (unbiased). Caster camera view-projection adds clipOffset·w to its Z row, where clipOffset = bias·0.5 for the legacy normalized bias, or worldSpaceBias / (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 (_csmGen bump) 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 setShadowCasterMaterial override 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 _held with 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 becomes null and 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 null keeps 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.jsBabylon 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) in shadow-generator.ts, standard-renderable.ts; PBR/Node renderables filter out CSM lights (they ignore CSM in v1).
  • _depthView field swap in the three receiver renderables (call → field read).
  • One hasCsm-gated dynamic import of std-csm-shadow-fragment.ts in standard-group-builder.ts.
  • shader-composer.ts bglEntry gains "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-receiver Texture2D adapter, 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.