JEPA4Japan · チュートリアル

第2章:すぐに記憶を失うピクセルの紙

4,224文字 12分で読めます #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Bitmap、CSS Pixel、物理Pixel、DPRを理解し、購読を重複させず状態も失わないCanvasHostを実装します。

コース進捗 コース目次 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歳児にもわかるゲームから始めよう

方眼紙を1枚取り、3行目の4番目の升を青いPenで塗ります。隣には「これはshape-7」と書いた付箋を置きます。友達には方眼紙だけを持っていってもらい、付箋は自分の手元に残してください。「青い升の名前は何? 誰が描いた? 右へ2升動かせる?」と尋ねます。友達に見えるのは青い色だけなので、名前は答えられません。今度は方眼紙をもっと細かな升の紙に交換します。古い青い升も消えたため、付箋の記録を見て描き直さなければなりません。

まず予想してください。10×10升の画用紙を2倍に引き伸ばすと、升の数が増えるのでしょうか。それとも元の升が太るだけでしょうか。20×20の新しい紙へそのまま交換すると、古い絵は自動で移るでしょうか。実際に試してみます。

  1. Objectを覚えるshape-7がどこにあるか
  2. 方眼紙を用意する画面の密度
  3. 記録どおりに塗るBitmapになる
  4. 紙を替えて描き直す古い色に頼らない
ApplicationがObjectを記憶し、Canvasは各升の色だけを保持します。

本章で伝える唯一の真実は、CanvasはBitmapを保存し、ApplicationはObjectを保存するということです。Canvas 2Dはimmediate-modeです。1回の呼び出しでPixelが変わり、Browserが「rectangle shape-7」を保持してくれるわけではありません。

おもちゃをCanvasに翻訳する

おもちゃの世界の物や動作Browserの概念結果
外枠の大きさCSS SizePage Layout内で占めるCSS Pixel数
方眼紙の升数canvas.width/heightBacking BitmapのPixel寸法
升の密度devicePixelRatio1つのCSS Pixelの周囲に複数のPhysical Pixelが存在し得る
新しい方眼紙へ交換するwidth / heightを設定するBitmapが消去され、Context StateがResetされる
付箋上のShape記録Document DataSceneを決定論的に再構築する
外枠を測る助手ResizeObserverContainerが変わるとHostへ通知する
画材箱CanvasRenderingContext2DTransform、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へ参加させてはいけません。

根拠と互換性

上記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呼び出し回数が00 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を用意し、一覧どおりに最初から描き直します。付箋と方眼紙には、それぞれ別の仕事があります。