《Cocos 游戏开发入门》4.1 帧率无关的移动与 deltaTime

本节从《方块冲刺》里一个真实 bug 出发:不乘 dt 的移动在不同帧率下速度完全不同。我们推导为什么乘 dt 就帧率无关、dt 从哪里来、为什么要钳制,分清先乘后加与先加后乘,踩一遍跳跃用 dt 的坑,最后给出 Cocos Creator 3.8 的对照写法。

4.1 帧率无关的移动与 deltaTime

在 1.3 节我们第一次用 requestAnimationFrame 拼出了一个能跑的循环,但那个循环里的方块是「每帧移动固定像素」的。在 60Hz 显示器上它看起来正常,换到 144Hz 电竞屏就会快得离谱。这一节要解决的,是《方块冲刺》里第一个真正会咬人的问题:速度必须和时间挂钩,而不是和帧数挂钩。

本节推进《方块冲刺》的核心运动学:给 mini2d 的 Game.step(dt) 引入正确的 deltaTime 语义。你会看到不乘 dt 会出什么 bug、为什么乘上就帧率无关、dt 从哪里来、要不要钳制,以及跳跃为什么是 dt 最容易被用错的地方。本章不写渲染,只关心「时间怎么流进物理」。

4.1.1 一个不乘 dt 的 bug

先看错误的写法。这是很多人写下的第一个「能跑」的版本:

// 错误:每帧加固定像素,与真实经过的时间无关
step(): void {
  this.player.x += 3;          // 3 像素 / 帧
  this.player.vy += -30;       // 每帧减 30 的速度
  this.player.y += this.player.vy;
}

在 60Hz 下它每秒移动 3 × 60 = 180 像素。问题在于这里的「3 像素」隐含了一个前提:每帧恰好经过 1/60 秒。一旦帧率变化,这个前提就崩了。

设备 / 帧率每秒移动像素与 60Hz 相比
60Hz 笔记本180基准
144Hz 高刷屏432快 2.4 倍
30Hz 省电模式90慢一半
掉帧到 20fps60慢 3 倍

同一份代码,在四台设备上跑出四种手感。更糟的是,帧率不是恒定的——后台标签页被浏览器降频、某一帧里恰好做了一次垃圾回收,都会让「这一帧」的时长突然变化,于是速度也跟着抖。

这类 bug 最阴险的地方在于:它在开发机上永远复现不了。你的机器稳定 60fps,测试同事的中端安卓机在 30fps 上下浮动,玩家说「怎么跑着跑着突然慢一下」,而你在本地怎么也看不出来。

4.1.2 为什么乘 dt 就帧率无关

修法只有一处:把「每帧多少」改成「每秒多少」,再乘上这一帧实际经过的秒数。

// 正确:速度单位是 像素/秒,乘上本帧经过的秒数
step(dt: number): void {
  this.player.x += MOVE_SPEED * dt;      // MOVE_SPEED = 260 px/s
  this.player.vy += GRAVITY * dt;        // GRAVITY = -1800 px/s²
  this.player.y += this.player.vy * dt;
}

为什么这样就对了?因为位移在数学上就是速度对时间的积分:Δx = ∫ v dt ≈ v · dt。当 dt 足够小时,积分可以近似成一次乘法。无论一帧是 16.7ms 还是 6.9ms,乘出来的位移都对应真实经过的时间,累计起来就是同一条轨迹。

用 1 秒的移动验证一遍,两种帧率下总位移应该几乎相等:

帧率单帧 dt每帧位移(260 px/s)1 秒累计
60fps0.01667s4.333 px260 px
120fps0.00833s2.167 px260 px
30fps0.03333s8.667 px260 px

差别只在浮点误差里。这就是「帧率无关」的全部含义:结果由真实时间决定,与切成多少帧无关。

要注意,帧率无关不等于「绝对精确」。把 1 秒切成 30 份和切成 120 份,用一次乘法近似积分,误差量级是 O(dt²)。对跑酷这种游戏无所谓;对需要长期守恒的物理(比如轨道模拟)就要用更高阶的积分器——那是 4.2 节固定步长要缓解的问题之一。

4.1.3 dt 从哪里来

dt 不是凭空来的,它来自两个真实时间戳的差:

let last: number | null = null;

function frame(now: number): void {
  if (last === null) {
    last = now;                 // 第一帧只对齐基准,不步进
    requestAnimationFrame(frame);
    return;
  }
  const dt = (now - last) / 1000;   // 毫秒差 → 秒
  last = now;
  game.step(dt);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

三个要点:

  • 用 performance.now(),不要用 Date.now()。performance.now() 返回单调递增的高精度毫秒时间戳;Date.now() 会被系统时钟调整(NTP 校时、用户改时间)影响,可能出现负的 dt,让物理倒着走。
  • 单位换算要显式。浏览器给毫秒,物理公式要秒。少除一个 1000,重力就会大一千倍,方块瞬间飞出屏幕。
  • 第一帧要特殊处理。last 还没初始化时,now - last 是垃圾值。上面的写法里第一次回调先把 last 设好再 return,这是最容易漏的一步。

4.1.4 为什么要钳制 dt

即使 dt 来路正确,也不能无脑用。考虑这些真实场景:

  • 玩家切到别的标签页,游戏被浏览器降频甚至暂停,回来时 now - last 可能是 8000ms。
  • 断点调试时你在 step() 里停了 30 秒。
  • 手机息屏再点亮,WebView 恢复。

如果直接把这 8 秒喂给物理,方块会在一次 step 里瞬移 8 秒的位移,穿过沿途所有平台,俗称「穿墙」。所以真实引擎都会钳制单帧 dt 的上限:

const MAX_DT = 1 / 15; // 单帧最多按 66ms 算
const raw = (now - last) / 1000;
const dt = Math.min(raw, MAX_DT);

钳制之后,卡顿会被「慢动作」化——游戏时间比真实时间走得慢——但物理不会爆。这正是 4.2 节固定步长累加器要解决的同一类问题,只不过那里做得更彻底:不是简单截断,而是把多余的时间攒起来慢慢补。

4.1.5 先乘后加,还是先加后乘

x += v * dt 这一行里其实藏了两个动作:先算 v * dt(乘),再加到 x(加)。但真正容易写错的是速度更新和位置更新这两行谁先谁后:

// 变体 A:先用旧速度更新位置,再更新速度(显式欧拉)
p.x += p.vx * dt;
p.vx += GRAVITY * dt;

// 变体 B:先更新速度,再用新速度更新位置(半隐式欧拉)
p.vx += GRAVITY * dt;
p.x += p.vx * dt;

两种写法在一帧内的差别只有 GRAVITY * dt²,看起来微不足道,但它是系统性的:变体 A 会让每次落地后系统累积一点点多余能量,方块在平台上轻微抖动;变体 B(半隐式欧拉)则稳定得多,几乎所有游戏引擎都用它。

mini2d 的 Game.step 用的就是变体 B——先算 vy += GRAVITY * dt,再用新的 vy 更新 y。记住这条顺序,能省掉后面第 7 章碰撞响应里一大半的「奇怪抖动」排查时间。

4.1.6 跳跃为什么是 dt 最容易被用错的地方

跳跃的坑和「是否乘 dt」相反——它的问题是乘法被用在了不该用的地方。

瞬时速度的正确写法是直接赋值:

if (jumpPressed) {
  p.vy = JUMP_VELOCITY;   // 720 px/s,设置的是速度,不乘 dt
  p.onGround = false;
}

但如果有人想「跳得高一点,那就乘 dt」,就会写成这样:

// 错误:把位移公式错套到瞬时速度上
p.vy = JUMP_VELOCITY * dt;   // dt ≈ 0.0167,跳跃高度几乎为 0

瞬时速度不是「这一帧积累的量」,它没有「每秒」以外的隐含时间。乘上 dt 等于把它缩小了 60 倍。判断规则很简单:只有持续作用的量(速度、加速度、角速度)才乘 dt;一次性事件(起跳、开火、开关切换)不乘。

还有第二个坑:起跳输入必须在同一帧被消费。如果 jump 是个「按住就一直为真」的布尔量,玩家落地后只要还按着键就会立刻再跳一次,变成连跳。mini2d 的做法是在 step 里用 onGround 守住起跳条件,同时由输入层保证 jump 是边沿触发(本帧刚按下为真):

if (this.input.jump && p.onGround) {
  p.vy = JUMP_VELOCITY;
  p.onGround = false;
}
// 输入层每帧结束后复位,下一帧要重新按下才算数
this.input.jump = false;

边沿触发、输入缓冲这些手感细节留到第 5 章展开,这里先记住:dt 只属于连续量。

4.1.7 一个可复现的对照实验

口说无凭,用假时钟做一次对照实验:同一段 1 秒的真实时间,分别用「每帧 3 像素」和「260 px/s × dt」推进,看方块走了多远。

// 实验一:不乘 dt(每帧固定像素)
function fixedPerFrame(frameCount: number): number {
  let x = 0;
  for (let i = 0; i < frameCount; i += 1) x += 3;
  return x;
}

// 实验二:乘 dt(像素/秒)
function scaledByDt(frames: number[], speed: number): number {
  let x = 0;
  for (const dt of frames) x += speed * dt;
  return x;
}

const fps60 = Array.from({ length: 60 }, () => 1 / 60);
const fps144 = Array.from({ length: 144 }, () => 1 / 144);

console.log(fixedPerFrame(60));          // 180
console.log(fixedPerFrame(144));         // 432  ← 同一秒,快 2.4 倍
console.log(scaledByDt(fps60, 260));     // 260
console.log(scaledByDt(fps144, 260));    // 260  ← 帧率无关

结果一目了然:

实验60fps 结果144fps 结果是否帧率无关
每帧 3 像素180432否
260 px/s × dt260260是

这个实验还可以换一组「忽大忽小」的帧序列,比如 [0.016, 0.040, 0.012, 0.033, ...](总和恰好 1 秒),scaledByDt 依然得到 260。它验证的正是 4.1.2 的结论:总位移只取决于真实时间总量,与怎么切分无关。

不过要小心一个常见的误解:帧率无关不等于确定性。上面实验二在 60fps 和 144fps 下结果相同,是因为它是纯粹的线性积分;一旦引入碰撞、摩擦、非线性阻尼,切分方式就会影响结果——1 秒切成 60 份和切成 144 份,碰撞发生的时刻不同,后续轨迹就可能分叉。要真正做到「同一份输入永远得到同一份结果」,需要 4.2 节的固定步长把切分方式也固定下来。这也是第 16 章确定性回放的地基。

4.1.8 Cocos Creator 3.8 对照:dt 从哪来

在 Cocos 3.8 里,你几乎不会自己算 dt。Component 的 update(dt) 由引擎每帧回调,dt 已经是以秒为单位的上一帧增量:

import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;

@ccclass('PlayerMove')
export class PlayerMove extends Component {
  private speed = 260; // px/s

  protected update(dt: number): void {
    const pos = this.node.position;
    this.node.setPosition(pos.x + this.speed * dt, pos.y, pos.z);
  }
}

几个要点:

  • update(dt) 的 dt 单位是秒(cc.d.ts 里明确写 “the delta time in seconds”),和 mini2d 的 step(dt) 口径一致,公式可以原样搬过来。
  • 想让「整帧逻辑都跑完再对齐表现」用 lateUpdate(dt):相机跟随、渲染插值放这里。
  • 引擎级的 dt 从 game.deltaTime(秒)拿。director.getDeltaTime() 同样能拿到,但 cc.d.ts 标了 @deprecated since v3.3.0, please use game.deltaTime instead,新代码不要用。
  • game.totalTime 是毫秒口径,别和秒混用;要秒就自己除以 1000。

下面这张表把两边的对应关系列清楚:

用途mini2dCocos Creator 3.8
每帧逻辑Game.step(dt)Component.update(dt)
帧末对齐表现手动在 step 末尾Component.lateUpdate(dt)
取上一帧增量自己 now - lastgame.deltaTime(秒)
取总时长自己累加game.totalTime(毫秒)

本节给出的 Cocos 代码是 L2 片段,只保证与 cc.d.ts(Cocos Creator 3.8.8)类型一致,不参与 mini2d 的 vitest 测试。

到这里,我们解决了「单帧 dt 怎么用」。但帧率本身是会变的——有的帧 16ms,有的帧 40ms。下一节把乘 dt 的直觉推广到整套固定步长模拟:用累加器把可变时间切成固定步长,再用渲染插值把画面抹平,让物理稳定与画面顺滑同时成立。

阅读导航:上一节:目录 · 下一节:4.2 固定步长物理与渲染插值 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画