JEPA4Japan · チュートリアル

第3章:お絵描きを再現可能なレシピにする

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

その場しのぎの追記描画を、Documentから画面全体を再構築できる決定論的なマルチパスRendererへ変えます。

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

Cookieを2枚、苺を1粒、紙を1枚用意します。最初に「笑顔のCake」を作るときは、好きなように並べた後、友達に皿を空にしてもらいます。そして「さっきの少しだけを戻して」とだけ伝えます。友達には「少し」がどこなのかわかりません。2回目は先にRecipeを書きます。皿をきれいに拭く。Cookieを(30, 20)と(70, 20)へ置く。苺を(50, 55)へ置く。最後に口を描く。皿を空にしてから別の友達へRecipeを渡しても、同じ笑顔を再構築できます。

まず予想してください。「半透明のSyrupをかける」という手順に「使い終えたら普通のSyrupへ戻す」と書かれていなければ、次のCookieはどうなるでしょう。「丸い皿の内側だけに粉砂糖を振る」という手順に終了の印がなければ、皿の外の飾りはどうなるでしょう。

  1. Recipe全体を読むObjectとCamera
  2. 皿をきれいに拭く古い色に頼らない
  3. Layer順に並べるBackground→Shape→Hint
  4. 結果を比較する毎回同じ
信頼できる描画とは、古い皿への継ぎ足しではなく、再生可能なRecipeです。

本章で伝える唯一の真実は、信頼できるCanvas Rendererは、Dataから画面全体を再構築できなければならないということです。目標Interfaceはrender(model, camera, context)であり、「UserがDragしたので、古いPixelへ少し描き足す」ではありません。

おもちゃをCanvasに翻訳する

Cake遊びの概念Canvas Engineering役割
材料一覧Document Model事実を保存する
盛り付けを見る角度CameraWorldをどう投影するか決める
RecipePure Renderer同じInputから同じ描画Commandを出す
皿を拭くclear/background pass古いBitmapを取り除く
型の輪郭Path / Path2DGeometry Boundaryを記述する
Syrupと輪郭線Fill / StrokePathの内部または縁を着色する
手袋を着けて外すsave() / restore()Transform、Alpha、Clip、Styleを隔離する
分担する料理人Render PassBackground、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を出すことです。

根拠と互換性

上記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も見えるScreenshotClearして完全再描画する
Line Style LeakPolylineの端/角が誤るlineCap/Join SnapshotShape内で設定し、最後にRestoreするShape順序の全Permutation TestDefault順序を戻す
Random/Timeがrenderへ入る同じInputでもScreenshotが異なる2つのCommand Traceのdiffclock/seedを明示Inputにする固定seedで同じReplayGlobal 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や型を使うたびに、手順の終わりで必ず片付けます。そうすれば皿が完全に空でも、別の人が一覧から作り直せます。結果は前回残った食べかすに依存しません。