物理系统和动画系统是游戏引擎里最容易被低估的两个模块。很多人做引擎学习时,把大量精力花在渲染管线上,觉得画面好看就行,结果一旦角色开始移动、碰撞、播放动作,各种穿模、抖动、滑步、卡顿就全冒出来了。我在实际项目里踩过的最典型的坑,就是角色明明站在地面上却一直往下掉,排查了半天才发现是物理更新频率和渲染帧率没有解耦。这篇文章就围绕游戏引擎中物理与动画系统的架构设计展开,把这两个系统各自的核心职责、它们之间的协作方式、以及实际落地时最容易出问题的地方讲清楚。不管你是刚接触引擎开发的新手,还是已经写过简单物理循环想进一步理解工业级设计的开发者,都能从中拿到可以直接参考的思路和代码结构。
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这套组合拳,或者针对特定形状对的专用算法。
GJK(Gilbert-Johnson-Keerthi)算法用来判断两个凸体是否相交,它的核心思想是在两个物体的闵可夫斯基差空间里,判断原点是否在差集内部。如果原点在内部,说明两个物体相交。EPA(Expanding Polytope Algorithm)则在 GJK 判定相交后,进一步计算出穿透深度和接触法线。
这套算法听起来很数学,但实际实现时有几个工程上的关键点:
- 支撑函数(Support Function):给定一个方向,返回物体在该方向上最远的点。凸体只需要这一个函数就能参与 GJK 计算,这是它优雅的地方。
- 单纯形(Simplex):GJK 迭代过程中维护的点集,2D 下是三角形,3D 下是四面体。
- 终止条件:迭代到单纯形包含原点,或者超过最大迭代次数。最大迭代次数一定要设,否则数值退化时可能死循环。
对于球体、胶囊体、平面这些常见形状,用解析法直接算接触信息比 GJK 更快也更稳。比如球-球碰撞,接触法线就是两球心连线方向,穿透深度就是半径之和减去球心距离,几行代码就搞定。所以工业级引擎通常是混合策略:常见形状对用解析法,通用凸体用 GJK/EPA。
2.3 接触点的缓存与持久化
这是很多人会忽略但极其重要的一环。物理求解器需要接触点在多帧之间保持稳定,否则会出现物体抖动、弹跳异常的问题。
做法是维护一个接触点缓存,每个物理步开始时,先用上一帧的接触信息去"预热"求解器。如果两个物体上一帧有接触,这一帧大概率还有,直接复用之前的接触点,求解器收敛会快很多,结果也更稳定。
struct ContactCache { BodyPair pair; std::vector<ContactPoint> 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::vector<int> 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::function<bool()> 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太大角色会僵硬抖动,太小又软绵绵没力气。我一般从kp=100, kd=10开始试,根据角色质量调整。
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 蒙皮可能要 1ms,GPU 蒙皮几乎不占 CPU 时间。
6.3 那些年我踩过的坑
坑一:物理频率和渲染频率不匹配导致抖动。前面提过,解决办法是固定步长加插值。但插值也有坑:如果插值的是位置但没插值旋转,物体会一边平移一边抖。位置和旋转都要插值。
坑二:角色控制器和物理引擎打架。角色控制器通常用胶囊体做碰撞,但角色移动是代码控制的,不走物理积分。如果角色控制器和物理引擎的碰撞响应没协调好,角色会卡在墙角或者穿墙。我的做法是角色控制器自己做碰撞检测和滑动响应,物理引擎只负责其他物体,两者通过碰撞层隔离。
坑三:动画事件在过渡期间丢失。动画事件(如脚步声、攻击判定)通常绑定在特定帧上。如果动画正在过渡,事件可能被跳过。解决办法是在过渡期间同时检查源动画和目标动画的事件,或者把事件独立于动画播放进度管理。
坑四:布娃娃切换时爆炸。从动画切换到布娃娃时,如果刚体的初始位置和动画姿态差太多,关节会瞬间产生巨大冲量,角色直接飞出去。解决办法是切换时把刚体位置精确同步到骨骼位置,并且给关节设置合理的最大力限制。
坑五:大量角色同时播放动画导致 CPU 飙升。每个角色的骨骼计算、混合、蒙皮都是 CPU 开销。角色超过 50 个时,必须上 GPU 蒙皮和动画 LOD。我做过一个百人同屏的场景,优化前 CPU 光动画就占了 15ms,上 GPU 蒙皮加 LOD 之后降到了 3ms 以内。
物理和动画这两个系统,单独看都不算特别复杂,但它们的协作方式和边界划分才是真正考验架构能力的地方。我的经验是,先把固定步长和插值做对,再把接触缓存和热启动加上,最后处理根运动和布娃娃的切换。这个顺序能让每一步的收益都立竿见影,也不容易在早期引入难以排查的 bug。