游戏里的物理:从 F=ma 到物理引擎

游戏里的物理封面:从F=ma到物理引擎
封面:游戏里的物理——从 F=ma 到物理引擎

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

游戏物理主循环:积分、碰撞检测、碰撞响应、约束求解
图 1:游戏物理主循环——固定时间步长下,每步先积分速度、再碰撞检测、后做响应与约束求解;渲染时用相邻两个物理步的位置插值

先补三件套:位置、速度、加速度

运动学的三个量:位置 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 默认用它,这是游戏物理的第一课。

三种积分器对比:解析解、显式欧拉、半隐式欧拉
图 2:三种积分方式对比——显式欧拉能量漂移、轨迹外扩;半隐式欧拉近似闭合、稳定(先算速度再算位置)

另一条路线是 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扫掠对比
图 3:离散检测两帧之间直接穿过薄墙;CCD 把运动扫成体积,先算出中途碰撞点(TOI),球停在墙面

解法按成本从高到低:

  • CCD(连续碰撞检测):把这一帧的运动扫成体积,先求中途碰撞时刻(TOI),精确但贵。Unity 里 Rigidbody 的 collisionDetectionMode = Continuous 走的就是扫掠。
  • 子步进:把大步拆成几个小步,实现简单,但整个物理循环都变贵。
  • 限制速度:钳制单帧最大位移小于最小碰撞体尺寸。游戏里最常见,因为代价是零。
  • 查询代替刚体:子弹、射线这类东西用 Raycast / Sweep 查询,而不是给一个高速刚体。

碰撞响应:冲量 + 摩擦

碰撞响应只干一件事:让两个刚体在接触瞬间交换动量,产生”反弹”和”被推开”的效果。核心参数是恢复系数 e:e = 0 完全非弹性(撞上就粘住,比如一坨泥),e = 1 完全弹性(能量无损失)。

冲量法碰撞响应:接触法线与恢复系数
图 4:冲量法碰撞响应——沿接触法线 n 施加冲量 j,相对速度按恢复系数 e 反弹;摩擦用库仑模型近似

实现上沿接触法线 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(冲量法与迭代求解的工程细节)
滚动至顶部