Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
- 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
- Container: a `floating` renderable was still taking part in the depth sort, so under a `Camera3d` a HUD drew *behind* the scene. `floating` opts a renderable out of the perspective projection — it is drawn in screen space — but its `pos` is then not a place in the world, and sorting it by distance from the camera put a score parked at a large z at the far end of the level, with every tree in front of it. There is no error; the HUD is simply behind the game. Floating children now always sort on top, and the order of everything else is untouched
- Container: floating siblings ordered by how near the camera their screen position happened to fall, which put **lower** `z` on top — the inverse of every other sort in the engine, and stable enough to look deliberate. They now layer by `z` like a 2D scene, higher on top
- 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

Expand Down
38 changes: 38 additions & 0 deletions packages/melonjs/skills/melonjs-3d/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,43 @@ would be nothing to read them.
lighting is broken rather than absent. If an older scene suddenly picks up
shading, that is why.

## HUDs and other floating renderables

`floating = true` draws a renderable in **screen space**, opting it out of the
perspective projection — which is what you want for a score, a banner or a
crosshair under a `Camera3d`:

```js
const score = new Text(20, 16, { font: "monospace", size: 26, text: "" });
score.floating = true;
world.addChild(score, 0);
```

Floating children are always drawn **on top of the world**, whatever their `z`.
Their `pos` is a screen position, not a place in the world, so it does not
compete with the scene for depth — you cannot put a HUD *behind* the level by
giving it a small z, and you do not need a large one to put it in front.

Among **themselves** they layer by `z`, higher on top, exactly as in a 2D
scene:

```js
world.addChild(score, 10);
world.addChild(pauseOverlay, 20); // covers the score
```

Two things about this changed in 20.4, so older advice and older code may
disagree with what you see:

- a large `z` used to read as *far away*, so a HUD parked at `z = 10000` sorted
to the far end of the level and every prop drew over it
- floating siblings used to order by how near the camera their screen position
happened to fall, which put **lower** z on top — the inverse of every other
sort in the engine

If you find a scene re-parking its HUD in front of the camera every frame, that
was the workaround; it can go.

## Colouring a mesh

There are four levels, and picking the wrong one is the usual reason a colour
Expand Down Expand Up @@ -286,6 +323,7 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after

| symptom | cause |
|---|---|
| a HUD draws behind the scenery | before 20.4 floating joined the depth sort; give it `floating = true` and no z games |
| a `lit` mesh renders fullbright | it had no normals — supply them, or let the engine generate them |
| a gradient across one mesh is impossible | `tint` is per object — use `vertexColors` / `setVertexColor` |
| a mesh stays solid as you fade it out | meshes render opaque; only `alpha` 0 (hidden) and 1 differ |
Expand Down
29 changes: 29 additions & 0 deletions packages/melonjs/src/renderable/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,35 @@ export default class Container extends Renderable {
* @ignore
*/
_sortDepth(a, b) {
// A floating child is drawn in SCREEN space, so its `pos` is not a
// place in the world and its distance from the camera is meaningless.
// Sorted on that distance a HUD lands wherever its z happens to put it
// — typically behind the scenery, since anything the player can see is
// nearer than a score parked at z = 10000. Floating always sorts
// nearest, which under `draw`'s reverse walk means drawn last, on top.
//
// That last step follows the same convention as the distance math
// below rather than adding a new one: the whole comparator is written
// for a reverse walk, so if the draw loop is ever flipped to iterate
// forwards this function is negated as a unit and the clause below
// flips with it.
//
// Ordering AMONG floating siblings is left to the distance math,
// unchanged.
const aFloating = a.floating === true;
const bFloating = b.floating === true;
if (aFloating !== bFloating) {
return aFloating ? -1 : 1;
}
if (aFloating === true) {
// Two floating siblings order by z, exactly as they would in a 2D
// layout — higher z on top. Left to the distance math below they
// ordered by how near the camera their SCREEN position happened to
// fall, which put LOWER z on top: the inverse of every other sort
// in the engine, and stable enough to look deliberate.
return b.pos.z - a.pos.z;
}

// Translate each child's LOCAL `pos` into world space via the
// parent container's offset (captured once per sort by
// `captureDepthOffset`). For the world container itself the
Expand Down
185 changes: 185 additions & 0 deletions packages/melonjs/tests/container-floating-depth.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* A floating child must draw on top under a `Camera3d`.
*
* `floating` opts a renderable out of the perspective projection — it is drawn
* in screen space — but it was still taking part in the depth sort, which
* orders children by distance from the camera. A HUD's `pos` is not a place in
* the world, so that distance is meaningless: parked at a large z to mean
* "in front", a score sorted to the FAR end of the scene and every tree in the
* level drew over it. There is no error; the HUD is simply behind the game.
*/
import { describe, expect, it } from "vitest";
import { Container, Renderable } from "../src/index.js";

/**
* Children are drawn back to front by walking the array in reverse, so index 0
* is drawn LAST — that is what "on top" means here.
* @param container - the container to inspect
* @returns child names in draw order, first drawn first
*/
const drawOrder = (container) => {
return container
.getChildren()
.map((child) => {
return child.name;
})
.reverse();
};

const child = (name, z, floating = false) => {
const renderable = new Renderable(0, 0, 10, 10);
renderable.name = name;
renderable.pos.z = z;
renderable.floating = floating;
return renderable;
};

describe("depth sort with floating children", () => {
const build = () => {
const world = new Container(0, 0, 800, 600);
world.autoDepth = false;
world.sortOn = "depth";
return world;
};

it("draws a floating child last however far away its z puts it", () => {
const world = build();
world.addChild(child("hud", 10000, true));
world.addChild(child("tree", 400));
world.addChild(child("rock", 80));
world.sortNow();

// the HUD's z would otherwise sort it to the far end of the valley,
// behind everything
expect(drawOrder(world).at(-1)).toBe("hud");
});

it("draws it last even when its z is nearer than everything", () => {
const world = build();
world.addChild(child("hud", -50, true));
world.addChild(child("tree", 400));
world.sortNow();
expect(drawOrder(world).at(-1)).toBe("hud");
});

it("keeps every floating child above every world child", () => {
const world = build();
world.addChild(child("hud", 10000, true));
world.addChild(child("banner", 5, true));
world.addChild(child("tree", 400));
world.addChild(child("rock", 80));
world.sortNow();

const order = drawOrder(world);
const lastWorld = Math.max(order.indexOf("tree"), order.indexOf("rock"));
const firstFloating = Math.min(
order.indexOf("hud"),
order.indexOf("banner"),
);
expect(firstFloating).toBeGreaterThan(lastWorld);
});

it("orders floating siblings by z, higher on top", () => {
// the 2D convention. Left to the distance math these ordered by how
// near the camera their screen position fell, which put LOWER z on
// top — the inverse of every other sort in the engine
const world = build();
world.addChild(child("banner", 1, true));
world.addChild(child("score", 100, true));
world.sortNow();
expect(drawOrder(world)).toEqual(["banner", "score"]);
});

it("orders them by z regardless of screen position", () => {
const world = build();
const banner = child("banner", 100, true);
banner.pos.x = 512;
banner.pos.y = 300;
const score = child("score", 1, true);
score.pos.x = 20;
score.pos.y = 16;
world.addChild(banner);
world.addChild(score);
world.sortNow();
// banner has the higher z, so it wins wherever the two sit on screen
expect(drawOrder(world).at(-1)).toBe("banner");
});

it("leaves the order of non-floating children untouched", () => {
// asserted as an invariant rather than an absolute order: the depth
// comparator measures distance from a module-level camera cache, so
// the concrete sequence depends on engine state a bare harness does
// not control. What must hold is that adding a floating sibling does
// not reshuffle the world.
const withoutHud = build();
for (const [name, z] of [
["far", 900],
["near", 100],
["mid", 500],
]) {
withoutHud.addChild(child(name, z));
}
withoutHud.sortNow();
const before = drawOrder(withoutHud);

const withHud = build();
for (const [name, z] of [
["far", 900],
["near", 100],
["mid", 500],
]) {
withHud.addChild(child(name, z));
}
withHud.addChild(child("hud", 10000, true));
withHud.sortNow();
const after = drawOrder(withHud).filter((name) => {
return name !== "hud";
});

expect(after).toEqual(before);
});
});

describe("the 2D sorts are unaffected", () => {
// Only `_sortDepth` changed. The z/x/y comparators are what a 2D game
// uses, and there a floating child is ordered by z like anything else — a
// floating backdrop at a low z belongs BEHIND the sprites. Forcing
// floating on top in 2D would be a silent regression for every HUD-behind
// -something layout that works today.
const build = (sortOn) => {
const world = new Container(0, 0, 800, 600);
world.autoDepth = false;
world.sortOn = sortOn;
return world;
};

it("still orders a floating child by z under sortOn: z", () => {
const world = build("z");
world.addChild(child("backdrop", 0, true));
world.addChild(child("sprite", 10));
world.sortNow();

// higher z draws later, floating or not
expect(drawOrder(world)).toEqual(["backdrop", "sprite"]);
});

it("puts a high-z floating child on top under sortOn: z", () => {
const world = build("z");
world.addChild(child("hud", 100, true));
world.addChild(child("sprite", 10));
world.sortNow();
expect(drawOrder(world)).toEqual(["sprite", "hud"]);
});

it("leaves sortOn: y ordering alone", () => {
const world = build("y");
const near = child("near", 0, true);
near.pos.y = 500;
const far = child("far", 0);
far.pos.y = 100;
world.addChild(near);
world.addChild(far);
world.sortNow();
expect(drawOrder(world)).toEqual(["far", "near"]);
});
});
Loading