站内的《游戏引擎实现原理》 四卷 180 篇,走的是从零实现渲染引擎内核这条路——图形管线、ECS、资源系统,一路讲到 WebGPU 的命令编码与多线程。本章走的是另一条路:不造引擎,而是在既有引擎之上把一款小游戏跑顺,只谈三件工程上的事——无 DOM 的渲染环境、多分辨率与安全区、渲染性能与合批。管线内核请去看那 180 篇,本节只解决「小游戏跑在一个没有 DOM 的宿主里,第一行渲染代码该怎么写」。
小游戏没有 DOM
先把这个约束说清楚。小游戏(微信 / 抖音小游戏、以及不少 H5 平台)的 JS 代码运行在一个受限宿主里:没有完整的 document / window,没有 DOM 元素,也就没有 CSS 布局。你拿不到 getBoundingClientRect(),不能用 flex 排版,更不能塞一个 <div> 进去。
环境给你的只有三样东西:
- 一块画布:一个 canvas 对象,能拿到 2D 或 WebGL context。它是唯一的渲染出口。
- 一个屏幕尺寸:系统告诉你当前分辨率与安全区(下一节展开)。
- 一套输入回调:触摸 / 点击来自平台 API,不是 DOM 事件(第 6 章展开)。
结论很直接:渲染的起点是「一块画布 + 一个 context」,之后的一切——布局、命中、刷新——都得自己画。这也是为什么小游戏渲染层必须先把绘制抽象成最小、可测试、不依赖引擎的一层,否则逻辑和平台细节会糊在一起。
把绘制收成三类指令
再复杂的一帧,拆到底也不过三类指令:清屏、画矩形、画文本。把这三类抽象成一个 DrawTarget 接口,宿主各自去实现——Node 里实现成一块整数像素缓冲(无 DOM 环境里的「假画布」),浏览器或平台里实现成真实的 Canvas 2D:
- 同一份
drawScene只管发指令,不关心宿主是谁; - 在
node --test里,指令落在像素缓冲上,可以逐像素断言; - 在浏览器里,同样的指令落在真实 Canvas 上,可以肉眼验收。
这就是「一份逻辑、两种宿主」在本章渲染层的具体形态,也是第 10 章那套平台抽象层的缩影。
D 层:把场景写成配置
下面这份 D 层配置落在 config/e03_render.json,定义了一帧要画的东西:一块背景色、一板方块、一个分数。颜色用 [r,g,b] 整数,坐标与尺寸一律整数像素——配置里不出现任何浮点,因为同一份数字要被 Node 内核和浏览器原型两处消费。
{
"version": 1,
"game": "merge-island",
"note": "无 DOM 渲染环境:一块 W×H 整数像素缓冲 + 清屏/矩形/文本三类绘制指令。颜色用 [r,g,b] 整数,坐标与尺寸一律整数像素,禁浮点。同一份 drawScene 既跑 Node 像素缓冲(P 层测试),又跑浏览器 Canvas(W 层原型)。",
"width": 240,
"height": 160,
"scale": 2,
"bgColor": [22, 26, 38],
"fgColor": [226, 232, 240],
"tileColors": [[56, 130, 246], [34, 197, 94], [245, 158, 11], [244, 63, 94]],
"board": { "cols": 4, "rows": 3, "cell": 40, "gap": 6, "originX": 12, "originY": 44 },
"tiles": [0, 1, 2, 3, 1, 0, 3, 2, 1, 3, 0, 2],
"score": 12480
}
board 描述棋盘格:4 列 3 行、格宽 40 px、间隙 6 px、左上角在 (12,44)。tiles 是 12 个方块的色号,score 是右上角要显示的分数。
P 层:最小绘制内核
下面这段是 P 层内核 core/src/e03/renderer.ts。三个设计点值得单独说:
- 文本用 7 段数码管拼:不引入任何字体文件,每个字符用 7 个矩形段表示,整数缩放,能画
0-9A-F和-,正好够 HUD 的分数与关卡号。 - 裁剪在像素缓冲里做:
fillRect对越界坐标逐像素跳过,所以画到屏幕外的部分被安静裁掉,不会越界写内存。 - 指纹用 FNV-1a:把整块缓冲压成一个 32 位整数,用来跨宿主比对「画的是不是同一幅」。
// file: core/src/e03/renderer.ts
// tier: P
// verify: node --test 'core/test/e03_*.test.ts'
// 无 DOM 渲染环境的最小 2D 绘制抽象:一块整数像素缓冲 + 清屏/矩形/文本三类指令。
// 不依赖任何引擎;同一份 drawScene 既跑 Node 像素缓冲,也跑浏览器 Canvas。
export interface Color { readonly r: number; readonly g: number; readonly b: number }
export interface DrawTarget { clear(c: Color): void; fillRect(x: number, y: number, w: number, h: number, c: Color): void }
export interface Surface extends DrawTarget { readonly width: number; readonly height: number; readonly data: Uint8ClampedArray }
export interface RenderConfig {
readonly width: number; readonly height: number; readonly scale: number;
readonly bgColor: number[]; readonly fgColor: number[]; readonly tileColors: number[][];
readonly board: { readonly cols: number; readonly rows: number; readonly cell: number; readonly gap: number; readonly originX: number; readonly originY: number };
readonly tiles: number[]; readonly score: number;
}
export function rgb(c: number[]): Color { return { r: c[0], g: c[1], b: c[2] }; }
// 7 段数码管:单位坐标 5×7 字符盒,每段一个矩形,整数缩放;支持 0-9A-F、'-'、空格。
const SEG: number[][] = [
[1, 0, 3, 1], [4, 1, 1, 2], [4, 4, 1, 2], [1, 6, 3, 1], [0, 4, 1, 2], [0, 1, 1, 2], [1, 3, 3, 1], // a b c d e f g
];
const GLYPH: Record<string, number> = {
"0": 63, "1": 6, "2": 91, "3": 79, "4": 102, "5": 109, "6": 125, "7": 7,
"8": 127, "9": 111, "A": 119, "b": 124, "C": 57, "d": 94, "E": 121, "F": 113, "-": 64, " ": 0,
};
export const GLYPH_ADVANCE = 6;
export function textWidth(text: string, scale: number): number { return text.length === 0 ? 0 : (text.length * GLYPH_ADVANCE - 1) * scale; }
export function drawText(t: DrawTarget, x: number, y: number, text: string, c: Color, scale: number): number {
for (let i = 0; i < text.length; i += 1) {
const mask = GLYPH[text[i]] ?? 127; // 未收录字符画满格
for (let s = 0; s < 7; s += 1) {
if ((mask & (1 << s)) === 0) continue;
const g = SEG[s];
t.fillRect(x + (i * GLYPH_ADVANCE + g[0]) * scale, y + g[1] * scale, g[2] * scale, g[3] * scale, c);
}
}
return textWidth(text, scale);
}
// 整数像素缓冲宿主:无 DOM 环境里的「假画布」,坐标裁剪到边界。
export function createTarget(width: number, height: number): Surface {
const data = new Uint8ClampedArray(width * height * 4);
return {
width, height, data,
clear(c: Color): void { for (let i = 0; i < data.length; i += 4) { data[i] = c.r; data[i + 1] = c.g; data[i + 2] = c.b; data[i + 3] = 255; } },
fillRect(x: number, y: number, w: number, h: number, c: Color): void {
for (let yy = y; yy < y + h; yy += 1) {
if (yy < 0 || yy >= height) continue;
for (let xx = x; xx < x + w; xx += 1) {
if (xx < 0 || xx >= width) continue;
const p = (yy * width + xx) * 4;
data[p] = c.r; data[p + 1] = c.g; data[p + 2] = c.b; data[p + 3] = 255;
}
}
},
};
}
export function countPainted(t: Surface, bg: Color): number {
let n = 0;
for (let i = 0; i < t.data.length; i += 4) if (t.data[i] !== bg.r || t.data[i + 1] !== bg.g || t.data[i + 2] !== bg.b) n += 1;
return n;
}
// FNV-1a:把整块缓冲压成 32 位指纹,用来跨宿主比对「画的是不是同一幅」。
export function hashSurface(t: Surface): number {
let h = 2166136261;
for (let i = 0; i < t.data.length; i += 1) h = Math.imul(h ^ t.data[i], 16777619) >>> 0;
return h;
}
// 场景:清屏 → 铺一板方块 → 顶上写分数。只发绘制指令,不关心宿主是谁。
export function drawScene(t: DrawTarget, cfg: RenderConfig): void {
t.clear(rgb(cfg.bgColor));
const b = cfg.board;
for (let i = 0; i < cfg.tiles.length; i += 1) {
const col = i % b.cols, row = Math.floor(i / b.cols);
t.fillRect(b.originX + col * (b.cell + b.gap), b.originY + row * (b.cell + b.gap), b.cell, b.cell, rgb(cfg.tileColors[cfg.tiles[i]]));
}
drawText(t, b.originX, 12, String(cfg.score), rgb(cfg.fgColor), cfg.scale);
}
P 层:把不变量写成断言。 清屏的 alpha、矩形的越界裁剪、7 段文本的像素几何、场景绘制的确定性——这些都要被 node --test 钉住。
// file: core/test/e03_renderer.test.ts
// tier: P
// verify: node --test 'core/test/e03_*.test.ts'
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { createTarget, countPainted, hashSurface, drawScene, drawText, textWidth, rgb } from "../src/e03/renderer.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(readFileSync(join(HERE, "..", "..", "config", "e03_render.json"), "utf-8"));
const BG = rgb(cfg.bgColor), FG = rgb(cfg.fgColor);
test("清屏填满背景色,alpha 全 255,无残留像素", () => {
const t = createTarget(8, 6);
t.clear(BG);
assert.equal(t.data.length, 8 * 6 * 4);
assert.equal(countPainted(t, BG), 0);
for (let i = 3; i < t.data.length; i += 4) assert.equal(t.data[i], 255);
assert.deepEqual([t.data[0], t.data[1], t.data[2]], [BG.r, BG.g, BG.b]);
});
test("矩形裁剪:越界部分被裁掉,只统计缓冲内的整数像素", () => {
const t = createTarget(10, 10);
t.clear(BG);
t.fillRect(-3, -3, 5, 5, FG);
assert.equal(countPainted(t, BG), 4);
t.fillRect(8, 8, 5, 5, FG);
assert.equal(countPainted(t, BG), 8);
});
test("7 段文本几何可预测、宽度按整数步进", () => {
const t = createTarget(40, 20);
t.clear(BG);
drawText(t, 0, 0, "1", FG, 2);
assert.equal(countPainted(t, BG), 16); // '1' 两段各 2×4 px
assert.equal(textWidth("12", 2), 22);
assert.equal(textWidth("", 2), 0);
const t2 = createTarget(40, 20);
t2.clear(BG);
drawText(t2, 0, 0, "8", FG, 1); // 全 7 段共 17 单位像素
assert.equal(countPainted(t2, BG), 17);
});
test("场景绘制确定性:同配置两次指纹逐位一致且真的画了东西", () => {
const a = createTarget(cfg.width, cfg.height), b = createTarget(cfg.width, cfg.height);
drawScene(a, cfg);
drawScene(b, cfg);
assert.equal(hashSurface(a), hashSurface(b));
assert.ok(countPainted(a, BG) > 1000);
});
test("逐像素不变量:方块内部是纯色且与背景可区分", () => {
const t = createTarget(cfg.width, cfg.height);
drawScene(t, cfg);
const b = cfg.board, c0 = rgb(cfg.tileColors[cfg.tiles[0]]);
const p = ((b.originY + 1) * cfg.width + (b.originX + 1)) * 4;
assert.deepEqual([t.data[p], t.data[p + 1], t.data[p + 2]], [c0.r, c0.g, c0.b]);
assert.notDeepEqual([c0.r, c0.g, c0.b], [BG.r, BG.g, BG.b]);
});
跑 node --test 'core/test/e03_*.test.ts',渲染内核这 5 个用例全过。关键的两个数字:"1" 在 scale=2 下正好 16 px(两段各 2×4),"8" 在 scale=1 下正好 17 px(7 段合计)——几何可预测,才有资格做断言。
W 层:两个宿主,同一幅画
下面这段是 W 层构建脚本 web/build_e03.mjs。它复用 web/pack.mjs 的擦除器把内核内联进单文件 HTML,然后让同一份 drawScene 跑两次:一次落在 Node 像素缓冲,一次落在真实 Canvas,把两边的非背景像素数摆在一起比对。
#!/usr/bin/env node
// file: web/build_e03.mjs
// tier: W
// verify: node web/build_e03.mjs
// 把 core/src/e03/renderer.ts 的无 DOM 绘制内核打成单文件 HTML 原型。产物 web/dist/e03.html。
// 页面上同一份 drawScene 跑两次:一次落在 Node 像素缓冲,一次落在真实 Canvas。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const config = JSON.parse(readFileSync(join(ROOT, "config", "e03_render.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e03/renderer.ts"]);
const script = `const cfg = ${inlineConfig(config)};
/* ---- core bundle: TS -> strip -> inline ---- */
${js}
/* ---- end bundle ---- */
// 宿主 A:纯 JS 像素缓冲(Node 里 P 层测试用的同一个 createTarget)。
const pixel = createTarget(cfg.width, cfg.height);
drawScene(pixel, cfg);
const nodePainted = countPainted(pixel, rgb(cfg.bgColor)), nodeHash = hashSurface(pixel);
// 宿主 B:真实 Canvas 2D,用同样的 drawScene 发同样一组绘制指令。
const cv = document.getElementById("scene"), ctx = cv.getContext("2d");
const canvasTarget = {
clear(c) { ctx.fillStyle = "rgb(" + c.r + "," + c.g + "," + c.b + ")"; ctx.fillRect(0, 0, cv.width, cv.height); },
fillRect(x, y, w, h, c) { ctx.fillStyle = "rgb(" + c.r + "," + c.g + "," + c.b + ")"; ctx.fillRect(x, y, w, h); },
};
drawScene(canvasTarget, cfg);
const img = ctx.getImageData(0, 0, cv.width, cv.height).data, bg = rgb(cfg.bgColor);
let canvasPainted = 0;
for (let i = 0; i < img.length; i += 4) if (img[i] !== bg.r || img[i + 1] !== bg.g || img[i + 2] !== bg.b) canvasPainted += 1;
// 把像素缓冲直接贴到第二块画布,肉眼比对两个宿主画的是不是同一幅。
const pv = document.getElementById("pixels");
pv.getContext("2d").putImageData(new ImageData(pixel.data, pixel.width, pixel.height), 0, 0);
document.getElementById("np").textContent = String(nodePainted);
document.getElementById("cp").textContent = String(canvasPainted);
document.getElementById("same").textContent = nodePainted === canvasPainted ? "一致" : "不一致";
globalThis.__pebble = { nodePainted, canvasPainted, nodeHash, same: nodePainted === canvasPainted, width: cfg.width, height: cfg.height, canvasDrawn: true };
`;
const body = ` <h1>《合成小岛》· 无 DOM 渲染环境</h1>
<div class="sub">同一份 core/src/e03/renderer.ts 的 drawScene,分别落在像素缓冲与真实 Canvas</div>
<div class="card row"><div><div class="k">像素缓冲绘制</div><div class="big" id="np">0</div></div><div><div class="k">Canvas 绘制</div><div class="big" id="cp">0</div></div><div><div class="k">两宿主</div><div class="big" id="same">-</div></div></div>
<div class="card"><div class="k">宿主 B · 真实 Canvas 2D</div><canvas id="scene" width="240" height="160"></canvas></div>
<div class="card"><div class="k">宿主 A · 纯 JS 像素缓冲(putImageData)</div><canvas id="pixels" width="240" height="160"></canvas></div>`;
const bytes = writePage(join(ROOT, "web/dist/e03.html"),
page({ title: "《合成小岛》无 DOM 渲染环境原型 · e03", css: "canvas { image-rendering: pixelated; height: auto; }", body, script }));
console.log(JSON.stringify({ html: "web/dist/e03.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
W 层:无头验证。 web/verify_e03.mjs 用 Playwright 无头打开产物,断言两个宿主的像素数一致、指纹稳定、Canvas 整块被清屏填满、页面无报错,并截图存档。
#!/usr/bin/env node
// file: web/verify_e03.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e03.mjs web/dist/e03.html
// W 层验收:Playwright 无头打开单文件 HTML,断言两个宿主画出的像素数一致、指纹稳定、
// Canvas 真的画了像素、页面无报错,并截图存档。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/e03.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const page = await browser.newPage({ viewport: { width: 760, height: 720 } });
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const snap = await page.evaluate(() => {
const s = globalThis.__pebble, cv = document.getElementById("scene");
const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
let painted = 0;
for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0) painted += 1;
return { nodePainted: s.nodePainted, canvasPainted: s.canvasPainted, nodeHash: s.nodeHash,
same: s.same, width: s.width, height: s.height, painted };
});
const dom = await page.evaluate(() => ({ same: document.getElementById("same").textContent }));
await page.screenshot({ path: join(ROOT, "out/shots/e03_renderer.png"), fullPage: true });
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(snap.width, 240, "缓冲宽应为 240");
assert.equal(snap.nodePainted, 16868, `像素缓冲绘制应为 16868 px,实测 ${snap.nodePainted}`);
assert.equal(snap.canvasPainted, snap.nodePainted, "两个宿主绘制的非背景像素数应一致");
assert.equal(snap.same, true, "页面自检应判两宿主一致");
assert.equal(snap.painted, 240 * 160, "Canvas 整块应被清屏填满(无透明像素)");
assert.equal(snap.nodeHash, 4051371389, "像素缓冲指纹应稳定");
assert.equal(dom.same, "一致", "DOM 应显示「一致」");
console.log(JSON.stringify({ page: "web/dist/e03.html", nodePainted: snap.nodePainted,
canvasPainted: snap.canvasPainted, nodeHash: snap.nodeHash, canvasPaintedPx: snap.painted,
domSame: dom.same, screenshot: "out/shots/e03_renderer.png" }, null, 2));
console.log("SMOKE-W3: PASS");
跑出来(同机相对量级,绝对数值不可移植):像素缓冲绘制 16868 px、Canvas 绘制 16868 px,两者逐位一致;缓冲指纹 nodeHash=4051371389;Canvas 整块 38400 px(240×160)全被清屏填满、无透明像素;第二块画布把像素缓冲 putImageData 贴出来,肉眼可见两幅完全相同;SMOKE-W3: PASS,截图落在 out/shots/e03_renderer.png。
这一节要记住的三件事
- 无 DOM 不是限制,是接口收窄。环境只给你一块画布,反而逼你把渲染抽象成「发指令」这一层,宿主可换、可测。
- 像素缓冲是可测的宿主。把 2D 绘制实现在整数像素缓冲上,
node --test就能逐像素断言;这比「截图比对人眼」可靠得多。 - 同一份逻辑,两个宿主,一个指纹。
drawScene不认宿主,FNV-1a 指纹负责证明两边画的是同一幅。这套「一份逻辑、两种宿主」在第 10 章会扩展到平台 API 层。
必须诚实标注:width=240 / height=160 / scale=2 是演示用的小画布,不是任何真机分辨率;真实设备上的分辨率与安全区适配是下一节 3.2 的主题。本节能确定的是抽象与断言的方法(指令层可换宿主、像素级可复现),真实平台上的 GPU 行为需真机验证,属于「需真实业务环境,命令行不可复现」的范畴。
阅读导航:上一节:构建产物与包体预算 · 下一节:多分辨率适配与安全区 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。