コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
5歳児にもわかるゲームから始めよう
Cookieを2枚、苺を1粒、紙を1枚用意します。最初に「笑顔のCake」を作るときは、好きなように並べた後、友達に皿を空にしてもらいます。そして「さっきの少しだけを戻して」とだけ伝えます。友達には「少し」がどこなのかわかりません。2回目は先にRecipeを書きます。皿をきれいに拭く。Cookieを(30, 20)と(70, 20)へ置く。苺を(50, 55)へ置く。最後に口を描く。皿を空にしてから別の友達へRecipeを渡しても、同じ笑顔を再構築できます。
まず予想してください。「半透明のSyrupをかける」という手順に「使い終えたら普通のSyrupへ戻す」と書かれていなければ、次のCookieはどうなるでしょう。「丸い皿の内側だけに粉砂糖を振る」という手順に終了の印がなければ、皿の外の飾りはどうなるでしょう。
- Recipe全体を読むObjectとCamera
- 皿をきれいに拭く古い色に頼らない
- Layer順に並べるBackground→Shape→Hint
- 結果を比較する毎回同じ
本章で伝える唯一の真実は、信頼できるCanvas Rendererは、Dataから画面全体を再構築できなければならないということです。目標Interfaceはrender(model, camera, context)であり、「UserがDragしたので、古いPixelへ少し描き足す」ではありません。
おもちゃをCanvasに翻訳する
| Cake遊びの概念 | Canvas Engineering | 役割 |
|---|---|---|
| 材料一覧 | Document Model | 事実を保存する |
| 盛り付けを見る角度 | Camera | Worldをどう投影するか決める |
| Recipe | Pure Renderer | 同じInputから同じ描画Commandを出す |
| 皿を拭く | clear/background pass | 古いBitmapを取り除く |
| 型の輪郭 | Path / Path2D | Geometry Boundaryを記述する |
| Syrupと輪郭線 | Fill / Stroke | Pathの内部または縁を着色する |
| 手袋を着けて外す | save() / restore() | Transform、Alpha、Clip、Styleを隔離する |
| 分担する料理人 | Render Pass | Background、Grid、Shape、Overlayを順序づける |
比喩が通用しない点もあります。Canvasのcurrent pathは保存Stackへ完全には含まれないため、save()がbeginPath()以前のPathまで消してくれると思ってはいけません。Path2Dは再利用可能なGeometry Containerですが、Document内でStable IDを持つBusiness Objectではありません。BrowserのFont、Image Decode、Antialiasingによって、Platform間のPixelはBit単位で一致しないこともあります。したがってDeterminismは、「Command順序/Model結果」と「許容誤差内の視覚結果」の両方で検証します。
まず誤った直感を捨てる
- 「Drag中は古いRectangleを消して新しいものを描けば最速だ」。古いRectangleの下にはGrid、Line、透明Layerがあるかもしれません。厳密なDirty Region Algorithmなしの局所修正はGhostを残します。学習用Kernelでは、まず全体を再構築します。
- 「
save()は自動的にrestore()と対になる」。なりません。ExceptionやEarly ReturnでStateがLeakするため、try/finallyを使います。 - 「各Shapeが好きなようにContextを変更してよい」。1つのShapeのalpha、clip、transform、lineJoinが、後続するすべてのShapeを汚染します。RegistryはIsolation Boundaryを設けなければなりません。
- 「見た目が同じなら決定論的だ」。Random Number、Current Time、未LoadのFont/Image、Stableな並び順を持たないObjectによって、次回の結果が変わります。
- 「PathがShapeだ」。Pathが持つのはGeometryだけです。Business Identity、Parent/Child関係、Permission、Persistence Semanticsは持ちません。
本番用バックパック
前提となる契約
前提は第2章のCanvasHostです。Bitmap寸法が変わるたびにDPRの基礎Transformを復元し、Rendererを呼びます。DocumentはRead-only Inputで、Rendererはそれを変更しません。Cameraは明示的なInputです。同じz値ではStable IDでTie-breakします。TimeとImage Resourceは明示的なParameterとして渡します。
正式な知識
Canvas 2DのPathは、current Path(beginPath/moveTo/lineTo/...)またはPath2Dで構築できます。fill()は内部を塗り、stroke()は縁に沿って描きます。lineCapは開いた線分の端を、lineJoinは角を決めます。どちらも見えるBoundaryへ影響するため、後のGeometry章と一致させなければなりません。
clip()は後続描画をRegion内へ制限し、以前のStateをRestoreしなければ終了できないため、save/restoreで囲みます。globalCompositeOperationはSource Pixelと既存Destination Pixelの合成方法を決め、globalAlphaは全体の透明度をさらに調整します。どちらかがLeakすると、「後から描いたObjectが消える、または薄くなる」ことがあります。GradientはContextが作りColor Stopを持ちます。PatternはImageやCanvasを繰り返します。これらはRendering Resourceであり、SerializeするDocumentへ詰め込んではいけません。drawImage()はHTMLImageElement、ImageBitmapなどを描けますが、Decode StateとCORSはAsset章で扱います。fillText()によるPrimitive TextはPreview Labelには適していますが、Caret、Selection、IME、Rich Text Editingは提供しません。
Rendererは固定Passに従います。BackgroundでBase Color全体を確立し、GridでCamera依存のReferenceを描き、Shapeはz-order順、Overlayは選択BoxやHintを描きますがDocumentへ書き込みません。各Passと各ShapeにwithSavedContext Boundaryを設け、Context State Isolationを監査可能にします。ここでいうPure Rendererは、数学的な意味で「副作用がない」のではありません。対象Bitmapへ必ず書き込みます。意味するのは、対象Context以外のModelを変更せず、隠れたGlobal Stateを読まず、同じInputから同じCommand Sequenceを出すことです。
根拠と互換性
- MDN:Canvas 2D drawing shapes / Path2D
- MDN:Applying styles and colors
- MDN:Compositing and clipping
- MDN:CanvasRenderingContext2D.drawImage
- MDN:save()とrestore()
- WHATWG HTML:2D rendering context
上記Sourceの確認日は2026-08-29です。roundRect()と一部のPath2D利用方法は対象Browserで確認します。非対応なら、moveTo/lineTo/quadraticCurveToを使って同じ輪郭を構築できます。Visual RegressionではFont、DPR、Color Space、Resource Decode時点を固定します。
この章のエンジニアリング増分
開始点: 第2章のHostにある例のCallbackがfillRectを直接呼びます。到達点: 4つの固定Passを用意し、Rectangle、Ellipse、Polyline、Image Placeholder、Primitive Text LabelをすべてDocumentから再構築します。
canvas-lab/src/lab/ch03/
model.ts
renderer.ts
renderer.test.ts
完全なModelとRendererです。主要Logicに隠れた省略はありません。
export type Camera = { x: number; y: number; zoom: number };
export type Shape =
| {
id: string;
type: 'rectangle';
x: number;
y: number;
w: number;
h: number;
fill: string;
z: number;
}
| {
id: string;
type: 'ellipse';
x: number;
y: number;
rx: number;
ry: number;
fill: string;
z: number;
}
| {
id: string;
type: 'polyline';
points: readonly { x: number; y: number }[];
stroke: string;
z: number;
}
| {
id: string;
type: 'image';
x: number;
y: number;
w: number;
h: number;
label: string;
z: number;
}
| { id: string; type: 'text'; x: number; y: number; text: string; color: string; z: number };
export type DocumentModel = {
background: string;
shapes: readonly Shape[];
selectedIds: ReadonlySet<string>;
};
function isolated(ctx: CanvasRenderingContext2D, draw: () => void): void {
ctx.save();
try {
draw();
} finally {
ctx.restore();
}
}
function shapeBounds(shape: Shape): { x: number; y: number; w: number; h: number } {
if (shape.type === 'rectangle' || shape.type === 'image') return shape;
if (shape.type === 'ellipse')
return { x: shape.x - shape.rx, y: shape.y - shape.ry, w: shape.rx * 2, h: shape.ry * 2 };
if (shape.type === 'text')
return { x: shape.x, y: shape.y - 16, w: shape.text.length * 10, h: 20 };
if (shape.points.length === 0) return { x: 0, y: 0, w: 0, h: 0 };
const xs = shape.points.map((p) => p.x),
ys = shape.points.map((p) => p.y);
return {
x: Math.min(...xs),
y: Math.min(...ys),
w: Math.max(...xs) - Math.min(...xs),
h: Math.max(...ys) - Math.min(...ys),
};
}
function drawShape(ctx: CanvasRenderingContext2D, shape: Shape): void {
isolated(ctx, () => {
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
if (shape.type === 'rectangle') {
const path = new Path2D();
path.rect(shape.x, shape.y, shape.w, shape.h);
ctx.fillStyle = shape.fill;
ctx.fill(path);
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 1;
ctx.stroke(path);
return;
}
if (shape.type === 'ellipse') {
ctx.beginPath();
ctx.ellipse(shape.x, shape.y, shape.rx, shape.ry, 0, 0, Math.PI * 2);
ctx.fillStyle = shape.fill;
ctx.fill();
return;
}
if (shape.type === 'polyline') {
const [first, ...rest] = shape.points;
if (!first) return;
ctx.beginPath();
ctx.moveTo(first.x, first.y);
for (const point of rest) ctx.lineTo(point.x, point.y);
ctx.strokeStyle = shape.stroke;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
return;
}
if (shape.type === 'image') {
ctx.fillStyle = '#e2e8f0';
ctx.fillRect(shape.x, shape.y, shape.w, shape.h);
ctx.strokeStyle = '#64748b';
ctx.strokeRect(shape.x, shape.y, shape.w, shape.h);
ctx.beginPath();
ctx.moveTo(shape.x, shape.y);
ctx.lineTo(shape.x + shape.w, shape.y + shape.h);
ctx.moveTo(shape.x + shape.w, shape.y);
ctx.lineTo(shape.x, shape.y + shape.h);
ctx.stroke();
ctx.fillStyle = '#334155';
ctx.fillText(shape.label, shape.x + 8, shape.y + 18);
return;
}
ctx.fillStyle = shape.color;
ctx.font = '16px system-ui';
ctx.textBaseline = 'alphabetic';
ctx.fillText(shape.text, shape.x, shape.y);
});
}
export function renderDocument(
model: DocumentModel,
camera: Camera,
ctx: CanvasRenderingContext2D,
viewport: { width: number; height: number; dpr: number },
trace: (event: string) => void = () => undefined,
): void {
const ordered = [...model.shapes].sort((a, b) => a.z - b.z || a.id.localeCompare(b.id));
trace('pass:background');
isolated(ctx, () => {
ctx.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0);
ctx.fillStyle = model.background;
ctx.fillRect(0, 0, viewport.width, viewport.height);
});
trace('pass:grid');
isolated(ctx, () => {
const scale = camera.zoom * viewport.dpr;
ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1 / camera.zoom;
const left = Math.floor(camera.x / 20) * 20,
top = Math.floor(camera.y / 20) * 20;
for (let x = left; x < camera.x + viewport.width / camera.zoom; x += 20) {
ctx.beginPath();
ctx.moveTo(x, camera.y);
ctx.lineTo(x, camera.y + viewport.height / camera.zoom);
ctx.stroke();
}
for (let y = top; y < camera.y + viewport.height / camera.zoom; y += 20) {
ctx.beginPath();
ctx.moveTo(camera.x, y);
ctx.lineTo(camera.x + viewport.width / camera.zoom, y);
ctx.stroke();
}
});
trace('pass:shape');
isolated(ctx, () => {
const scale = camera.zoom * viewport.dpr;
ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
for (const shape of ordered) {
trace(`shape:${shape.id}`);
drawShape(ctx, shape);
}
});
trace('pass:overlay');
isolated(ctx, () => {
const scale = camera.zoom * viewport.dpr;
ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
ctx.strokeStyle = '#f97316';
ctx.lineWidth = 2 / camera.zoom;
ctx.setLineDash([6 / camera.zoom, 4 / camera.zoom]);
for (const shape of ordered) {
if (!model.selectedIds.has(shape.id)) continue;
trace(`selection:${shape.id}`);
const b = shapeBounds(shape);
ctx.strokeRect(b.x, b.y, b.w, b.h);
}
});
}
ここではviewport.width/heightを意図的にCSS Pixelと定義し、すべてのsetTransform()へdprを含めています。第2章のHostは先にDPR Transformを設定しますが、Renderer自身がsetTransform()を呼ぶと、その値を置き換えます。ここでidentityを書けば、第2章で正しくした高解像度Bitmapの左上部分にしか描けません。traceは任意のTest Probeであり、Documentへ入らず、描画結果も変えません。
GradientはBackground PassでcreateLinearGradient()を使って構築でき、PatternはcreatePattern(decodedImage, 'repeat')で作れます。実ResourceはdrawImage()を使います。これらは明示的なResource Registryから提供しなければなりません。未Decodeなら上で実装したplaceholderを描き、非同期Callbackが古いBitmapへ密かに画像を継ぎ足すことを許してはいけません。
TestではState SnapshotでIsolationを証明し、繰り返しRenderしたCommand Recordで決定論的な順序を証明します。
import { describe, expect, it, vi } from 'vitest';
import { renderDocument, type DocumentModel } from './renderer';
describe('deterministic renderer', () => {
it('restores caller state and emits the same ordered draw trace', () => {
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 400;
const ctx = canvas.getContext('2d')!;
const model: DocumentModel = {
background: '#fff',
selectedIds: new Set(['b']),
shapes: [
{ id: 'b', type: 'rectangle', x: 20, y: 20, w: 60, h: 40, fill: '#bfdbfe', z: 2 },
{ id: 'a', type: 'text', x: 30, y: 45, text: '注文', color: '#111827', z: 2 },
],
};
const setTransform = vi.spyOn(ctx, 'setTransform');
ctx.globalAlpha = 0.37;
ctx.setLineDash([9, 3]);
const first: string[] = [];
renderDocument(
model,
{ x: 0, y: 0, zoom: 1 },
ctx,
{ width: 320, height: 200, dpr: 2 },
(event) => first.push(event),
);
expect(first).toEqual([
'pass:background',
'pass:grid',
'pass:shape',
'shape:a',
'shape:b',
'pass:overlay',
'selection:b',
]);
expect(setTransform).toHaveBeenCalledWith(2, 0, 0, 2, 0, 0);
expect(ctx.globalAlpha).toBe(0.37);
expect(ctx.getLineDash()).toEqual([9, 3]);
ctx.clearRect(0, 0, 640, 400);
const second: string[] = [];
renderDocument(
model,
{ x: 0, y: 0, zoom: 1 },
ctx,
{ width: 320, height: 200, dpr: 2 },
(event) => second.push(event),
);
expect(second).toEqual(first);
expect(model.shapes.map((s) => s.id)).toEqual(['b', 'a']);
});
});
npx vitest run src/lab/ch03/renderer.test.ts --environment jsdomを実行し、1 passedを期待します。Browser Visual Testでは、最初にCanvasをClearしてから同じDocument/Cameraを渡し、Screenshotが定めた許容誤差内で一致することを確認します。Recipeに戻ると、Rendererは毎回皿を拭き、固定工程どおりに盛り付けます。「前回だいたいここにあった」ことに依存するCookieは1枚もありません。
わざと壊す
drawShape外側のrestore()を意図的に削除し、最初のShape内でtransform、globalAlpha=0.2、円形clip、lineJoin='bevel'を設定します。
| 注入する故障 | 症状 | 根拠 | 修正 | Regression Test | 復旧 |
|---|---|---|---|---|---|
| Transform Leak | 後続Objectが移動/回転する | getTransform()とScreenshotを記録する | save + finally restore | 呼び出し側Transformが前後で等しい | Isolatorを戻す |
| Alpha Leak | 後続Objectがすべて薄くなる | render後のglobalAlphaが元の値と違う | Shapeごとに明示的に隔離する | alpha sentinel Assert | 注入を削除する |
| Clip Leak | 円の外のObjectが消える | 後続Shape CommandはあるのにPixelがない | clipを対になるState内に限定する | clip後のObjectも見えるScreenshot | Clearして完全再描画する |
| Line Style Leak | Polylineの端/角が誤る | lineCap/Join Snapshot | Shape内で設定し、最後にRestoreする | Shape順序の全Permutation Test | Default順序を戻す |
| Random/Timeがrenderへ入る | 同じInputでもScreenshotが異なる | 2つのCommand Traceのdiff | clock/seedを明示Inputにする | 固定seedで同じReplay | Global Readを削除する |
注入ごとに、1つの故障Switchだけを有効化→症状を記録→Command/State/Screenshotの根拠を保存→Isolation Boundaryを修正→Unit TestとVisual Regressionを実行→Clear後にDataだけで復旧、という順序を守ります。「悪いShapeを最後へ移す」ことで汚染を隠してはいけません。
根拠を示して合格する
| Gate | 自動/手動 | 根拠 |
|---|---|---|
| Bitmapを完全にClearできる | 自動 | Testが明示的にclearRectしても再構築できる |
| Documentが唯一のScene Input | 自動 | RendererがHistory Pixel/Global Storeを読まない |
| Pass順序が安定する | 自動 | Command Traceがbackground→grid→shape→overlay |
| Shape Stateが隔離される | 自動 | Transform/Alpha/Clip/Line Style故障のRegression |
| 5種類のShapeが完成する | 手動 | Rectangle、Ellipse、Polyline、Image Placeholder、Text LabelのScreenshot |
- Path/Path2D、fill/stroke、lineCap/lineJoinを説明できる。
- clip、compositing、alpha、gradient、pattern、drawImage、primitive textを正しい層へ置いた。
- すべての
save()にException Safeなrestore()がある。 - RendererはDocumentを変更せず、Random Timeを読まず、Stableに並べる。
- Canvas全体をClearした後、Document、Camera、Resource Stateだけで同じ結果を再描画できる。
5歳児に説明する
「Renderer」「Bitmap」「Context」「Pass」「Deterministic」という言葉を使わずに答えてください。「古い皿に少し足す」方法はなぜ信頼できないのでしょう。別の料理人にも同じ笑顔を作ってもらうには、どうすればよいでしょう。
専門用語を使わない合格回答を開く
古い皿の上にあったものは、取られたり、隠れたり、Syrupで汚れたりしているかもしれません。「少し足して」と言うだけでは、その下にもともと何があったかわかりません。完全な一覧と固定Recipeを保存します。まず皿を拭き、背景を敷き、食べ物を一つずつ置き、最後に目印を置きます。特別なSyrupや型を使うたびに、手順の終わりで必ず片付けます。そうすれば皿が完全に空でも、別の人が一覧から作り直せます。結果は前回残った食べかすに依存しません。