JEPA4Japan · チュートリアル

第16章:受付を厨房へ入れない——WorkerとGPUへの更新

6,294文字 20分で読めます #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Main-Thread Canvas 2D、OffscreenCanvas Worker、GPU Prototypeを比較し、乱順、Backpressure、Crash、Device Loss、Fallbackを扱います。

コース進捗 コース目次 18レッスン中 18件を公開中

第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標

  1. 01 第1章:まだ描かない——Canvasはプロダクト設計ではない 公開中
  2. 02 第2章:すぐに記憶を失うピクセルの紙 公開中
  3. 03 第3章:お絵描きを再現可能なレシピにする 公開中
  4. 04 第4章:4枚の地図と1台のカメラ 公開中

第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール

  1. 05 第5章:ピクセル世界に台帳を作る 公開中
  2. 06 第6章:ランプが点いたときだけ描き直す——Render SchedulerとReactの境界 公開中
  3. 07 第7章:マウス、指、ペンに同じ言葉を話してもらう 公開中
  4. 08 第8章:細い縁を調べる前に大きな箱を探す 公開中
  5. 09 第9章:ツールは信号機であり、Booleanの袋ではない 公開中

第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧

  1. 10 第10章:触って気持ちよいエディターにする 公開中
  2. 11 第11章:描かれた文字は編集できる文字ではない 公開中
  3. 12 第12章:借りた画像を勝手に箱へ詰めてはいけない 公開中
  4. 13 第13章:タイムマシンと古い箱 公開中
  5. 14 第14章:正しく見えることと、本当に正しいことは違う 公開中

第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI

  1. 15 第15章:1万人を一人ずつ探さない 公開中
  2. 16 第16章:受付を厨房へ入れない——WorkerとGPUへの更新 現在のレッスン
  3. 17 第17章:車を自作するか、実績あるシャーシを買うか 公開中
  4. 18 第18章:人とAIが同じ台帳を編集する 公開中

5歳児にもわかるゲームから始めよう

本章で伝える唯一の真実:Workerは仕事を受付のQueueから外へ移し、GPUは適した仕事を大勢の小さな料理人へ並列に分けます。両者が解決する問題は同じではありません。

おもちゃのRestaurantを作ります。受付の子どもはBellに答え、注文を書き、客へ応対しなければなりません。Kitchenの子どもたちは食材を切り、皿へ盛ります。注文のたびに受付係まで1万本の人参を切る必要があれば、次の客がBellを鳴らしても誰も答えられません。Main Threadが描画や計算に使い切られた状態と同じです。切るものの一覧をKitchenへ送れば、受付は客への応対を続けられます。これがWorkerに相当します。

今度はKitchenへ2種類の道具を渡します。1つは順番に切る普通の包丁、もう1つは同じ形の小さな刃が多数並び、一度に数百枚を切れるPressです。後者はGPUに似ています。まず予想してください。受付が遅い理由が、誤った住所を1文字ずつ確認していることなら、人参を切る刃を増やして役に立つでしょうか。いいえ。では、1つの複雑なDessertを作るとき、型を用意し、材料を運び、機械を洗う工程を追加すれば必ず速くなるでしょうか。これも違います。

  1. 受付がInputを受ける常に客への応答を優先する
  2. 注文に番号を付ける古い注文が新しい注文を覆わない
  3. KitchenがPanを所有する描画Ownerは必ず1つ
  4. 大勢の小さな料理人が並列に働く同じ仕事をBatchにする
  5. 故障時は冷たい料理を残すCompatibility RendererへFallbackする
先に判断してください。注文42を描き終えた後で注文41が遅れて届いたら、どちらを表示すべきでしょう。42です。Sequence NumberによってStale Frameを無効化します。

Upgradeは勲章ではありません。第15章でMain-Thread Canvas 2DがすでにBudgetを満たすと証明できたなら、そのまま使います。単純なArchitectureほど、Protocol、Copy、Dropped Frame、Device Loss、Compatibilityに関する故障が少なくなります。

おもちゃをCanvasに翻訳する

おもちゃのRestaurantCanvas LabEngineering上の意味
受付Main ThreadInput、DOM Overlay、Accessibility、React UI
KitchenDedicated WorkerDocument Diff処理とOffscreenCanvas描画
PanをKitchenへ渡すtransferControlToOffscreen()Canvas Controlを正確に1回だけTransferする
注文のCopyStructured CloneClone可能なMessageをDefaultでCopyする
Trayそのものを渡すTransferableArrayBuffer/ImageBitmapのOwnershipをTransferする
注文形式Message ProtocolVersion、Sequence、Type、Payload、Error
変わった部分だけを書くDocument Diff / Command StreamDocument全体を繰り返しCloneしない
注文番号Sequence NumberOut-of-order ResponseとStale Frameを捨てる
Kitchenが満杯なら注文を追加しないBackpressure置換可能なCamera/Preview Updateをまとめる
Batch全体をPressで切るGPU BatchingDraw Callを減らし、Instance DataをBatch化する
切断用の型Shader並列のVertex/Fragment Program
材料倉庫Buffer / Texture AtlasGPU DataとImage Collection
結果を受付へ戻して検査するReadbackCPUがGPUを待つため、Hot Pathでは高コスト
Kitchenの停電Worker Crash / Device Loss再構築またはCompatibility RendererへFallback

比喩が通用するのはここまでです。Workerを使っても高速になる保証はありません。WorkerもCPUを使い、Message CopyとState同期には時間がかかります。DOM、Input、Text OverlayはMain Threadに残ります。OffscreenCanvasをTransferした後、Main Threadは同じCanvasからContextを取得し続けられません。GPUにも無限の料理人はいません。Draw Call、Texture Bandwidth、Fill Rate、Buffer Update、Integrated GraphicsのShared Memoryが制約になります。WorkerとGPUは組み合わせることも、独立して採用することもできます。

まず誤った直感を捨てる

  • 「Workerへ移せば自動的にJankがなくなる」。Pointer Moveごとに50MBのDocumentをCloneすれば、Transfer Costが利益を使い切ります。DOM、Input、Text OverlayはMain Threadに残ります。
  • 「両側でCanvas Contextを持つほうが安全だ」。TransferされたCanvasのOwnerは1つです。両側からWriteするとFrameとLifecycleの挙動が未定義になります。
  • 「Messageは順番に送られるので、Business Workも必ず順番に終わる」。Worker内の非同期Decode、Network Update、再構築はInterleaveする場合があります。Protocolにはやはりseq、baseRevision、Idempotentな処理が必要です。
  • 「WebGPUは無条件にWebGL2を置き換える」。Device、Adapter、Limit、Browser/Enterprise Policy、Driverによって拒否される場合があります。WebGL ContextもLostする可能性があります。
  • 「GPU Pickingは常にCPU Geometryより速い」。PickingのReadbackはCPUとGPUを同期します。Candidate Setが小さければ、第8章と第15章のSpatial Indexのほうが通常は単純です。
  • 「Draw Callが少し増えても、Function Callが少し増えるだけだ」。各CallにはCPU/Driver Overheadがあります。GPU Pathの中心は通常、Batchingと少数のBuffer Updateです。
  • 「Three.jsは2D Canvasの卒業証書だ」。Perspective、Depth、Lighting、Mesh、3D Camera、Raycasting、Materialsが本当のProduct要件である場合にだけ、3D Branchへ進みます。

本番用バックパック

前提

第15章では、同じ決定論的Sceneに対するTraceとBudgetを用意し、ボトルネックがMain-thread Contention、Canvas 2D Paint、Document/Geometryのどこにあるか特定しなければなりません。Renderer Interfaceによって実装はすでに隔離されています。DocumentにはGPU BufferやWorker Handleを保存しません。DOM Text、Keyboard、Screen Readerの層はMain Threadに残します。

正式な知識

Worker / OffscreenCanvas。 Worker Ownershipとは、Transfer後にWorkerがBitmap、Context、Resize、Render Clockを所有することです。Main ThreadはInputControllerを保持し、Normalize済みEvent/CommandをWorkerへ送ります。transferControlToOffscreen()はTransferableなOffscreenCanvasを返します。ArrayBufferとImageBitmapもTransferできます。送信後、元Ownerはそれらを使うのをやめなければなりません。それ以外のObjectにはStructured Cloneを使い、そのCostを計測します。

Message Protocolには少なくともprotocolVersion、seq、baseRevision、kindを含めます。InitializationではSnapshotを送り、その後はCommand StreamまたはDocument Diffを送ります。WorkerがRevision Gapを検出したらresync-requiredを返し、Main Threadは専用のreplace snapshotを送らなければなりません。Workerは完全なStateをInstallする前に古いRecordをClearします。Snapshotをupserts + removes: []のように扱うと、すでに削除したShapeが永遠に残ります。CameraとPointer Previewは置換可能なStateです。QueueにBackpressureがかかったら最新だけを保持します。Document Commandは勝手に捨てられません。BackpressureではmaxInFlight、独立Ack、Coalescing Ruleを定義します。Rendering Channelでは複数Diffを最新Snapshotの最終効果へまとめられますが、Main ThreadのSource-of-truth層は完全なDomain Command/Historyを保持します。Sequence NumberによってStale FrameやResponseが新しいStateを覆うことを防ぎます。

Worker-Side rAFはSupportされる環境でWorker CanvasをScheduleします。それ以外ではMain-thread frame MessageまたはThrottleしたTimerをCompatibility Pathとして使います。Background TabではThrottleまたはPauseされます。Resume時に何千もの古いFrameへ追いつこうとせず、最新Snapshot/ClockからRenderします。Worker Crashはerror/messageerror/heartbeatで検出します。壊れたWorkerをTerminateし、Documentを保持したまま新しいCanvas Hostで再構築します。失敗が続けばMain-Thread Canvas 2DへFallbackします。transferControlToOffscreen()は元の<canvas>をPlaceholderへ変えます。Main ThreadはControlを「取り返す」ことも、同じElementに2D Contextを作ることもできません。Crash Fallbackでは新しいHTMLCanvasElementへ置き換え、DocumentをReplayしなければなりません。

GPU Renderer。 Canvas 2DはImmediate-modeのCompatibility Baselineです。WebGL2とWebGPUではBuffer、Texture、Shader、Render Passを明示的に管理します。同じMaterialを持つ多数のShapeを1つまたは少数のDraw Callへまとめます。ImageはTexture Atlasへ置き、Padding、Update、Maximum Texture Dimensionを守ります。CPUでWorld TransformとVisible Instanceを計算した後、BatchでBufferへWriteします。ShaderがVertexをTransformし、Pixelを並列にShadeします。GPU PickingではOff-screen TextureへIDをEncodeできますが、ReadbackはPipelineを同期するため、CPU Spatial IndexとBenchmarkで比較しなければなりません。

CPU/GPU Synchronizationはよくある罠です。頻繁なmap/readback、Submission待ち、Shapeごとの小さなBuffer UpdateはPipelineを直列化します。WebGPU Startupではnavigator.gpu、requestAdapter()、requestDevice()、Required Limit、Featureを検出します。Adapterはnullの場合があります。device.lostをListenし、Reasonを記録して、制限した回数だけResourceを再構築します。WebGLではcontext lost/restoredをListenし、Default BehaviorをPreventして再構築します。Progressive EnhancementはBenchmarkとCapabilityの両方から実装を選びます。Compatibility Rendererは常にDocumentを開き、基本Shapeを編集・Exportできなければなりません。

同じSceneでMain Canvas 2D、Offscreen Worker、GPU Prototypeを比較します。Startup(Initialization/Compilation/First Frame)、Frame Time、Main-Thread Responsiveness、Memory、Transfer Cost、Fallback Success Rate、Implementation Complexity(Protocol、Resource、Failure Path、Test Cost)です。Frame Timeが改善してもFirst Frame、Memory、Inputが悪化したなら、勝った指標だけを報告してはいけません。

根拠と互換性(2026-08-29確認)

対象BrowserでOffscreenCanvas、Transfer、Worker rAFをFeature Detectionします。MDN OffscreenCanvas、transferControlToOffscreen()、DedicatedWorkerGlobalScope requestAnimationFrameを参照してください。WebGPUのInitializationとError HandlingにはW3C WebGPU SpecificationとMDN WebGPU APIを使います。GPUDevice.lostはDevice Loss情報を返し、Recoveryには新しいDeviceのRequestとResource再構築が必要です。Productionの結論には、自分たちのBrowser Matrixと代表Device上のEvidenceが必要です。「APIがある」ことは決して「性能がよい」ことを意味しません。

この章のエンジニアリング増分

開始点: 第15章のSceneRendererはMain-thread Canvas 2Dを使い、Traceでは100k Sceneの代表Device上でMain-thread Render p95がBudgetを超えています。到達点: 同じScene/Camera/Documentに3つのRendererを用意します。WorkerにはVersion付きProtocol、Backpressure、Out-of-order Recovery、Crash Fallbackがあり、GPU PrototypeにはFeature Detection、Batching、Device-lost Fallbackがあります。

次のFileとInterfaceを追加します。

  • src/engine/render/Renderer.ts:互換性のあるinit/apply/render/dispose契約。
  • src/engine/worker/protocol.ts、render.worker.ts、WorkerRenderer.ts:OwnershipとMessage Protocol。
  • src/engine/gpu/WebGpuRenderer.ts:Batched Rectangle Prototype。
  • src/engine/render/selectRenderer.ts:Feature + Benchmark Gate。
  • bench/renderers.bench.ts:3 Pathすべてに対する同一Scene Report。
  • src/engine/worker/__tests__/protocol.test.ts:Out-of-order Message、Missing Diff、Backpressure、Version Mismatch。

次は完全な最小Main-thread/Worker Protocolです。重要な規則は、Commandを決して捨てない、Camera UpdateはCoalesceできる、Revision GapではSnapshotをRequestする、Canvasは正確に1回だけTransferする、の4つです。

// 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で送ったTaskにはFIFO保証があります。それでもここでseqが必要なのは、実際のPipelineには非同期Image Decode、Network Result、Crash Reconstruction、注入したTest Conditionが加わり、Completion順序がSubmission順序と一致しない場合があるからです。RENDER_PROTOCOL_BACKPRESSURE_RETRYは、上位LayerがChange Setを保持しAck後にRetryするか、render stateを最新Snapshotへまとめるという意味です。Domain Commandを削除してよいという許可ではありません。ready/ackとframeを分けることで、Initがreadyとframeの両方を返してもIn-flight Countを二重に減らしません。

GPU PrototypeはDocumentを変更しません。Visible RectangleをInstance BufferへPackします。次の完全な例はCapability Check、Batching、Shader、Device Loss Signalを示します。どの失敗でもnullを返し、SelectorがCompatibility 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;
  }
}

Restaurantへ戻ると、Instance Bufferは、大勢の小さな刃へ一度に送る1つの材料Trayです。Shapeごとにdrawを呼べば、皿を1枚ずつ運ぶ方法へ逆戻りします。ViewportはUniformを通してShaderへ届くため、この例は密かに1000×1000へ固定されていません。呼び出し側は引き続きCamera、DPR、Visible Setを一貫して変換する必要があります。dispose()を意図的に呼ぶとdevice.lostはdestroyedで完了します。これはProduction Failureではないため、Rebuild Stormを起こしてはいけません。本当のDevice Lossでも台帳はMain-thread Documentに残るので、注文を失うことなくPanを交換して調理を再開できます。

Protocol TestでOut-of-order MessageとRevision Gapを注入します。

import { expect, test } from 'vitest';

test('古いseqは新しいCameraを置き換えず、Revision Gapは完全同期を要求する', () => {
  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を実行します。同じSeedに対し、Startup、Frame p50/p95/p99、Main Thread Input Delay、Memory、Transfer bytes/ms、Fallback Test、Complexity Checklistを期待します。代表Device上で事前に定めたBenefit Thresholdを満たす場合だけ、Worker/GPUをDefaultで有効にします。npm exec vitest run src/engine/worker src/engine/gpuを実行し、Out-of-order Message、Dropped Diff、古いRecordを削除するReplace Snapshot、Version Mismatch、Adapter null、Intentional Destroy、Device Lostが、決定論的なResyncまたはFallback Pathをたどることを確認します。

わざと壊す

注入する故障症状根拠修正Regression Test復旧
WorkerがthrowするCanvasが停止するerror + last ack/revisionTerminateし、新しい<canvas>で再構築するCrash-on-seq Test新HostでMain-thread 2DへFallback
MessageがOut-of-orderで届くCameraが後ろへJumpするseq traceseq <= lastSeqを捨てる42の後に41最新Cameraを保持する
Diffが失われるShapeが欠けたままになるbaseRevision gapresync-required SnapshotDrop-revision TestAuthoritative Documentから再構築する
WebGPUがないInitialization ExceptionFeature ReportProgressive Selectionnavigator.gpuがないCanvas 2D
AdapterがnullBlank ScreenAdapter Resultnullを返し、Resourceを作らないMockしたnullWorker/Main 2D
Device LostGPU Frameが止まるdevice.lost reason意図的なdestroyedは無視し、本当のLossだけ制限付きで再構築Injected-loss + dispose TestDocumentをReplayする
Integrated GPUのほうが遅いInput/Frame p95が悪化する同一Scene A/BDevice-profile GateLow-power ProfileDefaultを2Dにする
TextureがLimitを超えるImageが欠けるDevice Limit/Asset SizeAtlasをPage分割しScaleするOversized ImagePlaceholder
PageがBackgroundへ入るResume時に古いFrameをReplayするVisibility + seqStale Workを捨て、最新だけを描くHide/show Test最新Snapshot
Main/WorkerのProtocol Versionが異なるParse ErrorHandshake Version拒否してRefresh/FallbackVersion 1 vs 2Compatibility Renderer

根拠を示して合格する

自動化された根拠手動の根拠合格条件
3 Rendererの同一Scene Benchmark、ProtocolのOut-of-order/Loss/Crash、Adapter-null/Device-lost Test代表Hardware上でRendererを切り替え、First Frame、Input Response、Fallback Noticeを確認する自動Benefit Thresholdを満たし、手動Fallback後もDocumentとEditabilityが保たれる
Browser Matrix Feature ReportとResource-limit TestIntegrated Graphics、Background Tab、GPU-disabled環境を各1回試すすべての環境が動作するCompatibility Rendererを選ぶ
判断必要な根拠
Workerが必要かMain Thread Input Delayの原因が移動可能なCPU/描画Workで、BenefitがMessage Costを上回る
GPUが必要かCanvas 2D Renderが明確なボトルネックで、Batched Prototypeが代表Deviceを改善する
Documentを失う可能性があるかRenderer Crash後、Durable/Current Documentから完全再構築する
Messagingは信頼できるかseq、baseRevision、ack、backpressure、resync Test
非対応ならどうするかFeature Detectionと自動Compatibility Renderer
Complexityに見合うかStartup、Memory、Transfer、Failure Path、Maintenance Costをすべて列挙する
  • Main-Thread 2D、Worker Offscreen 2D、GPU Prototypeを同じSceneで比較した。
  • Workerが唯一のCanvas Ownerであり、Commandは決して捨てず、Camera/PreviewだけをCoalesceできる。
  • Out-of-order Message、Missing Diff、Worker Crash、Background RecoveryにAutomated Testがある。
  • WebGPU Adapter null、Device Lost、Limit不足がすべてData LossなしでFallbackする。
  • GPU Hot PathにSynchronous Readbackがなく、PickingをCPU GeometryとBenchmarkで比較した。
  • Benchmarkの根拠でBenefitがなければ、単純なRendererを維持する。
  • Perspective/Depth/Lighting/Mesh/3D Camera/Raycasting/Materialsが本当の要件である場合だけThree.jsを評価する。

5歳児に説明する

「Worker」「GPU」「OffscreenCanvas」「Buffer」という言葉を使わずに説明してください。仕事をKitchenへ送ると受付がBellへ早く答えられるのに、料理自体が必ず早く完成するわけではないのはなぜでしょう。大勢の小さな料理人は同じ人参を切るのが得意なのに、誤った住所の確認を手伝えないのはなぜでしょう。Kitchenが停電しても注文が残るのはなぜでしょう。

専門用語を使わない合格回答

受付は客を迎え、注文を書くだけにします。時間のかかる食材切りをKitchenへ送れば次のBellを聞き逃しませんが、注文をCopyして運ぶことにも時間がかかるため、料理の完成が早まるとは限りません。大勢の小さな料理人は同じ形のものを一緒にたくさん作れますが、1文字ずつ調べる必要がある誤った住所には役立ちません。すべての注文には新旧を示す番号があり、遅れてきた古い料理が新しい料理を覆うことはありません。本当の台帳はPanの中に保存していないため、Kitchenや大きな機械が壊れても、普通のPanへ戻し、台帳から料理を作り直せます。