Shadows
Awaken casts real-time shadows from the sun using cascaded shadow maps - 1–4 distance slices fit to the view, texel-snapped for stability, filtered with PCF, and split into a cached static layer and a per-frame dynamic layer.
Directional-only, from the sun
Only the sun - the first directional light in the scene - casts shadows. Point lights and any additional directional lights light surfaces but never write to the shadow maps. Rotate the sun's GameObject to move the shadows; its shadowStrength (a render setting, 0–1) sets how dark shadowed pixels get.
There is one shadow map resource, sized 2048 × 2048 (SHADOW_SIZE), stored as a depth24plus array texture with one layer per cascade.
Cascaded shadow maps
A single shadow map stretched across a whole open world would be blurry up close. Cascaded shadow maps solve this by slicing the camera frustum into count distance ranges and giving each its own light-space map - near cascades cover a small area at high resolution, far cascades cover a large area at low resolution.
computeCascadeMatrices() (packages/render/src/shadow.ts) does the fit:
- Split the range
[0.5 m, shadowDistance]intocountslices using a blend of uniform and logarithmic split distances (60% toward log), which puts more resolution near the camera where it matters. - Bounding-sphere fit - for each slice, the 8 frustum-corner points are averaged to a centre and the max corner distance becomes the radius. Fitting a sphere (not a box) makes the cascade's size rotation-invariant, so it does not grow and shrink as you turn the camera.
- The radius is quantized (
ceil(r·16)/16) to further reduce shimmer, and a square orthographic light matrix is built looking down the sun direction.
The number of cascades is the Cascade Count render setting (1–4, default 3), and the total range is Shadow Distance (metres, default 120).
Texel snapping - stability and caching
As the camera creeps forward, a naïvely-fit cascade slides by sub-texel amounts each frame, and shadow edges crawl and shimmer. Awaken shifts each cascade in light clip space so its origin lands exactly on a shadow-map texel boundary:
wgsl
m[12] += (Math.round(ox) - ox) / half;
m[13] += (Math.round(oy) - oy) / half;This does two jobs. First, it removes edge shimmer under camera motion. Second - and this is the subtle one - it makes the cascade matrix byte-for-byte identical across sub-texel camera movement. That stability is exactly what the cached static shadow layer relies on: if the matrix hasn't changed, the cached depth is still valid and does not need re-rendering.
PCF filtering
Shadows are sampled with 3 × 3 percentage-closer filtering (pcf() in MESH_WGSL). For each fragment the shader picks the first cascade whose radial far distance covers it (cascadeSplits.xyz), projects the world position into that cascade's light space, and averages 9 hardware comparison samples (textureSampleCompareLevel) one texel apart. This softens the hard aliased edge a single sample would give. A small depth bias (0.0015) kills self-shadowing acne.
Static vs. dynamic layers (cached shadows)
The expensive part of shadows is re-drawing every caster into every cascade every frame. Awaken avoids most of that by keeping two depth arrays:
- Static array - non-moving casters (objects flagged static). Re-rendered for a cascade only when that cascade is dirty, then cached.
- Dynamic array - moving casters (the small non-static list). Re-rendered every frame.
The fragment shadow test samples both and takes the nearer occluder (min(pcf(static), pcf(dynamic))), so a moving object still shadows correctly against cached static geometry.
A cascade's static layer is marked dirty when any of these is true:
- The sun moved more than
sunMoveThresholdDeg(0.3°) since the last bake - day/night rotation. - The scene geometry changed (a
shadowEpochbump on cache rebuild). - The texel-snapped cascade region shifted - the camera moved at least one shadow texel, changing the matrix hash.
Because the octree cull only queries the cascades that must re-bake, the huge static caster sets are not even culled on cached frames, let alone drawn. This is what lets far, high-quality shadows stay cheap. Caching is the Shadow Cache render setting (default on); turn it off to force a full re-bake every frame.
📸 Screenshot - save as img/render-shadow-cascades.png
A scene lit by a low sun with long shadows across near and far geometry, ideally with cascade debug colours or the stats overlay showing shadow draw calls per cascade.
Settings summary
| Setting | Default | Effect |
|---|---|---|
| Shadow Strength | 0.85 | Darkness of shadowed pixels (1 = black, 0 = off) |
| Cascade Count | 3 | Number of distance slices (1–4) |
| Shadow Distance | 120 m | Total range the cascades cover |
| Shadow Cache | on | Cache static-caster depth between frames |
All four live in Render Settings.
Limitations
- Shadows are directional only - no point-light or spot-light shadow maps.
- There is a single 2048² map shared across cascades as array layers; there is no per-cascade resolution override.
- Very large Shadow Distance with few cascades trades crispness for coverage - add a cascade rather than stretching the distance.
See also
- PBR & Lighting - the sun and
shadowStrengthin the lighting model - Performance Systems - why cached shadows and octree culling make cascades cheap
- Render Settings - the shadow controls
- Static Batching - which objects count as static casters
- Render Pipeline Overview - where the shadow passes sit in the frame