Skip to content

Ground shadows for 3D objects (#1515) - #1579

Merged
obiot merged 3 commits into
masterfrom
ground-shadows-1515
Aug 8, 2026
Merged

Ground shadows for 3D objects (#1515)#1579
obiot merged 3 commits into
masterfrom
ground-shadows-1515

Conversation

@obiot

@obiot obiot commented Aug 8, 2026

Copy link
Copy Markdown
Member

Closes #1515.

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 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 castGroundShadow application setting), controllable at three levels, most specific first:

new Mesh(x, y, { model: "crate", castGroundShadow: true, shadowGroundY: 0 });  // per object
level.load("diorama", { castGroundShadow: true, shadowGroundY: 0 });           // per glTF scene
new Application(w, h, { cameraClass: Camera3d, castGroundShadow: false });     // 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. 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 Camera3d path 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 billboarded Sprite3d builds 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:

  • 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 stay byte-identical.

Testing

50 tests in ground_shadow.spec.js plus 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. Only readPixels caught 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 via level.load) and Instanced Forest (InstancedMesh).

Docs: CHANGELOG, both READMEs, and the Working-in-3D, glTF, supported-assets and 2.5D Games wiki 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

obiot and others added 2 commits August 8, 2026 16:25
`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
Copilot AI lite review requested due to automatic review settings August 8, 2026 09:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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
Copilot AI review requested due to automatic review settings August 8, 2026 09:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit 242966a into master Aug 8, 2026
6 checks passed
@obiot
obiot deleted the ground-shadows-1515 branch August 8, 2026 09:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sprite3d / 2.5D: built-in blob shadow helper

2 participants