Skip to content

fix(animation): harden animator state instance data#3024

Open
luzhuang wants to merge 20 commits into
dev/2.0from
fix/animation-shaderlab-split
Open

fix(animation): harden animator state instance data#3024
luzhuang wants to merge 20 commits into
dev/2.0from
fix/animation-shaderlab-split

Conversation

@luzhuang

@luzhuang luzhuang commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Keeps AnimatorStateInstance as the public per-Animator API and keeps AnimatorStatePlayData internal as the runtime playback slot.
  • Adds AnimationClip.sampleAnimation(entity, time) as a public curve-sampling API; direct sampling applies clip curves without dispatching AnimationEvents, with no separate internal sampling wrapper.
  • Adds Animator.fireEvents (default true) so AnimationEvent dispatch can be muted while event cursor bookkeeping stays in sync across loop wraps.
  • Fixes stable per-state instance lookup, null-safe current-state APIs, invalid or missing state/layer no-ops, controller-change/state-removal invalidation, replacement-safe layer state-machine invalidation, sparse layer reset, destroy reset cleanup, and default-state removal cleanup.
  • Uses a Map-backed state-data cache plus WeakMap-backed instance and curve-owner caches with layer-local reset traversal, rebuilds animation event handlers lazily from clip/script versions, preserves per-Animator speed/wrapMode overrides through AnimatorStateInstance, reduces repeated play-data getter access in the playback hot path, and avoids no-op crossFade transition setup.
  • Adds focused coverage for non-playing state lookup, crossFade slot reuse, same-state/destination crossFade no-op, invalid layers, script/clip event rebinding after play, default state removal, state-removal cache invalidation, replacement state-machine invalidation, sampleAnimation, fireEvents, fireEvents loop-wrap cursor sync, forward/backward loop and Once-clip AnimationEvents, deterministic current-state lookup, and the AnimatorHang load regression.

Verification

  • pnpm exec prettier --check packages/core/src/animation/Animator.ts packages/core/src/animation/AnimatorStateInstance.ts packages/core/src/animation/AnimatorStateMachine.ts packages/core/src/animation/index.ts packages/core/src/animation/internal/AnimationEventHandler.ts packages/core/src/animation/internal/AnimatorLayerData.ts packages/core/src/animation/internal/AnimatorStateData.ts packages/core/src/animation/internal/AnimatorStatePlayData.ts tests/src/core/Animator.test.ts tests/src/core/AnimatorHang.test.ts
  • pnpm exec prettier --check packages/core/src/animation/AnimatorController.ts packages/core/src/animation/AnimatorStateMachine.ts tests/src/core/Animator.test.ts
  • pnpm exec prettier --check packages/core/src/animation/Animator.ts tests/src/core/Animator.test.ts
  • pnpm exec prettier --check packages/core/src/animation/AnimatorController.ts packages/core/src/animation/AnimatorControllerLayer.ts tests/src/core/Animator.test.ts
  • npx eslint --fix --no-eslintrc -c .eslintrc.js packages/core/src/animation/AnimatorController.ts packages/core/src/animation/AnimatorStateMachine.ts tests/src/core/Animator.test.ts
  • npx eslint --fix --no-eslintrc -c .eslintrc.js packages/core/src/animation/Animator.ts tests/src/core/Animator.test.ts
  • npx eslint --fix --no-eslintrc -c .eslintrc.js packages/core/src/animation/AnimatorController.ts packages/core/src/animation/AnimatorControllerLayer.ts tests/src/core/Animator.test.ts
  • git diff --check
  • pnpm -F @galacean/engine-core run b:types
  • pnpm run b:module
  • pnpm vitest tests/src/core/Animator.test.ts --run -t "keeps AnimationEvent cursor in sync" (1 passed)
  • pnpm vitest tests/src/core/Animator.test.ts --run -t "replacing a layer state machine" (1 passed)
  • pnpm vitest tests/src/core/Animator.test.ts tests/src/core/AnimatorHang.test.ts --run (54 passed)
  • CI=true pnpm run coverage (111 passed, 1425 passed)
  • pnpm exec eslint packages/core/src/animation/Animator.ts --ext .ts --resolve-plugins-relative-to ./node_modules

Summary by CodeRabbit

  • New Features

    • Added AnimationClip.sampleAnimation(entity, time) for direct curve sampling.
    • Added Animator.fireEvents to control whether animation event handlers dispatch.
  • Bug Fixes

    • Improved animation event dispatch during loop wrap-around, including forward/backward refires and Once-clips behavior.
    • Refined animator playback/cross-fade handling so timing, speed, and wrap-mode overrides behave consistently (including shared controllers).
  • Tests

    • Updated animator tests to rely on public state/play APIs.
    • Added a regression test to ensure loading an Animator does not hang.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

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

The animation runtime is refactored to access playback state through AnimatorStatePlayData proxy properties (state, speed, wrapMode) instead of AnimatorStateInstance internals. WeakMap caching replaces numeric instanceId-keyed records across AnimatorLayerData, AnimationClipCurveBinding, and Animator. A public sampleAnimation method is added to AnimationClip, and a fireEvents flag is added to Animator with loop-aware wrap-around event dispatch logic. Tests are updated to use public API handles throughout.

Changes

Animator State/Play-Data Boundary Refactor

Layer / File(s) Summary
Internal data structures: WeakMap caches and AnimatorStatePlayData proxies
packages/core/src/animation/internal/AnimatorStatePlayData.ts, packages/core/src/animation/internal/AnimatorLayerData.ts, packages/core/src/animation/AnimationClipCurveBinding.ts
AnimatorStatePlayData adds public proxy getters/setters for state, speed, and wrapMode. AnimatorLayerData replaces its numeric curveOwnerPool with a WeakMap<Component, ...>, adds stateDataList, and removes getOrCreateInstance/completeCrossFade/clearCrossFadeSlot. AnimationClipCurveBinding switches _tempCurveOwner from an instanceId-keyed object to a WeakMap<Entity, ...>.
Public API: AnimationClip.sampleAnimation and Animator.fireEvents
packages/core/src/animation/AnimationClip.ts, packages/core/src/animation/Animator.ts
AnimationClip exposes a new public sampleAnimation(entity, time) that wraps _sampleAnimation. Animator gains a fireEvents: boolean field, and imports WrapMode to support loop-aware event gating.
Animator lifecycle: reset, owner pool, and state-data wiring
packages/core/src/animation/Animator.ts
_reset() iterates stateDataList/curveLayerOwner to revert curve default values and recreates _curveOwnerPool as a new WeakMap. _onDestroy() now calls _reset(). _saveAnimatorStateData reads and writes the WeakMap by Component identity. New AnimatorStateData entries are pushed onto stateDataList.
Playback and cross-fade evaluation using playData.state proxies
packages/core/src/animation/Animator.ts
All update/evaluate paths—playing, cross-fading, pose cross-fading, and finished—now read state/clip from playData.state/playData.state.clip and compute speed from playData.speed. _preparePlayOwner, _applyStateTransitions, and cross-fade no-op guards compare AnimatorState identity through playData.state.
Loop-aware animation event dispatch and fireEvents gating
packages/core/src/animation/Animator.ts
_fireAnimationEvents adds WrapMode.Loop wrap-around logic, firing events in two segments when the time window crosses clip boundaries. _fireAnimationEventsAndCallScripts gates all dispatch on this.fireEvents and the presence of built handlers.
Animator test migration, new coverage, and hang regression test
tests/src/core/Animator.test.ts, tests/src/core/AnimatorHang.test.ts
Tests are rewritten to use public AnimatorState handles and playData.state.name assertions. New coverage validates per-instance speed/wrapMode isolation, controller-sharing non-leak, fireEvents gating, sampleAnimation without events, and removing a default state preventing auto-play. A new regression test verifies an Animator loads without hanging from a GLTF resource.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • galacean/engine#2999: Overlapping refactors to Animator.ts cross-fade, state/playback handling, and state-machine evaluation paths directly precede and relate to this PR's changes.

Suggested labels

Animation

Suggested reviewers

  • GuoLei1990

Poem

🐰 Hop hop, the WeakMap grows,
No more instanceId woes!
playData.state leads the way,
fireEvents gates the fray,
Loop wraps and samples — hooray! 🎞️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(animation): harden animator state instance data' directly summarizes the main architectural change: improving the robustness and reliability of animator state instance data management, which aligns with the core objective of hardening the animator state instance data API and internal mechanics across multiple files.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/animation-shaderlab-split

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

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

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

10-10: ⚡ Quick win

Improve test description to clarify regression being tested.

The test title "Canvas 1024 test" does not clearly describe what hang or regression is being prevented. The PR description mentions "Adds an Animator hang regression test" but the test name is vague.

📝 Suggested improvement
-describe("Canvas 1024 test", function () {
+describe("Animator GLTF load hang regression", function () {

Or add a comment explaining what hang this prevents:

+// Regression test for animator hang when loading GLTF with 1024x1024 canvas
 describe("Canvas 1024 test", function () {
🤖 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` at line 10, Update the test title string
in the describe(...) call so it clearly documents the regression being prevented
(e.g., "Animator hang regression: ensure canvas draw loop does not hang with
1024px width/height") or add a short comment immediately above the describe
explaining which hang/bug this test prevents; target the describe invocation in
AnimatorHang.test.ts to make the purpose explicit for future readers.
🤖 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 225-241: The method findAnimatorState currently returns the live
layerData.srcPlayData as a fallback even when neither srcPlayData.state nor
destPlayData.state match the requested state, which returns play-data for the
wrong state and allows erroneous reads/mutations; change the fallback to return
null (keeping the AnimatorStatePlayData nullable return) so only an exact match
from _getAnimatorStateInfo / _animatorLayersData (checking srcPlayData.state and
destPlayData.state) is returned, or if you need to support per-state mutable
play data create and consult a dedicated per-state cache keyed by state name
instead of reusing srcPlayData/destPlayData.
- Around line 212-218: The public method getCurrentAnimatorState currently can
return undefined when the layer index is out of range or the layer has never
played (it reads this._animatorLayersData[layerIndex]?.srcPlayData?.state) but
its signature promises AnimatorState; update the contract by either changing the
return type to AnimatorState | null or ensuring the method normalizes
falsy/undefined results to null before returning (e.g., return null when
srcPlayData or state is missing) so callers never receive undefined.

In `@packages/core/src/animation/AnimatorStateMachine.ts`:
- Around line 18-23: Make defaultState nullable again and ensure removeState()
clears or reparents it: change the type of Animator.defaultState back to
AnimatorState | null, update any initialization logic so it starts as null, and
in removeState(state) check if state === this.defaultState and either set
this.defaultState = null or reassign it to a valid remaining state; also review
Animator._checkAnyAndEntryState() (and related logic around default-state usage
at the entry path) to handle a null defaultState safely so removed states cannot
remain live targets.

In `@packages/core/src/animation/internal/AnimationEventHandler.ts`:
- Around line 6-10: The dispose() method must clear pooled state: reset the
handlers array and release the event reference to avoid leaking callbacks and
stale state. Update AnimationEventHandler.dispose() to clear handlers (e.g.
this.handlers.length = 0 or replace with a new empty array) and nullify the
event (e.g. set this.event = undefined/null) and if necessary adjust the event
property type on the class (AnimationEvent -> AnimationEvent | undefined) so
clearing the reference compiles.

In `@packages/core/src/animation/internal/AnimatorStatePlayData.ts`:
- Around line 15-18: AnimatorStatePlayData currently exposes runtime-only
internals (stateData) and mutator methods (reset, updateOrientation, update);
refactor by introducing a public read-only interface (e.g.,
AnimatorStatePlayInfo or a readonly version of AnimatorStatePlayData) that only
exposes immutable fields needed by consumers (readonly state: AnimatorState and
any other read-only view fields) and remove AnimatorStateData and lifecycle
methods from that public shape; keep the current class as an internal
implementation (rename or mark non-exported) that still contains stateData and
the mutators (reset(), updateOrientation(), update()) and update
animation/index.ts to export the new readonly interface instead of the mutable
class so consumers cannot access or call the internal mutators.

In `@tests/src/core/AnimatorHang.test.ts`:
- Around line 10-20: The describe callback is async which causes a race because
Vitest doesn't await it; remove the async from the describe wrapper and move the
asynchronous setup into a beforeAll (or inside the it) so the test waits for
initialization. Specifically, perform WebGLEngine.create(...) and
resourceManager.load<GLTFResource>(...) inside a beforeAll that assigns engine,
scene, rootEntity, defaultSceneRoot, and animator (or await those calls inside
the it) and keep the it("loaded", ...) synchronous/asserting only after animator
is set. Ensure references to WebGLEngine.create, engine, resourceManager.load,
defaultSceneRoot.getComponent(Animator), and animator are updated to use the
variables populated by beforeAll or the test body.

---

Nitpick comments:
In `@tests/src/core/AnimatorHang.test.ts`:
- Line 10: Update the test title string in the describe(...) call so it clearly
documents the regression being prevented (e.g., "Animator hang regression:
ensure canvas draw loop does not hang with 1024px width/height") or add a short
comment immediately above the describe explaining which hang/bug this test
prevents; target the describe invocation in AnimatorHang.test.ts to make the
purpose explicit for future readers.
🪄 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: 2a682d95-69cf-47c9-adae-c9b9908285e9

📥 Commits

Reviewing files that changed from the base of the PR and between de75496 and abb5c3a.

📒 Files selected for processing (12)
  • packages/core/src/animation/AnimationClip.ts
  • packages/core/src/animation/AnimationClipCurveBinding.ts
  • packages/core/src/animation/Animator.ts
  • packages/core/src/animation/AnimatorStateInstance.ts
  • packages/core/src/animation/AnimatorStateMachine.ts
  • packages/core/src/animation/index.ts
  • packages/core/src/animation/internal/AnimationEventHandler.ts
  • packages/core/src/animation/internal/AnimatorLayerData.ts
  • packages/core/src/animation/internal/AnimatorStateData.ts
  • packages/core/src/animation/internal/AnimatorStatePlayData.ts
  • tests/src/core/Animator.test.ts
  • tests/src/core/AnimatorHang.test.ts
💤 Files with no reviewable changes (1)
  • packages/core/src/animation/AnimatorStateInstance.ts

Comment thread packages/core/src/animation/Animator.ts
Comment thread packages/core/src/animation/Animator.ts Outdated
Comment thread packages/core/src/animation/AnimatorStateMachine.ts
Comment thread packages/core/src/animation/internal/AnimationEventHandler.ts Outdated
Comment thread packages/core/src/animation/internal/AnimatorStatePlayData.ts Outdated
Comment thread tests/src/core/AnimatorHang.test.ts Outdated
GuoLei1990

This comment was marked as outdated.

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

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 marked this pull request as draft June 15, 2026 02:49
@luzhuang luzhuang changed the title fix(animation): split shaderlab animation fixes fix(animation): harden animator state instance data Jun 15, 2026
@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.68508% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.20%. Comparing base (de75496) to head (f2d788b).
⚠️ Report is 5 commits behind head on dev/2.0.

Files with missing lines Patch % Lines
packages/core/src/animation/AnimatorController.ts 60.00% 4 Missing ⚠️
...ages/core/src/animation/AnimatorControllerLayer.ts 93.93% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #3024      +/-   ##
===========================================
+ Coverage    77.48%   79.20%   +1.72%     
===========================================
  Files          914      914              
  Lines       101783   101744      -39     
  Branches     10430    11230     +800     
===========================================
+ Hits         78862    80587    +1725     
+ Misses       22738    20970    -1768     
- Partials       183      187       +4     
Flag Coverage Δ
unittests 79.20% <96.68%> (+1.72%) ⬆️

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

☔ View full report in Codecov by Harness.
📢 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.

@luzhuang luzhuang marked this pull request as ready for review June 16, 2026 07:20
@luzhuang

Copy link
Copy Markdown
Contributor Author

Ready for re-review.

The earlier review points are addressed on the latest head:

  • AnimatorStateInstance remains the public per-Animator facade; AnimatorStatePlayData stays internal.
  • getCurrentAnimatorState() / findAnimatorState() keep nullable, stable instance semantics and no longer return unrelated play data.
  • Removed default states are cleared from defaultState.
  • The AnimatorHang suite now uses beforeAll instead of async describe setup.
  • Event handlers rebuild lazily from clip/script versions; no pooled handler state remains.
  • Added focused coverage for non-playing state lookup, crossFade slot reuse, invalid layers, script/clip event rebinding after play, default state removal, and forward/backward loop events.

Current head bb323cac20a1d22a7e56f6a12fd45ebcbdfec92d has green CI. I also checked a local merge onto current origin/dev/2.0: b:module, focused Animator vitest (49 passed), and core b:types pass after generating @galacean/engine-design types.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

1579-1623: 💤 Low value

Minor assertion style inconsistency.

Line 1616 uses .to.be.null while the rest of the file consistently uses .to.eq(null). While both work with vitest's chai compatibility, prefer the consistent style for maintainability.

♻️ Proposed fix for consistency
-    expect(layerData.destPlayData).to.be.null;
+    expect(layerData.destPlayData).to.eq(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 `@tests/src/core/Animator.test.ts` around lines 1579 - 1623, Change the
assertion on line 1616 in the noExitTime transition scan test from `.to.be.null`
to `.to.eq(null)` to match the assertion style consistently used throughout the
rest of the test file. The assertion `expect(layerData.destPlayData).to.be.null`
should be updated to use `.to.eq(null)` for consistency with the file's
convention.

247-296: 💤 Low value

Good coverage for state lookup edge cases.

These three new tests provide solid coverage for:

  • Stable per-state instances for non-playing states
  • Instance stability across crossFade slot reuse
  • Graceful handling of invalid layer indices

However, line 283 accesses the private _state property:

expect(animator.findAnimatorState("Walk", 0)._state).to.eq(walkState);

While acceptable for verification in tests, this breaks the public API boundary. Consider whether this internal verification is necessary or if public properties suffice.

🤖 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/Animator.test.ts` around lines 247 - 296, The test
"findAnimatorState remains stable after crossFade play slots are reused"
accesses the private _state property on the result of
animator.findAnimatorState(), which breaks the public API boundary. Remove the
expectation that checks animator.findAnimatorState("Walk", 0)._state or replace
it with a verification using only public properties. The other assertions
already verify that the instance remains stable across the crossFade operation,
so this private property check may be unnecessary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/src/core/Animator.test.ts`:
- Around line 1579-1623: Change the assertion on line 1616 in the noExitTime
transition scan test from `.to.be.null` to `.to.eq(null)` to match the assertion
style consistently used throughout the rest of the test file. The assertion
`expect(layerData.destPlayData).to.be.null` should be updated to use
`.to.eq(null)` for consistency with the file's convention.
- Around line 247-296: The test "findAnimatorState remains stable after
crossFade play slots are reused" accesses the private _state property on the
result of animator.findAnimatorState(), which breaks the public API boundary.
Remove the expectation that checks animator.findAnimatorState("Walk", 0)._state
or replace it with a verification using only public properties. The other
assertions already verify that the instance remains stable across the crossFade
operation, so this private property check may be unnecessary.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 67ff5265-2b68-4bcc-9962-4814431c8219

📥 Commits

Reviewing files that changed from the base of the PR and between abb5c3a and bb323ca.

📒 Files selected for processing (6)
  • notes/animation/2026-06-15-animator-state-instance-boundary.md
  • packages/core/src/animation/Animator.ts
  • packages/core/src/animation/internal/AnimatorLayerData.ts
  • packages/core/src/animation/internal/AnimatorStatePlayData.ts
  • tests/src/core/Animator.test.ts
  • tests/src/core/AnimatorHang.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/src/core/AnimatorHang.test.ts

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

审查 @ 08ec9a1eb(本轮 request-changes,HEAD 未变)— fix(animation): harden animator state instance data

总结

逐项复核了整个 refactor。_reset/_onDestroy 的 revert 等价、canWrap 修 Once 重放、_curveOwnerPoolWeakMap<Component>animatorStateDataMapMap(identity 索引、非 GC fix)、state-removal 失效机制(_setChangeCallback 链路)、crossFade 自/活跃 dest no-op 守卫、PlayData getter 委托——这些都核为正确,方向是对的。唯一阻塞项是我此前指出、本 HEAD 仍未修的 fireEvents 跨 loop wrap 游标失同步 P2,本轮再次真机复现确认在位。

问题

[P2] Animator.ts:1617-1619fireEvents=false 跨 loop wrap 后再开,currentEventIndex 卡高位 → 静默丢下一轮事件

this.fireEvents &&
  eventHandlers.length &&
  this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime);

currentEventIndex 是播放游标。它_fireSubAnimationEvents/_fireBackwardSubAnimationEvents 内推进(1546/1571),并且 loop wrap 的游标重置(currentEventIndex = 0 / length-1,1507/1517)住在 _fireAnimationEvents 内。这三处全在 this.fireEvents 门控之下。所以当 fireEvents=false 跨越一次 loop wrap:update() 的 clipTime 取模 wrap 照常发生(与门控无关、每帧都跑),但 wrap 的游标重置被连同派发一起短路掉。fireEvents 重开后,游标卡在 wrap 前的高位 → _fireSubAnimationEvents 从该 stale 高位起遍历 → 新一轮 loop 里低 time 的早期事件立刻 time > curClipTime break永久丢弃,直到下次 wrap 才自愈

base 无此门控故无此 bug,是 fireEvents 新开关引入的回归。

复现(loop clip,事件 e0@0.1、e1@0.75):

  • F1 fireEvents=true,clipTime 0→0.8:e0、e1 fire,游标→1。
  • F2 fireEvents=false,wrap 0.8→0.05:_fireAnimationEvents 整个被短路,游标停在 1(wrap-reset 在门控内被跳过)。
  • F3 fireEvents=true,0.05→0.5(窗口含 e0@0.1):从游标=1 起,见 e1@0.75 > 0.5 → break。e0 从未被重新检查 → e0 共 fire 1 次(应 2 次)。

这是修复落点错的典型:把「是否派发」的开关放在「既派发又推进/重置游标」的函数外层,会连游标记账一起跳过。正确落点是把门控下沉到最内层「真正调 handler」那一处,让游标记账无条件执行:

  1. 1617-1619 去掉 this.fireEvents &&(保留 eventHandlers.length &&);
  2. _fireSubAnimationEvents(1543-1547) / _fireBackwardSubAnimationEvents(1567-1571) 里只把 handlers[j](parameter) 派发循环裹进 if (this.fireEvents)playState.currentEventIndex = … 赋值始终执行。

测试缺口:新测试 fireEvents gates AnimationEvent dispatch without consuming the eventupdate(0) + update(0.1)无 loop wrap,抓不到这个 bug;两个 loop-wrap 测试又从不切换 fireEvents。守这条回归的测试必须同时满足「fireEvents=false + 跨一次 loop wrap + 再开」,断言重开后那个低 index 事件仍 fire(卡在自愈前的精确窗口)。

其余核对(正确,不阻塞,记录结论避免重复审)

  • (D) _reset 新遍历覆盖完整_createCurveOwner 唯一调用点在 _saveAnimatorStateData:479,每个 owner 同时被包进 curveLayerOwner[i](495),其 stateDataanimatorStateDataMap(436)。新的 layerData → animatorStateDataMap → curveLayerOwner 遍历覆盖到旧扁平池遍历的所有 owner,无遗漏。_curveOwnerPool 仅剩 save 期去重职责。
  • (E) animatorStateDataMap WeakMap→Map — 是新 _reset 必须可迭代(WeakMap 不可迭代)所致,且无泄漏:removeState_onChanged → _updateFlagManager.dispatch() 置 dirty,下次 _resetIfControllerUpdated 整体清掉 _animatorLayersData(连同 Map),removed-state entry 随整体 GC。
  • (F) _onDestroy_reset — 不崩(Component._onDestroy 不 null 化 _entity,owner 持 assembler 直引用,revert 写入成功)。
  • (B) Once clip 的 canWrap — 正确跳过 wrap 重置 + 二段重放;playData.reset()currentEventIndex=0,Once 重放干净。
  • (G) crossFade no-op 守卫play()_preparePlay)无守卫,replay-from-offset 仍正常重启;两处 crossFade 守卫(387 预短路 / 1456 承重)一致,自/活跃 dest no-op 是「每 state 每 layer 单实例」的刻意设计,已有测试。
  • (C) PlayData getter 委托 — 与旧 playData.instance.x 同级间接,V8 可内联,热点循环已 destructure,不构成问题。
  • sampleAnimation@internal、JSDoc 完整、无 stale _sampleAnimation 引用。

不阻塞的小项

  • [P3] _onDestroy 现在会 revert 动画值 — 旧 _onDestroy 只减引用计数、不调 _reset;现在销毁 animator 会把动画目标 snap 回默认。整子树一起销毁时无害(常见),但若 animator 经 removeComponent 单独移除、或动画的是存活更久的兄弟/子实体,这些属性现在会在 destroy 时重置——新副作用。大概率是期望行为,确认意图 + 补一个测试更稳。
  • [P3] AnimatorControllerLayer.stateMachine 公开字段在 addLayer 之后重赋会丢 _onChanged 回调 → 对新 stateMachine 的 removeState 不触发失效。当前测试都在 addLayer 前重赋故未触发,pre-existing footgun,本 PR 的失效机制让它略相关。考虑加 setter 重连或文档化 ordering 要求。
  • [P3] 注释句号Animator.ts:471Animator.ts:483AnimatorStatePlayData.ts:100 的单行 // 注释带句号,违反「单行注释不加句号」,去掉。

简化建议

refactor 本身已足够干净,无进一步精简空间。fireEvents 门控下沉是唯一需要的结构修正。

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

总结

复审 HEAD a92f56bb3(自上轮 08ec9a1eb 起 1 个新 commit:a92f56bb3 fix(animation): keep event cursor synced when events are muted)。相对 merge-base(de75496876)真实 diff 仍为 7 src + test。本轮这个 commit 正是修我历轮(03:58 / 04:30 / 06:50)唯一开放阻塞项——fireEvents 跨 loop wrap 游标失同步 P2——的修复,落点与我此前给出的处方逐字一致,并补上了我要求的回归测试。逐行核对 + 真机反向证伪确认正确。该 P2 闭环,无 P0/P1/P2,可合并。

已闭环(本轮新修,逐项核对)

  • [P2→已修] fireEvents 跨 loop wrap 游标失同步a92f56bb3)—— 修法与我历轮处方完全一致:
    1. _fireAnimationEventsAndCallScripts(1621) 去掉 this.fireEvents &&,只保留 eventHandlers.length && this._fireAnimationEvents(...)_fireAnimationEvents 每帧无条件跑,wrap 段游标重置(1507 currentEventIndex=0 / 1517 =length-1)不再被门控短路
    2. _fireSubAnimationEvents(1543-1547) / _fireBackwardSubAnimationEvents(1570-1574) 里只把 handlers[j](parameter) 派发循环裹进 if (this.fireEvents)playState.currentEventIndex = …(1548/1575) 始终执行 → 游标记账无条件、只静默派发。
    • 前后向对称:backward 路径同样只 gate 派发循环、currentEventIndex = Math.max(eventIndex-1,0) 与 wrap-reset 1517 均无条件跑,结构与 forward 镜像一致。
    • 真机反向证伪(脱机精确复刻 _fireAnimationEvents+_fireSubAnimationEvents forward+Loop wrap 算法,clip len=1,e0@0.1/e1@0.75,跑测试三帧场景):
      • FIXED:F1{e0:1,e1:1} → F2 muted{e0:0,e1:0}(wrap 期间游标被重置回 0)→ F3{e0:1,e1:0}(重开后 e0 重新 fire)。
      • OLD(整 _fireAnimationEvents!fireEvents 短路):F1/F2 同,F3{e0:0,e1:0}(游标卡在 wrap 前高位 → e0 永久丢弃)。
      • 即 fix 前 fail(expect(0).toBe(1))、fix 后 pass,是真回归测试而非恒过桩
    • 测试缺口已补:新增 keeps AnimationEvent cursor in sync when fireEvents is disabled across forward loop wrap(Animator.test.ts:673)恰好命中我此前指出的"fireEvents=false + 跨一次 loop wrap + 再开"精确窗口,断言重开后低 index 事件 e0 仍 fire 恰一次(卡在自愈前的关键帧),与上面反向证伪逐帧一一对应。✅

已闭环 / 不在范围(逐条核对,不重提)

  • 两个 P1(findAnimatorState 非播放态返错 / removeStatedefaultState)+ 历史全部 P2(getCurrentAnimatorState nullable / _getAnimatorStateInfo 越界守卫 / event handler 无界增长 / AnimatorHang beforeAll / 双 JSDoc / 死 && 守卫 / _crossFade 重复守卫收窄 / 标题去 "shaderlab" / 测试去随机化)→ 历轮落定。
  • 重构简化(sampleAnimation 透传合并、completeCrossFade/clearCrossFadeSlot 内联、WeakMap+stateDataList 双源→单 Mapupdate() 热路径内联)、canWrap 修 Once 重放、WeakMap 化 curveOwner pool、state-removal 缓存失效(removeState_onChanged→dispatch→_reset,按对象 identity 索引、addState 非对称正确)→ 历轮核对正确。
  • 本轮源码除 a92f56bb3 三处派生外,其余 6 src 文件逐字节未变,结论沿用,不重提。

备注(P3,非阻塞,仅记录)

  • Animator.ts:473// Key owner lookup by Component identity instead of instanceId.)、Animator.ts:483// Keep layer owner lookup on the same Component identity path.)、AnimatorStatePlayData.ts:100// Reverse playback at clipTime=0 would step into negatives; jump to clipEnd.)三处单行 // 注释仍带句尾句号,违反「单行注释不加句号」。上轮已提一次,纯 cosmetic、不阻塞、不再展开。
  • _onDestroy 现在会 revert 动画值(新副作用)/ AnimatorControllerLayer.stateMachine addLayer 后重赋丢 _onChanged 回调(pre-existing footgun)→ 上轮已记 P3,本轮无变化,不重提。

自检

  • 重复检查:本轮无新 finding。我历轮唯一开放的 P2 由 a92f56bb3 修复并核对,归入「已闭环」,不再以任何形式重提。其余结论落在「已修复 / 已闭环 / 源码逐字节未变」。
  • 代码验证:fix 三处落点(1543/1570 派发 gate、1507/1517 wrap-reset、1621 外层 gate 移除)、前后向对称、新测试逐帧追踪,均经实读 PR head 源码 + 脱机反向证伪(FIXED F3 fire e0 / OLD F3 丢 e0)确认。真实 diff 7 文件口径、merge-base de75496876 经实读对照。

CI:lint / labeler 已绿,build×3 / codecov / e2e×4 进行中(与历轮一致的绿色基线)。本 PR 历经十余轮迭代,两个 P1 + 全部 P2(含本轮 fireEvents 跨 wrap 游标失同步这条静默丢事件回归)全部闭环建议合并

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

复审 @ f2d788bd(新 commit 闭环上轮 P3 footgun,--comment 可合并)

自上轮 --commenta92f56bb3,全部 P1+P2 闭环)以来推进一个 commit f2d788bd0 fix(animation): keep replaced state machines wired。这正是我上轮记录的 [P3] pre-existing footgunAnimatorControllerLayer.stateMachine 是公开字段,在 addLayer 之后重新赋值会丢掉 _onChanged 回调 → 新 state machine 的 removeState 不再触发缓存失效。本 commit 把它修成根上正确。

修复正确(落点在输入端,funnel 收口)

stateMachine 从公开字段改成 getter/setter(AnimatorControllerLayer.ts),setter 在每次换 state machine 时完成四件事:

  1. lastStateMachine._setChangeCallback(null) —— 摘掉旧 SM 的回调(避免旧 SM 仍持 layer 的失效信号);
  2. value._setChangeCallback(this._onStateMachineChanged) —— 把 layer 缓存的回调挂到新 SM;
  3. this._engine && value._setEngine(this._engine) —— 补齐引擎绑定;
  4. this._onStateMachineChanged?.() —— 立即 dispatch 一次,让换 SM 这个动作本身触发各 Animator 的缓存重建。

配套把 AnimatorControlleraddLayer/removeLayer/clearLayers/_setEngine 从直接戳 layer.stateMachine._setChangeCallback(...) 改为走 layer 新增的 _setStateMachineChangeCallback(onChanged)(同时记到 layer 的 _onStateMachineChanged 字段 + 转发给当前 SM)。这把"回调归属"从 controller 直接 reach-through 到 SM,收口成了 layer 自己持有 + 在 SM swap 时转发的 funnel —— 消除了 Demeter 违规,也让 setter 能在 swap 时正确重挂。架构方向对。

回归测试守得住(反向证伪成立)

新增 replacing a layer state machine keeps state removal invalidation(Animator.test.ts:1584)从公开 API 驱动:addLayerlayer.stateMachine = newSM(换 SM)→ play("Temp") 建缓存 → removeState(oldState) + addState/换 clip → 再 play → 断言 animatorStateDataMap.has(oldState)===falsehas(newState)===true

  • revert fix(恢复成公开字段)→ 新 SM 拿不到 _onChangedremoveState 不 dispatch → 缓存仍含 oldStatehas(oldState) 留 true → 测试 fail,是守得住换-SM 这条链的真回归测试,非戳私有的恒过桩。链路粒度正确(公开入口进、公开可观测态出,仅 _animatorLayersData 一处内部读用于断言缓存,可接受)。

边界核对(无 NPE / 无顺序坑)

  • setter 里 lastStateMachine 恒非 null(构造器初始化 _stateMachine = new AnimatorStateMachine()),lastStateMachine._setChangeCallback(null) 安全。
  • lastStateMachine === value 提前 return,自赋值无副作用。
  • _onStateMachineChanged 初始为 null,未加入 controller 前换 SM 时 value._setChangeCallback(null) + this._onStateMachineChanged?.() 均为 no-op,正确。
  • _setEngine 改成存 this._engine = engine 再转发给 SM,使 setter 的引擎补绑有据可依,顺序自洽。

结论

上轮 P3 footgun 已根上闭环,无新 P0/P1/P2,--comment,建议合并。 历史全部已闭环项(fireEvents 跨 wrap 游标同步、state-removal 缓存失效、_reset/_onDestroy revert 等价、WeakMap→Map identity)不再复述。剩余非阻塞 P3(若干单行注释带句号,历轮已提)维持,不阻塞。

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