
物理系统和动画系统是游戏引擎里最容易被低估的两个模块。很多人做引擎学习时把大量精力花在渲染管线上觉得画面好看就行结果一旦角色开始移动、碰撞、播放动作各种穿模、抖动、滑步、卡顿就全冒出来了。我在实际项目里踩过的最典型的坑就是角色明明站在地面上却一直往下掉排查了半天才发现是物理更新频率和渲染帧率没有解耦。这篇文章就围绕游戏引擎中物理与动画系统的架构设计展开把这两个系统各自的核心职责、它们之间的协作方式、以及实际落地时最容易出问题的地方讲清楚。不管你是刚接触引擎开发的新手还是已经写过简单物理循环想进一步理解工业级设计的开发者都能从中拿到可以直接参考的思路和代码结构。1. 物理系统在引擎架构中的定位与核心职责1.1 物理系统到底解决什么问题先想清楚一件事物理系统不是用来让画面动起来的那是动画系统的活。物理系统的核心职责是用数值计算的方式决定物体在受力之后应该出现在哪里、以什么姿态出现。它输出的是一组变换数据位置、旋转渲染器拿到这些数据之后负责画出来。这个边界如果一开始不划清楚后面架构会非常混乱。我见过有项目把碰撞检测的结果直接写进渲染节点的变换里结果物理线程和渲染线程互相踩数据画面撕裂得没法看。正确的做法是物理系统维护自己的一套内部状态刚体、碰撞体、约束每个物理步结束后把计算结果同步到一个中间缓冲区渲染线程从缓冲区读取两边不直接共享可变状态。物理系统通常包含以下几个子系统碰撞检测Collision Detection判断两个物体是否相交输出接触点、接触法线、穿透深度。约束求解Constraint Solver处理关节、接触、摩擦等约束计算出应该施加的冲量。积分器Integrator根据速度和受力更新物体的位置和旋转。场景查询Scene Query射线检测、形状扫描、重叠查询供游戏逻辑调用。这四个子系统里碰撞检测和约束求解是性能大头也是架构设计中最需要花心思的地方。1.2 固定步长与可变步长的取舍物理更新用固定步长还是可变步长这是每个引擎都要面对的第一个架构决策。我直接说结论生产级引擎几乎都用固定步长原因很简单——可变步长下同样的场景在不同帧率下会得到不同的物理结果这对游戏逻辑是灾难性的。固定步长的典型实现是这样的const float FIXED_DT 1.0f / 60.0f; float accumulator 0.0f; void Update(float frameDelta) { accumulator frameDelta; while (accumulator FIXED_DT) { PhysicsStep(FIXED_DT); accumulator - FIXED_DT; } float alpha accumulator / FIXED_DT; InterpolateRenderState(alpha); }这里有几个关键点值得展开说。accumulator累积的是真实流逝的时间每次物理步消耗一个固定的FIXED_DT。如果某一帧特别长比如加载资源导致卡了 200ms这个 while 循环会连续跑多次物理步保证物理时间不落后于真实时间。但这里有个隐患如果卡顿特别严重while 循环可能跑几十次反而让卡顿更严重。所以工业级实现通常会加一个最大步数限制const int MAX_STEPS 5; int steps 0; while (accumulator FIXED_DT steps MAX_STEPS) { PhysicsStep(FIXED_DT); accumulator - FIXED_DT; steps; } if (steps MAX_STEPS) { accumulator 0.0f; // 丢弃积压的时间避免死亡螺旋 }InterpolateRenderState(alpha)这一步很多人会忽略但它是消除画面抖动的重要手段。因为物理步和渲染帧不是一一对应的渲染时物体的位置应该是在两个物理状态之间插值得到的。不做插值的话在物理频率和渲染频率不匹配时画面会出现明显的周期性抖动。注意固定步长的值不要随便设。60Hz 是常见选择但如果你的游戏有高速物体比如子弹60Hz 下子弹一帧可能移动好几米直接穿过薄墙。这种情况要么提高物理频率要么开启连续碰撞检测CCD。1.3 物理世界的分层与过滤一个稍微复杂点的游戏场景里物体种类很多地形、角色、子弹、触发器、装饰物。如果所有物体之间都做碰撞检测性能会爆炸而且逻辑上也不对——子弹不应该和装饰物碰撞触发器不应该把角色弹开。物理引擎通常提供两种过滤机制过滤方式原理适用场景碰撞层Layer用位掩码标记物体属于哪些层、与哪些层碰撞静态分类配置简单碰撞组Group用整数索引标记分组同组内可设置碰撞规则动态分组灵活度高我个人的经验是大部分项目用碰撞层就够了。给每个物体分配一个categoryBits和一个maskBits碰撞检测前先做位运算bool ShouldCollide(const Body a, const Body b) { return (a.categoryBits b.maskBits) ! 0 (b.categoryBits a.maskBits) ! 0; }这个判断成本极低能在宽阶段Broad Phase之前就过滤掉大量无用的碰撞对。我做过一个测试在一个有 2000 个物体的场景里合理配置碰撞层之后宽阶段的碰撞对数量从 20 万降到了 3 万左右性能提升非常明显。2. 碰撞检测的宽阶段与窄阶段拆解2.1 宽阶段先用便宜的方法排除大多数宽阶段的目标只有一个快速排除明显不可能碰撞的物体对。它不需要精确只需要快。常见的宽阶段算法有这几种暴力遍历O(n²) 两两检测。物体少于 100 个时其实够用实现最简单。空间哈希Spatial Hashing把空间划分成网格物体按所在格子归类只检测同格子及相邻格子的物体。动态 AABB 树把物体的包围盒组织成树结构查询时快速剪枝。Bullet、PhysX 都用这个。扫描排序Sweep and Prune沿某个轴排序利用时间相干性减少检测量。选哪种取决于你的场景特点。物体分布均匀、大小相近空间哈希很好用物体大小差异大、分布稀疏动态 AABB 树更稳。我建议新手先从暴力遍历开始把窄阶段跑通等性能真的成为瓶颈再换。动态 AABB 树的核心操作是插入、删除和查询。插入时把物体的 AABB 插入树中删除时移除对应节点查询时用 AABB 去遍历树快速找到可能相交的节点。这里有个容易踩的坑物体移动后必须更新它在树中的位置。如果忘了更新会出现物体明明撞上了却检测不到的诡异现象。我当初就因为这个 bug 排查了一整个下午。2.2 窄阶段精确计算接触信息宽阶段输出的是可能碰撞的物体对窄阶段负责精确判断并生成接触信息。窄阶段的核心是GJK EPA这套组合拳或者针对特定形状对的专用算法。GJKGilbert-Johnson-Keerthi算法用来判断两个凸体是否相交它的核心思想是在两个物体的闵可夫斯基差空间里判断原点是否在差集内部。如果原点在内部说明两个物体相交。EPAExpanding Polytope Algorithm则在 GJK 判定相交后进一步计算出穿透深度和接触法线。这套算法听起来很数学但实际实现时有几个工程上的关键点支撑函数Support Function给定一个方向返回物体在该方向上最远的点。凸体只需要这一个函数就能参与 GJK 计算这是它优雅的地方。单纯形SimplexGJK 迭代过程中维护的点集2D 下是三角形3D 下是四面体。终止条件迭代到单纯形包含原点或者超过最大迭代次数。最大迭代次数一定要设否则数值退化时可能死循环。对于球体、胶囊体、平面这些常见形状用解析法直接算接触信息比 GJK 更快也更稳。比如球-球碰撞接触法线就是两球心连线方向穿透深度就是半径之和减去球心距离几行代码就搞定。所以工业级引擎通常是混合策略常见形状对用解析法通用凸体用 GJK/EPA。2.3 接触点的缓存与持久化这是很多人会忽略但极其重要的一环。物理求解器需要接触点在多帧之间保持稳定否则会出现物体抖动、弹跳异常的问题。做法是维护一个接触点缓存每个物理步开始时先用上一帧的接触信息去预热求解器。如果两个物体上一帧有接触这一帧大概率还有直接复用之前的接触点求解器收敛会快很多结果也更稳定。struct ContactCache { BodyPair pair; std::vectorContactPoint points; int lifetime; }; // 每帧更新 void UpdateContacts() { for (auto cached : cache) { if (StillColliding(cached.pair)) { cached.lifetime; // 复用接触点只更新穿透深度 } else { cached.lifetime 0; } } }接触点缓存做得好不好直接决定了堆叠的箱子稳不稳、角色站在斜坡上会不会慢慢滑下去。我调过一个堆叠场景没做缓存时箱子堆到五层就开始抖加了缓存之后堆到二十层都很稳。3. 约束求解器的架构与迭代策略3.1 约束求解的本质解一个线性互补问题约束求解器要解决的问题可以这样描述给定一组约束接触、关节、摩擦求一组冲量使得施加冲量后所有约束都被满足。数学上这是一个线性互补问题LCP精确求解成本很高所以实际引擎都用迭代法近似求解。最常用的迭代法是投影高斯-赛德尔Projected Gauss-Seidel, PGS。它的思路很直观逐个处理约束每次只调整当前约束对应的冲量让它尽量满足然后处理下一个。一轮下来可能还没收敛就多迭代几轮。void SolveConstraints(float dt, int iterations) { for (int iter 0; iter iterations; iter) { for (auto constraint : constraints) { float lambda constraint.ComputeImpulse(dt); constraint.ApplyImpulse(lambda); } } }迭代次数是个需要权衡的参数。次数太少约束不收敛物体会软绵绵地陷进去次数太多性能吃不消。常见配置是 8 到 20 次。我一般先用 10 次跑观察堆叠稳定性不够再加。3.2 顺序冲量法与热启动PGS 有个问题约束的处理顺序会影响结果。如果每次都按同样的顺序处理误差会累积在特定方向上。解决办法是随机化处理顺序或者用**顺序冲量法Sequential Impulse**配合热启动。热启动的思路是把上一帧求解出的冲量作为这一帧的初始值。因为相邻帧的物理状态变化很小上一帧的冲量是个很好的起点能让求解器更快收敛。// 热启动用上一帧的冲量初始化 for (auto c : constraints) { c.ApplyImpulse(c.cachedImpulse); } // 然后正常迭代求解 for (int i 0; i iterations; i) { for (auto c : constraints) { float delta c.ComputeImpulse(dt); c.ApplyImpulse(delta); c.cachedImpulse delta; } }热启动配合接触点缓存是让堆叠稳定的两大法宝。这两个机制配合使用效果比单独用任何一个都好得多。3.3 摩擦力的处理细节摩擦力是约束求解里最容易出问题的地方。库仑摩擦模型说摩擦力大小不超过法向力乘以摩擦系数方向与相对滑动趋势相反。实现时通常用两个切向约束来近似每个切向约束的冲量被限制在摩擦锥内。// 法向冲量 float normalImpulse ComputeNormalImpulse(); // 切向冲量限制在摩擦锥内 float maxFriction frictionCoeff * normalImpulse; float tangentImpulse Clamp(ComputeTangentImpulse(), -maxFriction, maxFriction);这里有个细节摩擦锥的边界应该用法向冲量来算而不是用法向力。因为冲量是力乘以时间步用法向冲量算出来的摩擦上限才和切向冲量在同一量纲上。我见过有人用法向力去 clamp 切向冲量结果摩擦力要么大得离谱要么小得可怜。另外静摩擦和动摩擦最好分开处理。静摩擦系数通常大于动摩擦系数物体从静止到滑动的那一刻摩擦力会有一个突变。不区分的话物体会在斜面上出现粘一下滑一下的顿挫感。4. 动画系统的分层架构与状态管理4.1 动画系统的核心抽象骨骼、蒙皮与姿态动画系统的输入是一组骨骼的变换输出是蒙皮后顶点的位置。中间的核心概念是姿态Pose——一组骨骼变换的集合。骨骼通常组织成树结构根骨骼是躯干子骨骼是四肢和末端。每个骨骼有一个相对于父骨骼的局部变换通过遍历树做矩阵乘法就能算出每个骨骼的世界变换。struct Bone { Transform localBindPose; // 绑定姿态下的局部变换 Transform localPose; // 当前局部变换 Transform worldPose; // 计算出的世界变换 int parentIndex; std::vectorint children; }; void ComputeWorldPose(Skeleton skeleton) { for (int i 0; i skeleton.bones.size(); i) { if (skeleton.bones[i].parentIndex -1) { skeleton.bones[i].worldPose skeleton.bones[i].localPose; } else { auto parent skeleton.bones[skeleton.bones[i].parentIndex]; skeleton.bones[i].worldPose parent.worldPose * skeleton.bones[i].localPose; } } }注意这里遍历顺序很重要必须保证父骨骼先于子骨骼计算。如果骨骼数组是按层级顺序排列的父在前子在后直接顺序遍历就行否则需要先做一次拓扑排序。蒙皮矩阵是绑定姿态的逆乘以当前世界姿态。这个矩阵把顶点从绑定空间变换到当前姿态空间。每个顶点通常受 4 根骨骼影响权重之和为 1。4.2 动画混合让动作过渡自然两个动作之间直接切换会非常生硬所以需要混合。最基础的是线性混合给定两个姿态和一个权重 t逐骨骼做插值。Pose Blend(const Pose a, const Pose b, float t) { Pose result; for (int i 0; i a.bones.size(); i) { result.bones[i].localPose Lerp(a.bones[i].localPose, b.bones[i].localPose, t); } return result; }但这里有个坑旋转不能用线性插值。四元数的线性插值会导致角速度不均匀正确的做法是用球面线性插值Slerp。不过 Slerp 计算量大实践中常用归一化线性插值Nlerp近似效果够用且快很多。更复杂的混合方式还有加法混合Additive Blending把一个动作作为增量叠加到基础动作上常用于受伤、瞄准等叠加层。遮罩混合Masked Blending只混合特定骨骼比如上半身播放射击、下半身播放跑步。分层混合Layered Blending多个动画层按优先级叠加每层有自己的权重和遮罩。4.3 状态机与过渡条件动画状态机管理的是当前播放哪个动画、什么时候切换到下一个。每个状态对应一个动画片段状态之间的转移由条件触发。struct AnimationState { std::string name; AnimationClip* clip; bool loop; float speed; }; struct Transition { int fromState; int toState; float duration; std::functionbool() condition; };状态机的更新逻辑是检查当前状态的所有出边如果某个转移的条件满足就开始过渡。过渡期间源状态和目标状态按时间比例混合。这里有个实战经验过渡时间不要设得太短。很多人为了响应快把过渡时间设成 0.05 秒结果动作切换像抽搐。一般 0.15 到 0.3 秒比较自然具体看动作类型。跑步到停止可以短一点待机到攻击可以长一点。另外状态机要处理好中断。比如角色正在播放受击动画这时候玩家按了跳跃应该允许中断还是等受击播完这需要给状态设置优先级和可中断标记。5. 物理与动画的协作从根运动到布娃娃5.1 根运动让动画驱动位移传统做法是动画只负责骨骼姿态位移由代码控制。但这样容易出现滑步——脚在动人却没走对距离。根运动Root Motion的思路是让动画本身携带位移信息代码从动画里提取根骨骼的位移应用到角色实体上。void ApplyRootMotion(Entity entity, const Pose pose) { Transform rootDelta pose.bones[0].localPose; entity.position rootDelta.translation; entity.rotation * rootDelta.rotation; // 把根骨骼的位移从姿态中移除避免双重应用 pose.bones[0].localPose.translation Vector3::Zero; }根运动的好处是动作和位移完全匹配不会滑步。坏处是位移不再由代码完全控制网络同步和碰撞处理会复杂一些。我的建议是单机游戏大胆用根运动联机游戏谨慎用或者只在特定动作如处决、翻越上用。5.2 物理驱动动画布娃娃与主动布娃娃布娃娃Ragdoll是把角色的骨骼替换成物理刚体用关节连接起来让物理引擎驱动角色姿态。常用于死亡、被击飞等场景。实现布娃娃的关键是骨骼到刚体的映射。每根骨骼对应一个刚体骨骼之间的父子关系对应物理关节。切换时把当前动画姿态的位置和旋转同步给刚体然后交给物理引擎。void EnableRagdoll(Skeleton skeleton, PhysicsWorld world) { for (auto bone : skeleton.bones) { RigidBody* body world.CreateBody(bone.worldPose); body-SetCollisionShape(GetBoneShape(bone)); bone.physicsBody body; } for (auto bone : skeleton.bones) { if (bone.parentIndex ! -1) { world.CreateJoint(bone.physicsBody, skeleton.bones[bone.parentIndex].physicsBody, bone.worldPose); } } }纯布娃娃的问题是角色会像一滩烂泥一样瘫下去没有还活着的感觉。所以有了主动布娃娃Active Ragdoll在布娃娃的基础上给每个关节施加力矩让姿态尽量靠近某个目标动画。这样角色既有物理的真实感又能保持一定的姿态控制。主动布娃娃的力矩计算通常用 PD 控制器Torque kp * (targetRotation - currentRotation) - kd * angularVelocity;kp和kd两个参数需要仔细调。kp太大角色会僵硬抖动太小又软绵绵没力气。我一般从kp100, kd10开始试根据角色质量调整。5.3 物理与动画的更新顺序这两个系统的更新顺序会影响最终效果。常见的有两种方案方案顺序特点物理优先物理步 → 动画更新 → 渲染动画能读到最新物理状态适合物理驱动动画动画优先动画更新 → 物理步 → 渲染物理能读到最新动画姿态适合动画驱动物理大部分情况用物理优先。因为物理步是固定步长的动画更新是每帧一次物理优先能保证动画读到的是稳定的物理状态。如果做主动布娃娃可能需要动画优先让物理读到最新的目标姿态。实际项目中我通常把物理步放在游戏逻辑更新之后、动画更新之前。这样游戏逻辑可以修改物理状态比如施加力物理步计算出结果动画再根据结果更新姿态。6. 性能优化与常见问题排查6.1 物理性能的瓶颈定位物理系统的性能问题通常出在三个地方宽阶段、窄阶段、约束求解。定位方法很简单给每个阶段加计时器auto t0 Clock::now(); BroadPhase(); auto t1 Clock::now(); NarrowPhase(); auto t2 Clock::now(); SolveConstraints(); auto t3 Clock::now(); stats.broadPhaseMs Duration(t0, t1); stats.narrowPhaseMs Duration(t1, t2); stats.solverMs Duration(t2, t3);如果宽阶段慢说明物体太多或者空间划分不合理如果窄阶段慢说明碰撞对太多或者形状太复杂如果求解器慢说明约束太多或者迭代次数太高。优化手段按性价比排序减少活跃物体数量让静止的物体进入睡眠状态不参与物理计算。简化碰撞形状用凸包代替三角网格用胶囊体代替复杂角色碰撞。降低迭代次数在可接受范围内减少求解器迭代。并行化宽阶段和窄阶段都可以并行求解器并行难度大一些。睡眠机制特别值得说。物体速度低于阈值且持续一段时间后标记为睡眠跳过积分和碰撞检测。当有外力或碰撞发生时唤醒。这个机制能大幅降低静止场景的物理开销。我做过测试一个 500 个箱子的堆叠场景开启睡眠后物理耗时从 8ms 降到了 1ms 以内。6.2 动画性能的优化点动画系统的性能瓶颈通常在蒙皮计算和骨骼数量上。优化方向减少骨骼数量不是所有骨骼都需要参与蒙皮末端骨骼如手指可以用简化表示。LOD 蒙皮远处的角色用低精度蒙皮减少每顶点骨骼数。GPU 蒙皮把蒙皮计算放到 GPU用纹理存储骨骼矩阵。角色数量多时收益明显。动画压缩减少关键帧数量用曲线拟合代替逐帧数据。GPU 蒙皮是角色数量多时的必选项。做法是把骨骼矩阵打包成纹理顶点着色器里采样纹理做蒙皮。一个 60 根骨骼的角色CPU 蒙皮可能要 1msGPU 蒙皮几乎不占 CPU 时间。6.3 那些年我踩过的坑坑一物理频率和渲染频率不匹配导致抖动。前面提过解决办法是固定步长加插值。但插值也有坑如果插值的是位置但没插值旋转物体会一边平移一边抖。位置和旋转都要插值。坑二角色控制器和物理引擎打架。角色控制器通常用胶囊体做碰撞但角色移动是代码控制的不走物理积分。如果角色控制器和物理引擎的碰撞响应没协调好角色会卡在墙角或者穿墙。我的做法是角色控制器自己做碰撞检测和滑动响应物理引擎只负责其他物体两者通过碰撞层隔离。坑三动画事件在过渡期间丢失。动画事件如脚步声、攻击判定通常绑定在特定帧上。如果动画正在过渡事件可能被跳过。解决办法是在过渡期间同时检查源动画和目标动画的事件或者把事件独立于动画播放进度管理。坑四布娃娃切换时爆炸。从动画切换到布娃娃时如果刚体的初始位置和动画姿态差太多关节会瞬间产生巨大冲量角色直接飞出去。解决办法是切换时把刚体位置精确同步到骨骼位置并且给关节设置合理的最大力限制。坑五大量角色同时播放动画导致 CPU 飙升。每个角色的骨骼计算、混合、蒙皮都是 CPU 开销。角色超过 50 个时必须上 GPU 蒙皮和动画 LOD。我做过一个百人同屏的场景优化前 CPU 光动画就占了 15ms上 GPU 蒙皮加 LOD 之后降到了 3ms 以内。物理和动画这两个系统单独看都不算特别复杂但它们的协作方式和边界划分才是真正考验架构能力的地方。我的经验是先把固定步长和插值做对再把接触缓存和热启动加上最后处理根运动和布娃娃的切换。这个顺序能让每一步的收益都立竿见影也不容易在早期引入难以排查的 bug。