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. - Eye-centred bounding-sphere fit - each slice gets a sphere centred on the eye, with radius
far x diag, wherediagreaches the far frustum corner in any view direction. Fitting a sphere rather than a box makes the cascade rotation-invariant in size; centring it on the eye makes the whole matrix rotation-invariant, which matters for the cache (see below). - The radius is quantized (
ceil(r·16)/16) so tiny field-of-view changes do not perturb it, and a square orthographic light matrix is built looking down the sun direction.
The eye-centred fit replaced a frustum-slice sphere centred ahead of the camera. That sphere orbited the eye as you turned, so the cascade matrix changed every frame, and the static shadow layer was re-baked each frame with a slightly different depth window. The visible result was self-shadow acne on grazing surfaces swimming as you rotated, worst in the coarse far cascade. The analytic projection was stable throughout; it was the re-bake that swam. Centred on the eye, the matrix depends only on the eye position, the sun direction and the projection, all of which are unchanged by turning in place, so the cache holds and nothing swims.
The trade is that a sphere centred on the eye also wraps behind the camera, so the radius grows by roughly 1.3x and far shadows are slightly softer. Raise SHADOW_SIZE if that ever reads as too soft.
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. Two biases together keep self-shadowing acne off grazing surfaces. The shadow bake pipelines carry a slope-scaled hardware bias (depthBias: 4, depthBiasSlopeScale: 2.5), which is what handles a facet nearly edge-on to the sun; the fragment test then subtracts a small flat bias (0.0004) in NDC. The flat bias used to be 0.0015 and carried the whole load alone, which is a blunter instrument: large enough to stop acne on grazing facets, it also detaches contact shadows on facing ones.
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 translated by at least one shadow texel, changing the matrix hash. Rotating in place does not, which is the point of the eye-centred fit above.
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