技术博客

AnimationMixer 的重构:AnimationPlayer/AnimationTree 的统一底座 —— 4.x 为什么多出一个基类

第 136 / 149 篇 · 2026-09-17 · godot · 动画系统源码分析架构

3.x 的动画系统有个怪味道:AnimationPlayer 既管"按时间播动画"又管"把值写回节点属性",AnimationTree 想做个图化混合,最后却只能在内部包一个 AnimationPlayer 来借它的播放能力——两层职责搅在一起,AnimationTree 的每个节点都隔着一层 player 的语义。Godot 4.0 的重构把公共部分抽成了 AnimationMixer(scene/animation/animation_mixer.h):播放、混合、事件分发都在基类,AnimationPlayer 只剩"手动播放"的语义,AnimationTree 则作为另一种"混合策略提供者"与它平级。这篇拆这个基类到底做了什么。

从"一个 player"到"一组活动动画"

3.x 的 AnimationPlayer 同一时刻只有一条"当前动画"的时间轴。AnimationMixer 的核心抽象改成了一张活动动画列表:play() 不再是"切换当前动画",而是往列表里加一个 ActiveAnimation 条目——

ActiveAnimation { animation, time, speed, weight, fade, loop_mode }

每个条目有自己的本地时钟和权重。_process(或物理帧回调)里,mixer 把每条活动动画的时间推进 $\Delta t \times \text{speed}$,按各自的 fade 曲线更新 weight,然后进入求值阶段:每条动画按当前时间在每条 track 上采样出值,不同活动动画对同一条 track 的采样值按 weight 混合,得到最终写回节点的值。交叉淡入淡出从"player 内部的特殊状态"变成了"两个普通 ActiveAnimation 的 weight 此消彼长"——这正是八-11 等功率曲线的动画版,只是权重归一化由 mixer 统一做。

求值与写回:track cache

混合出来的值要落到节点属性上。Mixer 维护一张 track cache:track 路径(NodePath)→ 目标对象 + 属性 setter 的缓存表。首次求值时解析路径(二-9 的 NodePath 解析那套,缓存后不再重复),之后每帧是纯粹的查表 + set()。这个缓存还有第二层收益:节点临时移出树(切场景、隐藏子树)时 cache 标记失效而不是报错,节点回来直接复用。

Track 类型决定混合方式——这是 mixer 里最要小心的正确性角落:

  • 连续值(position、float):线性加权 $\bar{v} = \sum_i w_i v_i \big/ \sum_i w_i$;
  • 旋转:quaternion 加权要做最近化(翻转符号使点积为正再 lerp/slerp),否则两条差 180° 的等价旋转会被插值穿过零点;
  • 离散/触发 track(方法调用、离散布尔):不能加权——权重最高的那条胜出,且只在它的时间跨过时触发一次。

为什么这次重构值得

对 AnimationTree,收益是结构性的:blend tree 的每个节点不再需要"借"一个 player,它们向 mixer 输出的就是一组带权重的 ActiveAnimation 提案,真正的播放状态只有 mixer 一份(九-2 展开求值顺序)。这消掉了 3.x 一堆诡异 bug 的根源——两个 player 时钟不一致导致的偏移累积。

对写脚本的,收益在 API 面:AnimationMixer 暴露的 play/stop/set_speed/animation_started/finished 这层语义,AnimationPlayer 和 AnimationTree 完全一致,而 3.x 里 AnimationTree 上这套东西是绕路实现的。还有 mixer 级别的根运动累计(get_root_motion_position() 一族):3.x 根运动归 player 管,AnimationTree 里做根运动要靠 hack;4.x 累计发生在 mixer 的求值阶段,对树内所有动画统一生效(九-6 的 root motion 基础就是它)。

AnimationMixer:活动动画加权混合后写回节点 活动动画列表 walk time 0.42 w 0.7 run  time 0.31 w 0.3 fade 中:权重互斥 每条独立时钟 × speed 循环模式各自生效 逐 track 求值 每条 track 采样 连续值:加权平均 旋转:四元数最近化 离散:最高权重胜出 方法 track 只触发一次 track cache 写回 NodePath → 对象+setter 首次解析,之后查表 节点离树标记失效 根运动在此累计 3.x 对比:AnimationTree 内部包一个 AnimationPlayer 借播放能力 4.x:播放状态只在 mixer 一份,Player 与 Tree 是两种"提案权重的方式",平级

迁移时的行为差异

从 3.x 迁代码有一个语义变化要适配:3.x 的 play() 是切换当前动画,4.x 是往列表里加条目。绝大多数时候行为一致(同名动画重复 play 会复用条目),但依赖"play B 时 A 立刻彻底停止"的老代码要检查——A 的停止现在受 fade out 控制,要立即停止得显式 stop("A") 或设 fade = 0。另外 current_animation 属性没了,对应状态从 get_current_animation() 系列 API 读。这些都在官方迁移文档里,但实际项目里最常踩的是第一条:以为停了其实还在淡出,方法是 track 还在被两条动画抢着写。

本文基于 Godot 4.2–4.4 源码(scene/animation/animation_mixer.h);AnimationMixer 自 4.0 引入,后续 minor 版本在回调时机上有修补,行为以主线为准。