Fix UI camera split going stale after a scene restart

A jump cut destroys all cameras and rebuilds a fresh main, leaving the stored split pointing at dead cameras. Worse, `main.ignore()` on the old main sets the id bit that the new main reuses, so objects assigned to the UI pass get ignored by the only live pass — rendering nowhere (the "clip plays in the background" bug).

- Add `liveSplit()` which validates the stored split against the scene's live camera list and clears it when stale
- Route `ensureUiCameras`, `assignUi`, and `assignWorld` through `liveSplit()` so a stale split is dropped and the pipeline falls back to a single pass
This commit is contained in:
Brian Fertig 2026-09-26 11:40:43 -06:00
parent 332431ecb5
commit 098b41b2fc
1 changed files with 41 additions and 3 deletions

View File

@ -55,6 +55,44 @@ export function isScreenPinned(obj) {
);
}
/**
* The stored split, validated against the LIVE camera list — or null
* (and cleared) when it is stale.
*
* A scene restart (the jump cut — GameScene._finishJump) runs the
* camera manager's shutdown, which DESTROYS every camera (fx-ui
* included) and re-adds a single fresh main. The stored split would
* then point at dead cameras, and worse: a later `main.ignore()` sets
* the OLD main's id bit on the object, and the re-created main reuses
* id 1 — so an object "assigned to the UI pass" is ignored by the ONLY
* live pass and drawn by no camera at all. That is the jump-clip
* "plays in the background" bug: after leaving an effects system, the
* next system's clip + opaque backdrop rendered nowhere while the world
* stayed fully visible. A split whose cameras are no longer in the
* scene's list is invalid — drop it so the pipeline falls back to a
* single pass (depth order restores the clip's topness) and a fresh
* split can be created if/when one is needed.
*
* @param {object} scene Phaser scene
* @returns {{ main: object, ui: object } | null} the live split, or null
*/
function liveSplit(scene) {
const split = scene._fxUiCameras;
const cams = scene.cameras?.cameras;
if (
split &&
split.ui &&
split.main &&
Array.isArray(cams) &&
cams.includes(split.main) &&
cams.includes(split.ui)
) {
return split;
}
if (split) scene._fxUiCameras = null; // stale (a restart rebuilt the cameras) — forget it
return null;
}
/**
* Create (once) the fixed UI camera and partition the scene's
* top-level objects between the two passes. Idempotent — subsequent
@ -65,7 +103,7 @@ export function isScreenPinned(obj) {
* the scene has no camera plugin (defensive).
*/
export function ensureUiCameras(scene) {
if (scene._fxUiCameras) return scene._fxUiCameras;
if (liveSplit(scene)) return scene._fxUiCameras;
const cameras = scene.cameras;
if (!cameras?.cameras || cameras.cameras.length === 0) return null;
@ -98,7 +136,7 @@ export function ensureUiCameras(scene) {
* @param {object} child object to attach to the UI pass
*/
export function assignUi(scene, child) {
const split = scene._fxUiCameras;
const split = liveSplit(scene);
if (split && child) split.main.ignore(child);
}
@ -112,6 +150,6 @@ export function assignUi(scene, child) {
* @param {object} child object to keep on the world pass
*/
export function assignWorld(scene, child) {
const split = scene._fxUiCameras;
const split = liveSplit(scene);
if (split && child) split.ui.ignore(child);
}