
游戏的物理目标不是复刻真实世界,而是”看着对、跑得快、不出 bug”。同样一个球,扔到真实世界里要考虑空气阻力和形变,游戏里只需要位置、速度、重力这几个量。现代引擎把这一套拆成四个阶段:积分更新运动、碰撞检测找接触、碰撞响应算反弹、约束求解管关节——每个物理步循环一次,见下图。

先补三件套:位置、速度、加速度
运动学的三个量:位置 x、速度 v = dx/dt、加速度 a = dv/dt。游戏里”物体动起来”,就是每个物理步给位置加上速度、给速度加上加速度。
牛顿第二定律 F = ma 是动力学的地基:质量越大的物体,同样的力产生的加速度越小。这里”力”可以理解为”加速度来源的集合”——重力、推力、弹簧力、风,全部加总后除以质量,得到这一帧的加速度。
动量 p = mv,冲量 J = F·Δt = m·Δv。碰撞在毫秒级完成,直接用力算既不稳定也不直观,物理引擎换了个思路:在碰撞瞬间交换动量。这就是后面冲量法的由来。
积分:把连续运动切成离散步
计算机是离散的:每个物理步 dt 更新一次状态。解析解只存在于自由落体、匀速圆周这类最简单的情形,任意受力组合只能数值积分。
最朴素的是显式欧拉:先用旧速度更新位置,再用旧加速度更新速度。它有个要命的性质——能量会漂移:弹簧越弹越高,圆周轨道越转越外。原因在于位置用的是”还没被这一步加速度修正过的旧速度”。
半隐式欧拉把顺序反过来:先算 v += a·dt,再算 x += v·dt。位置用刚更新的新速度去推,虽然仍是一阶精度,但能量不漂移,轨迹稳定不爆。Box2D、Bullet 默认用它,这是游戏物理的第一课。

另一条路线是 Verlet:x_new = 2·x − x_old + a·dt²。速度被隐式地存在”前后两个位置”里,好处是约束可以直接改位置,不用碰速度。它特别适合布料、绳子、头发这类约束驱动的模拟,js13k 比赛里一大半物理 demo 都是 Verlet 写的。
void Step(float dt) {
Vec2 gravity(0.f, -9.8f);
vel += (gravity + F / mass) * dt; // 1. 先更新速度(用上一步的力)
pos += vel * dt; // 2. 再更新位置(用新速度)
}
时间步长:固定还是可变
可变 dt(每帧把真实帧时间喂给物理)看似简单,其实有两个大坑。一是帧率低时 dt 变大,积分和碰撞检测都不稳定,弹簧爆炸、子弹穿地;二是同一局游戏在不同机器上复现不出来,网络对战需要确定性时这是灾难。
标准答案是固定 dt + 累加器:把真实流逝时间累进 accumulator,每凑满一个 dt 就补跑一步物理,一帧内可能跑 0 到 N 步。这一步数必须封顶,否则机器卡的时候物理会越补越慢,陷入”死亡螺旋”——永远追不上真实时间,正确做法是超时就丢弃时间、直接掉帧。
固定步长还有个附加收益:物理步长与渲染帧率解耦后,渲染位置用相邻两个物理步插值(alpha = accumulator / dt),画面不会因为物理步和帧错位而抖动。
float accumulator = 0.f;
const float dt = 1.f / 120.f;
while (running) {
float frameTime = min(GetElapsed(), 0.25f); // 钳制,防死亡螺旋
accumulator += frameTime;
while (accumulator >= dt) { // 一帧内补 0~N 个物理步
physics.Step(dt); // 永远用固定 dt
accumulator -= dt;
}
float alpha = accumulator / dt;
Render(Lerp(prevPos, curPos, alpha)); // 渲染用插值位置
}
数值上,常见配置是 dt = 1/60 或 1/120。dt 越小越接近连续,但每步都要重新做碰撞检测和求解,成本线性上涨。Bullet 默认 1/60,怕穿模的项目会压到 1/120。
碰撞检测:粗筛、精测、防穿透
碰撞检测分两阶段。Broad Phase 粗筛:用 AABB 包围盒、空间哈希或 Sweep and Prune 快速排除明显不相交的物体对,只留”可能碰”的候选。Narrow Phase 精测:对候选对做精确相交测试,2D 常用分离轴定理(SAT),3D 常用 GJK(凸体间最近距离)+ EPA(穿透深度),凹网格先凸分解。
真正让新手抓狂的是穿透(tunneling):小球足够快时,一帧内位移超过墙厚,离散检测在 t0、t1 两个位置都查不到墙,球就穿过去了。

解法按成本从高到低:
- CCD(连续碰撞检测):把这一帧的运动扫成体积,先求中途碰撞时刻(TOI),精确但贵。Unity 里 Rigidbody 的 collisionDetectionMode = Continuous 走的就是扫掠。
- 子步进:把大步拆成几个小步,实现简单,但整个物理循环都变贵。
- 限制速度:钳制单帧最大位移小于最小碰撞体尺寸。游戏里最常见,因为代价是零。
- 查询代替刚体:子弹、射线这类东西用 Raycast / Sweep 查询,而不是给一个高速刚体。
碰撞响应:冲量 + 摩擦
碰撞响应只干一件事:让两个刚体在接触瞬间交换动量,产生”反弹”和”被推开”的效果。核心参数是恢复系数 e:e = 0 完全非弹性(撞上就粘住,比如一坨泥),e = 1 完全弹性(能量无损失)。

实现上沿接触法线 n 施加冲量 j:相对速度的切向分量不动,法向分量按 e 反弹。上面的公式是对心碰撞的简化版;带角度、带旋转的完整版还要算碰撞点到质心的力臂和转动惯量,但骨架就是它。
// v_rel 在接触法线 n 上的分量 vn;e 是恢复系数
float vn = Dot(vB - vA, n);
if (vn > 0.f) return; // 正在分离,不处理
float e = 0.5f; // 0:粘住,1:完全反弹
float j = -(1.f + e) * vn / (1.f / mA + 1.f / mB);
vA -= (j / mA) * n;
vB += (j / mB) * n;
摩擦在游戏里同样用冲量近似:库仑模型说摩擦力 |F_f| ≤ μ·|F_n|,静摩擦 μ_s 大于动摩擦 μ_k。引擎实现是”切向冲量”,把切向相对速度衰减到某个比例。Box2D 里每个材质给 friction 和 restitution 两个值,仅此而已。
还有一个天天在用但没人注意的细节:位置修正。刚体难免嵌进地面几个毫米,直接硬顶会抖。引擎允许一点渗透容差(slop),超出部分按比例修正位置(Baumgarte 稳定化),让刚体”被推出去”而不是”弹出去”。
约束求解:关节与 Sequential Impulse
关节在物理引擎里就是约束:距离约束(杆子)、旋转约束(铰链)、滑轨、马达,全部写成”某个量必须等于或小于某个值”的方程。
所有约束合起来是个大方程组(LCP 问题),精确解很慢,而且大量约束同时生效。工程上用 Sequential Impulse:一轮轮迭代,每轮把所有约束各解一遍,几轮后收敛到近似解。Box2D 默认速度迭代 8 次 + 位置迭代 3 次,迭代次数就是”物体看起来有多硬”的旋钮——堆箱子抖不抖、多米诺倒得顺不顺,都跟它有关。
两个配套优化:
- Warm starting:把上一帧解出的冲量当作这一帧的初值,收敛快得多,静态堆叠的物体不会每帧重新”启动”一次而晃个不停。
- 接触缓存:Broad Phase 的候选结果跨帧复用,接触点不每帧从零重建。
Verlet 那条路线更省:距离约束直接改位置——求出当前长度与期望长度的差,两端各挪一半,迭代两三遍就挺稳。布料网格、绳子、发丝全用这个,不需要解任何方程。
void SolveDistance(Verlet& a, Verlet& b, float restLen) {
Vec2 d = b.pos - a.pos;
float dist = d.Length();
float diff = (dist - restLen) / dist;
Vec2 corr = d * (0.5f * diff); // 两端各挪一半,满足"长度守恒"
a.pos += corr;
b.pos -= corr;
}
引擎选型:别自己造轮子
2D 基本只有 Box2D 一个事实标准(Erin Catto 开发,MIT 许可),Unity 2D 物理内置的就是它,Godot 也能接入。积分、两阶段检测、冲量、关节、迭代求解,全部打包成几百个 API,文档干净。
3D 三巨头:
- PhysX:NVIDIA 出品,BSD 许可,Unity 和 Unreal 的默认物理,支持 GPU 加速,3A 用量最大。
- Bullet:开源(zlib),GTA IV、Blender、PyBullet 机器人模拟都在用,CPU 性能好,带软体(布料、绳子)和车辆模型。
- Havok:商业授权,多数 3A 动作游戏的选择,角色控制器和破坏效果成熟。
新一点的选择:Jolt(Godot 4 默认,C++ 开源,性能和易用性都不错)、Bepu(C#,.NET 高性能)。选型看四个维度:维度(2D/3D)、许可证、要不要 GPU、要不要软体。
对绝大多数项目,正确姿势是直接用引擎内置物理并调参。自己写物理引擎通常是”验证学没学会”的练习,不是产品决策。
调参清单:游戏物理的主要工作量
物理引擎把”力学”部分做好了,剩下的工程问题才占大头。按现象对号入座:
- 物体不动:查质量、重力缩放、isKinematic、碰撞层(Layer / Mask)。
- 穿模:开 CCD、固定时间步、钳制最大速度、减小 dt。
- 抖动:加迭代次数、开 warm starting、固定步长 + 渲染插值。
- 卡成幻灯片:看是不是死亡螺旋,限制每帧物理步数。
- 性能:60fps 下每帧 16.6ms,物理预算通常只有 1~3ms。动态刚体越少越好,静态地形用 static 碰撞体,别每帧改碰撞体形状。
最后提醒一句:真实感不等于手感。马里奥的跳跃重力、赛车漂移的手感、角色控制器,全是把真实物理调成”玩家觉得对”的伪物理。物理引擎是手段,游戏性才是目的。
总结
游戏物理的骨架就四条:牛顿力学定运动、积分器把连续切成离散、碰撞检测找接触、约束求解管关节。理解”半隐式欧拉为什么稳””固定时间步为什么是标配””CCD 解决什么问题”,比背引擎 API 有用得多——出 bug 时你能说清楚问题出在积分、检测还是求解。
参考
- Glenn Fiedler,Fix Your Timestep!(固定时间步长的经典方案):gafferongames.com/post/fix_your_timestep
- Erin Catto,Box2D 官网(Sequential Impulse、Warm Starting 的出处):box2d.org
- Bullet Physics:github.com/bulletphysics/bullet3
- NVIDIA PhysX:github.com/NVIDIA-Omniverse/PhysX
- Unity 手册,Continuous Collision Detection:docs.unity3d.com
- Wikipedia:Verlet integration、Semi-implicit Euler method
- Ian Millington,Game Physics Engine Development(中译《游戏物理引擎开发》,清华大学出版社)
- SIGGRAPH ASIA 课程,Practical Rigid Body Physics for Games(冲量法与迭代求解的工程细节)




