3.1 小游戏的渲染环境:无 DOM 的 Canvas / WebGL

把「小游戏没有 DOM」这件平台约束落成可运行的工程抽象:用一份不依赖任何引擎的最小 2D 绘制内核,把清屏/矩形/文本三类指令发到两个宿主——Node 里的整数像素缓冲与浏览器里的真实 Canvas,逐像素断言两边画出的非背景像素数一致、指纹稳定,并附可打开的单文件 HTML 原型与无头验证脚本。

站内的《游戏引擎实现原理》 四卷 180 篇,走的是从零实现渲染引擎内核这条路——图形管线、ECS、资源系统,一路讲到 WebGPU 的命令编码与多线程。本章走的是另一条路:不造引擎,而是在既有引擎之上把一款小游戏跑顺,只谈三件工程上的事——无 DOM 的渲染环境、多分辨率与安全区、渲染性能与合批。管线内核请去看那 180 篇,本节只解决「小游戏跑在一个没有 DOM 的宿主里,第一行渲染代码该怎么写」。

小游戏没有 DOM

先把这个约束说清楚。小游戏(微信 / 抖音小游戏、以及不少 H5 平台)的 JS 代码运行在一个受限宿主里:没有完整的 document / window,没有 DOM 元素,也就没有 CSS 布局。你拿不到 getBoundingClientRect(),不能用 flex 排版,更不能塞一个 <div> 进去。

环境给你的只有三样东西:

  1. 一块画布:一个 canvas 对象,能拿到 2D 或 WebGL context。它是唯一的渲染出口。
  2. 一个屏幕尺寸:系统告诉你当前分辨率与安全区(下一节展开)。
  3. 一套输入回调:触摸 / 点击来自平台 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。

这一节要记住的三件事

  1. 无 DOM 不是限制,是接口收窄。环境只给你一块画布,反而逼你把渲染抽象成「发指令」这一层,宿主可换、可测。
  2. 像素缓冲是可测的宿主。把 2D 绘制实现在整数像素缓冲上,node --test 就能逐像素断言;这比「截图比对人眼」可靠得多。
  3. 同一份逻辑,两个宿主,一个指纹。drawScene 不认宿主,FNV-1a 指纹负责证明两边画的是同一幅。这套「一份逻辑、两种宿主」在第 10 章会扩展到平台 API 层。

必须诚实标注:width=240 / height=160 / scale=2 是演示用的小画布,不是任何真机分辨率;真实设备上的分辨率与安全区适配是下一节 3.2 的主题。本节能确定的是抽象与断言的方法(指令层可换宿主、像素级可复现),真实平台上的 GPU 行为需真机验证,属于「需真实业务环境,命令行不可复现」的范畴。

阅读导航:上一节:构建产物与包体预算 · 下一节:多分辨率适配与安全区 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制