Skip to content

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

Merged
cptbtptpbcptdtptp merged 93 commits into
galacean:dev/2.0from
luzhuang:fix/animation-loader
May 18, 2026
Merged

fix(animation, loader): 从 #2983 抽离动画与 GLTF 加载器修复#2999
cptbtptpbcptdtptp merged 93 commits into
galacean:dev/2.0from
luzhuang:fix/animation-loader

Conversation

@luzhuang

@luzhuang luzhuang commented May 12, 2026

Copy link
Copy Markdown
Contributor

Summary

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

物理 raycast/sweep 修复同步抽离至 #2998

动画 — Shared asset + per-Animator view

AnimatorState 一分为二:共享资产AnimatorState,继续是 controller 持有的资源)和 per-Animator 视图AnimatorStateInstance,新增类型)。视图按 (Animator, state) 对惰性创建,放在 AnimatorLayerDataWeakMap 里;视图上的 override 只影响所属 Animator,其它共享同一 controller 的 Animator 不受影响。

公开 API

  • findAnimatorState(name, layerIdx?): AnimatorStateInstance | null — 返回该 Animator 上的视图(lazy create on first access);controller mutation 后失效,需要重新调用获取新视图
  • getCurrentAnimatorState(layerIndex): AnimatorStateInstance | null — 返回当前正在播的视图;同样在 controller mutation 后失效
  • AnimatorStateInstance.speed / wrapMode — per-instance overrideable;未覆盖时 fall-through 到底层 AnimatorState 资产(编辑器调 asset 仍能传递到未声明 override 的 instance),覆盖后该 instance 独立持有自己的值
  • AnimatorStateInstance.name / clip / clipStartTime / clipEndTime — 透传访问底层资产字段

真 Bug 修复(用户视角)

每条均以"用户场景 → 现象 → 修复后"描述。

  1. 越界 layerIndex 调用 API 时崩溃

    • 用户:animator.play("walk", 99) / crossFade("walk", 0.3, 99) / findAnimatorState("walk", 99) / getCurrentAnimatorState(99)
    • 旧:layers[99].stateMachineTypeError: Cannot read property 'stateMachine' of undefined
    • 新:safe no-op(play/crossFade 不动,find/getCurrent 返回 null)
  2. state 没绑 clip 时调 findAnimatorState 崩溃

    • 用户:在 state 还没设置 clip 之前查这个 state(编辑器/异步加载场景常见)
    • 旧:访问 state.clip.length 抛 NPE
    • 新:正常返回 instance;播放时改为 logger warn
  3. 暂停状态(instance.speed = 0)transition 时下一帧目标 state 卡住

    • 用户:把某个 state 的 speed 设为 0 当暂停,然后 transition 到下一个 state
    • 旧:剩余 deltaTime 计算 0 / 0 = NaN,目标 state 这一帧 deltaTime 完全丢失,看起来卡了一帧
    • 新:speed=0 时整个 deltaTime 直接传给目标,过渡平滑
  4. play("A") 中断 fade 后,再 crossFade 到原 fade 目标失效

    • 用户:A→B fade 进行中改主意 play("C"),之后又想 crossFade("B")
    • 旧:残留的 destPlayData 触发 active-dest guard,第二次 crossFade 被静默吞掉
    • 新:play 自动清理残留 fade 状态,后续 crossFade 正常生效
  5. 同名 removeState + addState 后新 state 用旧 state 的曲线播放

    • 用户:stateMachine.removeState(oldWalk); stateMachine.addState("walk"); animator.play("walk")
    • 旧:string-key 缓存命中旧 state 的 curveLayerOwner,动画行为错误
    • 新:identity-based WeakMap cache,新 state 必然 miss 重建
  6. 删了 defaultState 之后自动播放仍指向已删 state

    • 用户:stateMachine.removeState(stateMachine.defaultState)
    • 旧:defaultState 字段仍持有被删 state 引用,下次 controller 重启/onEnable 尝试播放已删 state
    • 新:removeState 自动把 defaultState 置 null
  7. play() 后再 addComponent(Script),clip 里已存在的事件不触发

    • 用户:state.clip.addEvent(...)animator.play(...)entity.addComponent(MyScript)
    • 旧:lazy cache 仅跟踪 clip version,scripts 集合变化未触发重建,MyScript 永不收事件
    • 新:scripts 维度也纳入失效信号(Entity._scriptsVersion
  8. runtime 替换 state.clip 后,旧 clip 的事件仍触发

    • 用户:state.clip = newClip
    • 旧:cache key 读 clip._updateFlagManager.version,新旧 clip version 巧合相等时 cache 误命中,eventHandlers 仍用旧 clip
    • 新:cache key 改读 state._updateFlagManager.version(state 自己 funnel 了 clip swap + events 变两路)
  9. instance.speed override 在 crossFade 阶段被忽略

    • 用户:findAnimatorState("X").speed = 0.5; animator.crossFade("X", 0.3)
    • 旧:_updateCrossFadeStatestate.speed 而非 instance.speed,fade 阶段不遵守 per-instance override
    • 新:fade 阶段也用 instance.speed
  10. crossFade 到当前正在播放/正在 fade 的 state 导致动画错乱

    • 用户:A→B fade 进行中再 crossFade("B")crossFade("A")
    • 旧:PlayData 持久化设计下,src/dest 别名同一对象,resetForPlay 覆盖活跃运行轨迹 + update 被调两次 → 时间错乱、速度翻倍
    • 新:guard 拦截,self/active-dest crossFade 退化为 no-op(完整支持留作 follow-up)
  11. 多 root 骨骼 GLTF 加载 rootBone 解析错误或抛错

    • 用户:加载带 skin、但 skin.skeleton 缺失、且 joints 跨多个 scene root 的 GLTF
    • 旧:_findSkeletonRootBone fallback 抛错或挂错 root entity
    • 新:joints LCA 算法稳定解析(converged joints → 真实 skeleton root;spanning joints → GLTF_ROOT wrapper)
  12. GLTF skin.skeleton 索引越界静默失败

    • 用户:GLTF 文件中 skin.skeleton 指向不存在的节点索引(外部数据错误或工具导出 bug)
    • 旧:entities[skeleton] 静默 undefinedskin.rootBone = undefined → 后续蒙皮渲染报远离根因的错
    • 新:加载时 throw 明确错误 Skin skeleton index N is out of range.
  13. controller 增删 layer 后,getCurrentAnimatorState / findAnimatorState 返回失效引用

    • 用户:animator.animatorController.addLayer/removeLayer/clearLayers 之后查这两个 API
    • 旧:返回旧 layer data 上的 instance,可能指向已被移除的 state(dangling)
    • 新:API 入口走 _resetIfControllerUpdated(),结构变化后正确返回 null 或新 instance

架构简化

  • lazy version pull 替代 listener pushUpdateFlagManager 加 monotonic _version 计数器;AnimatorStateData.eventsBuiltVersion 快照式记录上次构建版本。clip events 变化触发 _version++,Animator 在 update/play 路径上 pull-check version 决定是否重建。消除了原来 listener push 路径上的反向引用 leak 风险,以及 clipChangedListener 注册/注销/dispose 一整套基础设施
  • WeakMap 替代 Object MapAnimatorLayerDataanimatorStateDataMap / instanceMap 都用 WeakMap<AnimatorState, T>;state 被 GC 时 entry 自动消失,无需手动清理
  • 删除 ClearableObjectPool<AnimationEventHandler> — 在我们的多 stateData 共享 + 各自独立失效语义下,pool 永远单调增长,从未起到复用作用;直接 new 更简单且无差异
  • stateMachine → controller dispatch 转发链 — WeakMap 改造后变得不必要,一并删除
  • cross-fade slot 字段管理收敛 — 引入 AnimatorLayerData.completeCrossFade() / clearCrossFadeSlot() 把散落在 Animator 的 slot 字段管理收回 LayerData 内部
  • self/active-dest crossFade no-op — 每个 state 一个持久视图,self-cross-fade 会让 src/dest alias,故 alias guard 把这种调用 no-op;完整 self-cross-fade 支持需要拆 persistent override view / transient runtime track,留作 follow-up

Lifecycle 不变量

  • Animator 持有的 instance 在 controller 结构变化时失效addLayer / removeLayer / clearLayers 触发 controller updateFlag → Animator 下次访问入口走 _reset(),丢弃所有 _animatorLayersData(包含 instanceMap)。Public API JSDoc 明示这条契约,用户必须重新调用 findAnimatorState / getCurrentAnimatorState 获取新视图
  • per-instance override 跨 transition 保留 — 一个 state 进出 fade 后再次进入时,instance 上的 speed / wrapMode override 不会被 PlayData.reset() 清掉(它们物理上在 AnimatorStateInstance 上,PlayData.reset 只重置运行时游标)

GLTF 加载器

  • fix(loader): resolve skin rootBone by joint LCAGLTFSkinParser._findSkeletonRootBone 重写为 _findSkeletonRootBoneByLCA:没有显式 skin.skeleton 时,rootBone 一律通过 joints 的最近公共祖先算出来。删除之前"无 skeleton 时 fallback 抛错"分支。GLTF_ROOT wrapping 由 dev/2.0 已合入的 GLTFSceneParser: Always create container root node for consistent animation bone paths #2942/fix(loader): always create GLTF_ROOT container for consistent animation paths #2943 保证,multi-root spanning joints 自然解析为 wrapper,converged joints 解析为真实 skeleton root(如 Character_Root
  • GLTFSceneParser 同步写 _sceneRoots[i] — 已随 LCA-only rootBone 方案移除(LCA 不依赖 _sceneRoots),post-load 仍由 _handleSubAsset 异步填充
  • GLTFParserContext Scene-before-Skin parse order — 把 Scene 从并行 parse 列表的尾部前置到 Skin 之前;显式注释 LCA 依赖 wrapper 已挂 parent chain 的不变量,并警告 Skin parser 不能 await full Scene(避免 _createRenderer 反向请求 Skin 造成循环依赖)
  • GLTFSkinParser skin.skeleton 越界 guard — 显式 throw Skin skeleton index N is out of range.,不再静默 undefined

用户文档 + 示例

  • docs/{en,zh}/animation/animator.mdx — 同步新 API:findAnimatorState 返回 AnimatorStateInstance | null 加 null guard、getCurrentAnimatorState 返回 AnimatorStateInstance | null 加 null guard、instance.speed 暂停/恢复示例
  • e2e/case/animator-*.ts(5 个文件) — 适配 findAnimatorState 新返回类型,调用点加 null check;视图字段命名 idleState / walkState / runState(变量名指代视图本身,而不是 def 资产)
  • 测试tests/src/core/Animator.test.ts,+775/-256 行) — 新增 51 个测试覆盖 per-instance override / fall-through / crossFade 保留语义 / lazy version 重建 / WeakMap 等行为

抽离说明

Breaking changes (2.0)

  • 新增 AnimatorStateInstance 类型,从 @galacean/engine-core 公开导出
  • Animator.findAnimatorState() 返回 AnimatorStateInstance | null(旧返回 AnimatorState
  • Animator.getCurrentAnimatorState() 返回 AnimatorStateInstance | null(旧返回 AnimatorState,且 out-of-range layer 会抛错)
  • AnimatorStateInstance 上的 overridespeed / wrapMode 是 per-Animator 字段,fall-through 到底层 AnimatorState;视图上的 override 不影响其它 Animator
  • 视图生命周期AnimatorStateInstance 在 controller 结构变化(addLayer / removeLayer / clearLayers)后失效;用户持有的旧引用变成 orphan,必须重新调用 findAnimatorState / getCurrentAnimatorState 获取
  • AnimatorStateMachine.defaultState 类型从 AnimatorState 改为 AnimatorState | nullremoveState 删除当前 default state 时自动置 null
  • AnimatorStateMachine.removeState 行为更精确:state 不在 array 中时整体跳过(包括 _statesMap 清理),避免误删同名 entry
  • Animator.findAnimatorState/play/crossFade 对 out-of-range layerIndex 改为 safe no-op(旧实现会抛错)

量化

  • animation 范围:8 files,+883 / -256(包含测试)
  • 全部 13 个包 b:types 通过
  • 全部 52 个 Animator 测试通过

luzhuang added 30 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
Promote AnimatorStatePlayData from a play-slot object to a per-Animator
per-state persistent handle. Each AnimatorLayerData holds a state→PlayData
map; srcPlayData/destPlayData become nullable references into the map.

API:
- findAnimatorState(name, layerIdx?) returns AnimatorStatePlayData|null,
  lazy-creating the handle on first access (works even when the state has
  never played)
- playData.speed is a getter/setter backed by _speedOverride; reads
  fall back to state.speed (live binding); clearSpeedOverride() resumes
  tracking the shared default
- playData.state.xxx for shared asset access (no proxy properties)
- resetForPlay() resets runtime fields only; user overrides survive
  transitions

Bugs fixed:
- _updateCrossFadeState now multiplies by playData.speed (was state.speed),
  so per-instance speed applies during cross-fade
- findAnimatorState no longer returns the wrong state's playData when the
  queried state isn't currently playing (was: fell back to srcPlayData)

Lifecycle changes:
- AnimatorLayerData.statePlayDataMap caches per-state handles
- switchPlayData() replaced by promoteDest() (src ← dest, dest = null)
- _preparePlay/_prepareCrossFade get-or-create from the map and assign
  references rather than reset slot objects

Cleanup:
- Remove AnimatorStatePlayData proxy properties (name/clip/wrapMode/
  transitions/addStateMachineScript) — use playData.state.xxx instead
- Drop @todo on findLayerByName and duplicate JSDoc on findAnimatorState
…ernal/

Address code quality review on 57da59a:

- AnimatorStatePlayData constructor no longer reads state.clip; clipTime
  defers to resetForPlay so findAnimatorState doesn't crash for states
  with no clip yet
- Move AnimatorStatePlayData from internal/ to animation/ root since it
  is now public API returned by findAnimatorState; update imports
- Annotate findAnimatorState and getCurrentAnimatorState return types as
  | null to match runtime behavior
- Remove dead && guards in _updateCrossFadeState (layerState guarantees
  non-null entry)
- Tighten AnimatorLayerData field comments
Add 6 regression tests covering the new findAnimatorState handle:
- lazy create on first access (state never played)
- speed override set before play applies on first play
- override survives crossFade out and back
- override is per-Animator (clone isolation, shared asset unmutated)
- crossFade phase uses playData.speed (was state.speed before fix)
- clearSpeedOverride resumes live tracking of state.speed

Fix existing call sites broken by proxy removal: tests that accessed
state.clip / state.clearTransitions / state.clipStartTime etc. now go
through state.state.xxx (the shared AnimatorState). state.speed reads
and writes remain on the per-instance handle.
Address code quality review:
- Test #1 now uses a cloned animator (no afterEach pre-population) so it
  actually verifies lazy PlayData creation; rename to match intent
- Test #2 drops @ts-ignore on _animatorLayersData by reading the override
  through the same handle returned by findAnimatorState
- Test #5 tightens >0.1 threshold to closeTo(0.2, 0.05) so a regression
  reducing the multiplier wouldn't slip past
- Align .eq/.greaterThan calls with the file's .to.eq/.to.be convention
Previously walk-up went all the way to GLTF_ROOT (the wrapper, no parent),
but sceneRootChildren contains GLTF_ROOT's direct children — never
GLTF_ROOT itself. Result: function always returned null, making multi-root
skin wrapper detection a no-op.

Stop the walk as soon as the entity is a direct child of the scene root.
The final check then succeeds for joints under any sceneNode, returning
the wrapper sceneRoot as rootBone.

Verified via standalone reproduction matching the test fixture.
When entity X had a child also named X, findByPath("X") short-circuited to
return self due to the GLTF self-name prefix branch — making the same-name
child unreachable.

Try direct child lookup first; fall back to the self-name prefix only when
the child path doesn't match. Both the GLTF normalized-prefix case and the
same-name child case work correctly.
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).
…n flag

- _prepareCrossFadeByTransition guards against crossFade to current src
  or dest state, since statePlayDataMap holds a single PlayData per
  AnimatorState; without the guard, dest aliases to src, resetForPlay
  clobbers the active runtime, and _updateCrossFadeState updates the
  same object twice
- AnimatorStatePlayData.resetForPlay also resets _changedOrientation so
  re-entering a state doesn't carry the previous track's orientation
  flag into the new playback window

True self-crossfade support requires splitting persistent override fields
from transient src/dest runtime tracks; out of scope for this PR.
When the entity has a child with the same name as splits[0], findByPath
must not fallback to the self-prefix interpretation: the user clearly
intends to descend into the child, and a deeper-path miss should return
null rather than silently re-resolve the path against the entity itself.
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
findAnimatorState now returns AnimatorStatePlayData | null. e2e cases
were dereferencing without a guard, which would surface as
"Cannot read properties of null" if a state name doesn't match the
asset. Add fail-fast guards naming the missing state for actionable
errors.
… multiple roots

Previously: if all joints were under any sceneNodes' subtrees,
_findSceneRootBone returned GLTF_ROOT, even when joints converged to a
single top-level child. That over-promoted the rootBone to include
unrelated sibling nodes (lights, cameras, props), affecting bounds.

Now: track which top-level child each joint resolves to. Only return
sceneRoot when joints span >1 different top-level children. Otherwise
fall through to _findSkeletonRootBone for the LCA.
When play() interrupts a cross-fade, destPlayData and crossFadeTransition
were left dangling. With persistent statePlayDataMap, this caused the
self-crossFade alias guard to wrongly no-op subsequent crossFade calls
to the previously-fading state.

Clear destPlayData and crossFadeTransition on play() entry so the layer
state matches reality.
If the requested state name doesn't match any layer, _getAnimatorLayerData
was being called with playLayerIndex = -1, which would write a junk
AnimatorLayerData entry at array index -1 (JS array negative indexing
creates a property). Guard the lookup at the entry point.
Bring the JSDoc tag in line with the other engine-managed runtime fields
on AnimatorStatePlayData (playedTime/clipTime/etc.) so docs/IDE filtering
treats them uniformly.
Self-prefix fallback called _findChildByName with pathIndex=1, whose
not-found backtrack path recursed into entity.parent — for detached or
root entities, that's null and crashes on null._children. Use
splits.slice(1) with pathIndex=0 so the recursion stays within the
entity's subtree and returns null cleanly when the deeper path misses.

Also retitle the fallback comment to a generic path-semantics
description, since core/Entity should not carry GLTF-specific framing.
When called with an out-of-range layerIndex, _getAnimatorStateInfo
accessed layers[idx].stateMachine and threw. This propagated to
findAnimatorState (which is supposed to return null) and to play /
crossFade entry points. Bound-check the index and return a stateInfo
with layerIndex = -1 / state = null so all three callers see safe
behavior.
When per-instance state speed is 0 (paused) and a transition fires,
playCostTime / playSpeed produced NaN, which made remainDeltaTime > 0
evaluate false and the destination state silently dropped the remaining
delta on that frame. Treat speed=0 as "no time consumed by this state"
and pass deltaTime through to the destination instead.
GLTFSkinParser._findSceneRootBone reads glTFResource._sceneRoots which
GLTFSceneParser populates synchronously. The current AssetPromise.all
ordering preserves this; document the invariant so a future array
reorder doesn't silently break skin root resolution.
…hine

Bring local AnimatorStateTransition declarations into line with the
project's camelCase convention.
…te(null) idiom

AnimatorLayerData already used Record-style maps for animatorStateDataMap
and curveOwnerPool; statePlayDataMap was the only Map in the animation
module. Layer-internal stateName is canonical (AnimatorStateMachine
deduplicates by name). Switch to the project's standard pattern for
intra-class consistency and v8 hidden-class friendliness on small caches.

Also normalize animatorStateDataMap initialization to Object.create(null)
for the same null-prototype safety as curveOwnerPool.
The example showed `playData.speed = 0` immediately followed by
`playData.clearSpeedOverride()`, which silently cancels the override.
Comment out the resume call and label it as a later-stage operation so
copy-pasting actually pauses the state.
The previous comment phrased the guard as a temporary workaround. The
behavior is in fact deliberate: per-state persistent PlayData makes
self-cross-fade structurally inexpressible without a separate transient
track. Phrase the comment so future readers understand it as policy.
Replace per-scene Set<Entity> creation with parent-walk identity checks.
Tracks first-encountered top-level joint root and compares subsequent
joints by reference, returning sceneRoot the moment a divergent root is
found.
Replace splits.slice(1) + _findChildByName(pathIndex=1) with a dedicated
subtree-only path-search helper. Two improvements: no array allocation
on every fallback, and the "fallback never backtracks to siblings"
semantic is now expressed in the helper's contract instead of relying
on the caller to neutralize backtracking via slicing.
GuoLei1990

This comment was marked as outdated.

…t + skin index guard

- tests: "find animator state" was comparing `(instance as any)._state`
  (AnimatorState) against `getCurrentAnimatorState` (AnimatorStateInstance),
  which can never be equal. Also fix the layerIndex source — it was
  read from `_tempAnimatorStateInfo` *before* play() populated it, so
  it was -1 instead of 0.
- Animator.getCurrentAnimatorState now calls _resetIfControllerUpdated()
  so it doesn't return a stale instance after a controller mutation,
  consistent with play/crossFade/findAnimatorState/update.
- Add @remarks on both getCurrentAnimatorState and findAnimatorState
  spelling out that the returned instance is invalidated by controller
  structure changes (layers added/removed) and must be re-fetched.
- GLTFSkinParser: when `skin.skeleton` is an out-of-range index, throw
  a precise error instead of silently assigning `undefined` to
  `skin.rootBone` (which surfaces as a confusing error later).
…field refs

Two issues surfaced when running the full Animator suite:

1. animation event test fails — the lazy `_ensureEventHandlers` was only
   invoked at play()/crossFade() entry, so a clip.addEvent() after
   play() didn't rebuild handlers on the next update. Move the ensure
   call into `_fireAnimationEventsAndCallScripts` so update-path
   re-checks the clip version too.

2. Three tests still reference fields that moved during the refactor:
   - `srcPlayData.speed` — speed is now on the instance, change to
     `srcPlayData.instance.speed`
   - `layerData.destPlayData?.state.name` (two sites) — PlayData no
     longer has `state`, change to `.instance.name`

All 52 Animator tests pass after this commit.
GuoLei1990

This comment was marked as outdated.

`getCurrentAnimatorState` now returns the per-Animator view
(`AnimatorStateInstance`), which only exposes getters for these
clip-framing fields. Writing on the view was a silent no-op, so the
blendShape e2e never actually clamped the clip range — page timed out
waiting for the expected frame.

The intent is to freeze playback at frame 1, which is shared-state
behavior (every Animator using this controller would freeze the same
way). Write directly on the AnimatorState asset.
GuoLei1990

This comment was marked as outdated.

@luzhuang

Copy link
Copy Markdown
Contributor Author

@GuoLei1990 整体方向都正确(AnimatorStateInstance 拆分、WeakMap<AnimatorState, ...> identity cache、crossFade slot 收敛到 AnimatorLayerDatagetCurrentAnimatorState 加 controller-update reset、loader 改 LCA-only 后回退 _sceneRoots[i] 同步写入),没看到把已有修复改坏的地方。合入前还有 1 个 bug、1 个 corner case 想确认,外加 1 个 PR Summary 与代码不一致。


1. _ensureEventHandlers 对 "play 后 addComponent(Script)" 漏 rebuild

// Re-check whether the clip events/scripts changed since the last build —
// play()/crossFade() entry points already ensure on enter, but addEvent()
// or addComponent(Script) after play() must also flow through.
this._ensureEventHandlers(state, playData.stateData);

这段注释明确写了 addComponent(Script) after play() must also flow through,但实际 _ensureEventHandlers 的 early return 只比较 state.clip._updateFlagManager._version

private _ensureEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void {
const clipFlag = state.clip._updateFlagManager;
if (animatorStateData.eventsBuiltVersion === clipFlag._version) {
return;
}
const scripts = [];
this._entity.getComponents(Script, scripts);
const scriptCount = scripts.length;
const { events } = state.clip;
const { eventHandlers } = animatorStateData;
eventHandlers.length = 0;
for (let i = 0, n = events.length; i < n; i++) {
const event = events[i];
const eventHandler = new AnimationEventHandler();
const funcName = event.functionName;
const { handlers } = eventHandler;
eventHandler.event = event;
for (let j = scriptCount - 1; j >= 0; j--) {
const script = scripts[j];
const handler = <Function>script[funcName]?.bind(script);
handler && handlers.push(handler);
}
eventHandlers.push(eventHandler);
}
animatorStateData.eventsBuiltVersion = clipFlag._version;
}

Entity.addComponent 不会 dispatch clip flag。所以 “事件已在 clip 上 → play → addComponent(Script) → update” 这个顺序下,handlers 不会重建,新 Script 永远收不到事件。

现有 animation event 测试顺序是 play → addComponent → addEvent → update,第三步 addEvent 顺手把 clip _version bump 了,把这个漏报盖住了:

it("animation event", () => {
animator.play("Walk");
class TestScript extends Script {
event0(): void {}
}
const testScript = animator.entity.addComponent(TestScript);
const testScriptSpy = vi.spyOn(testScript, "event0");
const event0 = new AnimationEvent();
event0.functionName = "event0";
event0.time = 0;
const state = animator.findAnimatorState("Walk");
state.clip.addEvent(event0);
animator.update(10);
expect(testScriptSpy).toHaveBeenCalledTimes(1);
});

新增的 eventHandlers rebuild lazily when state.clip events change 也只覆盖 clip flag dispatch 路径,没覆盖 Script 集合变化:

it("eventHandlers rebuild lazily when state.clip events change", () => {
const survey = animator.findAnimatorState("Survey");
expect(survey).not.to.eq(null);
const surveyState = (survey as any)._state;
animator.play("Survey");
// @ts-ignore — internal layerData / stateData
const layerData = animator._animatorLayersData[0];
const stateData = layerData.animatorStateDataMap.get(surveyState);
expect(stateData, "stateData should exist after play").to.not.eq(undefined);
const versionBefore = stateData.eventsBuiltVersion;
expect(versionBefore).to.be.greaterThanOrEqual(0);
// Dispatching the clip flag bumps clip's version → next access invalidates eventsBuiltVersion.
surveyState.clip._updateFlagManager.dispatch();
// Next play / state-data access triggers _ensureEventHandlersUpToDate and rebuilds.
animator.play("Survey");
// @ts-ignore
const layerDataAfter = animator._animatorLayersData[0];
const stateDataAfter = layerDataAfter.animatorStateDataMap.get(surveyState);
expect(stateDataAfter.eventsBuiltVersion).to.be.greaterThan(versionBefore);
});

建议补一个红测,大致形态:

it("eventHandlers rebuild when Script is added after play (no clip event mutation)", () => {
  const event = new AnimationEvent();
  event.functionName = "event0";
  event.time = 0;
  const state = animator.findAnimatorState("Walk")!;
  state.clip.addEvent(event); // 事件先存在

  animator.play("Walk");      // 此时 Script 还没挂

  class TestScript extends Script {
    event0(): void {}
  }
  const script = animator.entity.addComponent(TestScript); // 不 bump clip _version
  const spy = vi.spyOn(script, "event0");

  animator.engine.time._frameCount++;
  animator.update(0.1);
  expect(spy).toHaveBeenCalledTimes(1); // 当前会失败
});

实现上最小改动:_ensureEventHandlers 同时记录 script 集合快照(数量 + instanceId hash),Script 集合变化时强制重建。


2. 同一 state 替换 clip 可能漏触发 rebuild(corner case)

set clip(clip: AnimationClip) {
const lastClip = this._clip;
if (lastClip === clip) {
return;
}
if (lastClip) {
lastClip._updateFlagManager.removeListener(this._onClipChanged);
}
this._clip = clip;
this._clipEndTime = Math.min(this._clipEndTime, 1);
this._onClipChanged();
clip && clip._updateFlagManager.addListener(this._onClipChanged);
}

clip setter 只 dispatch state._updateFlagManager,Animator 全文不 listen state-level flag。_ensureEventHandlers 比的是 state.clip._updateFlagManager._version(swap 后已指向新 clip)。如果新 clip 是 fresh(_version === 0),而旧快照恰好也是 0,会误命中 early return,把旧 clip 构建的 eventHandlers 留着用。实战概率低但语义不正确。

最小修法:把 snapshot 改成 “clip identity + clip version” 复合 key;或改用 state._updateFlagManager._version(state 自己在 clip 变化时已 dispatch)。可以放 follow-up,但建议至少有 issue 跟踪。


3. PR Summary 与代码不一致

PR body 还保留:

GLTFSceneParser 同步写 _sceneRoots[i] — 与 _defaultSceneRoot 在同 tick 可见 ...

但最终 GLTFSceneParser.ts 已没有这个写入(LCA-only 后不依赖):

export class GLTFSceneParser extends GLTFParser {
parse(context: GLTFParserContext, index: number): AssetPromise<Entity> {
const {
glTF: { scenes, scene = 0 },
glTFResource
} = context;
const sceneInfo = scenes[index];
const sceneExtensions = sceneInfo.extensions;
const engine = glTFResource.engine;
const isDefaultScene = scene === index;
const sceneNodes = sceneInfo.nodes || [];
let sceneRoot: Entity;
sceneRoot = new Entity(engine, "GLTF_ROOT");
// @ts-ignore
sceneRoot._markAsTemplate(glTFResource);
for (let i = 0; i < sceneNodes.length; i++) {
sceneRoot.addChild(context.get<Entity>(GLTFParserType.Entity, sceneNodes[i]));
}
if (isDefaultScene) {
glTFResource._defaultSceneRoot = sceneRoot;
}
const promises = new Array<AssetPromise<void[]>>();
for (let i = 0; i < sceneNodes.length; i++) {
promises.push(this._parseEntityComponent(context, sceneNodes[i]));
}
return AssetPromise.all(promises).then(() => {
GLTFParser.executeExtensionsAdditiveAndParse(sceneExtensions, context, sceneRoot, sceneInfo);
return sceneRoot;
});
}

建议 Summary 那条改成 “_sceneRoots[i] 同步写入已随 LCA-only rootBone 方案移除,post-load 仍由 _handleSubAsset 异步填充”,免得后续 reviewer 误以为最终代码还包含这个行为。

… eventHandlers cache

Address two corner-case rebuild misses in `_ensureEventHandlers`.

1. Script-add after play()
   Lazy cache only compared clip._updateFlagManager._version. addComponent(Script)
   on the Animator's entity does not bump any clip flag, so a script attached
   after play() never received existing clip events.
   Fix: Entity owns a `_scriptsVersion` counter bumped on _addScript/_removeScript;
   AnimatorStateData snapshots `eventsBuiltScriptsVersion`; rebuild on mismatch.

2. Clip swap with coincidental matching version
   `state.clip = newClip` already dispatches `state._updateFlagManager` (via
   _onClipChanged) and clip-internal addEvent/clearEvents bubbles through the
   same path. Switch the cache key from `clip._updateFlagManager.version` to
   `state._updateFlagManager.version` — state is the funnel for both clip-swap
   and clip-events-mutation, so a single version covers every input that
   affects eventHandlers binding.

Also:
- Promote `UpdateFlagManager._version` to a documented public `version` field
  (the class is @internal, so this stays engine-internal).
- afterEach cleans clip events to keep test isolation.
- Two red tests: scripts-added-after-play, and clip-swap-rebuilds-handlers.
@GuoLei1990

Copy link
Copy Markdown
Member

@luzhuang 三条都处理了,逐条确认:


1. ✅ _ensureEventHandlers script-add 漏 rebuild

按你的方向落地了。设计上选了让 source-of-truth 提供失效信号而不是消费者 hash:

  • Entity 上加 _scriptsVersion = 0,_addScript / _removeScript 时单调 bump(O(1))
  • AnimatorStateData 上加 eventsBuiltScriptsVersion = -1 快照
  • _ensureEventHandlers 复合判定: eventsBuiltVersion === stateVersion && eventsBuiltScriptsVersion === scriptsVersion

为什么走 entity-level version 而不是 Animator-local hash:

  • scripts 集合的 owner 是 Entity,失效信号挂在 owner 上是正确归属
  • _renderCount / _blendShapeCount 等已有 internal counter 风格一致
  • 任何其它需要监听 scripts 变化的消费者都能复用
  • swap 同类型 script 场景下,hash 方案需要 instanceId 兜底,version 方案天然覆盖

你的 red test 一字不改加进去了,以及补了 afterEach 清 clip events 防跨 test 污染。

2. ✅ clip swap corner case

走的是你建议的方案 B(改用 state-level dispatch),不是复合 key:

  • cache key 从 state.clip._updateFlagManager._version 改为 state._updateFlagManager.version
  • 理由:state._updateFlagManager_onClipChanged 里同时处理 ① clip-swap 自身触发 + ② clip 内 events 变 via listener 转发;state 是事件绑定的语义 funnel,它的 version 单一覆盖两路输入
  • 比"clip identity + clip version 复合 key"更干净 — 消费者不需要维护两份元数据,信号收口在 state 这层抽象上

顺带把 UpdateFlagManager._version 改为 version(@internal class 内,加了 JSDoc 说明它是 lazy pull-style cache invalidation 信号)。

补了 red test: eventHandlers rebuild when state.clip is swapped (state version covers clip swap),通过把 walkState.clip 换成 runState.clip 验证 swap 触发重建(原 cache key 在两个 clip version 巧合相等时会漏报)。

3. ✅ PR Summary 已更新

按你建议的措辞改了:

GLTFSceneParser 同步写 _sceneRoots[i] — 已随 LCA-only rootBone 方案移除(LCA 不依赖 _sceneRoots),post-load 仍由 _handleSubAsset 异步填充

用删除线保留历史诉求 + 明确说明已移除原因,避免后续 reviewer 误读。


新增 commit: 524e8f81 fix(animation): cover script-add and clip-swap invalidation paths for eventHandlers cache

测试: 54/54 Animator tests pass(原 52 + 2 个新增 red test)
Build: 全部 13 个包 type-check 通过

请再 check 一下方向是否对得上。

GuoLei1990

This comment was marked as outdated.

…teData

After WeakMap<AnimatorState, T> identity caching landed, the stateName
string parameter was no longer referenced inside _getAnimatorStateData
(map.get uses the AnimatorState object identity directly). Drop the
dead parameter and inline the now-stale local `name` variable in
_preparePlay's only remaining use.
GuoLei1990

This comment was marked as outdated.

When `skin.skeleton` is absent and joints contain only bones, the previous
joint-only LCA returned the bone root (e.g. mixamorig:Hips) rather than the
GLTF_ROOT wrapper that also dominates the skinned mesh node's local frame.
Real-world glTFs put the mesh node outside `skin.joints` and reference the
skin via `node.skin`, so the mesh node has to be folded into the LCA inputs.

`_findSkinRootBoneByLCA` prepends mesh nodes whose `node.skin === skinIndex`
to the joint list and reuses the path-walk LCA. The explicit `skin.skeleton`
out-of-range guard is preserved.

The existing `testSkinRoot` fixture was masking the gap by placing
Character_Man inside `joints`; the regression test now uses joints=[1,2]
and `Character_Man.skin = 0`, exercising the LCA-via-mesh-node path.

Ported from luzhuang/fix/shaderlab@4a8342b7d.
GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

Comment thread packages/core/src/animation/Animator.ts Outdated
return;
}

const scripts = [];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

这个数组可以缓存一下,不需要每次都新生成。

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.

已修复 (commit 4699ea5)。

Animator._tempScripts 静态字段复用,参照引擎里 PostProcessManager._tempColliders 的同模式。使用前后都 scripts.length = 0 清空,避免持有 stale script 引用造成弱 leak。

private static _tempScripts: Script[] = [];

private _ensureEventHandlers(...) {
  // ...
  const scripts = Animator._tempScripts;
  this._entity.getComponents(Script, scripts);
  // ... build handlers ...
  scripts.length = 0;
}

顺带一起处理了同段代码的另一个用户感知 bug:disabled Script 仍收事件 — handlers 数组以前存 Function[] (bind 出来的 closure),丢失 script 引用所以 fire 时无法查 script.enabled。改成 { script, fn }[],fire 时 script.enabled && fn.call(script, parameter),对齐 Unity 行为基线。

…euse temp scripts array

Two issues addressed:

1. Disabled scripts still received animation event callbacks.
   The handlers array previously stored bind-out closures (`Function[]`),
   which discarded the originating Script reference — fire path had no
   way to check `script.enabled`. Change AnimationEventHandler.handlers
   to `{ script, fn }[]` so the fire path pulls `script.enabled` directly
   from the source of truth at fire time. Unity parity: disabled
   MonoBehaviours do not receive OnAnimationEvent.

2. `_ensureEventHandlers` allocated a new `[]` every rebuild as the
   getComponents out array. Switch to a static `Animator._tempScripts`
   reused across rebuilds (clear-on-exit to drop script references),
   matching the engine convention (e.g. `PostProcessManager._tempColliders`).

Drop `IPoolElement` + empty `dispose()` from AnimationEventHandler — the
ClearableObjectPool was removed earlier in this PR and these had become
dead code.

Red test added: "disabled Script does not receive animation events"
verifies enable→fire, disable→silent, re-enable→fire cycle. 55/55 pass.
…Scripts and dead-code cleanup

Previous commit introduced `script.enabled && fn.call(...)` filtering in
the fire path and changed handlers to `{ script, fn }[]` to support it.
After a closer look at Unity / Unreal / Godot, all three intentionally do
NOT filter AnimationEvent dispatch by component-level enabled — the
event belongs to the clip's timeline, not to the script's lifecycle, so
`script.enabled` (which scopes Update/OnEnable) has no jurisdiction over
it. Galacean should follow the same boundary.

Revert:
- handler invocation back to direct `handlers[j](parameter)` (base shape)
- AnimationEventHandler.handlers back to `Function[]`
- drop the disabled-script red test

Keep (still valid improvements):
- `Animator._tempScripts` static array reuse — addresses
  @cptbtptpbcptdtptp's review point, mirrors `PostProcessManager._tempColliders`
- Drop `IPoolElement` + empty `dispose()` from AnimationEventHandler —
  dead since the ClearableObjectPool removal earlier in this PR

54/54 Animator tests pass.
GuoLei1990

This comment was marked as outdated.

Class doc condensed from 4-line prose to 2-line summary (core model + override
semantics). The four pass-through getters (name/clip/clipStartTime/clipEndTime)
have no JSDoc — their name + 1-line body is self-describing. speed/wrapMode
JSDoc reduced to a single sentence each, kept in multi-line form per project
comment-style convention.
Previous commit removed JSDoc from name/clip/clipStartTime/clipEndTime arguing
"name is self-describing". That was wrong — they are public API on a class
exported from @galacean/engine-core, so users need hover docs in VSCode and
TypeDoc output. Pass-through wrappers still need a one-sentence doc; not
writing one is only acceptable for internal/private fields.

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

增量审查(2026-05-18)

已关闭问题清单

所有历史跟踪问题均已修复,无遗留。上轮(2026-05-18T08:09:57Z HEAD c303355)结论 LGTM。

本轮新增 commits(fca8845151851c1e8f

均为纯文档变更,仅涉及 AnimatorStateInstance.ts,无实现代码改动。

fca884515 docs(animation): trim AnimatorStateInstance JSDoc

  • 将 class 级注释从 4 行精简为 2 行,保留核心语义(override 字段作用域 + 未设置时透传)。
  • 移除了 name / clip / clipStartTime / clipEndTime 四个 pass-through getter 的 JSDoc,认为其自说明;将 speed / wrapMode 的注释各缩为单行。

1851c1e8f docs(animation): restore JSDoc on pass-through public getters

  • 立即撤回了上一 commit 对 4 个透传 getter 移除 JSDoc 的决策。理由正确:这四个 getter 是 @galacean/engine-core 对外导出的公开 API,用户依赖 VSCode hover 和 TypeDoc 产物,不能无注释。
  • 每个 getter 补回了一行说明(The name/clip/... of the underlying state)。
  • 最终状态:所有公开 getter 均有 JSDoc,类级注释简洁且准确,speed / wrapMode 的单行描述也正确传达了 override 语义。

检查结论:

  • 两个 commit 组合后语义自洽,最终文件状态正确。
  • 注释内容与实现一致:speed/wrapMode 确实是 _speed ?? _state.speed 透传逻辑;类级 JSDoc 与实际 API 契约匹配。
  • 无新增实现风险点。

总结

本轮变更纯属文档调整,最终 AnimatorStateInstance 的 JSDoc 状态规范、准确、完整。LGTM,无新问题。

@cptbtptpbcptdtptp cptbtptpbcptdtptp added documentation Improvements or additions to documentation and removed documentation Improvements or additions to documentation labels May 18, 2026
@cptbtptpbcptdtptp
cptbtptpbcptdtptp merged commit f728209 into galacean:dev/2.0 May 18, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Animation Built-in animation system related functions documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants