Ground shadows for 3D objects (#1515) - #1579
Merged
Merged
Conversation
`castGroundShadow` gives a Mesh, a Sprite3d billboard or a whole
InstancedMesh scatter a soft blob shadow on the ground — the thing 2.5D
scenes had no way to get, and without which characters and props read as
floating however carefully they are placed. Deliberately not simulated:
for paper-thin billboards a shadow map costs far more than this engine
wants to spend and looks worse, because a flat silhouette has to be
special-cased to cast anything sensible. What the player needs is
contact, and a blob says exactly that.
On by default (the new `castGroundShadow` application setting), and
controllable at three levels, most specific first: per object, per glTF
scene via `level.load(name, { castGroundShadow, shadowGroundY })`, then
application-wide. The two blanket forms skip meshes with no vertical
extent — a flat plane lying on the floor IS the floor, and shadowing it
with itself smears the whole ground. 2D games are untouched whatever the
setting says: the shadow rides the retained Camera3d path only.
The blob is an ellipse built from the caster's own model-space footprint
carried through its transform, so it matches the object's aspect ratio
and turns with it; a thin upright panel gets a thin shadow lying along
the panel rather than a disc reading as perpendicular to it. Read from
`currentTransform`, not the model matrix, because a billboarded Sprite3d
builds that from a camera-facing basis and the blob would spin with the
camera.
Shadows are held back until every opaque mesh in the pass is down, then
drawn in one go. A blob writes no depth (so two overlapping at one ground
height blend instead of fighting), which leaves it nothing to defend
itself with, and a ground plane routinely sorts after the props standing
on it. Depth testing stays on, so a shadow is still correctly hidden
behind geometry genuinely in front of it. The queue is drained when the
renderer leaves mesh mode — but not on a lit/unlit switch, and not while
a mask is being stencilled in or inside a post-effect bracket, where the
device state is not the scene's — and at the end of the camera's own
draw, inside its FBO bracket.
The instanced tier costs ONE extra draw for an entire scatter regardless
of instance count: the blobs are read from the same instance buffer the
meshes draw from, through a standalone shader that reads only the
transform rows, so per-instance colour and emissive cannot leak into
them. Its quad carries the prototype's extents in its own vertices, so
the same asset draws the same shadow instanced or standalone.
An object that does not opt in is untouched — no extra draw, no extra
state, no changed pipeline key — and the shared falloff texture and quads
are allocated lazily, so an application with no shadows builds neither.
Also here, found while building it:
- `registerShader` deduped on module source alone, silently handing any
later caller the first registration's vertex layout. One module can
legitimately serve several layouts; now keyed on both, with the
compiled module still shared.
- WebGPU pipelines gain a conditional `depthWrite` axis, appended to the
key only when false so existing pipelines are byte-identical.
Both GPU backends. The Canvas renderer has no depth buffer and draws
none. 50 tests covering the backward-compatibility contract, opt-in
precedence, the deferred queue, resource lifetime and pixels — the last
because every draw-count, GL-state and matrix assertion passed while the
feature rendered nothing at all.
Docs: CHANGELOG, both READMEs, and the Working-in-3D, glTF, supported-
assets and 2.5D wiki pages (the last two carried caveats this removes).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
`world.children` is typed as the base `Renderable`, so `castGroundShadow` and `getBounds3d()` — which live on `Mesh` — are not visible on it. The diorama example silenced that with `as any` instead of proving it, which switched off checking for every member access in the loop that assigns `shadowGroundY`: exactly where the compiler is worth having. `filter((c): c is Mesh => c instanceof Mesh)` narrows properly. It also removes a latent bug: the old predicate was `castGroundShadow !== undefined`, which only selected anything because this scene passes `castGroundShadow: true` to `level.load`. The flag is deliberately TRI-STATE — `undefined` means "follow the application setting" — so on a scene that did not pass the option, that filter would have matched nothing and the whole ground-resolution loop would have silently done nothing. That contract had no test, so add four against the animated glTF path: an omitted option must leave the flag `undefined` (or an application-wide default can never reach a glTF scene), `true` and `false` must both forward, `shadowGroundY` must reach the part meshes, and a scene-wide opt-in must still skip a part with no vertical extent. Verified the first fails if the loader flattens `undefined` to `false`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
Ground shadows (#1515) ship ON, so every 3D mesh draw now issues a second one. That is free on a discrete GPU and is not on CI's SwiftShader software renderer: `renderTargetPool` and `texturecache-batcher-reset` both ran past their timeout there — 190s for a suite whose tests all passed — while the local suite stayed green throughout. The suites that are not testing shadows should not be paying for them, so the shared WebGL test renderer pins the setting off. `ground_shadow.spec` opts itself in per test and asserts the shipped default separately against `defaultApplicationSettings`, so coverage of the real default is unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1515.
castGroundShadowgives aMesh, aSprite3dbillboard or a wholeInstancedMeshscatter a soft blob shadow on the ground — the thing 2.5D scenes had no way to get, and without which characters and props read as floating however carefully they are placed.Deliberately not simulated: for the paper-thin billboards a 2.5D game is made of, a shadow map costs far more than this engine wants to spend and looks worse, because a flat silhouette has to be special-cased to cast anything sensible. What the player needs is contact — where the object stands, and how far off the ground it is mid-jump.
Using it
On by default (the new
castGroundShadowapplication setting), controllable at three levels, most specific first:The two blanket forms skip meshes with no vertical extent — a flat plane lying on the floor is the floor, and shadowing it with itself smears the whole ground. A per-object opt-in is an explicit instruction and bypasses that.
2D games are untouched whatever the setting says: the shadow rides the retained
Camera3dpath only, so the Canvas renderer and the 2D-camera path draw none.Notes for reviewers
The blob is an ellipse, not a disc — built from the caster's own model-space footprint carried through its transform, so it matches the object's aspect ratio and turns with it. Read from
currentTransform, not the model matrix, because a billboardedSprite3dbuilds that from a camera-facing basis and the blob would otherwise spin with the camera.Shadows are deferred to the end of the mesh pass. A blob writes no depth (so two overlapping at one ground height blend instead of fighting), which leaves it nothing to defend itself with — and a ground plane routinely sorts after the props standing on it. Depth testing stays on, so a shadow is still correctly hidden behind geometry genuinely in front of it. The queue drains when the renderer leaves mesh mode (not on a lit/unlit switch, and not while a mask is being stencilled in or inside a post-effect bracket, where the device state is not the scene's) and at the end of the camera's own draw, inside its FBO bracket.
The instanced tier costs ONE extra draw for an entire scatter, regardless of instance count: the blobs are read from the same instance buffer the meshes draw from, through a standalone shader that reads only the transform rows — so per-instance colour and emissive cannot leak into them.
Backward compatibility: an object that does not cast pays nothing — no extra draw, no extra state, no changed pipeline key — and the shared falloff texture and quads are allocated lazily, so an application with no shadows builds neither.
Two fixes to shared machinery came out of this and are worth a look on their own:
registerShaderdeduped on module source alone, silently handing any later caller the first registration's vertex layout. One module can legitimately serve several layouts; now keyed on both, with the compiled module still shared.depthWriteaxis, appended to the key only whenfalse, so existing pipelines stay byte-identical.Testing
50 tests in
ground_shadow.spec.jsplus loader and pipeline coverage, across the backward-compatibility contract, opt-in precedence, the deferred queue, resource lifetime, and pixels. The last matter most here: every draw-count, GL-state, matrix and uniform assertion passed while the feature rendered nothing at all, three separate times. OnlyreadPixelscaught it. Each new test was verified to fail with its fix reverted.Both GPU backends verified on all 14 examples. Shown in Per-material Textures (
Mesh), Billboard Sprites (Sprite3d), glTF Scene (whole scene vialevel.load) and Instanced Forest (InstancedMesh).Docs: CHANGELOG, both READMEs, and the
Working-in-3D, glTF, supported-assets and2.5D Gameswiki pages — the last two carried caveats this removes.Out of scope, deliberately: real shadow mapping, and a public
Mesh.blend(#1516) — the blend/depth capability here is internal to the shadow draw, because exposing it means owning back-to-front sorting for arbitrary translucent geometry.🤖 Generated with Claude Code
https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi