课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从五岁孩子也能理解的游戏开始
本章唯一需要牢记的事实:Worker 把工作移出接待队列;GPU 则把适合的工作分给许多并行的小厨师。它们解决的不是同一个问题。
搭建一家玩具餐厅。前台的孩子必须回应铃声、记下订单并招呼客人,厨房里的孩子则负责切菜和摆盘。如果前台每接一单还要切 10,000 根胡萝卜,下一位客人按铃时就无人回应。这就像主线程被绘制或计算占满。把切菜清单送到厨房,前台便可以继续接待客人;这就像使用 Worker。
现在给厨房两种工具:一种是依次切菜的普通刀,另一种是带有许多相同小刀片的压切机,可以一次切出数百片,就像 GPU。先预测一下:如果前台缓慢是因为它要逐字核对一个错误地址,更多胡萝卜刀片会有帮助吗?不会。再预测一下:制作一个精致甜点时,是否一定值得先制作模具、搬运原料并清洗机器?仍然不一定。
- 前台接收输入永远先回应客人
- 订单带有编号旧订单不能盖住新订单
- 厨房拥有平底锅绘制所有者只能有一个
- 许多小厨师并行工作批量处理相同工作
- 故障时仍有冷餐兼容 Renderer 回退
升级不是奖章。如果第 15 章已经证明 Main-Thread Canvas 2D 能满足预算,就保留它。架构越简单,协议、复制、丢帧、设备丢失和兼容性故障就越少。
把玩具对应到 Canvas
| 玩具餐厅 | Canvas Lab | 工程含义 |
|---|---|---|
| 前台 | Main Thread | Input、DOM Overlay、Accessibility 和 React UI |
| 厨房 | Dedicated Worker | Document Diff 处理和 OffscreenCanvas 绘制 |
| 把平底锅交给厨房 | transferControlToOffscreen() | 只转移一次 Canvas 控制权 |
| 订单的副本 | Structured Clone | 默认复制可克隆消息 |
| 直接交出托盘 | Transferable | 转移 ArrayBuffer/ImageBitmap 所有权 |
| 订单格式 | Message Protocol | 版本、序号、类型、payload 和错误 |
| 只写下发生的变化 | Document Diff / Command Stream | 不要反复克隆整份 Document |
| 订单编号 | Sequence Number | 丢弃乱序响应和 Stale Frames |
| 厨房满载时不再添加订单 | Backpressure | 合并可替换的 Camera/Preview 更新 |
| 用压切机处理整批原料 | GPU Batching | 更少的 Draw Calls 和批量 instance 数据 |
| 切菜模具 | Shader | 并行 vertex/fragment 程序 |
| 原料仓库 | Buffer / Texture Atlas | GPU 数据和图像集合 |
| 把结果拿回检查 | Readback | CPU 等待 GPU;热路径中代价昂贵 |
| 厨房停电 | Worker Crash / Device Loss | 重建或回退到兼容 Renderer |
这个类比也有边界:Worker 并不保证速度更快。它仍使用 CPU,消息复制和状态同步也有成本。DOM、输入和 Text Overlay 仍留在主线程。OffscreenCanvas 转移后,主线程不能继续从同一个 Canvas 获取 Context。GPU 也不提供无限多厨师:Draw Calls、纹理带宽、填充率、Buffer 更新,以及集成显卡上的共享内存都会形成限制。Worker 与 GPU 可以组合,也可以彼此独立地采用。
先消灭错误直觉
- “把它移入 Worker 就会自动消除卡顿。” 如果每次 Pointer Move 都克隆一份 50MB Document,Transfer Cost 会吃掉全部收益。DOM、输入和 Text Overlay 仍然位于主线程。
- “两边都保留 Canvas Context 更安全。” 转移后的 Canvas 只有一个 Owner。两边同时写入会产生未定义的帧和生命周期行为。
- “消息按顺序发送,业务工作就总会按顺序完成。” Worker 内部的异步解码、网络更新和重建可能交错。协议仍需要 seq、baseRevision 和幂等处理。
- “WebGPU 可以无条件取代 WebGL2。” 设备、adapter、limits、浏览器/企业策略或驱动都可能拒绝它。WebGL context 也可能丢失。
- “GPU Picking 总是比 CPU Geometry 快。” 拾取回读会同步 CPU 与 GPU。候选集合较小时,第 8、15 章的空间索引通常更简单。
- “增加 Draw Calls 只是多几次函数调用。” 每次调用都有 CPU/驱动开销。GPU 路径的核心通常是 Batching 和少量 Buffer 更新。
- “Three.js 是 2D Canvas 的毕业证。” 只有当 Perspective、Depth、Lighting、Mesh、3D Camera、Raycasting 和 Materials 都是真实产品需求时,才应进入 3D 分支。
生产级背包
前置条件
第 15 章必须针对同一个确定性场景提供 Trace 和预算,指出瓶颈究竟是主线程争用、Canvas 2D 绘制,还是 Document/Geometry。Renderer 接口已经隔离不同实现。Document 不保存 GPU Buffer 或 Worker handle。DOM Text、Keyboard 和 Screen Reader 层仍位于主线程。
正式知识
Worker / OffscreenCanvas。 Worker Ownership 意味着,转移之后由 Worker 拥有 bitmap、context、resize 和 render clock。主线程保留 InputController,并将归一化 Events/Commands 发送给 Worker。transferControlToOffscreen() 返回一个可转移的 OffscreenCanvas。ArrayBuffer 和 ImageBitmap 也可以转移;发送后,原所有者必须停止使用它们。其他对象使用 Structured Clone,其成本必须测量。
Message Protocol 至少包含 protocolVersion、seq、baseRevision 和 kind。初始化发送 Snapshot,后续消息发送 Command Stream 或 Document Diff。如果 Worker 发现版本间隙,它会发送 resync-required,主线程则必须发送专门的替换快照。Worker 在安装完整状态前,要先清除旧记录。假装 Snapshot 是 upserts + removes: [],会让已经删除的 Shapes 永久残留。Camera 和 Pointer Preview 属于可替换状态;队列积压时只保留最新值。Document Commands 不能随意丢弃。Backpressure 定义 maxInFlight、独立 acknowledgement 和合并规则。渲染通道可以把多个 Diffs 折叠为最新 Snapshot 的最终效果,但主线程的事实来源层仍保留完整的领域 Command/History。Sequence Numbers 防止 Stale Frame 或响应覆盖更新状态。
在支持的环境中,Worker-Side rAF 为 Worker Canvas 安排绘制。否则使用主线程 frame 消息或节流 timer 作为兼容路径。Background Tabs 会受到节流或暂停。恢复时,应根据最新 Snapshot/clock 渲染,而不是追赶数千个旧帧。通过 error/messageerror/heartbeat 检测 Worker Crash:终止损坏的 Worker,保留 Document,并使用新的 Canvas Host 重建。反复失败后,回退到 Main-Thread Canvas 2D。transferControlToOffscreen() 会把原始 <canvas> 变成占位符。主线程不能“收回”控制权,也不能在同一元素上创建 2D context。崩溃回退必须用新的 HTMLCanvasElement 替换它,再重放 Document。
GPU Renderer。 Canvas 2D 是立即模式的兼容基准。WebGL2 和 WebGPU 需要显式 Buffers、Textures、Shaders 和 Render Passes。把使用同一种材质的许多 Shapes 合并到一次或少数几次 Draw Calls 中。把图像放入 Texture Atlas,同时遵守 padding、更新和最大纹理尺寸。CPU 计算 World Transforms 和可见 instances 后,分批把它们写入 Buffer;Shaders 并行变换顶点和着色像素。GPU Picking 可以把 ID 编码进离屏纹理,但 Readback 会同步整条管线,必须与 CPU Spatial Index 做基准比较。
CPU/GPU Synchronization 是常见陷阱。频繁 map/readback、等待提交,或每个 Shape 更新一个很小的 Buffer,都会让管线串行化。WebGPU 启动时应对 navigator.gpu、requestAdapter()、requestDevice()、所需 limits 和 features 做 Feature Detection。adapter 可能为 null。监听 device.lost、记录原因,并在有限次数内重建资源。WebGL 则监听 context lost/restored,阻止默认行为,再执行重建。Progressive Enhancement 同时依据 Benchmark 和能力选择实现。Compatibility Renderer 必须始终能够打开 Document,并编辑/导出基础 Shapes。
在同一个 Scene 上比较 Main Canvas 2D、Offscreen Worker 和 GPU Prototype:Startup(初始化/编译/首帧)、Frame Time、Main-Thread Responsiveness、Memory、Transfer Cost、Fallback 成功率和 Implementation Complexity(协议、资源、失败路径和测试成本)。如果帧时间改善,首帧、内存或输入却变差,就不能只报告获胜项。
证据与兼容性(验证于 2026-08-29)
针对目标浏览器,对 OffscreenCanvas、transfer 和 Worker rAF 进行特性检测;参见 MDN OffscreenCanvas、transferControlToOffscreen() 和 DedicatedWorkerGlobalScope requestAnimationFrame。WebGPU 初始化和错误处理应遵循 W3C WebGPU Specification 与 MDN WebGPU API。GPUDevice.lost 返回设备丢失信息;恢复需要请求新设备并重建资源。生产结论必须有你自己的 Browser Matrix 和代表性设备证据。“API 存在”绝不意味着“性能更好”。
本章工程增量
起点: 第 15 章的 SceneRenderer 使用主线程 Canvas 2D,Trace 显示在代表性设备的 100k 场景中,主线程 render p95 超出预算。终点: 同一个 Scene/Camera/Document 拥有三个 Renderers;Worker 具备带版本的协议、背压、乱序恢复和崩溃回退;GPU 原型具备特性检测、批处理和 device-lost 回退。
添加以下文件和接口:
src/engine/render/Renderer.ts:兼容的init/apply/render/dispose契约;src/engine/worker/protocol.ts、render.worker.ts和WorkerRenderer.ts:所有权与消息协议;src/engine/gpu/WebGpuRenderer.ts:批量矩形原型;src/engine/render/selectRenderer.ts:feature + benchmark gate;bench/renderers.bench.ts:针对三条路径的同场景报告;src/engine/worker/__tests__/protocol.test.ts:乱序消息、缺失 Diffs、背压和版本不匹配。
下面是一份完整的最小主线程/Worker 协议。关键规则是:Commands 绝不能丢;Camera 更新可以合并;版本间隙必须请求 Snapshot;Canvas 只转移一次:
// protocol.ts
export type Rect = { id: string; x: number; y: number; w: number; h: number; color: string };
export type MainToWorker =
| {
protocolVersion: 1;
seq: number;
kind: 'init';
canvas: OffscreenCanvas;
width: number;
height: number;
dpr: number;
revision: number;
snapshot: Rect[];
}
| { protocolVersion: 1; seq: number; kind: 'snapshot'; revision: number; snapshot: Rect[] }
| {
protocolVersion: 1;
seq: number;
kind: 'commands';
baseRevision: number;
revision: number;
upserts: Rect[];
removes: string[];
}
| { protocolVersion: 1; seq: number; kind: 'camera'; x: number; y: number; zoom: number }
| { protocolVersion: 1; seq: number; kind: 'resize'; width: number; height: number; dpr: number };
export type WorkerToMain =
| { protocolVersion: 1; kind: 'ready'; ackSeq: number; revision: number }
| { protocolVersion: 1; kind: 'ack'; ackSeq: number; revision: number; ignored?: boolean }
| {
protocolVersion: 1;
kind: 'frame';
renderedThroughSeq: number;
revision: number;
durationMs: number;
}
| { protocolVersion: 1; kind: 'resync-required'; rejectedSeq: number; expectedRevision: number }
| { protocolVersion: 1; kind: 'fatal'; code: string };
// WorkerRenderer.ts
export class WorkerRenderer {
private worker: Worker | null = null;
private seq = 0;
private sentRevision = 0;
private pendingCamera: { x: number; y: number; zoom: number } | null = null;
private pendingAcks = new Set<number>();
private resyncSeq: number | null = null;
private readonly maxCameraInFlight = 2;
private readonly maxProtocolInFlight = 64;
constructor(
private readonly state: () => { revision: number; snapshot: Rect[] },
private readonly fallback: (reason: string) => void,
) {}
start(canvas: HTMLCanvasElement, width: number, height: number, dpr: number) {
if (!('transferControlToOffscreen' in canvas)) return this.fallback('OFFSCREEN_UNSUPPORTED');
let worker: Worker;
try {
worker = new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' });
} catch {
return this.fallback('WORKER_CONSTRUCTION_FAILED');
}
let offscreen: OffscreenCanvas;
try {
offscreen = canvas.transferControlToOffscreen();
} catch {
worker.terminate();
return this.fallback('OFFSCREEN_TRANSFER_FAILED');
}
this.worker = worker;
worker.onmessage = (e: MessageEvent<WorkerToMain>) => this.receive(e.data);
worker.onerror = () => this.crash('WORKER_ERROR');
worker.onmessageerror = () => this.crash('MESSAGE_ERROR');
const state = this.state();
this.sentRevision = state.revision;
this.send(
{
protocolVersion: 1,
seq: ++this.seq,
kind: 'init',
canvas: offscreen,
width,
height,
dpr,
revision: state.revision,
snapshot: state.snapshot,
},
[offscreen],
);
}
commands(upserts: Rect[], removes: string[], nextRevision: number) {
if (!this.worker) return;
if (this.pendingAcks.size >= this.maxProtocolInFlight)
throw new Error('RENDER_PROTOCOL_BACKPRESSURE_RETRY');
if (nextRevision <= this.sentRevision) throw new Error('NON_MONOTONIC_REVISION');
const message: MainToWorker = {
protocolVersion: 1,
seq: ++this.seq,
kind: 'commands',
baseRevision: this.sentRevision,
revision: nextRevision,
upserts,
removes,
};
this.sentRevision = nextRevision;
this.send(message);
}
camera(x: number, y: number, zoom: number) {
this.pendingCamera = { x, y, zoom };
this.flushCamera();
}
resize(width: number, height: number, dpr: number) {
this.send({ protocolVersion: 1, seq: ++this.seq, kind: 'resize', width, height, dpr });
}
private send(message: MainToWorker, transfer: Transferable[] = []) {
if (!this.worker) return;
this.pendingAcks.add(message.seq);
this.worker.postMessage(message, transfer);
}
private flushCamera() {
if (!this.pendingCamera || this.pendingAcks.size >= this.maxCameraInFlight) return;
const latest = this.pendingCamera;
this.pendingCamera = null;
this.send({ protocolVersion: 1, seq: ++this.seq, kind: 'camera', ...latest });
}
private sendSnapshot() {
if (this.resyncSeq !== null) return;
const state = this.state();
this.sentRevision = state.revision;
const seq = ++this.seq;
this.resyncSeq = seq;
this.send({
protocolVersion: 1,
seq,
kind: 'snapshot',
revision: state.revision,
snapshot: state.snapshot,
});
}
private receive(message: WorkerToMain) {
if (message.protocolVersion !== 1) return this.crash('PROTOCOL_MISMATCH');
if (message.kind === 'fatal') return this.crash(message.code);
if (message.kind === 'frame') return;
if (message.kind === 'resync-required') {
this.pendingAcks.delete(message.rejectedSeq);
this.sendSnapshot();
return;
}
this.pendingAcks.delete(message.ackSeq);
if (message.ackSeq === this.resyncSeq) this.resyncSeq = null;
this.flushCamera();
}
private crash(reason: string) {
this.worker?.terminate();
this.worker = null;
this.fallback(reason);
}
dispose() {
this.worker?.terminate();
this.worker = null;
}
}
// render.worker.ts
/// <reference lib="webworker" />
import type { MainToWorker, Rect, WorkerToMain } from './protocol';
const scope: DedicatedWorkerGlobalScope = self as unknown as DedicatedWorkerGlobalScope;
let canvas: OffscreenCanvas | null = null,
ctx: OffscreenCanvasRenderingContext2D | null = null;
let revision = 0,
lastSeq = 0,
camera = { x: 0, y: 0, zoom: 1 },
scheduled = false;
const shapes = new Map<string, Rect>();
const reply = (message: WorkerToMain) => scope.postMessage(message);
function schedule() {
if (scheduled) return;
scheduled = true;
const callback = () => {
scheduled = false;
draw();
};
if ('requestAnimationFrame' in scope) scope.requestAnimationFrame(callback);
else setTimeout(callback, 16);
}
function draw() {
if (!canvas || !ctx) return;
const start = performance.now();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(
camera.zoom,
0,
0,
camera.zoom,
-camera.x * camera.zoom,
-camera.y * camera.zoom,
);
for (const s of shapes.values()) {
ctx.fillStyle = s.color;
ctx.fillRect(s.x, s.y, s.w, s.h);
}
reply({
protocolVersion: 1,
kind: 'frame',
renderedThroughSeq: lastSeq,
revision,
durationMs: performance.now() - start,
});
}
scope.onmessage = (event: MessageEvent<MainToWorker>) => {
const m = event.data;
if (m.protocolVersion !== 1)
return reply({ protocolVersion: 1, kind: 'fatal', code: 'PROTOCOL_MISMATCH' });
if (m.seq <= lastSeq)
return reply({ protocolVersion: 1, kind: 'ack', ackSeq: m.seq, revision, ignored: true });
lastSeq = m.seq;
if (m.kind === 'init') {
canvas = m.canvas;
canvas.width = Math.round(m.width * m.dpr);
canvas.height = Math.round(m.height * m.dpr);
ctx = canvas.getContext('2d');
if (!ctx) return reply({ protocolVersion: 1, kind: 'fatal', code: 'NO_2D_CONTEXT' });
shapes.clear();
for (const s of m.snapshot) shapes.set(s.id, s);
revision = m.revision;
reply({ protocolVersion: 1, kind: 'ready', ackSeq: m.seq, revision });
schedule();
return;
}
if (!canvas || !ctx) return reply({ protocolVersion: 1, kind: 'fatal', code: 'NOT_INITIALIZED' });
if (m.kind === 'commands') {
if (m.baseRevision !== revision)
return reply({
protocolVersion: 1,
kind: 'resync-required',
rejectedSeq: m.seq,
expectedRevision: revision,
});
for (const id of m.removes) shapes.delete(id);
for (const s of m.upserts) shapes.set(s.id, s);
revision = m.revision;
}
if (m.kind === 'snapshot') {
shapes.clear();
for (const s of m.snapshot) shapes.set(s.id, s);
revision = m.revision;
}
if (m.kind === 'camera') camera = { x: m.x, y: m.y, zoom: m.zoom };
if (m.kind === 'resize') {
canvas.width = Math.round(m.width * m.dpr);
canvas.height = Math.round(m.height * m.dpr);
}
reply({ protocolVersion: 1, kind: 'ack', ackSeq: m.seq, revision });
schedule();
};
发送给同一个 Worker 的 postMessage 任务本来就具有 FIFO 保证。这里仍然需要 seq,因为真实管线会加入异步图像解码、网络结果、崩溃重建和注入式测试条件,所以完成顺序可能不等于提交顺序。RENDER_PROTOCOL_BACKPRESSURE_RETRY 表示上层应保留变更集,并在收到 ack 后重试;或者使用最新 Snapshot 折叠渲染状态。它绝不授权删除领域 Command。ready/ack 与 frame 保持分离,这样初始化同时返回 ready 和 frame 时,不会把在途计数减两次。
GPU Prototype 不会改变 Document,而是把可见矩形打包到 instance Buffer 中。下面的完整示例展示能力检查、批处理、Shader 和 Device Loss 信号。任何失败都会返回 null,使选择器能够启用兼容 Renderer:
const shader = `
struct Globals { viewport: vec2f }
struct Instance { rect: vec4f, color: vec4f }
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var<storage, read> items: array<Instance>;
struct Out { @builtin(position) position: vec4f, @location(0) color: vec4f }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
let corners = array<vec2f, 6>(vec2f(0,0),vec2f(1,0),vec2f(0,1),vec2f(0,1),vec2f(1,0),vec2f(1,1));
let p = items[i].rect.xy + corners[v] * items[i].rect.zw;
var out: Out; out.position = vec4f(p.x / globals.viewport.x * 2.0 - 1.0,
1.0 - p.y / globals.viewport.y * 2.0, 0.0, 1.0); out.color = items[i].color; return out;
}
@fragment fn fs(input: Out) -> @location(0) vec4f { return input.color; }
`;
export async function createGpuRenderer(
canvas: HTMLCanvasElement,
onLost: (reason: string) => void,
) {
if (!navigator.gpu) return null;
let device: GPUDevice | undefined,
disposed = false;
try {
const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
if (!adapter) return null;
device = await adapter.requestDevice();
device.lost.then((info) => {
if (!disposed && info.reason !== 'destroyed')
onLost(`GPU_LOST:${info.reason}:${info.message}`);
});
const context = canvas.getContext('webgpu');
if (!context) {
disposed = true;
device.destroy();
return null;
}
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format, alphaMode: 'premultiplied' });
device.pushErrorScope('validation');
const module = device.createShaderModule({ code: shader });
const pipeline = await device.createRenderPipelineAsync({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: {
module,
entryPoint: 'fs',
targets: [
{
format,
blend: {
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' },
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' },
},
},
],
},
primitive: { topology: 'triangle-list' },
});
const validation = await device.popErrorScope();
if (validation) throw validation;
const globals = device.createBuffer({
size: 16,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
let items = device.createBuffer({
size: 32,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
return {
render(
rects: Array<{
x: number;
y: number;
w: number;
h: number;
color: [number, number, number, number];
}>,
viewport: { width: number; height: number },
) {
if (!(viewport.width > 0 && viewport.height > 0)) throw new Error('INVALID_VIEWPORT');
const data = new Float32Array(rects.length * 8);
rects.forEach((r, i) => data.set([r.x, r.y, r.w, r.h, ...r.color], i * 8));
if (items.size < data.byteLength) {
items.destroy();
items = device!.createBuffer({
size: Math.max(32, Math.ceil(data.byteLength / 256) * 256),
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
}
device!.queue.writeBuffer(globals, 0, new Float32Array([viewport.width, viewport.height]));
if (data.byteLength) device!.queue.writeBuffer(items, 0, data);
const bind = device!.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: globals } },
{ binding: 1, resource: { buffer: items } },
],
});
const encoder = device!.createCommandEncoder(),
pass = encoder.beginRenderPass({
colorAttachments: [
{
view: context.getCurrentTexture().createView(),
loadOp: 'clear',
storeOp: 'store',
clearValue: [1, 1, 1, 1],
},
],
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bind);
pass.draw(6, rects.length);
pass.end();
device!.queue.submit([encoder.finish()]);
},
dispose() {
disposed = true;
globals.destroy();
items.destroy();
device!.destroy();
},
};
} catch {
disposed = true;
device?.destroy();
return null;
}
}
回到餐厅:instance Buffer 就是一只托盘,把原料一次送到许多小刀片前。每个 Shape 调用一次 draw,就等于重新逐盘端菜。Viewport 通过 Uniform 到达 Shader,因此示例并没有偷偷固定在 1000×1000。调用方仍必须以一致方式转换 Camera、DPR 和可见集合。调用 dispose() 会刻意使 device.lost 以 destroyed 结束;这不是生产故障,也不能触发重建风暴。真正发生 Device Loss 时,账本仍位于主线程 Document,因此系统可以更换平底锅并重新烹饪,而不会丢掉订单。
协议测试会注入乱序消息和版本间隙:
import { expect, test } from 'vitest';
test('an old seq does not replace the new Camera, and a revision gap requests a full sync', () => {
let lastSeq = 0,
revision = 7,
camera = { x: 0, y: 0 };
const acceptCamera = (m: { seq: number; x: number; y: number }) => {
if (m.seq <= lastSeq) return;
lastSeq = m.seq;
camera = { x: m.x, y: m.y };
};
acceptCamera({ seq: 42, x: 20, y: 30 });
acceptCamera({ seq: 41, x: 1, y: 1 });
expect(camera).toEqual({ x: 20, y: 30 });
const applyDiff = (baseRevision: number) =>
baseRevision === revision ? 'applied' : 'resync-required';
expect(applyDiff(6)).toBe('resync-required');
const workerShapes = new Map([
['shape-kept', 1],
['shape-deleted', 1],
]);
const replaceSnapshot = (ids: string[]) => {
workerShapes.clear();
for (const id of ids) workerShapes.set(id, 1);
};
replaceSnapshot(['shape-kept']);
expect([...workerShapes.keys()]).toEqual(['shape-kept']);
});
运行 npm run benchmark -- --renderers=main2d,worker2d,webgpu --scene=100k-simple。对于同一个随机种子,应输出 Startup、Frame p50/p95/p99、Main Thread input delay、Memory、Transfer bytes/ms、Fallback 测试和 Complexity 检查表。只有在代表性设备上通过预先写明的收益阈值时,才默认启用 Worker/GPU。运行 npm exec vitest run src/engine/worker src/engine/gpu;应确认乱序消息、丢失 Diffs、替换 Snapshot 移除旧记录、版本不匹配、adapter null、刻意 destroy 和 device lost,都进入确定性的重同步或回退路径。
故意破坏它
| 注入的故障 | 症状 | 证据 | 修复 | 回归测试 | 恢复 |
|---|---|---|---|---|---|
| Worker 抛出异常 | Canvas 冻结 | error + 最后 ack/revision | 终止并用新的 <canvas> 重建 | Crash-on-seq 测试 | 在新 Host 上回退到主线程 2D |
| 消息乱序到达 | Camera 向后跳 | seq trace | 丢弃 seq <= lastSeq | 42 后跟 41 | 保留最新 Camera |
| Diff 丢失 | Shape 一直缺失 | baseRevision 间隙 | resync-required Snapshot | 丢版本测试 | 从权威 Document 重建 |
| WebGPU 不存在 | 初始化异常 | Feature 报告 | 渐进式选择 | navigator.gpu 不存在 | Canvas 2D |
| Adapter 为 null | 空白屏幕 | Adapter 结果 | 返回 null;不创建资源 | 模拟 null | Worker/Main 2D |
| Device Lost | GPU 帧停止 | device.lost 原因 | 忽略刻意 destroyed;真实丢失时有限重建 | 注入丢失 + dispose 测试 | 重放 Document |
| 集成 GPU 更慢 | Input/Frame p95 变差 | 同场景 A/B | 设备配置门控 | 低功耗配置 | 默认使用 2D |
| Texture 超过上限 | 图像缺失 | 设备 limits/素材尺寸 | Atlas 分页和缩放 | 超大图像 | 占位符 |
| 页面进入后台 | 恢复时重放旧帧 | Visibility + seq | 丢弃过时工作;只绘制最新内容 | 隐藏/显示测试 | 最新 Snapshot |
| Main/Worker 协议版本不同 | 解析错误 | 握手版本 | 拒绝并刷新/回退 | 版本 1 对 2 | Compatibility Renderer |
用证据通过
| 自动化证据 | 手动证据 | 通过条件 |
|---|---|---|
| 三种 Renderers 的同场景基准;协议乱序/丢失/崩溃,以及 adapter-null/device-lost 测试 | 在代表性硬件上切换 Renderers;检查首帧、输入响应和回退通知 | 自动收益阈值通过;手动回退后 Document 和可编辑性保持完好 |
| Browser Matrix 特性报告和资源上限测试 | 分别测试集成显卡、后台标签页和 GPU 被禁用的环境 | 每个环境都能选出可用的 Compatibility Renderer |
| 决策 | 所需证据 |
|---|---|
| 我们需要 Worker 吗? | Main Thread 输入延迟来自可迁移的 CPU/绘制工作,并且收益超过消息成本 |
| 我们需要 GPU 吗? | Canvas 2D render 经证明是瓶颈,批处理原型能改善代表性设备 |
| Document 会丢失吗? | Renderer 崩溃后可从持久/当前 Document 完整重建 |
| 消息传递可靠吗? | seq、baseRevision、ack、backpressure 和 resync 测试 |
| 如果不受支持怎么办? | Feature Detection 和自动 Compatibility Renderer |
| 复杂度值得吗? | 完整列出 Startup、Memory、Transfer、失败路径和维护成本 |
- 在同一个 Scene 上比较 Main-Thread 2D、Worker Offscreen 2D 和 GPU Prototype。
- Worker 是唯一 Canvas Owner;Commands 绝不丢弃,Camera/Preview 则可以合并。
- 乱序消息、缺失 Diffs、Worker Crash 和后台恢复都有自动化测试。
- WebGPU adapter null、Device Lost 和 limits 不足都会在不丢数据的情况下回退。
- GPU 热路径不含同步 Readback;Picking 与 CPU Geometry 做过基准比较。
- Benchmark 证据显示没有收益时,保留更简单的 Renderer。
- 只有 Perspective/Depth/Lighting/Mesh/3D Camera/Raycasting/Materials 都是真实需求时,才评估 Three.js。
向五岁孩子解释
不要说“Worker”“GPU”“OffscreenCanvas”或“Buffer”。解释把工作送到厨房,为什么能让前台更快回应铃声,却不一定更快出餐。为什么许多小厨师擅长切相同的胡萝卜,却无法帮助核对错误地址?厨房停电时,订单为什么还能保留下来?
一个不使用术语的好答案
前台只负责欢迎客人和记录订单。把耗时的切菜送进厨房,可以避免错过下一声铃响,但复制和搬运订单也需要时间,所以食物不一定更快完成。许多小厨师可以一起做许多相同小块,却无法帮助核对一个必须逐步检查的错误地址。每张订单都有新旧编号,所以迟到的旧菜不能盖住新菜。真正的账本不放在平底锅里;如果厨房或大机器坏了,就换回普通锅具,再根据账本重新做菜。