《Cocos 游戏开发入门》1.2 游戏的心智模型:帧、时间、坐标系

游戏世界是离散采样的:一帧就是一次快照。本节把「一帧」的心智模型讲透——时间为什么是离散的、dt 从哪来、坐标系必须回答原点在哪与 y 轴朝哪、单位是什么这三个问题,并给出屏幕坐标(左上、y 向下)与世界坐标(中心、y 向上)的换算与一个可单测的往返实验。

1.2 游戏的心智模型:帧、时间、坐标系

环境搭好之后,很多人会直接去写「让方块动起来」的代码。但经验告诉我们,在写第一行动画之前,先把三个约定想清楚,能省掉后面每一章的返工。这三个约定是:时间怎么走、坐标从哪里量、y 轴朝哪边。

它们看起来像常识,实际上每一种都至少有两条互相冲突的「业界惯例」,而且浏览器、数学课本、引擎各选各的。你如果不在开头定死,就会在第 5 章处理点击偏移、第 7 章处理碰撞法线、第 11 章处理 UI 布局时,反复被同一类「方向反了」的 bug 咬。

本节是纯概念 + 少量可跑的验证代码,不给《方块冲刺》加玩法,但它决定了后面所有玩法代码长什么样。读完你应该能回答:为什么游戏里的时间是离散的、dt 为什么以秒为单位、屏幕坐标和世界坐标差在哪几步换算。

1.2.1 一帧就是一次采样

游戏画面不是连续的。你看到的「平滑动画」,其实是每秒几十张静止画面快速切换——这是电影留下的把戏,每秒 24 张就足以骗过人眼。

所以游戏世界运行在一串离散的时刻上,每个时刻叫一帧(frame)。引擎在每一帧做同一套事:读输入 → 更新状态 → 绘制画面。第 N 帧的世界状态,是从第 N-1 帧的状态推算出来的。用一句话概括:

世界不是「一直在动」,而是「每隔一小段时间被重算一次」。

这个视角一旦建立,很多问题就有了答案。比如「为什么快速移动的子弹会穿过墙」——因为碰撞只在每一帧检查一次,子弹在相邻两帧的位置之间可能已经越过了墙,两帧都检测不到。第 7 章会用连续碰撞检测处理它,但根因就在「一帧是一次采样」。

帧率(fps)就是每秒的采样次数。它同时决定了「每帧有多少毫秒可以花」,这个额度叫帧预算(frame budget):

目标帧率每帧预算典型设备
30fps33.3ms低端手机、省电模式
60fps16.7ms主流手机与显示器
120fps8.3ms高刷屏、ProMotion
144fps6.9ms电竞显示器

这张表的读法是:如果你这一帧的更新 + 绘制超过了预算,就会掉到下一档帧率。 60fps 掉到 30fps 的瞬间,帧时间从 16.7ms 跳到 33.3ms——如果代码把「每帧」当成固定时间单位,速度就会跟着抖一下。

但真实帧率从不恒定:后台标签页被降频、一次垃圾回收、一次纹理上传,都会让某一帧变长。把「每帧」当成固定时间单位,是新手第一个系统性错误。

1.2.2 dt:从时间戳到「这一步走了多久」

既然每帧间隔不固定,代码就不能假设「上一帧到现在刚好过了 1/60 秒」。我们需要在每帧开头,用真实时间戳算出这一帧实际经过了多久,这个量叫 dt(delta time)。

export interface FrameStamp {
  now: number; // 本帧的时间戳,毫秒
  dt: number;  // 距离上一帧经过的秒数
}

export function makeSampler(): (now: number) => FrameStamp {
  let last: number | null = null;
  return (now: number) => {
    if (last === null) {
      last = now;
      return { now, dt: 0 }; // 第一帧没有「上一帧」,dt 记为 0
    }
    const dt = (now - last) / 1000; // 毫秒差 → 秒
    last = now;
    return { now, dt };
  };
}

三条约定,和 4.1 节的口径一致,这里先立下:

  • 时间戳用 performance.now(),单位毫秒。 它单调递增,不会被系统校时影响。Date.now() 可能因 NTP 校时而回跳,导致 dt 为负、物理倒着走。
  • dt 的单位是秒。 物理公式里的「每秒多少」要用秒,所以时间戳的毫秒差要除以 1000。少除一次,重力就大一千倍。
  • 第一帧的 dt 是 0。 因为没有「上一帧」可减。这个特殊处理是后面固定步长循环里最容易漏的一步。

注意 dt 是浮点数,不是常量。60fps 时它约等于 0.0167,但可能是 0.016、0.021、0.014……正因为如此,速度类代码必须写成 x += v * dt,而不是 x += v。这是 1.3 节和整个第 4 章的主线。

1.2.3 坐标系要回答的三个问题

坐标系听起来抽象,其实只要回答三个问题,一套约定就定死了:

问题候选答案谁选了它
原点在哪?左上角 / 左下角 / 中心Canvas 左上、数学课本中心
y 轴朝哪?向下 / 向上屏幕向下、数学向上
单位是什么?CSS 像素 / 设备像素 / 世界单位浏览器、DPR、游戏世界

这三条一旦定错,症状是「东西画反了」「点偏了」「拉长了」。下面把两套最常用的坐标摊开讲:屏幕坐标(浏览器给的)和世界坐标(游戏逻辑用的)。

1.2.4 屏幕坐标:左上原点,y 向下

浏览器的 Canvas 沿用了印刷与图像处理的传统:原点在左上角,x 向右增大,y 向下增大。这跟你在数学课上画的坐标系正好上下颠倒。

// Canvas 的绘制调用就用这套坐标
const ctx = canvas.getContext('2d');
if (ctx) {
  ctx.fillStyle = '#4ea1ff';
  ctx.fillRect(0, 0, 32, 32);        // 左上角的一个方块
  ctx.fillRect(100, 50, 32, 32);     // 往右下移动 → x、y 都变大
}

除了方向,屏幕坐标还有一层坑:同一个像素位置,有「CSS 像素」和「设备像素」两种量法。CSS 像素是你在样式表里写的逻辑尺寸;设备像素是屏幕上真实的发光点。二者之差由 DPR(device pixel ratio)决定——Retina 屏上 DPR 通常是 2 或 3。

export function resizeCanvas(canvas: HTMLCanvasElement, cssW: number, cssH: number): number {
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.round(cssW * dpr);   // 位图分辨率
  canvas.height = Math.round(cssH * dpr);
  canvas.style.width = `${cssW}px`;        // 显示尺寸
  canvas.style.height = `${cssH}px`;
  return dpr;
}

位图尺寸 = CSS 尺寸 × DPR,显示尺寸 = CSS 尺寸。 忘掉后半句,画面会被放大成模糊的马赛克;忘掉前半句,在 Retina 上画面只有左上角四分之一。这两行是第 2.3 节分辨率适配的起点。

用一组具体数字感受一下差异。假设画布在页面里占 400 × 300 个 CSS 像素:

设备DPRcanvas.width × height显示尺寸若忘记乘 DPR
普通显示器1400 × 300400 × 300正常
MacBook Retina2800 × 600400 × 300模糊(被放大 2 倍)
部分安卓旗舰31200 × 900400 × 300更糊

所以「设备像素」和「CSS 像素」不是一回事。凡是和绘制有关的尺寸(canvas.width、相机视口、清屏区域)都用设备像素;凡是和布局有关的尺寸(元素位置、CSS 宽高)都用 CSS 像素。mini2d 内部统一用设备像素,把 CSS 像素只在入口处换算一次。

1.2.5 世界坐标:中心原点,y 向上

游戏逻辑内部不该直接用屏幕坐标。原因有三:

  1. 屏幕坐标的原点在左上角,让「角色在原点上方」这种描述变成一堆负号。
  2. 屏幕坐标绑死了像素,而游戏世界想要的是与分辨率无关的「世界单位」。
  3. 相机要能滚动——玩家往右跑,世界在屏幕上往左移,这个变换必须由相机统一做,而不是让每个对象自己减偏移。

所以 mini2d 采用和 Cocos、Unity 一致的约定:世界原点在视口中心,x 向右、y 向上,单位是「世界单位」。 y 向上是为了和数学课本一致——重力是 -g,抛物线开口向下,三角函数直接用标准定义,不用到处翻符号。

屏幕坐标和世界坐标之间隔着相机。相机有位置 (camX, camY) 和缩放 zoom,一次换算就是「先平移、再翻转 y、再缩放」:

export interface Vec2 { x: number; y: number; }
export interface Camera { x: number; y: number; zoom: number; }

// 世界坐标 → 屏幕坐标(设备像素,左上原点,y 向下)
export function worldToScreen(w: Vec2, cam: Camera, viewW: number, viewH: number): Vec2 {
  return {
    x: (w.x - cam.x) * cam.zoom + viewW / 2,
    y: (cam.y - w.y) * cam.zoom + viewH / 2, // 注意这里有个减法:y 被翻转了
  };
}

// 屏幕坐标 → 世界坐标:把上面的式子反解
export function screenToWorld(s: Vec2, cam: Camera, viewW: number, viewH: number): Vec2 {
  return {
    x: (s.x - viewW / 2) / cam.zoom + cam.x,
    y: cam.y - (s.y - viewH / 2) / cam.zoom,
  };
}

盯住 worldToScreen 第二行那个减法:(cam.y - w.y)。正是这个减号把「y 向上」的世界翻成了「y 向下」的屏幕。 世界里的高处(y 大)对应屏幕上的上方(y 小)。忘了它,你的角色就会头朝下。

1.2.6 单位、像素与视口

三套坐标各自的单位要记牢,混用是另一类高频 bug:

坐标空间原点y 方向单位典型来源
CSS 像素页面左上向下CSS px样式表、getBoundingClientRect
设备像素位图左上向下物理 pxcanvas.width/height
世界视口中心向上世界单位游戏逻辑内部

mini2d 里还有一个约定:1 世界单位默认对应 1 个设备像素(zoom = 1 时)。这不是必须的,但它让「世界单位」和「像素」在默认情况下数值一致,调试时不用心算。等第 2.3 节做分辨率适配时,我们会用 zoom 把「设计分辨率」映射到任意实际视口——那时世界单位才真正和像素解耦。

一个具体的换算是这样走的。假设鼠标事件给出 clientX = 410, clientY = 220,画布在页面左上角、没有滚动,视口 800×600:

// 1. 浏览器坐标 → 画布局部坐标(这里画布贴左上角,偏移为 0)
const local = { x: 410 - 0, y: 220 - 0 };
// 2. CSS 像素 → 设备像素
const dpr = 2;
const device = { x: local.x * dpr, y: local.y * dpr }; // (820, 440)
// 3. 设备像素 → 世界坐标(相机在原点、zoom=2、视口 800×600 设备像素)
const world = screenToWorld(device, { x: 0, y: 0, zoom: 2 }, 800, 600);
// → { x: 210, y: -70 }

手工验算第三步:x = (820 - 400) / 2 + 0 = 210;y = 0 - (440 - 300) / 2 = -70。世界里的 y 是负的,说明点在中心下方——和屏幕上「y 比中心大」一致。三步里任何一步漏掉,点击位置就会偏几十到几百像素。第 5.1 节会把这三步封装进 PointerRouter,这里先把它们显式摊开,好让你看清每一步在做什么。

1.2.7 一个可单测的往返实验

上面那段换算的正确性,最该用单测锁住的属性是往返一致:世界点 → 屏幕 → 世界,必须回到原点。这是第 16 章「确定性」思路的雏形——先证明变换可逆,再往上叠逻辑。

import { describe, expect, it } from 'vitest';
import { worldToScreen, screenToWorld } from './coord';

describe('坐标往返', () => {
  const cam = { x: 0, y: 0, zoom: 2 };
  const viewW = 800;
  const viewH = 600;

  it('世界 (10, 20) 落在屏幕 (420, 260)', () => {
    expect(worldToScreen({ x: 10, y: 20 }, cam, viewW, viewH)).toEqual({ x: 420, y: 260 });
  });

  it('屏幕点能精确还原回世界点', () => {
    const w = { x: 10, y: 20 };
    const s = worldToScreen(w, cam, viewW, viewH);
    const back = screenToWorld(s, cam, viewW, viewH);
    expect(back.x).toBeCloseTo(w.x);
    expect(back.y).toBeCloseTo(w.y);
  });
});

第一组数字手工验算:x = (10 - 0) × 2 + 400 = 420;y = (0 - 20) × 2 + 300 = 260。世界里的 y 是正的(在上方),屏幕里的 y 是 260(在中心 300 的上方,所以更小)——方向关系对上了。

这个测试还有一个隐藏价值:它固定了「相机在原点、zoom=2、视口 800×600」这组参数。第 5.1 节处理指针换算时,用的就是同一组数字,两次实现可以互相印证。

1.2.8 顺带看一眼 Cocos 3.8 的约定

Cocos Creator 3.8 的坐标约定和 mini2d 一致:世界原点在场景原点,y 轴向上,单位是「单位」而非像素。下面这段 L2 片段可以直接对上前面的心智模型(仅保证与 cc.d.ts 类型一致,不参与 mini2d 测试):

import { _decorator, Component, Vec3 } from 'cc';

const { ccclass } = _decorator;

@ccclass('OriginProbe')
export class OriginProbe extends Component {
  onLoad(): void {
    // 世界坐标:y 越大越靠上,与 mini2d 同向
    this.node.setPosition(new Vec3(120, 60, 0));
    const p = this.node.position;
    console.log(p.x, p.y); // 120 60
  }
}

几个对应关系:

  • y 向上:Vec3(0, 60, 0) 在原点上方,和 mini2d 一致,公式可以照搬。
  • 两套坐标:Cocos 里节点的 position 是相对父节点的局部坐标,worldPosition 是世界坐标;UI 节点(UITransform)另有以锚点为基准的坐标系。层次比 mini2d 多,但「局部 / 世界」的二分是同一个思想。
  • 像素换算:屏幕像素与设计分辨率的换算由引擎的 view 模块统一处理,你不用像 1.2.4 那样自己乘 DPR。

这里只顺带点一下对应关系。完整的 Cocos 对照在 1.3 节末——那里会讲引擎的 director 主循环与 Component.update 怎么替我们完成这一节的所有约定。

现在「时间怎么走、坐标怎么量」这两件事已经定死。剩下最后一个问题:怎么把「一帧一帧的采样」真正跑起来。 下一节我们写出 mini2d 的第一个 requestAnimationFrame 主循环,把 1.2.2 的 dt 采样接进去,并引入固定步长累加器——让逻辑更新与帧率彻底解耦,再用假时钟在 Node 里把它复现一遍。

阅读导航:上一节:1.1 环境与仓库骨架 · 下一节:1.3 第一个可跑循环:requestAnimationFrame 与固定步长 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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