游戏引擎架构深度解析写到第三篇,这次把物理和动画放在一起聊。很多刚入行的朋友会觉得这两个系统八竿子打不着——物理归物理,动画归动画。但真去翻 Unreal、Unity、Frostbite 这些引擎的源码结构,你会看到 Physics 和 Animation 往往被塞在同一条时间推进回路里,甚至共用同一套 Job 调度。原因很简单:它们俩都不是渲染那样的纯数据读取,而是需要在每帧持续"积分""求解"的模拟系统。这篇文章就从物理与动画在引擎架构中的定位讲起,一路拆到碰撞管线、约束求解、动画状态机、IK 后处理,再聊聊两者交汇处的架构设计。适合正在设计自研引擎、或者想在现有引擎里做底层改造的开发者,说实话,这一块踩坑的深度和频率往往比渲染管线还要高。
1. 先从"为什么他俩总被放一起讲"说起
1.1 两个子系统不是数据系统,而是模拟系统
如果你把引擎的各个模块按"数据的流向"画一张图,渲染是最直观的:场景里有一堆三角形、材质、光照,渲染器在每一帧把它们消费掉,输出一张屏幕图像。它的核心逻辑是"读取-变换-输出",数据和数据之间的先后关系稳定,中间没有太多"上一帧的输出会影响到这一帧输入"的闭环。
物理和动画不是这样。物理系统拿到的是当前所有刚体的线速度、角速度、位置、质量和碰撞形状,它要做的是对这些状态做一次时间积分,同时解一组不等式约束(不能穿透、摩擦力要满足库仑锥),最后输出新的状态。这个新状态又会成为下一帧模拟的输入。动画系统虽然表面上更像"读取-插值-输出",但一旦进入状态机过渡、混合空间、IK、程序化动画,上一帧的姿态和速度同样会直接影响当前帧的结果,你没办法只拿着当前输入就推断出输出。
用一句通俗的话总结:渲染像拍照,物理和动画像拍电影。拍照只需要保证按下快门那一刻光线正确;拍电影则必须保证每一帧和上一帧之间连续、稳定、可预测。所以引擎架构里,物理和动画常常共享同一条"固定步长模拟回路",它们对时间的敏感度和容错要求几乎一致,而不是像渲染那样去适配显示器的可变刷新率。这一点是理解后面所有设计的地基。
1.2 造成"必须同频"的核心场景:布娃娃与动态过场
让我举个最典型的例子:角色死亡之后倒地。动画系统有一个死亡动画,通常是从站立到瘫软的那么一两秒。但在很多游戏里,死亡瞬间角色会切换到布娃娃模式,让物理系统接管所有骨骼的运动,物体被子弹击中、被爆炸掀飞时也会是这样的表现。
问题来了:动画驱动的角色是一个骨骼层次结构,物理驱动的角色是一组互相用关节约束连起来的刚体。两者的"数据语言"不一样。动画系统给的是每个关节的局部旋转,物理系统给的是每个刚体在世界空间里的位置和四元数。要把一个动画驱动的角色切换到物理驱动的角色,你必须在一个确定的时间点上完成数据迁移——把当前骨骼姿态转换成物理体的初始姿态、初始角速度、初始线速度,同时把动画系统的速度尽量无损地"翻译"成物理系统的速度。这个转换如果不在同一个 tick 里完成,角色就会瞬间弹开、塌陷或者抖动。
这就是物理和动画必须同频的根本原因:它们不是两个彼此独立的子系统,而是同一个"角色运动生成链"的上游和下游。动画负责表达意图,物理负责表达受力后的反馈,引擎架构必须为两者提供一条共享的状态通道。后面第 4 章讲的固定步长、插值缓冲、过渡模式,全都是在为这条通道服务。
2. 物理系统架构:宽相、窄相、求解器,各管一段
实时物理系统的常规三段式是宽相(Broadphase)、窄相(Narrowphase)、求解器(Solver)。很多入门资料把这当成一个流程清单,念完就过了。但在架构层面,这三段的分工和取舍非常值得展开。
2.1 宽相:先把"不可能碰撞"的物体过滤掉
宽相阶段的任务是快速找出"有可能发生碰撞"的物体对。游戏场景里可能有几千个动态物体、几万个静态物体,如果每一对都去算精确碰撞,那是 O(n²) 的复杂度,帧率直接崩溃。宽相的目标就是把不可能碰的物体对尽早剔除。
最常见的做法有两类:Sweep and Prune(SAP)和基于包围盒层次树(BVH)。SAP 的核心思路是把所有物体按某个轴上的坐标排序,然后维护一个区间集合,两个区间重叠的物体对才进入候选列表。它的优点是增量更新很快,物体移动后只需要在排好序的数组里做小范围交换,非常适合动态物体密集的场景。BVH 则更适合静态世界或者物体分布差异很大的关卡,建树之后查询效率高,但对动态物体需要处理树的重新平衡。
我在实际引擎里通常采用混合策略:动态物体用 SAP 或者空间哈希网格做初步粗筛,静态的大型世界(地形、建筑、碰撞体积)单独走 BVH,再用一层 Phase 区分"动态 vs 静态""动态 vs 动态"的组合。别小看这层隔离,它可以避免很多无效的候选对——地形之间的碰撞对永远不会产生,为什么还要让它们进宽相候选队列?
宽相返回的不是"接触对",而是"候选对"。它只负责告诉你"这两个物体可能碰了",至于到底碰没碰、接触到什么程度,那是窄相的事。这个边界如果模糊了,后面的稳定性就会出问题,因为宽相的容错半径一旦被当作精确结果使用,接触点会来回抖。
2.2 窄相:接触点、法线和穿透深度从这里产生
窄相阶段对候选对做精确检测,输出接触流形(contact manifold)——也就是一组接触点、法线和穿透深度。现代引擎里,碰撞体被大力建议近似成凸体,原因在于凸体之间的碰撞检测算法非常多且成熟,比如 GJK(Gilbert-Johnson-Keerthi)配合 EPA(Expanding Polytope Algorithm)可以稳定输出最小穿透向量,SAT(Separating Axis Theorem)则很适合矩形盒和凸多边形。
凹体怎么处理?你可以把凹网格预计算成若干个凸体拼接,也可以在运行时做凸分解。引擎里大多数武器、角色、载具的碰撞体其实都是凸包组合,而不是美术给你一个高精度三角网就直接用。因为三角网格之间的碰撞检测算法复杂、性能不可控,而且接触点容易产生"缝隙穿透"的判断错误。
窄相里一个容易被忽视的架构细节是接触流形的持久化(Persistent Manifold)。如果每一帧都重新生成接触点,上一帧的接触信息和这一帧之间没有关联,物体之间的接触会出现高频抖动,尤其是箱子叠箱子、角色踩在斜坡上这类场景。持久化流形的做法是保留上一帧的接触点,在窄相生成新接触点后,对原来的接触点做"延续性验证",能匹配的继续沿用,甚至保留一些旧的接触点做平滑,稳定性立刻提升一个档次。这部分在架构上体现为:窄相不只是算法,它要维护碰撞形状的对应关系和一个带缓存的接触对象池。
2.3 求解器:接触、关节、摩擦都在约束系统里统一处理
求解器是物理系统里最难的部分。它拿到的是窄相输出的接触流形、所有刚体的当前状态、以及引擎层面的关节约束(铰链、球形关节、滑动关节、车辆悬挂),然后要在有限的计算量内解出一组"不违反约束"的速度修正。
实时引擎里最主流的是顺序冲量法(Sequential Impulses)。它的思路是把所有约束看成一组速度级的不等式,逐个约束迭代求解,每一轮修一点,多轮迭代后逼近一个稳定解。这个方案的优点是可以很自然地混合不同类型的约束,统一抽象为"一个约束求解器要处理的 Constraint 对象",每个 Constraint 负责计算相对速度、冲量以及摩擦锥的切线冲量。
架构上要注意的点是:约束系统的抽象层级决定了引擎的上限。我见过一些引擎把接触约束、关节约束、马达约束拆成三套独立代码,最后做车辆物理的时候痛苦到不行。更好的做法是把它们统一成"速度级约束 + 位置级约束"两类,每个约束实现宽相、窄相、求解三个阶段各自的接口。不管你是在做布娃娃的球形关节,还是做角色的脚部 FK,都能复用同一套求解逻辑。摩擦也是约束的一种,近似地用切线方向上的冲量限制来模拟库仑摩擦锥,迭代次数不够时会产生"物体一直在滑动"的观感,这属于求解器精度问题,不是架构问题,但架构上必须给求解器预留可配置的迭代次数,方便不同类型玩法调节稳定性与性能的平衡。
3. 动画系统架构:从采样到姿态,管线里的每一层都在解决"手感"问题
动画系统的架构常常被低估,因为"播放动画"听起来很简单。但一旦涉及动作游戏、射击游戏、复杂过场,动画系统的复杂度不比物理低,它要解决的核心问题是:如何从稀疏的关键帧数据,生成连续、顺滑、可叠加、可被逻辑中断的姿态。
3.1 资源与数据层:骨骼树、剪辑和采样曲线怎么组织
动画的资源层和运行时层通常是分离的。资源层里有一套"作者骨骼"(Authoring Skeleton),定义美术在 DCC 工具里建模时的骨骼层级和关节命名;运行时引擎会有一套"运行时骨骼"(Runtime Skeleton),它才是真正驱动网格蒙皮的那棵骨骼树。两者之间靠骨骼映射表转换。为什么要绕这么一层?因为引擎经常会重排骨骼顺序、删除不被引用的节点、合并冗余关节,以提升缓存和蒙皮效率,资源层则保持美术原始拓扑不变,方便迭代。
动画剪辑本质上是给每个关节存了一条随时间变化的曲线,每帧采样得到关节的局部平移、旋转和缩放。这里有两个性能关键点。第一是存储布局,通常要按"关节通道"批量排列,而不是按"时间帧"排列,这样连续采样同一帧所有关节时,缓存命中率会高很多。第二是压缩,动画数据可以量化到短整型,旋转分量用四元数归一化后完全可以用一个 3 分量加符号位的方式存储,精度损失在视觉上几乎不可感知。很多引擎还会对曲线做分段拟合,平坦区域每 N 帧存一个关键值,运动剧烈的区域才加密采样。
运行时拿到采样数据后,第一件事是计算局部姿势,然后沿着骨骼层级递归合成全局姿势(把父节点的变换乘下来)。这一段涉及大量矩阵乘法,是动画系统里最值得做 SIMD 优化和 Job 化的部分。架构上最好把"局部姿势缓冲"和"全局姿势缓冲"分成两个独立的大数组,方便后续蒙皮、物理修正、IK 读取,而不是每个关节生成一个独立矩阵再拼起来。
3.2 运行时图:状态机、过渡和混合不是"播放动画"
把动画播放理解成"读 clip 播关键帧"是新手最容易踩的坑。真正生产级别的动画系统是一张有向图,节点是动画状态,边是过渡条件,每帧在图上做一次"评估",然后输出一个融合后的姿态。
状态机的核心是过渡表(Transition Table)。每个状态记录它可以跳转到哪些状态,每个过渡带有一组条件(血量低于阈值、玩家按下攻击键、动画播放到某个比例等)和一组融合参数(过渡时长、插值曲线)。架构上常见的设计是"瞬切优先"和"同步点":比如角色从待机切到翻滚,可能要求立即生效,不允许插值;但如果是从走路过渡到跑步,通常需要 0.2 秒左右的速度融合,不然脚底会滑步。同步点解决的是过渡时两个动画未对齐的问题,让两个 clip 的指定时间点对齐后再开始插值,可以显著减少动作扭曲。
混合空间(Blend Space)是另一个关键节点。它用一到两个参数(比如移动速度、朝向夹角)在多个动画样本之间做加权混合。常见实现是 Delaunay 三角剖分或径向插值,把样本点放在一个二维平面上,输入参数落到哪个三角形里,就用该三角形三个顶点的权重合成姿态。姿态混合的数学本质是归一化权重加对旋转的球面插值,四元数部分用 nlerp 或 slerp,引擎里通常用 nlerp 加再归一化来节省性能,视觉上做一点采样对齐后基本区分不出 slerp 的差异。
3.3 后处理阶段:IK、根运动与动画事件
状态机输出的姿态是"标准动作",但角色在真实场景里会踩到台阶、会伸手够取物、会被物理打断。这些都需要后处理阶段修正动画姿态。
IK(反向动力学)是这里的主角。最简单的角色脚部 IK 会在不平整地面把脚掌贴到地面,常见的做法是每个脚步设置一个 IK 目标,引擎每帧修改踝关节和膝盖关节的旋转。算法上有二骨 IK(两关节解算)、CCD、FABRIK 等,引擎里大部分场景用二骨 IK 就够了,因为角色四肢最多也就是髋、膝、踝这么几节。手部 IK 用来让角色握手柄、按按钮时指尖对准目标,这里常用多约束迭代或者基于雅可比矩阵的求解器,但实时场景为了性能往往会做近似处理。
根运动(Root Motion)解决的是"动画里的位移由谁消费"的问题。一个攻击动画在导演轨迹里携带了角色重心向前位移的数据,你既不能让物理系统完全接管这段位移(会滑步),也不能完全忽略它(角色会原地挥拳)。架构上通用的做法是让动画系统在特定骨骼(通常是骨盆或脚底)输出"根位移增量",然后交给角色控制器或导航系统做下一步决策。这个数据流要是断了,最典型的表现就是角色在攻击时会"原地漂移"。
动画事件(Animation Notify)是在动画时间轴上挂逻辑断点,播放到 30% 的时候触发一个攻击判定,播放到 50% 触发音效。架构上要注意的是事件的触发要和动画时间尺度绑定,而不是和渲染帧计数绑定,否则慢动作播放时攻击判定会在错误时间点发生。
4. 物理与动画交汇:固定步长、缓冲区与部分驱动
前面铺垫了这么多,现在终于到两者真正的"接口设计"。这部分最考验引擎架构师的地方在于:怎么让两个不同性质的模拟系统在同一个时间基准下稳定协作。
4.1 固定步长与渲染插值:为什么不能各跑各的
物理模拟对时间步长极其敏感。一个显式 Euler 积分,步长一变,数值稳定性就变;约束求解的容差、穿透修正的力度、接触流形中点的存活时间全都依赖稳定的 deltaTime。所以成熟的引擎都会把物理模拟固定在某个步长上,常见的是 60Hz 或 120Hz,不管渲染帧率是 30 还是 144,物理都用这个恒定步长推进。
动画系统理想情况下也应该在这个固定步长上模拟,特别是动画状态机的过渡、混合时间、根运动累积,如果每帧 deltaTime 是 16.7ms 和 11.1ms 交替出现,动画混合的一致性会被破坏。所以你会看到不少引擎的架构里,动画和物理是被同一个"模拟时钟"驱动的:每经过一个固定步长,物理推进一步,动画系统也做一次采样和姿态更新,然后输出到一个"当前模拟姿态"的缓冲区里。
但渲染器跑在可变帧率上,如果渲染帧直接使用这个"当前模拟姿态",画面会出现频率不匹配的抖动。解决方式是插值:记录模拟步长内最近两次的姿态快照,渲染帧根据该帧所处的时间点 alpha 做插值。物理侧同样保留上一帧和当前帧的刚体位置,渲染时插值。这个"双缓冲+时间插值"的设计是物理与动画协作的根基,也是很多自研引擎一开始没有做、导致角色高速运动时画面一顿一挫的原因。
4.2 布娃娃和驱动动画的过渡:不是切个状态就完事
布娃娃切换是物理动画接口上最容易翻车的地方。从一个动画驱动的状态切到物理布娃娃,必须把动画给的姿态作为物理体的初始状态,这一点大家都懂,但很少有人注意到还要估算初始速度。角色正在快速奔跑时被一枪击毙,如果物理体初始线速度是 0,尸体不会向前飞出,观众一眼就会觉得假。
正确做法是在切换的那一帧,从动画系统里读取每个关键骨骼的局部旋转角速度,以及身体重心的线速度,然后映射到对应物理刚体的初始角速度/线速度。这就是第 1 章说的"数据语言翻译"。反过来,从布娃娃切回动画驱动更麻烦:你不能直接强制骨骼姿态等于动画输出,否则会产生非常生硬的"尸体突然活过来"的感觉。常用的方案是设定一个过渡期,动画系统输出目标姿态,物理关节作为驱动器被引导向目标姿态逼近,同时设置位置/角度硬约束的软度,让两者在几十毫秒内自然收敛。
4.3 部分驱动:把物理"嵌"进动画后处理链路
除了布娃娃这种整身切换,还有一类常见需求是"局部被物理驱动"。比如角色伸手推一个箱子,手的位置可能被物理反馈修正;或者头发、裙摆、尾巴这种次级体,用物理模拟来增加自然摆动。架构上,这类需求通常放在动画后处理阶段:先由状态机生成完整姿态,再让物理引擎对特定骨骼做"位置/旋转修正",最终姿态 = 动画姿态 + 物理修正。
这个阶段有一点类似"物理马达"的概念。物理修正不是简单地把骨骼拉到物理体的位置,那样会破坏后续蒙皮;正确的做法是把物理约束力、弹簧力作用到骨骼链上,让动画姿态向物理目标方向自然弯曲。引擎里会提供类似"Physics Effector"的组件,设置 Strength、Damping、Max Force 等参数,本质上是一个弹簧约束求解器。把这个关节控制器摆在动画后处理链里,物理结果就能在下一帧的动画生成中参与计算,形成"动画意图 -> 物理反馈 -> 动画修正"的闭环。
5. 在时间和数据布局上踩过的坑
再往下不是理论,是我在实际自研引擎里真的被磨掉几层皮的地方。这些坑几乎都和"物理&动画做两个独立模块"的错误架构假设有关。
5.1 时间缩放(Time Scale)对稳定的影响
很多项目会加一个全局时间缩放,用于镜头慢动作、剧情演出、击杀回放。第一版实现往往直接把 deltaTime 乘个系数丢给所有系统,动画和物理都按 0.1 倍步长推进。物理系统运行一会儿就会出问题:物体在慢动作下穿透地板、车漂移、布娃娃抽搐。
原因在于物理的穿透修正和约束容差都是按固定步长调校的,你擅自改了步长数值,解算器的行为就变了。正确做法是:时间缩放只作用于"模拟时钟的推进频率",而不是"模拟步长本身"。比如物理固定 120Hz,你把模拟频率降到 12Hz,但每次模拟仍然走的是 1/120 秒的积分步长,只是两次模拟之间的真实时间变长了。这样物理状态在慢动作下依然稳定,代价是物理响应会变"钝",但慢动作下观众反而注意不到这个钝。
5.2 确定性复现:不要指望同场景不同平台逐位一致
物理模拟的确定性问题折磨过很多联机项目。同一场重放,在 Windows 和 Linux 上跑结果不一致;甚至同一台机器上,开启多线程后,碰撞对到达窄相的顺序变化,导致接触求解顺序变化,最终结果也有细微不同。
架构上的应对思路是"分层确定性":跨平台逐 bit 一致几乎不可能,因为即使同一份浮点代码,不同编译器的 FMA 优化也会产生不同结果;真正能保证的是同一平台、同一构建下,如果输入序列一致,输出就一致。做法包括固定宽相候选对的排序规则、固定 Job 调度中物理体的处理顺序、为随机源提供可写入的种子。如果你还要做联机回放,那就记录"玩家输入+随机种子+物理配置"而不是每帧全量状态,回放时才和服务器逐帧校验关键状态。别在确定性上钻牛角尖,先保住可复现 Debug 的能力。
5.3 数据布局:物理缓存与动画 Pose 分区
最后一个坑是纯性能向的。物理系统的动态 AABB、接触流形、速度状态是每 tick 都在更新的热数据;动画系统的局部姿势、全局姿势、蒙皮矩阵是大尺寸的流式数据。如果把它们和场景渲染资源、UI 数据混在同一块内存池里,现代 CPU 的 L2 缓存会被打得七零八落,帧时间波动特别明显。
我后来把内存布局改成:物理模块独占一块模拟缓冲区,里面按 SoA(Structure of Arrays) 方式存放刚体状态,比如所有位置放在一个连续数组、所有四元数放另一个数组;动画模块独立维护姿势缓冲,局部姿势一个块、全局姿势一个块;两者之间的共享数据结构只有"每 tick 一次的接口 DTO"。这样热点数据紧凑,带宽占用小,GC 或者自定义分配器的碎片化问题也更好隔离。如果你在调帧率时发现物理和动画引起的 cache miss 一直在前十名徘徊,先检查数据分区,再考虑算法优化,收益会大得多。
如果让我给还在做架构决策的同行走一个最小建议:把"时间管理"和"数据布局"这两件事放在功能开发之前定下来。物理与动画的接口表面上是算法问题,骨子里是节奏和空间的协调问题,这两块地基稳了,之后的脚滑、抖动、布娃娃抽搐、重放不一致,绝大多数都能在不出 bug 的前提下消灭掉。这一篇先讲讲透,下一篇可以专门拆角色控制器和动画状态机在游戏逻辑层的协同,那些边角料比底层更磨人。