threejs-shadow-systems skill (Threejs-Awesome-Graphics-Agent-Skills)
- Install
- SKILL.md (verbatim)
- Cached clipmap workflow
- Failure conditions
- Routing boundary
- Other files in this skill
- references/cached-clipmap-shadows.md (verbatim)
- Contents
- 1. Identify the representation correctly
- 2. Preserve the exact default envelope
- 3. Store committed map state
- 4. Stabilize X/Y by the actual texel footprint
- 5. Derive the light-space frame once
- 6. Use a two-class update policy
- 7. Commit camera and map atomically
- 8. Cross-fade levels without divergent shadow samples
- 9. Scale normal bias by texel size
- 10. Target invalidation spatially
- 11. Attach and dispose ownership explicitly
- 12. Adaptation workflow
- 13. Required diagnostics
What it does. Implement stable, scalable directional-shadow systems for Three.js. Use for large procedural worlds, city scenes, terrain, moving cameras, WebGPU/TSL shadow nodes, cascades, cached clipmaps, texel stabilization, update budgets, and targeted invalidation. Part of scottstts/Threejs-Awesome-Graphics-Agent-Skills (scottstts/Threejs-Awesome-Graphics-Agent-Skills).
| Upstream | scottstts/Threejs-Awesome-Graphics-Agent-Skills |
| Skill file | skills/threejs-shadow-systems/SKILL.md |
| License | MIT |
| Author | Scott Sun (scottstts) |
| Fetched | 2026-09-10 |
Install
npx skills add scottstts/Threejs-Awesome-Graphics-Agent-Skills --skill threejs-shadow-systems, or copy the skill folder into~/.claude/skills/threejs-shadow-systems/.- Raw file:
curl -sL https://raw.githubusercontent.com/scottstts/Threejs-Awesome-Graphics-Agent-Skills/HEAD/skills/threejs-shadow-systems/SKILL.md
SKILL.md (verbatim)
name: threejs-shadow-systems
description: Implement stable, scalable directional-shadow systems for Three.js. Use for large procedural worlds, city scenes, terrain, moving cameras, WebGPU/TSL shadow nodes, cascades, cached clipmaps, texel stabilization, update budgets, and targeted invalidation.
Shadow Systems
Use a single shadow map only when its receiver region is genuinely bounded. For large moving views, make shadow coverage an explicit spatial hierarchy.
Cached clipmap workflow
- Define concentric light-space square levels.
- Snap each level center to its own texel grid.
- Cross-fade adjacent levels in shader space.
- Refresh near levels continuously.
- Cache coarse levels and update them under a frame budget.
- Invalidate intersecting levels when important casters or streamed terrain change.
- Scale normal bias by world-space texel width.
Read references/cached-clipmap-shadows.md before implementing a large-world directional light.
Read the cached shadow clipmaps for three light-space square levels, per-level texel snapping, containment cross-fades, cached coarse updates, scaled bias, and unshadowed outside weight.
Failure conditions
- projection centers move by fractions of a texel;
- shader containment does not match the map's committed center;
- all cascades refresh every frame without evidence;
- coarse levels freeze moving casters indefinitely;
- depth texture samples occur in divergent fragment control flow;
- the same normal bias is used across radically different texel sizes;
- level boundaries become visible under camera motion.
Routing boundary
Use this skill for light-space directional shadow maps. Use
$threejs-screen-space-ambient-occlusion for view-dependent ambient
visibility; AO is not a replacement for cast shadows.
Other files in this skill
references/cached-clipmap-shadows.md (verbatim)
Cached clipmap shadow system
Use this reference for stable directional shadows across a large procedural scene using committed light-space centers, texel snapping, bounded refresh budgets, cross-level blending, and targeted invalidation.
Contents
- Identify the representation correctly
- Preserve the exact default envelope
- Store committed map state
- Stabilize X/Y by the actual texel footprint
- Derive the light-space frame once
- Use a two-class update policy
- Commit camera and map atomically
- Cross-fade levels without divergent shadow samples
- Scale normal bias by texel size
- Target invalidation spatially
- Attach and dispose ownership explicitly
- Adaptation workflow
- Required diagnostics
1. Identify the representation correctly
The system is a set of concentric square shadow maps centered around the camera in light space.
It is not a virtual shadow map:
no page table
no physical page cache
no page-granular caster submission
one ordinary shadow texture per level
Every level consumes a sampled shadow texture in the material stage. Check the target device's sampled-texture limit before increasing level count.
2. Preserve the exact default envelope
Default construction:
first half-width 12 m
scale factor 2.5
maximum distance 2000 m
light margin 100 m
shadow near 1 m
shadow far cap 3000 m
guard band 0.15
cross-fade ratio 0.15
dynamic near levels 2
cached update budget 2 per frame
maximum cache age 64 frames
direction epsilon 0.002 radians
Level count:
ceil(log(maxDistance / firstRadius) / log(scaleFactor)) + 1
Each half-width is:
min(firstRadius * scaleFactor^level, maxDistance)
The last level is forced to maxDistance exactly.
Clamp adaptation controls to safe ranges:
firstRadius >= 1
scaleFactor >= 1.5
guardBand in [0.02, 0.5]
blendRatio in [0.01, 0.9]
dynamicLevels in [0, levelCount]
updateBudget >= 1
maxCacheAge >= 0
Per-level map sizes may differ. Missing entries use the directional light's current shadow-map width.
3. Store committed map state
Each level owns:
type LevelState = {
halfWidth: number
centerX: number
centerY: number
centerZ: number
valid: boolean
forceDirty: boolean
age: number
}
The shader-facing vector stores:
x = committed light-space center X
y = committed light-space center Y
z = sampled half-width = halfWidth * (1 - guardBand)
w = unused
Publish the center from the last completed map render. Do not publish the camera's desired center while a cached level waits for its budget slot.
That distinction prevents the shader containment box from drifting away from the map content and causing rhythmic boundary flicker.
Before a level renders once, park it at:
center = (1e9, 1e9)
sample half-width = 1e-6
An invalid level must never win selection.
4. Stabilize X/Y by the actual texel footprint
Per level:
texelWidth =
(orthographicRight - orthographicLeft)
/ mapWidth
desiredX = round(cameraLightX / texelWidth) * texelWidth
desiredY = round(cameraLightY / texelWidth) * texelWidth
This aligns the orthographic projection to a fixed world-space texel grid.
Quantize Z more coarsely:
zQuantum = halfWidth * 0.5
desiredZ = round(cameraLightZ / zQuantum) * zQuantum
Z changes depth coverage and update cadence but does not define the projected texel grid, so a coarser quantum is intentional.
Do not snap by a fraction of total level extent. At coarse levels that produces tens-of-meters jumps.
5. Derive the light-space frame once
Each frame:
lightDirection = normalize(light.target.position - light.position)
lightOrientation = lookAt(origin, lightDirection, worldUp)
worldToLight = inverse(lightOrientation)
cameraLight = worldToLight * cameraWorld
The direction is considered changed when:
dot(currentDirection, lastCommittedDirection)
< cos(directionEpsilon)
A direction change gives the frame enough budget to refresh all levels.
This gates a continuously moving sun into occasional coherent refreshes. If the art direction requires per-frame sun motion, reduce levels or accept the cost rather than allowing mismatched cached directions.
6. Use a two-class update policy
A level is dirty when:
it is in the dynamic near set
or it has never rendered
or forceDirty is set
or snapped X/Y/Z changed
or cache age expired
or light direction changed
Policy:
dynamic near levels:
render every frame
do not consume cached update budget
ordinary cached levels:
render only while budget remains
explicitly invalidated levels:
bypass the cached budget
Although invalidation may be described as “rate-limited”, forceDirty renders
without consuming or checking the ordinary budget. Preserve that exception
intentionally or change both behavior and documentation together.
On first update or light-direction change:
budget = levelCount
Otherwise:
budget = updateBudget
Age increments every frame and resets after render. Initial ages are staggered:
age(level) = floor(-level * maxCacheAge / levelCount)
This prevents all coarse levels expiring together.
7. Commit camera and map atomically
When a level renders:
- commit snapped X/Y/Z to
LevelState; - clear
forceDirty; - reset age;
- place the light at:
(centerX, centerY, centerZ + halfWidth + lightMargin)
- transform that position back from light space;
- aim the target one light-direction unit away;
- force light and target matrices current;
- render the shadow map immediately from that committed transform.
The level's orthographic depth range is:
near = configuredNear
far = max(
near + 1,
min(configuredFarCap, lightMargin + 2 * halfWidth)
)
Every cloned shadow has:
autoUpdate = false
needsUpdate = false
The clipmap owner drives updates manually. Allowing Three.js to update the clone independently can render from a transform different from the one later sampled.
8. Cross-fade levels without divergent shadow samples
Transform shadowPositionWorld to the shared light-space XY plane.
For each level:
distance = max(
abs(lightX - levelCenterX),
abs(lightY - levelCenterY)
)
fade =
1 - smoothstep(
sampledHalfWidth * (1 - blendRatio),
sampledHalfWidth,
distance
)
weight = fade * remaining
remaining *= 1 - fade
Accumulate from finest to coarsest. Leftover weight resolves to unshadowed, creating a smooth fade outside the outer level.
Critical GPU contract:
sample every level's depth-comparison texture unconditionally
multiply the result by its weight afterward
Do not put comparison sampling behind a per-pixel conditional. Doing so can produce view-dependent flicker and undefined derivatives.
BoundedShadowNode still evaluates the filter function, then selects 1
outside the level's projected XYZ range. This keeps the comparison sample in
uniform control flow while preventing out-of-bounds projection from shadowing.
9. Scale normal bias by texel size
Capture the original directional-light bias values before cloning.
Per level:
texelScale = levelTexelWidth / finestTexelWidth
shadow.bias = baseBias
shadow.normalBias = baseNormalBias * texelScale
The implementation keeps depth bias unchanged and scales only world-space normal bias.
Inspect acne and peter-panning separately per level. A single normal bias across 12 m and 2000 m levels is not coherent.
10. Target invalidation spatially
invalidate() with no bounds sets forceDirty on every level.
With a world-space bounding sphere:
- transform its center to light space;
- for each level compute:
reach = halfWidth + sphereRadius
- invalidate when both projected X and Y distances are below
reach.
Use this for:
- streamed terrain arrival;
- regenerated buildings;
- moving hero casters;
- vegetation chunks whose deformed silhouettes matter.
Observed limitation: the test is a conservative square intersection in XY. It does not test Z or the exact projected sphere-square distance. This is cheap and safe but may refresh extra levels.
11. Attach and dispose ownership explicitly
The node attaches through:
light.shadow.shadowNode = clipmapNode
Detaching removes that property only if it still points to the node.
Disposal must:
- detach from the directional light;
- dispose every level shadow node;
- dispose every cloned shadow;
- remove level lights and targets from their parent;
- invoke base disposal.
Cached shadow maps are persistent GPU resources. Treat missing disposal as a real leak.
12. Adaptation workflow
- Verify target Three.js WebGPU/TSL shadow-node APIs.
- Start with two or three equal-resolution levels and no caching.
- add X/Y texel snapping;
- add guard-band selection and cross-fade;
- verify unconditional comparison sampling;
- separate dynamic and cached level updates;
- publish committed centers only;
- add cache-age staggering;
- add targeted invalidation;
- tune per-level map sizes and normal bias.
Do not add caching before stable selection and committed-state tracking work. Caching makes a spatial mismatch persist longer.
13. Required diagnostics
Expose:
level count and texture count
rendered half-width and sampled half-width
map size and world texel width per level
desired versus committed X/Y/Z
selected level and cross-fade weights
remaining unshadowed weight
dynamic/cached classification
dirty reason bits
valid/forceDirty/age
budget before and after updates
direction delta versus epsilon
base and scaled normal bias
shadow-map preview per level
invalidation sphere in light space
level render count and GPU time
Failure diagnosis:
shadows crawl under slow camera motion:
X/Y center is not snapped to the level's texel width
level boundaries flicker every other frame:
desired center was published while the cached map retained its old center
shadows disappear by view angle:
comparison samplers were evaluated in divergent control flow
coarse moving casters freeze:
max age and targeted invalidation are both absent
all levels spike together:
cache ages were not staggered
important streamed geometry remains unshadowed:
explicit invalidation was incorrectly blocked by the coarse update budget
coarse levels show acne:
normal bias was not scaled by world texel width
memory grows after scene replacement:
cloned shadows, level nodes, lights, or targets were not disposed
Back to scottstts/Threejs-Awesome-Graphics-Agent-Skills or Agent skills.