Skip to content

Add per-object ground shadow scaling - #1712

Merged
obiot merged 1 commit into
melonjs:masterfrom
snowyukitty:feat/shadow-scale
Oct 5, 2026
Merged

obiot merged 1 commit into
melonjs:masterfrom
snowyukitty:feat/shadow-scale

Conversation

@snowyukitty

Copy link
Copy Markdown
Contributor

Description

A wide, flat-bottomed prop can hide its ground shadow entirely, and the footprint currently has no per-object size control. Add shadowScale (default 1) beside shadowOpacity so games can enlarge or tighten the blob independently of the object's size.

The setting works on Mesh, Sprite3d, and InstancedMesh and can be changed at runtime. It scales the footprint without changing the shadow's ground position or opacity; non-positive or non-finite values hide the shadow while leaving the object visible. Instanced shadow geometry refreshes when the scale changes and remains cached otherwise.

Type of change

  • Bug fix
  • New feature
  • Documentation update
  • Performance improvement
  • Refactoring (no functional changes)

Checklist

  • I have read the Contributing Guide
  • My code follows the existing code style (pnpm lint passes)
  • I have tested my changes locally (pnpm test --run --maxWorkers=2 passes)
  • I have added tests that cover my changes
  • The build succeeds (included in pnpm lint and pnpm test)

Validation uses Chromium with WebGL required. The full suite reports 7,553 passed and 11 skipped. The ground-shadow suite passes 72 tests, including 20 new cases for scaling, live changes, invalid values, instanced geometry caching, and rendered pixels outside a wide prop. On the original source, all 20 new cases fail as expected; the unmodified 52-test suite still passes.

Related issues

Part of #1631: implements item 1, per-object shadow scale. Items 2 and 3 remain open.

@obiot

obiot commented Oct 4, 2026

Copy link
Copy Markdown
Member

this is awesome, thank you for the contribution ! will review & merge asap as part of the 20.8 release

@obiot obiot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for this — it is a well-made PR, and the testing in particular is better than most. I verified your claims rather than taking them on trust and they hold up exactly:

  • tests/ground_shadow.spec.js runs 72 passed, 0 skipped here (the requireWebGL helper only skips on a genuinely absent renderer, so these really execute).
  • Reverting just the three source files to master and keeping your tests gives 20 failed / 52 passed — precisely the 20 new cases you described, and the pre-existing 52 untouched.
  • Full suite 7554 passed, lint gate clean.
  • Your "instanced geometry refreshes when the scale changes" claim is correct, and for the right reason: getInstancedShadowQuad keys on ${lit}:${version}:${halfX}:${halfZ} and the scale now feeds halfX/halfZ, so the key moves and the quad is rebuilt.

The design matches what #1631 asked for — shadowScale, default 1, beside shadowOpacity, footprint only, with non-positive and non-finite values hiding the blob without hiding the mesh.

One blocking gap: it does not reach a GLTFModel

GLTFModel forwards only two of the shadow settings to the parts it builds (src/level/gltf/GLTFModel.js:287-291):

castGroundShadow: /* … */,
shadowGroundY: options.shadowGroundY,

shadowOpacity was already missing (pre-existing, not yours) and shadowScale is missing too — so from a GLTFModel the new setting is unreachable without walking the children and assigning .shadowScale on each part by hand.

That matters because the motivating case in #1631 is "a wide, flat-bottomed prop", and the one consumer in this repo is exactly that and exactly a GLTFModel — the boat in the Jungle Rabbit example, which already does:

new GLTFModel(gltf("boat"), {
    castGroundShadow: true,
    shadowGroundY: WATER_LEVEL + SHADOW_LIFT,
})

A BOAT_SCALE 44 hull on water is the case the feature exists for, and today it cannot opt in. Could you add shadowScale: options.shadowScale alongside shadowGroundY there, with the matching @param on the constructor doc? Adding shadowOpacity in the same place would be welcome too, but that one is pre-existing so entirely your call whether to fold it in.

Changelog: style, not content

No merge conflict (I dry-ran it), but #1710 landed an hour ago and rewrote the whole 20.8.0 section into a terser house style, so your entry will arrive written to the old one. Current style is a bold lead-in, one or two sentences, no trailing full stop, and no blank line between entries. Something like:

- **`Mesh#shadowScale`**: the ground shadow's footprint independent of the object's size, on `Mesh`, `Sprite3d` and `InstancedMesh`. `1` by default; non-positive or non-finite hides the blob without hiding the object

It should also sit with the other ### Added entries rather than after a blank line at the end of the section.

Two small notes, neither blocking

  1. The InstancedMesh path rebuilds by quad.destroy() + new Mesh(...), so a game that animates shadowScale on an instanced scatter re-uploads retained GPU geometry every frame. The JSDoc says the change "rebuilds its shadow quad", which is accurate but reads as a neutral fact; worth making it a warning, e.g. "changing it on an InstancedMesh rebuilds retained geometry, so avoid animating it per frame".

  2. it.for([0.5, 2, 1e-8]) on the instanced path is a nice edge case, but 1e-8 passes the > 0 guard and builds a quad with half-extents around 1e-8. Harmless, just worth knowing the guard's boundary is "positive and finite" rather than "visible".

Happy to merge once the GLTFModel forwarding and the changelog entry are in. Thanks again — the mutation evidence in the description made this much faster to review.

@obiot

obiot commented Oct 5, 2026

Copy link
Copy Markdown
Member

I'm also adding #1631 on top of this, so that the light3d shadow is "complete" for now, thanks again

@obiot
obiot merged commit ee19bbf into melonjs:master Oct 5, 2026
3 checks passed
obiot added a commit that referenced this pull request Oct 5, 2026
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
obiot added a commit that referenced this pull request Oct 6, 2026
`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
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 commented Oct 6, 2026

Copy link
Copy Markdown
Member

FYI, I use this opportunity to also close related tickets, and now we have all this working :
https://melonjs.github.io/melonJS/examples/#/ground-shadows

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