コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
5歳児にもわかるゲームから始めよう
白い紙を1枚、透明なプラスチック板を1枚、積み木を1箱、そして消して書き直せる小さな黒板を用意します。白い紙には「注文A」と書き、透明板には矢印を描き、積み木は2つの四角形になるように並べ、最後に黒板の一部を青く塗ります。
先へ進む前に、4つのことを予想してください。文字を最も直しやすいのはどれでしょう。下にある文字を透かして見られるのはどれでしょう。手で直接動かせるのはどれでしょう。消した後、もともと何が描かれていたのか最もわかりにくいのはどれでしょう。実際に一度試し、「注文A」の A を B に変えてみます。「どれも同じ絵を表せる」ことは、「同じ操作のコストも同じ」ことを意味しないとわかります。
- 紙に文字を書く読めて、選択できる
- 透明板に線を描く拡大しても鮮明
- 積み木を並べる一つずつ識別できる
- 黒板を塗る残るのはPixelだけ
これが本章で伝える唯一の真実です。Infinite Canvasとは、Pan、Zoom、Object配置ができるプロダクトの操作モデルであり、描画技術ではありません。 DOM、SVG、Canvas 2D、GPU、あるいはそれらを組み合わせて実現できます。
おもちゃをCanvasに翻訳する
| おもちゃの素材 | エンジニアリング上の対応物 | 本当に問うべきこと |
|---|---|---|
| 白い紙に書いた文字 | DOM | 入力、選択、読み上げ、自動Layoutが必要か |
| 透明板に描いた線 | SVG | Vector Geometryとアドレス可能なNodeが必要か |
| 消せる黒板に塗った色 | Canvas 2D Bitmap | 大量の独自2D Pixel描画が必要か |
| 大勢の料理人が同時に盛り付ける | WebGL / WebGPU | Batchingや並列計算でボトルネックを解消できるという証拠があるか |
| 黒板に紙を貼る | Hybrid DOM/Canvas | Text編集はDOMに残し、Graphic描画はCanvasに任せる |
| GPU画面に紙を貼る | Hybrid DOM/GPU | Semantic層はDOMに残し、高Throughput描画はGPUに任せる |
| 「どこまでも動かせる机」 | Infinite Canvas | 基盤となる素材を指定しないプロダクト挙動 |
この比喩が通用するのはここまでです。現実の素材は一度に1か所にしか置けませんが、BrowserはCanvasの上にDOMを重ねられます。SVGも本物の透明板ではなく、やはりBrowserによってRasterizeされます。GPUも常に高速な魔法の料理人ではありません。Data Upload、Shader、Device Loss、Accessibilityにはそれぞれコストがあります。比喩は最初に正しい問いを立てる助けにはなりますが、Measurement、仕様、互換性検証の代わりにはなりません。
まず誤った直感を捨てる
- 「Infinite Canvasなら
<canvas>を使うべきだ」。誤りです。Infinite Canvasが約束するのはCameraとObjectの操作だけです。Textが多く、Object数が中程度で、Semanticsが重要なプロダクトでは、DOM/SVGのほうが適していることがよくあります。 - 「Objectが多ければ必ずCanvasを使う」。誤りです。総Object数が10万でも表示中が10個の場面と、影や画像を持つ2,000個が表示中の場面ではコストが異なります。まず表示数、変更頻度、Pixel面積を測ります。
- 「Three.jsはCanvas 2Dの上位版だ」。誤りです。Three.jsはScene、Camera、Mesh、Material、Depthなどの3D課題を扱います。普通の2D Whiteboardにそれらが不要なら、導入されるのは別種の、より複雑な制約です。
- 「tldrawはすべてのShapeをCanvas 2Dで描いている」。誤りです。tldrawの公式Shape契約では
component()がReact Componentを返し、Export経路ではSVGを生成します。Library名やプロダクトの見た目だけではRendererを証明できません。ここで述べているのは主要なShape描画Architectureであり、個々のComponent内部で<canvas>を使えることを否定しているわけではありません。
本番用バックパック
前提となる契約
本章ではMatrixやHit Testingの知識は求めません。Browser、TypeScript、Vitestを実行できれば十分です。まずプロダクト要件を固定します。Canvas Labは業務Process Diagram Editorです。CardのTitleをNativeに編集でき、ConnectorはZoomしても鮮明で、Gridは高頻度に移動します。ExportにはPNGと構造化JSONが必要で、KeyboardとScreen Readerも欠かせません。
正式な知識:信仰ではなく評価軸で選ぶ
| Renderer | 強み | 主なコスト | Canvas Labでの役割 |
|---|---|---|---|
| DOM | Native Text、CSS Layout、Semantics、Focus、Automated Testing | 多数のNodeを高頻度に変形するとStyle/Layoutコストが増える | Toolbar、Form、Text編集、Inspector |
| SVG | Vector、アドレス可能なGeometry Node、CSS、鮮明なExport | 多数のNodeが継続的に変化する場合はDOMコストの計測が必要 | 小規模なConnectorまたはExportの候補 |
| Canvas 2D | 2D描画を直接制御でき、APIが成熟し、頻繁な再描画に向く | Retained Object、Native Semantics、Text編集がない | 学習用KernelのGrid、Shape、Overlay |
| WebGL | 成熟したGPU Pipeline、Batching、Shader | Buffer、Texture、Picking、Context Lossを自分で扱う必要がある | Profileで根拠を得た後の置換候補 |
| WebGPU | より現代的なGPU Compute/Rendering Interface | 対応範囲、Device/Pipeline管理、Fallbackのコスト | Progressive Enhancementの実験であり、Baselineにはしない |
| DOM/Canvas | Semantic UIと高頻度Pixel層がそれぞれの役割を担う | 2層の座標、Focus、Stackingを同期する必要がある | 現在の選択 |
| DOM/GPU | Native UIと高Throughput描画 | 2つのLifecycleと、より高い実装難度 | 規模が大きく、GPUの効果が証明された場合 |
Reviewでは、Native Text編集、DOM Semantics、Accessibility、表示Object数、総Object数、秒間変更頻度、Pixel Effect、視覚的・SemanticなExport要件、Renderer置換コスト、多人共同編集Model、対象Browser、そしてTeamがGeometry/GPUを保守できるかを一項ずつ記録しなければなりません。共同編集そのものはRendererを決めません。同期するのはDocument Operationであり、Pixelではないからです。
根拠と互換性
Browserは<canvas>を、2d、webgl、webgl2、webgpuなどのContextをBindingできるElementとして定義しています。この事実自体が、Elementと特定のプロダクトが同じものではないと示しています。Release前には対象Browserの互換表を確認し、実機Benchmarkを行います。「Baseline」を性能保証として扱ってはいけません。
- MDN:Canvas API
- MDN:SVG
- MDN:WebGL API
- MDN:WebGPU API
- WHATWG HTML:Canvas
- tldraw:ShapesとReact componentの描画契約
上記Sourceの確認日は2026-08-29です。WebGPUの可用性とCapabilityはRuntimeで検出しなければなりません。本Tutorialでは、特定のBrowser Versionを永続的な事実として固定しません。
この章のエンジニアリング増分
開始点: 空のPageと、同じ一組のBusiness Data。到達点: DOM、SVG、Canvas 2Dの3実装が同じ「注文→発送」Business Diagramを表示し、Project最初のADRを作成します。File構成は次のとおりです。
canvas-lab/
src/lab/ch01/render-three-ways.ts
src/lab/ch01/index.html
src/lab/ch01/renderer-choice.ts
src/lab/ch01/renderer-choice.test.ts
docs/adr/0001-canvas-2d-teaching-kernel.md
完全なHTML Containerです。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>Canvas Lab / Renderer</title>
</head>
<body>
<main>
<section>
<h1>DOM</h1>
<div id="dom-demo"></div>
</section>
<section>
<h1>SVG</h1>
<svg id="svg-demo" viewBox="0 0 360 140" aria-label="注文プロセス図"></svg>
</section>
<section>
<h1>Canvas</h1>
<canvas id="canvas-demo" width="360" height="140">注文から発送まで</canvas>
</section>
</main>
<script type="module" src="./render-three-ways.ts"></script>
</body>
</html>
完全なTypeScriptです。3つの経路が同じDataを消費するため、Business ObjectがRendererに固定されません。
type Card = { id: string; label: string; x: number; y: number };
type Diagram = { cards: readonly Card[]; edge: readonly [string, string] };
export const diagram: Diagram = {
cards: [
{ id: 'order', label: '注文', x: 20, y: 40 },
{ id: 'ship', label: '発送', x: 240, y: 40 },
],
edge: ['order', 'ship'],
};
function boxStyle(card: Card): string {
return `position:absolute;left:${card.x}px;top:${card.y}px;width:100px;height:56px;border:2px solid #2563eb;border-radius:10px;display:grid;place-items:center`;
}
export function renderDOM(root: HTMLElement, model: Diagram): void {
root.replaceChildren();
root.style.cssText = 'position:relative;width:360px;height:140px';
const line = document.createElement('div');
line.style.cssText =
'position:absolute;left:120px;top:68px;width:120px;border-top:2px solid #64748b';
root.append(line);
for (const card of model.cards) {
const article = document.createElement('article');
article.dataset.shapeId = card.id;
article.style.cssText = boxStyle(card);
article.textContent = card.label;
root.append(article);
}
}
export function renderSVG(root: SVGSVGElement, model: Diagram): void {
const ns = 'http://www.w3.org/2000/svg';
root.replaceChildren();
const line = document.createElementNS(ns, 'line');
Object.entries({
x1: '120',
y1: '68',
x2: '240',
y2: '68',
stroke: '#64748b',
'stroke-width': '2',
}).forEach(([key, value]) => line.setAttribute(key, value));
root.append(line);
for (const card of model.cards) {
const group = document.createElementNS(ns, 'g');
group.dataset.shapeId = card.id;
const rect = document.createElementNS(ns, 'rect');
Object.entries({
x: String(card.x),
y: String(card.y),
width: '100',
height: '56',
rx: '10',
fill: 'white',
stroke: '#2563eb',
'stroke-width': '2',
}).forEach(([key, value]) => rect.setAttribute(key, value));
const text = document.createElementNS(ns, 'text');
text.setAttribute('x', String(card.x + 50));
text.setAttribute('y', String(card.y + 34));
text.setAttribute('text-anchor', 'middle');
text.textContent = card.label;
group.append(rect, text);
root.append(group);
}
}
export function renderCanvas(canvas: HTMLCanvasElement, model: Diagram): void {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2Dを利用できません');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#64748b';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(120, 68);
ctx.lineTo(240, 68);
ctx.stroke();
for (const card of model.cards) {
ctx.fillStyle = 'white';
ctx.strokeStyle = '#2563eb';
ctx.beginPath();
ctx.roundRect(card.x, card.y, 100, 56, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#0f172a';
ctx.textAlign = 'center';
ctx.font = '16px system-ui';
ctx.fillText(card.label, card.x + 50, card.y + 34);
}
}
renderDOM(document.querySelector('#dom-demo')!, diagram);
renderSVG(document.querySelector('#svg-demo')!, diagram);
renderCanvas(document.querySelector('#canvas-demo')!, diagram);
Decision InterfaceとTestにより、ADRへ検証可能なInputを与えます。
export type ProductNeeds = Readonly<{
nativeSceneText: boolean;
semanticScene: boolean;
visibleDynamicShapes: number;
changesPerSecond: number;
benchmark?: Readonly<{ domP95Ms: number; canvasP95Ms: number; frameBudgetMs: number }>;
}>;
type SceneChoice = 'dom' | 'canvas2d' | 'prototype-required';
export function chooseLayers(n: ProductNeeds): { ui: 'dom'; scene: SceneChoice } {
if (n.nativeSceneText || n.semanticScene) return { ui: 'dom', scene: 'dom' };
if (!n.benchmark) return { ui: 'dom', scene: 'prototype-required' };
const canvasWins =
n.benchmark.canvasP95Ms <= n.benchmark.frameBudgetMs &&
n.benchmark.canvasP95Ms < n.benchmark.domP95Ms;
return { ui: 'dom', scene: canvasWins ? 'canvas2d' : 'dom' };
}
import { describe, expect, it } from 'vitest';
import { chooseLayers } from './renderer-choice';
describe('renderer ADR rule', () => {
it('never moves semantic product UI into the bitmap', () => {
expect(
chooseLayers({
nativeSceneText: true,
semanticScene: true,
visibleDynamicShapes: 2000,
changesPerSecond: 120,
}),
).toEqual({ ui: 'dom', scene: 'dom' });
});
it('refuses to turn an object count into an unmeasured renderer verdict', () => {
expect(
chooseLayers({
nativeSceneText: false,
semanticScene: false,
visibleDynamicShapes: 2000,
changesPerSecond: 120,
}).scene,
).toBe('prototype-required');
});
it('permits Canvas only when the measured scene meets its frame budget', () => {
expect(
chooseLayers({
nativeSceneText: false,
semanticScene: false,
visibleDynamicShapes: 2000,
changesPerSecond: 120,
benchmark: { domP95Ms: 24, canvasP95Ms: 8, frameBudgetMs: 12 },
}).scene,
).toBe('canvas2d');
});
});
ADRには、学習用KernelでCanvas 2Dを選んだ理由が、Bitmap Lifecycle、Renderer、Geometry、Schedulingを自分で実装するためだと明記します。Toolbar、Form、Text編集は引き続きDOMに置きます。置換Triggerには、「Native TextとSemanticsが支配的になればDOM/SVGへ戻す」と、「BenchmarkでCanvas 2Dがp95 Frame Budgetを満たせず、GPU Prototypeによる改善が確認できた場合にGPUへ移行する」を含めます。互換性、Team Capability、Export、共同編集、Exit Costも記録します。
visibleDynamicShapesとchangesPerSecondは実験ScenarioとADRのInputになりますが、製品横断の普遍的なThresholdではありません。Benchmarkがなければ「Prototypeが必要」と返すことで、例の数値が法則のように見えるのを防ぎます。npm run devを実行し、Browserに同じ内容の図が3つ表示され、DOM Textを選択できることを確認します。npx vitest run src/lab/ch01/renderer-choice.test.tsを実行し、3 passedを期待します。
ゲームに戻りましょう。「紙と黒板のどちらが先進的か」を議論したのではありません。編集できる文字は紙に置き、繰り返し塗り直す場面は黒板に置きました。
わざと壊す
Titleと通常のFormもCanvas内に描き、Pageから本物の<h1>、<label>、<input>を削除します。
| 注入する故障 | 症状 | 根拠 | 修正 | Regression Test | 復旧 |
|---|---|---|---|---|---|
| Pixelの見出し | 見出しを選択できず、Document Outlineにも現れない | DevTools Accessibility Treeにheadingがない | 本物の<h1>を使い、CanvasにはSceneだけを描く | getByRole('heading')が成功する | DOMの見出しを戻す |
| PixelのInput | Tabで入れず、Screen Readerがlabel/valueを認識できない | Keyboard操作の録画とAccessibility Tree | <label><input>のDOM Overlayを使う | Keyboardだけで入力して送信する | 偽Inputを削除する |
| CanvasでLayoutしたForm | CSS Grid、折り返し、Validationをすべて手作業で再実装する必要がある | 実装Code量とBrowser Test数を比較する | Product UIをDOMに保つ | 200% Zoomでも重ならない | 元のLayoutを戻す |
| 座標AssertによるTest | Font/DPRが変わると壊れやすい | 別OSでTestが失敗する | DOMはRoleで検索し、CanvasはModelとScreenshotをTestする | Node Unit TestとBrowser Visual Test | Hard-coded座標を削除する |
Screen Reader、Keyboard、Text Selection、CSS、Layout、Testingは「後から足す飾り」ではなく、Renderer選定のコストです。故障実験を終えたら本物のDOM Controlを戻し、KeyboardとScreen Readerの確認をもう一度実行します。
根拠を示して合格する
| 証明する結論 | 受け入れられる根拠 | 受け入れられない回答 |
|---|---|---|
| どの層にCanvasが必要か | 動的な表示ObjectとFrame Trace、ADR、Prototype | 「Canvasのほうが速い」 |
| どの層をDOMに残すか | Keyboard、Semantic Tree、Text編集の要件 | 「みんなそうしている」 |
| Rendererを置換できるか | 3つの実装が同じDiagram Typeを消費する | Business ObjectをDOM Nodeとして保存する |
| 互換性を制御できるか | 対象Browserでの実測とFeature Detection | 世界全体のSupport Tableだけを見る |
- Infinite Canvasと
<canvas>Elementの違いを説明できる。 - DOM、SVG、Canvas 2D、WebGL、WebGPU、2種類のHybridを比較した。
- Object数だけでなく、12の選定軸をすべて記入した。
- 3つの実装が同じBusiness Diagramを表示し、同じDataを消費する。
- Canvasの偽Form故障について、録画、Semantic Tree、Testの根拠があり、復旧も済んでいる。
- ADRに現在の選択、置換条件、共同編集・Export・License・Teamのコストを記した。
5歳児に説明する
「Renderer」「DOM」「GPU」という言葉を使わずに答えてください。読み上げられる物語の本を、なぜ1枚の写真として全部描いてはいけないのでしょう。では、高速で動き続ける色鮮やかな背景を、なぜ1万枚の編集可能な紙片で作る必要はないのでしょう。
専門用語を使わない合格回答を開く
写真はたくさんの色を一度に見せるのは得意ですが、どこが題名で、どこに文字を入力できるかを知りません。本を読む機械にも、どこから読めばよいかわかりません。だから文字やButtonは、見つけて、選んで、操作できるものとして残します。反対に、ずっと動く大きな色の面を細かな紙片に分けすぎると、動くたびに全部を並べ直すことになり、とても忙しくなります。人が理解して操作するものには名前を残し、素早く描き直す部分はお絵描き板に任せるのが最善です。ゲームで黒板に紙を貼ったのと同じです。