キャンバス コンテンツをブラウザ機能に公開する

ウェブ機能: HTML in canvas

通常の <canvas> コンテンツは、スクリーン リーダー、インデックス登録、翻訳ツール、ユーザー補助ツール、ページ内検索、印刷などのブラウザ機能に公開されません。HTML in canvas を使用すると、実際の DOM を canvas 要素に直接レンダリングできます。<canvas> HTML 要素に content="drawable" 属性を追加すると、その子孫 HTML 要素がレイアウトされます。drawable 属性は、キャンバスのレンダリング コンテキスト内でレンダリングできる要素をマークします。これを使用すると、キャンバス内のテキストのスタイル設定やレイアウト、キャンバス コンテンツのブラウザ機能(ユーザー補助、翻訳、ページ内検索など)への公開、HTML への 2D および 3D 効果の適用を行うことができます。

導入方法

  1. ブラウザで HTML-in-Canvas がサポートされているかどうかを確認します。

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. content="drawable" 属性を <canvas> HTML 要素に追加します。

  3. HTML コンテンツを <canvas> 要素内に配置し、描画するすべての要素に drawable 属性を追加します。drawable を持つ要素は、そのサブツリーをキャプチャします。ただし、同じく drawable であるネストされた子孫は除きます。

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  4. ぼやけを防ぐため、デバイスのスケーリング ファクタに合わせてキャンバス グリッドをスケーリングします。

    const observer = new ResizeObserver(([entry]) => {
      const dpc = entry.devicePixelContentBoxSize;
      canvas.width = dpc
        ? dpc[0].inlineSize
        : Math.round(entry.contentRect.width * window.devicePixelRatio);
      canvas.height = dpc
        ? dpc[0].blockSize
        : Math.round(entry.contentRect.height * window.devicePixelRatio);
    });
    
    const supportsDevicePixelContentBox =
      typeof ResizeObserverEntry !== "undefined" &&
      "devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
    const options = supportsDevicePixelContentBox
      ? { box: "device-pixel-content-box" }
      : {};
    observer.observe(canvas, options);
    
  5. canvas.onpaint イベント ハンドラ内で HTML コンテンツをキャンバスにレンダリングします。要素は、キャンバスが描画した後にのみ描画できます。canvas.requestPaint() を呼び出して paint イベントをリクエストします。

  • 2D コンテキストでは、drawElementImage メソッドを使用します。CSS 変換の前に、drawable 要素の最後のスナップショットと、その要素の境界ボックスから始まる描画可能なサブツリーを描画します。
canvas.onpaint = () => {
  ctx.reset();
  // Draw the form element at x:0, y:0
  ctx.drawElementImage(form_element, 0, 0);
};
  • WebGL と WebGPU のコンテキストでは、要素をテクスチャにコピーします。canvas.captureElementImage(element) からテクスチャのサイズを設定します。width と height はキャンバス グリッド(バッキング ストア)のピクセル単位であるため、Math.ceil() で切り上げます。要素にまだペイント レコードがない場合、captureElementImage() はスローされます。requestPaint() を呼び出し、次の paint イベントで再試行します。
// Returns the element's size in canvas grid pixels, or null if the element
// has no paint record yet.
function getElementImageSize(canvas, element) {
  let elementImage;
  try {
    elementImage = canvas.captureElementImage(element);
  } catch (err) {
    canvas.requestPaint(); // No paint record yet: retry on the next paint event.
    return null;
  }
  const width = Math.ceil(elementImage.width);
  const height = Math.ceil(elementImage.height);
  elementImage.close();
  return width > 0 && height > 0 ? { width, height } : null;
}
  • WebGL コンテキストでは、まず texImage2D メソッドを使用してテクスチャ バッキングを事前に割り当てます。このガイドのスニペットでは、WebGL 2 コンテキスト(canvas.getContext("webgl2"))を使用します。ピクセルデータの代わりに null を指定して texImage2D() を呼び出すことで、テクスチャ ストレージを割り当てます。これにより、ピクセルをアップロードせずに、指定されたサイズと形式の GPU メモリが予約されます。以下のルールに従ってください。
    • 前述の方法で captureElementImage() からテクスチャのサイズを設定します。
    • 再割り当てを行うとテクスチャがクリアされるため、レベル 0 の割り当ては、最初のアップロード時またはサイズ変更時にのみ行います。可変ストレージを使用する: 要素のサイズが変更されたときに、不変の texStorage2D() ストレージのサイズを変更することはできません。
// Binds the texture, and allocates its storage on the first upload, and when
// the element size changes. `state` stores the allocated size.
function allocateElementTexture(gl, texture, state, width, height) {
  gl.bindTexture(gl.TEXTURE_2D, texture);
  if (state.width === width && state.height === height) return;

  gl.texImage2D(
    gl.TEXTURE_2D,
    0,                // Mipmap level
    gl.RGBA8,         // Internal format: 8 bits per RGBA channel
    width,            // Size in canvas grid pixels
    height,
    0,                // Border: must be 0
    gl.RGBA,          // Format and type that match RGBA8
    gl.UNSIGNED_BYTE,
    null              // null reserves GPU memory without uploading pixels
  );
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  state.width = width;
  state.height = height;
}

const texture = gl.createTexture();
const textureState = { width: 0, height: 0 };
  • 次に、WebGL コンテキストで、texElementSubImage2D メソッドを使用して HTML コンテンツをテクスチャにレンダリングします。以下のルールに従ってください。
    • 構成で { width, height } を渡します。これにより、キャンバスの拡大縮小方法に関係なく、コピーが割り当てられたテクスチャ全体に表示されます。
    • サイズが変更された場合は、上記のようにテクスチャを再割り当てします。
// Uploads the element into the texture. Returns false if the element has no
// paint record yet. In that case, it's uploaded on the next paint event.
function uploadElementWebGL(gl, canvas, texture, element, state) {
  const size = getElementImageSize(canvas, element);
  if (!size) return false;
  const { width, height } = size;

  // Bind the texture, and reallocate it if the size changed (see above).
  allocateElementTexture(gl, texture, state, width, height);
  gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, element, { width, height });
  return true;
}

canvas.onpaint = () => {
  try {
    uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
  } catch (err) {
    console.error('texElementSubImage2D copy failed:', err);
  }
};
  • WebGPU コンテキストでは、drawElementImageToTexture メソッドを使用します。以下のルールに従ってください。
    • COPY_DST と RENDER_ATTACHMENT を使用してテクスチャを作成し、TEXTURE_BINDING を追加してシェーダーでサンプリングします。誤った使用は、例外ではなく WebGPU 検証エラーとして報告されます。
    • テクスチャ サイズを size として渡します。size を指定しない場合、大きい要素はテクスチャにクリップされます。size を使用すると、要素はコンテナに合わせてスケーリングされます。
    • WebGPU テクスチャのサイズは変更できません。要素のサイズが変更されたときにテクスチャを再作成し、それを参照するバインド グループを再ビルドします。
// WebGPU textures can't be resized. Create the texture once, and recreate it
// when the element size changes.
function ensureElementTexture(device, state, width, height) {
  if (state.texture?.width === width && state.texture?.height === height) {
    return state.texture;
  }
  state.texture?.destroy();
  state.texture = device.createTexture({
    size: { width, height },
    format: "rgba8unorm",
    usage:
      GPUTextureUsage.TEXTURE_BINDING |
      GPUTextureUsage.COPY_DST |
      GPUTextureUsage.RENDER_ATTACHMENT,
  });
  return state.texture; // Recreate the bind groups that used the previous texture.
}

const textureState = { texture: null };

canvas.onpaint = () => {
  const size = getElementImageSize(canvas, uiElement);
  if (!size) return;
  const previousTexture = textureState.texture;
  const texture = ensureElementTexture(device, textureState, size.width, size.height);
  if (texture !== previousTexture) {
    // New texture: create, or rebuild, the bind groups that reference it.
  }
  try {
    device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
  } catch (err) {
    console.error('drawElementImageToTexture copy failed:', err);
  }
};

requestAnimationFrame ループを使用してシーンをレンダリングする場合は、ループ内で canvas.requestPaint() を呼び出して、HTML コンテンツがキャンバスにレンダリングされるようにします。event.changedElements には、レンダリングが変更された要素が一覧表示されます。子孫 HTML 要素が更新された場合にのみ、要素を再描画するか、テクスチャを再アップロードするようにします。

function render() {
  // Request to update the canvas
  canvas.requestPaint();
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

canvas.onpaint = (event) => {
  if (event.changedElements && event.changedElements.length > 0) {
    // Redraw the element, or update its texture, as shown above
  }
  // In WebGL and WebGPU, render the scene, and sync the element geometry as shown in step 6
};
  1. 要素のジオメトリを同期します。

ブラウザは、HTML 要素が描画される場所を認識して、その DOM 位置が描画されたピクセルと一致するようにする必要があります。たとえば、クリックやテキスト選択のヒットテスト、ユーザー補助機能では、このジオメトリが使用されます。ジオメトリが更新されていない drawable 要素は、ジオメトリ情報なしでユーザー補助ツリーに公開されます。キャンバスは、2D drawElementImage() メソッドで描画された要素、または updateElementGeometry() で登録された要素のみをヒットテストします。

  • 2D コンテキストの場合、ブラウザは要素を自動的に同期します。drawElementImage() は、DOM ヒットテストの境界とスクリーン リーダーのフォーカス リングを描画された座標に合わせるため、style.transform を設定する必要はありません。ジオメトリを手動で管理する場合(同じ要素を複数回描画する場合など)は、{ preserveElementGeometry: true } を drawElementImage() に渡し、canvas.updateElementGeometry() で変換を適用します。

  • WebGL または WebGPU を使用した 3D の場合、キャンバスは HTML 要素が描画される場所を認識できないため、それを指定する必要があります。要素のボーダーボックス(CSS ピクセル単位)をキャンバス(CSS ピクセル単位)にマッピングする DOMMatrix を計算します。ブラウザは、ビューポート変換を使用して、3D 座標空間から CSS 座標空間にマッピングする必要があります。これを容易にするには、次の操作を行います。

    • HTML 要素を正規化します。HTML 要素のサイズはピクセル単位で指定します(例: 幅 200px)。しかし、WebGL では通常、オブジェクトを「単位正方形」として扱います。たとえば、-0.5 から 0.5 の範囲です。正規化しないと、200 ピクセルのボタンは 200 倍の大きさで表示されます。このステップでは、Y 軸も反転します。CSS では下方向が正ですが、WebGL では上方向が正であるためです。
    • WebGL MVP 行列を DOM 行列に変換します。
    • キャンバスのビューポートにマッピングします。このステップは「再スケーリング」フェーズです。単位空間の計算を画面上の <canvas> 要素の CSS ピクセル寸法に合わせて引き伸ばし、Y 軸を反転させます。
    • 最終的な変換を計算します。ビューポート * MVP * 正規化の順に、行列を乗算します。これらを組み合わせて 1 つの最終的な変換を作成すると、3D 描画に合わせて HTML 要素を配置する場所をブラウザに正確に伝える「マップ」が生成されます。ブラウザがパースペクティブ分割を実行します。
    // Maps the element's border box (CSS pixels) to the canvas (CSS pixels), for a
    // quad that spans -0.5 to 0.5 in model space and is drawn with the `mvp` matrix.
    function computeCanvasTransform(canvas, element, mvp) {
      const width = element.offsetWidth;
      const height = element.offsetHeight;
    
      // 1. Normalize the HTML element (CSS pixels -> WebGL Model Space)
      const toGLModel = new DOMMatrix()
        // Scale pixels to 1 unit, flip Y (as in CSS it points down, and in WebGL it points up)
        .scale(1 / width, -1 / height, 1 / height)
        // Center the origin: (0,0) becomes (-width/2, -height/2) before scaling
        .translate(-width / 2, -height / 2);
    
      // 2. Convert WebGL MVP Matrix to DOM Matrix
      const mvpDOM = new DOMMatrix(Array.from(mvp));
    
      // 3. Map to the canvas viewport, in CSS pixels
      const clipToCanvasViewport = new DOMMatrix()
        // Move center (0,0) to center of canvas
        .translate(canvas.clientWidth / 2, canvas.clientHeight / 2)
        // Scale normalized clip (-1..1) to viewport size, and flip Y back
        .scale(canvas.clientWidth / 2, -canvas.clientHeight / 2, canvas.clientHeight / 2);
    
      // 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square)
      return clipToCanvasViewport.multiply(mvpDOM).multiply(toGLModel);
    }
    

次に、変換を canvasTransform として canvas.updateElementGeometry() に渡します。これにより、HTML 要素がレンダリングされたピクセルの真上に移動します。これにより、ユーザーがボタンをクリックしたりテキストを選択したりしたときに、実際の HTML 要素がヒットするようになります。ブラウザは、要素のユーザー補助境界(スクリーン リーダーのフォーカス リングなど)にも変換を使用します。

  canvas.updateElementGeometry(targetHTMLElement, {
    canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
  });

ヒットテストを制御するには、次のオプションを使用します。

  • updateElementGeometry() を呼び出すたびに、要素がヒットテストの順序の最上位に移動します。{ preserveHitTestOrder: true } を渡して位置を維持します。
  • canvas.clearElementGeometry(element) を呼び出して、ヒットテストから要素を削除します(要素を非表示にする場合など)。

コード例

2D Canvas

<canvas id="canvas" content="drawable" style="width: 400px; height: 200px;">
  <div id="ui-element" drawable>
    <p>
      This text is rendered inside the canvas but is present in the DOM tree.
    </p>
    <input type="email" name="email" placeholder="enter your email" />
    <button type="button">Submit</button>
  </div>
</canvas>

<script>
  const canvas = document.getElementById("canvas");
  const ctx = canvas.getContext("2d");
  const uiElement = document.getElementById("ui-element");

  canvas.onpaint = () => {
    ctx.reset();
    // Draw the HTML element at x:0, y:0. The browser syncs the DOM location
    // with the drawn location.
    ctx.drawElementImage(uiElement, 0, 0);
  };

  // Handle resizing to match device pixels
  const observer = new ResizeObserver(([entry]) => {
    const dpc = entry.devicePixelContentBoxSize;
    canvas.width = dpc
      ? dpc[0].inlineSize
      : Math.round(entry.contentRect.width * window.devicePixelRatio);
    canvas.height = dpc
      ? dpc[0].blockSize
      : Math.round(entry.contentRect.height * window.devicePixelRatio);
    canvas.requestPaint();
  });

  const supportsDevicePixelContentBox =
    typeof ResizeObserverEntry !== "undefined" &&
    "devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
  const options = supportsDevicePixelContentBox
    ? { box: "device-pixel-content-box" }
    : {};
  observer.observe(canvas, options);
</script>

WebGL キャンバス

<canvas id="canvas" content="drawable" style="width: 400px; height: 400px;">
  <div id="ui-element" drawable>
    <p>WebGL UI Element</p>
    <button>Action</button>
  </div>
</canvas>

<script>
  const canvas = document.getElementById("canvas");
  const gl = canvas.getContext("webgl2");
  const uiElement = document.getElementById("ui-element");

  // getElementImageSize(), allocateElementTexture(), and uploadElementWebGL()
  // are defined in step 5.
  // computeCanvasTransform() is defined in step 6.

  // Setup WebGL texture...
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  const textureState = { width: 0, height: 0 };

  canvas.onpaint = () => {
    // 1. Update texture with HTML content
    try {
      uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
    } catch (err) {
      console.error('texElementSubImage2D copy failed:', err);
    }

    // ... Render your 3D scene here, calculating htmlElementMVP matrix ...

    // 2. Sync DOM position, hit testing, and accessibility bounds with 3D scene
    canvas.updateElementGeometry(uiElement, {
      canvasTransform: computeCanvasTransform(canvas, uiElement, htmlElementMVP),
    });
  };
</script>

WebGPU キャンバス

<canvas id="canvas" content="drawable" style="width: 400px; height: 400px;">
  <div id="ui-element" drawable>
    <p>WebGPU UI Element</p>
  </div>
</canvas>

<script>
  const canvas = document.getElementById("canvas");
  const context = canvas.getContext("webgpu");
  const uiElement = document.getElementById("ui-element");

  // getElementImageSize() and ensureElementTexture() are defined in step 5.
  // computeCanvasTransform() is defined in step 6.

  // Setup WebGPU...
  // const device = ...
  const textureState = { texture: null };

  canvas.onpaint = () => {
    // 1. Copy HTML content to a texture that matches the element size
    const size = getElementImageSize(canvas, uiElement);
    if (size) {
      const previousTexture = textureState.texture;
      const texture = ensureElementTexture(device, textureState, size.width, size.height);
      if (texture !== previousTexture) {
        // New texture: create, or rebuild, the bind groups that reference it.
      }
      try {
        device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
      } catch (err) {
        console.error('drawElementImageToTexture copy failed:', err);
      }
    }

    // ... Render your 3D scene here, calculating htmlElementMVP matrix ...

    // 2. Sync DOM position, hit testing, and accessibility bounds (same matrix math as WebGL)
    canvas.updateElementGeometry(uiElement, {
      canvasTransform: computeCanvasTransform(canvas, uiElement, htmlElementMVP),
    });
  };
</script>

ベスト プラクティス

  • 必須: HTML-in-Canvas API を使用する前に、ブラウザがこの API をサポートしているかどうかを確認します。
  • 必須: <canvas> 要素に content="drawable" 属性を必ず追加します。
  • 必須: 描画するすべての要素(キャンバスの直接の子を含む)に drawable 属性を追加します。
  • 必須: onpaint イベント ハンドラを使用して、HTML コンテンツをキャンバスにレンダリングします。
  • 必須: 2D の drawElementImage メソッド、または WebGPU の drawElementImageToTexture を使用して、HTML コンテンツをキャンバスにレンダリングします。
  • 必須: WebGL では、texImage2D() を使用してテクスチャ バッファを 1 回(初期化時または要素サイズの変更時)事前に割り当てます。サイズが変更された場合にのみテクスチャを再割り当てし、TEXTURE_MIN_FILTER を LINEAR に設定します。
  • 必須: WebGL では、texElementSubImage2D メソッドを使用して、texImage2D() で事前に割り当てたテクスチャに HTML コンテンツをレンダリングします。サイズは captureElementImage() から取得します。
  • 必須: WebGPU では、captureElementImage() からテクスチャのサイズを設定し、サイズが変更された場合にのみ再作成します。COPY_DST と RENDER_ATTACHMENT の使用法でテクスチャを作成し、テクスチャ サイズを size として渡します。
  • 必須: HTML 要素のジオメトリをレンダリングされたコンテンツと同期します。2D では、drawElementImage が自動的に行います。WebGL と WebGPU で canvas.updateElementGeometry(element, { canvasTransform }) を呼び出します。
  • 必須: ResizeObserver を使用して画面サイズを監視し、デバイスのピクセルに合わせてキャンバス サイズを更新します。
  • クロスオリジン コンテンツをキャンバスに埋め込まないでください。サポートされていません。
  • onpaint イベント ハンドラ内で ResizeObserver を初期化しないでください。メモリリークが発生する可能性があります。

代替戦略

HTML-in-Canvas API は現在、すべての最新のブラウザでサポートされているわけではないため、通常はフォールバック戦略が必要です。

ただし、この API のパフォーマンス上のメリットを考慮すると、ブラウザがサポートしている場合は HTML-in-Canvas を使用する必要があります。

フォールバック戦略はユースケースによって異なります。たとえば、キャンバス内のインタラクティブな HTML コンテンツの場合、HTML-in-Canvas がサポートされていない場合は、CSS を使用して HTML コンテンツをキャンバスの上に配置します。

HTML-in-Canvas ポリフィル

次のポリフィル スクリプトを使用すると、HTML-in-Canvas API をサポートしていないブラウザでこの API を模倣できます。

  1. ライブラリをインストールまたは埋め込みます。

    # Install
    npm install three-html-render
    
    # Embed
    <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
    
  2. installHtmlInCanvasPolyfill() メソッドを実行して、HTML-in-Canvas を翻訳します。