第11章 · 角色动画一阶:状态机与混合空间

📑 目录

导语:第 9 章结束时,艾拉已经能在「回响峡谷」的灰盒里跑了。但盯着她看三秒就会出戏:起步瞬间从站立直接跳切到奔跑,像视频被剪掉了一段;停下时双脚在地面滑出去半米;下坡时整个人悬空漂浮。移动组件的账是平的——速度、加速度、碰撞全对。问题出在动画层:胶囊体在动,动画只是挂在胶囊体上播放的幻灯片。本章把这件事做对。

本章地图

mindmap
  root((角色动画一阶))
    补课地基
      十二法则的游戏映射 只挑 4 条
      关键帧 曲线 帧率
    动画蓝图
      EventGraph 算账
      AnimGraph 摆姿势
    状态机
      Idle Walk Run
      Jump Fall Land
      迟滞阈值防闪烁
    混合空间
      速度乘方向二维网格
      权重归一化
      Sync Group 锁相位
    滑移与 Root Motion
      速度差公式
      PlayRate 微调
      Root Motion 的使用边界
    回响峡谷落地
      艾拉资产清单
      状态机结构

动画十二法则,游戏里真正用得上的只有四条

补课:本节补动画史常识。十二法则出自 Frank Thomas 与 Ollie Johnston 1981 年的《The Illusion of Life》,是迪士尼手绘时代的经验总结。动画出身可跳过,程序出身请认真看——它解释了为什么你"逻辑全对"的角色看起来像假人。

十二条是:挤压与拉伸、预备动作、演出布局、逐帧与姿势到姿势、跟随与重叠、缓入缓出、弧线、次要动作、节奏、夸张、实体感、吸引力。逐条搬进游戏是学院派的浪费——交互性会废掉其中一半:演出布局归镜头管(见第 15 章),节奏和夸张是动画师在 DCC 里的活,挤压拉伸只配风格化项目。剩下四条直接决定"移动手感真不真",每条都能在动画蓝图里落地:

法则现实含义在动画蓝图里怎么落地代价是什么
预备动作(Anticipation)起跳前先下蹲,出拳前先收肩Jump 前插一个 3-5 帧的 JumpStart 状态,而不是从 Idle 直接切 Fall;攻击前摇走 Montage 段落响应变慢。窗口超过 100ms 玩家就觉得"肉",所以预备动画必须短,或允许被输入打断
跟随与重叠(Follow-through & Overlapping)马尾、披风、背包带在身体停下后继续晃AnimDynamics 或 Spring Controller 动画节点给指定骨骼挂弹簧模拟;布料交给 Chaos Cloth每根弹簧骨骼一份 CPU 解算,移动端要砍数量;50 个 NPC 同屏时这笔开销可感
缓入缓出(Slow In & Out)现实世界没有匀速启动和急停状态过渡用 Cubic 混合或自定义 Blend Profile 代替线性;Blend Space 的 Smoothing Time 设 0.1-0.2s;过渡时长按场景区分:Idle→Walk 约 150ms,Run→Idle 急停约 250ms混合期间脚在下面积累滑移误差,时长越长滑移越明显——第六节专门算这笔账
弧线(Arcs)手脚的轨迹是弧线,不是直线步态循环的首尾姿势必须衔接,脚在空中的轨迹才自然成弧;用 Sync Group 锁住 Walk/Run 相位,混合时两脚不打架;转向用 8 向采样,别拿镜像糊弄动画资产量翻倍(左右转向都要做),或接受镜像带来的骨盆反向扭动

剩下八条并非无用,而是落点不在动画蓝图:挤压与拉伸适合卡通渲染项目,写实骨架上做会让蒙皮穿帮;节奏(Timing)与夸张是动画师在 DCC 里定生死的活,引擎只负责忠实播放;演出布局(Staging)靠镜头与灯光(见第 10、15 章);逐帧法与姿势到姿势法是制作流程的两派之争,与运行时无关;次要动作、实体感、吸引力属于打磨层,第 12 章的程序化动画会接管其中一部分。

一个判断:这四条里,前两条管"动起来像生物",后两条管"动与动之间不跳变"。你的角色假,九成是后两条没做。

补课:关键帧、曲线与帧率——"60fps 动画"是个误会

补课:影视动画的基本概念。三条常识讲完,顺带纠正一个团队里最常见的错误认知。

关键帧与曲线。 动画师在 Maya/Blender 里只摆关键姿势(关键帧),中间帧由曲线插值生成——每根骨骼的位置、旋转各是一条以时间为横轴的贝塞尔曲线。导入 UE 后,UAnimSequence 里存的就是这些骨骼轨道曲线,压缩器按误差容差抽稀关键帧。

采样按时间,不按帧。 这是"60fps 动画"这个提法不成立的原因。引擎每帧用当前播放时间去曲线上求值:渲染跑 60fps 就采样 60 次,跑 144fps 就采样 144 次,姿势永远平滑。动画资产只需要一个烘焙采样率(动捕数据常见 30Hz 或 60Hz),和显示帧率无关。动捕原始数据可能是 120Hz,导入时压缩器会抽到视觉上无差别的密度——别为了"保真"关压缩,骨骼数乘轨道数乘采样率,主角一套动画的内存能差出好几倍。

影视 24fps 与游戏 60fps 的真实差异。 电影的每帧曝光 1/24 秒,运动模糊烤进画面,快速运动在单帧里是糊的,大脑脑补出连贯。游戏帧是瞬时采样,没有这份免费模糊,30fps 以下肉眼直接看到跳变。所以游戏要 60fps 起步——采样定理在替你挨骂,这跟攀比没关系。

帧率真正会咬人的一处:逻辑按"帧号"写。 攻击判定"在第 10 帧生效"是主机格斗游戏的老习惯,到了帧率波动的 PC 上就是 bug 温床。UE 的时间轴(Montage 的 Notify、曲线触发点)全部按秒走,你写逻辑也必须按秒。帧号只在和动画师对需求时用。

压缩器还有第二笔账:曲线精度。UE 的动画压缩按误差容差抽帧——容差调大,内存省得多,但手指这类小骨骼会肉眼可见地抖动。经验值:主角用默认容差,远景 NPC 可以放宽一档。具体面板在 5.8 中对应官方文档的 Animation Compression 词条,调参前以文档为准。

动画蓝图分两层:EventGraph 算账,AnimGraph 摆姿势

动画蓝图运行时是一个 UAnimInstance 对象,跟随 SkeletalMeshComponent 生死。它内部有两张图,职责必须分干净:

  • EventGraph(事件图):游戏线程,每帧执行一次 BlueprintUpdateAnimation(C++ 侧对应 NativeUpdateAnimation)。职责是从 Pawn 和 CharacterMovementComponent 读出原始数据,压成几个扁平变量:Speed、Direction、bIsFalling、VerticalVelocity。
  • AnimGraph(动画图):姿势求值图。节点是"姿势的函数"——状态机、混合空间、分层混合、IK。它只读 EventGraph 缓存好的变量,算出本帧最终骨骼姿势。

一条军规:AnimGraph 里禁止 Cast、禁止 Get Pawn Owner。 两个原因。其一,项目设置打开多线程动画更新(Allow Multi Threaded Animation Update)后,图节点的求值可能发生在工作线程,那时碰游戏对象直接崩。其二,图里每个节点各 Cast 一次,100 个角色就是每帧几百次无谓的类型检查。EventGraph 每帧只跑一次,所有"算账"集中在那里,图节点纯消费变量——干净,也快。

读变量的姿势在 5.x 有一个更稳的升级:Property Access(属性访问)。AnimGraph 节点直接绑定到 AnimInstance 的成员变量上,由引擎保证读取时机与线程安全,比在图里手动拉 Get 节点规范。另外把 NativeInitializeAnimation 利用起来:它只跑一次,把 Pawn 和 MovementComponent 的指针缓存成成员,Update 里连那一次 Cast 都省掉——后文的 C++ 示例为演示可读性保留了每帧 Cast,正式项目请挪进 Initialize。纯蓝图项目在 EventGraph 做同样的事:Event Blueprint Update AnimationTry Get Pawn OwnerCast to Character → 读 Velocity,节点链与 C++ 一一对应,只是换成连线。

每帧的执行流如下:

flowchart TD
    A[Pawn Tick
CharacterMovement 积分出新的速度与位置] --> B["AnimInstance::NativeUpdateAnimation
EventGraph:读 Velocity 与 IsFalling
算 Speed / Direction / bIsFalling"] B --> C[AnimGraph 开始求值
状态机检查所有启用的转移规则] C --> D{有转移触发?} D -->|是| E[启动 Crossfade
按过渡时长混合新旧状态] D -->|否| F[当前状态内部继续求值] E --> F F --> G[状态内的 Blend Space 采样
按 Speed Direction 算各剪辑权重] G --> H[分层混合 / IK / 曲线输出] H --> I[Final Pose 交给渲染线程]

类之间的关系一句话:Character 持有 Mesh 组件与移动组件,Mesh 组件驱动 AnimInstance,AnimInstance 从移动组件读数据、引用 BlendSpace 资产出姿势。

classDiagram
    class ACharacter {
        +Mesh: USkeletalMeshComponent
        +MoveComp: UCharacterMovementComponent
    }
    class USkeletalMeshComponent {
        +SetAnimInstanceClass()
    }
    class UAnimInstance {
        +NativeUpdateAnimation(DeltaSeconds)
    }
    class UEchoAnimInstance {
        +Speed: float
        +Direction: float
        +bIsFalling: bool
        +VerticalVelocity: float
    }
    class UBlendSpace {
        +AxisX: Speed
        +AxisY: Direction
        +Samples: 动画采样点列表
    }
    class UAnimSequence
    ACharacter *-- USkeletalMeshComponent
    ACharacter *-- UCharacterMovementComponent
    USkeletalMeshComponent --> UAnimInstance : 每帧驱动
    UEchoAnimInstance --|> UAnimInstance
    UAnimInstance ..> UCharacterMovementComponent : EventGraph 读取
    UAnimInstance ..> UBlendSpace : AnimGraph 节点引用
    UBlendSpace *-- UAnimSequence : 采样加权

「回响峡谷」的 AnimInstance 基类用 C++ 写,理由很实际:EventGraph 里一串蓝图节点在 50 个 AI 同屏时开销可感,C++ 版把每帧算账压到微秒级。这段代码解决"每帧把移动组件状态压成动画变量"这一件事:

// EchoAnimInstance.h/.cpp 精简合并版 —— 回响峡谷动画实例基类
#pragma once
#include "Animation/AnimInstance.h"
#include "EchoAnimInstance.generated.h"

UCLASS()
class ECHOCANYON_API UEchoAnimInstance : public UAnimInstance
{
    GENERATED_BODY()
public:
    virtual void NativeUpdateAnimation(float DeltaSeconds) override;

protected:
    // AnimGraph 只读这四个变量,图内禁止 Cast
    UPROPERTY(BlueprintReadOnly, Category="Locomotion") float Speed = 0.f;
    UPROPERTY(BlueprintReadOnly, Category="Locomotion") float Direction = 0.f;
    UPROPERTY(BlueprintReadOnly, Category="Locomotion") bool  bIsFalling = false;
    UPROPERTY(BlueprintReadOnly, Category="Locomotion") float VerticalVelocity = 0.f;
};

// .cpp
void UEchoAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
    Super::NativeUpdateAnimation(DeltaSeconds);
    const ACharacter* Owner = Cast<ACharacter>(TryGetPawnOwner());
    if (!Owner) return;

    const UCharacterMovementComponent* Move = Owner->GetCharacterMovement();
    const FVector Vel = Move->Velocity;
    Speed           = Vel.Size2D();
    VerticalVelocity = Vel.Z;
    bIsFalling      = Move->IsFalling();
    Direction       = UKismetAnimationLibrary::CalculateDirection(Vel, Owner->GetActorRotation());
}

三个要点:TryGetPawnOwner 在 PIE 里可能返回空(编辑器预览窗口),必须判空;CalculateDirection 输出 -180 到 180 的相对角度,正是混合空间 Direction 轴要的格式;基类只做搬运,任何"状态逻辑"都不进这里。

角色再多一层,还有按重要度降频的手段:Update Rate Optimization(URO),让远景 NPC 的动画按 15Hz 更新、主角保持全帧率。「远港 Online」同屏 50 人时这是标配,配置入口在 SkeletalMeshComponent 上,细则以 5.8 官方文档的 Animation Update Rate Optimizations 词条为准。

状态机管"离散的模式",别让它管"连续的量"

状态机三要素:State(状态,内部是一个姿势求值子图)、Transition Rule(转移规则,一个布尔表达式)、Conduit(导管,用来汇集分支、复用规则)。用动画师的思维切状态:一个状态对应一种"运动模式"——站着、走着、跑着、离地上升、下落、落地。六态覆盖第三人称移动的 90% 场景。

下面是本章的灵魂图,「回响峡谷」的移动状态机全图。盯住每条边上的条件看,尤其是成对的阈值:

stateDiagram-v2
    [*] --> Idle
    Idle --> Walk : Speed 大于 20 且着地
    Walk --> Idle : Speed 小于 10
    Walk --> Run : Speed 大于 380
    Run --> Walk : Speed 小于 340
    Idle --> Run : Speed 大于 380(起步即冲刺)
    Run --> Idle : Speed 小于 10(急停)
    Idle --> Jump : 跳跃输入 且着地
    Walk --> Jump : 跳跃输入 且着地
    Run --> Jump : 跳跃输入 且着地
    Jump --> Fall : VerticalVelocity 小于 0(越过顶点)
    Fall --> Land : IsFalling 变为 false(触地)
    Land --> Idle : 落地混合播完 且 Speed 小于 10
    Land --> Walk : 落地混合播完 且 Speed 10 至 380
    Land --> Run : 落地混合播完 且 Speed 大于 380

迟滞(Hysteresis) 是防闪烁的命根。 注意 Walk→Run 阈值 380,Run→Walk 阈值 340,两者不相等。电子工程师管这叫施密特触发器:如果进出共用 380 这一条线,当速度在 375-385 之间波动时(手柄半推、上坡减速都会造成),状态机每秒在走跑之间切换十几次,画面就是抽搐。留出 40cm/s 的缓冲带,状态切换就变成了"越过一条河再越过另一条河",抖动被物理消化。Idle↔Walk 的 20/10 同理。

过渡时长不是越小越跟手。 0ms 过渡等于跳帧,玩家看到的是姿势瞬移。地面过渡的甜区是 150-250ms;Fall→Land 反而要短(100ms 内),因为落地需要冲击感,软绵绵的落地像踩进棉花。混合类型别用默认线性,换 Cubic——这就是上一节"缓入缓出"的落点。

从"走跑跳"到"八向移动",新手会踩同一个坑: 为左移、右移、后退各建状态,再为左跑、右跑、后跑各建状态。状态数从 6 涨到 20,转移边接近 O(n²),动画蓝图变成蜘蛛网,没人敢改。正确的切法是本节标题那句话:状态机只管离散的模式(在地面 / 在空中 / 正在落地),连续的量(速度大小、移动方向)交给状态内部的混合空间。地面那三个状态在实现里其实可以合并成一个 Ground 状态,内部嵌一张速度×方向的混合空间——但教学阶段保留 Idle/Walk/Run 三态有个实际好处:你可以给"停稳"和"移动中"挂不同的脚部 IK 策略(见第 12 章)。

转移规则的求值成本提一句:每个"启用中"的规则每帧都会被求值一次。规则表达式只读缓存好的布尔和浮点,成本可以忽略;但如果有人在规则里写字符串比较、遍历数组、查 GameplayTag 容器,100 个角色的动画更新就会变成 profiler 里的红色长条。规则要"蠢",聪明的部分挪到 EventGraph 里一次算完。

调试状态机不用靠猜:动画蓝图编辑器在 PIE 运行时会实时高亮当前状态与刚触发的转移边,再配合把转移事件打进输出日志,九成"为什么没切过去"的问题都死在条件没满足或过渡没播完。另两个容易忽略的点:同一状态发出的多条规则按列表顺序求值,先命中先走,把最紧急的(比如进入 Fall 的打断)排在最上面;Fall 的进入也别只认跳跃,走出平台边缘、被击退浮空都该进 Fall——规则写成"离地超过 0.2 秒进 Fall",而不是"按了跳跃才进"。土狼时间与跳跃缓冲属于输入层(见第 5 章),状态机只消费它们算好的布尔,别在转移规则里重新计时。

艾拉这套状态机的转移条件,用伪代码写清楚(迟滞阈值与状态机图一一对应):

# 伪代码:艾拉移动状态机的转移规则集
# 约定:ctx 由 EventGraph 每帧填充;规则只读不算
WALK_ENTER = 20.0    # cm/s,进入 Walk 的阈值
WALK_EXIT  = 10.0    # cm/s,退出 Walk 的阈值(迟滞带 10)
RUN_ENTER  = 380.0
RUN_EXIT   = 340.0   # 迟滞带 40

def can_transition(src, dst, ctx):
    if dst == "Jump":
        return ctx.jump_pressed and ctx.on_ground          # 任意地面态可起跳
    if src == "Idle" and dst == "Walk":
        return ctx.speed > WALK_ENTER and ctx.on_ground
    if src == "Walk" and dst == "Idle":
        return ctx.speed < WALK_EXIT
    if src == "Walk" and dst == "Run":
        return ctx.speed > RUN_ENTER
    if src == "Run" and dst == "Walk":
        return ctx.speed < RUN_EXIT
    if src == "Jump" and dst == "Fall":
        return ctx.vertical_velocity < 0.0                 # 越过抛物线顶点
    if src == "Fall" and dst == "Land":
        return ctx.on_ground                               # 触地瞬间
    if src == "Land":
        return ctx.land_blend_finished                     # 落地混合播完再分流
    return False

要点:dst == "Jump" 的规则挂在导管(Conduit)上,三个地面态共享一份,不用复制三条;land_blend_finished 由落地剪辑的剩余时间推出,EventGraph 算好,规则只读。

混合空间:把速度与方向摊成一张连续的面

状态机解决了"什么时候播哪段",混合空间(Blend Space)解决"两段之间怎么连续"。定义一句话:把若干动画序列钉在坐标轴上(1D 一根轴,2D 一张平面),运行时输入坐标——速度×方向——引擎对落点所在的三角形做加权插值,输出混合姿势。2D 混合空间内部对采样点做三角剖分,你站在哪个三角形里,就按重心坐标吃哪三个采样的权重;权重永远归一化:

w^i=wij=1Nwj,P(t)=i=1Nw^iPi(t)\hat{w}_i = \frac{w_i}{\sum_{j=1}^{N} w_j}, \qquad \mathbf{P}(t) = \sum_{i=1}^{N} \hat{w}_i \, \mathbf{P}_i(t)

意思是:三角插值算出的原始权重 wiw_i 先除以总和归一化(w^i=1\sum \hat{w}_i = 1),最终姿势 P(t)\mathbf{P}(t) 就是各采样姿势 Pi(t)\mathbf{P}_i(t) 按归一化权重的加权和——骨骼平移按向量插值,骨骼旋转按四元数球面插值(Slerp)混合。归一化这一步保证了混合不会把角色"缩没"或"拉飞":权重和恒为 1,姿势永远在采样点围成的凸包内部。

艾拉的地面混合空间长这样——速度轴 0-450cm/s,方向轴 -180° 到 180°,采样点钉在两排(Walk 175 / Run 450)加中央一个 Idle:

混合空间二维网格:速度 × 方向(艾拉·地面移动)方向角(°)速度(cm/s)-180°-90°0°(前)+90°+180°0175450Idle(方向无意义,单点)Walk_BWalk_LWalk_FWalk_RWalk_BRun_BRun_LRun_FRun_RRun_B当前输入(Speed 260,Dir +45°)四点加权:Walk 两点各约 0.35,Run 两点各约 0.15,和恒为 1

注:-180° 与 +180° 是同一方向(正后方),UE 在网格接缝处自动环绕;45° 斜向不单独做剪辑,由相邻采样插值产生。

采样点布置的三个原则,图上都能看出来:

  • Speed=0 那行只有一个 Idle 点。 站着不动时方向没有意义,摆五个 Idle 采样只会引入无谓的混合噪声。
  • 斜向 45° 不做资产。 Walk_F 与 Walk_R 插值出来的 45° 步态足够用,这让 8 向移动只需要 4 套方向资产(F/B/L/R)。如果追求极致,可以再补四个斜向采样把圆画得更准——资产量翻倍,收益递减,竖切阶段不值。
  • 接缝环绕。 -180° 和 +180° 是同一点,UE 处理方向轴时自动环绕,但你的资产要保证 Walk_B 的首尾姿势和左右两边衔接得上,否则转身扫过 180° 线时会出现一帧跳变。

编辑器里的落地步骤是固定的几步:新建 Blend Space 资产并指定骨骼,横轴设 Direction(-180 到 180),纵轴设 Speed(0 到 450);把序列从内容浏览器拖进网格对应位置;拖动绿色预览点逐区检查混合效果。一个警告:轴向的格数(Grid Divisions)只影响编辑器里吸附的颗粒度,不影响插值精度——插值始终基于采样点本身。新手常以为格子越多越平滑,白调半天。只按速度混合时还有更省的选择:1D 混合空间,一根 Speed 轴上钉 Idle/Walk/Run 三个点,适合锁定朝向的俯视角或第一人称项目——没有方向维度,资产量直接砍半。

Sync Group:混合空间里的隐形杀手是相位。 Walk 循环 1.0 秒、Run 循环 0.7 秒,两者直接混合时,左脚着地的时刻对不上,混合带里角色的脚像踩月球车——这本身就是滑移的一种。解法:把同一组步态剪辑加入同一个 Sync Group(命名如 "Locomotion"),并在剪辑里打同步标记(Sync Marker,如 LeftFootDown / RightFootDown)。引擎在混合时按标记对齐各剪辑的时间轴,左脚对左脚,右脚对右脚,权重怎么变相位都不漂。这是不做不行的一步,且五个字就能向动画师提需求:"步态全锁相。"

Smoothing Time 是第二次缓入缓出。 输入坐标突变(玩家瞬间从前进打到后退)时,权重立刻跳变会让姿势闪变。Blend Space 提供目标权重插值时间(Smoothing Time,常用 0.1-0.2s 的指数平滑),等价于给权重变化加了个一阶低通。调多大看手感:动作游戏偏小(跟手),写实项目偏大(稳重)。

一次速度变化从输入到画面的完整旅程:

sequenceDiagram
    participant P as 玩家输入
    participant CM as CharacterMovement
    participant AI as AnimInstance(EventGraph)
    participant SM as 状态机(AnimGraph)
    participant BS as Blend Space 节点
    P->>CM: 推满左摇杆 按冲刺
    CM->>CM: 加速度积分 Velocity 升到 450 cm/s
    CM-->>AI: 同帧 NativeUpdateAnimation 读取
    AI->>AI: Speed=450 Direction=+15
    AI->>SM: 转移规则求值 Walk 到 Run 命中
    SM->>SM: 启动 0.25s Cubic Crossfade
    SM->>BS: 以坐标 450,+15 采样
    BS->>BS: Speed 恰在 Run 排:Run_F 0.83 加 Run_R 0.17
    BS-->>SM: 按 Sync Group 对齐相位后输出混合姿势
    SM-->>AI: Final Pose 本帧提交渲染

顺带划清一个常见混淆:Aim Offset(瞄准偏移)不是混合空间。 Aim Offset 是叠加型资产,把"抬头 30°"这类差量姿势加在当前姿势之上,用于瞄准俯仰;混合空间是替换型,输出完整姿势。两者都能在 AnimGraph 里挂节点,但解决的问题完全不同——把瞄准做成混合空间是第二章常见的返工原因。

混合空间之上还有一层"分身体区域混合"的需求:艾拉手举电筒时,下半身要继续跑状态机,上半身要切到持物姿势。AnimGraph 里用 Layered blend per bone 节点按骨骼子树做遮罩,配置思路如下:

# 伪代码:AnimGraph 中 Layered blend per bone 的配置(艾拉持电筒)
node "Layered blend per bone":
    BasePose   = LocomotionStateMachine.Output     # 全身移动姿势
    BlendPose0 = Slot('UpperBody').Output          # 上半身持物 Montage

    # 遮罩:从脊柱第一节往上的整棵子树权重 1,骨盆与双腿保持 BasePose
    BlendSetup = [
        { bone = "spine_01", depth = 0, weight = 1.0 },
    ]
    BlendMode = "BranchFilter"     # 按骨骼分支过滤,子骨骼继承父权重
    # 教训:权重写 0.5 的半混合会让手电位置漂移,宁可 1.0 全切

这个节点是上下半身分离的起点,也是第 12 章全身 IK 与分层动画的地基——先记住"遮罩按骨骼子树继承"这一条,九成配置错误都出在想对单根骨骼做半权重。

脚步滑移:动画速度与移动速度的账没对上

滑移的判定很朴素:脚接触地面期间,鞋底相对地面发生了位移。起步、急停、变速、混合过渡期间它都会冒头,但匀速直线行走时还滑,就是数值没对上。

推导只需要小学物理。步态动画在制作时有一个制作速度 vauthoredv_{\text{authored}}:动画师按"角色每秒前进多少厘米"设计循环,Walk 常见 150-180cm/s,Run 常见 400-500cm/s。播放时乘以播放速率 RR(PlayRate,默认 1.0),动画表现出的移动速度是 RvauthoredR \cdot v_{\text{authored}}。而胶囊体的实际速度 vactualv_{\text{actual}} 由 CharacterMovement 决定。脚贴地的那段时间里,鞋底与地面的相对速度就是:

vslide=vactualRvauthoredv_{\text{slide}} = v_{\text{actual}} - R \cdot v_{\text{authored}}

vslide=0v_{\text{slide}} = 0 时脚像粘在地上,完全真实。人眼对滑动的感知阈值大约是步速的 5%——Walk 175cm/s 时,差出 9cm/s 以上就开始"出戏"。举例:Walk 剪辑按 175cm/s 制作,而你的 MaxWalkSpeed 保持引擎默认 600cm/s 的四分之一速(150cm/s)或直接用 600 当跑速,vslidev_{\text{slide}} 常年挂着几十 cm/s,角色就是在滑冰。反解这个公式也得到 PlayRate 的调法:R=vactual/vauthoredR^{*} = v_{\text{actual}} / v_{\text{authored}},让动画速度主动追上移动速度。

四种修法,按性价比排序:

修法做法成本局限
对齐数值MaxWalkSpeed 直接设成动画制作速度(Walk 175 / Run 450)移速被动画资产绑架,策划改移速必须联动换资产
PlayRate 微调R=vactual/vauthoredR = v_{\text{actual}} / v_{\text{authored}},实时算低,几行代码限制在 0.9-1.1,超出肉眼可辨"快放/慢放"
Stride WarpingAnimation Warping 插件的 Stride Warping 节点,程序化拉伸步幅中,需要调参与测试极端速度差下腿部形变不自然;第 12 章细讲
Root Motion动画自己驱动胶囊体位移高,改变整个移动架构见下文的边界讨论

实战里的组合拳:匀速段用"对齐数值"治本,加减速与混合过渡期用 PlayRate 微调兜底,3A 品质要求再叠 Stride Warping。前两条做完,滑移能消掉八成。

vauthoredv_{\text{authored}} 怎么测:在动画序列编辑器里打开根骨骼位移曲线,一个循环内根骨骼前进的厘米数除以循环时长即是;in-place 资产没有根位移,就向动画师要制作设定值,或用"步幅×步频"估算。还有一个宽限:加速与急停的两三帧里滑移不可避免,但玩家的注意力在镜头与目标上,几乎不会察觉——别为过渡期的滑移过度工程,匀速段治不好才是真问题。

Root Motion(根源运动)的边界要画清楚。 它的含义:位移数据不在移动组件里,而在动画剪辑的根骨骼轨道上,播放时引擎把根骨骼位移转交给胶囊体。AnimInstance 的 Root Motion Mode 有四档,常用的就两档:

  • Root Motion from Montages Only:只有 Montage 播放的剪辑提取根运动。这是位移技能的标准姿势——翻滚、处决、翻越矮墙、上台阶。位移曲线在动画里,打击感和落点精度都是动画师可控的,且官方对 Montage 的根运动提供复制与预测支持(见第 23 章)。「回响峡谷」里艾拉的翻滚闪避就该走这条路。
  • Root Motion from Everything:所有动画都驱动位移。TLOU 这类单机动作游戏的移动真实感很大程度来自它——加速减速的每一步都是动画师踩出来的。但代价是放弃了 CharacterMovement 的整套预测框架。MMO 别碰:客户端预测与服务器回滚围绕 in-place 移动模型设计,全量根运动在 100ms 延迟下意味着每一次回滚都伴随视觉位移跳变。「远港 Online」的底线写死在这里:持续移动一律 in-place,根运动只服务 Montage 位移技。单机项目想用全量根运动也有前提:动画资产必须高质量且全覆盖,任何速度、任何转向都有对应剪辑,否则"胶囊体被动画拖着走"的失控感比滑移更糟——这也是 TLOU 级项目养着一个动捕团队的原因,预算不够时别轻易学。

艾拉的移动动画集:资产清单与状态机落位

把前面五节拼起来,「回响峡谷」主角艾拉需要的移动动画资产一共 14 个序列,命名与职责如下:

资产名数量状态机位置备注
A_Ella_Idle1Idle 状态循环,呼吸感即可
A_Ella_IdleBreak2Idle 状态(随机插入)站立 15 秒以上触发,拨头发、张望,防"蜡像感"
A_Ella_Walk_F/B/L/R4Walk 状态内混合空间制作速度 175cm/s,Sync Group: Locomotion
A_Ella_Run_F/B/L/R4Run 状态内混合空间制作速度 450cm/s,同一 Sync Group
A_Ella_JumpStart1Jump 状态3-5 帧预备+起跳,非循环
A_Ella_FallLoop1Fall 状态循环,下落姿势带轻微摆动
A_Ella_Land_Soft / Hard2Land 状态(按下落高度分流)Soft 接混合空间;Hard(3 米以上)走 Montage 打断移动

资产来源的现实路径,按阶段来:占位期用 Mixamo 免费库重定向到艾拉骨骼(重定向技术见第 12 章);竖切期换官方 Game Animation Sample 里的移动动画库,质量够用来定手感参数;最终版本上动捕。一套主角基础移动集的外包行情是数万元级、数周工期量级——这是劝你前期别在资产上较劲的论据,手感参数先用占位资产调死,动捕来了直接套。

目录与命名也提前定死:动画统一放 Content/Characters/Ella/Animations/,前缀 A_Ella_,移动集再按 Walk/Run/Jump 分子目录。15 个序列看不出规矩的价值,等第 12 章加上转身动画、IK 目标姿势与 Motion Matching 数据库、资产翻五倍时,没有命名宪法的人会在内容浏览器里迷路。

状态机落位时还有四条实操军规:

  1. 所有 locomotion 剪辑关闭根运动提取(Root Motion Mode 用 Montages Only,剪辑本身按 in-place 制作或导入时提取掉根位移),否则混合空间的权重计算会带上意外的位移分量。
  2. 循环剪辑检查首尾姿势:Walk/Run/FallLoop 的首帧与末帧姿势必须一致,差一帧都会周期性抽动。在剪辑编辑器里开循环预览逐帧过一遍。
  3. 步态全部进 Sync Group "Locomotion",并统一在左脚着地、右脚着地打同步标记。
  4. Land 的分流条件写在 EventGraph:下落高度在触地瞬间从 CharacterMovement 的坠落距离缓存读,换算成布尔 bHardLanding,Land 状态内部再分流 Soft 序列与 Hard Montage——别在转移规则里做除法和开方。

最终状态机结构一览,方便对照搭建:

状态内部内容发出的转移说明
IdleIdle 循环序列 + IdleBreak 随机 MontageWalk / Run / Jump站立 15 秒触发小动作
Walk2D 混合空间节点(低 Speed 区域主导)Idle / Run / Jump与 Run 引用同一资产
Run同一 2D 混合空间节点(高 Speed 区域主导)Walk / Idle / Jump急停直接回 Idle
JumpJumpStart 单序列,非循环Fall播完在原地等待顶点条件
FallFallLoop 循环序列Land走出平台边缘也落到这里
LandLand_Soft 序列 / Land_Hard MontageIdle / Walk / Run按下落高度分流

注意 Walk 与 Run 在资产层共用同一张 2D 混合空间这件事:两个状态各放一个 Blend Space 节点但引用同一资产,权重计算是连续的,Walk→Run 的 Crossfade 就不会出现"双脚重新找相位"的顿挫。

到这里回看本章开头那个事故现场:起步跳帧,因为 Idle→Run 之间缺了 Walk 过渡和 Crossfade;停下滑行半米,因为 Run 的制作速度 450 和实际 600 没对齐;下坡漂浮,因为没有 Fall 状态承接离地检测。三个病,开的药分别是状态机、速度差公式、离地状态——你现在都有了。

小结

动画蓝图的铁律是分层:EventGraph 每帧把游戏状态压成几个浮点和布尔,AnimGraph 只消费这些变量——在图里写 Cast,是给自己埋线程与性能两颗雷。状态机管离散模式,混合空间管连续量:把左右移动、后退塞进状态机是新手第一坑,状态数膨胀的代价比做四向资产大得多。转移阈值必须成对出现(进 380 / 出 340),迟滞带消化速度抖动;过渡时长 150–250ms 配 Cubic 混合,是地面过渡的甜区。脚步滑移的账就一个公式:vslide=vactualRvauthoredv_{\text{slide}} = v_{\text{actual}} - R \cdot v_{\text{authored}},先对齐 MaxWalkSpeed 治本,再用 PlayRate 微调兜底,Stride Warping 留给品质攻坚。Root Motion 属于位移技能与单机演出;MMO 的持续移动保持 in-place,第 23 章的预测系统会感谢这个决定。

上手任务

  1. 搭出艾拉的六态状态机(Idle/Walk/Run/Jump/Fall/Land,含迟滞阈值)。验收:用键盘让速度在 370-390cm/s 之间往复振荡 10 秒,Walk↔Run 状态切换不超过 1 次;0.25 倍慢放下观察 Idle→Walk,无姿势跳变。
  2. 建速度×方向二维混合空间并验证权重。验收:在 Blend Space 编辑器里把预览点拖到(175, +45°),确认 Walk_F 与 Walk_R 权重各约 0.5、总和恒为 1;再拖到(260, +45°),确认变为四点加权(Walk 两点各约 0.35、Run 两点各约 0.15);游戏内斜向行走呈现混合姿势而非朝向突变。
  3. 治滑移。验收:把 MaxWalkSpeed 对齐到 Walk 剪辑的制作速度后,匀速直线行走时以旁观视角慢放观察鞋底,贴地阶段无明显位移,滑移量控制在步速 5% 以内;再故意把速度改回 600,观察滑移复现,确认你理解的是公式而不是玄学。
  4. 把 EventGraph 逻辑搬进 C++ AnimInstance。验收:AnimGraph 内零 Cast 节点;用 Unreal Insights 对比 50 个 AI 角色同屏时动画更新耗时,C++ 版应有可见下降。

下一章

状态机加混合空间能让角色"跑得像样",但 3A 角色还有三件事这套体系给不了:脚踩台阶不悬空、几百段动画里自动选出最连贯的一帧、跑动中自然转头盯着一个 NPC。第 12 章拆这三板斧——Motion Matching、IK 与 Control Rig,并且算一笔诚实的成本账:什么时候它们不值得上。

延伸阅读

  • 官方文档:Unreal Engine → Animating Characters and Objects → Animation Blueprint → State Machines
  • 官方文档:Unreal Engine → Animating Characters and Objects → Blend Spaces(含 Sync Group 与 Sync Marker 词条)
  • 官方文档:Unreal Engine → Animating Characters and Objects → Root Motion
  • GDC 2014,David Rosen:Animation Bootcamp: An Indie Approach to Procedural Animation(程序化动画入门经典,跟随与重叠法则的工程版)
  • Frank Thomas & Ollie Johnston,《The Illusion of Life: Disney Animation》,1981(十二法则原始出处,读"Timing"一章即可)
  • 源码:Engine/Source/Runtime/Engine/Classes/Animation/AnimInstance.h(NativeUpdateAnimation 的调用链与线程语义)