three-flatland renders per-light, SDF-traced soft shadows. Casters silhouette themselves into a screen-space mask each frame; a Jump Flood Algorithm (JFA) converts that mask into a signed distance field; lit fragments sphere-trace toward each light through the SDF and darken if a caster gets in the way. Shadows are soft, elevation-aware, and runtime-toggleable per light and per sprite — there are no shadow maps, no light-space cameras, and no per-light passes.
If your scene doesn’t have shadows yet, you don’t need this page. Come back when your hero is on screen and you want a proper torch-on-wall moment. This page is the mental model: how the pipeline turns casters into a distance field and how the trace reads it. For opting sprites and tiles in or out, the shadowRadius knobs, and the tuning uniforms, see the Shadows guide.
How Shadows Work
Section titled “How Shadows Work”The shadow pipeline runs as a pre-pass before the main scene render:
- OcclusionPass renders every shadow-casting sprite and tile into an offscreen render target, masked by each instance’s
castsShadowbit. The output is a binary alpha silhouette of all casters in screen space. - SDFGenerator runs JFA over that alpha mask and produces a signed distance field — each texel stores world-space distance to the nearest caster (positive outside, negative inside).
- Lit fragment shader (e.g.
DefaultLightEffect) sphere-traces from each fragment toward each light through the SDF. If the trace hits an occluder before reaching the light, the fragment is shadowed for that light.
The SDF is recomputed every frame at half-resolution by default (the silhouette is binary, so quarter-area is plenty), and the trace runs at full resolution per-fragment per-light — but only when the fragment actually needs it (see Performance).
A single distance field serves every hero light, so occlusion cost is shared across lights rather than paid per-light-per-occluder. Fill lights (castsShadow: false) skip the trace entirely and contribute pure attenuated diffuse.
Self-Shadowing
Section titled “Self-Shadowing”The first time you mark a sprite as a caster, a question arises: the sprite is an occluder, so the shadow trace starts inside its own silhouette. Without an escape, every fragment of every caster would self-shadow against itself.
three-flatland handles this with a per-instance shadowRadius — the world-units distance the trace skips before sampling the SDF. A 64-unit knight needs ~64 units of escape; a 32-unit slime needs ~32. Manually tuning this per-sprite would be tedious, so the value is auto-derived every frame from the sprite’s scale at batch-write time:
shadowRadius = max(Math.abs(scale.x), Math.abs(scale.y))This tracks scale changes automatically and covers animated sprites whose source frame size differs (e.g. AnimatedSprite2D updates scale from frame.sourceWidth/Height). For most scenes you set castsShadow = true and never think about shadowRadius again. When you do need to override it — transparent art padding, non-uniform scale, a tighter escape — the Shadows guide covers the manual knobs.
Elevation-Aware Shadows
Section titled “Elevation-Aware Shadows”DefaultLightEffect treats every light as a 3D direction with a +Z component (lightHeight, default 0.75). When a sprite provides a per-fragment elevation channel — typically through NormalMapProvider reading the normal-map’s blue channel — the elevation is subtracted from lightHeight before the diffuse N·L calculation:
L = normalize(vec3(toLight2D, lightHeight - elevation))When elevation > lightHeight, L.z goes negative, N·L clamps to zero, and the fragment receives no direct light from that source. In practice this means wall caps physically don’t receive direct light — they’re elevated above the torch height, so the torch’s contribution geometrically misses them. Floor fragments at elevation = 0 see the full lightHeight, get strong diffuse, and behave like normal lit ground.
This replaces the older capShadowStrength / capShadowThreshold post-hoc darkening hack (now removed): instead of darkening caps after the fact, the geometry of the light direction does it for free. Elevation-aware shaping is automatic once your sprites carry a baked elevation channel and you’re using DefaultLightEffect (which declares requires: ['normal', 'elevation']). For how to produce that channel and turn the feature on, see Enabling Elevation-Aware Shadows.
Performance
Section titled “Performance”The per-light shadow trace is gated on the GPU by four runtime conditions, all of which must be true for the trace to run:
isAmbient === falseNdotL > 0atten > 0.01lightCastsShadow > 0.5In practice this means most fragments skip the trace cheaply:
- Ambient lights never trace (they don’t have a direction).
- Back-faces (
N·L ≤ 0, including elevated wall caps under low torches) skip the trace — they’re already dark. - Distant fragments (
atten < 0.01, sub-visible attenuation) skip the trace — the light couldn’t make a visible delta anyway. castsShadow: falselights unconditionally skip the trace.
This is why flipping cosmetic fills to castsShadow: false is the dominant shadow-perf lever: the 32-tap SDF trace is the per-light cost, and a fill that skips it is nearly free. The Shadows guide covers the practical knobs (occlusion-mask resolution, caster counts).
Next Steps
Section titled “Next Steps”- Shadows guide — Opt-in flags,
shadowRadius, elevation setup, and tuning uniforms - 2D Lighting — The Forward+ pipeline shadows plug into
- Sprites — per-sprite flags including
castsShadow,receiveShadows, andshadowRadius - Pass Effects — the
DefaultLightEffectschema and the shadow uniforms in context