《Cocos 游戏开发入门》2.1 Canvas2D 绘制与坐标变换

本节从浏览器原生 fillRect 起步,讲清 Canvas2D 的 y 轴向下坐标系这个新手第一坑;随后手写 2×3 仿射矩阵,用纯 TypeScript 实现平移、旋转、缩放与矩阵乘法,解释变换顺序为何不可交换,并深入 ctx.save/restore 的状态栈;最后把变换接进 mini2d 的节点渲染,并给出 Cocos Creator 3.8 中 Node 变换的对照写法。

2.1 Canvas2D 绘制与坐标变换

第 1 章我们跑通了第一个循环:requestAnimationFrame 每帧调用一次 update 与 render,但 render 里还只有一句 ctx.clearRect(...)。屏幕是干净的,也是空的。这一章要往画布上真正画出东西——《方块冲刺》的主角、平台、金币,都得先能「出现在正确的像素位置」,才谈得上跑酷。

本节把 mini2d 推进到「能画出第一个方块」:理解 Canvas2D 的坐标系,手写一个 2×3 仿射矩阵,实现平移、旋转、缩放三个基本变换,并搭好 ctx.save/restore 的变换栈。这是后面场景图、批处理、相机全部的地基——所有 2D 渲染最终都归结为「算出一个矩阵,把局部坐标映射到屏幕坐标」。

2.1.1 从 fillRect 起步

最朴素的绘制就一个调用:

ctx.fillStyle = '#4c9aff';
ctx.fillRect(100, 80, 64, 64);

fillRect(x, y, w, h) 以 (x, y) 为左上角画一个宽 w、高 h 的实心矩形。看起来毫无难度,但这里已经埋了两个坑。

第一,y 轴向下。 Canvas2D 的坐标原点在画布左上角,x 向右为正,y 向下为正。你在数学课上学的是 y 向上,于是直觉上「往上移动」应该减小 y 才对——但在 Canvas 里,往上移动是减小 y,往下才是增大 y。这个方向差异会在相机、物理、输入三处反复咬人。我们在 mini2d 里做一个决定:世界坐标用 y 向上(与 Cocos 一致,也和数学直觉一致),只在最后映射到屏幕时翻一次 y。这样上层逻辑不用迁就 Canvas。

第二,原点不在中心。 (0, 0) 是左上角而不是屏幕中心。屏幕中心是 (width / 2, height / 2)。如果每个对象都手动加偏移,代码会充满 + canvas.width / 2。这正是变换矩阵要解决的问题:把「以屏幕中心为原点、y 向上」这套更自然的坐标系,一次性变换到 Canvas 的像素坐标系。

2.1.2 为什么需要变换矩阵

一个对象在游戏里通常带着三种信息:位置、旋转、缩放。我们可以每次绘制时手算坐标,但一旦对象有父节点(主角站在移动的平台上),手算就会爆炸——子节点的世界位置取决于父节点的完整变换。

矩阵把这件事变成乘法:把父变换和子变换乘起来,得到子节点的世界变换。这就是场景图(第 3 章)的数学基础。2D 里我们只需要 2×3 仿射矩阵,它足以表达平移、旋转、缩放、错切,以及它们的任意组合。

仿射矩阵在齐次坐标下是 3×3:

[ a   c   tx ]
[ b   d   ty ]
[ 0   0   1  ]

最后一行恒为 [0 0 1],所以实际只存 6 个数:a b c d tx ty。对点 (x, y) 的变换是:

x' = a * x + c * y + tx
y' = b * x + d * y + ty

a/d 管缩放,a b c d 一起管旋转与错切,tx/ty 管平移。浏览器原生 Canvas 的 setTransform(a, b, c, d, e, f) 用的正是这同一套 6 参数约定——我们只要算出这 6 个数,就能交给 Canvas 去画。

2.1.3 手写 2×3 仿射矩阵

新建 src/math/mat2d.ts,写一个零依赖的 Mat2D:

// src/math/mat2d.ts
export class Mat2D {
  a = 1;
  b = 0;
  c = 0;
  d = 1;
  tx = 0;
  ty = 0;

  clone(): Mat2D {
    const m = new Mat2D();
    m.a = this.a;
    m.b = this.b;
    m.c = this.c;
    m.d = this.d;
    m.tx = this.tx;
    m.ty = this.ty;
    return m;
  }

  identity(): this {
    this.a = 1;
    this.b = 0;
    this.c = 0;
    this.d = 1;
    this.tx = 0;
    this.ty = 0;
    return this;
  }

  apply(x: number, y: number): { x: number; y: number } {
    return {
      x: this.a * x + this.c * y + this.tx,
      y: this.b * x + this.d * y + this.ty,
    };
  }
}

clone 返回一份独立副本,identity 复位为单位矩阵,apply 把一个点按矩阵映射。注意 apply 里我们先乘 a/c 再乘 b/d,与上面公式一致;写反了会得到镜像后的结果,而且往往要等到旋转时才暴露。

2.1.4 平移、旋转、缩放

三个基本变换都是「在当前矩阵上再叠一层」,也就是右乘一个基本矩阵:

  translate(x: number, y: number): this {
    this.tx += this.a * x + this.c * y;
    this.ty += this.b * x + this.d * y;
    return this;
  }

  scale(sx: number, sy: number): this {
    this.a *= sx;
    this.b *= sx;
    this.c *= sy;
    this.d *= sy;
    return this;
  }

  rotate(rad: number): this {
    const cos = Math.cos(rad);
    const sin = Math.sin(rad);
    const { a, b, c, d } = this;
    this.a = a * cos + c * sin;
    this.b = b * cos + d * sin;
    this.c = a * -sin + c * cos;
    this.d = b * -sin + d * cos;
    return this;
  }

translate 为什么不是简单地 tx += x?因为当前矩阵可能已经带旋转或缩放,此时「沿本地 x 轴前进 x」在父坐标系里是一条斜线。用 a * x + c * y 计算,才把方向正确地考虑进去。这是手写矩阵最容易写错的地方:平移必须被当前的线性部分变换。

rotate 里 cos/sin 的符号也要小心。我们的约定是顺时针为正(因为 y 向下时,数学上的正角看起来是顺时针)。a = cos, c = -sin, b = sin, d = cos,代入 apply 后对点 (1, 0) 得到 (cos, sin),在 y 向下的屏幕上是顺时针转,符合预期。

2.1.5 变换顺序不可交换

矩阵乘法不满足交换律,这不是抽象数学,而是你每天都会碰到的渲染行为。看一个最小例子:先平移再旋转,和先旋转再平移,结果完全不同。

// tests/mat2d.test.ts
import { describe, it, expect } from 'vitest';
import { Mat2D } from '../src/math/mat2d';

describe('Mat2D', () => {
  it('平移与旋转不可交换', () => {
    const p = { x: 1, y: 0 };
    const translateThenRotate = new Mat2D().translate(10, 0).rotate(Math.PI / 2);
    const rotateThenTranslate = new Mat2D().rotate(Math.PI / 2).translate(10, 0);

    const a = translateThenRotate.apply(p.x, p.y);
    const b = rotateThenTranslate.apply(p.x, p.y);

    expect(a.x).not.toBeCloseTo(b.x);
    expect(a.y).not.toBeCloseTo(b.y);
  });
});

把 p = (1, 0) 代进去手算:第一条链得到 (10, 1)——点先绕原点转 90° 到 (0, 1),再整体右移 10。第二条链得到 (0, 11)——点先右移 10 到 (11, 0),再绕原点转 90°。差别来自「旋转中心」:前者绕对象自己的原点转,后者绕世界原点转。游戏里绝大多数时候你要的是前者,所以平移在前、旋转在后是更常见的书写顺序。

一句话记住:矩阵链从右往左作用到点上,最右边的最先执行;但从左往右读代码,越靠左的变换越「靠外」(作用于已经变换好的整体)。

2.1.6 ctx.save / restore 与变换栈

浏览器把当前变换存在一个栈上。ctx.save() 压栈(连同变换、裁剪区、样式一起),ctx.restore() 弹栈。它解决的是「临时改一下画完再还原」的需求:

ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.fillRect(-w / 2, -h / 2, w, h);
ctx.restore();

save/restore 必须成对。漏掉 restore,下一次绘制就会带着上一次的旋转和偏移,表现为「所有东西越画越歪」。反过来,多写一个 restore 会把更外层的状态也弹掉,症状更隐蔽。mini2d 里我们用一个显式计数在开发模式下断言栈平衡,把这个 bug 挡在早期。

不过 save/restore 也有成本:每次调用都要拷贝一份完整的绘制状态。一帧里几千个对象各存各弹,开销不可忽略。更好的做法是自己算好矩阵,用 setTransform 一次性设置,把栈留给真正需要的嵌套结构(比如遮罩)。这正是我们在 2.1.7 要做的。

2.1.7 把变换接进 mini2d

有了 Mat2D,渲染器只要把 6 个数交给 Canvas 即可:

// src/render/canvas-renderer.ts
import { Mat2D } from '../math/mat2d';

export class CanvasRenderer {
  ctx: CanvasRenderingContext2D;

  constructor(ctx: CanvasRenderingContext2D) {
    this.ctx = ctx;
  }

  drawQuad(m: Mat2D, w: number, h: number, color: string): void {
    this.ctx.setTransform(m.a, m.b, m.c, m.d, m.tx, m.ty);
    this.ctx.fillStyle = color;
    this.ctx.fillRect(-w / 2, -h / 2, w, h);
  }
}

注意 fillRect 用的是 -w/2, -h/2:这样矩形的中心就落在变换的原点上,旋转、缩放都绕中心进行,符合直觉。这就是「锚点」(pivot)思想的雏形,2.2 会把它一般化。

跑起来验证一下:给主角一个 Mat2D,每帧 translate(pos.x, pos.y).rotate(angle),就能看到一个绕自身中心旋转的方块。矩阵一旦立起来,后面加相机、加父节点都只是在这个乘法链上再接一段。

2.1.8 Cocos 对照:Node 的变换

在 Cocos Creator 3.8 里,你不写矩阵,而是操作 Node 的 position、angle、scale 三个属性,引擎在渲染前把它们合成矩阵。对照 mini2d 的写法:

// assets/scripts/BlockMover.ts
import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('BlockMover')
export class BlockMover extends Component {
  @property
  speed = 120;

  update(dt: number) {
    const p = this.node.position;
    this.node.setPosition(p.x + this.speed * dt, p.y, p.z);
    this.node.angle = 45;
    this.node.setScale(1.5, 1.5, 1);
  }
}

几个关键对应:

  • node.position 是只读的 Vec3,不能原地改,必须用 node.setPosition(x, y, z) 重新赋值;直接 node.position.x += 1 不会生效。
  • node.angle 是 2D 旋转角,单位是度且顺时针为正,与 Canvas 的弧度制、顺时针为正正好对应。要弧度就用 node.setRotationFromEuler(0, 0, rad * 180 / Math.PI)。
  • node.scale 同样是只读 Vec3,赋值要用 node.setScale(...)。
  • 每个 Node 都带位置、旋转、缩放,父节点的变换自动作用到子节点——这就是我们手写的「父矩阵 × 子矩阵」。

一句话总结差异:mini2d 里矩阵是显式的、由你自己维护;Cocos 里矩阵是隐式的、由 Node 内部合成,你只声明局部变换。理解了后者不过是前者的一层封装,你在排查「为什么子节点位置不对」时就知道该往父节点的变换链上找。

下一节我们会把「画一个方块」升级成「画一张贴图」,讲 drawImage 的九参形式、图集切分和锚点,让《方块冲刺》的主角真正有脸。

阅读导航:上一节:目录 · 下一节:2.2 精灵、纹理与图集切分 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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