"ScreenPanel.ZIndex defaults to 100, and tied roots have no paint order"
▸ SYMPTOM
A full-screen surface goes invisible even though every painter-side health check reads green: the component is valid, the panel built, its children are present, the tree is correct. Nothing in the panel itself is wrong — it painted a perfectly healthy tree — yet on screen there is nothing, or another full-screen root is showing instead.
The tell is timing. It often works for months, then a menu (or HUD, or overlay) that opened fine a hundred times comes up blank after a particular sequence — commonly after some other panel toggled on and off — or right after an editor hotload. On a fresh boot it comes back; mutate the set of live panels again and it can flip back to blank.
▸ CAUSE
Two engine facts combine.
1. ScreenPanel.ZIndex defaults to 100, not 0. From engine source (Scene/Components/UI/ScreenPanel.cs):
[Property] public int ZIndex { get; set; } = 100;So every ScreenPanel that never explicitly sets a tier sits at 100 — which is exactly the value most projects reach for when they author a "modal above the HUD" tier. A menu authored "at 100, above the base UI" is not above anything: it is tied with every base root that was left at its default.
2. Two roots with equal ZIndex have no defined paint order. Cross-root compositing is (CameraComponent.OnCameraRenderUI):
Scene.GetAll<ScreenPanel>().OrderBy( x => x.ZIndex )OrderBy is a stable sort, so equal tiers keep their input order — but that input order is the scene object index's enumeration order, which is a raw HashSet bucket order with no contract. It happens to match creation order on a fresh boot (which is why the bug hides for so long), and it re-rolls whenever the set mutates:
- any
ScreenPaneldisable/enable interleave reuses freedHashSetslots, reshuffling ties; - an editor hotload rebuilds the whole index from an unordered
HashSet<object>(Scene.HotloadObjectIndex), re-rolling all ties at once.
When two full-screen roots are tied, whichever the compositor happens to emit last paints over the other. The victim painted correctly; it is simply composited under a sibling at the same tier. Every health check you can run lives on the painter side and reads green — the answer is on the compositor side.
▸ FIX
- Author an explicit tier for every root. Never leave a
ScreenPanelat its default. Set the intended "bottom" surface to a real0rather than assuming unset means bottom — unset means 100. - Keep any two surfaces that can be up at the same time on distinct tiers. Ties are the bug; never let two full-screen roots that can coexist share a ZIndex.
- When debugging "painted but invisible", dump the roots in
OrderBy(ZIndex)order and look at what composites after the victim. Trust the compositor's ordering, not the painter's health checks — the panel being valid tells you nothing about which sibling wins a tie.
▸ WHY IT WORKS
The invisibility is never a fault in the hidden panel — it is a fault in the ordering of equal-tier roots, and that ordering is undefined by construction (OrderBy is stable but its input is an unordered set). Giving every root a distinct, explicit ZIndex removes the tie entirely, so the stable sort has a total order to honor and the result no longer depends on HashSet bucket layout, enable/disable history, or hotload timing. Setting the base surface to a literal 0 (instead of relying on "unset = bottom") closes the specific trap that the default of 100 creates: a modal authored at 100 finally sits genuinely above a base at 0, rather than tied with it.
- Published