
物理与动画之间是游戏引擎里最容易扯皮的一层。你经常能在项目中遇到这样的画面角色靠近台阶动画明明在播“迈步”可胶囊体就是被碰撞体挡住整个人直挺挺滑出去又或者物理受击时人物抖得跟弹簧一样动画师收货后测试却觉得手感完全不对。做引擎架构的人如果处理不好这两个系统的边界后面的玩法、手感、性能基本都跟着遭殃。这篇是《游戏引擎架构深度解析》系列的第三篇重点放在物理系统与动画系统上。不是单独讲某个物理引擎或者动画软件的用法而是站在引擎整体架构的角度把这两个子系统各自的模块划分、数据流、步进策略以及它们在角色控制、布娃娃、布料这些交汇场景里如何协作完整拆一遍。无论你是做自研引擎的还是在Unity、Unreal、Godot 里做底层系统开发的程序员我建议你都把这一层的数据流动捋清楚因为它几乎决定了所有“带手感”的项目调优空间。1. 物理与动画相遇的地方先搞清楚数据的“主从关系”1.1 引擎中这两个子系统并不对称——动画是目标物理是约束很多初读引擎源码的人会把物理系统和动画系统理解成两个平行模块平时各自算各自的最后都写到Transform组件上完事。这个印象在纯展示型项目里碰巧能跑但真正做角色操控、打击反馈、载具碰撞时一定会翻车。原因很朴素动画模块输出的是一套“期望姿态”物理模块输出的是一套“受约束后的行为”两者地位天然不对称。动画系统服务于表现力它告诉你角色的脚应该抬多高、手应该挥到哪里、重心怎么走物理系统负责规则它校准这套姿态是否符合世界中的碰撞边界、速度和力。在这条链路里动画多数情况下是“目标”物理是“约束”。比如角色跑步动画驱动了躯干的位移与旋转但角色的胶囊体同时也要参与物理空间的碰撞。如果引擎直接把动画写好的Transform同步给物理体那物理引擎就会把它当普通刚体处理墙一挡就乱反之如果完全让物理接管角色就没法按动画师的表演去走了。我在做过的一个第三人称项目中就踩过这个坑。最初接入第三方物理引擎时角色根骨骼直接驱动刚体运动结果角色被门的碰撞体挤得东倒西歪动画根运动明明在往前走实际世界坐标却纹丝不动。后来我们把角色胶囊体改成Kinematic刚体也就是不受力、只接受程序控制的运动学刚体才解决。Kinematic刚体不会参与受力求解但它依然会推动Dynamic刚体、触发碰撞回调。角色移动完全由动画根运动和输入控制计算得出物理只作为碰撞的“检测方”和“对外施力方”这才是典型的角色控制器架构。1.2 需要在Transform之外单独维护的“中间层”双缓冲与合并时机解决了主从关系后下一个问题就是数据怎么流动。如果你的引擎直接让物理系统和动画系统往同一个Transform组件上写就会产生最常见的竞态这一帧动画先写了物理后写覆盖了动画下一帧物理先算动画后写又覆盖回去。这种相互覆盖最后表现出来的就是角色抖动、位置漂移。所以架构上一定要加一层缓冲。比较常见的设计是动画系统把每帧算出的骨骼姿态写入一个PoseBuffer物理系统把碰撞修正后的位移、旋转写入另一个DeltaBuffer然后由角色控制模块或者场景图容器在一个确定的时机做合并。可以把它理解成两阶段提交——每个系统都在自己的局部空间算完最后才到一个总线上同步避免高频来回覆盖。实际落地时合并时机也非常讲究。渲染是逐帧进行的物理却有固定步长动画采样又可能在渲染帧开始时才求值这三者天然不在一个节奏上。如果你把动画结果直接用在物理步进前每一个物理步进都会吃到“昨天的动画”角色走路会出现明显滑步如果你在物理步进后才取动画结果那碰撞和动画永远不同步穿墙就会发生。我见过比较稳妥的做法是引擎层把动画求值放在物理步进前但把根骨骼的移动量以缓存位移的形式提交给物理物理在自己的固定步长上处理完后再在渲染前对物理体做一次位置插值渲染最终看到的世界坐标与动画姿态才能把误差控制在肉眼不可感知的范围。2. 物理系统的多层分级碰撞检测和求解的分裂事件物理引擎本身通常是一个独立库但从引擎架构角度看它需要被拆成好几层来看因为每一层的数据结构、性能瓶颈、调优手段都完全不同。最常见的分层是“宽阶段碰撞检测”“窄阶段碰撞检测”“约束求解”“步进集成”这四块。2.1 宽阶段动态BVH与空间划分的取舍宽阶段的目的是快速排除绝大多数不相交的物体对。如果直接用全量两两测试场景里200个刚体就要做两万次碰撞检测再大的CPU也扛不住。实际引擎里一般用两种方案静态世界用空间哈希或静态BVH动态物体集合用扫描剪枝或动态BVH。动态BVH听着玄其实就是把每个物体的包围盒挂在一棵自平衡的树上物体移动时逐个更新在树里的叶子节点所有祖先节点都跟着重算最小包围盒。这套结构的优势是增量更新快适合角色、子弹、掉落物这类不断小幅移动的物体缺点是树结构有指针间接跳转缓存不太友好。所以主流引擎都做了混合处理地面、墙壁这些静态几何体单独建一棵静态加速结构只有动态物体才进动态BVH每次跨树做一次时空对测试。这里有个值得注意的工程细节如果你的项目里有一根又长又薄的碰撞体比如钓鱼竿、旗帜杆它的AABB会特别夸张。AABB是轴对齐的只要杆子斜着放包围盒比杆本身大出好几倍导致宽阶段产生大量误报把本不该参与检测的对数拖进窄阶段。遇到这种形状要么把它拆成多个小碰撞体要么在宽阶段就根据物体朝向做一次预判不然明明是十个物体却要触发三十多组成对检测。2.2 窄阶段GJK/EPA与凸分解带来的架构影响窄阶段真正处理宽阶段筛出来的成对碰撞体。这一层最常听到的算法是GJK、EPA、SAT。GJK擅长判断两个凸体是否相交并给出最小距离但给不出穿透深度EPA在GJK基础上继续迭代求出穿透向量和深度SAT则更适合处理二维凸多边形碰撞。商业引擎里的凸体碰撞基本都绕不开这几个算法。但一个很有趣的架构影响在于引擎给物理系统用到的碰撞形状不直接等于渲染网格。游戏里大量物体是凹的比如一个L型台阶、一面有柱子的墙直接拿凹网格去做窄阶段非常昂贵。业界通行做法是在资源导入管线里把凹网格提前拆成若干凸包组合这个过程叫凸分解可以离线做也可以在资源加载时做。拆完之后物理引擎只认一堆凸体列表每个凸体拥有自己的顶点、面和包围盒碰撞精度足够性能也稳住了。我在接入自研物理系统时干脆规定美术那边所有碰撞体必须手工指定基础图元组合最多允许少量凸包。这样虽然牺牲了一点便利换来了窄阶段预测的确定性。因为GJK这类基于支撑函数的算法对凸体的顶点数量敏感你控制凸体顶点数就能控制最坏情况下的CPU耗时不让物理成为豁口。2.3 解算器迭代求解、刚体休眠与稳定性碰撞检测只是告诉引擎“谁撞了谁、穿了多少”真正让物体停下来、弹出去的是约束求解器。刚体之间的碰撞、关节连接、摩擦、受力全部在求解器里被打成一组约束方程。理论上这是个大规模线性方程问题但实时游戏不可能用精确直接解法所以大家几乎都选迭代求解一帧内反复迭代十几到几十次逼近稳定状态。迭代求解带来的最大问题就是“抖动”。几个箱子堆叠在一起时迭代次数不够就会发软箱子一点点凹陷迭代次数太高运动又会变得过刚能量释放不自然。这个问题没有银弹只能根据项目调整个数并开启刚体休眠机制当一个物体的速度长时间低于阈值且不受外力就把它标为休眠体不会再参与求解这样堆叠场景能省下大量CPU。但休眠也常背锅比如角色把一个箱子推到墙边箱子刚休眠下一秒又被玩家挤到它就死活不动表现得像被焊死一样。所以我后来都建议项目里休眠阈值设保守点宁可让物体多算两帧也别让玩家撞上“焊死的箱子”。2.4 固定步长、子步与渲染帧率脱钩的工程含义物理系统最容易被误解的就是“物理更新频率应该等于渲染帧率”。帧率是不稳定的有时显示器是144Hz有时显卡掉到40帧如果你的物理直接绑在渲染帧上那角色在低帧率时会明显穿过薄墙在高帧率时箱子又会疯魔乱抖。所以引擎普遍采用固定时间步长常见的是每秒50次或60次物理步进渲染则每帧根据插值系数从上一帧和当前帧之间取画面。这个脱钩在架构上意味着物理系统与渲染系统之间必须有一个状态快照的机制。物理在自己的固定步进里更新每完成一个步进就保存一套刚体位置与旋转渲染层渲染时根据当前真实时间在快照间线性插值拿到一个视觉上的平滑位置。很多新手做物理渲染时会直接去读当前刚体的Transform看起来也没什么问题可一遇到帧率波动运动就呈现“一顿一顿”的观感。高帧率显示器上尤其明显。还有一个进阶设计对于高速小体积物体比如子弹、乒乓球固定60Hz步进仍然不够会产生“隧道效应”—上一帧在墙左边下一帧已经到了墙右边中间完全没检测到碰撞。解决这种问题要靠连续碰撞检测方案或者对这类物体做子步进把一个物理步进内再拆成两到四个小步逐个求解物体会更稳定。代价自然是成倍的物理耗时。所以正确姿势是根据物体的速度阈值动态启用连续检测或者只在子弹和角色高速运动时才开子步。3. 动画系统的写实流程从骨骼资产到蒙皮矩阵如果说物理系统是引擎里的“硬规则”动画系统就是“表现层的地基”。动画系统的整体架构一眼看上去只是在播片段往里拆会发现它包含了骨骼层次管理、关键帧采样、混合求值、状态机控制、蒙皮计算这些环环相扣的模块。这里面的工程问题一点不比物理少。3.1 骨骼层级与蒙皮权重数据的组织方式动画系统第一个绕不开的概念是骨骼层次。一个角色模型通常有几十到几百根骨骼从根骨骼出发形成一颗树。每个骨骼在绑定姿势下定义了一个本地空间变换再通过父子关系级联出世界空间矩阵。引擎里不会真的把世界矩阵每根都直接存下来更多是存局部变换按需做父子组合。蒙皮数据则直接挂在网格上每个顶点记录它被哪几根骨骼影响以及对应权重行业默认最常见的是4根骨骼影响一个顶点。这4个索引和4个权重需要打包得足够紧凑渲染时才能快速取用。如果你在引擎里做动画数据部的设计一定记住蒙皮权重数组和骨架索引数组应该和顶点缓冲放在一起别因为图形学接口不熟就另起一套否则GPU蒙皮时来回拿不到数据性能直接掉一个档次。这里我要提一个容易踩的坑骨骼系统中“绑定姿势逆矩阵”必须在导入时计算好并缓存。蒙皮算法要把顶点从绑定姿势变换到当前姿势每一步都要用到绑定姿势矩阵的逆。很多引擎实现图省事每帧临时重新计算这个逆矩阵结果就是角色扭动时顶点乱飞。其实这个矩阵从资源导入后就不会变离线算一次运行时只做乘法就行。3.2 动画采样、混合与求值时的性能节点动画片段本身是一组带时间戳的关键帧曲线每根骨骼上有位移、旋转、缩放三条通道缩放大多数情况下是恒定的频繁变化的还是旋转。运行时播放动画时系统要根据当前播放时间在附近两个关键帧间插值再把局部变换传给子骨骼。如果角色一场戏里同时有几十个动画每个动画上百根骨骼直接逐根逐帧插值是很可观的CPU开销。常见优化有几类先是一次性采样所有动画到临时缓冲再用四元数插值的方式做混合。注意混合可不是简单矩阵加权平均矩阵加权算出的结果会带缩放、不正交表现上会出现“骨头拉长”的诡异现象。正确做法是把旋转统一用四元数表示用nlerp或slerp做加权平均位移与缩放单独处理最后再合成每个骨骼的局部变换。还有一类优化与架构强相关动画求值是可以很好地并行化的因为它只依赖当前动画片段和播放时间骨骼之间在采样阶段相互独立。只有到“局部变换合成世界变换”那一步才需要按树做后序遍历。所以很多引擎把动画求值拆成两个阶段第一个阶段多线程并行采样和混合第二个阶段单线程或分Job合成骨骼矩阵。这两个阶段中间必须有一个明确的同步屏障如果合不清动画和物理就会各用各的姿势渲染出来的角色蒙皮与碰撞体就会错位。3.3 状态机、动画通知与运行时事件流动画片段在引擎里不是孤立的状态机负责把它们串起来。它本质上是一个运行时有限状态机状态是动画或混合空间转换条件是角色速度、代码参数、按键输入等。这个设计比较直观但架构上真正要重视的是动画通知系统。动画通知是挂在某个动画片段时间点上的事件比如“脚落地”“武器拔出”“跳跃离地”。它们由动画系统在采样时检测到然后发给游戏逻辑。这个通道非常容易出问题的地方是同步时机动画在固定步进里求值游戏逻辑在另一个更新环里跑如果通知发晚了半帧角色脚还没落地音效先响了就很出戏。所以通知不能直接抛给游戏逻辑完事最好先进入一个事件队列带精确帧信息由逻辑层在固定时间点统一消费。另外状态机的转换本身也有耗时。两条动画片段切换时如果直接换掉角色就会出现“瞬移”一样的僵硬连接。所以引擎会在状态层之上设计一个混合过渡层切换的前几十到几百毫秒里逐渐调整权重把两个姿势平滑融合。做引擎时这一层权重由状态机管理还是由独立混合器管理常常引发争论。我倾向把状态机单纯当作“决策层”把实际插值混合的计算放进一个和状态机解耦的混合系统里因为状态机的重点是逻辑混合是数学解耦后也好测试。3.4 CPU蒙皮与GPU蒙皮的边界划分蒙皮计算就是根据最终骨骼矩阵把顶点从绑定姿势变换到当前姿势。这个操作既可以放在CPU上做也可以放在GPU上做。GPU蒙皮是主流因为它可以把几万顶点的变换交给显卡并行处理每帧只需把骨骼矩阵数组上传到GPU常量缓冲区在顶点着色器里执行线性蒙皮或者双四元数蒙皮非常高效。但GPU蒙皮有个明显限制变换的结果直接进入渲染管线CPU拿不到最终的顶点位置。对于一些需要在CPU侧做高精度碰撞的业务比如布料解算需要读取蒙皮后的表面点、程序化IK需要找脚底世界坐标纯GPU蒙皮就会很别扭。于是引擎里常见的是两种蒙皮并存普通渲染走GPU蒙皮对少数关键角色或需要被物理读取的部位额外做一份CPU蒙皮写入共享缓冲。这个“双蒙皮”会带来额外的CPU开销所以只应该用于那些真正需要物理交互的局部网格比如头发的碰撞体、衣料的上身附着点。千万不能全角色都用CPU蒙皮角色一多CPU整体被拖垮。4. 物理与动画的协作边界角色控制器、布娃娃与程序化动画把两套系统分开拆完又回到最开始的问题它们究竟怎么协作。这一部分我讲几个最常见也最容易架构失衡的场景。4.1 角色控制器为什么往往是一个独立模块而不是刚体你可以直接把角色做成一个动态刚体加个力让它走路但手感大概率会变成“左脚踩右脚”。动态刚体有惯性、有速度累积动画和操控输入加进去会像隔着一层水。因此主流引擎都提供角色控制器Unity里有CharacterControllerUnreal里是CharacterMovementComponent。它的定位是“半物理半动画”的中间层跟物理世界有碰撞但不参与受力求解有速度概念但这速度主要由外部输入和动画根运动决定。角色控制器内部通常会封装几个核心能力胶囊体碰撞、地面检测、坡度限制、步长提升。这些能力是一套面向游戏的简化物理逻辑而不是完整刚体模拟。比如“步长提升”就是角色走到比膝盖矮一点的路沿时控制器可以自动把人抬上去这在物理世界里是不可能的但对游戏手感至关重要。如果这里用传统物理一截十几厘米高的台阶就能把角色卡住玩起来像在跨一堵墙。4.2 布娃娃与死亡/受击的过渡编排布娃娃系统把动画骨骼和物理刚体绑定在一起角色死亡后物理接管全身制造出“尸体被甩出去”的效果。实现方式一般是给每段关键骨骼创建一个刚体用关节把它们连起来再根据当前动画姿势初始化骨骼位置、旋转和速度。这样角色从播放死亡动画过渡到物理布娃娃时不会出现整个人瞬间飞到半空再掉落。布娃娃和普通动画之间更需要的是合适的过渡权重。常见做法是先让布娃娃完全接管再用几百毫秒时间把物理效果按权重叠加回原创动画状态用于受击反馈。这里我给你一个很直接的工程建议不要把布娃娃骨骼完全交给物理就撒手不管最好在每根骨骼上保留一层“混合遮罩”让动画师指定脖子、手臂等部位在受击后仍然保留多少表演动作。否则物理打击到头部时整个颈椎会像面条一样乱甩看起来又假又吓人。触发布娃娃的时机也很有讲究。触发条件通常不是直接检测血量归零而是要检测“最后播放的死亡动画是否已经播放到可脱离点”。如果一触发布娃娃就清零角色明明被刀砍的风险了身体却还保持着跑步姿态观众会觉得画面断片。所以我在引擎里会为每个角色维护一个布娃娃初始化状态它的数据来源是动画系统最后一帧的骨骼矩阵而不是物理系统当前值确保交接无缝。4.3 布料与动力学骨骼挂在动画管线末端的轻量物理布料本质上也和物理与动画都有关系。布料顶点依赖骨骼动画给出的世界坐标同时又受重力、风力和碰撞体影响。引擎里有两种架构思路一种是布料模拟作为物理引擎的一个子系统发力和碰撞全部走物理统一管线另一种是把布料做成动画系统末端的一个后处理模块用简化约束模拟。我做了几个项目后发现游戏里大部分布料需求并不需要高精度物理比如裙子、披风、头发动画师关心的只是它“跟着动得顺不顺”。这种场景用动力学骨骼更合适骨骼跟随动画旋转而是由惯性、阻尼和重力驱动再用两三个约束点把它拉回理想位置。它不像物理引擎那样需要层叠约束求解算得很快。走到物理布料这一步往往是穿在角色身上又要和周围环境强碰撞的大面积裙摆或披风这种才值得挂到物理引擎里。架构上比较好的方式是做一个垂直的布料管线动画先算骨骼再把骨骼变换传给布料布料模拟完出自己的偏移量最后把它叠加到蒙皮矩阵上。不要反过来不要让布料去驱动骨骼否则动画骨骼和布料会争夺控制权产生抖动。5. 工程落地时决定成败的细节确定性、缓存与调试最后这部分不吹算法也不讲功能专门说几个真正影响项目交付的工程细节。它们不决定功能的“有没有”但决定系统的“稳不稳”。5.1 物理确定性在多线程与网络下的现实挑战物理引擎的求解结果对浮点运算顺序特别敏感。同一套输入单线程领域内几十次迭代性能非常稳定一旦把刚体分散到多线程并行每个线程的求解顺序不同浮点累加可能产生微小差异这些差异经过几十帧累加会变成可见的偏差。简单说就是物理在单线程下可以确定性播放多线程下不一定。这对单机游戏没什么感觉但一旦你要做网络同步或者竞技回放就会很麻烦。服务器采取锁步同步时要求所有客户端在完全相同输入下得到完全相同物理结果否则每个客户端看到的位置各跑各的。这时要么你把物理严格限制在单线程执行并且所有浮点运算在编译时关闭快速数学优化要么你就放弃确定性改用服务器授权客户端只接受服务器下发的位置快照。前者适合对战帧同步游戏后者适合中大型多人在线场景。5.2 数据布局与缓存物理和动画面向数据的差异现代游戏引擎对缓存友好性的要求越来越高。物理系统里刚体位置和速度要每帧被读和写这组数据会被CPU反复访问所以它应该放在紧凑而连续的内存块里读取时尽量连续命中。我在引擎中更倾向于用结构体数组存储物理刚体因为物理算法往往是遍历所有刚体做同一种操作结构体数组能最大化利用缓存预取。动画的骨骼数据则不完全一样动画采样时每个骨骼只读自己的关键帧而蒙皮计算时又需要频繁读顶点权重这两处热点不一样最好把“骨骼变换数组”和“顶点蒙皮权重”分开存储避免拉到无用缓存行。CPU蒙皮仍然存在的引擎一定要关心蒙皮后的结果写入位置。最怕碰到的是每次蒙皮完立刻拷给GPU并在一堆系统间转手频繁拷贝对性能的打击往往被低估。正确做法是保留一块固定缓冲区结果直接在这里生成GPU需要通过共享内存直接访问而不是再传一份CPU数据。5.3 可视化调试、回放与同步问题的复现物理和动画的bug大多是“时序”问题口头很难描述截图也看不清。所以我强烈建议在引擎里做一套可视化调试层能在一个视口里同时画出物理碰撞体、骨骼层级骨架、动画当前状态、以及触发的通知点。运行时你把这些叠加画出来立刻能看到动画骨骼和物理碰撞体是否贴合。脚底不一致、滑步、碰撞体穿插就能一眼定位。比可视化更进一步的是确定性回放。如果你有物理确定性就可以记录玩家输入序列并在回放时以同样输入重新驱动物理与动画。这样可以彻底复现“到底在哪一帧动画和物理的偏差被拉大”的过程。没有确定性的时候回放只能靠录屏排查问题会很被动。我之前在自研引擎里加了一个轻量级输入录制器把每帧输入、时间戳、物理随机种子都记录下来物理相关的诡异bug基本从“玄学”变成了“半天的活儿”。再补充一个特别容易踩的同步问题动画在Update里求值物理在FixedUpdate里步进两者频率不同如果动画逻辑里读取了物理体位置然后立即把位移写回物理那么会造成隐形的位置累加偏差。举个例子角色在FixedUpdate里被物理施加了一个向外推的力随后动画又根据这个位置计算根运动每帧都把多出来的一小段位移再写回到物理体一秒钟之后角色就莫名平移出去一大截。解决这种问题的方法只有一个明确物理体位置的“权威”动画只能从物理读取结果不能在读完后再反写自己的根运动否则就是闭环自激。如果动画确实需要驱动位移那就在角色控制器层级处理把动画根运动的意图当成目标速度再让物理控制器转化为最终位移而不是直接修改物理体的Transform。最后说点实在的做引擎架构时间越长我越觉得物理和动画之间其实不存在一个“完美接口”只存在一个“提前定好的规矩”。是动画驱动物理多一点还是物理约束动画多一点必须在项目启动时定好画成数据流图贴墙上否则等到玩法调试期再去调改一发动全身团队的沟通成本会让你怀疑人生。如果你正在自研引擎我建议第一步不要急着接全套物理或做复杂动画混合先用一套极简的角色控制器加一套可切换的动画求值器跑通“动画算姿态、物理做约束、最终合成”的最小闭环再逐步替换内部实现。小步快走比上来就堆大功能要稳得多。