Skip to content

Ground shadows: offset and stretch, for a light that is not overhead - #1714

Closed
obiot wants to merge 5 commits into
masterfrom
shadow-offset-stretch-1631
Closed

obiot wants to merge 5 commits into
masterfrom
shadow-offset-stretch-1631

Conversation

@obiot

@obiot obiot commented Oct 5, 2026

Copy link
Copy Markdown
Member

#1631 items 2 and 3, as one explicit feature rather than two. The issue recorded four reasons they were not done first, "so it is not re-proposed without them being addressed" — so here is each one and what answers it.

The four objections

1. "An offset blob slides off the slope it is supposed to be lying on."

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 a flat quad across it is honest. The fallback, where the blob sits at the caster's own base, refuses the offset outright.

2. "A low sun should lengthen a shadow, which a footprint-sized ellipse cannot express."

It can, and more cheaply than the issue assumed. The basis is already an oriented, anisotropic pair — axX/axZ and azX/azZ come from the caster's own model columns with independent lengths (mesh.js:1970-1973), and today both simply share one scalar k. The stretch is an anisotropic scale in world XZ:

S = I + (stretch - 1) · d ⊗ d      →      S·v = v + (stretch - 1)(v · d) d

which leaves anything perpendicular to d exactly as it was. No new primitive, no shader change.

3. "It needs an arbitrary 'which light wins' rule, plus a fallback when there is no directional light."

Nothing is inferred. shadowLight names one light and is read every draw; shadowDirectionX/shadowDirectionZ set it by hand. No light named means no direction, so there is no dominant-light rule to invent and no fallback to define.

4. "Coupling it to a real light makes the blob look like it is modelling something, which invites scrutiny the model cannot survive."

The stretch is clamped to 3 and the blob fades by 1/√stretch as it pulls, so an extreme value degrades into nothing rather than into an obvious smear. The controls are named and documented as art direction, and the skill says plainly that this is not a projection and that direction-correct shadows want a shadow map, which this tier does not do.

API

mesh.shadowDirectionX = 1;   // ground direction the shadow is cast along
mesh.shadowDirectionZ = 0;
mesh.shadowOffset = 30;      // world distance along it; needs shadowGroundY
mesh.shadowStretch = 2;      // longer along the same line, clamped to 3
mesh.shadowLight = sun;      // or take the direction from a named Light3d

Forwarded through Sprite3d and GLTFModel.

Scope

Per-object. An InstancedMesh shares one quad across instances that each carry their own rotation, so a direction fixed in world space cannot be baked into the shared geometry — instances rotated differently would each stretch a different way. It ignores both halves rather than honouring one and reading as a bug. There is a test pinning that.

Also fixed

GLTFModel forwarded only castGroundShadow and shadowGroundY to the parts it builds, so shadowOpacity was silently dropped on every loaded model — which is most of the props a scene has. All the shadow settings now pass through.

Tests

16 new cases in tests/ground_shadow.spec.js, 68 total in that file, all mutation-tested. Dropping the offset, removing the shadowGroundY gate, removing the clamp, removing the fade, or ignoring the named light each turn one red.

One is worth calling out: the first stretch test used a direction along world X, and on an axis-aligned caster the second basis column projects to zero — so leaving that column unstretched passed anyway. There is now a test at 45°, where both columns carry a share, and it measures the blob's reach via its support function along d and along the perpendicular. That one does catch it.

Gates

Full suite 7587 passed, 0 failed. Build and eslint gate clean, biome clean, typedoc 0 warnings.

Overlap with #1712

#1712 adds shadowScale, a uniform multiplier, and touches the same k line in mesh.js plus the same GLTFModel forwarding block. The two compose naturally (shadowScale sizes the blob, shadowStretch pulls it along one axis). Whichever lands second takes a small rebase; I have deliberately not added shadowScale forwarding here, since that was requested of that PR's author.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

#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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI balanced review requested due to automatic review settings October 5, 2026 00:02

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.

`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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI balanced review requested due to automatic review settings October 5, 2026 01: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.

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI balanced review requested due to automatic review settings October 5, 2026 02:06

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.

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI balanced review requested due to automatic review settings October 5, 2026 04:59

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.

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI balanced review requested due to automatic review settings October 5, 2026 07:18

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 commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Superseded by #1715, which now targets master directly and contains every commit from this branch (verified: git rev-list --count shadow-ground-normal..shadow-offset-stretch-1631 is 0).

The offset and stretch work landed there alongside the slope normal, the instanced tier and the fixes that came out of building a showcase for all of it. Closing this one so there is a single PR to review.

@obiot obiot closed this Oct 6, 2026
obiot added a commit that referenced this pull request Oct 6, 2026
* Ground shadows: offset and stretch, for a light that is not overhead

#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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Ground shadows: shadowScale reaches GLTFModel, and the skill says so

- 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_0146cnBA4Z2zLzWYa3wjnYaW

* Jungle Rabbit: shadows placed by the sun, not by fake altitude

`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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Jungle Rabbit: aim the shadows at the sun you can see

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Jungle Rabbit: the throw distance belongs to the caster, not the scene

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Ground shadows: shadowOffset is in blob radii, not world units

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Ground shadows: a floor that is not level

`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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Ground shadows: an instanced set can lie on a slope too

`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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Ground shadows: an instanced set stretches too

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Ground shadows: two settings that were documented but never arrived

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Ground shadows: say what the tier can actually do

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Examples: a Ground Shadows showcase

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Jungle Rabbit: shadows on the bank, and three reasons they were not

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* Changelog: credit @snowyukitty for the shadowScale contribution

`shadowScale` came in as #1712 and the entry did not say so. External
contributions get named.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

* README: what a ground shadow can be told, and the new example

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
@obiot
obiot deleted the shadow-offset-stretch-1631 branch October 6, 2026 00:32
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.

2 participants