Skip to content

Container: keep floating children out of the depth sort - #1628

Closed
obiot wants to merge 3 commits into
masterfrom
fix/floating-depth-sort
Closed

Container: keep floating children out of the depth sort#1628
obiot wants to merge 3 commits into
masterfrom
fix/floating-depth-sort

Conversation

@obiot

@obiot obiot commented Aug 31, 2026

Copy link
Copy Markdown
Member

The bug

floating = true opts a renderable out of the perspective projection — it is drawn in screen space — but it was still being ordered by distance from the camera in _sortDepth.

Its pos is then not a place in the world, so that distance means nothing. Parking a HUD at a large z to mean "in front" is the intuitive move, and it did the opposite: a score at z = 10000 sorted to the far end of the scene, and every tree in the level drew over it.

Nothing errors. The HUD is simply behind the game.

The fix

Floating children sort ahead of everything else, so draw's reverse walk draws them last:

const aFloating = a.floating === true;
const bFloating = b.floating === true;
if (aFloating !== bFloating) {
    return aFloating ? -1 : 1;
}

Ordering among floating siblings is left to the existing distance math, unchanged, and non-floating children are untouched.

The clause deliberately follows the same reverse-walk convention as the distance math it sits above rather than inventing a second one — so flipping draw's loop to iterate forwards means negating this comparator as a unit, and the clause flips with it.

Tests

4 new:

  • a floating child draws last however far away its z puts it
  • and also when its z is nearer than everything
  • every floating child sits above every world child
  • the order of non-floating children is untouched

That last one is asserted as an invariant — sort a set of children, then sort the same set with a floating sibling added, and compare the non-floating subsequence — rather than pinning an absolute order. My first draft did pin one and failed: _sortDepth measures against a module-level camera cache that a bare harness does not control, so the concrete sequence is environment-dependent. Testing the invariant is both correct and what the change actually promises.

Full suite: 6,463 passing, 267 files. eslint 0 errors, biome clean.

Docs

melonjs-3d gains a HUDs and other floating renderables section — floating children are always drawn on top whatever their z, and the "give it a big z to put it in front" instinct is exactly backwards — plus a symptom row:

symptom cause
a HUD draws behind the scenery before 20.4 floating joined the depth sort; give it floating = true and no z games

How it was found

Building a 3D example: the score sat behind the trees. The workaround was re-parking the HUD just in front of the moving camera every frame, which is the sort of thing nobody should have to discover.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N

`floating = true` opts a renderable out of the perspective projection — it is
drawn in screen space — but it was still being ordered by distance from the
camera. Its `pos` is then not a place in the world, so that distance means
nothing: a HUD parked at a large z to mean "in front" sorted to the FAR end of
the scene, and every tree in the level drew over the score. Nothing errors; the
HUD is simply behind the game.

Floating children now sort ahead of everything else, so they are drawn last.
Ordering among floating siblings, and the order of every non-floating child,
are unchanged — the test asserts that as an invariant rather than pinning an
absolute sequence, since the comparator measures against a module-level camera
cache a bare harness does not control.

The clause follows the same reverse-walk convention as the distance math it
sits above, so flipping `draw`'s loop to iterate forwards means negating this
comparator as a unit and the clause flips with it.

Found while building a 3D example: the score sat behind the trees, and the
workaround was to re-park the HUD just in front of the moving camera every
frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI lite review requested due to automatic review settings August 31, 2026 08:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Two problems with the first version, both of which made it pass for the wrong
reason.

`Container.sort()` DEFERS the sort, so reading `getChildren()` straight after
it returns the array in insertion order and every assertion was vacuous — the
suite was green against an engine with the bug still in it. Switched to
`sortNow()`, the synchronous path the engine itself uses per frame.

Verified by removing the fix and re-running: two tests fail without it, all
pass with it.

Added three guards for the 2D comparators, which the fix deliberately does not
touch. That is where the regression risk actually was: forcing floating on top
under `sortOn: "z"` would silently break every layout with a floating backdrop
behind its sprites. They pin that a floating child is still ordered by z there,
above and below.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI review requested due to automatic review settings August 31, 2026 08:19
@obiot

obiot commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Strengthened the tests after review — the first version did not actually test the fix.

Container.sort() defers the sort, so reading getChildren() straight after it returns the array in insertion order. Every assertion was passing on insertion order alone: the suite was green against an engine with the bug still in it. Switched to sortNow(), the synchronous path the engine uses per frame.

Verified by mutation — with the fix removed, 2 of the 7 fail; with it restored, all pass:

=== with the fix REMOVED ===
  × draws a floating child last however far away its z puts it
  × keeps every floating child above every world child
  Tests  2 failed | 5 passed (7)
=== fix restored ===
  Tests  7 passed (7)

Also added three guards for the 2D comparators, which this change deliberately does not touch — that is where the real regression risk was. Forcing floating on top under sortOn: "z" would silently break every layout with a floating backdrop behind its sprites, so those pin that a floating child is still ordered by z there, both above and below a sprite.

Confirmed end to end as well: in a 3D scene with the HUD back at depth: 10000 and its per-frame workaround deleted, the score now draws over the scenery.

Full suite 6,466 passing, 267 files.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Excluding floating children from the depth sort left a second question open:
how two of them order against each other. They fell through to the distance
math, which measures from the camera to a `pos` that is a SCREEN position for a
floating renderable — so they ordered by where they happened to sit on screen,
and with equal screen positions LOWER z came out on top.

That is the inverse of `_sortZ`, of the 2D path, and of what anyone would
expect from "higher z is in front" — and stable enough across runs to look
deliberate rather than accidental.

Floating siblings now order by z like a 2D scene. Non-floating children and the
floating-above-world rule are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI review requested due to automatic review settings August 31, 2026 09:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants