本节摘要:物理步进必须用固定时间步,渲染帧时间只能进累加器。卡顿时多跑几个固定步并钳制最大步数,显示时用余量做插值。这样弹簧不会因 delta 抖动而爆炸,回放和联机才有可复现的状态序列。核心循环的主权在物理,不在渲染。
阅读完本节,你应当能够:
渲染帧时间是脏的。垂直同步、后台任务、着色器编译,都可能让这一帧变成 8 毫秒或 48 毫秒。若把这个数直接塞进 v += g*dt; x += v*dt,你等于每帧在换一套物理定律。显式方法的截断误差跟 dt 的平方有关,dt 翻四倍,误差项不是翻四倍。弹簧、布料、堆叠箱子对这件事极其敏感:同样的场景,流畅时稳如磐石,卡一下就炸成烟花。
钟表的擒纵机构不管你看表的频率,每次摆轮都走固定的一格。物理引擎需要同样的擒纵:真实时间可以走快走慢,内部只接受例如 1/60 秒或 1/120 秒的格子。格子大小是引擎的物理常量,不是这一帧碰巧多重绘了一次。
变步长还有第二宗罪:无法复现。你按同样输入重放,帧时间序列变了,碰撞顺序就变了,箱子倒向另一边。联机同步、录像、自动化测试全部建立在“同一输入 + 同一 dt 序列 → 同一状态”上。变步长把这个等式撕掉。
| 方案 | 做法 | 观感 | 物理稳定性 | 复现性 |
|---|---|---|---|---|
| 绑渲染 | dt 等于帧时间 | 跟手 | 卡顿即爆炸 | 无 |
| 纯固定步不插值 | 物理 60Hz,画面跟着跳 | 可能微顿 | 好 | 好 |
| 固定步加插值 | 物理固定,画面在两态之间 | 平滑 | 好 | 好 |
| 半固定 | 小卡用变步,大卡钳制 | 折中 | 中等 | 差 |
我更倾向第三行。多花两个状态缓冲,换来调试和联机都省事。只有做工具软件、物体很少、没有弹簧时,才可以考虑绑渲染,并且要在注释里写明这是故意的债。
主循环每帧拿到 frameTime(秒),把它加进 accumulator。然后当累加器大于等于 dt 时,跑一次 step(dt),累加器减去 dt。为防螺旋式死亡——物理越算越慢、落后真实时间越多、于是一步里要补更多步——必须钳制:单帧最多跑 N 次 step,多出来的时间丢掉或减速游戏。丢掉时间会让模拟变慢;不丢会卡死。游戏通常丢,并打日志。
accumulator += min(frameTime, maxFrameCatchup) steps = 0 while accumulator >= dt and steps < maxSteps: save previousState step(dt) accumulator -= dt steps += 1 alpha = accumulator / dt render(lerp(previousState, currentState, alpha))
maxFrameCatchup 防止从后台回来时累加器里塞了 8 秒,于是一次性算 480 步把机器打满。通常钳到 0.05 到 0.1 秒,相当于承认“玩家切回来的那一下,物理可以跳”。maxSteps 常见 4 到 8。超过就丢时间,画面上世界会短暂变慢,总比卡死强。
插值用的是位置和朝向,不是速度。速度插值没有几何意义,渲染只关心这一帧物体画在哪。朝向要用球面插值或归一化后的线性插值,四元数直接 lerp 再归一化通常够用。不要插值接触力,那是内部量。
有人会问:能不能物理也 30Hz、渲染 144Hz?可以。dt 越大,穿透风险越大,高速物体需要连续碰撞检测。物理 120Hz 更稳、更吃 CPU。平台游戏常用 60;赛车、弹珠常用 120 或子步。子步是在一次 step 里把 dt 再切成 k 份,碰撞和求解跑 k 次,积分更细。子步和“外层多 step”效果类似,但子步共享同一帧的输入采样,联机更好对齐。
💡 关键直觉:dt 是物理世界的原子时间。渲染只是在两个原子之间偷看。谁决定“现在是几点”,只能是累加器,不能是 GPU 什么时候画完。
固定步还有一个隐蔽好处:约束求解器的偏置项里有 /dt。dt 乱跳,同样的穿透会产生乱跳的修正速度,堆叠会自己跳舞。很多“求解器调不稳”其实是时间步在抖。把 dt 钉死后,再去调鲍姆加特系数,才调得动。
输入要在 step 边界采样,不要在渲染帧采样再让多个 step 共用一份被改过的输入。典型做法:渲染帧把按键写入“当前输入槽”,每次 step 开始时复制到“物理输入”。联机时,输入槽来自网络缓冲,而不是本地即时按键——那是 7.2 节的事,但循环形状现在就要留出来。
掉步策略需要产品决策。
| 落后情况 | 策略 | 副作用 |
|---|---|---|
| 轻卡一两帧 | 多跑 step 追上 | CPU 尖峰 |
| 卡超过 maxSteps | 丢时间,世界变慢 | 手感短暂发黏 |
| 长时间后台 | 清空累加器,不追 | 时间线跳变 |
| 要严格确定性 | 永不丢步,改为减慢整局 | 所有端一起慢 |
回放系统选择最后一行的变体:以逻辑帧为准,渲染跟着逻辑走,逻辑永不丢步。实时对战若一端机器太慢,需要降级特效而不是偷偷丢物理步,否则两边世界会分叉。
螺旋式死亡的经典现场:求解器迭代次数设很大,一帧算不完,累加器继续涨,下一帧要跑更多 step,于是更算不完。钳制 maxSteps 是保险丝。保险丝熔断时要可视化:屏幕角上显示“物理落后 N 毫秒”,否则你会以为是碰撞写错。
运动学平台的位置更新也要走固定步。若电梯按渲染帧移动,物理体按固定步检测,会在台阶边缘出现一帧的错位,角色掉下去。动画系统若以帧驱动,需要在 step 里按逻辑时间采样动画,而不是按显示时间。
step(dt): 采样输入与运动学轨迹 积分动态体 宽相 窄相与接触缓存 求解速度约束 写位置 位置约束与休眠
本章还没有宽相求解器,但循环骨架现在就要按这个顺序排。以后往槽里填函数,不要把积分塞到碰撞后面又改回来。顺序是契约。
⚠️ 常见坑:累加器用 float 累加很长时间后,
accumulator >= dt因误差变得不稳定。用双精度累加器,或改成整数逻辑帧计数,dt 只在积分内部用。联机项目更应该用整数 tick。
调试时把 dt、本帧 step 次数、累加器余量画成三条曲线。正常情况 step 次数几乎恒定,余量在 0 到 dt 之间锯齿。若 step 次数频繁顶到 maxSteps,先优化求解器,不要先把 dt 改大——改大是在用稳定性换性能,债会在高速碰撞时收。
插值还有一个边界:传送门、重生、爆炸传送。这类状态突变不要插值,否则会画出一道穿越墙壁的残影。给刚体一个 teleport 标记,该帧渲染直接用当前态,并复制 previous=current。摄像机跟随同理。
固定步长的选择可以按最高预期速度和最小物体尺寸估:物体每步移动不要超过自身半径的一小半,否则球会对上穿过薄板。更严格的做法是连续碰撞检测,代价高,第 3 章再谈。能用更小 dt 解决的,先不要上 CCD。手机上 30Hz 物理加子步 2,往往比 60Hz 但不稳定更省电。
把这一节落到弹跳球上:把原来的 update(frameTime) 拆成累加器循环调用 step(1/60)。你会发现球的跳跃高度不再随拖窗口变化。这就是擒纵机构就位。有了它,第 2 章比较积分器才公平——比较的是同一把尺子下谁守恒、谁阻尼。
整数 tick 值得单独说。逻辑帧编号从 0 递增,物理时间等于 tick * dt。所有超时、电机相位、动画采样都用 tick,不用墙上时钟。墙上时钟只负责往累加器里倒时间。这样录像文件就是“每 tick 的输入”,重放时不依赖当时机器有多快。测试用例也可以写成“在 tick=120 时速度应低于阈值”。没有固定步,这类断言无法写。
从后台切回时,操作系统可能一次给你 8000 毫秒的帧时间。若无 maxFrameCatchup,累加器会要求跑几百次 step,这一帧卡死,玩家以为崩溃。钳制后世界会跳时间,电梯可能瞬移。产品上要同时:钳制步数、对运动学平台做传送标记取消插值、UI 提示“模拟已追赶”。工具软件可以暂停累加器,切回时从零继续,逻辑时间与墙钟脱钩——编辑器常用这种,游戏不行,因为任务计时会停。
垂直同步打开时帧时间接近 16.6 毫秒,和 60Hz 物理对齐,累加器余量很稳。关掉垂直同步或显示器 144Hz,余量锯齿更密,插值更重要。不要在 144Hz 下把物理也改成 144Hz:接触和求解会贵三倍,高速物体未必更稳,因为求解迭代没变。物理频率和刷新率解耦,是这一节的全部政治。
联机预留:每个 tick 开始时复制输入。本地预测时,尚未被权威确认的 tick 用本地输入,确认后若不一致则回滚。回滚要求 step 不读墙钟、不读全局随机、不在内部改渲染状态。现在就把 step 写成纯函数形状:入参是状态加输入加 dt,出参是新状态。渲染插值在 step 外。谁把粒子特效写进 step,谁的回滚就会把特效倒放成灾难。
再做一个实验:固定 dt=1/60,用半隐式积分一根弹簧,记录 10 秒振幅。然后故意每 30 帧插入一帧 50 毫秒的“卡顿”(仍走累加器)。振幅曲线应与无卡顿几乎重合。若你把卡顿帧时间直接喂给积分,振幅会在那一帧附近跳。两张图并排,就是固定步的毕业答辩。
有人用“半固定”:dt 在 1/60 附近微调以对齐刷新。对画面友好,对确定性不友好。只在单机、无回放、无弹簧硬约束的休闲游戏里可以。本教程默认不采用。若你采用,请把弹簧刚度降下来,并放弃联机物理一致。
整数 tick 溢出:用 32 位毫秒大约 24 天,用 32 位 tick 在 60Hz 下约 414 天。长会话服务器用 64 位 tick。测试里把 tick 设到接近溢出,看插值和电机相位是否炸掉。这种测试现在做,比上线后做便宜。
暂停游戏请停输入、开 UI,不要停累加器除非单机且无网络。联机停累加器等于停协议时钟。过场用睡眠减算力,仍推进 tick。这两句足够避免把时间模型做成一堆 if。
下一节把刚体从“每个对象一块内存”改成数组,让后面的求解器和 SIMD 有东西可扫。