1.2 固定时间步与核心循环


1.2 固定时间步与核心循环

本节摘要:物理步进必须用固定时间步,渲染帧时间只能进累加器。卡顿时多跑几个固定步并钳制最大步数,显示时用余量做插值。这样弹簧不会因 delta 抖动而爆炸,回放和联机才有可复现的状态序列。核心循环的主权在物理,不在渲染。

本节目标

阅读完本节,你应当能够:

  1. 写出带累加器、固定 dt、最大步数的主循环
  2. 解释为什么变步长欧拉会让弹簧“有时稳有时炸”
  3. 用插值把物理状态交给渲染,避免视觉卡顿
  4. 在螺旋式死亡、螺旋式变慢、掉步之间做取舍

一、时钟不能跟着画面走

渲染帧时间是脏的。垂直同步、后台任务、着色器编译,都可能让这一帧变成 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。

核心回顾

  • 物理用固定 dt,渲染用累加器余量插值,两套时钟不要焊死。
  • 单帧 step 次数必须有上限,防止螺旋式死亡;熔断时要能看见落后量。
  • 输入在 step 边界采样,为确定性和联机留槽。
  • 偏置项含 1/dt,变步长会让堆叠自己跳舞,先钉时间再调求解器。
  • 传送不要插值;普通运动才 lerp。
  • 单位是米和秒,tick 是整数;墙上时钟只给累加器供能。
  • 先把弹跳球改成固定步,再比较积分器,否则实验本身在抖。

下一节把刚体从“每个对象一块内存”改成数组,让后面的求解器和 SIMD 有东西可扫。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U