Skip to content

fix(core): disable cache for SceneLoader to avoid loadScene self-destroy#2993

Draft
cptbtptpbcptdtptp wants to merge 2 commits into
galacean:dev/2.0from
cptbtptpbcptdtptp:fix/scene-asset-cache-eviction
Draft

fix(core): disable cache for SceneLoader to avoid loadScene self-destroy#2993
cptbtptpbcptdtptp wants to merge 2 commits into
galacean:dev/2.0from
cptbtptpbcptdtptp:fix/scene-asset-cache-eviction

Conversation

@cptbtptpbcptdtptp

@cptbtptpbcptdtptp cptbtptpbcptdtptp commented May 11, 2026

Copy link
Copy Markdown
Collaborator

Disable resource cache for SceneLoader so loadScene always produces a fresh Scene instance.

Bug: loadScene(url) with destroyOldScene=true, cached Scene is active scene -> resourceManager.load returns same instance -> then-handler destroys it -> rootEntities empty, PhysicsScene released, screen blank.

Fix: useCache true to false for SceneLoader. Consistent with PrimitiveMeshLoader and ProjectLoader.

Aligned with Unity/Unreal which all create fresh Scene instances.

Origin: Replaces evict workaround from commit 599a7e6 on fix/shaderlab (by @luzhuang) with root-cause fix.

Summary by CodeRabbit

  • Bug Fixes
    • Adjusted scene asset loader configuration
  • Tests
    • Added validation tests for scene asset loader behavior

Review Change Stack

@codecov

codecov Bot commented May 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.09%. Comparing base (1bc2b10) to head (63b6b1f).

Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #2993      +/-   ##
===========================================
- Coverage    78.25%   78.09%   -0.16%     
===========================================
  Files          900      900              
  Lines        99234    99234              
  Branches     10172    10198      +26     
===========================================
- Hits         77657    77499     -158     
- Misses       21406    21564     +158     
  Partials       171      171              
Flag Coverage Δ
unittests 78.09% <100.00%> (-0.16%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown

Walkthrough

The PR flips the @resourceLoader(AssetType.Scene, ["scene"], ...) decorator's third argument from true to false on SceneLoader and adds Vitest tests that assert loader useCache flags for scene, primitive mesh, and texture assets.

Changes

Scene Loader Registration

Layer / File(s) Summary
Scene Loader registration flag and validation tests
packages/loader/src/SceneLoader.ts, tests/src/core/resource/SceneLoaderCache.test.ts
The SceneLoader class's @resourceLoader(AssetType.Scene, ["scene"], ...) decorator changes its third boolean parameter from true to false. Added tests initialize a WebGLEngine and assert ResourceManager._loaders entries: AssetType.Scene and AssetType.PrimitiveMesh have useCache === false, while AssetType.Texture2D/AssetType.Texture has useCache === true.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Poem

🐰 I flipped a flag, a quiet tweak,
Scenes will load fresh, not cling to the meek.
Tests hop in, they check and cheer,
Cache for textures, scenes stay clear.
A tiny change, a happier year.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: disabling cache for SceneLoader to prevent self-destruction during loadScene operations. It directly relates to the primary fix in the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
packages/core/src/SceneManager.ts (3)

93-116: Consider documenting concurrent loadScene behavior.

If loadScene(url, true) is called multiple times concurrently with overlapping execution, the second promise resolution will destroy scenes added by the first (lines 108-111 destroy ALL scenes in _scenes). While this may be acceptable given typical usage patterns, it could cause unexpected behavior in edge cases.

Consider adding a guard or documentation noting that concurrent loadScene calls with destroyOldScene=true should be avoided, or implementing a loading state flag to prevent concurrent loads.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/SceneManager.ts` around lines 93 - 116, The loadScene
method can concurrently destroy scenes added by overlapping calls when
destroyOldScene=true; add a simple guard to prevent concurrent destructive loads
by introducing a private flag (e.g., this._isLoadingDestructiveScene) or a
pending load tracker (e.g., this._pendingDestructiveLoadUrl) and check it at the
start of loadScene(url, destroyOldScene) to either reject/return the existing
promise or queue the new request; set the flag before calling
resourceManager.load and clear it in the scenePromise.then and its rejection
path, and use the existing symbols loadScene, destroyOldScene, scenePromise,
this._scenes and resourceManager to locate where to implement the guard.

99-99: ⚡ Quick win

Add defensive check for _virtualPathResourceMap existence.

If resourceManager._virtualPathResourceMap is undefined, accessing [url]?.path will throw a TypeError. Consider adding a defensive check or null coalescing for the entire map.

🛡️ Suggested defensive fix
-    const realPath = resourceManager._virtualPathResourceMap[url]?.path ?? url;
+    const realPath = resourceManager._virtualPathResourceMap?.[url]?.path ?? url;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/SceneManager.ts` at line 99, The code assumes
resourceManager._virtualPathResourceMap exists before indexing it which can
throw if undefined; update the realPath resolution in SceneManager (the line
referencing resourceManager._virtualPathResourceMap and url) to defensively
access the map (e.g., check or use optional chaining on the entire map before
indexing) so you use the mapped path when present and fall back to url
otherwise.

99-102: ⚡ Quick win

Use the existing _getRemoteUrl() helper method instead of duplicating its logic.

Line 99 duplicates the logic from ResourceManager._getRemoteUrl() (which does this._virtualPathResourceMap[url]?.path ?? url). Replace the inline logic with:

const realPath = resourceManager._getRemoteUrl(url);

This avoids duplicating the internal path resolution logic and keeps the code maintainable if the resolution strategy changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/SceneManager.ts` around lines 99 - 102, The code duplicates
ResourceManager path-resolution logic by inlining
this._virtualPathResourceMap[url]?.path ?? url to compute realPath; replace that
inline expression with a call to resourceManager._getRemoteUrl(url) so the
SceneManager uses ResourceManager's canonical resolution strategy (affecting
realPath, the subsequent getFromCache<Scene>(realPath) lookup, and the
_deleteAsset(cached) branch).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/core/src/SceneManager.ts`:
- Around line 93-116: The loadScene method can concurrently destroy scenes added
by overlapping calls when destroyOldScene=true; add a simple guard to prevent
concurrent destructive loads by introducing a private flag (e.g.,
this._isLoadingDestructiveScene) or a pending load tracker (e.g.,
this._pendingDestructiveLoadUrl) and check it at the start of loadScene(url,
destroyOldScene) to either reject/return the existing promise or queue the new
request; set the flag before calling resourceManager.load and clear it in the
scenePromise.then and its rejection path, and use the existing symbols
loadScene, destroyOldScene, scenePromise, this._scenes and resourceManager to
locate where to implement the guard.
- Line 99: The code assumes resourceManager._virtualPathResourceMap exists
before indexing it which can throw if undefined; update the realPath resolution
in SceneManager (the line referencing resourceManager._virtualPathResourceMap
and url) to defensively access the map (e.g., check or use optional chaining on
the entire map before indexing) so you use the mapped path when present and fall
back to url otherwise.
- Around line 99-102: The code duplicates ResourceManager path-resolution logic
by inlining this._virtualPathResourceMap[url]?.path ?? url to compute realPath;
replace that inline expression with a call to resourceManager._getRemoteUrl(url)
so the SceneManager uses ResourceManager's canonical resolution strategy
(affecting realPath, the subsequent getFromCache<Scene>(realPath) lookup, and
the _deleteAsset(cached) branch).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4f5f2e47-1909-4c4b-a9e3-acc848386503

📥 Commits

Reviewing files that changed from the base of the PR and between 1bc2b10 and 2dc6913.

📒 Files selected for processing (1)
  • packages/core/src/SceneManager.ts

GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 mentioned this pull request May 11, 2026
3 tasks
Scene is a live runtime tree, not an immutable asset. Caching the loaded
Scene caused a self-destroy race when loadScene(url) was called with a URL
whose cached Scene was the current active scene:

  1. resourceManager.load<Scene>({url}) returned the cached (= currently
     active) Scene instance
  2. SceneManager.loadScene then entered the destroyOldScene branch and
     destroyed the old scenes — including the one just returned
  3. The "new" scene was the same object, now destroyed: rootEntities
     empty, native PhysicsScene released, screen blank, no error logged

The root cause is the cache-vs-construct conflict. A Scene is both an
asset (a JSON blob on disk) and a constructed live runtime. Caching the
constructed instance and returning it for "load same URL again" violates
the user's intent ("reload this level").

Other engines (Unity SceneManager.LoadScene, Cocos director.loadScene,
Unreal OpenLevel) all create a fresh Scene per load — none cache.
Aligning Galacean to the same convention.

`PrimitiveMeshLoader` and `ProjectLoader` already use `useCache: false`
for similar reasons (constructed at load time, not immutable assets),
so the pattern is established in the codebase.
@cptbtptpbcptdtptp cptbtptpbcptdtptp force-pushed the fix/scene-asset-cache-eviction branch from 2dc6913 to e1b18a2 Compare May 12, 2026 02:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/loader/src/SceneLoader.ts`:
- Line 170: The resourceLoader registration currently sets useCache globally for
AssetType.Scene (the decorator at resourceLoader(AssetType.Scene, ["scene"],
false)), which disables caching for all SceneLoader instances and breaks
additive loads; instead revert the registration to allow normal caching and
implement targeted eviction inside SceneManager.loadScene: keep the
decorator/SceneLoader registration default (allow cache), and when
loadScene(...) is called with destroyOldScene === true, explicitly evict or
reload the specific scene from the loader/cache (e.g., call the SceneLoader
instance's cache invalidation method or a cache.clearFor(sceneId) function)
before loading; reference resourceLoader(AssetType.Scene,...), the SceneLoader
class, and SceneManager.loadScene(destroyOldScene) to locate and modify the
code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 93d29346-a827-4e96-b28c-9ee3b0cfecba

📥 Commits

Reviewing files that changed from the base of the PR and between 2dc6913 and e1b18a2.

📒 Files selected for processing (1)
  • packages/loader/src/SceneLoader.ts

}

@resourceLoader(AssetType.Scene, ["scene"], true)
@resourceLoader(AssetType.Scene, ["scene"], false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify resourceLoader third-arg semantics and scene load call-path impact (read-only).

set -euo pipefail

echo "== Locate resourceLoader declaration/signature =="
rg -n -C3 '\bfunction\s+resourceLoader\b|\bconst\s+resourceLoader\b|\bexport\s+function\s+resourceLoader\b'

echo
echo "== Find all Scene loader registrations and third argument values =="
rg -n -C2 '@resourceLoader\(\s*AssetType\.Scene'

echo
echo "== Find SceneManager.loadScene implementation and cache eviction logic =="
rg -n -C6 '\bclass\s+SceneManager\b|\bloadScene\s*\(' packages

echo
echo "== Find internal cache operations relevant to scene assets =="
rg -n -C4 '\b_deleteAsset\b|\bget\b.*Asset|virtualPath|cache' packages

Repository: galacean/engine

Length of output: 50371


The cache behavior change is global and affects additive scene loads unintentionally.

Setting useCache: false on the Scene loader registration at line 170 disables caching for all SceneManager.loadScene() calls, regardless of the destroyOldScene parameter. The resourceLoader decorator passes useCache to the loader constructor at registration time, not per-load call, so additive scene loads (destroyOldScene = false) will also bypass the cache. The PR objective specifies targeted cache eviction only when destroying old scenes, but this implementation affects both destructive and additive loads equally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/loader/src/SceneLoader.ts` at line 170, The resourceLoader
registration currently sets useCache globally for AssetType.Scene (the decorator
at resourceLoader(AssetType.Scene, ["scene"], false)), which disables caching
for all SceneLoader instances and breaks additive loads; instead revert the
registration to allow normal caching and implement targeted eviction inside
SceneManager.loadScene: keep the decorator/SceneLoader registration default
(allow cache), and when loadScene(...) is called with destroyOldScene === true,
explicitly evict or reload the specific scene from the loader/cache (e.g., call
the SceneLoader instance's cache invalidation method or a
cache.clearFor(sceneId) function) before loading; reference
resourceLoader(AssetType.Scene,...), the SceneLoader class, and
SceneManager.loadScene(destroyOldScene) to locate and modify the code.

@cptbtptpbcptdtptp cptbtptpbcptdtptp changed the title fix(core): evict active scene asset cache on loadScene to avoid self-destroy fix(core): disable cache for SceneLoader to avoid loadScene self-destroy May 12, 2026
GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

Adds regression tests for SceneLoader cache policy:
- SceneLoader.useCache === false (the fix in this PR)
- PrimitiveMeshLoader.useCache === false (existing convention, contrast)
- Texture2D loader.useCache === true (immutable asset, contrast)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/src/core/resource/SceneLoaderCache.test.ts`:
- Around line 9-11: Add an afterAll teardown that destroys the WebGLEngine
created in beforeAll to prevent WebGL resource leaks: implement afterAll(async
() => { if (engine) await engine.destroy(); }); referencing the existing engine
variable and the WebGLEngine API (engine.destroy) so the test file
SceneLoaderCache.test.ts cleans up after itself and avoids cross-test
contamination.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: bd3993f9-f2cd-4660-8771-990d197fcbcb

📥 Commits

Reviewing files that changed from the base of the PR and between e1b18a2 and 63b6b1f.

📒 Files selected for processing (1)
  • tests/src/core/resource/SceneLoaderCache.test.ts

Comment on lines +9 to +11
beforeAll(async () => {
engine = await WebGLEngine.create({ canvas: document.createElement("canvas") });
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add teardown for WebGLEngine to avoid test resource leaks.

WebGLEngine is created in beforeAll but never destroyed. Please add afterAll(async () => { await engine.destroy(); }) (or the engine’s correct dispose API) to prevent cross-test contamination and WebGL resource leaks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/resource/SceneLoaderCache.test.ts` around lines 9 - 11, Add an
afterAll teardown that destroys the WebGLEngine created in beforeAll to prevent
WebGL resource leaks: implement afterAll(async () => { if (engine) await
engine.destroy(); }); referencing the existing engine variable and the
WebGLEngine API (engine.destroy) so the test file SceneLoaderCache.test.ts
cleans up after itself and avoids cross-test contamination.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

增量审查(最终)

已关闭问题清单

问题 状态
loadScene(activeSceneUrl, destroyOldScene=true) self-destroy 竞态 ✅ 已修复(useCache=false,loader 不再返回同一已激活 Scene 实例)
方案选择:按需 evict vs 永不缓存 ✅ 已关闭(永不缓存与 PrimitiveMeshLoader/ProjectLoader 一致,Scene 是有状态运行时树非不可变资产)
useCache=false 是否同时阻断缓存 ✅ 已确认(_addAsset 不调用 → _assetUrlPool 永无 Scene 条目 → ResourceManager._loadSingleItem line 370 的 cache-read 必 miss,读写双向都断)
additive load(destroyOldScene=false)也绕过缓存 ✅ 已关闭(有意为之,非副作用)
ProjectLoader 第二条 load<Scene> 消费点受影响 ✅ 已确认拿到 fresh Scene 正是期望行为,无负面影响
[P2] 测试只验证 policy flag,非行为 regression ✅ 已接受(loader 配置即契约,端到端模拟 destroyOldScene 竞态成本高,作为非阻塞接受)
[P3] 测试缺 afterAll(engine.destroy) / PrimitiveMesh 对照测试隐式锁定 ✅ 已提,非阻塞

总结

本 PR diff 仅 2 文件SceneLoader.ts@resourceLoader 第三参 true→false)+ 新增 SceneLoaderCache.test.ts。根因链已逐行对照源码确认:load<Scene> 命中 _assetUrlPool 返回同一 Scene 实例 → 当它正是 active scene 时,SceneManager.loadScenedestroyOldScene 分支把它销毁 → addScene 一个已 destroyed 对象 → rootEntities 空、PhysicsScene 释放、黑屏。useCache=false 后每次 load<Scene> miss → new Scene(...),新旧实例必然不同,问题消除。这是根因层修复,方向正确,替代了 599a7e611 的主动 evict workaround。

问题

[P1] SceneManager.ts:97(本 PR diff 不含此文件,属同链路上的既有 latent bug)— destroyOldScene 销毁循环用 getArray() 遍历,destroy() 会在迭代中 splice 底层数组

const scenes = this._scenes.getArray();          // 返回 _scenes 的 live backing 引用
for (let i = 0, n = scenes.length; i < n; i++) {
  scenes[i].destroy();                            // → Scene._onDestroy → sceneManager.removeScene → scenes.removeByIndex(index)
}

Scene._onDestroy()Scene.ts:502)调用 removeScenescenes.removeByIndex(index),在遍历中修改了正在迭代的数组。当存在 ≥2 个 active scene 且 destroyOldScene=true 时,销毁 scenes[0] 后数组左移,i=1 读到的是原 scenes[2]scenes[1]跳过不被销毁 → 泄漏一个本应销毁的 Scene。_scenesSafeLoopArray,正是为此提供了 getLoopArray()(返回快照副本),应改用它:

const scenes = this._scenes.getLoopArray();

说明:此 bug 在 base dev/2.0 已存在(line 97 同样是 getArray()),本 PR 未引入也未触碰 SceneManager。我此前多轮 review 误称"本 PR 已包含 getLoopArray() 修复"——经核对 PR diff(仅 SceneLoader.ts + 测试)该说法不成立,特此更正并把这个真实存在的 latent bug 重新提出。建议在本 PR 内顺手补一行(与 self-destroy 同属 loadScene 链路),或拆独立 PR 修复——不阻塞本 PR 合入

简化建议 / 长期方向

底层真因:Scene 身兼"按 URL 缓存的 asset"与"引擎运行并销毁的有状态对象"两个职责且无 ref counting,本 PR(issue #2979 的 R2 小改面方案)绕开冲突而非拆分职责。若未来需"同 URL 多实例并存/复用",R1(拆 SceneAsset 可缓存 schema vs 运行时 Scene,对标 Cocos/Unity)会更干净。留作 follow-up。

结论

本 PR 自身改动干净、最小、根因定位准确,可合入。P1 为同链路既有 latent bug(非本 PR 引入),建议处理但不阻塞。

@GuoLei1990 GuoLei1990 marked this pull request as draft June 15, 2026 02:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants