From cf66b78f04c5fb3088132ea20b554ab98c36b014 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 31 Aug 2026 21:08:10 +0800 Subject: [PATCH 1/2] Ground shadows: drain the queue only where the world draw is finished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blob shadows are queued and replayed once the opaque meshes are down. The replay was triggered by any batcher switch, which fired in two places it should not have. Inside the screen-projection window `Container.draw` opens around a `floating` child: the blobs are WORLD-space geometry, so replaying them there fed every one screen-space clip coordinates and put it off-screen. A single HUD anywhere in a scene silently deleted every ground shadow in it. And in the middle of a scene, whenever anything non-mesh sorted there. A particle emitter or a sprite raises the same transition, and every mesh still to come then painted straight over the blobs just put down — the ground plane above all, which routinely sorts after the props standing on it. That is the case the deferral exists to prevent, and it was reintroducing it. The queue now drains at three sites that really are the end of the world draw: `Container.draw` just before a floating child (so an overlay still covers them), `Camera2d.draw` once the whole container is down, and `Application.draw` at end of frame. `Renderer` grows a screen-space bracket so a drain raised inside one is held rather than lost. Behaviour change: a NON-floating 2D renderable drawn part-way through a 3D scene now draws under the blobs instead of over them. The spec that pinned the old drain site is rewritten rather than dropped, and two cover the failures above. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/renderable/container.js | 8 +++ packages/melonjs/src/video/renderer.js | 34 +++++++++- .../melonjs/src/video/webgl/webgl_renderer.js | 6 -- .../src/video/webgpu/webgpu_renderer.js | 17 ----- packages/melonjs/tests/ground_shadow.spec.js | 62 +++++++++++++++++-- 6 files changed, 99 insertions(+), 29 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index dc333dfb9..7d3b82a7d 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -8,6 +8,7 @@ - Mesh: normals are generated from the geometry when a `lit` mesh is built without them. A lit mesh with no normals had nothing for the shader to light with and rendered **fullbright** — asking for lighting and silently getting flat colour — and every hand-built mesh had to write the same accumulate-and-normalize loop first. Flat versus smooth is decided by the geometry rather than a flag: face normals accumulate into their vertices weighted by area, so shared vertices average into smooth shading while a triangle soup (each face owning its three vertices) resolves to the face normal and shades flat. An explicit `settings.normals` still wins, and an unlit mesh gets none ### Fixed +- Ground shadows: a scene could lose every blob it drew, in two different ways. Shadows are queued and replayed once the opaque meshes are down, and the replay used to be triggered by any batcher switch. That fired in two places it should not have: inside the screen-projection window `Container.draw` opens around a `floating` child, where world-space blobs were fed screen-space clip coordinates and landed off-screen — so a single HUD deleted every ground shadow in the scene — and in the middle of a scene whenever anything non-mesh sorted there (a particle emitter, a sprite), after which the meshes still to come painted straight over the blobs just put down. The queue now drains only where the world draw is actually finished: before a floating child, at the camera, and at end of frame. A **non-floating** 2D renderable drawn part-way through a 3D scene consequently draws under the shadows rather than over them - Mesh: `alpha = 0` painted the mesh **opaque black** instead of hiding it, on both GPU backends. `CanvasRenderer.drawMesh` has always skipped when the global alpha falls below `1/255` — the same guard eight other Canvas draw methods use — but neither GPU renderer had it, and the mesh path disables blending (`MeshBatcher.bind`), so the alpha never reached the blend stage: the shader multiplied the colour by zero and wrote the result opaque. Hiding a mesh with `alpha = 0` left a black silhouette of it, and the same property behaved differently per backend. Both GPU renderers now skip at the same threshold - Color: `toUint32()` returned a **negative** number for any colour with alpha at or above 0.5. The packing used `|`, which yields a signed int32, so a method named `toUint32` — documented as returning "a Uint32 ARGB representation" — handed back e.g. `-16711936` for green. Every consumer inside the engine writes it into a `Uint32Array` or a shader attribute where the bit pattern is identical, so nothing rendered wrong; what broke was reading the value back, comparing it, or printing it. The four unit tests covering this had the correct expectations commented out and the signed values asserted instead - Container: a `floating` child in a depth-sorted world was ordered by its **screen** position. `Container.draw` gives a floating child `resetTransform()` and the camera's screen projection, so its `pos.x/y` are canvas pixels — but `_sortDepth` fed those to a world-space distance and subtracted the camera position on top. Two consequences, both visible under a `Camera3d`: a HUD's layering depended on where it sat on the screen (a score in a corner scored `20² + 16²` and floated above the scene, while the same text centred scored `512² + 200²` and sank behind it), and it drifted as the camera travelled, so a HUD that was correct at the start of a level was buried by the end of it. A floating child is now ordered by `|pos.z|` alone — a small depth draws in front of the world, a large one behind it — which is the convention screen-space content already used (a HUD at -150, a sky backdrop at -10000 or 100000), now holding at any camera position and from anywhere on the screen rather than by luck of the numbers diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 697c42d51..0f42d835f 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -1292,6 +1292,13 @@ export default class Container extends Renderable { } if (isFloating) { + // Put any deferred ground shadows down BEFORE the screen + // projection goes in (#1515). They are world-space geometry, + // so they cannot be replayed once it is installed, and this + // is where they belong in the order anyway: over the world, + // under the overlay about to be drawn. + renderer.flushGroundShadows?.(); + renderer.beginScreenSpace?.(); renderer.save(); renderer.resetTransform(); // Floating renderables draw in screen space — swap to @@ -1324,6 +1331,7 @@ export default class Container extends Renderable { ); } renderer.restore(); + renderer.endScreenSpace?.(); } } } diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 731b28d95..36c066bc7 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -349,6 +349,25 @@ export default class Renderer { * supplies one blob per instance, for the instanced tier * @ignore */ + /** + * Mark the start of a screen-space (`floating`) draw, during which the + * camera's screen projection is installed and world-space geometry cannot + * be replayed. Balanced by {@link Renderer#endScreenSpace}. + * @ignore + */ + beginScreenSpace() { + this._screenSpaceDepth = (this._screenSpaceDepth ?? 0) + 1; + } + + /** + * Mark the end of a screen-space draw. + * @ignore + */ + endScreenSpace() { + const depth = (this._screenSpaceDepth ?? 0) - 1; + this._screenSpaceDepth = depth > 0 ? depth : 0; + } + queueGroundShadow(quad, modelMatrix, tint, instanced) { const pool = (this._shadowPool ??= []); const at = this._shadowCount ?? 0; @@ -379,7 +398,20 @@ export default class Renderer { */ flushGroundShadows() { const count = this._shadowCount ?? 0; - if (count === 0 || this._shadowFlushing === true) { + if ( + count === 0 || + this._shadowFlushing === true || + // A queued blob is WORLD-space geometry, and replaying it needs the + // world projection. `Container.draw` installs the camera's screen + // projection around a `floating` child, so a drain triggered from + // inside that window feeds every blob screen-space clip coordinates + // and lands it off-screen — one HUD silently deleted every ground + // shadow in the scene. Skipping is safe rather than lossy: the + // queue survives, and `Container.draw` drains it just BEFORE + // opening the window, which is also where the blobs belong — + // under the overlay, over the world. + (this._screenSpaceDepth ?? 0) > 0 + ) { return; } const pool = this._shadowPool; diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 599b62cb3..5e02d3df1 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -869,9 +869,6 @@ export default class WebGLRenderer extends Renderer { // Switching *within* mesh mode (lit ↔ unlit) must NOT drain it — // the meshes still to come are exactly what the shadows have to // beat. - if (name !== "mesh" && name !== "litMesh" && this._canDrainShadows()) { - this.flushGroundShadows(); - } if (this.currentBatcher !== undefined) { // flush the current batcher, then let it tear down any // state it set up at `bind()` time (Mesh batcher restores @@ -913,9 +910,6 @@ export default class WebGLRenderer extends Renderer { * @returns {boolean} true when a drain is safe here * @ignore */ - _canDrainShadows() { - return this.maskLevel === 0 && this._effectPassDepth === 0; - } /** * Reset the gl transform to identity diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index bfc305e5b..4e4514922 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -1776,23 +1776,6 @@ export default class WebGPURenderer extends Renderer { } if (this.currentBatcher !== batcher) { - // Leaving mesh mode drains the deferred ground-shadow queue first, - // so the blobs land on top of every opaque mesh in the pass (#1515). - // Switching *within* mesh mode (lit ↔ unlit) must NOT drain it — the - // meshes still to come are exactly what the shadows have to beat. - if ( - name !== "mesh" && - name !== "litMesh" && - // see WebGLRenderer#_canDrainShadows: a transition raised while - // a mask is being stencilled in (colour writes off) or inside a - // post-effect bracket is not the end of the mesh pass, and - // draining there loses the blobs. The camera drain still gets - // them. - this.maskLevel === 0 && - this.stencilMode !== "write" - ) { - this.flushGroundShadows(); - } if (this.currentBatcher !== null) { this.currentBatcher.flush(); this.currentBatcher.unbind(); diff --git a/packages/melonjs/tests/ground_shadow.spec.js b/packages/melonjs/tests/ground_shadow.spec.js index dc4ff6463..0a8dc1561 100644 --- a/packages/melonjs/tests/ground_shadow.spec.js +++ b/packages/melonjs/tests/ground_shadow.spec.js @@ -745,22 +745,74 @@ describe("Ground shadows (#1515)", () => { mesh.destroy(); }); - it("leaving mesh mode drains the queue without being asked", (ctx) => { + it("does NOT drain on a batcher switch, so later meshes cannot overpaint", (ctx) => { requireWebGL(ctx, renderer); - // the real drain site — every other test here calls - // flushGroundShadows() by hand, which would keep passing if this - // site were deleted + // This used to drain here, on the assumption that leaving mesh mode + // meant the mesh pass was over. It does not: anything non-mesh that + // sorts into the MIDDLE of a scene — a particle emitter, a sprite — + // raises the same transition, and every mesh still to come then + // paints straight over the blobs just put down. A scene with a + // particle trail lost its ground shadows to exactly this. + // + // The queue now survives to a site that really is the end of the + // world draw: `Container.draw` before a floating child, and + // `Camera2d.draw` once the whole container is down. const mesh = makeMesh({ castGroundShadow: true }); mesh.preDraw(renderer); mesh.draw(renderer, camera); mesh.postDraw(renderer); - expect(renderer._shadowCount).toBeGreaterThan(0); + const queued = renderer._shadowCount; + expect(queued).toBeGreaterThan(0); renderer.setBatcher("quad"); + expect(renderer._shadowCount).toBe(queued); + + renderer.flushGroundShadows(); + expect(renderer._shadowCount).toBe(0); + mesh.destroy(); + }); + + it("refuses to drain while a screen projection is installed", (ctx) => { + requireWebGL(ctx, renderer); + // A queued blob is WORLD-space geometry. `Container.draw` installs + // the camera's screen projection around a `floating` child, and a + // drain raised in that window replays every blob with screen-space + // clip coordinates — off-screen, gone. One HUD anywhere in a scene + // silently deleted every ground shadow in it. + const mesh = makeMesh({ castGroundShadow: true }); + mesh.preDraw(renderer); + mesh.draw(renderer, camera); + mesh.postDraw(renderer); + const queued = renderer._shadowCount; + expect(queued).toBeGreaterThan(0); + + renderer.beginScreenSpace(); + try { + renderer.flushGroundShadows(); + // held, not lost + expect(renderer._shadowCount).toBe(queued); + } finally { + renderer.endScreenSpace(); + } + // and released the moment the window closes + renderer.flushGroundShadows(); expect(renderer._shadowCount).toBe(0); mesh.destroy(); }); + it("balances nested screen-space brackets", (ctx) => { + requireWebGL(ctx, renderer); + renderer.beginScreenSpace(); + renderer.beginScreenSpace(); + renderer.endScreenSpace(); + expect(renderer._screenSpaceDepth).toBe(1); + renderer.endScreenSpace(); + expect(renderer._screenSpaceDepth).toBe(0); + // never goes negative, so an unbalanced end cannot wedge the queue + renderer.endScreenSpace(); + expect(renderer._screenSpaceDepth).toBe(0); + }); + it("switching WITHIN mesh mode does not drain early", (ctx) => { requireWebGL(ctx, renderer); // lit <-> unlit is still inside the pass: the meshes yet to come are From 0235b688e64228f00c7f1d19044e0bb4ea184496 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 31 Aug 2026 21:12:28 +0800 Subject: [PATCH 2/2] Skills: document what a ground shadow is and is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things that cost real time to work out on a live scene, neither of them written down. A blob is an ellipse sized to the caster's own footprint and placed at the caster's x/z — never offset by light direction. A tall or narrow object shows its shadow; a wide flat-bottomed one resting on the floor covers its own completely from a camera looking down at it. That is correct behaviour, and reads as a missing feature. And the fix that suggests itself is the wrong one: raising `shadowGroundY` 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 ring around it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/skills/melonjs-3d-assets/SKILL.md | 7 +++++++ packages/melonjs/skills/melonjs-3d/SKILL.md | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md index d4315fbbb..3e3a46bd5 100644 --- a/packages/melonjs/skills/melonjs-3d-assets/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d-assets/SKILL.md @@ -166,6 +166,12 @@ Omit `shadowGroundY` and each blob sits at its own object's base at full strength, which is right for props already resting on the ground. Set it when things jump or fly, so the shadow stays on the floor and shrinks with height. +A blob is centred on its caster's x/z and is never offset by light direction, so +a wide flat-bottomed prop hides its own shadow under itself. Raising +`shadowGroundY` to force one into view floats the blob up over the object as a +dark ring rather than sliding it clear — the setting is for things that leave +the ground, not a visibility knob. + ## OBJ/MTL A different shape entirely: OBJ produces raw geometry you hand to a `Mesh`, @@ -227,6 +233,7 @@ need a prefix. | a character does not deform | vertex skinning is out of scope; rig hierarchically or billboard | | animation names come back empty | the asset has no node-TRS channels (skin-only rig) | | a hundred copies tank the frame rate | exported without `EXT_mesh_gpu_instancing` | +| a prop casts no visible shadow | wide and flat-bottomed — the blob is under it; `shadowGroundY` haloes it rather than revealing it | | a shadow smeared across the whole floor | a ground plane cast its own blob — use the scene-wide opt-in | | `onLoaded` gets a string, not the scene | it is called with the level id; load into your own container instead | | scene renders flat and unlit | no `Camera3d` — the 2D-camera path is CPU-projected and unlit (with a `Camera3d` on Canvas you get a black canvas instead) | diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index b417dcd41..d3c148d33 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -256,6 +256,18 @@ skip geometry with no vertical extent — a ground plane. Per object, `castGroundShadow: true`/`false` overrides the app setting and is obeyed as 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. + +**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. + ## glTF / GLB scenes Loaded through the same level director as everything else: @@ -312,6 +324,8 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after | black canvas under `Camera3d` | Canvas renderer (no depth buffer) — check the `console.warn` | | everything flat and unlit | `lit: true` with no `Light3d` in the world (falls back to fullbright), or a mesh under a 2D camera | | a `floating` HUD draws behind the scenery | a large \|z\| is *far* under `Camera3d` — use a small depth | +| an object casts no visible shadow | wide and flat-bottomed — its own blob is underneath it; raising `shadowGroundY` haloes it instead of revealing it | +| a dark ring around the top of an object | `shadowGroundY` lifted too far, floating the blob up into the caster | | a mesh sits at the wrong depth after being added | `autoDepth` overwrote `pos.z` with the child index — pass `addChild(mesh, z)` | | a mesh sits half its size off | `anchorPoint` — only on the 2D-camera path; a `Camera3d` mesh pivots on its model origin | | a billboard tips over when the camera looks down | `"spherical"`, or a mistyped mode string falling through to it — use `true` / `"cylindrical"` |