Skip to content

Ground shadows: shaped by the light, and laid on any floor - #1715

Merged
obiot merged 17 commits into
masterfrom
shadow-ground-normal
Oct 6, 2026
Merged

obiot merged 17 commits into
masterfrom
shadow-ground-normal

Conversation

@obiot

@obiot obiot commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Supersedes #1714, whose commits are all contained here. Closes the remaining items on #1631.

A blob shadow could only ever sit straight under its caster, on level ground. This gives it a direction, a shape and a floor, and makes all of it work on an InstancedMesh as well as a loose prop.

Added

  • shadowLight points a blob at a Light3d and re-reads it every draw, so a day/night cycle carries every shadow with it. Nothing is inferred: a scene with no named light and no shadowDirectionX/Z gets no direction, because a scene with several lights has no non-arbitrary answer.
  • shadowOffset slides the blob out from the caster's feet, measured in multiples of the blob's own radius rather than world units, so one value serves a boulder, a pebble and every part of a glTF model. Honoured only when shadowGroundY is set, since a flat quad can only be slid across a plane the game has named.
  • shadowStretch lengthens it along that direction for a low sun, clamped to 3 and fading as it pulls, so an extreme value degrades to nothing rather than to a smear.
  • shadowGroundNormal lays it on a floor that is not level. The blob is rotated onto the plane rather than projected, so it keeps its size, and it stays directly under its caster. Clamped at 75 degrees.
  • shadowScale sizes the blob independently of the object, which is the knob for a wide flat-bottomed prop that hides its own shadow. From Add per-object ground shadow scaling #1712, thanks @snowyukitty.

On an InstancedMesh

All of the above, as one value for the whole set.

The instanced tier used to refuse shadowStretch, on the grounds that an anisotropic scale reaches the GPU through the group matrix and would smear the instance positions along with each blob. That is true of the matrix and only of the matrix. The vertex stage builds uModelMatrix * (instancePos + quadOffset * footprint), so the shared quad is the one thing the positions never pass through, which is already why the slope's tilt is baked there.

A sweeping light moves four corners every frame, so the quad's geometry is rewritten in place and its version bumped rather than the mesh being rebuilt.

The two genuine limits are a consequence of a set getting one plane and one quad: no height fade as an instance rises, and no per-instance value for anything. So a scatter over curved ground is one set per flat facet.

Fixed

  • Sprite3d dropped depthTest. It documented the option and never forwarded it to Mesh, so every sprite was depth tested whatever it asked for.
  • A glTF scene took 2 of 9 shadow settings. GLTFModel forwarded all nine; GLTFScene forwarded castGroundShadow and shadowGroundY only, so a scene loaded through level.load() could not be told where its sun was. They are named in GLTFScene.loadOptions, so a Trigger-driven load carries them too.
  • GLTFModel dropped shadowOpacity on every part it built.
  • The transparent pass sorts a mesh by where its geometry is rather than where its origin is; blob shadows under a corner-authored ground plane were the visible casualty.

Docs

The 3D skill gains a capability table covering every setting against every renderable, stated once rather than half-remembered in prose. The assets skill said a blob "is never offset by light direction" and that the Trigger option list is hardcoded; neither had been true for a while. Worked @example blocks where a reader would otherwise guess.

README: the feature bullet now says what a game can actually ask for. Also corrects the audio line, which credited howler.js as though it were a dependency when it is a customized derivative vendored under its own LICENSE, and the engine has no runtime dependencies.

Examples

  • Ground Shadows, a new showcase: nine labelled stations under an orbiting sun, each wired to one setting, plus a checkbox that flips castGroundShadow across the scene. Blobs are easy to look at and conclude nothing about, and the A/B is the only honest way to see what they contribute. Measured that way each station darkens its floor by 34 to 69 of 255, against 2.4 for bare ground.
  • Jungle Rabbit gets shadows on its banks, which took three fixes: the lowest band's plane was a secant that sat below the facet it stood for, so those blobs lost the depth test to the ground; blobs near a facet seam were cut by the neighbouring terrain; and the settings were tuned past the point of reading. Its title screen cast nothing at all and now does. bigleaf.glb carried its bloom 0.87 units above the plant with no stem to hold it.

Verification

Full suite 7648 passing, 0 failing. 27 new tests on this work, each mutation-tested: reverting the behaviour it guards fails that test and no other. Build (lint gate included), tsc, biome and typedoc all clean; the four affected example routes load with no page errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

obiot and others added 8 commits October 5, 2026 08:02
#1631 items 2 and 3, as one explicit feature rather than two, with the
four objections the issue recorded addressed rather than ignored.

`shadowOffset` slides the blob along `shadowDirectionX`/`shadowDirectionZ`
and `shadowStretch` lengthens it along the same line. `shadowLight` takes
that direction from a Light3d you name, re-read every draw.

- an offset blob slides off a slope: the offset is honoured ONLY when
  `shadowGroundY` is set. Setting it is the game stating where its floor
  is, and that is the only case where sliding across it is honest. The
  fallback, where the blob sits at the caster's own base, refuses.
- a low sun should lengthen a shadow, which the issue judged beyond a
  footprint ellipse. It is not: the basis is already an oriented,
  anisotropic pair taken from the caster's own model columns, so the
  stretch is `S = I + (stretch - 1)·d⊗d` applied in world XZ, which
  leaves everything perpendicular to `d` untouched. No new primitive.
- which light wins: nothing is inferred. Name a light or get no
  direction, so there is no dominant-light rule to invent and no
  fallback to define.
- coupling to a real light invites scrutiny the model cannot survive:
  the stretch is clamped to 3 and the blob fades as it pulls, so an
  extreme value degrades into nothing rather than into a smear, and the
  controls are named and documented as art direction.

Per-object. An InstancedMesh shares one quad across instances that each
carry their own rotation, so a world-space direction cannot be baked into
it; it ignores both halves rather than honouring one.

Also: `GLTFModel` now forwards the shadow settings to the parts it
builds. Only `castGroundShadow` and `shadowGroundY` reached them, so
`shadowOpacity` was silently dropped on every loaded model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
…ch-1631

# Conflicts:
#	packages/melonjs/src/renderable/instanced_mesh.js
#	packages/melonjs/src/renderable/mesh.js
#	packages/melonjs/src/renderable/sprite3d.js
#	packages/melonjs/tests/ground_shadow.spec.js
- GLTFModel forwards shadowScale alongside the other shadow settings
- melonjs-3d skill: shadowScale is the simple answer to a wide prop hiding
  its own blob; symptom table updated
- jsdoc: don't animate shadowScale on an InstancedMesh (rebuilds geometry)
- CHANGELOG: shadowScale entry in house style, under Added

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146cnBA4Z2zLzWYa3wjnYaW
`SHADOW_LIFT = 8` was a workaround for the gap #1714 closes. Its own
comment said why it existed: "the engine centres a blob under its caster
and does not offset it by the light direction, so a boulder sitting in
the shallows hides its own contact shadow completely from this camera".
Raising `shadowGroundY` does not slide the blob out from under the rock,
it floats the blob UP, which is the exact move the melonjs-3d skill warns
against, and the comment was factually wrong as of #1714.

So: the shadow plane sits on the water, and the blobs are thrown along
the scene's own `Light3d` through `shadowLight`.

Measured in the browser, caster (-264, 4881) puts its blob at
(-280, 4902): a shift of (-16, +21), which is 26 units along the sun's
normalised ground bearing (-0.614, +0.789). The offset is also honest
rather than chosen for effect — the sun stands 55 degrees up, so a
40-unit rock casts a shadow about 28 units long.

Note the visible change is small, and for a reason worth recording: that
bearing is +0.789 in Z, away from the camera, so the blob moves behind
the caster where the caster hides it; and the blob's half-extent is 76 to
83 units against a 26-unit throw. The win here is correctness, not
visibility.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Two things were wrong with the first pass.

`SHADOW_LIFT` was doing a second job I had not noticed. Render space is
Y-down, so `WATER_LEVEL + 8` is BELOW the water surface: the lift was
also separating the blob from the river plane, not only faking an offset.
Putting the plane exactly on the water made the shadows vanish. Restored
as `SHADOW_SINK`, named for the job it actually does.

And the direction was taken from the `Light3d`, which does not agree with
the sun the player can see. The billboard sits at `(0, -1500, +8200)`,
dead ahead and about ten degrees up, so its light travels (0, +0.18,
-0.98) TOWARD the camera. The light's own direction is (-0.35, +0.82,
+0.45), away from it: the two are 107 degrees apart, opposite in Z. That
direction was chosen for how it shades the valley walls, which is a fair
thing to tune by eye because nothing in the frame contradicts it. A
shadow is contradicted: the sun is on screen, so a shadow pointing away
from it reads as a bug, and it also hides behind its own caster.

So the shadows are thrown along the drawn sun instead, which is both
correct against what is on screen and the visible choice. A low sun
ahead also gives them something to show: offset 70 with a 2.5 stretch,
lying in front of each rock toward the viewer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
One scene-wide `SHADOW_OFFSET` cannot serve props of different sizes. The
right distance is about `footprint radius x (stretch - 1)`, the amount
that shifts a stretched ellipse so its trailing edge still sits at the
caster's feet, and that is a property of the caster.

A boulder's blob has a radius around 76 and a carrot's around 26, so a
single 70 moved the boulder barely at all while throwing the carrot's
shadow clean off its own feet: measured, the carrot's blob sat 55 to 65
screen pixels below its base, up to 38 darker than the water either side,
with bright water in between. A shadow with no contact reads as a stain
on the floor rather than as the carrot's.

Per kind now. The carrot's shadow starts at its tip (-17.5 and -22.3
against the water beside it, at the base and just below) and stretches
toward the camera.

The carrots are what this is for. A boulder sits bedded in the water with
its widest part at the surface, so it covers its own contact shadow
whatever is done to it, which is the shadow being right rather than a
thing to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
The example found this before the engine did. Its two per-kind
constants, tuned independently by eye, came out at 38 units for a carrot
and 110 for a boulder: 1.46 and 1.45 of their own blob radii. The same
number twice, which is what a world distance was hiding.

The distance that reads right is the one that shifts a stretched ellipse
far enough for its trailing edge to stay at the caster's feet, about
`stretch - 1` of its radius. That is a property of the caster, so a world
value has to be retuned for every size of thing and cannot serve the
parts of one glTF model at all. Taken as a ratio the example's two
constants collapse into one.

Measured against `extent * SHADOW_SPREAD`, the blob's radius at FULL
strength rather than its drawn size, so a rising object's shadow does not
slide back under it as the height fade shrinks the blob.

Two corrections alongside it:

- the note in `_drawInstancedGroundShadow` said `shadowOffset` and
  `shadowStretch` "cannot be baked" into the shared quad. Only the
  stretch cannot: an anisotropic world scale reaches that path through
  the group matrix, which multiplies the instance POSITIONS too and
  would smear the whole scatter. The offset is a pure translation and
  composes fine. Ignoring both is a choice, and the comment now says so
  rather than claiming an impossibility.
- `shadowLight` was typed `{object}`. It is a `Light3d`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
`shadowGroundY` said where the floor was; nothing said which way it faced,
so the blob was always a horizontal quad. On anything but level ground it
sliced into the surface rather than lying on it.

`shadowGroundNormal` is that missing half. World up is `(0, -1, 0)`,
since render space is Y-down, and it is the default: the matrix comes out
bit-for-bit as before, which is what the existing 69 cases pin.

Three choices worth stating:

- The blob is ROTATED onto the plane, not projected. A vertical
  projection would lengthen it by `1 / cos(tilt)` and an orthogonal one
  shrink it by `cos(tilt)`, so the blob would change size by more and
  more exactly where the feature is used. A rotation preserves its shape.
  The rotation is the Rodrigues matrix with the source axis fixed at
  world up, so the usual cross-product terms collapse.
- It stays directly UNDER the caster. The normal turns the blob, it does
  not move it; projecting the origin along the normal instead would
  slide a jumping character's shadow down the slope as it rose.
- The offset rides IN the plane, turned onto it like the axes. Slid
  horizontally it would climb off a tilted floor by `distance *
  tan(tilt)` and hang in the air.

Clamped at 75 degrees from vertical, past which an edge-on blob has
nothing left to show, and clamped rather than refused so a normal sliding
off a curved surface stops tilting instead of vanishing.

Per-object. An `InstancedMesh` shares one quad across its instances, so
one normal cannot serve a scatter spread over curved ground; it ignores
this as it already ignores the offset and the stretch.

The setting is an accessor: it takes an array or a `Vector3d` and keeps a
normalized copy of its own, so assigning one live cannot leave the draw
path reading `.x` off an array, and a game that goes on mutating what it
passed does not steer the shadow by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI balanced review requested due to automatic review settings October 5, 2026 07:43

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 and others added 9 commits October 5, 2026 17:50
`shadowGroundNormal` reached only the per-object path. An instanced set
flattens every blob onto one plane, which on uneven ground draws them all
at the same height: a hard stripe across the hillside rather than a
shadow under each thing standing on it.

Two changes make the tilt work there, neither of which needs a shader:

- The quad's four vertices are baked pre-rotated into the plane. The
  instanced shadow reaches the GPU as `uModelMatrix * (instancePosition +
  quadOffset)`, one matrix for the whole set, so a tilt applied there
  would turn the instance POSITIONS too and slide the scatter. On the
  offsets alone it only turns each blob. The normal is pulled back
  through the group's `diag(s, -s, ±s)` axis bridge first, since the
  vertices live in the space that bridge starts from, and the quad cache
  keys on it so a set that changes tilt rebuilds.
- With a tilt set, the matrix's Y row is left alone, so each blob keeps
  the height of the instance it belongs to. An instance planted on the
  ground already carries the right height in its own transform; there is
  nothing a shared plane can add, and plenty it takes away.
  `shadowGroundY` is therefore unused on that branch: the anchor is each
  instance rather than one plane for the set.

`shadowOffset` and `shadowStretch` stay per-object. The stretch genuinely
cannot work here, since an anisotropic world scale would reach the
instance positions through the same shared matrix and smear the set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Brings the external `shadowScale` contribution and the follow-up that
carries it through `GLTFModel` and the 3D skill, alongside the offset,
stretch and ground-normal work on this branch.

Three conflicts, all the same shape: both sides were adding to the same
list of shadow settings. Resolved by keeping both.

One decision rather than a merge: `shadowOffset` is measured in the
blob's OWN radius, and `shadowScale` is what changes that radius, so the
scale now rides in the product. Left out, a game that widened its blob
would find the shadow's trailing edge creeping back under the caster,
which is the one relationship the ratio exists to hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
The instanced tier refused `shadowStretch`, on the grounds that an
anisotropic scale reaches the GPU through the group matrix and would smear
the instance positions along with each blob. That is true of the MATRIX and
only of the matrix. The vertex stage builds

    uModelMatrix * (instancePos + quadOffset * footprint)

so the shared QUAD is the one thing the positions never pass through, which
is already why the slope's tilt is baked there. One stretch, one direction
and one quad for the whole set is all a set needs, and all it can carry.

The quad now takes its two ground axes as vectors rather than half-widths,
since a stretched blob's axes no longer lie along local X and Z. A sweeping
light moves four corners every frame, so the geometry is rewritten in place
and the version bumped rather than the mesh being rebuilt: the retained path
re-uploads on exactly that, and allocating a mesh and its GPU buffers per
frame to move twelve floats is what makes a feature not worth having.

`resolveShadowStretch` and `stretchAlong` are shared with the per-object
tier, so the same asset cannot draw a differently stretched blob depending
on which one happens to carry it, and the fade is the same `1 / sqrt(stretch)`
in both. The throw now rides the CLAMPED floor normal rather than the raw
one, matching the plane the blob is actually projected onto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Both are the same bug class. A constructor that builds its own settings
object for the class below it drops anything it forgets to name, and does it
in silence.

`Sprite3d` documents `depthTest` and never forwarded it, so every sprite was
depth tested whatever it asked for. A world-space label billboarded over a
prop was sliced by that prop's own foliage, which is the case the option
exists for.

`GLTFScene` forwarded `castGroundShadow` and `shadowGroundY` and no more, so
a scene loaded through `level.load()` could not be told where its sun was,
how big its blobs should be, or what slope they land on. The nine settings a
rigged `GLTFModel` already took now reach a static scene as well, through one
shared object so the two paths cannot drift, and they are named in
`GLTFScene.loadOptions` so a `Trigger`-driven load carries them too. That
list is what #1649 added for exactly this.

Each option is passed RAW. An omitted one has to arrive as `undefined` for a
mesh to keep its own default, and coercing it would pin every scene to a
value its author never chose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
The docs had drifted behind the code in both directions, and the drift was
costing real time: three separate "this does not work" claims turned out to
be stale rather than true.

The 3D skill gains a capability table covering every setting against every
renderable, stated once so it cannot be half-remembered. Everything works on
an `InstancedMesh`; the only two gaps are a height fade and a per-instance
value, which are one limitation seen twice, since a set gets one plane and
one shared quad.

The assets skill said a blob "is never offset by light direction" and that
the `Trigger` option list is hardcoded. Neither has been true for a while.
It now names `shadowScale` and `shadowOffset` as the knobs for a prop that
hides its own shadow, and says plainly that `shadowGroundY` is not one.

Worked examples added where a reader would otherwise guess: a bobbing
collectible for `shadowGroundY`, a crate for `shadowScale`, one sun driving
a whole scene for `shadowLight`, a hillside for `shadowGroundNormal`, and a
per-facet scatter on `InstancedMesh`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Nine labelled stations on a checkered floor with a slope cut into one side,
under a sun that orbits, each wired to one setting so the difference between
them is the only thing moving: the plain contact blob, opacity, footprint,
stretch and offset driven off the sun's own height, the height fade, a
hand-set direction that pointedly ignores the sun, a seventy-fern scatter,
props standing on the slope, and a scatter on it.

The "shadows" checkbox is the point of the thing. Blobs are easy to look at
and conclude nothing about, and this session concluded wrongly three times
from screenshots; flipping `castGroundShadow` across the scene is the only
honest way to see what they were contributing. Measured that way, each
station darkens its floor by 34 to 69 of 255 against 2.4 for bare ground.

Two things learned here are worth knowing before editing it. A squat caster
hides its own blob at a raised camera, so the prop is chosen to suit the
knob rather than for variety. And `addChild(child, z)` overwrites `pos.z`,
so the depth has to be the second argument: a `.depth =` after it is lost in
silence, which collapsed every station onto one row.

Props are borrowed from Jungle Rabbit rather than shipped again, read
straight off the parsed glTF so the example owns no texture of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
The planting cast nothing a player could see. Three separate causes, each
found by measuring the floor's own brightness with the blobs switched off
and on, because reading it off a screenshot got it wrong every time.

The lowest band was BURIED. A set's shadow plane was the chord between the
planting band's own edges, but the terrain draws facets between its COLUMN
positions, and the ferns start at 0.55 of the half width, which is not a
column. A secant of a convex profile sits below the facet it stands in for,
here by 2.7 px against a lift of half a pixel, so those blobs lost the depth
test to the ground. That is the "ferns at the bottom of the slope cast
nothing" report, three times over. The chord now runs column to column while
the planting stays inside its band.

They were CUT at every seam. A blob near a facet edge reaches past it, where
its own plane diverges from the next facet's and ends up under it, so the
neighbouring terrain drew over the part that crossed. A tenth of all blob
rims, up to 13 px deep; the level plateau sets measured exactly zero, which
is the control. Each sloped set's plane is now raised by the divergence a
blob can reach. The level ones keep their exact plane.

And they were tuned past the point of reading. 2.5x stretch on a crown-sized
blob is a bar lying across the hillside, and blobs wide enough to overlap
stop being shadows and become one dark wash whose edge is the planting
band's own boundary, which reads as the ground being painted over them. The
bank now has its own settings rather than the boat's.

Bank darkening went from 2.8-4.5 of 255 to 5-8, with the lowest band from
0.4 to 4.7. The title screen was planted with `castGroundShadow: false` and
one set per asset; it has the same per-facet split now, so it casts too.

Also: `bigleaf.glb` carried its bloom 0.87 units above the plant with no
stem to hold it, which read as a pink speck hovering over the bank. The 48
vertices are lowered into the crown and the accessor's bounds follow them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
`shadowScale` came in as #1712 and the entry did not say so. External
contributions get named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
The feature bullet described a blob that only ever sits straight under its
caster, which stopped being the whole story once `shadowLight`, the offset,
the stretch, the scale and the ground normal landed. It now says what a game
can actually ask for, and that a scatter takes all of it too.

Adds the Ground Shadows showcase to the examples list, and Jungle Rabbit,
which had shipped without an entry.

The audio line credited howler.js as though it were a dependency. It is a
customized derivative vendored into `src/audio/backend` under its own
LICENSE, and the engine has no runtime dependencies at all, which is a claim
made two paragraphs earlier. The credit stays, the implication does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
@obiot
obiot changed the base branch from shadow-offset-stretch-1631 to master October 6, 2026 00:25
@obiot obiot changed the title Ground shadows: a floor that is not level Ground shadows: shaped by the light, and laid on any floor Oct 6, 2026
@obiot
obiot merged commit 555c543 into master Oct 6, 2026
3 checks passed
@obiot
obiot deleted the shadow-ground-normal branch October 6, 2026 00:31
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.

3 participants