
游戏引擎架构深度解析写到第三篇这次把物理和动画放在一起聊。很多刚入行的朋友会觉得这两个系统八竿子打不着——物理归物理动画归动画。但真去翻 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 PruneSAP和基于包围盒层次树BVH。SAP 的核心思路是把所有物体按某个轴上的坐标排序然后维护一个区间集合两个区间重叠的物体对才进入候选列表。它的优点是增量更新很快物体移动后只需要在排好序的数组里做小范围交换非常适合动态物体密集的场景。BVH 则更适合静态世界或者物体分布差异很大的关卡建树之后查询效率高但对动态物体需要处理树的重新平衡。我在实际引擎里通常采用混合策略动态物体用 SAP 或者空间哈希网格做初步粗筛静态的大型世界地形、建筑、碰撞体积单独走 BVH再用一层 Phase 区分动态 vs 静态动态 vs 动态的组合。别小看这层隔离它可以避免很多无效的候选对——地形之间的碰撞对永远不会产生为什么还要让它们进宽相候选队列宽相返回的不是接触对而是候选对。它只负责告诉你这两个物体可能碰了至于到底碰没碰、接触到什么程度那是窄相的事。这个边界如果模糊了后面的稳定性就会出问题因为宽相的容错半径一旦被当作精确结果使用接触点会来回抖。2.2 窄相接触点、法线和穿透深度从这里产生窄相阶段对候选对做精确检测输出接触流形contact manifold——也就是一组接触点、法线和穿透深度。现代引擎里碰撞体被大力建议近似成凸体原因在于凸体之间的碰撞检测算法非常多且成熟比如 GJKGilbert-Johnson-Keerthi配合 EPAExpanding Polytope Algorithm可以稳定输出最小穿透向量SATSeparating 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 缓存会被打得七零八落帧时间波动特别明显。我后来把内存布局改成物理模块独占一块模拟缓冲区里面按 SoAStructure of Arrays 方式存放刚体状态比如所有位置放在一个连续数组、所有四元数放另一个数组动画模块独立维护姿势缓冲局部姿势一个块、全局姿势一个块两者之间的共享数据结构只有每 tick 一次的接口 DTO。这样热点数据紧凑带宽占用小GC 或者自定义分配器的碎片化问题也更好隔离。如果你在调帧率时发现物理和动画引起的 cache miss 一直在前十名徘徊先检查数据分区再考虑算法优化收益会大得多。如果让我给还在做架构决策的同行走一个最小建议把时间管理和数据布局这两件事放在功能开发之前定下来。物理与动画的接口表面上是算法问题骨子里是节奏和空间的协调问题这两块地基稳了之后的脚滑、抖动、布娃娃抽搐、重放不一致绝大多数都能在不出 bug 的前提下消灭掉。这一篇先讲讲透下一篇可以专门拆角色控制器和动画状态机在游戏逻辑层的协同那些边角料比底层更磨人。