Skip to content

fix(animation, physics, audio, gltf): cherry-pick fixes from fix/shaderlab#3014

Closed
luzhuang wants to merge 10 commits into
dev/2.0from
fix/loader-animation-physics
Closed

fix(animation, physics, audio, gltf): cherry-pick fixes from fix/shaderlab#3014
luzhuang wants to merge 10 commits into
dev/2.0from
fix/loader-animation-physics

Conversation

@luzhuang

@luzhuang luzhuang commented May 25, 2026

Copy link
Copy Markdown
Contributor

Summary

fix/shaderlab 分支抽离 Animation、Physics、Audio、GLTF Loader 相关修复,合入 dev/2.0

Animation

  • 重构 Animator 状态管理:用 AnimatorStatePlayData 承载 per-instance 的 speed/wrapMode,替代 AnimatorStateInstance 代理模式
  • AnimationClip.sampleAnimation() 公开 API,支持外部采样动画
  • Animator.fireEvents 开关,控制是否触发 AnimationEvent 回调
  • _saveAnimatorEventHandlers 用 listener 模式监听 clip 变更,_reset() 时正确移除 listener 防止泄漏
  • _crossFade 增加 null guard,防止 _getAnimatorStateInfo 返回空 state 时崩溃
  • _updatePlayingState 增加 playSpeed === 0 除零保护,防止 NaN
  • 动画事件处理器使用 ClearableObjectPool 池化复用

Physics

  • Collider 增加 _teleportToEntityTransform,re-enable 时 teleport 而非 sweep
  • DynamicCollider 增加 applyForceAtPositionsetKinematicTarget
  • DynamicCollider 移除 addForce/addTorque 中冗余的 wakeUp 调用
  • Collision 碰撞法线方向修正(shape0→shape1 约定)
  • MeshColliderShape 增加 cooking 自动重试
  • PhysXPhysics 支持 tolerance scale 配置
  • PhysicsMaterial 属性同步修复
  • CharacterController 增加 _teleportToEntityTransform override
  • 修复多项物理测试(Collision、DynamicCollider、MeshColliderShape、PhysicsMaterial、PhysicsScene)

Audio

  • AudioManager 重构前后台生命周期管理:hide/show 时自动 suspend/resume playing sources
  • AudioSource pending playback 注册到 AudioManager,由 context 恢复时统一驱动
  • 增加 _suspendPlaybackForInterruption / _resumeInterruptedPlayback 机制
  • _initSourceNode 增加 offset 边界处理和非循环播放结束保护
  • AudioLoader 新增 "audio" 扩展名支持
  • 新增 AudioSourcePendingPlayback 测试覆盖 autoplay blocking、前后台恢复、手势解锁等场景

GLTF Loader

  • GLTFSceneParser 存储 _sceneRoots_computeSkinnedBounds 简化为直接用 rootBone worldMatrix 逆矩阵
  • GLTFSkinParser 重构 _findSkinRootBoneByLCA,skin root 解析时包含 skinned mesh 节点参与 LCA 计算
  • GLTFExtensionSchemaIGalaceanMaterialRemap 改为独立的 url/key 字段(去除 RefItem 依赖)
  • GLTFUtils.loadImageBuffer 恢复 URL.revokeObjectURL 防止 blob URL 泄漏
  • 新增 GLTFSkinParser.test.ts 测试 rootBone LCA 解析

Test plan

  • npx vitest run tests/src/core/Animator.test.ts
  • npx vitest run tests/src/core/physics/
  • npx vitest run tests/src/core/audio/
  • npx vitest run tests/src/loader/GLTFLoader.test.ts
  • 验证 GLTF 动画加载路径(单根/多根场景)
  • 验证物理碰撞 clone、raycast、kinematic teleport
  • 验证音频后台恢复和 autoplay blocking 恢复

Summary by CodeRabbit

  • New Features

    • Public animation sampling and per-animator event toggle; improved audio suspend/resume and gesture recovery; per-collider force-at-position and kinematic transform sync mode; configurable physics tolerances; contact-event enable/disable.
  • Bug Fixes

    • Fixed contact normal orientation; automatic mesh-collider rebuild/retry; more reliable teleport/re-enable positioning; tightened animation event firing (loop/once semantics); safer audio/source lifecycle handling.
  • Tests

    • Expanded animation, audio suspend/recovery, physics (colliders, collisions, materials), and GLTF skin/bounds coverage.

Loader:
- componentRef 解析支持 clone entity 子树查找
- always create GLTF_ROOT container for consistent animation paths
- normalize gltf wrapper and skin roots
- include skinned mesh nodes in skin root LCA
- compute skinned bounds from non-joint rootBone
- add audio extension to AudioLoader supported types
- recognize .m4a/.aac/.flac extensions for audioClip loader
- apply scene physics settings
- cover stripped entity child attachment

Animation:
- normalize single-root clip binding paths
- add per-instance speed to AnimatorStatePlayData
- use per-instance speed during cross fade
- update animator
- expose clip sampling and gate animation events

Physics:
- resolve clone-bypass and runtime native handle desync bugs
- add DynamicCollider.applyForceAtPosition
- remove redundant wakeUp in addForce/addTorque
- repair 8 pre-existing test failures (Lite material + PhysX scene queries)
- kinematic transform sync via setKinematicTarget
- correct collision contact normal orientation
- support kinematic transform teleport sync
- teleport collider on re-enable instead of sweeping
- support PhysX tolerance scale
- raycast 和 sweep 跳过 initial overlap

Clone:
- prefab 克隆时自动 deep clone 同类型 Object 属性

Audio:
- restore pending playback correctly
- restore sources after background interruption
- recover audio after foreground resume
@augmentcode

augmentcode Bot commented May 25, 2026

Copy link
Copy Markdown

This pull request is abnormally large and would use a significant amount of tokens to review. If you still wish to review it, comment "augment review" and we will review it.

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: feb7f739-d124-4c7c-a8c2-6da2d2f6b4a7

📥 Commits

Reviewing files that changed from the base of the PR and between 09081f1 and 8a0924f.

📒 Files selected for processing (5)
  • packages/core/src/physics/PhysicsScene.ts
  • packages/design/src/physics/IPhysicsScene.ts
  • packages/physics-lite/src/LitePhysicsScene.ts
  • packages/physics-physx/src/PhysXPhysicsScene.ts
  • tests/src/core/physics/PhysicsScene.test.ts

Walkthrough

Refactors animation runtime to per-play-data flows and pooled event handlers; adds physics sync modes, contact/sleep defaults, and force-at-position; implements AudioManager suspend/resume with gesture/visibility recovery; updates GLTF skin root resolution and related tests.

Changes

Combined change set

Layer / File(s) Summary
Animation contracts and sampling
packages/core/src/animation/*
Replaced instance-centric Animator APIs with play-data-backed APIs, added AnimationClip.sampleAnimation, migrated Animator state returns to AnimatorState/AnimatorStatePlayData, and updated internal play-data, pooling, and curve-owner caches.
Animator event pooling & reset
packages/core/src/animation/*
Introduced pooled AnimationEventHandler, per-state clip-changed listeners, Animator.fireEvents flag, and expanded _reset() cleanup and wiring.
Playback & transition evaluation
packages/core/src/animation/*
Evaluation, cross-fade, finished-state, and event-range wrapping now use play-data.state/speed and updated cross-fade/destination reset flows.
Animator tests & sampling test
tests/src/core/Animator.test.ts, tests/src/core/AnimatorHang.test.ts
Tests rewritten to use handle-based APIs, added event gating and sampleAnimation test, and added a hang smoke test.
Audio lifecycle & pending playback
packages/core/src/audio/*, tests/src/core/audio/*
AudioManager and AudioSource track pending/playing/interrupted sources, support gesture/visibility-driven resume, and add comprehensive pending-playback tests with mocks.
Physics sync, defaults, and dynamic collider
packages/core/src/physics/*, packages/physics-physx/*
Added teleport vs. target sync hooks, conditional contactOffset/sleepThreshold defaults, DynamicCollider.applyForceAtPosition, kinematic transform sync mode, PhysX tolerances/options and CCD handling, and material native sync/clone helpers.
MeshCollider retry & contact event buffering
packages/core/src/physics/shape/*, packages/physics-physx/*, packages/physics-lite/*
MeshColliderShape retries native cooking on physics ticks; PhysX/Lite scenes add contact-event enable/disable plumbing; physics-lite material setters now warn and noop.
GLTF skin/root-bone and scene bounds
packages/loader/src/gltf/parser/*, tests/src/loader/*
Skin parser uses LCA across nodes to infer root bone when skeleton index missing; Scene parser caches scene roots and uses inverse root-bone world matrix for bounds fallback; tests and fixtures updated.
Misc tests & formatting
tests/*
Many tests updated to import WebGLEngine from @galacean/engine-rhi-webgl, plus formatting and fixture tweaks across suites.

Sequence Diagram(s)

(No sequence diagram generated.)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

Animation, physics, bug

Suggested reviewers

  • zhuxudong
  • hhhhkrx
  • cptbtptpbcptdtptp

"🐰
I hop through frames and switch the play,
pooled handlers tidy events away,
physics teleports and forces spin,
audio waits for your click to begin,
GLTF bones find roots at last today!"

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/loader-animation-physics
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/loader-animation-physics

@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

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/core/src/physics/shape/MeshColliderShape.ts (1)

199-203: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear pending retry state for non-retryable mesh collider config.

When non-convex is used on a non-kinematic DynamicCollider, _createNativeShape() returns early but _pendingNativeShapeCreation can remain true (set in set mesh), so _onPhysicsUpdate() keeps retrying and logging every tick.

💡 Proposed fix
   private _createNativeShape(): void {
     // Non-convex MeshColliderShape is only supported on StaticCollider or kinematic DynamicCollider
     if (!this._isConvex && this._collider instanceof DynamicCollider && !this._collider.isKinematic) {
       console.error("MeshColliderShape: Non-convex mesh is not supported on non-kinematic DynamicCollider.");
+      this._pendingNativeShapeCreation = false;
       return;
     }

Also applies to: 248-250

🤖 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/physics/shape/MeshColliderShape.ts` around lines 199 - 203,
The method _createNativeShape() returns early when a non-convex mesh is used on
a non-kinematic DynamicCollider but does not clear the retry flag
_pendingNativeShapeCreation, causing _onPhysicsUpdate() to repeatedly retry;
update _createNativeShape() to set this._pendingNativeShapeCreation = false
before each early return (including the non-convex/non-kinematic DynamicCollider
branch and the other early-return branches around the same area) so pending
retry state is cleared when the mesh config is non-retryable.
packages/loader/src/SceneLoader.ts (1)

1-13: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Fix missing Material import in SceneLoader sky-material lookup

packages/loader/src/SceneLoader.ts uses Material in resourceManager.getResourceByRef<Material>(background.skyMaterial), but Material is not imported from @galacean/engine-core, which will fail TypeScript compilation.

➕ Suggested fix
 import {
   AssetPromise,
   AssetType,
   BackgroundMode,
   DiffuseMode,
   Loader,
   LoadItem,
   Logger,
+  Material,
   Mesh,
   resourceLoader,
   ResourceManager,
   Scene
 } from "`@galacean/engine-core`";
🤖 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` around lines 1 - 13, The import list in
SceneLoader.ts is missing Material, causing the generic call
resourceManager.getResourceByRef<Material>(background.skyMaterial) to fail;
update the import from "`@galacean/engine-core`" in SceneLoader.ts to include
Material alongside AssetPromise, AssetType, BackgroundMode, DiffuseMode, Loader,
LoadItem, Logger, Mesh, resourceLoader, ResourceManager, and Scene so the
Material type is available for the skyMaterial lookup and TypeScript compilation
succeeds.
packages/core/src/animation/Animator.ts (1)

400-415: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missing layer-index bounds check can throw runtime errors.

At Line 414, layers[layerIndex] is accessed without validating range. Invalid layerIndex values should safely return no state instead of throwing.

🤖 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/animation/Animator.ts` around lines 400 - 415, In
_getAnimatorStateInfo, avoid indexing layers with an out-of-range layerIndex:
before accessing layers[layerIndex].stateMachine (or calling findStateByName on
a specific layer), validate that layerIndex is >= 0 and < layers.length; if the
index is invalid, return the default _tempAnimatorStateInfo (no state) instead
of proceeding. Ensure both the else branch that uses layers[layerIndex] and any
post-loop access paths check bounds, and keep references to symbols:
_getAnimatorStateInfo, _animatorController, layers, layerIndex,
stateMachine.findStateByName, and _tempAnimatorStateInfo to locate and update
the code.
🟠 Major comments (23)
.husky/_/husky.sh-1-1 (1)

1-1: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Replace the accidental local absolute path with the real Husky helper.

Line 1 hardcodes a workstation-specific path, so hooks will fail on any other checkout, and this also leaks a local filesystem path into the repo. Please restore/regenerate the actual .husky/_/husky.sh script instead of committing a local absolute path literal.

🤖 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 @.husky/_/husky.sh at line 1, The committed .husky/_/husky.sh contains a
workstation-specific absolute path instead of the real Husky helper script;
remove the hardcoded path and restore the proper husky helper by regenerating or
replacing the file with Husky's shipped script (the standard husky.sh helper
used by Git hooks). To fix, delete the current .husky/_/husky.sh, then
regenerate the helper by running the Husky install command (or copy the husky.sh
helper from the Husky package) so the file contains the canonical husky helper
content rather than an absolute local path; verify hooks invoke the restored
husky.sh helper correctly.
tests/src/core/particle/ParticleStopResume.test.ts-85-89 (1)

85-89: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tests are asserting known-bug behavior instead of expected behavior.

Line 87 and Line 140 currently validate the bug path, which hardens the defect into CI and blocks the real fix later. Please switch these to expected-correct assertions (or mark as it.fails/it.todo until the fix lands).

Also applies to: 137-140

🤖 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/particle/ParticleStopResume.test.ts` around lines 85 - 89, The
test currently asserts the known-bug behavior instead of the correct behavior;
update the failing assertions that reference emittedDuringIdle,
emittedFirstFrameAfterResume and the time delta playTimeAfterIdle -
playTimeAtStop to reflect the expected-correct outcomes (e.g.,
emittedFirstFrameAfterResume should assert a small/single catch-up like
toBeLessThanOrEqual(1) and playTimeAfterIdle - playTimeAtStop should assert a
small delta like toBeLessThanOrEqual(1) or toBeCloseTo) OR, if you cannot fix
behavior now, mark the affected test cases (the Particle stop/resume test that
uses those variables) as it.todo or it.fails so CI does not lock in the buggy
expectations.
packages/loader/src/gltf/parser/GLTFSkinParser.test.ts-5-7 (1)

5-7: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Global window is being clobbered and not restored.

Line 5 replaces the entire window object, which can pollute global state and cause cross-test flakiness. Stub only required fields and restore after each test.

🔧 Safer isolation pattern
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";

 describe("GLTFSkinParser rootBone resolution", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
   async function createParser(): Promise<any> {
-    (globalThis as any).window = { AudioContext: undefined, TextMetrics: undefined };
+    vi.stubGlobal("window", {
+      ...(globalThis as any).window,
+      AudioContext: undefined,
+      TextMetrics: undefined
+    });
     const { GLTFSkinParser } = await import("./GLTFSkinParser");
     return new GLTFSkinParser();
   }
🤖 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/gltf/parser/GLTFSkinParser.test.ts` around lines 5 - 7,
The test currently replaces globalThis.window entirely which can pollute global
state; instead, in GLTFSkinParser.test.ts stub only the needed properties (e.g.,
set globalThis.window = globalThis.window ?? {} then assign window.AudioContext
and window.TextMetrics) or better save const _origWindow = globalThis.window and
replace only the missing properties, and restore the original window in an
afterEach/teardown block; ensure the test still imports GLTFSkinParser after
stubbing and that the restoration happens even if the test errors so other tests
are unaffected.
packages/core/src/animation/AnimatorStateMachine.ts-22-23 (1)

22-23: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

defaultState can become dangling after removeState

Line 22 makes defaultState non-null, but Line 66-73 does not rebind it when the current default is removed. This can leave auto-play pointing to a state no longer in states/_statesMap.

🔧 Suggested fix
-  defaultState: AnimatorState;
+  defaultState: AnimatorState | null = null;
@@
   removeState(state: AnimatorState): void {
@@
     if (index > -1) {
       this.states.splice(index, 1);
     }
     delete this._statesMap[name];
+    if (this.defaultState === state) {
+      this.defaultState = this.states[0] ?? null;
+    }
   }

Also applies to: 66-73

🤖 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/animation/AnimatorStateMachine.ts` around lines 22 - 23,
The defaultState property can become dangling when removeState removes the
currently assigned default; update the removeState method (and any code that
mutates states/_statesMap or autoplay behavior) to check if the state being
removed === this.defaultState and, if so, rebind this.defaultState to either
null/undefined or to a safe fallback state (e.g., the first remaining state in
states/_statesMap) and update autoplay handling accordingly so auto-play never
references a removed state; also make defaultState nullable in the class
signature if you choose to allow null to represent "no default".
packages/loader/src/resource-deserialize/resources/parser/ParserContext.ts-42-49 (1)

42-49: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset dependency-progress state in clear().

Lines 42-49 clear entity/component collections but leave _tasks, _loaded, and _total unchanged. If this context is reused, URL dedupe and progress reporting become stale.

🔧 Proposed fix
   clear() {
     this.entityMap.clear();
     this.components.clear();
     this.componentConfigMap.clear();
     this.entityConfigMap.clear();
     this.rootIds.length = 0;
     this.strippedIds.length = 0;
+    this._tasks.clear();
+    this._loaded = 0;
+    this._total = 0;
   }
🤖 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/resource-deserialize/resources/parser/ParserContext.ts`
around lines 42 - 49, The clear() method on ParserContext currently resets
entity/component collections but fails to reset dependency/progress state
(_tasks, _loaded, _total), causing stale URL dedupe and progress when the
context is reused; update ParserContext.clear() to clear or reinitialize _tasks
(e.g., call _tasks.clear() or assign a new empty map/set) and reset numeric
counters _loaded and _total back to 0 so dependency tracking and progress
reporting start fresh.
packages/loader/src/ShaderLoader.ts-21-24 (1)

21-24: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast when @builtin resolves to an unknown shader in ShaderLoader

Shader.find(name) is return Shader._shaderMap[name], so an unknown name returns a missing entry at runtime. ShaderLoader currently returns Shader.find(builtinShader) directly, while MaterialLoader already guards if (shader) ... before using it—so ShaderLoader should reject/throw instead of resolving with an invalid value.

🔧 Proposed fix
       const builtinShader = this._getBuiltinShader(code);
       if (builtinShader) {
-        return Shader.find(builtinShader);
+        const shader = Shader.find(builtinShader);
+        if (!shader) {
+          throw new Error(`ShaderLoader: builtin shader "${builtinShader}" not found.`);
+        }
+        return shader;
       }
🤖 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/ShaderLoader.ts` around lines 21 - 24, The current
ShaderLoader returns Shader.find(builtinShader) directly which can yield
undefined for unknown builtins; update the logic in ShaderLoader (use the
_getBuiltinShader method and the call to Shader.find) to check the result and
throw or reject with a clear error if Shader.find(builtinShader) is falsy
(include the builtin name in the message), instead of returning the invalid
value so callers fail fast.
packages/loader/src/MaterialLoader.ts-24-32 (1)

24-32: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard missing renderState before applying it.

When the schema omits renderState, this code currently assigns material.renderState = undefined, which wipes the material’s default render-state object. That turns a missing optional field into a runtime regression for otherwise valid .mat files.

🛠️ Proposed fix
-function parseProperty(object: Object, key: string, value: any) {
-  if (typeof value === "object") {
-    for (let subKey in value) {
-      parseProperty(object[key], subKey, value[subKey]);
-    }
-  } else {
-    object[key] = value;
-  }
+function parseProperty(object: any, key: string, value: any) {
+  if (value == null) {
+    return;
+  }
+
+  if (typeof value === "object") {
+    const target = object[key];
+    if (target == null) {
+      return;
+    }
+
+    for (const subKey in value) {
+      parseProperty(target, subKey, value[subKey]);
+    }
+  } else {
+    object[key] = value;
+  }
 }
@@
-    parseProperty(material, "renderState", renderState);
+    renderState && parseProperty(material, "renderState", renderState);

Also applies to: 125-126

🤖 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/MaterialLoader.ts` around lines 24 - 32, The
parseProperty function currently assigns nested keys blindly and ends up setting
material.renderState = undefined when the schema omits renderState; update
parseProperty (and the code paths that call it for material.renderState, e.g.,
where material.renderState is populated around the renderState handling at the
second occurrence) to first check that object[key] (i.e., material.renderState)
exists and, if not, create or skip applying subkeys as appropriate—specifically:
when key === "renderState" ensure you do not overwrite an existing
material.renderState with undefined (create a default object only if needed) or
skip assignment when value is undefined so missing optional renderState does not
wipe defaults.
packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts-48-53 (1)

48-53: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Load sky dependencies independently instead of requiring both refs.

At Line 48, dependency collection only runs when both skyMesh and skyMaterial are present. If only one is configured, it won’t be collected.

💡 Suggested fix
-      const { skyMesh, skyMaterial } = background;
-      if (skyMesh && skyMaterial) {
-        // `@ts-ignore`
-        context._addDependentAsset(skyMesh.url, resourceManager.getResourceByRef(skyMesh));
-        // `@ts-ignore`
-        context._addDependentAsset(skyMaterial.url, resourceManager.getResourceByRef(skyMaterial));
-      }
+      const { skyMesh, skyMaterial } = background;
+      // `@ts-ignore`
+      skyMesh && context._addDependentAsset(skyMesh.url, resourceManager.getResourceByRef(skyMesh));
+      // `@ts-ignore`
+      skyMaterial && context._addDependentAsset(skyMaterial.url, resourceManager.getResourceByRef(skyMaterial));
🤖 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/resource-deserialize/resources/scene/SceneParser.ts`
around lines 48 - 53, The current logic only adds sky dependencies when both
skyMesh and skyMaterial are truthy; change it to add each dependency
independently: if skyMesh is present call
context._addDependentAsset(skyMesh.url,
resourceManager.getResourceByRef(skyMesh)), and if skyMaterial is present call
context._addDependentAsset(skyMaterial.url,
resourceManager.getResourceByRef(skyMaterial)) so each is collected even when
the other is missing; update the checks around skyMesh and skyMaterial in
SceneParser (the block using context._addDependentAsset and
resourceManager.getResourceByRef) accordingly.
packages/loader/src/Texture2DLoader.ts-49-52 (1)

49-52: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the original mipmap decision during content restore.

The initial image path only generates mipmaps when supportGenerateMipmapsWithCorrection(...) says it is valid, but restoreContent() always regenerates them. Textures loaded with mipmap: false or unsupported dimensions/format will restore to different GPU state after context loss.

🧩 Suggested fix
-            loadImageFromBuffer(buffer).then((img) => {
-              const texture = this._createTexture(img, item, resourceManager);
-              resourceManager.addContentRestorer(new Texture2DContentRestorer(texture, url, requestConfig));
+            loadImageFromBuffer(buffer).then((img) => {
+              const { texture, generateMipmap } = this._createTexture(img, item, resourceManager);
+              resourceManager.addContentRestorer(
+                new Texture2DContentRestorer(texture, url, requestConfig, generateMipmap)
+              );
               resolve(texture);
             }, reject);
@@
-  private _createTexture(img: HTMLImageElement, item: LoadItem, resourceManager: ResourceManager): Texture2D {
+  private _createTexture(
+    img: HTMLImageElement,
+    item: LoadItem,
+    resourceManager: ResourceManager
+  ): { texture: Texture2D; generateMipmap: boolean } {
@@
-    return texture;
+    return { texture, generateMipmap };
   }
 }
 
 class Texture2DContentRestorer extends ContentRestorer<Texture2D> {
   constructor(
     resource: Texture2D,
     public url: string,
-    public requestConfig: RequestConfig
+    public requestConfig: RequestConfig,
+    private readonly generateMipmap: boolean = false
   ) {
     super(resource);
   }
@@
           } else {
             return loadImageFromBuffer(buffer).then((img) => {
               texture.setImageSource(img);
-              texture.generateMipmaps();
+              this.generateMipmap && texture.generateMipmaps();
               return texture;
             });
           }

Also applies to: 60-96, 108-127

🤖 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/Texture2DLoader.ts` around lines 49 - 52, The loader
currently discards the original mipmap decision and always regenerates mipmaps
during restore; update the flow so the mipmap boolean chosen at load time is
stored and reused by the restorer. Concretely: when creating the texture in
loadImageFromBuffer and other load paths (calls to _createTexture and
instantiation of Texture2DContentRestorer), capture the computed mipmap flag
(from supportGenerateMipmapsWithCorrection(...) or explicit mipmap option) and
pass that flag into Texture2DContentRestorer. Then change
Texture2DContentRestorer.restoreContent to use the stored mipmap flag instead of
recomputing/generating mipmaps unconditionally. Ensure all places that add a
Texture2DContentRestorer (the other load branches) are updated to include this
stored flag.
tests/src/core/audio/AudioSourcePendingPlayback.test.ts-90-109 (1)

90-109: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore window.AudioContext in teardown.

beforeEach() overwrites the global constructor, but afterEach() never puts it back. That leaks the mock into later suites and makes them order-dependent.

🤖 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/audio/AudioSourcePendingPlayback.test.ts` around lines 90 -
109, The test setup overrides the global constructor by assigning
MockAudioContext to window.AudioContext in beforeEach but never restores it;
capture the original constructor (e.g., save window.AudioContext to a variable
like originalAudioContext at top or at start of beforeEach) and restore it in
afterEach by reassigning window.AudioContext = originalAudioContext (or delete
window.AudioContext if it was undefined originally) so the mock does not leak
into other suites; update the afterEach alongside the existing
vi.restoreAllMocks()/useRealTimers() cleanup to perform this restore.
packages/core/src/clone/CloneManager.ts-165-194 (1)

165-194: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Deep-cloning Map/Set still aliases nested mutable entries.

These branches only allocate a new container. Non-_remap keys/values are copied by reference, so CloneMode.Deep still shares nested objects inside Map and Set.

🤖 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/clone/CloneManager.ts` around lines 165 - 194, The Map/Set
branches only allocate new containers but still copy nested objects by reference
when they don't implement _remap, so CloneMode.Deep doesn't actually deep-clone
entries; modify the Map (targetPropertyM) and Set (targetPropertyS) loops to,
for each entry where value or key is an object and does NOT have a _remap,
invoke the same recursive cloning routine used elsewhere in CloneManager (the
per-property deep-clone function used with srcRoot/targetRoot and
CloneMode.Deep) to produce a cloned key/value before inserting into the new
Map/Set, while keeping the existing _remap path (i.e., prefer
(<ICustomClone>...)._remap(srcRoot, targetRoot) when present).
packages/core/src/animation/internal/AnimatorLayerData.ts-15-17 (1)

15-17: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a null-prototype map for state names.

animatorStateDataMap is now keyed by arbitrary state names, but {} makes keys like __proto__ and constructor special. A user-authored controller can corrupt lookups here.

🛡️ Suggested fix
-  animatorStateDataMap: Record<string, AnimatorStateData> = {};
+  animatorStateDataMap: Record<string, AnimatorStateData> = Object.create(null);
🤖 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/animation/internal/AnimatorLayerData.ts` around lines 15 -
17, animatorStateDataMap is currently initialized with {} which allows prototype
keys like "__proto__" to collide; update the AnimatorLayerData implementation so
animatorStateDataMap uses a null-prototype object or an actual Map to avoid
prototype pollution: replace the current Record<string, AnimatorStateData> = {}
initialization with either Record<string, AnimatorStateData> =
Object.create(null) (keeping the same type) or change the field to Map<string,
AnimatorStateData> and initialize it with new Map() and update any
lookup/assignment code that references animatorStateDataMap accordingly.
packages/core/src/clone/CloneManager.ts-108-139 (1)

108-139: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep explicit clone decorators authoritative.

@ignoreClone no longer wins for remappable refs because _remap runs first, and Lines 128-138 can upgrade @shallowClone into deep cloning. That breaks the documented contract of both decorators.

✂️ Suggested fix
-    // 1. Remappable references (Entity/Component) are always remapped, highest priority
-    if (sourceProperty instanceof Object && (<ICustomClone>sourceProperty)._remap) {
-      target[k] = (<ICustomClone>sourceProperty)._remap(srcRoot, targetRoot);
-      return;
-    }
-
-    // 2. Explicit ignore
+    // 1. Explicit ignore must stay authoritative
     if (cloneMode === CloneMode.Ignore) return;
 
-    // 3. Primitives / null / undefined - direct assign
+    // 2. Remappable references (Entity/Component)
+    if (sourceProperty instanceof Object && (<ICustomClone>sourceProperty)._remap) {
+      target[k] = (<ICustomClone>sourceProperty)._remap(srcRoot, targetRoot);
+      return;
+    }
+
+    // 3. Primitives / null / undefined - direct assign
     if (!(sourceProperty instanceof Object)) {
       target[k] = sourceProperty;
       return;
     }
@@
-    } else if (effectiveCloneMode !== CloneMode.Assignment) {
-      // Decorated Shallow/Deep: upgrade to Deep if target already has independent same-type instance.
-      // Assignment is never upgraded — it means the user explicitly wants a reference copy.
-      const targetProperty = target[k];
-      if (
-        targetProperty &&
-        targetProperty !== sourceProperty &&
-        targetProperty.constructor === sourceProperty.constructor
-      ) {
-        effectiveCloneMode = CloneMode.Deep;
-      }
     }
🤖 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/clone/CloneManager.ts` around lines 108 - 139, The current
logic upgrades a decorated shallow clone into deep because the upgrade branch
checks effectiveCloneMode !== CloneMode.Assignment rather than whether the
cloneMode was explicitly provided; to fix this, make explicit decorators
authoritative by only performing the "upgrade to Deep when target already has
independent same-type instance" logic when the incoming cloneMode was not
explicitly provided (i.e., cloneMode === undefined / inferred). Concretely, in
CloneManager (the block that sets effectiveCloneMode and then checks
targetProperty to maybe set effectiveCloneMode = CloneMode.Deep), change the
condition so the upgrade runs only when the original cloneMode parameter was
undefined (use that original flag or a local isExplicit := cloneMode !==
undefined) and keep Assignment behavior unchanged; reference symbols: cloneMode,
effectiveCloneMode, CloneMode, CloneManager._inferCloneMode, sourceProperty,
target[k], and _remap.
packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts-308-321 (1)

308-321: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard the optional components array.

IBasicEntity.components is optional in the schema. _addComponents() currently assumes it is always present and will throw on components.length for valid files that omit an empty component list.

Suggested fix
   private _addComponents(entity: Entity, components: IEntity["components"]): void {
+    if (!components?.length) {
+      return;
+    }
+
     const context = this.context;
     const componentMap = context.components;
     const componentConfigMap = context.componentConfigMap;
🤖 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/resource-deserialize/resources/parser/HierarchyParser.ts`
around lines 308 - 321, The _addComponents method assumes components is always
present and will throw when IEntity["components"] is undefined; add a guard at
the top of _addComponents(entity: Entity, components: IEntity["components"]) to
return early if !components or components.length === 0. Locate the function
named _addComponents and add the check before using componentMap,
componentConfigMap, Loader.getClass(...) and calling this._addComponentPlugin so
the rest of the loop only runs when a valid components array is provided.
packages/loader/src/HDRDecoder.ts-182-189 (1)

182-189: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Strip \r when reading HDR header lines.

_readStringLine() currently keeps carriage returns, so parseHeader() will reject valid CRLF-terminated HDR files: "FORMAT=32-bit_rle_rgbe\r" no longer matches, and the blank separator line is never seen as empty.

Suggested fix
   private static _readStringLine(uint8array: Uint8Array, startIndex: number): string {
     let line = "";
     for (let i = startIndex, n = uint8array.length; i < n; i++) {
       const character = String.fromCharCode(uint8array[i]);
       if (character === "\n") break;
-      line += character;
+      if (character !== "\r") {
+        line += character;
+      }
     }
     return line;
   }
🤖 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/HDRDecoder.ts` around lines 182 - 189, _readStringLine
currently preserves carriage returns which breaks parseHeader's detection of
lines (e.g., "FORMAT=32-bit_rle_rgbe\r") and blank separator lines; update
HDRDecoder._readStringLine to not include '\r' characters when accumulating the
line (or trim a trailing '\r' before returning) so lines returned to parseHeader
are normalized and CRLF-terminated files are accepted. Ensure the change affects
the HDRDecoder._readStringLine method only and that parseHeader will see an
empty string for a CRLF blank separator.
packages/core/src/Entity.ts-572-590 (1)

572-590: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset _isActiveChanging in a finally block.

_setActiveInHierarchy() and _setInActiveInHierarchy() can run user code through component/script enable/disable hooks. If one of those throws, this flag stays true forever and every later active-state change on the entity will fail immediately.

Suggested fix
   _processActive(activeChangeFlag: ActiveChangeFlag): void {
     if (this._isActiveChanging) {
       throw "Note: can't set the 'main inActive entity' active in hierarchy, if the operation is in main inActive entity or it's children script's onDisable Event.";
     }
-    this._isActiveChanging = true;
-    this._setActiveInHierarchy(activeChangeFlag);
-    this._isActiveChanging = false;
+    this._isActiveChanging = true;
+    try {
+      this._setActiveInHierarchy(activeChangeFlag);
+    } finally {
+      this._isActiveChanging = false;
+    }
   }
@@
   _processInActive(activeChangeFlag: ActiveChangeFlag): void {
     if (this._isActiveChanging) {
       throw "Note: can't set the 'main active entity' inActive in hierarchy, if the operation is in main active entity or it's children script's onEnable Event.";
     }
-    this._isActiveChanging = true;
-    this._setInActiveInHierarchy(activeChangeFlag);
-    this._isActiveChanging = false;
+    this._isActiveChanging = true;
+    try {
+      this._setInActiveInHierarchy(activeChangeFlag);
+    } finally {
+      this._isActiveChanging = false;
+    }
   }
🤖 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/Entity.ts` around lines 572 - 590, Both _processActive and
_processInActive leave this._isActiveChanging set to true if
_setActiveInHierarchy/_setInActiveInHierarchy throws; wrap the call and the flag
reset in a try/finally so this._isActiveChanging is always set back to false
(and rethrow the original error after finally). Modify the bodies of
_processActive and _processInActive to set this._isActiveChanging = true before
calling _setActiveInHierarchy/_setInActiveInHierarchy, call them inside a try,
and reset this._isActiveChanging = false in finally to guarantee cleanup even on
exceptions.
packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts-21-38 (1)

21-38: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Await transform mutations before resolving parseEntity.

parsePropsAndMethods() is async, but this path drops its promise and returns the Entity immediately. Any async transform prop/method resolution can still be running while callers continue wiring the hierarchy, so initialization order becomes nondeterministic.

Suggested fix
-  parseEntity(entityConfig: IEntity): Promise<Entity> {
-    return this._getEntityByConfig(entityConfig).then((entity) => {
+  parseEntity(entityConfig: IEntity): Promise<Entity> {
+    return this._getEntityByConfig(entityConfig).then(async (entity) => {
       entity.isActive = entityConfig.isActive ?? true;
       const transform = entity.transform;
       const transformConfig = entityConfig.transform;
       if (transformConfig) {
-        this.parsePropsAndMethods(transform, transformConfig);
+        await this.parsePropsAndMethods(transform, transformConfig);
       } else {
         const { position, rotation, scale } = entityConfig;
         if (position) transform.position.copyFrom(position);
         if (rotation) transform.rotation.copyFrom(rotation);
         if (scale) transform.scale.copyFrom(scale);
       }
       entity.layer = entityConfig.layer ?? entity.layer;
       // `@ts-ignore`
       this._context.type === ParserType.Prefab && entity._markAsTemplate(this._context.resource);
       return entity;
     });
   }
🤖 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/resource-deserialize/resources/parser/ReflectionParser.ts`
around lines 21 - 38, parseEntity returns before async transform mutations
complete because parsePropsAndMethods is called but not awaited; update
parseEntity (the method that calls this._getEntityByConfig(...).then(...)) to
await or return the promise from parsePropsAndMethods when transformConfig
exists so the Entity is only resolved after parsePropsAndMethods finishes.
Ensure the change preserves setting entity.isActive, applying fallback
position/rotation/scale, assigning entity.layer, and still calls
entity._markAsTemplate(this._context.resource) when this._context.type ===
ParserType.Prefab.
packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts-65-79 (1)

65-79: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply Layer.Layer0 explicitly instead of truthiness-checking it.

layer is numeric, so Layer0 is 0. The current check skips valid overrides back to layer 0, which breaks prefab/scene modifications when the existing entity is already on a non-zero layer.

Suggested fix
-    if (entityConfig.layer) entity.layer = entityConfig.layer;
+    if (entityConfig.layer !== undefined) entity.layer = entityConfig.layer;
🤖 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/resource-deserialize/resources/parser/HierarchyParser.ts`
around lines 65 - 79, In _applyEntityData, the code uses truthiness to decide
whether to set entity.layer (if (entityConfig.layer) ...), which skips valid
numeric layer 0; change the guard to check for undefined (e.g. if
(entityConfig.layer !== undefined) entity.layer = entityConfig.layer) so a
config value of Layer.Layer0 (0) is applied to Entity.layer; update the
condition in the _applyEntityData method referencing entityConfig.layer and
Entity.layer accordingly.
packages/loader/src/gltf/parser/GLTFSceneParser.ts-199-201 (1)

199-201: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Include the skinned-mesh node transform in the fallback bounds conversion.

mesh.bounds are in the renderer entity's local space, not in rootBone space. Using only inverse(rootBone.worldMatrix) drops the skinned-mesh node transform, so localBounds becomes wrong whenever rootBone is outside bones and the renderer node is not identity relative to it.

Suggested fix
     } else {
+      const toRootBoneSpace = new Matrix();
       const inverseRootBoneWorld = new Matrix();
       Matrix.invert(rootBone.transform.worldMatrix, inverseRootBoneWorld);
-      BoundingBox.transform(mesh.bounds, inverseRootBoneWorld, skinnedMeshRenderer.localBounds);
+      Matrix.multiply(inverseRootBoneWorld, skinnedMeshRenderer.entity.transform.worldMatrix, toRootBoneSpace);
+      BoundingBox.transform(mesh.bounds, toRootBoneSpace, skinnedMeshRenderer.localBounds);
     }
🤖 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/gltf/parser/GLTFSceneParser.ts` around lines 199 - 201,
The current fallback uses only inverseRootBoneWorld
(Matrix.invert(rootBone.transform.worldMatrix)) which ignores the skinned-mesh
node's world transform and yields wrong localBounds; compute a combined
transform that maps the renderer's local space into the rootBone local space
(i.e., first get the skinned-mesh node's world matrix from the renderer node,
multiply it by inverseRootBoneWorld or multiply inverseRootBoneWorld by the node
world depending on matrix convention), then pass that combined matrix to
BoundingBox.transform(mesh.bounds, combinedMatrix,
skinnedMeshRenderer.localBounds) so the renderer node transform is included when
converting mesh.bounds.
packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts-251-275 (1)

251-275: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pass the full prefab ref through and register the nested dependency.

IRefEntity carries key and isClone, but this path only forwards { url }, so nested prefab/GLTF subresource refs no longer resolve the same way as ReflectionParser._getEntityByConfig(). This branch also never associates the loaded PrefabResource/GLTFResource with the owning prefab resource, so nested asset lifetime tracking is lost.

Suggested fix
-        .getResourceByRef<Entity>({
-          url: assetUrl
-        })
+        .getResourceByRef({
+          url: assetUrl,
+          key: entityConfig.key,
+          isClone: entityConfig.isClone
+        })
         .then((prefabResource: PrefabResource | GLTFResource) => {
+          if (this.context.type === ParserType.Prefab) {
+            // `@ts-ignore`
+            this.context.resource._addDependenceAsset(prefabResource);
+          }
           const entity =
             prefabResource instanceof PrefabResource
               ? prefabResource.instantiate()
🤖 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/resource-deserialize/resources/parser/HierarchyParser.ts`
around lines 251 - 275, The _parsePrefab path currently calls
engine.resourceManager.getResourceByRef with only { url: assetUrl }, losing
IRefEntity fields (key, isClone) and failing to register the loaded
PrefabResource/GLTFResource as a nested dependency; update the call in
_parsePrefab to pass the full IRefEntity (entityConfig) so nested refs resolve
the same way as ReflectionParser._getEntityByConfig, and after the resource is
loaded associate/link it with the owning prefab resource (using your resource
manager's dependency/link API) so lifetime tracking is preserved and nested
prefab/GLTF subresources are registered (keep the rest of the logic that
instantiates, creates ParserContext, sets _prefabContextMap and resolves
_prefabPromiseMap).
packages/loader/src/resource-deserialize/resources/texture2D/TextureDecoder.ts-48-73 (1)

48-73: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix image-backed Texture2D decode: reject on Image failure, revoke object URLs, and cache in _objectPool.

  • The image-backed path never attaches img.onerror, so the AssetPromise can hang forever on a broken base/mip image.
  • URL.createObjectURL(...) is never revoked (leaks).
  • The successful decode is not registered into engine.resourceManager._objectPool[url], unlike the pixel-buffer branch, so it can’t be found later by URL.
Suggested fix
       } else {
-        const blob = new window.Blob([imagesData[0]]);
-        const img = new Image();
-        img.onload = () => {
-          texture2D.setImageSource(img);
-          let completedCount = 0;
-          const onComplete = () => {
-            completedCount++;
-            if (completedCount >= mipCount) {
-              resolve(texture2D);
-            }
-          };
-          onComplete();
-          if (mipmap) {
-            texture2D.generateMipmaps();
-            for (let i = 1; i < mipCount; i++) {
-              const blob = new window.Blob([imagesData[i]]);
-              const img = new Image();
-              img.onload = () => {
-                texture2D.setImageSource(img, i);
-                onComplete();
-              };
-              img.src = URL.createObjectURL(blob);
-            }
-          }
-        };
-        img.src = URL.createObjectURL(blob);
+        const loadImage = (data: Uint8Array) =>
+          new Promise<HTMLImageElement>((resolveImage, rejectImage) => {
+            const objectURL = URL.createObjectURL(new window.Blob([data]));
+            const img = new Image();
+            img.onload = () => {
+              URL.revokeObjectURL(objectURL);
+              resolveImage(img);
+            };
+            img.onerror = (e) => {
+              URL.revokeObjectURL(objectURL);
+              rejectImage(e);
+            };
+            img.src = objectURL;
+          });
+
+        loadImage(imagesData[0])
+          .then((baseImage) => {
+            texture2D.setImageSource(baseImage);
+            if (mipmap) {
+              texture2D.generateMipmaps();
+            }
+            return Promise.all(
+              imagesData.slice(1).map((data, index) =>
+                loadImage(data).then((mipImage) => texture2D.setImageSource(mipImage, index + 1))
+              )
+            );
+          })
+          .then(() => {
+            // `@ts-ignore`
+            engine.resourceManager._objectPool[url] = texture2D;
+            resolve(texture2D);
+          }, reject);
       }
🤖 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/resource-deserialize/resources/texture2D/TextureDecoder.ts`
around lines 48 - 73, The image-backed decode in TextureDecoder must handle
image load errors, revoke created object URLs, and cache the finished Texture2D
in engine.resourceManager._objectPool keyed by the source URL; update the img
handling in the block that creates Image() (base and mip images) to attach
img.onerror to call reject(err) on the surrounding AssetPromise (or pass an
Error describing which mip failed), call URL.revokeObjectURL(img.src) in both
onload and onerror, and after the final onComplete resolves, store the texture
into engine.resourceManager._objectPool[url] (same key used by the pixel-buffer
branch); ensure the mip loop also revokes each created blob URL on load/error
and propagates failures to reject so the promise never hangs.
packages/core/src/animation/Animator.ts-492-520 (1)

492-520: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clip-change listeners are never unsubscribed.

At Line 519, listeners are registered on each state data build, but there is no matching unsubscribe in reset/destroy paths (Line 336-Line 354, Line 367-Line 374). This can leak closures and trigger duplicate handler rebuilds over time.

Also applies to: 336-354, 367-374

🤖 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/animation/Animator.ts` around lines 492 - 520, The
clipChangedListener created in _saveAnimatorEventHandlers is added to
state._updateFlagManager but never removed, causing leaks and duplicate
rebuilds; modify _saveAnimatorEventHandlers to return the created listener (or
store it on AnimatorState/AnimatorStateData, e.g. attach to
animatorStateData._clipChangedListener) and ensure reset/destroy paths (methods
handling state teardown referenced around lines handling reset/destroy) call
state._updateFlagManager.removeListener(listener) (or use that stored reference)
when the state is cleared/destroyed so the listener is unsubscribed and closures
are released.
packages/core/src/animation/Animator.ts-232-242 (1)

232-242: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

findAnimatorState can return the wrong state play-data.

At Line 240-Line 241, if the target state exists but is not current srcPlayData/destPlayData, the method still returns srcPlayData (which may belong to a different state). This can silently mutate/read the wrong state instance.

💡 Suggested fix
   findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData {
     const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex);
     if (!state || foundLayer < 0) return null;
     const layerData = this._animatorLayersData[foundLayer];
     if (!layerData) return null;
-    // Check srcPlayData and destPlayData for the matching state
+    // Check srcPlayData and destPlayData for the matching state
     if (layerData.srcPlayData.state === state) return layerData.srcPlayData;
     if (layerData.destPlayData.state === state) return layerData.destPlayData;
-    // State exists in controller but not currently playing — return srcPlayData initialized with the state
-    return layerData.srcPlayData;
+    return null;
   }
🤖 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/animation/Animator.ts` around lines 232 - 242,
findAnimatorState currently returns layerData.srcPlayData when the requested
state exists in the controller but isn’t equal to srcPlayData or destPlayData,
which can return the wrong instance; update findAnimatorState to, after
obtaining { state, layerIndex: foundLayer } from _getAnimatorStateInfo and
validating layerData, if neither layerData.srcPlayData.state nor
layerData.destPlayData.state matches state, create and return a new
AnimatorStatePlayData (or a properly initialized play-data object) whose .state
is the found state (do not return the existing srcPlayData instance), ensuring
you use the AnimatorStatePlayData constructor/initialization used elsewhere and
preserve null checks for state and layerData.
🟡 Minor comments (4)
packages/loader/src/SpriteLoader.ts-35-35 (1)

35-35: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Type inference for getResourceByRef isn’t possible without explicit generics

getResourceByRef<T extends EngineObject>(ref: { url: string; ... }): AssetPromise<T> can’t infer T from the ref argument because T only appears in the return type. In packages/loader/src/SpriteLoader.ts (lines 35 and 47), the calls are also preceded by // @ts-ignore``, so the annotated .then((atlas: SpriteAtlas) => ...) / `.then((texture: Texture2D) => ...)` isn’t enforced by the actual return type—reintroduce explicit generics or adjust the API typing to make the returned asset type safe.

🤖 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/SpriteLoader.ts` at line 35, Calls to getResourceByRef
lack an explicit generic so TypeScript can’t infer the returned AssetPromise
type; update the SpriteLoader usage to call getResourceByRef with an explicit
type parameter (e.g., getResourceByRef<SpriteAtlas>(...) and
getResourceByRef<Texture2D>(...)) and remove the surrounding // `@ts-ignore` so
the .then callbacks (atlas: SpriteAtlas / texture: Texture2D) are type-checked,
or alternatively adjust the getResourceByRef signature to surface the asset
generic from the ref argument so callers don’t need to specify generics.
tests/src/loader/SceneParser.test.ts-4-5 (1)

4-5: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Skipped placeholder test provides no regression signal.

The new suite currently adds no active coverage. Please either implement this test now or convert it to it.todo(...) and track it outside this PR.

If you want, I can draft a concrete test case for the deleted-component reference path and propose assertions.

🤖 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/loader/SceneParser.test.ts` around lines 4 - 5, The skipped
placeholder test "should load project successfully with component be deleted" in
tests/src/loader/SceneParser.test.ts provides no coverage; either implement the
test body for the deleted-component reference path inside the existing test
(replace it.skip with it and add assertions that SceneParser loads the project
and handles deleted components gracefully), or change it.skip to it.todo("should
load project successfully with component be deleted") so CI records the pending
test; look for the test identifier string and the use of it.skip to locate and
update the test accordingly.
packages/loader/src/resource-deserialize/resources/schema/MaterialSchema.ts-93-95 (1)

93-95: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add boolean to IMaterialSchema.shaderData[*].value to match MaterialLoaderType.Boolean.

MaterialLoaderType includes Boolean, MaterialLoader.ts consumes it as value ? 1 : 0, but IMaterialSchema.shaderData[*].value omits boolean, rejecting valid boolean shader payloads.

🔧 Proposed fix
   shaderData: {
     [key: string]: {
       type: MaterialLoaderType;
-      value: IVector3 | IVector2 | IColor | number | IAssetRef;
+      value: IVector3 | IVector2 | IColor | number | boolean | IAssetRef;
     };
   };
🤖 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/resource-deserialize/resources/schema/MaterialSchema.ts`
around lines 93 - 95, The IMaterialSchema currently defines shaderData[*].value
without boolean, but MaterialLoaderType includes Boolean and MaterialLoader.ts
expects booleans (it uses value ? 1 : 0); update the IMaterialSchema type for
shaderData entries so that the value union adds boolean (e.g., include "boolean"
alongside IVector3 | IVector2 | IColor | number | IAssetRef) so boolean shader
payloads validate correctly; adjust any associated types or tests that rely on
IMaterialSchema.shaderData to accept the new boolean variant.
packages/loader/src/Texture2DLoader.ts-19-29 (1)

19-29: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Revoke the blob URL on failed image decodes.

Line 27 rejects without releasing the createObjectURL() result, so repeated bad/aborted image loads leak blob URLs in the page.

♻️ Suggested fix
 function loadImageFromBuffer(buffer: ArrayBuffer): AssetPromise<HTMLImageElement> {
   return new AssetPromise((resolve, reject) => {
     const blob = new Blob([buffer]);
     const img = new Image();
+    const objectURL = URL.createObjectURL(blob);
     img.onload = () => {
-      URL.revokeObjectURL(img.src);
+      URL.revokeObjectURL(objectURL);
       resolve(img);
     };
-    img.onerror = reject;
-    img.src = URL.createObjectURL(blob);
+    img.onerror = (e) => {
+      URL.revokeObjectURL(objectURL);
+      reject(e);
+    };
+    img.src = objectURL;
   });
 }
🤖 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/Texture2DLoader.ts` around lines 19 - 29, The
loadImageFromBuffer function leaks blob URLs on failed decodes because the
img.onerror handler calls reject without revoking the URL; update
loadImageFromBuffer so that both img.onload and img.onerror revoke
URL.revokeObjectURL(img.src) before resolving/rejecting (and pass the
error/event into reject), ensuring the blob created for img.src is always
released; reference the img.onload, img.onerror handlers and URL.revokeObjectURL
usage in loadImageFromBuffer to implement this change.
🧹 Nitpick comments (1)
tests/src/loader/StrippedEntity.test.ts (1)

67-128: ⚡ Quick win

Guard test cleanup with try/finally to avoid cache leakage on assertion failures.

At Line 127/204/275, cleanup runs only on success. Wrap each test body after cachePrefab(...) in try/finally so removeCachedPrefab(...) and root.destroy() always execute.

💡 Suggested pattern
-    const nestedPrefab = await PrefabParser.parse(engine, "char.prefab", nestedPrefabData);
-    cachePrefab("char.prefab", nestedPrefab);
-    ...
-    const outerPrefab = await PrefabParser.parse(engine, "outer.prefab", outerPrefabData);
-    const root = outerPrefab.instantiate();
-    ...
-    root.destroy();
-    removeCachedPrefab("char.prefab");
+    const nestedPrefab = await PrefabParser.parse(engine, "char.prefab", nestedPrefabData);
+    cachePrefab("char.prefab", nestedPrefab);
+    let root;
+    try {
+      ...
+      const outerPrefab = await PrefabParser.parse(engine, "outer.prefab", outerPrefabData);
+      root = outerPrefab.instantiate();
+      ...
+    } finally {
+      root?.destroy();
+      removeCachedPrefab("char.prefab");
+    }

Also applies to: 139-205, 216-276

🤖 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/loader/StrippedEntity.test.ts` around lines 67 - 128, The test can
leak cached prefabs and not destroy instantiated roots on assertion failure;
wrap the test body that begins after cachePrefab("char.prefab", ...) in a
try/finally so that removeCachedPrefab("char.prefab") and root.destroy() always
run; locate the block around PrefabParser.parse/outerPrefab.instantiate() and
ensure the finally calls root.destroy() (guarding if root is defined) and
removeCachedPrefab("char.prefab") so cleanup happens even when expectations
fail; apply the same try/finally pattern to the other similar tests that call
cachePrefab/removeCachedPrefab (the blocks referenced at the other ranges).

Comment thread packages/loader/src/prefab/PrefabResource.ts Outdated
GuoLei1990

This comment was marked as outdated.

- Remove dead parseProperty/renderState in MaterialLoader
- Restore revokeObjectURL in GLTFUtils.loadImageBuffer
- Add null guard in Animator._crossFade
- Guard playSpeed=0 division in _updatePlayingState
- Clean up clipChangedListener in Animator._reset
GuoLei1990

This comment was marked as outdated.

luzhuang added 2 commits May 26, 2026 11:26
Revert non-gltf loader changes and restore misdeleted
files back to dev/2.0 state.
…ture

AssetType enum has no Texture2D member; this was an incorrect
change from fix/shaderlab.
@luzhuang luzhuang changed the title fix(loader, animation, physics): cherry-pick fixes from fix/shaderlab fix(animation, physics, audio, gltf): cherry-pick fixes from fix/shaderlab May 26, 2026
GuoLei1990

This comment was marked as outdated.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/animation/Animator.ts (1)

238-242: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fallback returns play data for a different state than requested.

When the requested state exists in the controller but is not currently playing in srcPlayData or destPlayData, the fallback at line 241 returns srcPlayData which may contain a completely different state. The JSDoc promises "Per-instance play data for a state by name" but this can return play data for the wrong state.

If caller requests state "Run" while "Walk" is playing, srcPlayData.state is "Walk" but we return it as the result for "Run".

Consider returning null when the state is not currently active, consistent with the JSDoc's "or null if not found" contract:

   if (layerData.srcPlayData.state === state) return layerData.srcPlayData;
   if (layerData.destPlayData.state === state) return layerData.destPlayData;
-  // State exists in controller but not currently playing — return srcPlayData initialized with the state
-  return layerData.srcPlayData;
+  // State exists in controller but not currently playing
+  return null;
🤖 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/animation/Animator.ts` around lines 238 - 242, The fallback
in Animator.ts currently returns layerData.srcPlayData when neither
layerData.srcPlayData.state nor layerData.destPlayData.state matches the
requested state, which can return play data for the wrong state; change the
method in the Animator class that queries per-instance play data (the code
referencing layerData.srcPlayData, layerData.destPlayData, srcPlayData.state and
destPlayData.state) to return null instead of returning srcPlayData when no
active playData matches the requested state, and update the method's return
handling/comments to reflect the "or null if not found" contract so callers can
detect a missing active state.
🤖 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.

Outside diff comments:
In `@packages/core/src/animation/Animator.ts`:
- Around line 238-242: The fallback in Animator.ts currently returns
layerData.srcPlayData when neither layerData.srcPlayData.state nor
layerData.destPlayData.state matches the requested state, which can return play
data for the wrong state; change the method in the Animator class that queries
per-instance play data (the code referencing layerData.srcPlayData,
layerData.destPlayData, srcPlayData.state and destPlayData.state) to return null
instead of returning srcPlayData when no active playData matches the requested
state, and update the method's return handling/comments to reflect the "or null
if not found" contract so callers can detect a missing active state.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 03b05a77-20b0-4a05-a92d-3b96c56f8486

📥 Commits

Reviewing files that changed from the base of the PR and between 618d4dd and 0375f07.

📒 Files selected for processing (6)
  • packages/core/src/animation/Animator.ts
  • packages/core/src/animation/internal/AnimatorStateData.ts
  • tests/src/core/particle/ParticleTextureSheetAnimation.test.ts
  • tests/src/core/particle/RateOverTimeReplay.test.ts
  • tests/src/loader/PrefabResource.test.ts
  • tests/src/loader/RenderTargetLoader.test.ts
✅ Files skipped from review due to trivial changes (3)
  • tests/src/loader/RenderTargetLoader.test.ts
  • tests/src/core/particle/RateOverTimeReplay.test.ts
  • tests/src/loader/PrefabResource.test.ts

GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 mentioned this pull request Jun 1, 2026
3 tasks

@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.

总结

从 fix/shaderlab 摘出 animation/physics/audio/gltf 四个子系统的修复,收窄成独立 PR。physics(teleport-vs-sweep 拆 _syncEntityTransformToNative、Collision normal 方向、PhysX kinematic CCD)、audio(前后台生命周期 + pending playback)、gltf(skinned bounds 用 rootBone 逆世界矩阵、LCA 含 skinned-mesh 节点)这三块方向合理、基本 self-contained。阻塞项集中在 animation,外加两个非 animation 的 scope 问题。

问题

P0

  • physics-physx/package.json / physics-lite/package.json 版本被降级 2.0.0-alpha.330.0.0-experimental-2.0-game.14(已核对 HEAD,physx 第 3 行就是)。这是从 game 分支 cherry-pick 带进来的产物,绝不能合进 dev/2.0。改回 alpha 版本。

P1

  • Animator.ts:241findAnimatorState 的 fallback 返回错误 state 的 playData

    if (layerData.srcPlayData.state === state) return layerData.srcPlayData;
    if (layerData.destPlayData.state === state) return layerData.destPlayData;
    // State exists in controller but not currently playing — return srcPlayData initialized with the state
    return layerData.srcPlayData;   // ← bug

    查询的 state 存在但未在播时,返回的是当前正在播的 srcPlayData,它的 .state 是另一个动画。注释说 "srcPlayData initialized with the state" 与代码不符——根本没初始化成查询的 state。调用方拿去改 .speed/.wrapMode 会静默作用到错误的动画上。应 return null

  • AnimatorStateMachine.removeStatedelete this._statesMap[name] 移到 if (index > -1) 块外,且不再 null defaultState

    拿一个 stale stateindex===-1)调用时,仍会把 map 里同名的新 state删掉。加上 defaultState 类型从 AnimatorState | null 改成非空但 removeState 不再清它 → 悬挂 default 隐患。

  • 自/活跃-dest crossFade 的 no-op 守卫被删除,且无替代测试

    老代码里 if srcPlayData/destPlayData.state === crossState return false 的守卫连同 3 个测试一起删了,没有补充测试说明新行为。如果是有意对齐 Unity,请补一个行为测试或在描述里标 breaking change。

  • 测试质量两条(链路 bug 必须用公开 API 测):

    • Animator.test.ts "find animator state":expect(animatorState).to.eq(currentAnimatorState)findAnimatorState(返回 AnimatorStatePlayData)和 getCurrentAnimatorState(返回 AnimatorState)比,类型不同永远不等。应比 .state
    • crossfade-speed 测试仍 reach 进 animator["_animatorLayersData"][0] 私有字段,而不是走它本该验证的 findAnimatorState 公开 API。把实现结构锁死进测试,且 revert findAnimatorState 修复后测试照样绿——没守住回归。

P2

  • Animator.ts:384-385 新增的 srcPlayData && ... / destPlayData && ... 空守卫是死代码 — 这俩现在 by construction 非空(AnimatorLayerData 急切初始化成 new AnimatorStatePlayData())。是本 PR 新引入的 + 行冗余,删掉。

  • findAnimatorState 上方有两个相邻的 /** */ JSDoc 块,只有第二个生效,删掉第一个。

  • PhysicsScene.test.ts 静默删了约 290 行 raycast/sweep filter-stack 测试。如果这些测试对应的生产代码确实没被 cherry-pick 进来,删除是合理的;但这是一大块静默的覆盖移除,请确认这些测试的被测对象确实不在 dev/2.0,并在描述里说明。(注意同文件里两个既有测试的断言从 not.toHaveBeenCalled 翻成 toHaveBeenCalled——那个是 kinematic-move→setKinematicTarget 修复带来的合理行为变更,已有注释,OK。)

P3

  • Collision.test.ts / DynamicCollider.test.ts 里有一批探索式测试(HYPOTHESIS:/ultrathink probe/R0:-R8:/cocos-parity probe,还有个 console.info 打 ratio 的),像开发期 scratchpad 而非沉淀的回归用例。建议清理成明确断言的回归测试。

注意(不是问题,是确认对了)

physics 的 _syncEntityTransformToNative 拆分是非对称的:Collider 基类给 teleport 语义,DynamicCollider override 把 kinematic-Target 走 move()CharacterController 只 override _teleportToEntityTransform(用 setWorldPosition、忽略 rotation)。每个子类确实不同——这是好模式,不是"对称改动"陷阱。唯一确认点:CharacterController 丢 rotation 是否有意。

自检

findAnimatorState:241physics-physx/package.json:3 版本降级均已对照 HEAD 0375f073f 实际源码确认。我 03:34/09:56 两轮 review 针对的就是当前 HEAD,所列 animation 项均未在 HEAD 修复。作者未对任何 inline 回复,无已解释项需排除。_syncEntityTransformToNative 非对称确认为正确模式、不作为问题列出。

cptbtptpbcptdtptp and others added 4 commits June 11, 2026 15:36
Animator added a clipChangedListener to each AnimatorState's update flag
manager but never removed it. After destroy, the listener kept the state
and the destroyed Animator graph reachable, leaking memory across reloads.
Track the listener on AnimatorStateData and unsubscribe in _reset; call
_reset from _onDestroy.

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

Two related leaks the previous `Record<instanceId, ...>` layout caused:

1. `Animator._curveOwnerPool` and `AnimatorLayerData.curveOwnerPool` were
   keyed by `Component.instanceId`. Destroying the bound component left
   the entry pinned for the Animator's lifetime, so the curve owners
   (and the entity-tree defaults they captured) couldn't be collected.
   `AnimationClipCurveBinding._tempCurveOwner` had the same shape keyed
   by `Entity.instanceId`. Re-key all three by the live object via
   `WeakMap<Component | Entity, ...>` so dead keys release the entry.

2. `Animator._reset` reverted default values for every owner *before*
   removing each state's `clipChangedListener`. If any owner threw on a
   dead target, the listener-remove block was skipped, leaving the
   polyfill listener on a shared `AnimatorState`; the listener closure
   captures `this` (the Animator), which pins the whole entity tree
   until the AnimatorState itself dies.

   Move listener cleanup into the inner loop, wrap each
   `revertDefaultValue` in its own `try/catch`, so listener cleanup
   always runs. (Root-cause fix would be a destroyed-target guard
   inside `AnimationCurveOwner.revertDefaultValue` itself — left as a
   follow-up.)

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

🧹 Nitpick comments (2)
tests/src/core/AnimatorHang.test.ts (1)

7-9: ⚡ Quick win

Consider moving canvas creation inside the describe block.

Creating the canvas at module scope (outside the test suite) can lead to test isolation issues if multiple test files share the same canvas instance. Moving it inside describe ensures each test suite has its own isolated canvas.

♻️ Proposed refactor
-const canvasDOM = document.createElement("canvas");
-canvasDOM.width = 1024;
-canvasDOM.height = 1024;
 describe("Canvas 1024 test", async function () {
+  const canvasDOM = document.createElement("canvas");
+  canvasDOM.width = 1024;
+  canvasDOM.height = 1024;
   const engine = await WebGLEngine.create({ canvas: canvasDOM });
🤖 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/AnimatorHang.test.ts` around lines 7 - 9, Move the
module-scoped canvasDOM creation into the test suite setup so each run gets an
isolated canvas: remove the top-level const canvasDOM =
document.createElement("canvas") and instead create and configure canvasDOM
(width/height) inside the describe(...) block or a beforeEach/beforeAll inside
AnimatorHang.test.ts, and update any references to the canvasDOM variable inside
the tests to use that locally-scoped instance.
packages/core/src/animation/Animator.ts (1)

337-339: ⚡ Quick win

Consider translating the Chinese comment to English.

The comment is accurate and helpful, but for an international codebase, English comments improve accessibility for all contributors.

🌐 Suggested translation
-    // revertDefaultValue 可能在死 target 上抛;listener 清理必须始终发生,否则
-    // polyfill 的 clipChangedListener 会留在共享的 AnimatorState 上,捕获 this(Animator)→Entity 整树。
-    // 用 try/catch 隔离 revert,listener 块紧跟其后无条件执行。
+    // revertDefaultValue may throw on dead targets; listener cleanup must always occur, otherwise
+    // the polyfilled clipChangedListener remains on the shared AnimatorState, capturing this(Animator)→Entity tree.
+    // Use try/catch to isolate revert; listener cleanup block runs unconditionally afterward.
🤖 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/animation/Animator.ts` around lines 337 - 339, Translate
the Chinese comment into English while preserving its meaning and context:
explain that revertDefaultValue may throw on a dead target, listener cleanup
must always run to avoid leaving polyfill's clipChangedListener attached to
shared AnimatorState (which would capture Animator->Entity tree), and that
revert is isolated in a try/catch with the listener cleanup executed
unconditionally immediately after; update the comment near revertDefaultValue,
clipChangedListener, AnimatorState, and Animator references accordingly.
🤖 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/AnimatorHang.test.ts`:
- Around line 10-20: The test is performing async setup directly inside an async
describe and top-level awaits (WebGLEngine.create, resourceManager.load) which
can hide failures and leak resources; move all async setup into a beforeAll or
beforeEach hook where you await WebGLEngine.create, assign engine, scene
(sceneManager.activeScene), rootEntity (createRootEntity), load the GLTFResource
via resourceManager.load, and extract defaultSceneRoot and animator
(defaultSceneRoot.getComponent(Animator)), then change the describe callback to
synchronous and make the it() assertions run after setup; add corresponding
afterAll/afterEach teardown to destroy the engine and release resources to avoid
leaks, and expand the single it("loaded") into meaningful assertions (e.g.,
animator exists and expected animator state) to match the "Canvas 1024 test"
intent.

---

Nitpick comments:
In `@packages/core/src/animation/Animator.ts`:
- Around line 337-339: Translate the Chinese comment into English while
preserving its meaning and context: explain that revertDefaultValue may throw on
a dead target, listener cleanup must always run to avoid leaving polyfill's
clipChangedListener attached to shared AnimatorState (which would capture
Animator->Entity tree), and that revert is isolated in a try/catch with the
listener cleanup executed unconditionally immediately after; update the comment
near revertDefaultValue, clipChangedListener, AnimatorState, and Animator
references accordingly.

In `@tests/src/core/AnimatorHang.test.ts`:
- Around line 7-9: Move the module-scoped canvasDOM creation into the test suite
setup so each run gets an isolated canvas: remove the top-level const canvasDOM
= document.createElement("canvas") and instead create and configure canvasDOM
(width/height) inside the describe(...) block or a beforeEach/beforeAll inside
AnimatorHang.test.ts, and update any references to the canvasDOM variable inside
the tests to use that locally-scoped instance.
🪄 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: c89875eb-08aa-4982-a67c-afda345b933c

📥 Commits

Reviewing files that changed from the base of the PR and between 0375f07 and 09081f1.

📒 Files selected for processing (6)
  • packages/core/src/animation/AnimationClipCurveBinding.ts
  • packages/core/src/animation/Animator.ts
  • packages/core/src/animation/AnimatorStateInstance.ts
  • packages/core/src/animation/internal/AnimatorLayerData.ts
  • packages/core/src/animation/internal/AnimatorStateData.ts
  • tests/src/core/AnimatorHang.test.ts
💤 Files with no reviewable changes (1)
  • packages/core/src/animation/AnimatorStateInstance.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/animation/internal/AnimatorLayerData.ts

Comment on lines +10 to +20
describe("Canvas 1024 test", async function () {
const engine = await WebGLEngine.create({ canvas: canvasDOM });
const scene = engine.sceneManager.activeScene;
const rootEntity = scene.createRootEntity();
rootEntity.addComponent(Camera);
const resource = await engine.resourceManager.load<GLTFResource>(glbResource);
const defaultSceneRoot = resource.defaultSceneRoot;
rootEntity.addChild(defaultSceneRoot);
const animator = defaultSceneRoot.getComponent(Animator);
it("loaded", () => { expect(animator).not.eq(null); });
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Reconsider the async describe pattern and move setup into a test hook.

This test has several structural issues:

  1. Async describe with top-level awaits: The describe callback is async, and setup (engine creation, resource loading) happens at the describe-block top level before any it() tests. If the async setup fails, Vitest may not report the failure clearly as a test failure.

  2. Single trivial assertion: The it("loaded") test only checks animator is not null, but the test name "Canvas 1024 test" suggests broader intent (possibly a hang/regression smoke test?).

  3. No cleanup: The engine and resources are created but never destroyed, which may leak resources across test runs.

♻️ Recommended test structure
-describe("Canvas 1024 test", async function () {
-  const engine = await WebGLEngine.create({ canvas: canvasDOM });
-  const scene = engine.sceneManager.activeScene;
-  const rootEntity = scene.createRootEntity();
-  rootEntity.addComponent(Camera);
-  const resource = await engine.resourceManager.load<GLTFResource>(glbResource);
-  const defaultSceneRoot = resource.defaultSceneRoot;
-  rootEntity.addChild(defaultSceneRoot);
-  const animator = defaultSceneRoot.getComponent(Animator);
-  it("loaded", () => { expect(animator).not.eq(null); });
+describe("Canvas 1024 test", function () {
+  it("loads GLB with animator without hanging", async () => {
+    const canvasDOM = document.createElement("canvas");
+    canvasDOM.width = 1024;
+    canvasDOM.height = 1024;
+    const engine = await WebGLEngine.create({ canvas: canvasDOM });
+    const scene = engine.sceneManager.activeScene;
+    const rootEntity = scene.createRootEntity();
+    rootEntity.addComponent(Camera);
+    const resource = await engine.resourceManager.load<GLTFResource>(glbResource);
+    const defaultSceneRoot = resource.defaultSceneRoot;
+    rootEntity.addChild(defaultSceneRoot);
+    const animator = defaultSceneRoot.getComponent(Animator);
+    expect(animator).not.eq(null);
+    // Cleanup
+    engine.destroy();
+  });
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
describe("Canvas 1024 test", async function () {
const engine = await WebGLEngine.create({ canvas: canvasDOM });
const scene = engine.sceneManager.activeScene;
const rootEntity = scene.createRootEntity();
rootEntity.addComponent(Camera);
const resource = await engine.resourceManager.load<GLTFResource>(glbResource);
const defaultSceneRoot = resource.defaultSceneRoot;
rootEntity.addChild(defaultSceneRoot);
const animator = defaultSceneRoot.getComponent(Animator);
it("loaded", () => { expect(animator).not.eq(null); });
});
describe("Canvas 1024 test", function () {
it("loads GLB with animator without hanging", async () => {
const canvasDOM = document.createElement("canvas");
canvasDOM.width = 1024;
canvasDOM.height = 1024;
const engine = await WebGLEngine.create({ canvas: canvasDOM });
const scene = engine.sceneManager.activeScene;
const rootEntity = scene.createRootEntity();
rootEntity.addComponent(Camera);
const resource = await engine.resourceManager.load<GLTFResource>(glbResource);
const defaultSceneRoot = resource.defaultSceneRoot;
rootEntity.addChild(defaultSceneRoot);
const animator = defaultSceneRoot.getComponent(Animator);
expect(animator).not.eq(null);
// Cleanup
engine.destroy();
});
});
🧰 Tools
🪛 ESLint

[error] 19-19: Replace ·expect(animator).not.eq(null); with ⏎····expect(animator).not.eq(null);⏎·

(prettier/prettier)

🤖 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/AnimatorHang.test.ts` around lines 10 - 20, The test is
performing async setup directly inside an async describe and top-level awaits
(WebGLEngine.create, resourceManager.load) which can hide failures and leak
resources; move all async setup into a beforeAll or beforeEach hook where you
await WebGLEngine.create, assign engine, scene (sceneManager.activeScene),
rootEntity (createRootEntity), load the GLTFResource via resourceManager.load,
and extract defaultSceneRoot and animator
(defaultSceneRoot.getComponent(Animator)), then change the describe callback to
synchronous and make the it() assertions run after setup; add corresponding
afterAll/afterEach teardown to destroy the engine and release resources to avoid
leaks, and expand the single it("loaded") into meaningful assertions (e.g.,
animator exists and expected animator state) to match the "Canvas 1024 test"
intent.

@luzhuang

Copy link
Copy Markdown
Contributor Author

Closing in favor of scoped replacement PRs against the latest dev/2.0: #3024 animation, #3025 physics, #3026 audio, #3027 glTF loader.

@luzhuang luzhuang closed this Jun 11, 2026
@GuoLei1990 GuoLei1990 deleted the fix/loader-animation-physics branch June 29, 2026 09:00
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