JEPA4Japan · 教程

第 16 章:别让前台挤进厨房——Worker 与 GPU 升级

5,461字 17分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

比较主线程 Canvas 2D、OffscreenCanvas Worker 和 GPU 原型,并处理乱序、背压、崩溃、设备丢失与降级。

课程进度 课程大纲 已发布 18/18 课

从五岁孩子也能理解的游戏开始

本章唯一需要牢记的事实:Worker 把工作移出接待队列;GPU 则把适合的工作分给许多并行的小厨师。它们解决的不是同一个问题。

搭建一家玩具餐厅。前台的孩子必须回应铃声、记下订单并招呼客人,厨房里的孩子则负责切菜和摆盘。如果前台每接一单还要切 10,000 根胡萝卜,下一位客人按铃时就无人回应。这就像主线程被绘制或计算占满。把切菜清单送到厨房,前台便可以继续接待客人;这就像使用 Worker。

现在给厨房两种工具:一种是依次切菜的普通刀,另一种是带有许多相同小刀片的压切机,可以一次切出数百片,就像 GPU。先预测一下:如果前台缓慢是因为它要逐字核对一个错误地址,更多胡萝卜刀片会有帮助吗?不会。再预测一下:制作一个精致甜点时,是否一定值得先制作模具、搬运原料并清洗机器?仍然不一定。

  1. 前台接收输入永远先回应客人
  2. 订单带有编号旧订单不能盖住新订单
  3. 厨房拥有平底锅绘制所有者只能有一个
  4. 许多小厨师并行工作批量处理相同工作
  5. 故障时仍有冷餐兼容 Renderer 回退
先作判断:订单 42 已经画完后,订单 41 才迟到。应该显示哪一个?42。Sequence Number 会使过时帧失效。

升级不是奖章。如果第 15 章已经证明 Main-Thread Canvas 2D 能满足预算,就保留它。架构越简单,协议、复制、丢帧、设备丢失和兼容性故障就越少。

把玩具对应到 Canvas

玩具餐厅Canvas Lab工程含义
前台Main ThreadInput、DOM Overlay、Accessibility 和 React UI
厨房Dedicated WorkerDocument 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 AtlasGPU 数据和图像集合
把结果拿回检查ReadbackCPU 等待 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 <= lastSeq42 后跟 41保留最新 Camera
Diff 丢失Shape 一直缺失baseRevision 间隙resync-required Snapshot丢版本测试从权威 Document 重建
WebGPU 不存在初始化异常Feature 报告渐进式选择navigator.gpu 不存在Canvas 2D
Adapter 为 null空白屏幕Adapter 结果返回 null;不创建资源模拟 nullWorker/Main 2D
Device LostGPU 帧停止device.lost 原因忽略刻意 destroyed;真实丢失时有限重建注入丢失 + dispose 测试重放 Document
集成 GPU 更慢Input/Frame p95 变差同场景 A/B设备配置门控低功耗配置默认使用 2D
Texture 超过上限图像缺失设备 limits/素材尺寸Atlas 分页和缩放超大图像占位符
页面进入后台恢复时重放旧帧Visibility + seq丢弃过时工作;只绘制最新内容隐藏/显示测试最新 Snapshot
Main/Worker 协议版本不同解析错误握手版本拒绝并刷新/回退版本 1 对 2Compatibility 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”。解释把工作送到厨房,为什么能让前台更快回应铃声,却不一定更快出餐。为什么许多小厨师擅长切相同的胡萝卜,却无法帮助核对错误地址?厨房停电时,订单为什么还能保留下来?

一个不使用术语的好答案

前台只负责欢迎客人和记录订单。把耗时的切菜送进厨房,可以避免错过下一声铃响,但复制和搬运订单也需要时间,所以食物不一定更快完成。许多小厨师可以一起做许多相同小块,却无法帮助核对一个必须逐步检查的错误地址。每张订单都有新旧编号,所以迟到的旧菜不能盖住新菜。真正的账本不放在平底锅里;如果厨房或大机器坏了,就换回普通锅具,再根据账本重新做菜。