コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
5歳児にもわかるゲームから始めよう
方眼紙を1枚取り、3行目の4番目の升を青いPenで塗ります。隣には「これはshape-7」と書いた付箋を置きます。友達には方眼紙だけを持っていってもらい、付箋は自分の手元に残してください。「青い升の名前は何? 誰が描いた? 右へ2升動かせる?」と尋ねます。友達に見えるのは青い色だけなので、名前は答えられません。今度は方眼紙をもっと細かな升の紙に交換します。古い青い升も消えたため、付箋の記録を見て描き直さなければなりません。
まず予想してください。10×10升の画用紙を2倍に引き伸ばすと、升の数が増えるのでしょうか。それとも元の升が太るだけでしょうか。20×20の新しい紙へそのまま交換すると、古い絵は自動で移るでしょうか。実際に試してみます。
- Objectを覚えるshape-7がどこにあるか
- 方眼紙を用意する画面の密度
- 記録どおりに塗るBitmapになる
- 紙を替えて描き直す古い色に頼らない
本章で伝える唯一の真実は、CanvasはBitmapを保存し、ApplicationはObjectを保存するということです。Canvas 2Dはimmediate-modeです。1回の呼び出しでPixelが変わり、Browserが「rectangle shape-7」を保持してくれるわけではありません。
おもちゃをCanvasに翻訳する
| おもちゃの世界の物や動作 | Browserの概念 | 結果 |
|---|---|---|
| 外枠の大きさ | CSS Size | Page Layout内で占めるCSS Pixel数 |
| 方眼紙の升数 | canvas.width/height | Backing BitmapのPixel寸法 |
| 升の密度 | devicePixelRatio | 1つのCSS Pixelの周囲に複数のPhysical Pixelが存在し得る |
| 新しい方眼紙へ交換する | width / heightを設定する | Bitmapが消去され、Context StateがResetされる |
| 付箋上のShape記録 | Document Data | Sceneを決定論的に再構築する |
| 外枠を測る助手 | ResizeObserver | Containerが変わるとHostへ通知する |
| 画材箱 | CanvasRenderingContext2D | Transform、alpha、StyleなどのStatefulな道具 |
この比喩には限界があります。CSS Pixelは固定寸法の物理的な升ではなく、DPRもHardwareだけで決まりません。Browser ZoomやDisplay間の移動でも有効比率は変わります。ResizeObserverは同期的な定規ではありません。CallbackのBatchとLayout CycleはBrowserがSchedulingします。現実の紙を交換しても「Penの色がReset」されることはありませんが、CanvasのDimension属性はContext StateまでResetします。そのため、Stateを明示的に再構築しなければなりません。
まず誤った直感を捨てる
- 「CSSに
width: 800pxと書けば、鮮明な800 Pixelになる」。CSSが変えるのは表示寸法だけです。Defaultの300×150 Bitmapを拡大しても、ぼやけて引き伸ばされます。 - 「最初のLoad時だけDPRを読む」。Browser ZoomやDisplay間の移動後にDPRが変わることがあります。Hostは有効な計測のたびに再計算しなければなりません。
- 「同じ
canvas.widthを設定しても影響はない」。仕様ではDimension設定によってResetが起きます。canvas.width = canvas.widthでさえ画面を消去し、Context StateをDefaultへ戻します。 - 「Resize後はCameraだけ戻せばよい」。Transform、clip、alpha、lineDash、FontなどのStateがすべて失われる可能性があります。信頼できる方法は、Dimension変更後に統一されたInitializationと完全な再描画を行うことです。
- 「Observerは多いほど敏感になる」。ObserverやListenerが重複すると、重複計測、Loop、Leakが起きます。1つのHostが1組のResourceを所有します。
本番用バックパック
前提となる契約
前提は第1章の境界です。Product UIはDOMに残し、動的SceneにはCanvas 2Dを使います。この時点のCanvas Labには、Pixelを保持しないredraw(viewport) Callbackしかありません。CameraはApplication Dataに属し、Contextの現在のTransformへ隠してはいけません。Containerは非表示になり得て、幅や高さが0になることもあります。破棄後は、どのCallbackも再び描画してはいけません。
正式な知識
<canvas>はDOM Elementであり、getContext('2d', settings)を呼んで初めてRendering ContextがBindingされます。fillRect()のようなCanvas 2D呼び出しは出力Bitmapを即座に変更し、Query可能なRectangle Nodeは生成しません。CSS SizeはLayout寸法を決め、Backing Bitmap Sizeは利用可能なPixel数を決めます。通常はround(cssSize × effectiveDpr)を使い、続けてctx.setTransform(dpr, 0, 0, dpr, 0, 0)を設定して、以後の描画をCSS Pixel単位で続けます。
Physical PixelはDevice Displayの単位で、CSS PixelはBrowserの論理単位です。devicePixelRatioは比率であり、永続的なDevice Labelではありません。Pixel寸法には上限が必要です。そうしなければ、高DPRの大画面で驚くほど大きなMemoryを割り当てます。幅または高さが0なら、0 SizeのBitmapを書いて完了したふりをしてはいけません。Suspended Stateへ入り、次の非ゼロ計測を待ちます。
仕様上、Dimensionへの代入は新しいBitmapを初期化し、Context StateをResetします。値が以前と同じでも、副作用がないとは扱えません。計算後のPixel寸法が実際に変わった場合だけ代入します。代入したら、CSS Size設定→Backing Size設定→Context初期化→Modelから完全再描画、という固定順序に従います。
Context作成Optionは要求であり、保証ではありません。alpha: falseは透明Backgroundが不要だという意味です。desynchronizedは低LatencyのHintです。willReadFrequentlyは頻繁にgetImageData()する場面に向き、Acceleration戦略を変える可能性があります。colorSpaceではsrgbまたは対応Wide Gamutを要求できます。unorm8/float16のようなcolorTypeのSupportは、さらに慎重な検出が必要です。getContextAttributes?.()で実際の属性を報告し、入力した設定をCapabilityの証拠にしてはいけません。getContext()がnullを返す場合もあります。
毎Frame Pollingする代わりに、ResizeObserverでContainerを監視します。一部の環境ではdevicePixelContentBoxSizeを提供しますが、contentRect × DPRのFallbackも用意します。CallbackがCanvas自身のDimension変更を理由に監視対象Containerまで変更すると、Resize Loopを作る可能性があります。HostのCSS RuleはCanvasをContainerに追従させ、Bitmap属性をLayoutへ参加させてはいけません。
根拠と互換性
- WHATWG HTML:Canvas elementとBitmap寸法設定Algorithm
- MDN:Canvas basic usage
- MDN:Window.devicePixelRatio
- MDN:ResizeObserver
- MDN:getContext()
- MDN:getContextAttributes()
上記Sourceの確認日は2026-08-29です。colorType、device-pixel-content-box、Context属性ReportはFeature Detectionします。非対応なら、基礎描画を止めるのではなく、sRGB、CSS Size×DPR、「unknown」へFallbackします。
この章のエンジニアリング増分
開始点: 第1章では固定寸法でrenderCanvasを直接呼び出しています。到達点: Observerを1つだけ作成し、DPR、0 Size、破棄に対応し、Resetのたびに決定論的に再描画し、Capability Reportを出力するCanvasHostです。
canvas-lab/
src/lab/ch02/canvas-host.ts
src/lab/ch02/canvas-host.test.ts
src/lab/ch02/demo.html
Interface契約では、redrawがCSS Viewportと実際のDPRを受け取ります。HostはDocumentを保存せず、Rendererへ通知するだけです。以下が省略のない主要実装です。
export type Viewport = { cssWidth: number; cssHeight: number; dpr: number };
export type CapabilityReport = {
requested: CanvasRenderingContext2DSettings;
actual: Record<string, unknown> | null;
};
export type HostPlatform = {
dpr(): number;
observe(element: Element, callback: () => void): () => void;
watchDpr(callback: () => void): () => void;
};
const browserPlatform: HostPlatform = {
dpr: () => window.devicePixelRatio || 1,
observe(element, callback) {
const observer = new ResizeObserver(callback);
observer.observe(element);
return () => observer.disconnect();
},
watchDpr(callback) {
let media: MediaQueryList | null = null;
const onChange = (): void => {
arm();
callback();
};
const arm = (): void => {
media?.removeEventListener('change', onChange);
media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
media.addEventListener('change', onChange);
};
arm();
return () => media?.removeEventListener('change', onChange);
},
};
export class CanvasHost {
readonly context: CanvasRenderingContext2D;
readonly capabilities: CapabilityReport;
private stopObserving: (() => void) | null = null;
private stopWatchingDpr: (() => void) | null = null;
private destroyed = false;
private last: Viewport | null = null;
constructor(
private readonly container: HTMLElement,
private readonly canvas: HTMLCanvasElement,
private readonly redraw: (ctx: CanvasRenderingContext2D, viewport: Viewport) => void,
private readonly platform: HostPlatform = browserPlatform,
) {
const requested: CanvasRenderingContext2DSettings = {
alpha: false,
desynchronized: false,
willReadFrequently: false,
colorSpace: 'srgb',
colorType: 'unorm8',
};
const context = canvas.getContext('2d', requested);
if (!context) throw new Error('Canvas 2D contextを利用できません');
this.context = context;
const readable = context as CanvasRenderingContext2D & {
getContextAttributes?: () => Record<string, unknown>;
};
this.capabilities = {
requested,
actual: readable.getContextAttributes?.() ?? null,
};
this.canvas.style.display = 'block';
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';
this.stopObserving = this.platform.observe(this.container, () => this.measure());
this.stopWatchingDpr = this.platform.watchDpr(() => this.measure());
this.measure();
}
measure(): void {
if (this.destroyed) return;
const rect = this.container.getBoundingClientRect();
const cssWidth = Math.max(0, rect.width);
const cssHeight = Math.max(0, rect.height);
if (cssWidth === 0 || cssHeight === 0) {
this.last = null;
return;
}
const dpr = Math.min(3, Math.max(1, this.platform.dpr()));
const pixelWidth = Math.max(1, Math.round(cssWidth * dpr));
const pixelHeight = Math.max(1, Math.round(cssHeight * dpr));
const bitmapChanged = this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight;
const viewport: Viewport = { cssWidth, cssHeight, dpr };
const viewportChanged =
!this.last ||
this.last.cssWidth !== cssWidth ||
this.last.cssHeight !== cssHeight ||
this.last.dpr !== dpr;
if (!bitmapChanged && !viewportChanged) return;
if (bitmapChanged) {
this.canvas.width = pixelWidth;
this.canvas.height = pixelHeight;
}
this.initializeContext(viewport);
this.last = viewport;
this.redraw(this.context, viewport);
}
private initializeContext(viewport: Viewport): void {
this.context.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0);
this.context.globalAlpha = 1;
this.context.globalCompositeOperation = 'source-over';
this.context.lineCap = 'butt';
this.context.lineJoin = 'miter';
this.context.setLineDash([]);
this.context.fillStyle = '#ffffff';
this.context.fillRect(0, 0, viewport.cssWidth, viewport.cssHeight);
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.stopObserving?.();
this.stopWatchingDpr?.();
this.stopObserving = null;
this.stopWatchingDpr = null;
this.last = null;
}
}
完全な利用Pageでは、本物のAccessibleな見出しを残します。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<style>
#stage {
width: 80vw;
height: 50vh;
}
canvas {
display: block;
}
</style>
</head>
<body>
<h1>Canvas Lab</h1>
<div id="stage"><canvas id="scene">2枚の業務Card</canvas></div>
<script type="module">
import { CanvasHost } from './canvas-host.ts';
const host = new CanvasHost(stage, scene, (ctx, view) => {
ctx.fillStyle = '#2563eb';
ctx.fillRect(24, 24, 120, 64);
ctx.fillStyle = '#fff';
ctx.fillText(`${view.dpr}x`, 34, 60);
});
window.addEventListener('pagehide', () => host.destroy(), { once: true });
</script>
</body>
</html>
TestではBrowser Timingを推測せず、PlatformをInjectしてResource Ownershipと再描画をAssertします。
import { describe, expect, it, vi } from 'vitest';
import { CanvasHost, type HostPlatform } from './canvas-host';
describe('CanvasHost', () => {
it('resizes for both element and DPR changes, then releases both subscriptions once', () => {
const container = document.createElement('div');
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
width: 200,
height: 100,
} as DOMRect);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const setTransform = vi.spyOn(ctx, 'setTransform');
const redraw = vi.fn();
const disconnect = vi.fn();
const unwatchDpr = vi.fn();
let notifyResize = () => undefined;
let notifyDpr = () => undefined;
let dpr = 2;
const platform: HostPlatform = {
dpr: () => dpr,
observe: (_element, callback) => {
notifyResize = callback;
return disconnect;
},
watchDpr: (callback) => {
notifyDpr = callback;
return unwatchDpr;
},
};
const host = new CanvasHost(container, canvas, redraw, platform);
expect([canvas.width, canvas.height]).toEqual([400, 200]);
expect(setTransform).toHaveBeenLastCalledWith(2, 0, 0, 2, 0, 0);
expect(redraw).toHaveBeenCalledTimes(1);
notifyResize();
expect(redraw).toHaveBeenCalledTimes(1);
dpr = 1.5;
notifyDpr();
expect([canvas.width, canvas.height]).toEqual([300, 150]);
expect(setTransform).toHaveBeenLastCalledWith(1.5, 0, 0, 1.5, 0, 0);
expect(redraw).toHaveBeenCalledTimes(2);
host.destroy();
host.destroy();
expect(disconnect).toHaveBeenCalledTimes(1);
expect(unwatchDpr).toHaveBeenCalledTimes(1);
});
it('waits through a zero-sized hidden container', () => {
const container = document.createElement('div');
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
width: 0,
height: 0,
} as DOMRect);
const redraw = vi.fn();
const platform: HostPlatform = {
dpr: () => 2,
observe: () => () => undefined,
watchDpr: () => () => undefined,
};
new CanvasHost(container, document.createElement('canvas'), redraw, platform);
expect(redraw).not.toHaveBeenCalled();
});
});
ResizeObserverが答えるのはElement Boxが変わったかどうかだけです。Box寸法が不変でもDPRだけが変わることがあるため、MDNの例と同じように、現在のResolutionに対するmatchMedia Listenerを再設定します。npx vitest run src/lab/ch02/canvas-host.test.ts --environment jsdomを実行し、2 passedを期待します。npm run devを実行してWindowとBrowser Zoomを変えます。ConsoleのCapability Reportは安定し、ShapeのEdgeは引き伸ばされないはずです。方眼紙のゲームに戻ると、Hostは外枠と密度に合わせて紙を替え、Penを用意し直します。本当の図柄は、やはり付箋の記録から描き直されます。
わざと壊す
| 注入する故障 | 症状 | 根拠 | 修正 | Regression Test | 復旧 |
|---|---|---|---|---|---|
| Browser Zoomを100%から200%へ | 古い実装がぼやける、または寸法がずれる | CSS/Bitmap/DPRの3値とScreenshotを記録する | 計測のたびにDPRを再計算する | PlaywrightでDPR変更後にPixel寸法が一致する | Zoomを戻す |
| 標準Displayから高DPI DisplayへDrag | 古い密度のままになる | devicePixelRatioは変わったのにBitmapが変わらない | Resize/Media変更でmeasureを起動する | DPR 1→2をSimulationする | 元のDisplayへ戻して再確認する |
display:noneの後で表示 | 最初の計測が0になり、永久に空白 | redraw呼び出し回数が0 | 0 SizeでSuspendし、次のCallbackで再開する | 0→300×200で1回呼ぶ | 表示を戻す |
| Dimension変更後に初期化しない | Camera/alpha/lineDashを失う | getTransform()がidentityでScreenshotも誤る | State初期化後にModelから再描画する | spy順序:resize→setTransform→render | 故障Branchを削除する |
| Callbackが常に同じ寸法を書き込む | 消去によるちらつきと重複render | 同じ計測でもredraw回数が増える | 代入前にpixel/viewportを比較する | notifyを繰り返しても再描画しない | Guardを戻す |
| React renderのたびにHostを作る | 複数ObserverとLoop | 構築/切断Countが非対称 | Lifecycle内で1つのHostだけを所有する | mount/unmountを2回行っても各1:1 | 余分なInstanceを破棄する |
幅が極端に小さい場合、高DPRの大画面、Capability Reportがない場合も手動で確認します。復旧とは「Refreshしたら見た目が直った」ことではありません。注入を削除し、Observer数が0に戻り、Testが再び通り、次のResizeでも描けることを意味します。
根拠を示して合格する
| 性質 | 自動/手動 | 根拠 |
|---|---|---|
| ぼやけない | 手動 | CSS Size×DPRとBitmap SizeのLog、Display間Screenshot |
| 引き伸ばされない | 自動 | CSSとBitmapのAspect Ratio Assert |
| Transform/Cameraを失わない | 自動 | Resize後のgetTransform/Camera描画Test |
| 複数のObserverを作らない | 自動 | 構築・disconnect Countと再mount Test |
| 再描画を再現できる | 自動 | Clear/Resize後、同じDocumentのPixel Snapshotが一致する |
| GracefulにFallbackできる | 自動/手動 | 属性Reportなし、DPR=1、0 Size経路のTest |
- CSS Size、Backing Size、CSS Pixel、Physical Pixelの違いを説明できる。
- 同じ値の代入も含め、
width/height変更がBitmapを消去しContext StateをResetすると理解している。 - HostがResize、DPR、Initialization、Capability Report、決定論的な再描画をまとめて扱う。
-
alpha/desynchronized/willReadFrequently/colorSpace/colorTypeを、記録した要求と実測Capabilityとして扱う。 - 非表示→表示、Zoom、Display間移動、0 Size、Resize Loopのすべてに根拠がある。
-
destroy()は冪等で、ObserverがLeakしない。
5歳児に説明する
「Pixel」「DPR」「Context」「Observer」という言葉を使わずに答えてください。小さな方眼紙を引き伸ばすことが、より細かな方眼紙へ替えることと同じではないのはなぜでしょう。紙を替えた後、なぜ記録を見て描き直さなければならないのでしょう。
専門用語を使わない合格回答を開く
古い紙を引き伸ばしても、一つひとつの升が太く見えるだけで、縁はぼやけます。升の多い新しい紙なら、もっと細かな縁を描けます。でも新しい紙は最初は空です。古い紙の青い色が勝手に飛び移ることはなく、Penの直前の色すら当てにはできません。だから「何があり、どこに置くか」という一覧を残します。紙の大きさや細かさが変わるたびに、まずPenを用意し、一覧どおりに最初から描き直します。付箋と方眼紙には、それぞれ別の仕事があります。