不用关键帧的蜥蜴:从世界锁脚到 UE 程序化动画

让一只蜥蜴盯着目标、向前追赶、转身并交替迈步,不一定需要先制作一组动画片段。另一种做法是保存脚踩在哪里,再根据身体和脚的关系计算关节姿势。

本文记录 Orbit 中一只程序化蜥蜴的实现:参考 WeaverDev 的 Bonehead 思路,在 UE 5.8 中用 C++ 更新运动与姿态,以原创蒙皮模型呈现结果。重点是各层怎样协作,以及为什么“每条腿都能做 IK”仍然不足以让它稳定行走。

文中的实现与验证以 2026 年 10 月 24 日的本地版本为准。它是运动学动画演示,尚未完成与参考影片的视觉等价验收;工程最新契约以项目的 Docs/engineering/environment.md 和验证约定为准。

这是当前版本的 24 秒实机记录,原生画幅为 984×708、30 fps。视频中的模型和贴图为原创,动作没有使用 Animation Sequence 或关键帧曲线。

先把“动画”和“物理”分清

传统骨骼动画保存一系列姿势,再根据时间播放、混合。这里保存的是目标、接触点、摆脚进度等状态,程序在每个模拟步重新计算姿势。

它依然需要模型、骨架和蒙皮权重。区别在于骨骼变换由程序生成:

1
2
3
4
5
6
7
8
外部目标
→ 身体位移与转向
→ 每只脚的 home 与世界接触状态
→ 对角组调度及摆脚轨迹
→ 身体起伏、头眼追踪、尾部弯曲
→ 两段 IK 连接腿与脚端
→ 完整局部骨骼姿势
→ 蒙皮与渲染

这里没有求解足底反力、肌肉力矩或质心动力学。两只对角脚留在地面也不意味着已经满足真实静力平衡。它通过有限的规则形成可信的运动,适合做可交互的生物表现;若要攀墙、跳跃或被外力撞倒,需要另外设计对应状态与约束。

第一层:身体移动,脚先留在原地

理解程序行走,最关键的是区分两个位置:

状态 含义 身体移动时是否一起移动
home 这只脚在身体附近的理想休息位置 是
contact 这只脚目前踩住的世界位置 否

home 可以由参考姿势中的脚骨位置经过 Actor 变换得到。站立脚的 contact 则必须保存在世界空间。身体向前移动时,home 逐渐离开 contact;距离超过阈值,才产生迈步需求。

如果把 contact 也作为身体的子节点一起移动,脚会始终停在身体旁边,看起来像四肢摆动的滑行玩具。锁脚不是某个最后补上的视觉特效,它直接决定了换脚的触发条件。

当前每只脚保存世界位置、旋转、接触法线、home、摆动起终点与进度。站立期间不更新位置和旋转;起步后由摆脚状态更新它们。

身体跟随目标时,距离过远就靠近,进入休息距离区间后减速停下,过近时以较低速度后退。目标在身后时优先转身,接近最大转速时降低平移速度。根运动还要检查未来支撑点和障碍,不能先移动身体,再指望 IK 把脚拉回来。

第二层:谁可以抬脚,比怎样抬脚更重要

只让每条腿独立判断“离 home 太远就迈步”,容易让四条腿同时离地。因此把四只脚分成两组:前左/后右,以及前右/后左。

一组获得迈步机会后,其中每只脚分别判断是否确实需要移动。已经处在合适位置的搭档继续支撑,不强制一起抬起;当前组运动期间,还会继续检查尚未起步的另一只脚,让它可以稍后加入。整个组完成后,再优先检查另一组。

当前实现额外记录一组内哪些脚已经起步,每脚每轮最多开始一次,避免移动太快时某一组持续重启、另一组长期得不到机会。它保留了对角组的基本方式,但并非照搬原协程的全部调度细节。

调度也必须与速度配合。若身体一秒走过的距离远大于两组脚能补回的距离,再公平的调度也会让腿被拉直。当前用一个保守的容量估计限制速度:

\[ v_{\mathrm{safe}} = \frac{d_{\min}\,s_{\min}(1+o)}{2\tau} \]

其中,d_min 是前后腿步距中的较小值,s_min 是 Actor 各轴绝对缩放的最小值,o 是前送比例,tau 是单步时长。分母中的 2 表示两组脚都需要时间。它是工程保护,不是生物步频定律;实际根运动还受当前脚端可达范围约束。

第三层:先找到落点,再释放支撑

迈步申请成功之前,要先找到可用的结束位置。当前从 home 加一段朝向 home 的前送偏移,向下进行有界地面查询。命中点还要满足坡度和腿长约束;找不到有效落点,就保持原接触。

前送距离取步距阈值的一定比例,而不是任意乘上当前误差。这样既给身体留出继续前进的余量,也降低落地后立刻再次超出阈值的风险。

脚骨通常在脚掌上方,因此 IK 目标并不等于地面命中点。当前使用沿接触法线的脚踝偏移;模型等比放大后,这个偏移也随之放大,否则脚踝高度正确、脚掌几何却可能穿入地面。

确定起点 P0、终点 P2 后,用二次贝塞尔生成摆脚轨迹:

\[ B(u)=(1-u)^2P_0+2(1-u)uP_1+u^2P_2 \]

控制点取起终点中点,再沿身体上方向抬高半个实际跨步长度:

\[ P_1=\frac{P_0+P_2}{2}+\hat n\frac{\lVert P_2-P_0\rVert}{2} \]

注意,控制点抬高半个步长,不代表脚会抬到半个步长。起终点等高时,中点的额外抬升只有四分之一个步长,因为贝塞尔控制点在参数 0.5 处的权重为 0.5。控制点高度与实际轨迹高度不能混为一谈。

归一化时间 t 先经过三次缓入缓出,再作为曲线参数 u:

\[ u=E(t)= \begin{cases} 4t^3,&t<\frac12\\ 1-\frac{(-2t+2)^3}{2},&t\ge\frac12 \end{cases} \]

旋转也使用同一个 u。起步时固定完整的世界起止旋转,终点朝向沿地面切线、上方向沿接触法线,随后用四元数 Slerp 插值。这与作者 Full 迈步器 的固定终点和共享缓动方式一致。

第四层:脚端决定腿的姿势

IK 要解决的问题是:已知腿根、两段骨长和脚端目标,求中间关节在哪里。

若两段长度为 a、b,腿根到目标的距离为 d,那么腿根处夹角可由余弦定理得到:

\[ \cos\theta=\frac{a^2+d^2-b^2}{2ad} \]

仅有距离还不足以确定膝肘往哪边弯,需要 pole 给出弯曲平面。没有稳定的 pole,关节可能绕腿根到脚端的轴突然翻转。

作者提供了自写的两段 IK。Orbit 使用 UE 的 AnimationCore::SolveTwoBoneIK,并在输入端处理退化情况:目标与腿根重合时补一个有效方向,pole 共线时换用备用平面,目标距离限制在两段骨长能形成三角形的范围内,并关闭拉伸。

IK 中的距离截断只保证解算有效,不会把不可达目标变成真实接触。如果实际脚骨停在最大伸展位置、内部 contact 却仍留在更远处,就会出现“目标已经锁脚,模型仍在滑动”的假象。因此要在根移动和落脚决策时约束可达性,并在测试中比较实际脚骨位置与 contact。

最后,程序把世界脚端目标变换到模型空间,旋转上、下腿使脚端到达目标,再设置脚掌旋转。当前通过 UPoseableMeshComponent 一次写入整套局部姿势,没有再叠加另一个动画实例来争夺这些骨骼的控制权。

身体、头、眼睛和尾巴怎样接上来

只有换脚,生物仍然像一张移动的桌子。身体起伏、注视和转弯尾摆提供了动作的连续感,但它们也会改变腿根的位置,所以必须服从接触约束。

当前在每个模拟步先更新根运动和脚状态,再构造身体姿态。身体起伏使用多轴周期变化与运动状态权重;这部分是程序生成的循环,不是脚步由正弦直接驱动。若起伏会让腿根离支撑脚太远,就缩减起伏幅度,保持脚端接触。

头部从上一模拟步的实际头位指向目标,限制三维转角,再把部分转角分给脊柱和胸部,髋部适当反倾。躯干弯曲过大会消耗腿长,因此继续按接触预算减小躯干转动,由头部补足剩余朝向。

眼睛必须在头部姿态确定后求解。先把真实目标方向转换到已经转动的头部坐标系,再求眼睛需要补偿的剩余方向;如果只读取“已经限角的头部目标”,头到达限角后,眼睛就可能失去继续注视的依据。左右眼分别限制向鼻侧的转动,允许更大的向外转动。

尾巴由实际角速度驱动反向弯曲,经过平滑后分配到七节尾骨。它采用 FK,不是 Verlet 绳链,也没有模拟尾部质量和惯性。

这里与作者 Full 控制器 存在需要说明的差异:Orbit 显式分配躯干转角并加接触保护,眼睛采用局部欧拉角平滑;作者使用骨链局部旋转反馈,眼睛则在世界空间做四元数平滑后限制局部角度。参数看起来相同,不代表瞬态响应相同。当前回归确认了限角和持续追踪,尚未完成两者的同输入轨迹对照。

两个看起来很小、实际影响很大的错误

放大模型后,腿长预算仍停在原尺寸

最初实现把 Hip 与 contact 放在世界空间比较,但 Reach 直接取参考骨架中的原始长度。模型放大两倍,世界距离跟着放大,腿长预算却没有变,结果每次根移动都被拒绝。更隐蔽的是,骨架初始化正常,系统仍报告“就绪”。

修复时选择统一计算空间:用包含位置、旋转和缩放的候选 Actor 变换,把世界接触点转回模型空间,再与参考骨架腿长比较。未来 home 的预测也使用完整变换,而不是只旋转偏移。

缩小模型还暴露了另一个相关问题:若 home 到 contact 的距离按世界单位触发,原步距对小腿来说可能太长。因此步距与前送偏移也改为模型空间定义,安全速度容量随缩放变化。配置的移动速度仍是世界厘米每秒,小尺寸实例可能先受到步态容量限制。

验证覆盖 0.5、1、2 倍等比缩放的 30/120 fps 运动、接触和一致性。非等比缩放、负缩放,以及行走期间动态改变尺寸,未纳入这次验证范围,不应由等比结果推断它们已经可用。

位置用了缓动,旋转却在追逐身体

另一个错误是脚端位置使用缓动后的 u,脚掌旋转却使用原始 t,并且每个模拟步把当前身体朝向当作新的旋转终点。

对固定 90° 转角,在单步 25% 时,三次缓动参数为 1/16,对应 5.625°;线性插值却已经转了 22.5°。这还没计算终点随身体转动带来的额外变化。脚的位置看起来正常,掌面却会提早扭过去。

修复后的核心关系可以缩成三行:

1
2
3
const float U = EaseInOutCubic(Phase);
Position = QuadraticBezier(Start, Control, End, U);
Rotation = FQuat::Slerp(StartRotation, EndRotation, U);

这里是说明原理的伪代码。起止位置和旋转在起步时捕获;落地保留结束旋转,之后身体怎么转,都不再改写这一只站立脚的世界朝向。

更新顺序、时间步与职责

当前模拟以 120 Hz 固定步长推进,渲染帧累计时间后执行必要的模拟步。过长帧限制追赶量,避免补算无限增长;代价是严重卡顿时不会追上全部墙钟时间。固定步长也不意味着任意移动输入在所有帧率下自然相同:外部目标仍需要相同时间采样,相关一致性测试使用明确的输入条件。

主体的几个职责保持在一个生物 Actor 内:根运动、接触状态、对角调度和姿态。独立的 Lab Actor 只负责演示路线、目标胶囊、注视方块和调试输入;移除 Lab 后,生物仍可由其他对象提供世界目标。Blueprint 保存模型引用与内容默认值,C++ 不写死某个素材路径。

这种边界让以后给生物更换目标来源时,不必把演示输入、装备或游戏进度塞进动画控制器。当前骨名和腿布局是这只原创蜥蜴的资产契约,没有把它提前包装成通用四足动画框架。

怎样证明“脚真的踩住了”

测试要观察实际结果,不能只检查内部状态是否自洽。

检查 关注的问题
实际脚骨与 contact 的世界距离 IK 有没有真的到达目标
站立期间的脚端位置与旋转 身体转动有没有把脚拖走
活动脚组合与总数 是否跨对角组同时抬脚
两组的实际步数 调度有没有长期饿死某一组
相同输入下的不同帧率 固定时钟与状态重置是否成立
坡面、缺失地面与 IK 退化输入 无效几何是否导致假接触或无效数值
转弯中四分之一、四分之三时刻的真实脚骨旋转 是否使用正确缓动和固定终点

当前版本通过了 20 项原生回归,蓝图编译与模型绑定检查通过。另用 D3D12/SM6 固定画幅记录了 24 秒、720 帧的路线,复核连续运动。修复前后根位置、速度、目标和迈步时序逐帧相同,脚掌朝向按修复发生变化。

这些结果证明了本实现的若干约束,没有证明与作者影片完全一致。作者场景初始相机参数下拍出的原创模型画面,也依然是本实现,不是参考影片。手动镜头、目标操作、头眼响应和造型差异需要分别比较。当前没有性能采样,也没有验证大量实例同时运行的成本。

继续扩展时,先保留哪条因果链

最值得保留的是“真实身体移动改变 home 与接触点的关系,触发有支撑条件的迈步,脚端再决定关节姿态”。头眼和尾巴可以暂时关闭,模型可以先用简单骨架替代,但这条因果链应该仍然成立。

落脚与姿态稳定后,再增加地形适应、行为选择或更精细的表现。遇到步态异常时,先检查坐标空间、骨长、目标可达性和更新顺序,再调整频率与幅度;扩大容差通常只能让错误暂时不那么显眼。

参考与署名

Orbit 的相关实现保留 WeaverDev 的代码许可与署名;本篇配图、视频和模型来自本地原创版本,没有使用作者的美术资源。