HTML-in-Canvas API を使用すると、実際の DOM をキャンバス要素内に直接レンダリングできます。WebGL、WebGPU、Three.js などの 3D レンダリング コンテキストに適用する場合、content="drawable" 属性をキャンバスに追加し、drawable 属性をその子孫 HTML 要素に追加すると、それらの要素を 3D シーンにシームレスに投影できます。重要なのは、HTML 要素がアクティブな DOM レイアウト ツリーの一部として残るため、完全なインタラクティビティが維持されることです。これにより、ユーザーは複雑なレイキャストやカスタム イベント処理を必要とせずに、ボタンのクリック、テキストの選択、フォーカス状態のトリガーをネイティブに行うことができます。
導入方法
WebGL と WebGPU
WebGL または WebGPU を使用する場合は、次の手順を行います。
ブラウザで HTML-in-Canvas がサポートされているかどうかを確認します。
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }<canvas>HTML 要素にcontent="drawable"属性を追加して、子孫 HTML 要素をサポートするように<canvas>を初期化します。HTML コンテンツを<canvas>要素内に配置し、描画するすべての要素にdrawable属性を追加します。drawableを持つ要素は、そのサブツリーをキャプチャします。ただし、同じくdrawableを持つネストされた子孫は除きます。<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>ぼやけを防ぐため、デバイスのスケーリング ファクタに合わせてキャンバス グリッドをスケーリングします。
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);canvas.onpaintイベント ハンドラ内で HTML コンテンツをテクスチャにレンダリングします。要素をアップロードできるのは、キャンバスが要素を描画した後のみです。canvas.requestPaint()を呼び出してpaintイベントをリクエストします。
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) {
// Update the texture as shown above
}
// Render the scene, and sync the element geometry as shown in step 5
};
- 要素のジオメトリを同期します。
要素のボーダーボックス(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)を呼び出して、ヒットテストから要素を削除します(要素を非表示にする場合など)。
Three.js
ブラウザで HTML-in-Canvas がサポートされているかどうかを確認します。
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }HTML コンテンツ用のカスタム ジオメトリとマテリアルを作成します。
DOM 要素を THREE.HTMLTexture に渡します。レンダラは、要素をキャンバスに追加し、必要な属性を設定します。
material.map = new THREE.HTMLTexture(element); mesh = new THREE.Mesh( geometry, material ); scene.add( mesh );HTML 要素をインタラクティブにするには、
InteractionManagerにメッシュを登録します。要素をメッシュに揃え、要素のジオメトリをキャンバスと同期することで、クリックやテキスト選択が実際の HTML 要素にヒットするようにします。レンダリングの前に、アニメーション ループでinteractions.update()を呼び出します。
import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';
const interactions = new InteractionManager();
interactions.connect( renderer, camera );
interactions.add( mesh );
// In the animation loop
interactions.update();
renderer.render( scene, camera );
コード例
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 4.
// computeCanvasTransform() is defined in step 5.
// 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 4.
// computeCanvasTransform() is defined in step 5.
// 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>
Three.js
// 1. Initialize Three.js camera, scene, renderer, mesh, interactions;
// 2. Ensure HTML-in-Canvas feature support
if (!('content' in HTMLCanvasElement.prototype)) {
// Use a fallback strategy
}
// 3. Initialize the source HTML DOM element
const element = document.createElement('div');
element.innerHTML = '<h1>Hello World</h1>';
// 4. Create geometry and material
const geometry = new RoundedBoxGeometry( 100, 100, 100, 10, 10 );
const material = new THREE.MeshStandardMaterial( { roughness: 0, metalness: 0.5 } );
// 5. Pass the DOM element into THREE.HTMLTexture
material.map = new THREE.HTMLTexture(element);
mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );
// 6. Make the HTML element interactive
interactions = new InteractionManager();
interactions.connect( renderer, camera );
interactions.add( mesh );
// 7. Render Loop
function animate() {
// Keep the HTML element aligned with the mesh, so it receives input
interactions.update();
renderer.render(scene, camera);
}
ベスト プラクティス
- 必須: HTML-in-Canvas API を使用する前に、ブラウザがこの API をサポートしているかどうかを確認します。
- 必須: WebGL または WebGPU を使用する場合は、必ず
content="drawable"属性を<canvas>要素に追加します。 - 必須: WebGL または WebGPU を使用する場合は、キャンバスの直接の子を含む、描画するすべての要素に
drawable属性を追加します。 - 必須: WebGL または WebGPU を使用する場合は、
onpaintイベント ハンドラを使用して HTML コンテンツをキャンバスにレンダリングします。 - 必須: WebGL を使用する場合は、
texImage2D()を使用してテクスチャ バッファを 1 回(初期化時または要素サイズの変更時)事前に割り当てます。サイズが変更された場合にのみテクスチャを再割り当てし、TEXTURE_MIN_FILTERをLINEARに設定します。 - 必須: WebGL を使用する場合は、
texElementSubImage2Dメソッドを使用して、texImage2D()で事前に割り当てたテクスチャに HTML コンテンツをレンダリングします。サイズはcaptureElementImage()から取得します。 - 必須: WebGPU を使用する場合は、
drawElementImageToTextureメソッドを使用して HTML コンテンツをキャンバスにレンダリングします。captureElementImage()からテクスチャのサイズを設定し、サイズが変更された場合にのみ再作成します。COPY_DSTとRENDER_ATTACHMENTの使用法でテクスチャを作成し、テクスチャ サイズをsizeとして渡します。 - 必須: WebGL または WebGPU を使用する場合は、
canvas.updateElementGeometry(element, { canvasTransform })を呼び出して、HTML 要素のジオメトリをレンダリングされたコンテンツと同期します。 - 必須: Three.js を使用する場合は、
InteractionManagerでメッシュを登録し、アニメーション ループでinteractions.update()を呼び出して、HTML 要素が入力を受け取るようにします。 - 必須: 画面サイズを監視し、デバイスのピクセルに合わせてキャンバス サイズを更新します(
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 を模倣できます。
ライブラリをインストールまたは埋め込みます。
# Install npm install three-html-render# Embed <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>installHtmlInCanvasPolyfill()メソッドを実行して、HTML-in-Canvas を翻訳します。