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 精灵、纹理与图集切分 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。