Skip to content

feat(animation): full self-crossFade via persistent handle + transient track split #2986

@luzhuang

Description

@luzhuang

背景

PR #2984AnimatorStatePlayData 升级为"per-Animator per-state 持久 handle"模型,每个 state 在每个 layer 内只有一个 PlayData,承载两类数据:

  • per-instance override(如 _speed
  • runtime track 状态(_playedTime / _clipTime / _playState / _currentEventIndex / _isForward / _offsetFrameTime 等)

当前限制

由于一个 state 对应一份 PlayData,crossFade 到当前 src 或 active dest 时会让 srcPlayData === destPlayData,runtime 字段被双重更新:

  • _playedTime 在 src track 和 dest track 路径各加一次
  • _currentEventIndex 在 dest track 路径 reset,事件可能重复触发
  • _clipTime 在两条路径写入互相覆盖

为避免数据损坏,_prepareCrossFadeByTransition 加了 alias guard 把这种调用变成 no-op(Animator.ts:1454-1466,commit 9f2992b49 / 注释 5a2cd7417 / 15dd5b4b8):

if (animatorLayerData.srcPlayData?.state === crossState
 || animatorLayerData.destPlayData?.state === crossState) {
  return false;
}

失去的能力

  • self-restart 平滑过渡:受击 / 重击 / 特定事件触发的"重新进入当前 state + 短 cross-fade"用户调 crossFade(currentState, 0.2) 期望平滑重启 → 当前直接 no-op
  • 重定向到 active dest:cross-fade 进行中改变目标(如 Walk → Run 进行中又切到 Run 的新一份)→ 当前直接 no-op
  • 同 state 多实例重叠:多发射弹道 / 多个手部动画叠加 → 当前模型不支持

Unity 对照

Unity Mecanim 用 transient Playable + PlayableGraph 模型,不会出现 alias:

维度 Unity Mecanim Galacean 当前
state asset 跟播放实例 1 asset : N playable(多 instance 同时活) 1 asset : 1 handle
播放实例生命周期 transient — 每次 play/crossFade 新建 persistent — layerData 生命周期
per-instance 状态 parameter 外置(name 绑定 + 乘法叠加) handle 上的 _speed 字段
Self-crossFade 自然支持(新建 playable,旧 playable 继续衰减) 必须 alias guard no-op

Unity animator.CrossFade("Walk", 0.3) 当 Walk 已在播 → 旧 Walk playable 继续运行,新建一个 Walk fresh playable,mixer 在两者间渐变。两个 Walk playable 是不同对象,无 alias。

提议方案 — 拆两层数据所有权

现在:
  AnimatorStatePlayData (1 per state, persistent)
    ├─ _speed                  // override
    ├─ _playedTime / _clipTime / _playState / _currentEventIndex / _isForward / _offsetFrameTime
    └─ _stateData

拆成:
  AnimatorStatePlaybackHandle (1 per state, persistent)
    └─ _speed                  // per-instance override

  AnimatorStatePlayTrack (N per state, transient)
    ├─ _playedTime / _clipTime / _playState / _currentEventIndex / _isForward / _offsetFrameTime
    ├─ _stateData
    └─ → 关联到对应的 PlaybackHandle 拿 override

调用点:

  • srcPlayData / destPlayData 改为指 AnimatorStatePlayTrack(transient,每次 play / crossFade 新建)
  • 移除 alias guard,crossFade(X→X) 时 src 是旧 track,dest 是新 track,两个对象天然不同
  • findAnimatorState 返回 AnimatorStatePlaybackHandle(公开 API surface 不变,承载语义不变)
  • override 仍按 state.name 索引 PlaybackHandle,跨 transition 保留

取舍

收益

  • self-crossFade 完整支持
  • runtime 状态跟 override 解耦,未来加更多 per-track 字段不影响 override
  • src/dest 不再可能 alias,模型本身排除一类 bug

代价

  • 每次 play / crossFade 新建一个 PlayTrack 对象(脱离 lazy 暖机模型)
  • LayerData 数据结构变(statePlayDataMapstateHandleMap + 一组 active tracks)
  • Animator.ts 内 ~30 处访问点要分清访问 "override" 还是 "runtime"
  • 测试要重写(特别是 cross-fade 相关)

范围

不在本 PR 内做。本 PR (#2984) 的工程取舍是 alias guard no-op + 注释明确"这是设计语义而非临时兜底",给未来重构留好回放点。

引用

  • 当前 alias guard 实现: packages/core/src/animation/Animator.ts:1454-1466
  • 设计语义注释: packages/core/src/animation/Animator.ts:1460-1463
  • 测试锁定当前 no-op 行为: tests/src/core/Animator.test.ts (crossFade to current state is no-op, state-machine self-transition is also a no-op)
  • 相关 PR: fix(animation, loader): 从 #2983 抽离动画与 GLTF 加载器修复 #2984

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions