Skip to content

SceneManager.loadScene(url, true) self-destructs the active scene on same-URL reload — dual-role Scene (asset vs engine object) #2979

@luzhuang

Description

@luzhuang

Summary

Calling sceneManager.loadScene(url, destroyOldScene = true) where url resolves to the currently active scene causes the active scene to be destroyed in place and re-attached as its own replacement. rootEntities get cleared, the native PxScene handle is released, and _destroyed flips to true, yet sceneManager.activeScene still points at the same JS object. Any subsequent access to physics or the entity tree triggers RuntimeError: null function from PhysX wasm.

The underlying cause is that Scene plays two conflicting roles in the engine — an asset cached by URL in ResourceManager._assetUrlPool, and an engine object with a terminal destroy() lifecycle in SceneManager._scenes — and the two roles have incompatible expectations for what "reload same URL" should do.

Reproduction

Minimal case

const engine = await WebGLEngine.create({ canvas, physics: new PhysXPhysics() });
await engine.resourceManager.load({ url: 'project.json', type: AssetType.Project });
engine.run();
// At this point main.scene is the active scene.

// Reload it:
await engine.sceneManager.loadScene('/Scene/scenes/main.scene');

// Any of the following now crashes:
engine.sceneManager.activeScene.physics.gravity = new Vector3(0, -9.81, 0);
// → RuntimeError: null function at PxScene.setGravity (wasm)

Reproduces without any userland wrapper. Confirmed by calling the prototype method directly, bypassing all possible project-level patches:

Object.getPrototypeOf(engine.sceneManager).loadScene
  .call(engine.sceneManager, '/Scene/scenes/main.scene');

Observed state before/after (measured in a running project)

Field Before reload After reload (same call, destroyOldScene=true)
sceneManager.activeScene sceneA (instanceId 9376) sceneA (same instance, id 9376)
sceneManager.activeScene === previous true
activeScene.destroyed false true
activeScene.isActive true true
activeScene.rootEntitiesCount 2 0
activeScene.physics._nativePhysicsScene present reference retained, internals released
activeScene.physics.gravity getter returns cached Vector3 still returns cached value (JS side)
activeScene.physics.gravity = ... setter OK RuntimeError: null function

The JS wrapper survives; the native handle is gone; the flags are internally inconsistent.

Cause chain (source: packages/core/src/SceneManager.ts)

  1. SceneManager.loadScene(url, true) (L93):
    const scenePromise = this.engine.resourceManager.load<Scene>({ url, type: AssetType.Scene });
    scenePromise.then((scene: Scene) => {
      if (destroyOldScene) {
        const scenes = this._scenes.getLoopArray();
        for (let i = 0, n = scenes.length; i < n; i++) scenes[i].destroy();
      }
      this.addScene(scene);
    });
  2. ResourceManager.load (packages/core/src/asset/ResourceManager.ts L375):
    const cacheObject = this._assetUrlPool[remoteAssetBaseURL];
    if (cacheObject) {
      return new AssetPromise((resolve) => resolve(this._getResolveResource(cacheObject, paths) as T));
    }
    Cache hits, resolves with the same Scene instance that is currently sceneManager.activeScene.
  3. Back in loadScene.then: destroyOldScene === true, so the loop calls .destroy() on every entry of this._scenes — which includes that very instance.
  4. Scene.destroy() cascades to EngineObject._onDestroy() (packages/core/src/base/EngineObject.ts L62-70), which destroys rootEntities, releases the native PxScene, sets _destroyed = true, and calls resourceManager._deleteAsset(this) — so the asset cache is emptied after destruction.
  5. this.addScene(scene) re-attaches the destroyed instance as the sole managed scene.
  6. The JS object now simultaneously has isActive = true, destroyed = true, rootEntitiesCount = 0, and a dead native physics handle. Any method that touches the native scene or entities blows up.

Why other asset types don't exhibit this

Compare Scene with other classes cached by ResourceManager:

Asset type Lifecycle Cache interaction
Texture2D created once, seldom destroyed cache hit returns a usable instance; if user manually destroys it, that's considered a user error, not an engine flow
Material template-like; users clone if they need independent state cache returns the template; no engine-driven destroy loop
Mesh immutable GPU buffer same as above
Prefab schema; instantiate() builds fresh entities cache returns the schema, not the runtime instance
Scene same instance is the runtime root AND the cache entry; SceneManager has a destroy-then-readd loop cache hit can point at a live managed scene, which the destroy loop then terminates

Scene is the only type where "the thing in the cache" is also "the thing the engine actively runs and may destroy". That's what makes same-URL reload unsafe.

Root cause: dual-role Scene

The Scene class conflates two responsibilities on a single JS object:

Role A — Asset

Aspect Expectation
Identity Defined by URL / virtual path
Storage ResourceManager._assetUrlPool[realPath]
Lifecycle Managed by cache policy (refcount, manual eviction, _deleteAsset on destroy)
Idempotency Same URL → same instance (by contract of asset cache)
State model Immutable content (or treated as such for sharing)

Role B — Engine Object / Scene-graph root

Aspect Expectation
Identity A concrete runtime instance owned by SceneManager
Storage SceneManager._scenes
Lifecycle constructor → onEnable → ... → destroy (terminal)
Idempotency Destroy once, gone forever (like every other EngineObject)
State model Heavily stateful: rootEntities, physics._nativePhysicsScene (PxScene wasm), shaderData, postProcessManager, lights, etc.

The conflict

Translate the user intent "restart the current level" into both roles:

Intent Role A (Asset) says Role B (Engine Object) says
"Reload main scene" Same URL → return the cached instance (cache is idempotent) Build me a fresh runtime — entities reset, physics rebuilt, transforms zeroed
"Destroy the old one" Destroying an asset-shared instance kills everyone's reference Normal terminal op, nothing special
"After reload, scene should be usable" Whatever cache returns is usable (cache contract) Only if the instance hasn't been destroy()-ed

The destroyOldScene=true branch of loadScene honors Role B's "destroy then replace" expectation, but Role A's cache silently satisfies it with the same instance Role B is about to destroy. Neither role is wrong by itself — they just can't coexist on a single object without either (a) an identity check, (b) non-cacheable semantics for this type, or (c) separating the asset from the runtime instance.

Comparison with prior art

  • Cocos Creator: SceneAsset (read-only schema, asset-cacheable) and runtime Scene are separate. director.loadScene(name) always constructs a new runtime scene from the (possibly cached) asset. Same-URL reload works naturally — the cache returns an asset, not a live instance.
  • Unity: SceneAsset (editor-only .unity file wrapper) and runtime Scene (struct) are separate. SceneManager.LoadScene always materializes a new runtime.
  • Three.js: No scene-asset concept; userland manages lifetime explicitly.

Galacean 2.0 merged the two concepts into one class, which is the structural origin of this bug.

Alternatives considered and why they're insufficient

Alt 1: Identity guard in .then

scenePromise.then((newScene) => {
  if (destroyOldScene) {
    for (const s of this._scenes) if (s !== newScene) s.destroy();
  }
  this.addScene(newScene);
});

Prevents the crash, but the "new scene" returned from cache is the unmodified live instance — entities still at whatever state, physics still running, transforms not reset. Breaks the director.loadScene-style semantics users expect from reload.

Alt 2: Guard _destroyed in hot setters

e.g. if (this._destroyed) return; at the top of PhysicsScene.gravity setter. Pure symptom suppression. The scene is still a zombie; every subsequent call is a silent no-op.

Alt 3: _deleteAsset timing in EngineObject._onDestroy

Moving resourceManager._deleteAsset(this) before the rest of _onDestroy doesn't help — by the time we're in destroy(), cache is already past the hit-and-return step.

Alt 4: Userland workaround — load an empty scene first

await sm.loadScene('/Scene/scenes/empty.scene');
await sm.loadScene('/Scene/scenes/main.scene');

Works but requires every project to maintain a throwaway scene and leaks the engine's limitation to every call site.

Interim local patch (shared for reference, not an upstream PR)

Evict the cached Scene asset before resourceManager.load when the cached instance is still managed by SceneManager. Forces cache miss → loader constructs a fresh Scene → old instance is destroyed correctly by the existing loop → new instance is added cleanly. Sub-asset caches (Texture/Material/Mesh) are untouched, so there's no re-download cost.

loadScene(url: string, destroyOldScene: boolean = true): AssetPromise<Scene> {
    const resourceManager = this.engine.resourceManager;
    // Evict the Scene asset cache for managed scenes about to be destroyed, so a fresh Scene
    // instance is created by the loader instead of returning the same instance we're about to
    // destroy (self-destroy would leave the active scene in a zombie state).
    if (destroyOldScene) {
      const realPath = resourceManager._virtualPathResourceMap[url]?.path ?? url;
      const cached = resourceManager.getFromCache<Scene>(realPath);
      if (cached && this._scenes.indexOf(cached) !== -1) {
        resourceManager._deleteAsset(cached);
      }
    }
    const scenePromise = resourceManager.load<Scene>({ url, type: AssetType.Scene });
    scenePromise.then((scene: Scene) => {
      if (destroyOldScene) {
        const scenes = this._scenes.getLoopArray();
        for (let i = 0, n = scenes.length; i < n; i++) scenes[i].destroy();
      }
      this.addScene(scene);
    });
    return scenePromise;
}

This uses only public APIs (getFromCache, _deleteAsset — both @internal public). TypeScript strict mode passes. Measured behavior after the patch:

Field After reload (patched)
sceneManager.activeScene === previous false (new instance)
new activeScene.instanceId different from old
previous scene.destroyed true (old instance destroyed normally)
new activeScene.rootEntitiesCount matches prefab (2 in repro project)
new activeScene.physics.gravity = ... OK

Known residual issues not covered by the interim patch

  1. Concurrent loadScene(sameURL, true): ResourceManager._loadingPromises (L392) coalesces a second call onto the first in-flight promise. Both .then handlers then execute destroy() + addScene(), so the second handler can destroy the scene the first just added. This is pre-existing — not introduced by, not fixed by, the cache-eviction patch.
  2. Other callers of resourceManager.load<Scene>(url) (i.e. code that bypasses SceneManager.loadScene and loads a Scene as if it were a prefab-like asset) still receive the cached instance; if the instance has been destroyed, those callers inherit the zombie.
  3. loadScene(url, false) unchanged: same-URL with destroyOldScene=false continues to return the same instance (no eviction). That's consistent with the flag's "don't destroy old scene, just add" semantics, but it reveals Role A/B tension — the caller presumably doesn't want a new instance here; this is OK but worth noting.

Suggested upstream directions

R1 — Split SceneAsset and Scene (most thorough, matches Cocos/Unity)

class SceneAsset extends ReferResource {
    readonly schema: ISerializedScene;
    // no rootEntities, no physics, no destroy-runtime semantics
}

class Scene extends EngineObject {
    constructor(engine: Engine, asset?: SceneAsset) { /* build rootEntities from asset */ }
    // rootEntities, physics, destroy() — runtime only, NOT cached by URL
}

class SceneManager {
    loadScene(url: string, destroyOldScene = true): AssetPromise<Scene> {
        return this.engine.resourceManager.load<SceneAsset>({ url, type: AssetType.SceneAsset })
            .then(asset => {
                const scene = new Scene(this.engine, asset);
                if (destroyOldScene) for (const s of this._scenes) s.destroy();
                this.addScene(scene);
                return scene;
            });
    }
}

Pros: completely removes the self-destruct class, resolves the concurrent-load race (no shared Scene promise, just a shared asset promise), matches user mental model inherited from Cocos/Unity.
Cons: new public type; migration for engine.sceneManager.scenes[0] consumers; Editor export format may need a scene-asset/scene split.

R2 — Keep single Scene class, make SceneLoader bypass the asset cache

In SceneLoader.load, do not populate _assetUrlPool with Scene instances. Each load<Scene>(url) call re-fetches and re-parses scene.json and constructs a fresh Scene. Sub-assets (Texture/Material/Mesh) still use the shared cache and are not re-downloaded.

Pros: smaller surface change — only SceneLoader touched; eliminates concurrent-reload race as a free side effect (no in-flight Scene promise to coalesce onto); no new public types.
Cons: scene.json is re-parsed each reload (typically negligible, scene.json is small); any code path that assumes resourceManager.load<Scene>(url) is idempotent would now get a new instance each time, but that path is arguably already misusing Scene as an asset.


Happy to open a PR for either R1 or R2, or iterate on the interim eviction approach, depending on maintainer preference. The interim patch has been running on a production-facing migration project (a Cocos Creator game ported to Galacean 2.0) and closes the user-visible bug; the architectural discussion above is for the longer-term fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions