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)
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);
});
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.
- Back in
loadScene.then: destroyOldScene === true, so the loop calls .destroy() on every entry of this._scenes — which includes that very instance.
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.
this.addScene(scene) re-attaches the destroyed instance as the sole managed scene.
- 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
- 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.
- 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.
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.
Summary
Calling
sceneManager.loadScene(url, destroyOldScene = true)whereurlresolves to the currently active scene causes the active scene to be destroyed in place and re-attached as its own replacement.rootEntitiesget cleared, the nativePxScenehandle is released, and_destroyedflips totrue, yetsceneManager.activeScenestill points at the same JS object. Any subsequent access to physics or the entity tree triggersRuntimeError: null functionfrom PhysX wasm.The underlying cause is that
Sceneplays two conflicting roles in the engine — an asset cached by URL inResourceManager._assetUrlPool, and an engine object with a terminaldestroy()lifecycle inSceneManager._scenes— and the two roles have incompatible expectations for what "reload same URL" should do.Reproduction
Minimal case
Reproduces without any userland wrapper. Confirmed by calling the prototype method directly, bypassing all possible project-level patches:
Observed state before/after (measured in a running project)
destroyOldScene=true)sceneManager.activeScenesceneA(instanceId 9376)sceneA(same instance, id 9376)sceneManager.activeScene === previoustrueactiveScene.destroyedfalsetrueactiveScene.isActivetruetrueactiveScene.rootEntitiesCount20activeScene.physics._nativePhysicsSceneactiveScene.physics.gravitygetteractiveScene.physics.gravity = ...setterRuntimeError: null functionThe JS wrapper survives; the native handle is gone; the flags are internally inconsistent.
Cause chain (source:
packages/core/src/SceneManager.ts)SceneManager.loadScene(url, true)(L93):ResourceManager.load(packages/core/src/asset/ResourceManager.ts L375):Sceneinstance that is currentlysceneManager.activeScene.loadScene.then:destroyOldScene === true, so the loop calls.destroy()on every entry ofthis._scenes— which includes that very instance.Scene.destroy()cascades toEngineObject._onDestroy()(packages/core/src/base/EngineObject.ts L62-70), which destroys rootEntities, releases the nativePxScene, sets_destroyed = true, and callsresourceManager._deleteAsset(this)— so the asset cache is emptied after destruction.this.addScene(scene)re-attaches the destroyed instance as the sole managed scene.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
Scenewith other classes cached byResourceManager:Texture2DMaterialMeshPrefabinstantiate()builds fresh entitiesSceneSceneManagerhas a destroy-then-readd loopSceneis 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
SceneThe
Sceneclass conflates two responsibilities on a single JS object:Role A — Asset
ResourceManager._assetUrlPool[realPath]_deleteAsseton destroy)Role B — Engine Object / Scene-graph root
SceneManagerSceneManager._scenesconstructor → onEnable → ... → destroy(terminal)EngineObject)rootEntities,physics._nativePhysicsScene(PxScene wasm),shaderData,postProcessManager, lights, etc.The conflict
Translate the user intent "restart the current level" into both roles:
destroy()-edThe
destroyOldScene=truebranch ofloadScenehonors 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
SceneAsset(read-only schema, asset-cacheable) and runtimeSceneare 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.SceneAsset(editor-only.unityfile wrapper) and runtimeScene(struct) are separate.SceneManager.LoadScenealways materializes a new runtime.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
.thenPrevents 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
_destroyedin hot setterse.g.
if (this._destroyed) return;at the top ofPhysicsScene.gravitysetter. Pure symptom suppression. The scene is still a zombie; every subsequent call is a silent no-op.Alt 3:
_deleteAssettiming inEngineObject._onDestroyMoving
resourceManager._deleteAsset(this)before the rest of_onDestroydoesn't help — by the time we're indestroy(), cache is already past the hit-and-return step.Alt 4: Userland workaround — load an empty scene first
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.loadwhen the cached instance is still managed bySceneManager. Forces cache miss → loader constructs a freshScene→ 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.This uses only public APIs (
getFromCache,_deleteAsset— both@internal public). TypeScript strict mode passes. Measured behavior after the patch:sceneManager.activeScene === previousfalse(new instance)activeScene.instanceIdscene.destroyedtrue(old instance destroyed normally)activeScene.rootEntitiesCount2in repro project)activeScene.physics.gravity = ...Known residual issues not covered by the interim patch
loadScene(sameURL, true):ResourceManager._loadingPromises(L392) coalesces a second call onto the first in-flight promise. Both.thenhandlers then executedestroy()+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.resourceManager.load<Scene>(url)(i.e. code that bypassesSceneManager.loadSceneand 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.loadScene(url, false)unchanged: same-URL withdestroyOldScene=falsecontinues 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
SceneAssetandScene(most thorough, matches Cocos/Unity)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
Sceneclass, makeSceneLoaderbypass the asset cacheIn
SceneLoader.load, do not populate_assetUrlPoolwith Scene instances. Eachload<Scene>(url)call re-fetches and re-parsesscene.jsonand constructs a freshScene. Sub-assets (Texture/Material/Mesh) still use the shared cache and are not re-downloaded.Pros: smaller surface change — only
SceneLoadertouched; eliminates concurrent-reload race as a free side effect (no in-flight Scene promise to coalesce onto); no new public types.Cons:
scene.jsonis re-parsed each reload (typically negligible, scene.json is small); any code path that assumesresourceManager.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.