From b46baca27e6df81fe8db76854f4c5da3df2fe58f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 5 Oct 2026 08:02:02 +0800 Subject: [PATCH 1/5] Ground shadows: offset and stretch, for a light that is not overhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/CHANGELOG.md | 2 + packages/melonjs/skills/melonjs-3d/SKILL.md | 38 ++- packages/melonjs/src/level/gltf/GLTFModel.js | 16 ++ .../melonjs/src/renderable/instanced_mesh.js | 7 + packages/melonjs/src/renderable/mesh.js | 156 ++++++++++- packages/melonjs/src/renderable/sprite3d.js | 10 + packages/melonjs/tests/ground_shadow.spec.js | 249 ++++++++++++++++++ 7 files changed, 467 insertions(+), 11 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 0cd1b2d09f..1fce698ef2 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [20.8.0] (melonJS 2) - _unreleased_ ### Added +- **Ground shadow shape** (`shadowOffset`, `shadowStretch`, `shadowDirectionX` / `shadowDirectionZ`, `shadowLight`): slide the blob along the ground and lengthen it, for a light that is not overhead. The offset needs `shadowGroundY`, since a flat quad can only be slid across a plane the game has named; the stretch is clamped and fades as it pulls. Per-object, and art direction rather than a projection ([#1631](https://github.com/melonjs/melonJS/issues/1631)) - **3D particles** (`elevation`, `elevationVariation`): the launch lifts out of the emitter's plane, so a burst is a volume rather than a disc facing the viewer and a trail can recede. Both default to `0`, which leaves the 2D path exactly as it was ([#1696](https://github.com/melonjs/melonJS/issues/1696)) - **`minSpread` / `maxSpread`**: `angle` and `elevation` become an AXIS and each particle leaves at a polar angle off it, giving a cone, a flat disc or a whole sphere. Sampling azimuth and elevation independently cannot describe a ring tangent to a surface, since there the elevation is a function of the azimuth ([#1696](https://github.com/melonjs/melonJS/issues/1696)) - **`ProgressBar`**, a gauge renderable: track, value-sized fill, optional border and label, four directions, colour or gradient. The loading screen is built on it @@ -17,6 +18,7 @@ - **`Camera3d#setBasis(right, up, forward)` and `lookAt(target, up)`**: pose a camera from a basis the game already holds, which is what a view over a curved surface needs ### Fixed +- `GLTFModel` passes the ground-shadow settings to the parts it builds. Only `castGroundShadow` and `shadowGroundY` reached them, so `shadowOpacity` was silently ignored on every loaded model, which is most of the props a scene has - A particle was culled and sorted at twice its depth, because the emitter's depth was stamped onto each particle as a container-local `pos.z` and then summed again by the chain walk. Under a `Camera3d` a burst anywhere but the near face of the scene drew nothing at all - Destroying a `ParticleEmitter` returns its particles to the pool and cancels the sort it still owed. `Container#destroy` emptied itself through its own public `reset()`, which `ParticleEmitter` redefines to re-apply settings, so on an emitter that call did neither - Pointer events reach whatever is drawn on top. `pos.z` is container-local, so a button inside one panel outranked another panel stacked over it. A covered region is also told when it loses the pointer diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index ec215dc4f4..323f75a026 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -667,16 +667,40 @@ skip geometry with no vertical extent — a ground plane. Per object, given, safeguard included; `shadowGroundY` names the floor the blob lands on. The blob is an ellipse sized to the caster's own footprint and placed at the -caster's x/z — it is **never offset by light direction**. So a tall or narrow -object (a character, a tree, a pickup) shows its shadow clearly, while a wide, -flat-bottomed one resting on the floor covers its own completely from a camera -looking down at it. That is the shadow behaving correctly, not a bug. +caster's x/z. So a tall or narrow object (a character, a tree, a pickup) shows +its shadow clearly, while a wide, flat-bottomed one resting on the floor covers +its own completely from a camera looking down at it. That is the shadow +behaving correctly, not a bug. **Do not chase it by raising `shadowGroundY`.** Lifting the plane does not slide the blob out from under the object, it floats the blob *up* — and past a few -units it projects over the top of the caster as a dark halo ringing it. If an -object needs a visible shadow, give it a smaller footprint relative to its -height, or accept that a boulder bedded in the ground has none. +units it projects over the top of the caster as a dark halo ringing it. + +Use the shape controls instead: + +```js +mesh.shadowOffset = 30; // slide it out along the direction below +mesh.shadowDirectionX = 1; // the ground direction it is cast along +mesh.shadowDirectionZ = 0; +mesh.shadowStretch = 2; // and lengthen it along the same line +``` + +`shadowOffset` is honoured **only when `shadowGroundY` is set**, because the +blob is a flat quad on one named plane: sliding it across a plane the game has +not named puts it where there may be no floor. `shadowStretch` is clamped to 3 +and the blob fades as it pulls, so an extreme value degrades to nothing rather +than to a smear. Both are per-object — an `InstancedMesh` shares one quad +across instances that each carry their own rotation, so it ignores them. + +`shadowLight` takes the direction from a `Light3d` you name, re-read every +draw, so a moving sun carries the shadows with it. Nothing is inferred: the +engine never picks a dominant light, because a scene with several has no +non-arbitrary answer and one with none has no answer at all. + +These are **art direction, not a projection**. A stretched ellipse is not a +silhouette and has no contact with terrain, so on ground that is not the plane +you named it will not lie on it. Direction-correct shadows want a shadow map, +which this tier does not do. ### Get the sign right: the floor is a GREATER y diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 8391f92c85..b211fad238 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -102,6 +102,12 @@ export default class GLTFModel extends Container { * @param {boolean} [options.lit=false] - render the part meshes through the lit batcher * @param {boolean} [options.castGroundShadow] - give the parts a ground shadow; omit to inherit the application setting * @param {number} [options.shadowGroundY] - world Y of the floor those shadows land on + * @param {number} [options.shadowOpacity=0.45] - opacity of those shadows before any height fade + * @param {number} [options.shadowOffset=0] - world distance to slide them along their direction; needs `shadowGroundY` (see {@link Mesh#shadowOffset}) + * @param {number} [options.shadowStretch=1] - how much longer they are along that direction, clamped to 3 + * @param {number} [options.shadowDirectionX=0] - x of the ground direction they are cast along + * @param {number} [options.shadowDirectionZ=0] - z of the ground direction they are cast along + * @param {object} [options.shadowLight] - a {@link Light3d} to take that direction from instead */ constructor(data, options = {}) { super(0, 0); @@ -289,6 +295,16 @@ export default class GLTFModel extends Container { ? hasVerticalExtent(prim.vertices, prim.vertexCount) : castGroundShadow, shadowGroundY: options.shadowGroundY, + // the rest of the shadow controls, which did not reach a + // glTF model at all before: a wide flat-bottomed prop is + // exactly the case they exist for, and a prop is usually + // loaded rather than built + shadowOpacity: options.shadowOpacity, + shadowOffset: options.shadowOffset, + shadowStretch: options.shadowStretch, + shadowDirectionX: options.shadowDirectionX, + shadowDirectionZ: options.shadowDirectionZ, + shadowLight: options.shadowLight, }); if (prim.instances) { fillInstances(mesh, prim.instances); diff --git a/packages/melonjs/src/renderable/instanced_mesh.js b/packages/melonjs/src/renderable/instanced_mesh.js index 63c34793ec..dcd83e64b3 100644 --- a/packages/melonjs/src/renderable/instanced_mesh.js +++ b/packages/melonjs/src/renderable/instanced_mesh.js @@ -725,6 +725,13 @@ export default class InstancedMesh extends Mesh { * @internal */ _drawInstancedGroundShadow(renderer) { + // NOTE: `shadowOffset` and `shadowStretch` are per-object controls and + // are deliberately ignored here. One quad is shared by every instance + // and each applies its OWN transform, so a direction fixed in world + // space cannot be baked into the shared geometry: instances that are + // rotated differently would each stretch a different way. Honouring + // one of the two and not the other would read as a bug, so neither is. + // `shadowScale` is uniform and so has no such problem. if (typeof renderer.drawInstancedShadow !== "function") { return; } diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index de8907379f..423a45d693 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -60,6 +60,18 @@ const SHADOW_MIN_AXIS_RATIO = 0.5; // contact shadows spread a little anyway, because no light source is a point. const SHADOW_SPREAD = 1.2; +/** + * Ceiling on {@link Mesh#shadowStretch}. + * + * A stretched ellipse is not a projected silhouette, and the further it is + * pulled the more plainly it is a smear rather than a shadow. Past about three + * times its own footprint there is nothing left to read, so the setting stops + * there rather than letting a game dial in something that can only look broken. + * @ignore + * @internal + */ +const SHADOW_MAX_STRETCH = 3; + // reusable matrix for combining projection × model in draw() const _combinedMatrix = new Matrix3d(); @@ -378,6 +390,11 @@ function buildTextureGroups( * @property {boolean} [fog] - set `false` to exempt this mesh from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does * @property {number} [shadowGroundY] - world Y of the floor the shadow lands on. Omit and the blob sits at the object's own base at full strength; set it and the blob shrinks and fades as the object rises. Render space is Y-down, so the floor is a **greater** Y than the object above it. * @property {number} [shadowOpacity=0.45] - opacity of the shadow directly beneath the object, before any height fade. + * @property {number} [shadowOffset=0] - world distance to slide the shadow along `shadowDirectionX`/`shadowDirectionZ`, for the look of a light that is not directly overhead. Honoured only when `shadowGroundY` is set, since the blob is a flat quad and sliding it off a plane the game has NOT named puts it somewhere there is no floor. + * @property {number} [shadowStretch=1] - how much longer the blob is along `shadowDirectionX`/`shadowDirectionZ`, for the look of a low light. Clamped to 3, and the shadow fades as it stretches. + * @property {number} [shadowDirectionX=0] - x of the ground direction the shadow is cast along. Together with `shadowDirectionZ`, zero length means no offset and no stretch. + * @property {number} [shadowDirectionZ=0] - z of the ground direction the shadow is cast along. + * @property {object} [shadowLight] - a {@link Light3d} to take the direction from instead of setting it by hand. Read every draw, so a moving sun carries the shadow with it. Nothing is inferred from the scene: no light here means no direction. */ /** @@ -932,6 +949,77 @@ export default class Mesh extends Renderable { ? settings.shadowOpacity : 0.45; + /** + * World distance to slide the ground shadow along + * {@link Mesh#shadowDirectionX}/{@link Mesh#shadowDirectionZ}, which is + * what gives the look of a light that is not directly overhead. + * + * Honoured ONLY when {@link Mesh#shadowGroundY} is set. The blob is a + * flat quad on one named plane with no contact with terrain, so sliding + * it across a plane the game has not named puts it somewhere there may + * be no floor at all. Setting `shadowGroundY` is the game saying where + * its floor is, and that is the only case where an offset is honest. + * + * Per-object: an {@link InstancedMesh} ignores this, because its + * instances share one quad and each carries its own rotation. + * @type {number} + * @default 0 + */ + this.shadowOffset = + typeof settings.shadowOffset === "number" ? settings.shadowOffset : 0; + + /** + * How much longer the ground shadow is along its direction, for the + * look of a low light. `1` leaves it round. + * + * Clamped to 3, and the blob fades as it stretches: this is art + * direction, not a projection, and the further it is pulled the less + * there is to believe. Values below `1` are treated as `1`; shortening + * the blob is what {@link Mesh#shadowScale} is for. + * + * Per-object, for the same reason as {@link Mesh#shadowOffset}. + * @type {number} + * @default 1 + */ + this.shadowStretch = + typeof settings.shadowStretch === "number" ? settings.shadowStretch : 1; + + /** + * X of the ground direction the shadow is cast along. + * @type {number} + * @default 0 + * @see Mesh#shadowOffset + */ + this.shadowDirectionX = + typeof settings.shadowDirectionX === "number" + ? settings.shadowDirectionX + : 0; + + /** + * Z of the ground direction the shadow is cast along. Together with + * {@link Mesh#shadowDirectionX}, a zero-length pair means no offset and + * no stretch. + * @type {number} + * @default 0 + * @see Mesh#shadowOffset + */ + this.shadowDirectionZ = + typeof settings.shadowDirectionZ === "number" + ? settings.shadowDirectionZ + : 0; + + /** + * A {@link Light3d} to take the shadow direction from, instead of + * setting it by hand. + * + * Read every draw, so a sun that moves carries the shadow with it. + * NOTHING is inferred: the engine never picks a dominant light for you, + * because a scene with several has no non-arbitrary answer and one with + * none has no answer at all. Name the light or get no direction. + * @type {object|undefined} + */ + this.shadowLight = settings.shadowLight; + /** * Cached horizontal half-extent used to size the shadow, resolved on * first shadowed draw. Cached because the alternative is a @@ -2029,6 +2117,62 @@ export default class Mesh extends Renderable { } } + // A light that is not overhead: slide the blob along the ground and + // pull it out along the same line. + // + // The basis below is already an ORIENTED, anisotropic pair — `axX/axZ` + // and `azX/azZ` come from the caster's own model columns and carry + // independent lengths — so stretching it needs no new primitive, only + // an anisotropic scale applied in world XZ: + // + // S = I + (stretch - 1) · d ⊗ d (d unit, in the ground plane) + // S·v = v + (stretch - 1) · (v · d) · d + // + // which leaves anything perpendicular to `d` exactly as it was. + let offsetX = 0; + let offsetZ = 0; + let stretchFade = 1; + const light = this.shadowLight; + let dirX = this.shadowDirectionX; + let dirZ = this.shadowDirectionZ; + if (light !== undefined && light.direction !== undefined) { + // the direction a light TRAVELS along is the way its shadows go + dirX = light.direction.x; + dirZ = light.direction.z; + } + const dirLen = Math.hypot(dirX, dirZ); + if (dirLen > 1e-6) { + dirX /= dirLen; + dirZ /= dirLen; + + let stretch = this.shadowStretch; + if (!Number.isFinite(stretch) || stretch < 1) { + stretch = 1; + } else if (stretch > SHADOW_MAX_STRETCH) { + stretch = SHADOW_MAX_STRETCH; + } + if (stretch !== 1) { + const gain = stretch - 1; + const axDot = axX * dirX + axZ * dirZ; + axX += gain * axDot * dirX; + axZ += gain * axDot * dirZ; + const azDot = azX * dirX + azZ * dirZ; + azX += gain * azDot * dirX; + azZ += gain * azDot * dirZ; + // the same darkness spread over more ground is less of it + // anywhere, and fading as it pulls is what lets an extreme + // value degrade into nothing rather than into a smear + stretchFade = 1 / Math.sqrt(stretch); + } + + // Only on a plane the game NAMED: see `shadowOffset`. + const distance = this.shadowOffset; + if (this.shadowGroundY !== undefined && Number.isFinite(distance)) { + offsetX = dirX * distance; + offsetZ = dirZ * distance; + } + } + // the quad is a unit square, so a half-extent of `k · axis` needs the // basis column to be twice that const k = (0.5 + strength * 0.5) * 2 * SHADOW_SPREAD; @@ -2051,10 +2195,12 @@ export default class Mesh extends Renderable { out[9] = 0; out[10] = azZ * k; out[11] = 0; - out[12] = originX; - // render space is Y-DOWN, so lifting off the floor is a SMALLER y + out[12] = originX + offsetX; + // render space is Y-DOWN, so lifting off the floor is a SMALLER y. + // `extent` is measured BEFORE any stretch, so pulling the blob out + // does not also lift it off the floor it is lying on. out[13] = groundY - extent * SHADOW_LIFT; - out[14] = originZ; + out[14] = originZ + offsetZ; out[15] = 1; // Stash and restore by hand rather than save()/restore(): restore() @@ -2071,7 +2217,9 @@ export default class Mesh extends Renderable { const savedTintAlpha = tint.alpha; const savedAlpha = renderer.getGlobalAlpha(); tint.setColor(0, 0, 0); - renderer.setGlobalAlpha(this.shadowOpacity * strength * savedAlpha); + renderer.setGlobalAlpha( + this.shadowOpacity * strength * stretchFade * savedAlpha, + ); try { renderer.drawMesh(quad, quad._modelMatrix); } finally { diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js index 1081846e35..3d2f7a688a 100644 --- a/packages/melonjs/src/renderable/sprite3d.js +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -168,6 +168,11 @@ export default class Sprite3d extends Mesh { * @param {number[]|Float32Array} [settings.emissive] - emissive color (see {@link Mesh}) * @param {number} [settings.shadowGroundY] - world Y of the floor the blob shadow lands on. Omit and it falls back to the sprite's own base — which for a billboard moves with the camera, so a scene that knows where its floor is should say so. * @param {number} [settings.shadowOpacity=0.45] - opacity of the shadow directly beneath the sprite, before any height fade + * @param {number} [settings.shadowOffset=0] - world distance to slide the shadow along its direction; needs `shadowGroundY` (see {@link Mesh#shadowOffset}) + * @param {number} [settings.shadowStretch=1] - how much longer the blob is along its direction, clamped to 3 + * @param {number} [settings.shadowDirectionX=0] - x of the ground direction the shadow is cast along + * @param {number} [settings.shadowDirectionZ=0] - z of the ground direction the shadow is cast along + * @param {object} [settings.shadowLight] - a {@link Light3d} to take that direction from instead * @param {boolean} [settings.castGroundShadow] - give this sprite a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a {@link Camera3d}. * @param {boolean} [settings.fog] - set `false` to exempt this sprite from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does. A sun or a moon wants this — everything else at that distance dissolves into the haze, and so would it. * @param {boolean} [settings.transparent] - draw in the transparent pass (blended, back-to-front, no depth write) instead of the opaque one. Omit and the sprite goes transparent whenever its draw alpha is fractional; `true` for a soft-alpha sprite such as an additive glow; `false` to stay opaque however faded. @@ -288,6 +293,11 @@ export default class Sprite3d extends Mesh { transparent: settings.transparent, shadowGroundY: settings.shadowGroundY, shadowOpacity: settings.shadowOpacity, + shadowOffset: settings.shadowOffset, + shadowStretch: settings.shadowStretch, + shadowDirectionX: settings.shadowDirectionX, + shadowDirectionZ: settings.shadowDirectionZ, + shadowLight: settings.shadowLight, }); /** diff --git a/packages/melonjs/tests/ground_shadow.spec.js b/packages/melonjs/tests/ground_shadow.spec.js index 921deb5e8c..070d8b0702 100644 --- a/packages/melonjs/tests/ground_shadow.spec.js +++ b/packages/melonjs/tests/ground_shadow.spec.js @@ -329,6 +329,255 @@ describe("Ground shadows (#1515)", () => { mesh.destroy(); }); }); + /** + * Offset and stretch (#1631 items 2 and 3). + * + * The blob is a flat quad on one named plane, so an offset is only honest + * where the game has NAMED that plane. The stretch needs no new primitive: + * the basis is already an oriented, anisotropic pair, so pulling it out is + * an anisotropic scale `S = I + (stretch - 1)·d⊗d` applied in world XZ. + */ + describe("shadowOffset / shadowStretch", () => { + /** a mesh on a NAMED floor, which is what an offset needs */ + const onFloor = (settings = {}) => { + return makeMesh({ + castGroundShadow: true, + shadowGroundY: 20, + ...settings, + }); + }; + const origin = (lit = false) => { + const m = renderer._shadowQuads[lit ? "lit" : "unlit"]._modelMatrix.val; + return { x: m[12], z: m[14] }; + }; + + it("slides the blob along the direction, by exactly the distance asked", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor(); + drawOnce(mesh); + const before = origin(); + mesh.shadowDirectionX = 3; + mesh.shadowDirectionZ = 4; // length 5, so it must be normalised + mesh.shadowOffset = 10; + drawOnce(mesh); + const after = origin(); + expect(after.x - before.x).toBeCloseTo(6, 5); + expect(after.z - before.z).toBeCloseTo(8, 5); + mesh.destroy(); + }); + + it("refuses to slide off a plane the game never named", (ctx) => { + requireWebGL(ctx, renderer); + // no `shadowGroundY`: the blob falls back to the caster's own base, + // and there is no claim about where the floor is to slide across + const mesh = makeMesh({ castGroundShadow: true }); + drawOnce(mesh); + const before = origin(); + mesh.shadowDirectionX = 1; + mesh.shadowOffset = 25; + drawOnce(mesh); + expect(origin().x).toBeCloseTo(before.x, 6); + expect(origin().z).toBeCloseTo(before.z, 6); + mesh.destroy(); + }); + + it("does nothing without a direction to go in", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor(); + // twice: the caster's half-extent is resolved lazily on the first + // shadowed draw, so the first frame is not comparable with later ones + drawOnce(mesh); + drawOnce(mesh); + const before = { o: origin(), a: shadowAxes() }; + // asked for both, but with nowhere to point + mesh.shadowOffset = 40; + mesh.shadowStretch = 2; + drawOnce(mesh); + expect(origin().x).toBeCloseTo(before.o.x, 6); + expect(origin().z).toBeCloseTo(before.o.z, 6); + expect(shadowAxes().x).toBeCloseTo(before.a.x, 6); + expect(shadowAxes().z).toBeCloseTo(before.a.z, 6); + mesh.destroy(); + }); + + it("lengthens along the direction and leaves the perpendicular alone", (ctx) => { + requireWebGL(ctx, renderer); + // direction along world X, so the X axis stretches and Z must not + const mesh = onFloor({ shadowDirectionX: 1, shadowDirectionZ: 0 }); + drawOnce(mesh); + const before = shadowAxes(); + mesh.shadowStretch = 2; + drawOnce(mesh); + const after = shadowAxes(); + expect(after.x).toBeCloseTo(before.x * 2, 5); + expect(after.z).toBeCloseTo(before.z, 5); + mesh.destroy(); + }); + + it("stretches along a DIAGONAL, where both basis axes contribute", (ctx) => { + requireWebGL(ctx, renderer); + // A direction along a world axis only exercises one of the two + // basis columns on an axis-aligned caster: the other projects to + // zero and could be left untouched without anything noticing. + // At 45 degrees both carry a share. + const d = Math.SQRT1_2; + // the blob is a unit square mapped by the two basis columns, so its + // reach along a unit vector is half the sum of their projections + const support = (ux, uz) => { + const m = renderer._shadowQuads.unlit._modelMatrix.val; + return ( + (Math.abs(m[0] * ux + m[2] * uz) + Math.abs(m[8] * ux + m[10] * uz)) * + 0.5 + ); + }; + const mesh = onFloor({ shadowDirectionX: d, shadowDirectionZ: d }); + drawOnce(mesh); + drawOnce(mesh); + const along = support(d, d); + const across = support(d, -d); + mesh.shadowStretch = 2.5; + drawOnce(mesh); + expect(support(d, d)).toBeCloseTo(along * 2.5, 4); + // and nothing at right angles to it moved + expect(support(d, -d)).toBeCloseTo(across, 4); + mesh.destroy(); + }); + + it("clamps the stretch rather than letting it run", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor({ shadowDirectionX: 1 }); + drawOnce(mesh); + const before = shadowAxes().x; + mesh.shadowStretch = 50; + drawOnce(mesh); + // 3, the ceiling, not 50 + expect(shadowAxes().x).toBeCloseTo(before * 3, 5); + mesh.destroy(); + }); + + it.for([0.25, 0, -2, Number.NaN, Number.POSITIVE_INFINITY])( + "treats a stretch of %s as 1 rather than shrinking the blob", + (stretch, ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor({ shadowDirectionX: 1 }); + drawOnce(mesh); + const before = shadowAxes(); + mesh.shadowStretch = stretch; + drawOnce(mesh); + expect(shadowAxes().x).toBeCloseTo(before.x, 5); + expect(shadowAxes().z).toBeCloseTo(before.z, 5); + mesh.destroy(); + }, + ); + + it("fades as it stretches, so an extreme value goes to nothing", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor({ shadowDirectionX: 1, shadowOpacity: 0.8 }); + let alpha; + const draw = renderer.drawMesh.bind(renderer); + const spy = vi + .spyOn(renderer, "drawMesh") + .mockImplementation((object, matrix) => { + if (object !== mesh) { + alpha = renderer.getGlobalAlpha(); + } + return draw(object, matrix); + }); + try { + drawOnce(mesh); // warm-up, as above + drawOnce(mesh); + const flat = alpha; + mesh.shadowStretch = 4; // clamps to 3 + drawOnce(mesh); + // The alpha round-trips through an 8-bit packed tint, so it + // lands on the nearest 1/255 and cannot be compared more + // finely than that: 147/255 stretches to 84.87/255, which is + // read back as 85/255. + expect(Math.abs(alpha - flat / Math.sqrt(3))).toBeLessThanOrEqual( + 1 / 255, + ); + // and it is genuinely fainter, not merely different + expect(alpha).toBeLessThan(flat); + } finally { + spy.mockRestore(); + mesh.destroy(); + } + }); + + it("takes the direction from a named light, and follows it when it moves", (ctx) => { + requireWebGL(ctx, renderer); + const sun = { direction: { x: 1, y: -1, z: 0 } }; + const mesh = onFloor({ shadowLight: sun, shadowOffset: 10 }); + drawOnce(mesh); + const east = origin(); + // the sun swings round; the shadow goes with it + sun.direction.x = 0; + sun.direction.z = 1; + drawOnce(mesh); + const south = origin(); + expect(east.x - south.x).toBeCloseTo(10, 5); + expect(south.z - east.z).toBeCloseTo(10, 5); + mesh.destroy(); + }); + + it("prefers the light over a direction set by hand", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor({ + shadowDirectionX: -1, + shadowLight: { direction: { x: 1, y: -1, z: 0 } }, + shadowOffset: 7, + }); + drawOnce(mesh); + const withLight = origin().x; + mesh.shadowLight = undefined; + drawOnce(mesh); + // the hand-set direction is the OPPOSITE way, so the two differ by 2x + expect(withLight - origin().x).toBeCloseTo(14, 5); + mesh.destroy(); + }); + + it("is ignored by an InstancedMesh, both halves of it", (ctx) => { + requireWebGL(ctx, renderer); + // one shared quad, each instance with its own transform: a + // world-space direction cannot be baked in, so neither half + // applies rather than one of them silently doing so + const mesh = new InstancedMesh(0, 0, { + ...GEOMETRY, + width: 32, + normalize: false, + instanceCount: 2, + castGroundShadow: true, + shadowGroundY: 20, + }); + const placement = new Matrix3d(); + for (let i = 0; i < 2; i++) { + placement.identity().translate(i * 8, 0, 0); + mesh.setInstance(i, placement); + } + drawOnce(mesh); + const before = mesh._shadowQuad.originalVertices.slice(); + mesh.shadowDirectionX = 1; + mesh.shadowOffset = 30; + mesh.shadowStretch = 3; + drawOnce(mesh); + expect(Array.from(mesh._shadowQuad.originalVertices)).toEqual( + Array.from(before), + ); + mesh.destroy(); + }); + + it("leaves the lift alone, so a stretched blob still lies on the floor", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = onFloor({ shadowDirectionX: 1 }); + drawOnce(mesh); + const before = shadowGroundY(); + mesh.shadowStretch = 3; + drawOnce(mesh); + expect(shadowGroundY()).toBeCloseTo(before, 6); + mesh.destroy(); + }); + }); + // ── Sprite3d, which is the whole point of the feature ─────────────── describe("Sprite3d", () => { From 364c7552c883beec32e7118320afb61f63f7dcd3 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 5 Oct 2026 09:50:23 +0800 Subject: [PATCH 2/5] Jungle Rabbit: shadows placed by the sun, not by fake altitude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../src/examples/jungleRabbit/GameStage.ts | 20 +++++++++++++------ .../src/examples/jungleRabbit/constants.ts | 19 ++++++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/examples/src/examples/jungleRabbit/GameStage.ts b/packages/examples/src/examples/jungleRabbit/GameStage.ts index 1fdc490711..d1d7852ef7 100644 --- a/packages/examples/src/examples/jungleRabbit/GameStage.ts +++ b/packages/examples/src/examples/jungleRabbit/GameStage.ts @@ -132,7 +132,8 @@ import { RIDE_Y, ROCK_COUNT, ROCK_HALF, - SHADOW_LIFT, + SHADOW_OFFSET, + SHADOW_STRETCH, SKY, SPAWN_AHEAD, SPAWN_BEHIND, @@ -694,7 +695,12 @@ export class GameStage extends Stage { // a lit and a shaded fur tone, which is enough shape at this size. lit: false, castGroundShadow: true, - shadowGroundY: WATER_LEVEL + SHADOW_LIFT, + // on the water, not floated above it: the blob is thrown clear by + // the sun's own direction now rather than by fake altitude + shadowGroundY: WATER_LEVEL, + shadowLight: this.sun, + shadowOffset: SHADOW_OFFSET, + shadowStretch: SHADOW_STRETCH, }); // The hull's own box. A SENSOR: the engine reports the contact and the // game decides what it means (a life, a lurch, a hit-stop, a pickup) — @@ -1415,7 +1421,10 @@ export class GameStage extends Stage { // flat boulder and lights correctly. lit: kind !== "carrot", castGroundShadow: true, - shadowGroundY: WATER_LEVEL + SHADOW_LIFT, + shadowGroundY: WATER_LEVEL, + shadowLight: this.sun, + shadowOffset: SHADOW_OFFSET, + shadowStretch: SHADOW_STRETCH, // The engine default (0.45), for both kinds. A carrot used to be // darkened to 0.78 here because its shadow was barely there — but // that was the renderer replaying the river plane over the top of @@ -1476,9 +1485,8 @@ export class GameStage extends Stage { sprite.pos.x = x; sprite.pos.y = WATER_LEVEL; - // Render space is Y-DOWN, so the floor an object stands on is a GREATER - // y than the object: the shadow plane is `pos.y + LIFT`, never minus. - sprite.shadowGroundY = sprite.pos.y + SHADOW_LIFT; + // the plane the blob lands on is the water the prop sits in + sprite.shadowGroundY = sprite.pos.y; sprite.depth = this.travelled + aheadOfSkier; // keep the frontier honest even on the initial fill, or the first // respawns measure from zero and pile up at the near edge diff --git a/packages/examples/src/examples/jungleRabbit/constants.ts b/packages/examples/src/examples/jungleRabbit/constants.ts index f428acbe33..fbe21a8f2b 100644 --- a/packages/examples/src/examples/jungleRabbit/constants.ts +++ b/packages/examples/src/examples/jungleRabbit/constants.ts @@ -91,14 +91,21 @@ export const RIVER_FLOW = 0; export const RIPPLE_UV = 520; /** - * How far above the water a blob shadow floats, in world units. + * How far the blob shadows are thrown along the sun's direction, in world + * units, and how much longer they are along it. * - * 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. A small lift brings the near edge out - * from under the rock; too much and the blob rides up over the top of it. + * This used to be `SHADOW_LIFT`, a few units of fake altitude added to the + * shadow plane. A boulder sitting in the shallows hides its own contact + * shadow completely from this camera, and raising the plane was the only + * lever there was: it does not slide the blob out from under the rock, it + * floats the blob UP, and past a few units it rides over the top of the rock + * as a dark ring. The engine's own 3D skill warns against exactly that. + * + * The shadows are offset along the real sun now, so the lever is gone and the + * plane sits on the water where it belongs. */ -export const SHADOW_LIFT = 8; +export const SHADOW_OFFSET = 26; +export const SHADOW_STRETCH = 1.5; /** length of one terrain tile along +Z */ export const TILE_LEN = 2400; From 7a6b0b324db089d9b861f5fc3e50b14e25c9b6e9 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 5 Oct 2026 10:06:13 +0800 Subject: [PATCH 3/5] 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) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../src/examples/jungleRabbit/GameStage.ts | 15 ++++++++----- .../src/examples/jungleRabbit/constants.ts | 22 +++++++++++++++++-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/examples/src/examples/jungleRabbit/GameStage.ts b/packages/examples/src/examples/jungleRabbit/GameStage.ts index d1d7852ef7..dc68896970 100644 --- a/packages/examples/src/examples/jungleRabbit/GameStage.ts +++ b/packages/examples/src/examples/jungleRabbit/GameStage.ts @@ -132,7 +132,10 @@ import { RIDE_Y, ROCK_COUNT, ROCK_HALF, + SHADOW_DIR_X, + SHADOW_DIR_Z, SHADOW_OFFSET, + SHADOW_SINK, SHADOW_STRETCH, SKY, SPAWN_AHEAD, @@ -697,8 +700,9 @@ export class GameStage extends Stage { castGroundShadow: true, // on the water, not floated above it: the blob is thrown clear by // the sun's own direction now rather than by fake altitude - shadowGroundY: WATER_LEVEL, - shadowLight: this.sun, + shadowGroundY: WATER_LEVEL + SHADOW_SINK, + shadowDirectionX: SHADOW_DIR_X, + shadowDirectionZ: SHADOW_DIR_Z, shadowOffset: SHADOW_OFFSET, shadowStretch: SHADOW_STRETCH, }); @@ -1421,8 +1425,9 @@ export class GameStage extends Stage { // flat boulder and lights correctly. lit: kind !== "carrot", castGroundShadow: true, - shadowGroundY: WATER_LEVEL, - shadowLight: this.sun, + shadowGroundY: WATER_LEVEL + SHADOW_SINK, + shadowDirectionX: SHADOW_DIR_X, + shadowDirectionZ: SHADOW_DIR_Z, shadowOffset: SHADOW_OFFSET, shadowStretch: SHADOW_STRETCH, // The engine default (0.45), for both kinds. A carrot used to be @@ -1486,7 +1491,7 @@ export class GameStage extends Stage { sprite.pos.x = x; sprite.pos.y = WATER_LEVEL; // the plane the blob lands on is the water the prop sits in - sprite.shadowGroundY = sprite.pos.y; + sprite.shadowGroundY = sprite.pos.y + SHADOW_SINK; sprite.depth = this.travelled + aheadOfSkier; // keep the frontier honest even on the initial fill, or the first // respawns measure from zero and pile up at the near edge diff --git a/packages/examples/src/examples/jungleRabbit/constants.ts b/packages/examples/src/examples/jungleRabbit/constants.ts index fbe21a8f2b..623cf1ec84 100644 --- a/packages/examples/src/examples/jungleRabbit/constants.ts +++ b/packages/examples/src/examples/jungleRabbit/constants.ts @@ -104,8 +104,26 @@ export const RIPPLE_UV = 520; * The shadows are offset along the real sun now, so the lever is gone and the * plane sits on the water where it belongs. */ -export const SHADOW_OFFSET = 26; -export const SHADOW_STRETCH = 1.5; +export const SHADOW_OFFSET = 70; +export const SHADOW_SINK = 8; +/** + * The ground bearing the shadows are thrown along. + * + * Taken from where the sun is DRAWN, not from the `Light3d`. The two do not + * agree: the billboard sits dead ahead at `SUN_AHEAD` and about ten degrees + * up, so its light travels toward the camera, while the light's own direction + * was chosen for how it shades the valley walls and travels away from it. They + * are 107 degrees apart. + * + * Shading can afford a direction picked for looks, because nothing in the + * frame contradicts it. A shadow cannot: the player can see the sun, so a + * shadow pointing away from it reads as a bug. It also happens to be the + * visible choice, since a shadow thrown toward the camera lands in front of + * its caster instead of hiding behind it. + */ +export const SHADOW_DIR_X = 0; +export const SHADOW_DIR_Z = -1; +export const SHADOW_STRETCH = 2.5; /** length of one terrain tile along +Z */ export const TILE_LEN = 2400; From d7c96fcf1c3518178159993d137820ee7922c893 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 5 Oct 2026 12:59:23 +0800 Subject: [PATCH 4/5] 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) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../src/examples/jungleRabbit/GameStage.ts | 8 +++++--- .../src/examples/jungleRabbit/constants.ts | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/examples/src/examples/jungleRabbit/GameStage.ts b/packages/examples/src/examples/jungleRabbit/GameStage.ts index dc68896970..a06705fa84 100644 --- a/packages/examples/src/examples/jungleRabbit/GameStage.ts +++ b/packages/examples/src/examples/jungleRabbit/GameStage.ts @@ -134,7 +134,8 @@ import { ROCK_HALF, SHADOW_DIR_X, SHADOW_DIR_Z, - SHADOW_OFFSET, + SHADOW_OFFSET_CARROT, + SHADOW_OFFSET_ROCK, SHADOW_SINK, SHADOW_STRETCH, SKY, @@ -703,7 +704,7 @@ export class GameStage extends Stage { shadowGroundY: WATER_LEVEL + SHADOW_SINK, shadowDirectionX: SHADOW_DIR_X, shadowDirectionZ: SHADOW_DIR_Z, - shadowOffset: SHADOW_OFFSET, + shadowOffset: SHADOW_OFFSET_ROCK, shadowStretch: SHADOW_STRETCH, }); // The hull's own box. A SENSOR: the engine reports the contact and the @@ -1428,7 +1429,8 @@ export class GameStage extends Stage { shadowGroundY: WATER_LEVEL + SHADOW_SINK, shadowDirectionX: SHADOW_DIR_X, shadowDirectionZ: SHADOW_DIR_Z, - shadowOffset: SHADOW_OFFSET, + shadowOffset: + kind === "carrot" ? SHADOW_OFFSET_CARROT : SHADOW_OFFSET_ROCK, shadowStretch: SHADOW_STRETCH, // The engine default (0.45), for both kinds. A carrot used to be // darkened to 0.78 here because its shadow was barely there — but diff --git a/packages/examples/src/examples/jungleRabbit/constants.ts b/packages/examples/src/examples/jungleRabbit/constants.ts index 623cf1ec84..4a0406c6f7 100644 --- a/packages/examples/src/examples/jungleRabbit/constants.ts +++ b/packages/examples/src/examples/jungleRabbit/constants.ts @@ -104,7 +104,22 @@ export const RIPPLE_UV = 520; * The shadows are offset along the real sun now, so the lever is gone and the * plane sits on the water where it belongs. */ -export const SHADOW_OFFSET = 70; +/** + * How far each kind throws its shadow, in world units. + * + * Per KIND, because the right distance is not a property of the scene: it is + * roughly `footprint radius x (stretch - 1)`, the amount that shifts a + * stretched ellipse so its trailing edge still sits at the caster's feet. One + * value cannot serve both here. A boulder's blob has a radius around 76 and a + * carrot's around 26, so a single 70 left the boulder barely moved and threw + * the carrot's shadow clean off its own feet, floating a gap ahead of it. + * + * The carrots are what this is for. A boulder is 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 behaving correctly. + */ +export const SHADOW_OFFSET_CARROT = 38; +export const SHADOW_OFFSET_ROCK = 110; export const SHADOW_SINK = 8; /** * The ground bearing the shadows are thrown along. From 4e25a615c8fa24a4d1d582abe553fb997f499f4c Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 5 Oct 2026 15:18:40 +0800 Subject: [PATCH 5/5] 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) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../src/examples/jungleRabbit/GameStage.ts | 8 +-- .../src/examples/jungleRabbit/constants.ts | 22 +++---- packages/melonjs/CHANGELOG.md | 2 +- packages/melonjs/skills/melonjs-3d/SKILL.md | 7 +- packages/melonjs/src/level/gltf/GLTFModel.js | 2 +- .../melonjs/src/renderable/instanced_mesh.js | 18 +++-- packages/melonjs/src/renderable/mesh.js | 35 +++++++--- packages/melonjs/src/renderable/sprite3d.js | 2 +- packages/melonjs/tests/ground_shadow.spec.js | 65 +++++++++++++++---- 9 files changed, 114 insertions(+), 47 deletions(-) diff --git a/packages/examples/src/examples/jungleRabbit/GameStage.ts b/packages/examples/src/examples/jungleRabbit/GameStage.ts index a06705fa84..dc68896970 100644 --- a/packages/examples/src/examples/jungleRabbit/GameStage.ts +++ b/packages/examples/src/examples/jungleRabbit/GameStage.ts @@ -134,8 +134,7 @@ import { ROCK_HALF, SHADOW_DIR_X, SHADOW_DIR_Z, - SHADOW_OFFSET_CARROT, - SHADOW_OFFSET_ROCK, + SHADOW_OFFSET, SHADOW_SINK, SHADOW_STRETCH, SKY, @@ -704,7 +703,7 @@ export class GameStage extends Stage { shadowGroundY: WATER_LEVEL + SHADOW_SINK, shadowDirectionX: SHADOW_DIR_X, shadowDirectionZ: SHADOW_DIR_Z, - shadowOffset: SHADOW_OFFSET_ROCK, + shadowOffset: SHADOW_OFFSET, shadowStretch: SHADOW_STRETCH, }); // The hull's own box. A SENSOR: the engine reports the contact and the @@ -1429,8 +1428,7 @@ export class GameStage extends Stage { shadowGroundY: WATER_LEVEL + SHADOW_SINK, shadowDirectionX: SHADOW_DIR_X, shadowDirectionZ: SHADOW_DIR_Z, - shadowOffset: - kind === "carrot" ? SHADOW_OFFSET_CARROT : SHADOW_OFFSET_ROCK, + shadowOffset: SHADOW_OFFSET, shadowStretch: SHADOW_STRETCH, // The engine default (0.45), for both kinds. A carrot used to be // darkened to 0.78 here because its shadow was barely there — but diff --git a/packages/examples/src/examples/jungleRabbit/constants.ts b/packages/examples/src/examples/jungleRabbit/constants.ts index 4a0406c6f7..5539ac59a8 100644 --- a/packages/examples/src/examples/jungleRabbit/constants.ts +++ b/packages/examples/src/examples/jungleRabbit/constants.ts @@ -105,21 +105,19 @@ export const RIPPLE_UV = 520; * plane sits on the water where it belongs. */ /** - * How far each kind throws its shadow, in world units. + * How far a shadow is thrown, in multiples of the blob's own radius. * - * Per KIND, because the right distance is not a property of the scene: it is - * roughly `footprint radius x (stretch - 1)`, the amount that shifts a - * stretched ellipse so its trailing edge still sits at the caster's feet. One - * value cannot serve both here. A boulder's blob has a radius around 76 and a - * carrot's around 26, so a single 70 left the boulder barely moved and threw - * the carrot's shadow clean off its own feet, floating a gap ahead of it. + * One value for everything now. It used to be two, a carrot's and a + * boulder's, because the setting took a world distance and a boulder's blob + * is about three times a carrot's: 38 and 110 units. Both worked out at the + * same 1.45 of their own radius, which is what a world distance was hiding. + * The engine takes the ratio directly, so the two collapse. * - * The carrots are what this is for. A boulder is 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 behaving correctly. + * It is roughly `stretch - 1`: enough to shift a stretched ellipse so its + * trailing edge still sits at the caster's feet, which is what keeps a + * shadow attached to the thing casting it. */ -export const SHADOW_OFFSET_CARROT = 38; -export const SHADOW_OFFSET_ROCK = 110; +export const SHADOW_OFFSET = 1.45; export const SHADOW_SINK = 8; /** * The ground bearing the shadows are thrown along. diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 1fce698ef2..f86564afa3 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,7 +3,7 @@ ## [20.8.0] (melonJS 2) - _unreleased_ ### Added -- **Ground shadow shape** (`shadowOffset`, `shadowStretch`, `shadowDirectionX` / `shadowDirectionZ`, `shadowLight`): slide the blob along the ground and lengthen it, for a light that is not overhead. The offset needs `shadowGroundY`, since a flat quad can only be slid across a plane the game has named; the stretch is clamped and fades as it pulls. Per-object, and art direction rather than a projection ([#1631](https://github.com/melonjs/melonJS/issues/1631)) +- **Ground shadow shape** (`shadowOffset`, `shadowStretch`, `shadowDirectionX` / `shadowDirectionZ`, `shadowLight`): slide the blob along the ground and lengthen it, for a light that is not overhead. The offset is in multiples of the blob's own radius rather than world units, so one value serves casters of any size, and it needs `shadowGroundY`, since a flat quad can only be slid across a plane the game has named; the stretch is clamped and fades as it pulls. Per-object, and art direction rather than a projection ([#1631](https://github.com/melonjs/melonJS/issues/1631)) - **3D particles** (`elevation`, `elevationVariation`): the launch lifts out of the emitter's plane, so a burst is a volume rather than a disc facing the viewer and a trail can recede. Both default to `0`, which leaves the 2D path exactly as it was ([#1696](https://github.com/melonjs/melonJS/issues/1696)) - **`minSpread` / `maxSpread`**: `angle` and `elevation` become an AXIS and each particle leaves at a polar angle off it, giving a cone, a flat disc or a whole sphere. Sampling azimuth and elevation independently cannot describe a ring tangent to a surface, since there the elevation is a function of the azimuth ([#1696](https://github.com/melonjs/melonJS/issues/1696)) - **`ProgressBar`**, a gauge renderable: track, value-sized fill, optional border and label, four directions, colour or gradient. The loading screen is built on it diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index 323f75a026..f20774799f 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -679,13 +679,16 @@ units it projects over the top of the caster as a dark halo ringing it. Use the shape controls instead: ```js -mesh.shadowOffset = 30; // slide it out along the direction below +mesh.shadowOffset = 1.5; // slide it out, in blob radii mesh.shadowDirectionX = 1; // the ground direction it is cast along mesh.shadowDirectionZ = 0; mesh.shadowStretch = 2; // and lengthen it along the same line ``` -`shadowOffset` is honoured **only when `shadowGroundY` is set**, because the +`shadowOffset` is measured in multiples of the blob's **own radius**, not in +world units, so one value serves a boulder and a pebble and every part of a +glTF model. About `stretch - 1` is what keeps the shadow attached to its +caster. It is honoured **only when `shadowGroundY` is set**, because the blob is a flat quad on one named plane: sliding it across a plane the game has not named puts it where there may be no floor. `shadowStretch` is clamped to 3 and the blob fades as it pulls, so an extreme value degrades to nothing rather diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index b211fad238..e154cde892 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -107,7 +107,7 @@ export default class GLTFModel extends Container { * @param {number} [options.shadowStretch=1] - how much longer they are along that direction, clamped to 3 * @param {number} [options.shadowDirectionX=0] - x of the ground direction they are cast along * @param {number} [options.shadowDirectionZ=0] - z of the ground direction they are cast along - * @param {object} [options.shadowLight] - a {@link Light3d} to take that direction from instead + * @param {Light3d} [options.shadowLight] - a {@link Light3d} to take that direction from instead */ constructor(data, options = {}) { super(0, 0); diff --git a/packages/melonjs/src/renderable/instanced_mesh.js b/packages/melonjs/src/renderable/instanced_mesh.js index dcd83e64b3..07d8ed9181 100644 --- a/packages/melonjs/src/renderable/instanced_mesh.js +++ b/packages/melonjs/src/renderable/instanced_mesh.js @@ -726,12 +726,18 @@ export default class InstancedMesh extends Mesh { */ _drawInstancedGroundShadow(renderer) { // NOTE: `shadowOffset` and `shadowStretch` are per-object controls and - // are deliberately ignored here. One quad is shared by every instance - // and each applies its OWN transform, so a direction fixed in world - // space cannot be baked into the shared geometry: instances that are - // rotated differently would each stretch a different way. Honouring - // one of the two and not the other would read as a bug, so neither is. - // `shadowScale` is uniform and so has no such problem. + // are ignored here. That is a CHOICE, and only half of it is forced. + // + // `shadowStretch` genuinely cannot work: an anisotropic world-space + // scale reaches this path through the group matrix, which multiplies + // the instance POSITIONS as well as each quad, so it would smear the + // whole scatter rather than lengthen each blob. `shadowOffset` is a + // pure translation and would compose perfectly well. + // + // Honouring the one that works and not the one that does not would + // leave an instanced set lit by the same settings as its per-object + // neighbours and looking different for no reason the game can see, so + // neither applies. `shadowScale` is uniform and has no such problem. if (typeof renderer.drawInstancedShadow !== "function") { return; } diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 423a45d693..d028ee3e85 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -30,6 +30,7 @@ let _warnedLitUnder2dOnce = false; * @import CanvasRenderer from "./../video/canvas/canvas_renderer.js"; * @import WebGLRenderer from "./../video/webgl/webgl_renderer.js"; * @import Camera2d from "../camera/camera2d.ts"; + * @import Light3d from "../lighting/light3d.ts"; * @import GLShader from "../video/webgl/glshader.js"; */ @@ -390,11 +391,11 @@ function buildTextureGroups( * @property {boolean} [fog] - set `false` to exempt this mesh from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does * @property {number} [shadowGroundY] - world Y of the floor the shadow lands on. Omit and the blob sits at the object's own base at full strength; set it and the blob shrinks and fades as the object rises. Render space is Y-down, so the floor is a **greater** Y than the object above it. * @property {number} [shadowOpacity=0.45] - opacity of the shadow directly beneath the object, before any height fade. - * @property {number} [shadowOffset=0] - world distance to slide the shadow along `shadowDirectionX`/`shadowDirectionZ`, for the look of a light that is not directly overhead. Honoured only when `shadowGroundY` is set, since the blob is a flat quad and sliding it off a plane the game has NOT named puts it somewhere there is no floor. + * @property {number} [shadowOffset=0] - how far to slide the shadow along `shadowDirectionX`/`shadowDirectionZ`, in multiples of the blob's own radius, for the look of a light that is not directly overhead. A ratio rather than a world distance so one value serves casters of any size. Honoured only when `shadowGroundY` is set, since the blob is a flat quad and sliding it off a plane the game has NOT named puts it somewhere there is no floor. * @property {number} [shadowStretch=1] - how much longer the blob is along `shadowDirectionX`/`shadowDirectionZ`, for the look of a low light. Clamped to 3, and the shadow fades as it stretches. * @property {number} [shadowDirectionX=0] - x of the ground direction the shadow is cast along. Together with `shadowDirectionZ`, zero length means no offset and no stretch. * @property {number} [shadowDirectionZ=0] - z of the ground direction the shadow is cast along. - * @property {object} [shadowLight] - a {@link Light3d} to take the direction from instead of setting it by hand. Read every draw, so a moving sun carries the shadow with it. Nothing is inferred from the scene: no light here means no direction. + * @property {Light3d} [shadowLight] - a {@link Light3d} to take the direction from instead of setting it by hand. Read every draw, so a moving sun carries the shadow with it. Nothing is inferred from the scene: no light here means no direction. */ /** @@ -950,9 +951,17 @@ export default class Mesh extends Renderable { : 0.45; /** - * World distance to slide the ground shadow along - * {@link Mesh#shadowDirectionX}/{@link Mesh#shadowDirectionZ}, which is - * what gives the look of a light that is not directly overhead. + * How far to slide the ground shadow along + * {@link Mesh#shadowDirectionX}/{@link Mesh#shadowDirectionZ}, in + * multiples of the blob's OWN radius, which is what gives the look of + * a light that is not directly overhead. + * + * A ratio rather than a world distance. 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, which is about + * `stretch - 1` of its radius, so a world distance has to be retuned + * for every size of thing and cannot serve the parts of one glTF model + * at all. * * Honoured ONLY when {@link Mesh#shadowGroundY} is set. The blob is a * flat quad on one named plane with no contact with terrain, so sliding @@ -1016,7 +1025,7 @@ export default class Mesh extends Renderable { * NOTHING is inferred: the engine never picks a dominant light for you, * because a scene with several has no non-arbitrary answer and one with * none has no answer at all. Name the light or get no direction. - * @type {object|undefined} + * @type {Light3d|undefined} */ this.shadowLight = settings.shadowLight; @@ -2166,8 +2175,18 @@ export default class Mesh extends Renderable { } // Only on a plane the game NAMED: see `shadowOffset`. - const distance = this.shadowOffset; - if (this.shadowGroundY !== undefined && Number.isFinite(distance)) { + // + // Measured in the blob's OWN radii, not in world units. 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, and that is a property of the caster: a value in + // world units has to be retuned for every size of thing. Taken + // against `extent * SHADOW_SPREAD`, the blob's radius at full + // strength, so a rising object's shadow does not slide as the + // height fade shrinks it. + const ratio = this.shadowOffset; + if (this.shadowGroundY !== undefined && Number.isFinite(ratio)) { + const distance = ratio * extent * SHADOW_SPREAD; offsetX = dirX * distance; offsetZ = dirZ * distance; } diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js index 3d2f7a688a..053ca08c92 100644 --- a/packages/melonjs/src/renderable/sprite3d.js +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -172,7 +172,7 @@ export default class Sprite3d extends Mesh { * @param {number} [settings.shadowStretch=1] - how much longer the blob is along its direction, clamped to 3 * @param {number} [settings.shadowDirectionX=0] - x of the ground direction the shadow is cast along * @param {number} [settings.shadowDirectionZ=0] - z of the ground direction the shadow is cast along - * @param {object} [settings.shadowLight] - a {@link Light3d} to take that direction from instead + * @param {Light3d} [settings.shadowLight] - a {@link Light3d} to take that direction from instead * @param {boolean} [settings.castGroundShadow] - give this sprite a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a {@link Camera3d}. * @param {boolean} [settings.fog] - set `false` to exempt this sprite from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does. A sun or a moon wants this — everything else at that distance dissolves into the haze, and so would it. * @param {boolean} [settings.transparent] - draw in the transparent pass (blended, back-to-front, no depth write) instead of the opaque one. Omit and the sprite goes transparent whenever its draw alpha is fractional; `true` for a soft-alpha sprite such as an additive glow; `false` to stay opaque however faded. diff --git a/packages/melonjs/tests/ground_shadow.spec.js b/packages/melonjs/tests/ground_shadow.spec.js index 070d8b0702..936ea506c7 100644 --- a/packages/melonjs/tests/ground_shadow.spec.js +++ b/packages/melonjs/tests/ground_shadow.spec.js @@ -338,11 +338,20 @@ describe("Ground shadows (#1515)", () => { * an anisotropic scale `S = I + (stretch - 1)·d⊗d` applied in world XZ. */ describe("shadowOffset / shadowStretch", () => { - /** a mesh on a NAMED floor, which is what an offset needs */ + /** + * A mesh on a NAMED floor, which is what an offset needs, and sitting + * ON it rather than above it. + * + * `shadowGroundY` at the caster's own origin means no height fade, so + * `strength` is 1 and the drawn blob is its full-strength size. That + * matters because `shadowOffset` is measured in full-strength radii: + * it deliberately does NOT shrink with the fade, so that a rising + * object's shadow does not slide back under it as it goes. + */ const onFloor = (settings = {}) => { return makeMesh({ castGroundShadow: true, - shadowGroundY: 20, + shadowGroundY: 0, ...settings, }); }; @@ -351,21 +360,51 @@ describe("Ground shadows (#1515)", () => { return { x: m[12], z: m[14] }; }; - it("slides the blob along the direction, by exactly the distance asked", (ctx) => { + /** the blob's own radius, which is the unit `shadowOffset` is in */ + const blobRadius = () => { + const a = shadowAxes(); + return (a.x + a.z) / 2; + }; + + it("slides the blob by the asked multiple of its OWN radius", (ctx) => { requireWebGL(ctx, renderer); const mesh = onFloor(); drawOnce(mesh); + drawOnce(mesh); const before = origin(); + const r = blobRadius(); mesh.shadowDirectionX = 3; mesh.shadowDirectionZ = 4; // length 5, so it must be normalised - mesh.shadowOffset = 10; + mesh.shadowOffset = 2; drawOnce(mesh); const after = origin(); - expect(after.x - before.x).toBeCloseTo(6, 5); - expect(after.z - before.z).toBeCloseTo(8, 5); + // 2 radii along (0.6, 0.8) + expect(after.x - before.x).toBeCloseTo(2 * r * 0.6, 4); + expect(after.z - before.z).toBeCloseTo(2 * r * 0.8, 4); mesh.destroy(); }); + it("throws two differently sized casters the same RELATIVE distance", (ctx) => { + requireWebGL(ctx, renderer); + // the whole reason the unit is a ratio: one value has to serve a + // big caster and a small one, which a world distance cannot do + const shifts = []; + for (const width of [16, 64]) { + const mesh = onFloor({ width, shadowDirectionX: 1 }); + drawOnce(mesh); + drawOnce(mesh); + const before = origin().x; + const r = blobRadius(); + mesh.shadowOffset = 1.5; + drawOnce(mesh); + shifts.push((origin().x - before) / r); + mesh.destroy(); + } + // different sizes, different world distances, same ratio + expect(shifts[0]).toBeCloseTo(1.5, 4); + expect(shifts[1]).toBeCloseTo(1.5, 4); + }); + it("refuses to slide off a plane the game never named", (ctx) => { requireWebGL(ctx, renderer); // no `shadowGroundY`: the blob falls back to the caster's own base, @@ -507,16 +546,18 @@ describe("Ground shadows (#1515)", () => { it("takes the direction from a named light, and follows it when it moves", (ctx) => { requireWebGL(ctx, renderer); const sun = { direction: { x: 1, y: -1, z: 0 } }; - const mesh = onFloor({ shadowLight: sun, shadowOffset: 10 }); + const mesh = onFloor({ shadowLight: sun, shadowOffset: 2 }); + drawOnce(mesh); drawOnce(mesh); const east = origin(); + const reach = 2 * blobRadius(); // the sun swings round; the shadow goes with it sun.direction.x = 0; sun.direction.z = 1; drawOnce(mesh); const south = origin(); - expect(east.x - south.x).toBeCloseTo(10, 5); - expect(south.z - east.z).toBeCloseTo(10, 5); + expect(east.x - south.x).toBeCloseTo(reach, 4); + expect(south.z - east.z).toBeCloseTo(reach, 4); mesh.destroy(); }); @@ -525,14 +566,16 @@ describe("Ground shadows (#1515)", () => { const mesh = onFloor({ shadowDirectionX: -1, shadowLight: { direction: { x: 1, y: -1, z: 0 } }, - shadowOffset: 7, + shadowOffset: 1.5, }); drawOnce(mesh); + drawOnce(mesh); const withLight = origin().x; + const reach = 1.5 * blobRadius(); mesh.shadowLight = undefined; drawOnce(mesh); // the hand-set direction is the OPPOSITE way, so the two differ by 2x - expect(withLight - origin().x).toBeCloseTo(14, 5); + expect(withLight - origin().x).toBeCloseTo(2 * reach, 4); mesh.destroy(); });