Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/melonjs/skills/melonjs-3d-assets/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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) |
Expand Down
14 changes: 14 additions & 0 deletions packages/melonjs/skills/melonjs-3d/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"` |
Expand Down
8 changes: 8 additions & 0 deletions packages/melonjs/src/renderable/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1324,6 +1331,7 @@ export default class Container extends Renderable {
);
}
renderer.restore();
renderer.endScreenSpace?.();
}
}
}
Expand Down
34 changes: 33 additions & 1 deletion packages/melonjs/src/video/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 0 additions & 6 deletions packages/melonjs/src/video/webgl/webgl_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 0 additions & 17 deletions packages/melonjs/src/video/webgpu/webgpu_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
62 changes: 57 additions & 5 deletions packages/melonjs/tests/ground_shadow.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading