Container: keep floating children out of the depth sort - #1628
Conversation
`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
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
|
Strengthened the tests after review — the first version did not actually test the fix.
Verified by mutation — with the fix removed, 2 of the 7 fail; with it restored, all pass: 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 Confirmed end to end as well: in a 3D scene with the HUD back at Full suite 6,466 passing, 267 files. |
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
The bug
floating = trueopts 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
posis then not a place in the world, so that distance means nothing. Parking a HUD at a largezto mean "in front" is the intuitive move, and it did the opposite: a score atz = 10000sorted 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: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:
zputs itzis nearer than everythingThat 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:
_sortDepthmeasures 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-3dgains a HUDs and other floating renderables section — floating children are always drawn on top whatever theirz, and the "give it a big z to put it in front" instinct is exactly backwards — plus a symptom row:floating = trueand no z gamesHow 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