コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
5歳児にもわかるゲームから始めよう
本章で伝える唯一の真実:Workerは仕事を受付のQueueから外へ移し、GPUは適した仕事を大勢の小さな料理人へ並列に分けます。両者が解決する問題は同じではありません。
おもちゃのRestaurantを作ります。受付の子どもはBellに答え、注文を書き、客へ応対しなければなりません。Kitchenの子どもたちは食材を切り、皿へ盛ります。注文のたびに受付係まで1万本の人参を切る必要があれば、次の客がBellを鳴らしても誰も答えられません。Main Threadが描画や計算に使い切られた状態と同じです。切るものの一覧をKitchenへ送れば、受付は客への応対を続けられます。これがWorkerに相当します。
今度はKitchenへ2種類の道具を渡します。1つは順番に切る普通の包丁、もう1つは同じ形の小さな刃が多数並び、一度に数百枚を切れるPressです。後者はGPUに似ています。まず予想してください。受付が遅い理由が、誤った住所を1文字ずつ確認していることなら、人参を切る刃を増やして役に立つでしょうか。いいえ。では、1つの複雑なDessertを作るとき、型を用意し、材料を運び、機械を洗う工程を追加すれば必ず速くなるでしょうか。これも違います。
- 受付がInputを受ける常に客への応答を優先する
- 注文に番号を付ける古い注文が新しい注文を覆わない
- KitchenがPanを所有する描画Ownerは必ず1つ
- 大勢の小さな料理人が並列に働く同じ仕事をBatchにする
- 故障時は冷たい料理を残すCompatibility RendererへFallbackする
Upgradeは勲章ではありません。第15章でMain-Thread Canvas 2DがすでにBudgetを満たすと証明できたなら、そのまま使います。単純なArchitectureほど、Protocol、Copy、Dropped Frame、Device Loss、Compatibilityに関する故障が少なくなります。
おもちゃをCanvasに翻訳する
| おもちゃのRestaurant | Canvas Lab | Engineering上の意味 |
|---|---|---|
| 受付 | Main Thread | Input、DOM Overlay、Accessibility、React UI |
| Kitchen | Dedicated Worker | Document Diff処理とOffscreenCanvas描画 |
| PanをKitchenへ渡す | transferControlToOffscreen() | Canvas Controlを正確に1回だけTransferする |
| 注文のCopy | Structured Clone | Clone可能なMessageをDefaultでCopyする |
| Trayそのものを渡す | Transferable | ArrayBuffer/ImageBitmapのOwnershipをTransferする |
| 注文形式 | Message Protocol | Version、Sequence、Type、Payload、Error |
| 変わった部分だけを書く | Document Diff / Command Stream | Document全体を繰り返しCloneしない |
| 注文番号 | Sequence Number | Out-of-order ResponseとStale Frameを捨てる |
| Kitchenが満杯なら注文を追加しない | Backpressure | 置換可能なCamera/Preview Updateをまとめる |
| Batch全体をPressで切る | GPU Batching | Draw Callを減らし、Instance DataをBatch化する |
| 切断用の型 | Shader | 並列のVertex/Fragment Program |
| 材料倉庫 | Buffer / Texture Atlas | GPU DataとImage Collection |
| 結果を受付へ戻して検査する | Readback | CPUが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/revision | Terminateし、新しい<canvas>で再構築する | Crash-on-seq Test | 新HostでMain-thread 2DへFallback |
| MessageがOut-of-orderで届く | Cameraが後ろへJumpする | seq trace | seq <= lastSeqを捨てる | 42の後に41 | 最新Cameraを保持する |
| Diffが失われる | Shapeが欠けたままになる | baseRevision gap | resync-required Snapshot | Drop-revision Test | Authoritative Documentから再構築する |
| WebGPUがない | Initialization Exception | Feature Report | Progressive Selection | navigator.gpuがない | Canvas 2D |
| Adapterがnull | Blank Screen | Adapter Result | nullを返し、Resourceを作らない | Mockしたnull | Worker/Main 2D |
| Device Lost | GPU Frameが止まる | device.lost reason | 意図的なdestroyedは無視し、本当のLossだけ制限付きで再構築 | Injected-loss + dispose Test | DocumentをReplayする |
| Integrated GPUのほうが遅い | Input/Frame p95が悪化する | 同一Scene A/B | Device-profile Gate | Low-power Profile | Defaultを2Dにする |
| TextureがLimitを超える | Imageが欠ける | Device Limit/Asset Size | AtlasをPage分割しScaleする | Oversized Image | Placeholder |
| PageがBackgroundへ入る | Resume時に古いFrameをReplayする | Visibility + seq | Stale Workを捨て、最新だけを描く | Hide/show Test | 最新Snapshot |
| Main/WorkerのProtocol Versionが異なる | Parse Error | Handshake Version | 拒否してRefresh/Fallback | Version 1 vs 2 | Compatibility 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 Test | Integrated 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へ戻し、台帳から料理を作り直せます。