1. 物理与动画系统在游戏引擎中的定位1.1 为什么这两个系统总是被放在一起讲但凡做过几年游戏开发的人都会发现一个现象面试的时候问完渲染管线下一个问题大概率就是物理和动画。这不是巧合。物理系统和动画系统在架构层面有一个非常本质的共性——它们都是时间驱动的仿真系统都需要在每一帧里根据时间步长推进状态都要处理插值、外推、状态同步这些破事。更关键的是这两个系统在运行时是深度耦合的角色的碰撞体跟着动画骨骼走布娃娃系统在动画和物理之间来回切换载具的悬挂既受物理约束又受动画状态机控制。我见过不少项目把物理和动画拆成两个完全独立的模块各写各的结果到了集成阶段发现动画驱动的根骨骼位移和物理胶囊体的位置对不上角色在斜坡上滑步布娃娃切换时出现瞬间弹飞。这些问题的根源都在架构设计阶段就埋下了。所以这一篇我打算把物理和动画放在一起拆重点讲清楚三件事物理系统的时间步进和约束求解怎么设计才稳动画系统的骨骼计算和状态机怎么组织才不乱以及这两个系统在架构层面如何优雅地交换数据。1.2 本文适合什么样的读者如果你正在从零搭建一个小型引擎或者想给现有项目加一套轻量级的物理动画方案这篇内容应该能帮你省下不少试错时间。如果你只是用现成引擎做游戏了解这些底层机制也能帮你在遇到滑步、抖动、穿模这些问题时知道该往哪个方向调参。我不会贴大段大段的数学公式但关键的推导逻辑会讲清楚。代码示例用伪代码和 C 风格为主因为大部分引擎的底层还是 C 的天下。好我们直接进入正题。2. 物理系统的架构拆解与核心模块2.1 物理系统的三大核心职责一个游戏物理引擎不管规模大小本质上就干三件事碰撞检测、约束求解、状态积分。听起来简单但每一件事往下挖都是无底洞。碰撞检测负责找出场景里哪些物体可能相交输出接触点、法线、穿透深度这些信息。约束求解根据这些接触信息和其他约束比如关节、马达计算出应该施加多大的冲量来消除穿透和满足约束。状态积分则把求解出的速度、冲量应用到物体的位置和旋转上推进到下一帧。这三件事的执行顺序不能乱。一定是先检测、再求解、最后积分。我见过有人把积分放在检测前面结果就是物体永远在“追”碰撞穿透问题怎么调都调不好。2.2 宽阶段与窄阶段的职责划分碰撞检测拆成宽阶段和窄阶段这个大家都知道。但具体怎么拆拆到什么粒度这里面有讲究。宽阶段用 AABB 树或者空间哈希把可能相交的物体对筛出来。这一步的目标是快宁可多筛一些候选对也不能漏掉任何一个可能相交的物体。我一般用动态 AABB 树因为游戏场景里物体是动态增删的动态树的插入删除效率比静态树好很多。窄阶段对候选对做精确的几何相交测试。这一步的目标是准要输出精确的接触流形。球体对球体最简单直接算球心距离。凸包对凸包就复杂了GJK 算法加 EPA 算法是标配。我实测下来GJK 的收敛速度在大多数场景下够用但如果物体形状特别扁或者特别长迭代次数会飙升这时候可以考虑用 SAT 做补充。注意窄阶段输出的接触流形最好缓存起来下一帧优先用缓存的接触点做 warm start这样约束求解的收敛速度会快很多。这个优化在堆叠场景里效果特别明显我试过在一个 200 个箱子的堆叠场景里warm start 能把求解迭代次数从 20 次降到 8 次左右。2.3 约束求解器的选型与参数调校约束求解是物理引擎里最玄学的部分。目前主流方案是序列冲量求解器和投影高斯赛德尔求解器两者本质上是同一类方法的不同表述。序列冲量求解器的思路是逐个遍历约束每次只求解一个约束把冲量累加到物体速度上然后立刻用更新后的速度去求解下一个约束。迭代多轮之后所有约束会逐渐收敛到满足状态。这里有几个关键参数需要调迭代次数默认 8 到 10 次。堆叠场景可以加到 15 到 20 次。但注意迭代次数不是越多越好超过一定次数后收敛曲线就平了白白浪费 CPU。松弛因子控制每次冲量施加的“激进程度”。设得太高会震荡设得太低收敛慢。我一般从 0.8 开始试根据场景调整。穿透容差允许物体之间保留多少穿透量。设太小会导致抖动设太大会看到明显的穿模。通常取 0.01 到 0.05 个世界单位。下面是一个简化的序列冲量求解器伪代码展示了核心循环的结构void solveConstraints(std::vectorConstraint constraints, int iterations) { for (int iter 0; iter iterations; iter) { for (auto c : constraints) { // 计算当前约束违反程度 float violation c.computeViolation(); // 计算需要的冲量 float impulse c.computeImpulse(violation); // 施加冲量到两个刚体 c.bodyA.applyImpulse(-impulse * c.normal); c.bodyB.applyImpulse(impulse * c.normal); } } }这段代码看起来简单但computeImpulse里面的数学才是真正的难点。它需要考虑两个物体的质量、惯性张量、接触点的位置以及约束的方向。如果两个物体质量差很大比如一个大铁球撞一个小乒乓球冲量计算不好就会导致小物体被弹飞。2.4 时间步进策略固定步长还是可变步长这是物理系统架构里最容易被忽视但影响最大的决策之一。固定步长的意思是物理系统以恒定的时间间隔推进比如每 1/60 秒一步。不管渲染帧率是多少物理步长不变。可变步长则是根据渲染帧的实际耗时来推进物理。我的经验是物理系统必须用固定步长。原因很简单可变步长会让物理行为依赖于帧率同样的场景在 30 帧和 60 帧下表现不一致这在联机游戏里是灾难性的。而且可变步长下约束求解器的收敛性会变差容易出现抖动。固定步长的实现方式通常是累加器模式float accumulator 0.0f; const float fixedDeltaTime 1.0f / 60.0f; void update(float frameDeltaTime) { accumulator frameDeltaTime; while (accumulator fixedDeltaTime) { physicsStep(fixedDeltaTime); accumulator - fixedDeltaTime; } // 可选用剩余时间做插值渲染 float alpha accumulator / fixedDeltaTime; interpolateRenderState(alpha); }这个模式的好处是物理行为完全确定同样的输入序列永远产生同样的输出。坏处是如果某一帧特别卡累加器会积累很多步导致下一帧要跑多次物理步进可能引发“死亡螺旋”。所以通常要加一个最大步数限制比如单帧最多跑 5 步物理超出的部分直接丢弃。实操心得固定步长的值不要随便改。一旦确定了 1/60 秒所有调好的物理参数都是基于这个步长的。如果改成 1/30 秒重力、弹性、摩擦系数全都要重新调。我一般会在项目初期就把步长定死后面不再动。3. 动画系统的架构设计与骨骼计算3.1 骨骼动画的数据组织方式动画系统的核心数据是骨骼层级和关键帧曲线。骨骼层级是一棵树根骨骼在原点子骨骼相对父骨骼有偏移和旋转。关键帧曲线则记录了每个骨骼在不同时间点上的变换值。数据组织方式直接决定了动画系统的性能上限。我见过两种主流方案第一种是扁平化数组。把所有骨骼的变换矩阵按顺序存在一个数组里父骨骼的索引小于子骨骼。更新的时候从前往后遍历每个骨骼的世界变换等于父骨骼的世界变换乘以本地变换。这种方案的缓存友好性极好遍历速度快。第二种是树形结构。每个骨骼节点存指针指向子节点更新的时候递归遍历。这种方案逻辑清晰但缓存命中率差而且递归调用有开销。我推荐第一种。具体做法是在加载动画数据的时候对骨骼做一次拓扑排序保证父骨骼永远在子骨骼前面。然后每帧更新的时候只需要一次线性遍历就能算出所有骨骼的世界变换。struct Bone { Transform localTransform; // 本地变换 Transform worldTransform; // 世界变换 int parentIndex; // 父骨骼索引根骨骼为 -1 }; void updateSkeleton(std::vectorBone bones) { for (int i 0; i bones.size(); i) { if (bones[i].parentIndex -1) { bones[i].worldTransform bones[i].localTransform; } else { bones[i].worldTransform bones[bones[i].parentIndex].worldTransform * bones[i].localTransform; } } }这段代码的关键在于bones数组已经按拓扑序排列所以不需要递归一次遍历就搞定。3.2 动画混合与状态机的架构单个动画播起来简单但游戏里的角色不可能只播一个动画。跑、走、跳、攻击、受击这些动画之间要平滑过渡就需要动画混合和状态机。动画混合的本质是在两个或多个动画的骨骼变换之间做插值。最简单的线性混合就是按权重加权平均Transform blend(const Transform a, const Transform b, float t) { Transform result; result.position lerp(a.position, b.position, t); result.rotation slerp(a.rotation, b.rotation, t); result.scale lerp(a.scale, b.scale, t); return result; }注意旋转要用球面线性插值不能用线性插值否则在旋转角度大的时候会出现明显的变形。状态机负责管理当前应该播哪些动画、权重是多少、什么时候切换。一个典型的状态机包含状态每个状态对应一个动画或一组混合动画。转移定义从一个状态到另一个状态的条件比如“速度大于阈值”或“收到攻击事件”。转移时长切换时的混合时间通常 0.1 到 0.3 秒。状态机的架构我倾向于用数据驱动的方式。把状态和转移定义在配置文件里运行时解析成状态机对象。这样策划改动画逻辑不需要重新编译代码效率高很多。3.3 动画事件与根骨骼位移的处理动画事件是指在动画播放到特定时间点时触发回调比如脚步声、攻击判定、粒子特效。实现方式通常是在动画数据里标记事件时间点每帧检查当前播放时间是否越过了事件点。根骨骼位移是另一个容易踩坑的地方。很多动画比如跑步、翻滚的根骨骼会带着角色移动但游戏逻辑又需要控制角色的实际位置。如果处理不好就会出现角色滑步或者位移翻倍。我的做法是把根骨骼位移提取出来交给游戏逻辑层处理。动画系统只负责播放原地动画根骨骼的位移曲线被采样后传给角色控制器由控制器决定是否应用这个位移。这样动画和逻辑解耦滑步问题就好调了。注意根骨骼位移的提取要在动画导入阶段就做好不要等到运行时再算。导入的时候把根骨骼的位移曲线单独存一份同时把动画里的根骨骼位移清零这样运行时直接读提取好的数据就行。4. 物理与动画的耦合架构4.1 角色控制器的物理代理设计角色在游戏世界里需要一个物理代理来参与碰撞检测。这个代理通常是一个胶囊体因为胶囊体在斜坡和台阶上的表现比盒子好。代理的更新流程是这样的动画系统算出骨骼的世界变换后从骨骼里提取出根骨骼的位置和朝向同步给物理代理。物理代理做碰撞检测和约束求解后把修正后的位置写回角色控制器角色控制器再决定是否更新动画系统的根骨骼位置。这个流程里有一个关键决策物理代理是驱动动画还是动画驱动物理代理。两种模式各有适用场景动画驱动物理适用于大多数角色动画。动画播放根骨骼移动物理代理跟随。物理只负责碰撞修正不主动移动角色。物理驱动物画适用于布娃娃、载具、被击飞等场景。物理模拟决定位置动画系统根据物理状态做匹配。我一般会在角色控制器里维护一个状态标志根据当前状态决定用哪种模式。切换的时候要做平滑过渡否则会出现瞬间跳变。4.2 布娃娃系统的切换与混合布娃娃系统是物理和动画耦合最紧密的场景。角色死亡或者被击飞时从动画驱动切换到物理驱动身体各部位变成物理刚体受重力和冲量影响。切换的瞬间是最容易出问题的。如果直接把骨骼位置赋给刚体由于动画骨骼和物理刚体的形状不完全一致会出现明显的弹跳。我的做法是切换时先做一次位置和速度的匹配把动画骨骼的速度估算出来赋给刚体这样切换会平滑很多。void switchToRagdoll(Skeleton skeleton, Ragdoll ragdoll) { for (int i 0; i ragdoll.bodies.size(); i) { // 把骨骼的世界变换赋给刚体 ragdoll.bodies[i].setTransform(skeleton.bones[i].worldTransform); // 估算骨骼速度并赋给刚体 Vector3 velocity (skeleton.bones[i].worldTransform.position - skeleton.bones[i].prevWorldPosition) / deltaTime; ragdoll.bodies[i].setLinearVelocity(velocity); } }从布娃娃切回动画的时候反过来做把刚体的位置映射回骨骼然后从当前动画的对应姿势开始混合混合时间通常 0.3 到 0.5 秒。4.3 物理约束与动画IK的协同IK反向动力学是动画系统里用来让角色手脚贴合环境的工具。比如角色站在斜坡上脚要贴合斜坡表面就需要 IK 来调整腿部骨骼。IK 和物理的协同点在于IK 的目标位置往往来自物理射线检测。从角色髋部向下打射线检测到地面后把射线命中点作为脚部 IK 的目标。这样角色在凹凸不平的地面上也能站稳。但 IK 和物理约束有时候会打架。比如角色用手推一个物理箱子IK 让手保持在箱子表面但物理又在推箱子移动手的目标位置每帧都在变。这时候需要给 IK 加一个最大调整速度让 IK 平滑地跟随物理目标而不是瞬间跳过去。实操心得IK 的迭代次数不要设太高2 到 3 次就够了。设太高会导致骨骼扭曲看起来很不自然。而且 IK 最好只在需要的时候开启比如脚部 IK 只在角色移动时开启站立不动时可以关掉省性能。5. 性能优化与常见问题排查5.1 物理系统的性能瓶颈与优化手段物理系统的性能瓶颈通常出现在两个地方碰撞检测的窄阶段和约束求解的迭代。窄阶段的优化手段包括碰撞形状简化用凸包代替三角网格用胶囊体代替复杂角色模型。我见过一个项目用完整的角色模型做碰撞一个角色几千个三角形物理开销直接爆炸。换成胶囊体后性能提升了几十倍。碰撞过滤用层和掩码控制哪些物体之间需要检测。比如子弹和子弹之间不需要碰撞装饰物和装饰物之间不需要碰撞。休眠机制长时间不动的物体进入休眠状态跳过碰撞检测和约束求解。唤醒条件是受到冲量或者邻近物体移动。约束求解的优化手段包括warm start用上一帧的冲量作为初始值减少迭代次数。约束分组把互不相关的约束分到不同组里可以并行求解。早期退出如果所有约束的违反程度都小于阈值提前结束迭代。下面是一个性能对比表格展示了不同优化手段的效果优化手段测试场景优化前帧耗时优化后帧耗时提升幅度碰撞形状简化100 角色混战12ms3ms4 倍休眠机制200 箱子堆叠8ms2ms4 倍warm start200 箱子堆叠8ms5ms1.6 倍约束分组并行500 刚体场景15ms6ms2.5 倍5.2 动画系统的性能优化动画系统的性能瓶颈主要在骨骼数量和混合层数。骨骼数量的优化手游通常控制在 30 到 60 根骨骼端游可以到 100 到 200 根。超过这个数量骨骼矩阵的计算和蒙皮的开销就会很明显。优化手段包括骨骼LOD远处的角色用简化骨骼近处的用完整骨骼。蒙皮缓存如果骨骼变换没变跳过蒙皮计算。GPU 蒙皮把骨骼矩阵传到 GPU在顶点着色器里做蒙皮。这个方案能大幅减少 CPU 开销但会增加 GPU 负担。混合层数的优化每多一层混合就要多采样一次动画曲线多算一次插值。我一般把混合层数控制在 3 层以内基础层、上半身层、表情层。超过 3 层就要考虑合并或者用更高效的混合方案。5.3 常见问题速查表问题现象可能原因排查方向解决方案角色滑步动画速度与移动速度不匹配对比动画根骨骼位移和实际移动距离调整动画播放速率或移动速度物理抖动约束求解迭代不足或穿透容差太小增加迭代次数调大穿透容差迭代加到 15 次容差设 0.02布娃娃切换弹飞切换时速度未匹配检查切换瞬间的速度赋值估算骨骼速度并赋给刚体动画切换跳变混合时间太短或姿势差异太大检查混合时长和源目标姿势加长混合时间加中间过渡姿势碰撞穿透物体速度过快或步长太大检查物体速度和物理步长减小步长开启连续碰撞检测IK 骨骼扭曲IK 迭代次数过高或目标不可达检查 IK 迭代次数和目标位置降低迭代次数加目标可达性检查5.4 调试工具与可视化物理和动画的调试光看日志是不够的必须要有可视化工具。物理调试通常需要画碰撞形状的线框、接触点和法线、约束的连接线、物体的速度和角速度。我一般会在引擎里做一个调试绘制层用不同颜色区分不同类型的物理对象。动画调试需要画骨骼的层级连线、IK 的目标位置、动画事件的触发点、混合权重。骨骼连线用不同颜色表示不同层级的骨骼IK 目标用小球表示动画事件用竖线标在时间轴上。这些调试绘制在发布版本里要能一键关闭否则会影响性能。我通常用编译宏控制Debug 版本开启Release 版本关闭。注意调试绘制本身也会消耗性能尤其是骨骼数量多的时候。如果发现开启调试绘制后帧率明显下降可以只绘制当前选中角色的骨骼而不是全部角色。6. 从零搭建一个最小可用的物理动画系统6.1 整体架构与模块划分如果你要自己搭一套最小可用的物理动画系统我建议按下面的模块划分数学库向量、矩阵、四元数、变换。这是基础必须自己写或者找一个轻量的库。物理模块刚体、碰撞形状、碰撞检测、约束求解、世界管理。动画模块骨骼、动画剪辑、采样器、混合器、状态机。耦合层角色控制器、布娃娃切换、IK 求解。调试模块物理绘制、动画绘制、性能统计。模块之间的依赖关系要清晰数学库被所有模块依赖物理和动画互不依赖耦合层同时依赖物理和动画调试模块依赖所有模块。6.2 关键接口设计物理世界的核心接口class PhysicsWorld { public: void step(float deltaTime); RigidBody* createBody(const BodyDesc desc); void destroyBody(RigidBody* body); void addConstraint(Constraint* constraint); RaycastResult raycast(const Vector3 origin, const Vector3 direction, float maxDist); };动画系统的核心接口class AnimationSystem { public: void update(float deltaTime); void play(const std::string clipName, float blendTime); void setFloat(const std::string paramName, float value); void setBool(const std::string paramName, bool value); const Skeleton getSkeleton() const; };角色控制器的核心接口class CharacterController { public: void update(float deltaTime); void move(const Vector3 direction, float speed); void jump(); void switchToRagdoll(); void switchToAnimation(); };这些接口的设计原则是物理和动画不直接互相调用所有交互都通过角色控制器这个中间层。这样两个系统可以独立测试和替换。6.3 集成步骤与测试方法集成的时候按下面的顺序来先单独测试物理模块。创建一个地面和几个箱子看箱子能不能正确下落和堆叠。再单独测试动画模块。加载一个角色模型播放一个动画看骨骼变换是否正确。然后把物理代理加到角色上测试角色能不能在地面上站立和移动。接着测试动画和物理的同步看角色移动时动画是否匹配。最后测试布娃娃切换和 IK看边界情况是否正常。每一步都要有明确的测试标准。比如第一步的标准是箱子落地后不抖动堆叠三层不倒塌。第二步的标准是动画播放流畅骨骼无扭曲。第三步的标准是角色移动时胶囊体不穿墙上下斜坡正常。测试的时候要特别注意边界情况极快速度、极大质量比、极端角度、零质量物体。这些情况在正常游戏里很少见但一旦出现就是崩溃级别的 bug。6.4 后续扩展方向最小系统跑通之后可以按需扩展连续碰撞检测解决高速物体穿透问题。布料模拟用质点弹簧模型做披风、旗帜。破坏系统把刚体碎裂成多个小刚体。动画压缩减少动画数据的内存占用。网络同步物理和动画状态的网络复制。每个扩展方向都可以独立做不影响核心架构。这也是模块化设计的好处——加新功能不用动老代码。我个人在实际操作中的体会是物理和动画系统的架构设计最难的不是写代码而是定义清楚模块之间的边界。边界定义好了每个模块内部怎么实现都可以灵活调整。边界定义不好后面每加一个功能都要改好几个模块维护成本会指数级上升。所以如果你正在设计这套系统建议先在纸上把模块划分和接口定义画清楚再动手写代码。这个前期投入绝对值得。
阅读完成 · 觉得有帮助?