在固定步长帧循环里,物理系统是"世界会不会穿模、子弹会不会命中"的判定者。很多游戏开发者把物理引擎当黑盒用——拖个 Rigidbody、加个 Collider 完事,直到遇到高速穿透、角色抖进墙里、布娃娃爆炸这些问题时才意识到需要理解引擎内部。本文从刚体动力学出发,拆解碰撞检测的宽/窄相位、碰撞响应的脉冲与约束求解,最后横向对比 Box2D、PhysX、Bullet 三大引擎的定位与选型。
前置知识:固定步长帧循环见 游戏引擎架构:ECS 与资源管理;物理相关的网络确定性要求见 游戏网络同步:延迟补偿与回滚。
1. 刚体动力学:世界如何动起来
1.1 运动学基础
刚体(Rigid Body)在物理引擎里是一个"有质量、有速度、可受力"的对象。引擎用积分逐帧推进状态:
每个固定步长 dt:
v += (a) * dt // 速度 = 速度 + 加速度×dt
x += v * dt // 位置 = 位置 + 速度×dt
其中加速度来自外力(重力、推力、碰撞产生的冲量)。这是显式欧拉积分,简单但误差累积。质量越大的物体越难被推动,体现在冲量分配上:Δv = J / m,质量 m 越大,同样冲量 J 造成的速度变化越小。
1.2 力的种类与接口
物理引擎对外暴露的典型力接口:
// 伪代码:物理引擎力接口
body.AddForce(Vector3 force, ForceMode mode);
body.AddTorque(Vector3 torque, ForceMode mode);
body.AddImpulse(Vector3 impulse); // 瞬时冲量,如子弹命中
body.SetVelocity(Vector3 v); // 直接设速度(可能破坏物理一致性)
| ForceMode | 含义 | 适用 |
|---|---|---|
| Force | 持续力(F,加速度 = F/m) | 推力、风 |
| Impulse | 瞬时冲量(J,Δv = J/m) | 射击、爆炸 |
| VelocityChange | 无视质量直接改速度 | 玩家控制角色 |
| Acceleration | 加速度(无视质量) | 重力、恒定力 |
法则:能不用 SetVelocity 就别用,直接改速度会跳过引擎的约束求解,容易导致"物体穿透 + 抖动"。
1.3 刚体类型对比
| 类型 | 动力学参与 | 碰撞响应 | 典型用途 |
|---|---|---|---|
| Static(静态) | 不参与,质量无限大 | 只做碰撞面 | 地面、墙 |
| Kinematic(运动学) | 手动控制速度 | 只推动态物体 | 电梯、平台 |
| Dynamic(动态) | 完全模拟 | 全参与 | 玩家、可推动物 |
Godot 的 RigidBody3D/CharacterBody3D/StaticBody3D、Unity 的 Rigidbody(IsKinematic)都遵循这套三分法。玩家角色建议用 Kinematic 自己控制 + 手动检测碰撞,而不是 Dynamic——Dynamic 角色会被物理引擎推得满地打滑。
2. 碰撞检测:宽相位与窄相位
碰撞检测是物理引擎最贵的部分,因此被拆成两阶段:
宽相位(Broad Phase):找出"可能相交"的候选对(快速排除)
↓
窄相位(Narrow Phase):对候选对做精确的几何相交测试
↓
碰撞响应(Response):计算冲量/约束并更新状态
2.1 宽相位:包围体与空间索引
宽相位用廉价的包围体快速排除明显不相交的物体。常用结构:
| 结构 | 原理 | 复杂度 | 适用 |
|---|---|---|---|
| 暴力两两 | 每对测试 | O(n²) | n < 100 |
| Sweep and Prune (SAP) | 按轴排序扫描,只测重叠区间 | O(n log n) | 2D 物理 |
| 均匀网格 | 空间分桶,桶内测试 | O(n) 摊还 | 大量均匀分布物体 |
| BVH / 四叉树 / 八叉树 | 包围体层级树 | O(log n) 查询 | 3D 大世界 |
// Sweep and Prune 的思想:把 AABB 投影到轴上,按 min/max 排序
// 只有 min_A < max_B 且 min_B < max_A 的重叠区间才进入窄相位
struct AABB { float minX, maxX, minY, maxY; };
bool Overlap(AABB a, AABB b) {
return a.minX < b.maxX && b.minX < a.maxX &&
a.minY < b.maxY && b.minY < a.maxY;
}
关键优化:运动预测(Swept)。物体高速移动时,单纯做瞬时相交测试会漏掉"上一帧没穿、这一帧已经穿透"的情况。宽相位常用扫描体(Swept AABB)——把这一帧的运动轨迹包起来再测。
2.2 窄相位:精确相交测试
窄相位对候选对做精确测试。最经典的三种:
分离轴定理(SAT):两个凸多边形不相交,当且仅当存在一条分离轴,沿该轴投影的区间不重叠。测试所有候选法线轴(每边法线 + 每对顶点连线法线)。
// SAT 核心:对每个轴做投影区间重叠测试
bool TestOverlapOnAxis(Polygon a, Polygon b, Vector2 axis) {
float aMin = Project(a, axis).min, aMax = Project(a, axis).max;
float bMin = Project(b, axis).min, bMax = Project(b, axis).max;
return aMin <= bMax && bMin <= aMax;
}
bool SATIntersect(Polygon a, Polygon b) {
foreach (var axis in GetCandidateAxes(a, b))
if (!TestOverlapOnAxis(a, b, axis))
return false; // 找到分离轴即不相交
return true; // 所有轴都重叠才相交
}
GJK(Gilbert-Johnson-Keerthi):利用明可夫斯基差(Minkowski Difference)——两物体相交当且仅当原点在两者之差内。用单纯形(simplex)逐步逼近,收敛快,是 3D 物理引擎(PhysX、Bullet)的默认方案。
连续碰撞检测(CCD):处理高速穿透的终极手段。常用 swept volume / conservative advancement,把运动轨迹卷成扫掠体再测试。代价高,只对高速小物体(子弹、箭矢)开启。
2.3 碰撞形状的选择
| 形状 | 内存 | 精度 | 典型用途 |
|---|---|---|---|
| AABB(轴对齐盒) | 极低 | 低 | 宽相位、静态环境 |
| OBB(有向盒) | 低 | 中 | 中等物体 |
| Sphere / Capsule | 极低 | 低 | 玩家角色、子弹 |
| Convex Hull | 中 | 高 | 复杂但凸的物体 |
| 三角网格(Mesh Collider) | 高 | 最高 | 地形、静态场景 |
铁律:非凸物体的碰撞形状必须用多个凸形状组合(Compound Collider),物理引擎的窄相位只支持凸体相交测试。用 Mesh Collider 做动态物体碰撞是性能灾难。
3. 碰撞响应:脉冲与约束
3.1 脉冲法(Impulse)
找到碰撞点后,引擎计算一个沿法线方向的冲量 J,让两物体在碰撞点分离。核心公式(简化版):
J = -(1 + e) * (v_rel · n) / (1/m1 + 1/m2 + ...)
其中 e 是恢复系数(restitution,0 完全非弹性、1 完全弹性),v_rel 是相对速度,n 是碰撞法线。
// 伪代码:一次脉冲碰撞响应
void ResolveCollision(RigidBody a, RigidBody b, Vector2 normal) {
float relV = Vector2.Dot(b.velocity - a.velocity, normal);
if (relV > 0) return; // 正在分离,无需处理
float e = Math.Min(a.restitution, b.restitution);
float invMassSum = a.invMass + b.invMass;
float j = -(1 + e) * relV / invMassSum; // 冲量大小
a.velocity -= (j * normal) * a.invMass;
b.velocity += (j * normal) * b.invMass;
}
3.2 约束求解与位置修正
单纯脉冲法在堆叠(一摞箱子)和多接触点场景下会不稳定——每个接触点单独解会产生抖动。现代引擎用约束求解器(Constraint Solver):把所有接触点(和关节)组织成约束方程组,迭代求解(Sequential Impulse,顺序冲量,常见迭代 4-10 次)。
迭代求解(每次迭代逐步改进):
for iter in 1..8:
for each contact:
J = solve_contact(contact) // 顺序处理每个接触点
apply_impulse(contact, J)
位置修正(Baumgarte Stabilization / Split Impulse):避免物体陷入地面时靠速度反弹"滑进去"——求解后额外按穿透深度给一个小位移修正。注意修正系数过大物体会抖。
3.3 关节与约束类型
| 关节 | 约束自由度 | 用途 |
|---|---|---|
| 距离关节(Distance) | 保持两点距离 | 绳索、锁链 |
| 铰链关节(Hinge) | 绕一轴旋转 | 门、摆锤 |
| 滑动关节(Prismatic) | 沿一轴平移 | 活塞、滑轨 |
| 弹簧关节(Spring) | 带弹性距离 | 车辆悬挂、布娃娃 |
3.4 触发器(Trigger)与物理查询
不是所有碰撞都需要"弹开"。**触发器(Trigger/传感器)**只报告重叠事件、不做物理响应,用于检测区、伤害区域、捡拾判定:
// Unity:IsTrigger 的碰撞体只触发事件不产生冲量
void OnTriggerEnter(Collider other) {
if (other.CompareTag("Player")) ApplyDamage(other);
}
物理引擎还提供查询接口用于主动检测,这是玩法代码的常用工具:
| 查询 | 用途 | 代价 |
|---|---|---|
| Raycast(射线检测) | 射击命中、视线 | 便宜,可每帧大量调用 |
| SphereCast / BoxCast | 移动碰撞扫描 | 中 |
| OverlapSphere / OverlapBox | 范围敌人检测 | 中 |
| 凸包扫描(Sweep) | 连续移动检测 | 贵,谨慎使用 |
// Unity:每帧一次的射线查询
if (Physics.Raycast(ray, out var hit, range, layerMask)) {
hit.collider.GetComponent<IDamageable>()?.TakeDamage(damage);
}
查询优化:射线/范围查询也要用 LayerMask 过滤(只测相关层),否则引擎会对无关物体做相交测试。大世界尽量用**空间索引(宽相位结构)**做粗筛选,再做精确查询。
4. 物理引擎对比与选型
| 维度 | Box2D | PhysX | Bullet |
|---|---|---|---|
| 维度 | 2D | 3D | 3D |
| 语言 | C++(被移植无数) | C++(NVIDIA 维护) | C++ |
| 求解器 | 顺序冲量 | PGS/迭代 | 顺序冲量 + 各类改进 |
| 特色 | 简单稳定、教学首选 | 工业级、NVIDIA 生态 | 开源、游戏+机器人 |
| 依赖 | 无 | 闭源 SDK(有开源版) | 纯开源 |
| 集成 | Godot 默认 2D、Cocos2d | Unity 默认物理 | Blender、机器人仿真 |
选型指南:
- 2D 游戏:直接用 Godot 内置(基于自研+Box2D 思想)或 Lua + Box2D 生态。
- Unity 项目:PhysX 是默认且深度集成(
PhysX 4),一般不需要换。 - 自研引擎 / 仿真:Bullet 开源、文档全、跨平台成熟。
- 需要确定性(帧同步回滚,见 游戏网络同步:延迟补偿与回滚):Box2D 与 Bullet 都提供确定性模式,但浮点一致性是硬伤——同一引擎在不同平台结果可能不同,需要做定点数替换。
4.1 物理引擎的三大性能热点
| 热点 | 成本 | 优化 |
|---|---|---|
| 宽相位 | O(n log n) | 减少碰撞体数量、合理尺寸(别用超大 Mesh) |
| 窄相位 | 精确几何测试 | 优先用球/胶囊,少用 Mesh Collider |
| 约束求解 | 每接触点迭代 | 限制迭代次数、减少关节数量 |
4.2 物理子步(Sub-stepping)与休眠(Sleeping)
子步:当物体速度过快或碰撞密集时,单步 dt=1/60 可能不够精确。引擎可在一个固定步长内跑多次子步(如 2-4 次 1/240),提升稳定性,但成本线性上升。只在需要的地方(高速场景)开子步,全局开启是浪费。
休眠(Sleeping):物理引擎的自动优化——静止的刚体自动进入休眠,停止参与模拟,直到受到外力才唤醒:
刚体静止 → 速度/角速度 < 阈值 连续 N 帧 → 进入休眠(不再模拟)
被碰撞/受力 → 唤醒 → 重新模拟
休眠是移动端物理性能的隐形功臣:一摞箱子堆完静止后,物理成本几乎归零。坑:玩家推了一下静止物体却发现推不动——那是它仍在休眠,需要 WakeUp() 或调低休眠阈值。
4.3 常见物理故障排查表
| 故障现象 | 大概率原因 | 排查方向 |
|---|---|---|
| 高速穿透 | 未开 CCD / 子步不足 | 开 CCD、加子步、缩小碰撞体 |
| 堆叠抖动 | 约束迭代不足 / 位置修正过大 | 提高迭代次数、调 Baumgarte 系数 |
| 角色滑墙 | 用 Dynamic 做角色 | 换 Kinematic/CharacterController |
| 布娃娃爆炸 | 关节强度不足 / 大穿透 | 调关节阻尼、加位置修正 |
| 物理突然卡顿 | 大量刚体同时唤醒 | 查休眠阈值、减少碰撞体数 |
5. 物理层最佳实践与总结
物理系统设计检查单:
- 固定步长驱动物理:物理必须在固定步长(Unity
FixedUpdate/ Godot_PhysicsProcess)内推进,否则结果不确定(回滚/帧同步大忌)。 - 高速物体开 CCD:子弹、箭、移动平台,否则必然穿透。
- 角色用 Kinematic / CharacterController:动态角色手感差、容易卡墙。
- Compound Collider 而非 Mesh Collider:动态物体碰撞必须凸分解。
- 分层(Layer + Mask):只让需要碰撞的层互相碰撞,减少无效候选对(如玩家子弹不跟玩家碰撞)。
- 物理与逻辑分离:物理表现(布娃娃、碎屑)与游戏判定(伤害计算)解耦,物理失败不应影响判定。
调试技巧:
- Unity:
Physics Debugger(可视化碰撞体、接触点、velocity)。 - Godot:
Rendering > Visible Collision Shapes。 - 通用:把
dt调小看是否稳定(排除积分误差);把穿透深度打印出来看是否超阈值。
物理引擎不是魔法:理解宽/窄相位就能定位"为什么碰撞检测慢",理解冲量与约束就能解决"为什么堆叠会抖",理解确定性限制就能避免回滚网络游戏里"各平台结果不一致"的灾难。
相关阅读:游戏网络同步:延迟补偿与回滚 讲解物理确定性与回滚的结合;游戏渲染管线基础 讲解物理结果如何被渲染消费;游戏引擎架构:ECS 与资源管理 讲解物理在帧循环中的位置。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。