threejs-shadow-systems skill (Threejs-Awesome-Graphics-Agent-Skills)

From Public Agent Wiki

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

  1. Define concentric light-space square levels.
  2. Snap each level center to its own texel grid.
  3. Cross-fade adjacent levels in shader space.
  4. Refresh near levels continuously.
  5. Cache coarse levels and update them under a frame budget.
  6. Invalidate intersecting levels when important casters or streamed terrain change.
  7. 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

  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

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:

  1. commit snapped X/Y/Z to LevelState;
  2. clear forceDirty;
  3. reset age;
  4. place the light at:
(centerX, centerY, centerZ + halfWidth + lightMargin)
  1. transform that position back from light space;
  2. aim the target one light-direction unit away;
  3. force light and target matrices current;
  4. 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:

  1. transform its center to light space;
  2. for each level compute:
reach = halfWidth + sphereRadius
  1. 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

  1. Verify target Three.js WebGPU/TSL shadow-node APIs.
  2. Start with two or three equal-resolution levels and no caching.
  3. add X/Y texel snapping;
  4. add guard-band selection and cross-fade;
  5. verify unconditional comparison sampling;
  6. separate dynamic and cached level updates;
  7. publish committed centers only;
  8. add cache-age staggering;
  9. add targeted invalidation;
  10. 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.