Skip to content

fix(animation, loader): 从 #2983 抽离动画与 GLTF 加载器修复#2984

Closed
luzhuang wants to merge 53 commits into
galacean:dev/2.0from
luzhuang:feat/animation-physics
Closed

fix(animation, loader): 从 #2983 抽离动画与 GLTF 加载器修复#2984
luzhuang wants to merge 53 commits into
galacean:dev/2.0from
luzhuang:feat/animation-physics

Conversation

@luzhuang
Copy link
Copy Markdown
Contributor

@luzhuang luzhuang commented May 9, 2026

Summary

#2983 抽离动画 + GLTF 加载器修复,独立 PR 便于 review 与合入 dev/2.0

物理 raycast/sweep 相关修复已抽出独立 PR:#2998

动画 — Per-state PlayData handle

AnimatorStatePlayData 升级为"per-Animator per-state 持久 handle",公开 API。

  • findAnimatorState(name, layerIdx?): AnimatorStatePlayData | null — 返回稳定 handle(lazy create on first access),即使 state 从未播放也能拿到;controller mutation 后通过 update flag guard 先 reset 再返回,避免拿到 stale layerData
  • AnimatorLayerData.statePlayDataMap: Record<string, AnimatorStatePlayData> — 按 state.name 索引,Object.create(null) 初始化,跟同 class 内 animatorStateDataMap/curveOwnerPool 风格一致;getOrCreatePlayData 内做 identity 校验,同名 state 替换时重建 handle
  • playData.speed — getter/setter,内部 _speed: number | undefined;未写入前 live-bind state.speed(编辑器调 asset 仍能传递到未声明 override 的 instance),写入后该 instance 拥有自己的 speed(之后 asset.speed 变化不再传递);想再次跟随 asset 直接写 playData.state.speed
  • playData.state.xxx — 显式访问 shared 资产(删除全部 proxy properties / addStateMachineScript
  • engine-managed runtime 字段全部 _ 前缀_stateData/_playedTime/_clipTime/_playState/_currentEventIndex/_isForward/_offsetFrameTime),stripInternal@internal 字段从 d.ts 剥离,npm 用户公开面只剩 state / speed
  • AnimatorStateDatastate 改 readonly 构造参数 + dispose() 集中 detach clipChangedListener;三处生命周期边界(identity miss / _reset / _onDestroy)都调 dispose() 释放 listener,避免 controller mutation 或 destroy 后 listener 残留在 surviving state 上
  • _updateCrossFadeState — cross fade 阶段使用 playData.speed(修复原来用 state.speed 导致 cross fade 阶段忽略 per-instance override 的 bug)
  • clipless-state safetyfindAnimatorState 不再读 state.clip.length,避免 state 没有 clip 时崩溃
  • out-of-range layerIndex safe no-op_getAnimatorStateInfo 加 bounds check,findAnimatorState 返回 null,play/crossFade no-op,避免 layers[99].stateMachine 抛错
  • zero playSpeed NaN guardplayData.speed=0_updatePlayingStateplayCostTime / playSpeed 不再产生 NaN
  • self/active-dest crossFade no-op — 显式设计:每个 state 一个持久 PlayData,self-cross-fade 会让 src/dest alias,故 alias guard 把这种调用 no-op;完整支持需要拆 persistent override handle / transient track(follow-up)
  • _preparePlay 清理 stale 状态 — 中断 cross fade 时清空 destPlayDatacrossFadeTransition
  • getCurrentAnimatorState — 返回类型 AnimatorState | null(layer 不存在 / 无 state 播放时返回 null)

加载器(动画路径相关)

  • fix(loader): resolve skin rootBone by joint LCA — Scene parser 同步把 top-level scene nodes 挂到 GLTF_ROOT wrapper 下;没有显式 skin.skeleton 时,rootBone 一律通过 joints 的最近公共祖先(LCA)算出来。multi-root spanning joints 自然解析为 wrapper,converged joints 解析为真实 skeleton root(如 Character_Root)。删除之前的 _findSceneRootBone 特化分支
  • fix(animation): normalize single-root clip binding paths — single-root scene 的 clip binding path 前置 sceneRoot 名字,让 binding 永远相对 GLTF_ROOT
  • GLTFParserContext Scene-before-Skin 注释 — 显式说明 LCA 依赖 wrapper 已挂 parent chain 的不变量;禁止 Skin parser await full Scene(避免 _createRenderer 反向请求 Skin 造成循环依赖)

Entity

  • Entity.findByPath — 通用 path 解析 + 同名 sibling backtrack;null parent 不再 crash

测试

  • Animator — per-state handle lazy create / 提前 set speed 生效 / cross fade 持久化 / clone 隔离 / cross fade 阶段使用 playData.speed / out-of-range layerIndex safe / zero-speed transition no NaN / self-crossFade no-op / state-machine self-transition no-op / play 中断 crossFade 清 stale dest / state identity 替换时重建 stateData 和 curve owner / _resetdestroy 都 detach listener 不累积
  • EntityfindByPath 通用 path + 同名 sibling backtrack
  • GLTF — wrapper / skin root(multi-root span / single-skeleton converge)

抽离说明

Breaking changes (2.0)

  • Animator.findAnimatorState() 返回 AnimatorStatePlayData | null(旧返回 AnimatorState
  • Animator.getCurrentAnimatorState() 返回 AnimatorState | null(旧实现 out-of-range layer 会抛错)
  • AnimatorStatePlayData 不再代理 AnimatorState 字段(name/clip/wrapMode/transitions/addStateMachineScript)—— 用 playData.state.xxx
  • AnimatorStatePlayData 移出 internal/,从 @galacean/engine-core 公开导出;stripInternal 把 engine-managed runtime 字段从 d.ts 剥离,公开面只剩 state / speed
  • Animator.findAnimatorState/play/crossFade 对 out-of-range layerIndex 改为 safe no-op(旧实现会抛错)

Test plan

  • tests/src/core/Animator.test.ts — 通过 53 个,含 per-state handle / per-instance speed / alias guard / out-of-range layer / zero-speed transition / state-machine self-transition / play interrupt crossFade clears dest / state identity 替换 / listener 不累积 / destroy detach
  • tests/src/core/Entity.test.ts — 通过 39 个
  • tests/src/loader/GLTFLoader.test.ts — 通过 7 个,含 GLTF wrapper / skin root(multi-root span / single-skeleton converge)
  • tests/src/loader/SceneFormatV2.test.ts — 通过

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds per-instance animator playback handles with speed overrides and lifecycle; updates Animator APIs and playback/crossfade to use per-instance play-data; supports self-name-prefixed Entity.findByPath; records GLTF scene roots for skin resolution; configures PhysX post-filtering to skip initial overlaps; updates runtime PhysX URLs and tests/e2e examples.

Changes

Animation Speed & Entity Path Resolution

Layer / File(s) Summary
Entity Path Resolution
packages/core/src/Entity.ts
findByPath() now attempts normal child-descend lookup, then accepts an optional self-name prefix (returning this for a single segment or searching from the next segment).
AnimatorStatePlayData Class
packages/core/src/animation/AnimatorStatePlayData.ts
New exported class encapsulating per-animator, per-state runtime playback data with per-instance speed override, timing/orientation tracking, and lifecycle methods (resetForPlay, updateOrientation, update).
AnimatorStatePlayData Proxy & Speed Init
packages/core/src/animation/internal/AnimatorStatePlayData.ts
Adds read-only proxy getters (name, clip, wrapMode, transitions), addStateMachineScript() for scripts, and initializes per-instance speed from state.speed.
AnimatorLayerData Per-State Management
packages/core/src/animation/internal/AnimatorLayerData.ts
Changes srcPlayData/destPlayData to nullable slots, adds lazy statePlayDataMap, and introduces getOrCreatePlayData() and promoteDest() replacing switchPlayData().
Animator Public API
packages/core/src/animation/Animator.ts
getCurrentAnimatorState() returns AnimatorState | null; findAnimatorState() returns AnimatorStatePlayData | null and lazy-creates per-instance play-data.
State Preparation & Transitions
packages/core/src/animation/Animator.ts
Playback setup and crossfade logic now use getOrCreatePlayData() and resetForPlay(); crossfade promotion uses promoteDest() and guards against no-op crossfades.
Per-Instance Speed Calculations
packages/core/src/animation/Animator.ts
Playback and crossfade update methods now use playData.speed (per-instance) instead of shared state.speed.
Public Module Exports
packages/core/src/animation/index.ts
Adds AnimatorStatePlayData to public animation exports.
Tests
tests/src/core/Animator.test.ts, tests/src/core/Entity.test.ts
Tests were expanded/updated to validate per-instance speed overrides, handle migration (*.state), self-name-prefixed path resolution, and curve sampling on wrapped roots.

GLTF Scene Root Tracking & Skin Detection

Layer / File(s) Summary
GLTF Load Ordering
packages/loader/src/gltf/parser/GLTFParserContext.ts
Reorders promises so Scene is requested before Texture/Material/Mesh/Skin/Animation to make scene roots available earlier.
Scene Root Registration
packages/loader/src/gltf/parser/GLTFSceneParser.ts
Stores each created sceneRoot into glTFResource._sceneRoots[index] for later use by skin parsing.
Skin Root Bone Resolution
packages/loader/src/gltf/parser/GLTFSkinParser.ts
Adds _findSceneRootBone() to prefer scene-derived rootBone when skeleton is undefined, falling back to skeleton-based detection.
Tests
tests/src/loader/GLTFLoader.test.ts
Adds fixtures/tests for single-root animation curve path and multi-root skin root detection using stored scene roots.

PhysX Query Filtering & Runtime URLs

Layer / File(s) Summary
Query Filter Configuration
packages/physics-physx/src/PhysXPhysicsScene.ts
Adds _pxRaycastFilterData with POST_FILTER flag for raycast/sweep queries.
Post-Filter Logic
packages/physics-physx/src/PhysXPhysicsScene.ts
raycast and _sweepSingle postFilter skip initial-overlap hits by returning eNONE when distance <= 0, otherwise eBLOCK.
Filter Data Usage
packages/physics-physx/src/PhysXPhysicsScene.ts
raycastSingle and sweepSingle now pass _pxRaycastFilterData.
Resource Cleanup
packages/physics-physx/src/PhysXPhysicsScene.ts
Scene destroy() releases _pxRaycastFilterData resources.
Runtime Script URLs
packages/physics-physx/src/PhysXPhysics.ts
Default fallback PhysX runtime script CDN URLs updated.
Tests
tests/src/core/physics/PhysicsScene.test.ts
Updated tests expect queries to skip initial-overlap hits (return false) and verify far-object hits remain detectable.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • galacean/engine#2415: Modifies animation runtime classes and play-data/time logic; closely related to Animator/AnimatorStatePlayData changes.

Suggested reviewers

  • GuoLei1990

Poem

🐰 I bound to play-data, speed in my paw,

Paths that name myself I now gently draw,
Scene roots guide bones through multi-root trees,
Physics skips overlaps with graceful ease,
Hooray — animations hop without a flaw!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title is vague and does not clearly convey the scope or nature of the changes, using generic terms like '从 #2983 抽离' without meaningful detail about what 'animation' and 'loader' fixes entail. Clarify the title to describe the main change more specifically, e.g., 'refactor: extract animation per-state PlayData and loader root-bone resolution from #2983'.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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.

@luzhuang luzhuang force-pushed the feat/animation-physics branch from 7e7591b to 6ab7437 Compare May 9, 2026 08:52
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

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)

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

Cross-fade still uses shared AnimatorState.speed instead of per-instance speed.
Line 774 and Line 775 should use srcPlayData.speed / destPlayData.speed; otherwise per-instance speed changes are ignored during cross-fade.

💡 Proposed fix
-    const srcPlaySpeed = srcState.speed * speed;
-    const dstPlaySpeed = destState.speed * speed;
+    const srcPlaySpeed = srcPlayData.speed * speed;
+    const dstPlaySpeed = destPlayData.speed * speed;
🤖 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 774 - 776, The
cross-fade calculation incorrectly reads shared AnimatorState.speed
(srcState.speed / destState.speed) instead of the per-instance play speeds, so
replace usages that set srcPlaySpeed and dstPlaySpeed to use srcPlayData.speed
and destPlayData.speed respectively and recompute dstPlayDeltaTime from
dstPlaySpeed and deltaTime; update any downstream calculations that use
srcPlaySpeed/dstPlaySpeed (e.g., destPlayDeltaTime) so the per-instance speed
values from srcPlayData and destPlayData are honored during cross-fade.
🤖 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/core/src/animation/Animator.ts`:
- Around line 228-232: The method findAnimatorState currently declares return
type AnimatorStatePlayData but returns null on failure; change its signature to
return AnimatorStatePlayData | null (or | undefined) and update the function
implementation as needed so the type matches the null-returning behavior (refer
to findAnimatorState and its helper _getAnimatorStateInfo and use
_animatorLayersData to determine null cases); then update call sites that assume
non-null (e.g., tests or any code that dereferences .clip) to handle the
nullable return (add null checks or early returns) so no unsafe dereference
occurs.

In `@packages/loader/src/gltf/parser/GLTFSkinParser.ts`:
- Line 42: The single-line declaration combining the nullish coalescing call
makes formatting fail; split it into two lines with proper indentation by first
assigning rootBone from this._findSceneRootBone(context, joints, entities) on
its own line, then if that returns null/undefined assign rootBone to
this._findSkeletonRootBone(joints, entities), ensuring consistent indentation
and a line break between calls so ESLint/Prettier accept it; reference the
rootBone variable and the methods _findSceneRootBone and _findSkeletonRootBone
when applying the change.

In `@packages/physics-physx/src/PhysXPhysicsScene.ts`:
- Around line 53-55: _pxFilterData currently includes QueryFlag.POST_FILTER
though overlap queries (_overlapMultiple) only implement a preFilter and no
postFilter, which can cause binding failures; remove QueryFlag.POST_FILTER from
the shared this._pxFilterData flags initialization (update the call setting
this._pxFilterData.flags in PhysXPhysicsScene to use QueryFlag.STATIC |
QueryFlag.DYNAMIC | QueryFlag.PRE_FILTER only) and ensure any code that expects
postFilter uses a different filterData or explicitly adds POST_FILTER where both
preFilter and postFilter are implemented.

In `@tests/src/core/AnimatorHang.test.ts`:
- Around line 10-20: The test currently performs async setup inside the describe
callback (creating WebGLEngine via WebGLEngine.create, loading GLTFResource,
getting Animator) which runs at collection time; move that async setup into a
beforeAll hook and move cleanup into an afterAll hook that calls
engine.destroy() to free WebGL resources. Specifically, create a beforeAll(async
() => { engine = await WebGLEngine.create(...); scene =
engine.sceneManager.activeScene; rootEntity = scene.createRootEntity();
rootEntity.addComponent(Camera); resource = await
engine.resourceManager.load<GLTFResource>(glbResource); defaultSceneRoot =
resource.defaultSceneRoot; rootEntity.addChild(defaultSceneRoot); animator =
defaultSceneRoot.getComponent(Animator); }) and an afterAll(() =>
engine.destroy()); keep the describe callback synchronous and keep the
it("loaded", ...) assertion referencing the shared animator variable.

---

Outside diff comments:
In `@packages/core/src/animation/Animator.ts`:
- Around line 774-776: The cross-fade calculation incorrectly reads shared
AnimatorState.speed (srcState.speed / destState.speed) instead of the
per-instance play speeds, so replace usages that set srcPlaySpeed and
dstPlaySpeed to use srcPlayData.speed and destPlayData.speed respectively and
recompute dstPlayDeltaTime from dstPlaySpeed and deltaTime; update any
downstream calculations that use srcPlaySpeed/dstPlaySpeed (e.g.,
destPlayDeltaTime) so the per-instance speed values from srcPlayData and
destPlayData are honored during cross-fade.
🪄 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: d027c66a-6998-449f-8191-f2275769b9e7

📥 Commits

Reviewing files that changed from the base of the PR and between e19b764 and 7e7591b.

⛔ Files ignored due to path filters (4)
  • e2e/.dev/physx.release.simd.wasm is excluded by !**/*.wasm
  • e2e/.dev/physx.release.wasm is excluded by !**/*.wasm
  • packages/physics-physx/libs/physx.release.simd.wasm is excluded by !**/*.wasm
  • packages/physics-physx/libs/physx.release.wasm is excluded by !**/*.wasm
📒 Files selected for processing (17)
  • e2e/.dev/physx.release.js
  • e2e/.dev/physx.release.simd.js
  • packages/core/src/Entity.ts
  • packages/core/src/animation/Animator.ts
  • packages/core/src/animation/index.ts
  • packages/core/src/animation/internal/AnimatorStatePlayData.ts
  • packages/loader/src/gltf/parser/GLTFParserContext.ts
  • packages/loader/src/gltf/parser/GLTFSceneParser.ts
  • packages/loader/src/gltf/parser/GLTFSkinParser.ts
  • packages/physics-physx/libs/physx.release.js
  • packages/physics-physx/libs/physx.release.simd.js
  • packages/physics-physx/src/PhysXPhysics.ts
  • packages/physics-physx/src/PhysXPhysicsScene.ts
  • tests/src/core/Animator.test.ts
  • tests/src/core/AnimatorHang.test.ts
  • tests/src/core/Entity.test.ts
  • tests/src/loader/GLTFLoader.test.ts

Comment thread packages/core/src/animation/Animator.ts Outdated
Comment thread packages/loader/src/gltf/parser/GLTFSkinParser.ts Outdated
Comment thread packages/physics-physx/src/PhysXPhysicsScene.ts Outdated
Comment thread tests/src/core/AnimatorHang.test.ts Outdated
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Vitest support an async callback passed directly to describe(), or should async setup be done in beforeAll/afterAll?

💡 Result:

No—Vitest does not support an async callback passed directly to describe as a reliable way to do setup. Reason: Vitest’s describe callback is not part of the test execution lifecycle; it’s used to define/collect the suite. Vitest runs suites by collecting tests first and executing hooks/tests later [1]. In contrast, async setup belongs in suite-level hooks such as beforeAll/afterAll, which explicitly wait for returned promises: beforeAll “If the function returns a promise, Vitest waits until the promise resolve before running tests” [2] and afterAll has the same promise-waiting behavior [2]. If you try to put async work in the describe callback itself, that work will happen during the collection/definition phase (or can be effectively ignored), which can lead to race conditions with the later test execution [1]. Recommendation: do async initialization in beforeAll/afterAll (or in hooks provided by test.extend/fixtures), and keep describe callback synchronous (just defining tests/suites and registering hooks).

Citations:


Move async setup out of describe and add teardown.

The async callback passed to describe at line 10 runs during test collection, not execution, causing potential race conditions. Vitest requires async setup in beforeAll/afterAll hooks. Additionally, add engine.destroy() in afterAll to clean up WebGL resources and prevent leaks across test runs.

💡 Proposed refactor
-import { describe, expect, it } from "vitest";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
@@
-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("Animator hang regression", () => {
+  let engine: WebGLEngine;
+  let animator: Animator | null;
+
+  beforeAll(async () => {
+    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);
+    animator = defaultSceneRoot.getComponent(Animator);
+  });
+
+  afterAll(async () => {
+    await engine.destroy();
+  });
+
+  it("loaded", () => {
+    expect(animator).not.eq(null);
+  });
 });
📝 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); });
});
import { afterAll, beforeAll, describe, expect, it } from "vitest";
describe("Animator hang regression", () => {
let engine: WebGLEngine;
let animator: Animator | null;
beforeAll(async () => {
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);
animator = defaultSceneRoot.getComponent(Animator);
});
afterAll(async () => {
await engine.destroy();
});
it("loaded", () => {
expect(animator).not.eq(null);
});
});
🧰 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 currently
performs async setup inside the describe callback (creating WebGLEngine via
WebGLEngine.create, loading GLTFResource, getting Animator) which runs at
collection time; move that async setup into a beforeAll hook and move cleanup
into an afterAll hook that calls engine.destroy() to free WebGL resources.
Specifically, create a beforeAll(async () => { engine = await
WebGLEngine.create(...); scene = engine.sceneManager.activeScene; rootEntity =
scene.createRootEntity(); rootEntity.addComponent(Camera); resource = await
engine.resourceManager.load<GLTFResource>(glbResource); defaultSceneRoot =
resource.defaultSceneRoot; rootEntity.addChild(defaultSceneRoot); animator =
defaultSceneRoot.getComponent(Animator); }) and an afterAll(() =>
engine.destroy()); keep the describe callback synchronous and keep the
it("loaded", ...) assertion referencing the shared animator variable.

GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 mentioned this pull request May 9, 2026
3 tasks
@luzhuang
Copy link
Copy Markdown
Contributor Author

luzhuang commented May 9, 2026

@GuoLei1990 @cptbtptpbcptdtptp 已根据 review 重做,把 PlayData 升级为 per-Animator per-state 持久 handle,覆盖你们提的所有问题。

核心 lifecycle 改造:

  • AnimatorLayerData.statePlayDataMap 缓存每个 state 的 PlayData(lazy create)
  • srcPlayData / destPlayData 退化为 nullable 引用
  • findAnimatorState 通过 getOrCreatePlayData 拿 handle——即便 state 从未播放也能拿,再也不会返回错的 srcPlayData
  • switchPlayData()promoteDest()(cross fade 完成时单向赋值,不再 swap)

override 模型(codex 建议的 live binding):

  • playData.speed 是 getter/setter,内部 _speedOverride: number | undefined
  • 未 override 时读 state.speed(live binding,asset 改了也跟着变)
  • clearSpeedOverride() 恢复继承
  • resetForPlay() 只重置 runtime 字段,不动 override,所以跨 transition 持久

cross fade speed bug:

  • _updateCrossFadeState 改用 playData.speed(旧用 state.speed,cross fade 阶段忽略 per-instance override)

proxy 清理:

  • 删掉 name/clip/wrapMode/transitions/addStateMachineScript 全部 proxy
  • playData.state.xxx 显式访问 shared 资产
  • 同步把 AnimatorStatePlayData 移出 internal/(现在是公开 API)

附带修复:

  • _findSceneRootBone walk-up 早停(standalone repro 验证之前永远返回 null)
  • Entity.findByPath 优先子节点查找(TDD 红绿),self-name 作 fallback
  • raycast initial overlap 测试 + 修复 PR 上一个 commit 引入的 wasm crash(POST_FILTER 在 shared filter data 上让 overlap 查询炸了,拆成独立 _pxRaycastFilterData
  • 4 个旧物理测试断言更新为新行为
  • clipless-state 安全:findAnimatorState 不再读 state.clip.length

测试: Animator 40/40,PhysicsScene 49/49 都过。请 review。

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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)

1410-1453: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Cross-fading to the currently-playing state aliases srcPlayData and destPlayData to the same instance, corrupting the cross-fade calculation.

layerData.getOrCreatePlayData(crossState) is keyed by AnimatorState, so when crossState === layerData.srcPlayData?.state (e.g., user calls animator.crossFade("CurrentState", ...) or crossFadeInFixedDuration with the current state name), the lookup returns the same instance that is currently srcPlayData. resetForPlay(...) then overwrites that shared instance's runtime fields (playedTime, clipTime, playState, currentEventIndex, isForward, offsetFrameTime), and line 1426 assigns it back as destPlayData — leaving srcPlayData === destPlayData.

Downstream in _updateCrossFadeState (lines 785–786), both srcPlayData.update(srcPlayCostTime) and destPlayData.update(dstPlayCostTime) then mutate the same fields, causing the source side to accumulate both cost times instead of just its own. This corrupts crossWeight = Math.abs(destPlayData.playedTime) / transitionDuration at line 795. On completion, promoteDest() (line 1061) becomes a no-op since src and dest are the same object, and destPlayData = null then loses the reference.

In the previous implementation srcPlayData and destPlayData were separate AnimatorStatePlayData instances managed by switchPlayData(), so this scenario was safe. There is no canTransitionToSelf-style guard on the manual crossFade* path (the _checkNoExitTimeTransitions guard with excludeDestState only applies to stateMachine transitions).

Suggested fix: Early-return when crossing to self:

  private _prepareCrossFadeByTransition(transition: AnimatorStateTransition, layerIndex: number): boolean {
    const crossState = transition.destinationState;

    if (!crossState) {
      return false;
    }
    if (!crossState.clip) {
      Logger.warn(`The state named ${crossState.name} has no AnimationClip data.`);
      return false;
    }

    const animatorLayerData = this._getAnimatorLayerData(layerIndex);
+   // Cross-fading to the currently-playing state would alias src/dest to the same
+   // AnimatorStatePlayData instance (Map keyed by AnimatorState). Skip until
+   // canTransitionToSelf semantics are supported.
+   if (
+     animatorLayerData.layerState === LayerState.Playing &&
+     animatorLayerData.srcPlayData?.state === crossState
+   ) {
+     return false;
+   }
    const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex);
🤖 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 1410 - 1453, The bug is
that _prepareCrossFadeByTransition can alias srcPlayData and destPlayData when
crossState equals the currently playing state, corrupting cross-fade math; fix
it in _prepareCrossFadeByTransition by detecting if
animatorLayerData.srcPlayData?.state === crossState (or
animatorLayerData.srcPlayData === destPlayData after getOrCreatePlayData) and
early-return false (or abort the cross-fade) before calling resetForPlay and
assigning destPlayData, so srcPlayData and destPlayData remain distinct; update
any related state transitions (layerState, crossFadeTransition) only when a true
cross-fade will occur.
🤖 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/Animator.test.ts`:
- Around line 1316-1418: Add a regression test that exercises crossFade to the
currently-playing state to ensure src and dest PlayData are not aliased: create
a test in Animator.test.ts that calls animator.play("Walk"), advances a frame
(increment engine.time._frameCount and call animator.update), capture the
layer's srcPlayData (via animator._animatorLayersData[0] or
_getAnimatorLayerData(0)), then call animator.crossFade("Walk", 0.3) and advance
another frame/update, and assert that if layerData.destPlayData exists it is not
equal to layerData.srcPlayData and that layerData.srcPlayData remains === the
original captured instance; this locks down behavior around
getOrCreatePlayData/_prepareCrossFadeByTransition when dest equals the currently
playing AnimatorState.

In `@tests/src/core/physics/PhysicsScene.test.ts`:
- Around line 472-478: The test's ray origin is currently at (3,3,3) which lies
on the boundary for boxShape.size = new Vector3(6,6,6); update the test so the
origin is unambiguously inside the collider (e.g., use new Ray(new
Vector3(0,0,0), ...) or new Vector3(2.9,2.9,2.9)) before calling
physicsScene.raycast(ray, outHitResult) so that the assertions about skipped
initial overlap (expect(...).to.eq(false), outHitResult.distance === 0,
outHitResult.entity === null) reliably exercise the inside-origin case; adjust
the Ray construction in this test block accordingly.

---

Outside diff comments:
In `@packages/core/src/animation/Animator.ts`:
- Around line 1410-1453: The bug is that _prepareCrossFadeByTransition can alias
srcPlayData and destPlayData when crossState equals the currently playing state,
corrupting cross-fade math; fix it in _prepareCrossFadeByTransition by detecting
if animatorLayerData.srcPlayData?.state === crossState (or
animatorLayerData.srcPlayData === destPlayData after getOrCreatePlayData) and
early-return false (or abort the cross-fade) before calling resetForPlay and
assigning destPlayData, so srcPlayData and destPlayData remain distinct; update
any related state transitions (layerState, crossFadeTransition) only when a true
cross-fade will occur.
🪄 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: 863f88a5-ff41-42a7-a1b2-4962deff4c0b

📥 Commits

Reviewing files that changed from the base of the PR and between 7e7591b and 28edd38.

📒 Files selected for processing (11)
  • packages/core/src/Entity.ts
  • packages/core/src/animation/Animator.ts
  • packages/core/src/animation/AnimatorStatePlayData.ts
  • packages/core/src/animation/index.ts
  • packages/core/src/animation/internal/AnimatorLayerData.ts
  • packages/core/src/animation/internal/AnimatorStatePlayData.ts
  • packages/loader/src/gltf/parser/GLTFSkinParser.ts
  • packages/physics-physx/src/PhysXPhysicsScene.ts
  • tests/src/core/Animator.test.ts
  • tests/src/core/Entity.test.ts
  • tests/src/core/physics/PhysicsScene.test.ts
💤 Files with no reviewable changes (1)
  • packages/core/src/animation/internal/AnimatorStatePlayData.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/core/src/animation/index.ts
  • tests/src/core/Entity.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/loader/src/gltf/parser/GLTFSkinParser.ts
  • packages/core/src/Entity.ts
  • packages/physics-physx/src/PhysXPhysicsScene.ts

Comment thread tests/src/core/Animator.test.ts
Comment thread tests/src/core/physics/PhysicsScene.test.ts Outdated
luzhuang added a commit to luzhuang/engine that referenced this pull request May 9, 2026
PR galacean#2984 changed Animator.findAnimatorState() to return
AnimatorStatePlayData instead of AnimatorState. Unit tests were already
updated to access shared-asset members via `.state.xxx`; e2e cases were
missed and would TypeError at runtime when playwright loaded them.

Convert each shared-asset access on findAnimatorState() results:
- .clip -> .state.clip (animator-event, animator-additive)
- .addTransition / .addExitTransition / ._getDuration -> .state.xxx
  (animator-stateMachine)
- .addStateMachineScript -> .state.addStateMachineScript
  (animator-stateMachineScript)

.speed reads/writes are intentionally preserved on the per-instance
handle (the whole point of the API change).
@codecov
Copy link
Copy Markdown

codecov Bot commented May 9, 2026

Codecov Report

❌ Patch coverage is 85.20548% with 54 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.40%. Comparing base (25ba6eb) to head (4457f62).

Files with missing lines Patch % Lines
e2e/case/animator-stateMachine.ts 0.00% 27 Missing ⚠️
packages/loader/src/gltf/parser/GLTFSkinParser.ts 25.00% 9 Missing ⚠️
e2e/case/animator-additive.ts 0.00% 5 Missing ⚠️
e2e/case/animator-play-backwards.ts 0.00% 5 Missing ⚠️
e2e/case/animator-event.ts 0.00% 4 Missing ⚠️
e2e/case/animator-stateMachineScript.ts 0.00% 4 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #2984      +/-   ##
===========================================
+ Coverage    78.14%   78.40%   +0.25%     
===========================================
  Files          900      900              
  Lines        99255    99419     +164     
  Branches     10213    10232      +19     
===========================================
+ Hits         77563    77948     +385     
+ Misses       21521    21300     -221     
  Partials       171      171              
Flag Coverage Δ
unittests 78.40% <85.20%> (+0.25%) ⬆️

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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)
e2e/case/animator-stateMachine.ts (1)

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

Guard nullable findAnimatorState() results before .state dereference.

At lines 57–59, findAnimatorState() results are used without null checks and subsequently dereferenced with .state downstream. If any of these state names (idle/walk/run) are not found, the code will crash at runtime.

Add an early guard to fail fast with a clear error message:

✅ Minimal fix pattern
       const idleState = animator.findAnimatorState("idle");
       const walkState = animator.findAnimatorState("walk");
       const runState = animator.findAnimatorState("run");
+      if (!idleState || !walkState || !runState) {
+        throw new Error("Required animator states (idle/walk/run) were not found.");
+      }
🤖 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 `@e2e/case/animator-stateMachine.ts` around lines 57 - 59, The code calls
animator.findAnimatorState("idle"/"walk"/"run") and later dereferences .state
without checking for null, so add an early guard after obtaining idleState,
walkState, and runState to verify none are null/undefined and throw or assert
with a clear message (e.g., "Animator state 'idle' not found") before any usage
of .state; reference the animator.findAnimatorState calls and the
idleState/walkState/runState variables when adding the checks so the failure is
fast and the error message identifies the missing 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 `@e2e/case/animator-stateMachine.ts`:
- Around line 57-59: The code calls
animator.findAnimatorState("idle"/"walk"/"run") and later dereferences .state
without checking for null, so add an early guard after obtaining idleState,
walkState, and runState to verify none are null/undefined and throw or assert
with a clear message (e.g., "Animator state 'idle' not found") before any usage
of .state; reference the animator.findAnimatorState calls and the
idleState/walkState/runState variables when adding the checks so the failure is
fast and the error message identifies the missing state.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 707726dd-fb6f-4d45-8ce6-aa21b99d5950

📥 Commits

Reviewing files that changed from the base of the PR and between 28edd38 and d2bfaea.

📒 Files selected for processing (4)
  • e2e/case/animator-additive.ts
  • e2e/case/animator-event.ts
  • e2e/case/animator-stateMachine.ts
  • e2e/case/animator-stateMachineScript.ts

@luzhuang
Copy link
Copy Markdown
Contributor Author

luzhuang commented May 9, 2026

Round-2 review 修复:

P1:crossFade-to-self alias bug (9f2992b)
statePlayDataMap 一个 state 一个 PlayData,crossFade 到当前 src/dest 的 state 会让 destPlayData 跟 srcPlayData 同对象 → resetForPlay 清掉 src runtime + _updateCrossFadeState 对同一对象 update 两次。本 PR 显式禁止 self-crossFade(src 或 dest 任一匹配即 no-op)。完整支持 self-crossFade 需要拆 persistent override 和 transient src/dest track,超出本 PR 范围。补 2 项回归测试(current state / fading dest state)。

P2:_changedOrientationresetForPlay 重置(9f2992b,同上 commit)
runtime flag 应跟其他 runtime 字段一起重置,避免再入 state 时携带旧方向 flag。

P2:Entity.findByPath self-prefix fallback 收紧(3ea4dd47
当存在同名 child 但深路径 miss 时,不应 fallback 到 self-prefix——用户明确想进 child。root.findByPath('A/B') 当 root 名字 'A' 且有 child 'A' 但没 'A/B' → 现在返回 null(之前会 fallback 到 self-prefix 返回 root/B)。补 1 项回归测试。

P2:PhysX inside-collider raycast 边界点改严格 inside(7785231
ray 起点从 (3,3,3)(box (6,6,6) 角点)改为 (2.9, 2.9, 2.9)(严格 inside),消除 PhysX edge tolerance 影响。

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 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/core/src/animation/Animator.ts`:
- Around line 625-626: When computing playSpeed/playDeltaTime (using
srcPlayData.speed and this.speed) guard against playSpeed === 0 to avoid 0/0 →
NaN: if playSpeed is zero, treat playDeltaTime as 0 and any subsequent division
like playCostTime / playSpeed should short‑circuit to 0 (or skip applying
remaining time) instead of performing the division. Update the logic around the
symbols playSpeed, playDeltaTime and any use of playCostTime / playSpeed (also
the same pattern around the block at the other occurrence referenced by the
reviewer) to check for playSpeed === 0 and handle that branch explicitly.
- Around line 1428-1431: Reformat the if guard around
animatorLayerData.srcPlayData?.state and animatorLayerData.destPlayData?.state
comparing to crossState so Prettier stops failing: update the condition in
Animator.ts (the if that checks animatorLayerData.srcPlayData?.state ===
crossState || animatorLayerData.destPlayData?.state === crossState) to a
single-line expression or to a Prettier-friendly multiline layout (e.g., put the
|| at the start of the second line or align each comparison on its own line)
while keeping the same logic and references to animatorLayerData, srcPlayData,
destPlayData, and crossState.
- Around line 225-228: The method findAnimatorState should return null for
out-of-range layerIndex values instead of calling _getAnimatorStateInfo which
assumes a valid index; add an early bounds check (e.g., if layerIndex >= 0 and
(layerIndex < this._layers.length) is false) and return null before calling
_getAnimatorStateInfo, or alternatively update _getAnimatorStateInfo to handle
invalid layer indexes safely; ensure you reference the same layer storage
(this._layers) and still call _getAnimatorLayerData(foundLayer) only after a
validated foundLayer.
🪄 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: 18b4e6a1-07fc-425d-b9a0-8fd6878018b6

📥 Commits

Reviewing files that changed from the base of the PR and between d2bfaea and 7785231.

📒 Files selected for processing (6)
  • packages/core/src/Entity.ts
  • packages/core/src/animation/Animator.ts
  • packages/core/src/animation/AnimatorStatePlayData.ts
  • tests/src/core/Animator.test.ts
  • tests/src/core/Entity.test.ts
  • tests/src/core/physics/PhysicsScene.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/core/src/Entity.ts
  • tests/src/core/Entity.test.ts
  • packages/core/src/animation/AnimatorStatePlayData.ts
  • tests/src/core/physics/PhysicsScene.test.ts

Comment thread packages/core/src/animation/Animator.ts
Comment thread packages/core/src/animation/Animator.ts
Comment thread packages/core/src/animation/Animator.ts Outdated
luzhuang added a commit to luzhuang/engine that referenced this pull request May 9, 2026
PR galacean#2984 changed findAnimatorState to return AnimatorStatePlayData | null.
Update both EN and ZH docs to reflect:
- Per-instance speed override (playData.speed)
- Shared asset access (playData.state.xxx)
- Nullable return guard
- clearSpeedOverride() to resume live binding to state.speed
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label May 9, 2026
@luzhuang
Copy link
Copy Markdown
Contributor Author

Round-4 review 处理:

P1 已修

Entity.findByPath self-prefix fallback null-parent crash (da0d6a3)
独立验证:root 名 "root"、无 children、无 parent,调 findByPath("root/missing") → fallback 调 _findChildByName(this, 0, splits, 1) → 内部 backtrack 到 _findChildByName(entity.parent, ..., paths, 0)null._children crash。
修法:用 splits.slice(1) + pathIndex=0,让递归保持在 entity 子树内,找不到时干净返回 null。补 2 项 regression(detached root + subtree 不外溢)。

_getAnimatorStateInfo 越界 layerIndex 抛错 (a235c60)
修:在 _getAnimatorStateInfo 集中 bounds check,findAnimatorState / play / crossFade 一次覆盖。补 2 项 regression(findAnimatorState 越界 null + play/crossFade 越界 no-op)。

P2 已修

zero-speed 0/0 → NaN (4c84f13)
playData.speed = 0playSpeed = 0playCostTime / playSpeed → NaN,destination state 拿不到 remaining。修法:speed === 0 ? deltaTime : deltaTime - costTime / speed。补 paused-state 转 destination 测试。

_pxRaycastFilterData_pxRaycastSweepFilterData (2afe65d)
名字反映同时被 raycast 和 sweep 用。

GLTFParserContext Scene-before-Skin 注释 (123c521)
显式说明 SkinParser 同步读 _sceneRoots,依赖 SceneParser 先 request。

e2e camelCase (5f7a854)
RunToWalkTransitionrunToWalkTransition

不修(设计决策)

  • findAnimatorState 命名 / breaking change:PR 已声明 2.0 breaking,文档已同步。不拆 API。
  • AnimatorStatePlayData public surface 收窄:留 follow-up,需要专门的 interface/wrapper 设计。
  • 架构 A1(persistent handle vs transient track 拆分):codex 也认同短期接受 no-op;follow-up issue 跟踪。Self-crossFade no-op 行为已有显式测试锁定。

CI 全绿(lint / build × 3 / codecov / e2e × 4)。

@luzhuang
Copy link
Copy Markdown
Contributor Author

关于 lazy getOrCreatePlayData 的分配 / GC / 帧成本说明

针对 round-12 AnimatorLayerDataswitchPlayData (swap 2 个固定 slot) 改为 getOrCreatePlayData + promoteDest (per-state 持久 handle + null) 之后,transition crossFade 是否会频繁动态创建 PlayData / 触发 GC / 掉帧 的第一性原理验证。

1. 分配点:全代码库只有 1 处

new AnimatorStatePlayData(state) 仅出现在 AnimatorLayerData.getOrCreatePlayData 的 lazy-create 分支:

getOrCreatePlayData(state) {
  let playData = this.statePlayDataMap.get(state);
  if (!playData) {
    playData = new AnimatorStatePlayData(state);  // ← 唯一分配点
    this.statePlayDataMap.set(state, playData);
  }
  return playData;
}

getOrCreatePlayData 共 4 个调用点,共享同一个 per-layer statePlayDataMap

  • findAnimatorState(name) — 用户主动查询
  • _preparePlayplay() 入口
  • _prepareCrossFadeByTransition包含 transition 自动触发的 crossFade
  • 任意路径首次访问某 state 分配 1 次,之后所有路径访问同一 state 全走 map.get 复用,0 分配

2. 量化单次分配成本

AnimatorStatePlayData 构造函数只做一次引用赋值(其余字段都是 class field initializer 赋的基本类型,object shape monomorphic,V8 fast path):

constructor(state: AnimatorState) {
  this.state = state;
}
  • new (new space 分配) ~50–100ns
  • map.set ~150–300ns
  • 单次总成本 ≈ 200–400ns

3. 帧预算占比 (60 fps, 一帧 16.67ms)

场景 分配次数 占帧预算
单 transition 暖 1 个新 state 1 × ~400ns 0.0024%
同帧暖 10 个新 state 10 × ~400ns 0.024%
同帧暖 100 个 state(不现实) 100 × ~400ns 0.24%
暖好后任意 transition crossFade 0 0%

实际 transition 有方向,一帧最多切到 1 个目标 state;即使极端构造同帧暖 100 个 state 也只占帧预算 0.24%,不可能掉帧

4. 每帧 hot path 零分配

crossFade 进行中 _updateCrossFadeState (Animator.ts:785-) 每帧执行的操作:

srcPlayData.update(srcPlayCostTime);     // 累加 _playedTime
destPlayData.update(dstPlayCostTime);
_evaluatePlayingState(srcPlayData, ...); // 读 curve 写 component
_evaluatePlayingState(destPlayData, ...);

全部操作已有 PlayData 引用,0 PlayData 分配promoteDest() 也只是 src = dest; dest = null 两次引用赋值,0 分配。

5. GC 不会被触发

  • PlayData 被 map.set 立即引用 → 下次 minor GC 时对 map 可达 → V8 晋升到 old space(O(1))
  • old space GC 触发条件是 heap 压力 (MB 级),N 个 ~80 字节对象 (N=100 也只 8KB) 远低于阈值
  • 暖机期间 N 次分配本身也不触发 minor GC:new space 通常 1–8MB 容量,PlayData 总量 N×80B 完全在容量内

6. vs 旧 swap 模式

阶段 新 promote 模式 旧 swap 模式
第一次 play(X) new PlayData(X) + map.set reset srcSlot 字段
后续 play(X) map.get(X) 命中复用,0 分配 reset srcSlot 字段
transition crossFade(X→Y) 首次 Y new PlayData(Y) + map.set reset destSlot 字段
后续 transition crossFade map.get 复用,0 分配 reset destSlot 字段
每帧 update / evaluate 0 分配 0 分配
crossFade 完成 promoteDest 赋值,0 分配 switchPlayData 赋值,0 分配

暖机后两者完全等价。新模式的额外内存代价是每个被访问过的 state 一个轻量对象 (N × ~80B),换取 _speedOverride 等 per-instance 持久能力。

结论

  • lazy 暖机:每个 state 一生只 new 一次,~200–400ns
  • 稳态 0 分配:暖好后 transition crossFade 全走 map.get 复用
  • 不可能掉帧:最坏极端 100 个 state 同帧暖机占帧预算 0.24%
  • 不触发 GC spike:长寿对象进 old space,N 个轻量对象远低于 GC 阈值

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

@luzhuang
Copy link
Copy Markdown
Contributor Author

@GuoLei1990 11:51Z 的总结提到「目前无新问题」,跟 11:23Z 的 CHANGES_REQUESTED 状态似乎不同步。简要确认下 11:23Z 唯一的 P2(AnimatorHang.test.ts async describe)已经不适用:

1. AnimatorHang.test.ts 不在本 PR 范围

本 PR 分支从来没有这个文件:

$ git log dev/2.0..HEAD -- tests/src/core/AnimatorHang.test.ts
# (empty, 本 PR 分支无任何提交触碰此路径)

$ find tests/ -name "AnimatorHang.test.ts"
# (empty, 工作树不存在)

该文件唯一来源是 f5be42400 fix: raycast and clone (zhanyingwei.zyw),存在于 feat/51game / fix/shaderlab / fix/transform-reparent-dirty-propagation 分支,不在 feat/animation-physics

2. 本 PR 的 Animator.test.ts 不用 async describe

// tests/src/core/Animator.test.ts:40
describe("Animator test", function () {   // ← 同步函数,不是 async
  ...
  beforeAll(async function () { ... });   // ← async 在 beforeAll 内,Vitest 正确 await
});

53 个 Animator 测试在本 PR HEAD (e5b881c) 全部通过,没有"静默跳过/时序不确定"。

如果可以,请 dismiss 11:23Z 的 CHANGES_REQUESTED,本 PR 已 mergeable (冲突已解,CI 待 reverify)。

GuoLei1990

This comment was marked as outdated.

@luzhuang
Copy link
Copy Markdown
Contributor Author

@GuoLei1990 12:15Z 评论里说的 Animator.test.ts async describe 问题,本地第一性原理核实是误报

1. tests/src/core/Animator.test.ts:40 的 describe callback 是同步函数

// tests/src/core/Animator.test.ts:40-55
describe("Animator test", function () {        // ← sync function declaration
  let animator: Animator;
  let resource: GLTFResource;
  let engine: WebGLEngine;

  beforeAll(async function () {                 // ← async 只出现在 beforeAll 内
    engine = await WebGLEngine.create({ canvas: canvasDOM });
    ...
  });

Vitest 不 await describe 返回值这点是事实,但前提是 describe callback 本身是 async(返回 Promise)才会触发"await 之后的 it() 被 collect 阶段跳过"。本文件 describe callback 是 sync function () {},所有 53 个 it() 都在顶层同步注册,async 只在 beforeAll 里——这是 Vitest 官方推荐的写法,runner 会正确 await beforeAll

2. 实测 53/53 通过,零 silent skip

$ npx vitest run tests/src/core/Animator.test.ts
✓ src/core/Animator.test.ts (53 tests) 93ms
Test Files  1 passed (1)
     Tests  53 passed (53)

collect 阶段就识别到 53 个 case,全部执行通过。如果存在 async describe 导致的注册时序问题,应该会出现 Tests N passed(N < 53)或 collected: 0,但实际数字对得上。

3. 11:23Z 提的 AnimatorHang.test.ts 和 12:15Z 提的 Animator.test.ts 不是同一文件

前者本 PR 分支不存在(前评论已证),后者存在但无 async describe。两次 review 的 P2 都不成立。

请 dismiss 之前的 CHANGES_REQUESTED,PR 当前 mergeStateStatus: BLOCKED 仅由 review decision 阻塞,无其它问题。

GuoLei1990

This comment was marked as outdated.

@luzhuang
Copy link
Copy Markdown
Contributor Author

@GuoLei1990 06:23Z review 的 5 个 issue 第一性原理逐项核实,全部不存在于当前 HEAD (e5b881c),看起来 CodeRabbit 在读 stale snapshot:


[P1] cross-fade 仍用 srcState.speed / destState.speed —— ❌ FALSE

packages/core/src/animation/Animator.ts:793-794 当前:

const srcPlaySpeed = srcPlayData.speed * speed;    // ✅ playData.speed
const dstPlaySpeed = destPlayData.speed * speed;   // ✅ playData.speed

全文件 grep "srcState\.speed\|destState\.speed" 零命中。PR 描述里你自己列的「cross fade 阶段使用 playData.speed」已落地。


[P2] findAnimatorState 返回类型应为 AnimatorStatePlayData | null —— ❌ 早已是

Animator.ts:222:

findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData | null {

你 11:23Z 的「已关闭清单」第二行就确认了这条已修。


[P2] _speedOverride 在 controller mutation 后是否保留 —— ⚠️ 字段名错 + 是 by design

  • 字段名是 _speed,不是 _speedOverrideAnimatorStatePlayData.ts:41)。
  • _controllerUpdateFlag 只在 addLayer / removeLayer / clearLayers 这三个结构性变更时 dispatch(AnimatorController.ts:116/127/138);添加 state、改参数都不会触发。
  • 结构性 mutation 后 _reset() 清空整个 _animatorLayersData(含 statePlayDataMap),下次 getOrCreatePlayData 创建新 handle,_speed === undefined → live-bind 回 state.speed
  • 这是合理设计:layer 结构变了,per-instance binding 本身就失去依附对象。Unity 也不允许 runtime 重写 controller layer 结构。PR 描述里已说明。

[P2] AnimatorStatePlayData 公开后 runtime 字段暴露 —— ✅ 文档已写

AnimatorStatePlayData.ts:18-20 class doc:

All other fields are engine-managed runtime state and are underscore-prefixed to
mark them as implementation detail; mutating them from user code will corrupt
Animator invariants.

你要求加的那行已经在那里。


[P3] GLTFSkinParser.ts:42 _findSceneRootBone 单行链式 nullish coalescing —— ❌ 该函数已删除

$ git log --all -S "_findSceneRootBone" -- packages/loader/src/gltf/parser/GLTFSkinParser.ts
0623cb93a refactor(loader): drop _findSceneRootBone, resolve skin rootBone via LCA only

当前文件 90 行,只有 _findSkeletonRootBoneByLCA:42 是注释行("Resolve rootBone from the joints' lowest common ancestor."),没有 nullish coalescing 链式调用。


阻塞状态

  • 11:23Z CHANGES_REQUESTED 的唯一 P2(AnimatorHang.test.ts async describe)—— 文件不在本 PR 分支。
  • 12:15Z COMMENTED 的 P2(Animator.test.ts async describe)—— describe 是 sync。
  • 06:23Z COMMENTED 的 5 个 issue —— 上述全部 stale。

PR 当前 mergeable: MERGEABLE + mergeStateStatus: BLOCKED,阻塞仅由 11:23Z CHANGES_REQUESTED 状态本身造成,无实际未修问题。请 dismiss 该 review 解除阻塞。

如果对 CodeRabbit 报告有具体疑问,建议你在 e5b881c5egit checkout 后实测,而不是只看 PR diff 摘要——CodeRabbit 多次报告的代码位置在当前 HEAD 都不存在。

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

Copy link
Copy Markdown
Member

@GuoLei1990 GuoLei1990 left a comment

Choose a reason for hiding this comment

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

已关闭问题清单

前几轮 review 提出的所有关键问题已全部关闭:

问题 状态
findAnimatorState 返回类型破坏性变更 ✅ 已修复:返回 AnimatorStatePlayData | null
cross-fade 用 state.speed 忽略 per-instance override ✅ 已修复:改用 srcPlayData.speed
out-of-range layerIndex 越界 ✅ 已修复:safe no-op
zero playSpeed NaN ✅ 已修复
self-crossFade alias guard ✅ 已修复
GLTF skin rootBone LCA ✅ 已修复
PhysX POST_FILTER 污染 overlap ✅ 已修复:独立 _pxRaycastSweepFilterData
PhysX query callback 复用 ✅ 已修复

总结

#2983 抽离的动画 + GLTF + 物理三方向修复,方向正确,53 个 Animator 测试、GLTF wrapper/skin root 测试、PhysX initial overlap + reentrancy 测试全覆盖。breaking changes 已在 PR 描述中完整列出,文档同步更新。整体设计质量高。

问题

[P2] AnimatorStatePlayData 缺少恢复 live-bind 的公开 API

playData.speed = x 写入后 _speedOverride 被设置,此后 state.speed(asset 侧)的变化不再传递到该 instance。若用户想重新跟随 asset,文档建议 playData.speed = playData.state.speed,但这是"设置一次相同的值"而非"清除 override"——之后 asset.speed 再变化仍不传递。

建议提供 clearSpeedOverride() 或允许 speed = undefined 清回 live-bind 状态,并在 AnimatorStatePlayData 的 JSDoc 中说明这两种模式的切换方式。

[P2] Animator.ts — 文档中 per-state PlayData handle 的生命周期边界未说明

entity disable→enable 一轮后 _reset() 会被调用,per-instance speed 等 override 会丢失。建议在 AnimatorStatePlayData 的类注释中明确:"handle 在 Animator 的 controller 未更换时稳定持有;entity disable/enable 后 override 状态被重置",避免用户误以为 override 在整个对象生命周期内持久。

luzhuang added 2 commits May 12, 2026 20:08
AnimatorState.speed is part of the shared AnimatorController asset.
Modifying it at runtime pollutes all Animator instances sharing the
same controller, causing animation speed corruption after cloning.

- Add speed field to AnimatorStatePlayData, initialized from AnimatorState.speed on reset
- Add proxy properties (name/clip/wrapMode/transitions/addStateMachineScript)
- Change speed calculation to playData.speed * animator.speed
- findAnimatorState now returns per-instance AnimatorStatePlayData
- Export AnimatorStatePlayData for consumer code
luzhuang added 22 commits May 12, 2026 20:08
The earlier transition-out-of-speed-0 test went through
_updateCrossFadeState (manual crossFade). The 0/0 NaN guard actually
lives in _updatePlayingState's state-machine transition branch. Add
a state-machine no-exit transition test that fires from a paused
(speed override = 0) source state, ensuring dest receives the
preserved remaining deltaTime and no NaN propagates.
… fields

The public class previously exposed engine-managed runtime fields
(stateData/playedTime/playState/clipTime/currentEventIndex/isForward/
offsetFrameTime) as plain public properties, relying on @internal JSDoc
for visibility hiding. With underscore prefix the API surface is
unambiguous: state (readonly), speed (getter/setter), and
clearSpeedOverride() — anything else is implementation detail.

User code that mutated runtime fields was already breaking Animator
invariants; underscore-renaming makes the boundary explicit.
- Replace this._children.some(callback) with explicit for-loop to avoid
  the per-call callback allocation; matches core/animation iteration
  style for small caches and hot-ish paths.
- Annotate _findChildByPathDown return type as Entity | null since the
  helper genuinely returns null on miss.
…miss

The previous helper short-circuited on the first same-name child match
and never tried later same-name siblings, regressing vs the old
_findChildByName behavior. When a path like "root/a/leaf" needed to
fall through the first 'a' subtree (no leaf) into the second 'a'
subtree (has leaf), the helper returned null instead of finding the
leaf. Continue the for-loop on miss so subsequent same-name siblings
are attempted.

Subtree containment is preserved: the helper still doesn't backtrack
into entity.parent or beyond the entity's subtree.
statePlayDataMap was keyed by state.name; if a user removed and re-added
a state with the same name (creating a fresh AnimatorState instance),
the next getOrCreatePlayData would return the cached handle whose
.state still pointed at the removed state. Validate identity on lookup
and rebuild on mismatch.

This addresses dynamic controller mutation patterns; it doesn't depend
on AnimatorController dispatching an update flag for state-level
mutations (which it currently doesn't).
play() and update() guarded against stale _animatorLayersData by
checking _controllerUpdateFlag.flag and calling _reset(). findAnimatorState
didn't, so handles returned right after a controller mutation
(addLayer/removeLayer/clearLayers) were backed by pre-mutation layer
data. Add the same guard.
Same-name removeState + addState would hit the stateName-keyed cache and
return the previous state's curveLayerOwner / event handlers, so playing
the new state evaluated with the old curve owners (e.g. position curve
applied to rotation owner). Track the source state on AnimatorStateData
and rebuild on identity miss; detach the prior clipChangedListener so
the old state's UpdateFlagManager doesn't keep mutating discarded data.

Adds a play+evaluate regression locking down curveLayerOwner rebinding.
…ulation

_reset() dropped the entire animatorLayersData array, which meant the
clipChangedListener installed on each surviving AnimatorState's
UpdateFlagManager was leaked. Repeated controller mutations would
register a fresh listener per play() while the previous ones lived on,
mutating discarded stateData on every clip change.

Walk the cached stateData maps before clearing the array and remove the
listener via the handle now stored on AnimatorStateData.

Adds a regression locking down listener count after three controller
mutation cycles.
Round-11 plugged the same listener leak on _reset() but destroy() never
went through that path, so destroying an Animator while its controller
or any AnimatorState outlived it left the clipChangedListener attached
to that state's UpdateFlagManager. The closure kept the destroyed
entity reachable via clip dispatch.

Call _reset() at the top of _onDestroy() so destroy reuses the listener
detach pass we already added on controller mutation. Adds a regression
that builds an Animator on a shared controller, plays once, destroys
it, and asserts the listener count returns to its baseline.
The self-prefix branch added in 0273ebf assumed Animator could sit
directly on a GLTF sceneRoot (single-root scenes), but c3d2160 in
this same PR unconditionally wraps every scene in a GLTF_ROOT container
3 days earlier. After that change the Animator is always on the wrapper
and binding paths always start with a real child name — the self-prefix
branch is never reached on a real GLTF load.

Evidence: the Animator regression added by 0273ebf itself
(samples self-name-prefixed curve paths on wrapped roots) builds a
"GLTF_ROOT" wrapper and a "mixamorig:Hips" child, then asserts
wrappedRoot.findByPath("mixamorig:Hips"). splits[0] is "mixamorig:Hips",
this.name is "GLTF_ROOT" — the self-prefix guard splits[0] === this.name
is false, so the test passes via the generic child lookup with or
without the branch. The four follow-up commits (c446e49 / 3ea4dd4 /
da0d6a3 / 6de9e5a) were all patching edge cases introduced by the
self-prefix branch itself; none of those bugs exist when the branch is
gone.

Drop:
- findByPath self-prefix fallback (Entity.ts)
- _findChildByPathDown helper, which existed only to back that fallback
- six Entity tests that locked the self-prefix behaviour contract

Keep:
- generic findByPath sibling backtrack test (Entity.test.ts:304) which
  exercises _findChildByName's same-name-sibling retry
- the Animator wrapper-relative regression (it now exercises the
  generic path, which is what it was actually testing anyway)
- GLTFAnimationParser single-root prefix logic (necessary to make
  bindings GLTF_ROOT-relative)
… chaining

The two conditions !playData || playData.state !== state can be
expressed as a single playData?.state !== state — identical semantics
across all three cases (undefined, identity match, identity mismatch).
Removes one branch token while making the intent ("cache entry doesn't
belong to this state") read as a single thought.
clearSpeedOverride() existed solely to flip the instance back into
live-binding state.speed after a write. In practice no production flow
actually needs that: every "restore" use case is expressible as a
direct write (playData.speed = previousValue, or = playData.state.speed
to follow the current asset value). Keeping the API around forces
users to reason about a two-state model (live-bind vs override) instead
of the simpler "write = instance owns it" semantics.

Behavior:
- speed still live-binds to state.speed until first write (good editor
  ergonomics: tweaking asset speed propagates to instances that haven't
  claimed ownership yet)
- writing once flips the instance to own its own speed; no escape hatch
  back to live-bind — by design, since the write is the signal of intent
- to re-follow the asset, simply assign playData.state.speed back

Drops 1 public API from AnimatorStatePlayData.d.ts. Surface is now
{ state, speed, get/set speed }.

Tests: removed the dedicated clearSpeedOverride regression. The
remaining 53 Animator tests still cover speed override read/write,
override survival across cross-fade, clone isolation, and speed=0
no-NaN guard.

Docs: updated zh/en animator.mdx pause example + findAnimatorState
section to use "assign the asset value back" instead of
"clearSpeedOverride()".
Two adjustments codex called out in round-9 polish review:

A) Centralize listener detach in AnimatorStateData.dispose(). The two
   call sites that previously open-coded the same "destructure { state,
   clipChangedListener }, null-check, removeListener" template — the
   identity-mismatch branch in _getAnimatorStateData and the cleanup
   loop in _reset — now both call stateData.dispose(). Future listener
   sources (or rebuild paths) stay encapsulated in the data holder.

B) Make AnimatorStateData.state a readonly constructor parameter. The
   runtime invariant is "a live stateData is always bound to a state",
   but the previous nullable field forced every consumer to guard
   `state && clipChangedListener` even though the null window only
   existed for one statement between new and assignment. Construction
   via `new AnimatorStateData(animatorState)` mirrors the same shape as
   AnimatorStatePlayData(state) and removes the dead null branches.

No behavior change. 53 Animator tests still pass — they already cover
the lazy create, identity rebuild, listener accumulation prevention,
and destroy-time detach paths.
The signature on this PR became `AnimatorState | null` (it returns
`_animatorLayersData[layerIndex]?.srcPlayData?.state ?? null`), but the
2.0 breaking-change summary only called out `findAnimatorState()`.
Update JSDoc + zh/en animator.mdx to spell out the two null cases
(missing layer / no state currently playing) and switch the snippet to
the if-guarded form so callers see the right pattern.
…e-Skin order

Strengthens the inline comment so a future "let's make the dependency
explicit" refactor doesn't fall into the obvious trap of awaiting
context.get(Scene) inside Skin. GLTFSceneParser._createRenderer
requests context.get(Skin) for skinned renderers, so Skin → full Scene
→ Skin would deadlock the cached promise.

The correct long-term path is exposing scene root wrapper creation as
its own synchronous phase (e.g. ensureSceneRootsCreated()) that Skin
can pull on demand without awaiting the entire Scene parse — but
that's a loader-pipeline refactor for a follow-up PR, not this one.
…LCA only

_findSceneRootBone was a fast-path classifier introduced in 9974c5b
to handle multi-root scenes where joints span multiple top-level scene
nodes — it short-circuited those cases to return the GLTF_ROOT wrapper.

But c3d2160 (already on the branch) made GLTFSceneParser create the
wrapper unconditionally and attach every top-level scene node under it.
With that invariant, joint parent chains always include the wrapper at
the top, so the existing LCA algorithm in _findSkeletonRootBone is
already correct for both cases:

  Multi-root spanning: joints share only GLTF_ROOT → LCA returns wrapper.
  Single-root / converged: joints share a deeper ancestor → LCA returns
    the actual skeleton root, not the wrapper.

The two-function ?? chain was masquerading a dispatch (case selection)
as a fallback (try-then-retry), forcing readers into both helpers to
understand that null from _findSceneRootBone meant "not my case" rather
than "lookup failed". Removing it leaves a single, uniform rule:
rootBone = LCA(joints).

Changes:
- Drop _findSceneRootBone (~50 lines)
- Rename _findSkeletonRootBone to _findSkeletonRootBoneByLCA so the
  algorithm is self-describing
- Tighten return type to Entity | null (rootNode starts as null and the
  function can return null when paths don't share any ancestor)
- Simplify the call site to one path; the comment now states the
  invariant rather than guarding against a phantom fallback

Existing GLTFLoader tests cover both branches and still pass:
- "Multi-root skins without skeleton should use the scene wrapper as
  rootBone" verifies LCA → wrapper for the spanning case
- "Multi-root scenes whose joints converge to a single top-level root
  should not use the scene wrapper" verifies LCA → Character_Root for
  the converged case
0623cb9 deleted _findSceneRootBone and made the Skin parser resolve
rootBone via joint parent-chain LCA only. The previous comment still
referenced both _findSceneRootBone and the "Skin reads _sceneRoots"
mechanism, neither of which is true anymore.

Restate the actual invariant: LCA needs the GLTF_ROOT wrapper present
in joint parent chains, which Scene's parse head provides synchronously
when it attaches top-level nodes under the wrapper. The cycle warning
against "Skin awaits full Scene" stays — Scene's async tail still
requests Skin via _createRenderer.
…verride mode

After d0f3c35 removed clearSpeedOverride(), the "override mode" two-
state concept stopped existing — speed is now simply a per-instance
value that live-binds to state.speed until written. The internal
_speedOverride field name still implied the old "claims override, can
be cleared" semantics. Rename to _speed and clean up "override" wording
from the doc header, resetForPlay comment, and five test names /
inline comments so the source matches the actual model.

Behavior unchanged. All 53 Animator tests still pass.
The same three lines —

  if (this._controllerUpdateFlag?.flag) {
    this._reset();
  }

— guarded entry into play(), update(), findAnimatorState(), and
_crossFade() to drop stale layer data after a controller mutation.
Pull them into a single private helper so the intent is named once
and the call sites read as one statement.

No behavior change. All 53 Animator tests still pass.
…_correctTime

Two doc-only polish points from a second-pass review:

- The top-level class JSDoc duplicated the `speed` getter's full
  semantics paragraph. Shorten the class doc to a one-line surface
  pointer ("see the `speed` getter for live-bind semantics"), so the
  detailed read/write behavior is documented once at the getter.

- `_correctTime` reads as "correct what?" without tracing both call
  sites. Add a one-line comment explaining that reverse playback
  resumed at clipTime=0 would step into negatives, so we snap to
  clipEnd to keep the reverse loop seamless.

No code or behavior change. 53 Animator tests still pass.
9974c5b added a synchronous (glTFResource._sceneRoots ||= [])[index] = sceneRoot
write inside GLTFSceneParser.parse. Its original consumer (the now-deleted
_findSceneRootBone in GLTFSkinParser, removed in 0623cb9) is gone, so the
line looks redundant against the asynchronous _handleSubAsset path that the
glTFResourceMap[Scene] = "_sceneRoots" mapping triggers.

It is not redundant: dev/2.0 already writes _defaultSceneRoot synchronously
here, while _sceneRoots[i] would only be filled in Scene's async tail through
_handleSubAsset. The two wrapper-index fields would observe different visibility
timings, and any same-tick reader (e.g. a future synchronous parser consumer or
editor inspector) could see _defaultSceneRoot set while _sceneRoots[i] is still
undefined.

Add an inline comment so a future cleanup pass doesn't delete this line under
the wrong assumption that _handleSubAsset already covers it.

No behavior change. GLTF loader tests still pass.
@luzhuang luzhuang force-pushed the feat/animation-physics branch from e5b881c to 4457f62 Compare May 12, 2026 12:12
@luzhuang luzhuang changed the title fix(animation, physics): 从 #2983 抽离动画与物理修复 fix(animation, loader): 从 #2983 抽离动画与 GLTF 加载器修复 May 12, 2026
@luzhuang
Copy link
Copy Markdown
Contributor Author

@GuoLei1990 已将物理 raycast/sweep 修复抽离至独立 PR #2998

本 PR 现在仅含:

  • 动画(per-instance speed / per-state PlayData handle / API breaking changes / 各 lifecycle 修复)
  • GLTF loader(skin rootBone via LCA / clip binding path 归一化)
  • Entity.findByPath

物理 9 个 commits 已 rebase 到独立分支 fix/physics-raycast-sweep(PR #2998),与本 PR 零文件重叠。

Force-push 已完成(commits: 62 → 53)。指向旧 SHA 的 inline review comments 会标 outdated,评论本体保留。

请重新评审较小的 diff,或确认无新问题后 dismiss 之前的 CHANGES_REQUESTED。

@luzhuang
Copy link
Copy Markdown
Contributor Author

续在 #2999:本 PR 因分支重命名(feat/animation-physicsfix/animation-loader)需要重建 head ref,故关闭本 PR 在新 PR 上继续。本 PR 的 review 讨论历史保留供参考。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants