HTML-in-Canvas API memungkinkan rendering DOM asli langsung di dalam elemen kanvas. Saat diterapkan ke konteks rendering 3D seperti WebGL, WebGPU, atau Three.js, menambahkan atribut content="drawable" ke kanvas, dan atribut drawable ke elemen HTML turunannya, memungkinkan elemen tersebut diproyeksikan dengan lancar ke dalam tampilan 3D. Yang terpenting, karena elemen HTML tetap menjadi bagian dari hierarki tata letak DOM aktif, elemen tersebut mempertahankan interaktivitas penuh—sehingga pengguna dapat mengklik tombol, memilih teks, dan memicu status fokus secara native tanpa memerlukan raycasting yang kompleks atau penanganan peristiwa kustom.
Cara menerapkan
WebGL dan WebGPU
Saat menggunakan WebGL atau WebGPU, ikuti langkah-langkah berikut:
Periksa apakah HTML-in-Canvas didukung di browser:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Lakukan inisialisasi
<canvas>untuk mendukung elemen HTML turunan dengan menambahkan atributcontent="drawable"ke elemen HTML<canvas>. Tempatkan konten HTML Anda di dalam elemen<canvas>, dan tambahkan atributdrawableke setiap elemen yang Anda gambar. Elemen dengandrawablemenangkap subhierarkinya, kecuali turunan bertingkat yang juga merupakandrawable:<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>Skalakan petak kanvas agar sesuai dengan faktor skala perangkat untuk mencegah keburaman:
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);Render konten HTML ke tekstur di dalam pengendali peristiwa
canvas.onpaint. Elemen hanya dapat diupload setelah kanvas merendernya: panggilcanvas.requestPaint()untuk meminta peristiwapaint.
Ukur tekstur dari canvas.captureElementImage(element). width dan height-nya berada dalam piksel petak kanvas (backing store), jadi bulatkan dengan Math.ceil(). captureElementImage() menampilkan error jika elemen belum memiliki data gambar: panggil requestPaint(), dan coba lagi pada peristiwa paint berikutnya.
// 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;
}
- Dalam konteks WebGL, alokasikan terlebih dahulu dukungan tekstur menggunakan metode
texImage2D. Cuplikan dalam panduan ini menggunakan konteks WebGL 2 (canvas.getContext("webgl2")). Alokasikan penyimpanan tekstur dengan memanggiltexImage2D()dengannull, bukan data piksel, yang mencadangkan memori GPU dengan ukuran dan format tertentu tanpa mengupload piksel apa pun. Ikuti aturan berikut:- Ubah ukuran tekstur dari
captureElementImage(), seperti yang ditunjukkan di atas. - Alokasikan level 0 hanya pada upload pertama, atau saat ukuran berubah, karena alokasi ulang akan menghapus tekstur. Gunakan penyimpanan yang dapat diubah: penyimpanan
texStorage2D()yang tidak dapat diubah tidak dapat diubah ukurannya saat elemen berubah ukuran.
- Ubah ukuran tekstur dari
// 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 };
- Kemudian, dalam konteks WebGL, render konten HTML ke tekstur menggunakan metode
texElementSubImage2D. Ikuti aturan berikut:- Teruskan
{ width, height }dalam konfigurasi. Hal ini membuat salinan mengisi tekstur yang dialokasikan, terlepas dari cara kanvas diskalakan. - Alokasikan ulang tekstur jika ukurannya berubah, seperti yang ditunjukkan di atas.
- Teruskan
// 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);
}
};
- Dalam konteks WebGPU, gunakan metode
drawElementImageToTexture. Ikuti aturan berikut:- Buat tekstur dengan penggunaan
COPY_DSTdanRENDER_ATTACHMENT, lalu tambahkanTEXTURE_BINDINGuntuk mengambil sampelnya di shader. Penggunaan yang salah dilaporkan sebagai error validasi WebGPU, bukan sebagai pengecualian. - Teruskan ukuran tekstur sebagai
size. Tanpasize, elemen yang lebih besar akan dipangkas ke tekstur. Dengansize, elemen diskalakan untuk mengisinya. - Tekstur WebGPU tidak dapat diubah ukurannya. Buat ulang tekstur saat ukuran elemen berubah, dan buat ulang bind group yang mereferensikannya.
- Buat tekstur dengan penggunaan
// 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);
}
};
Saat menggunakan loop requestAnimationFrame untuk merender tampilan, panggil canvas.requestPaint() dalam loop untuk memastikan konten HTML dirender ke kanvas. event.changedElements mencantumkan elemen yang rendering-nya berubah. Pastikan Anda hanya mengupload ulang tekstur jika ada update pada elemen HTML turunan:
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
};
- Menyinkronkan geometri elemen.
Menghitung DOMMatrix yang memetakan kotak batas elemen, dalam piksel CSS, ke kanvas, dalam piksel CSS. Browser perlu memetakan dari ruang koordinat 3D ke ruang koordinat CSS menggunakan transformasi area pandang. Untuk memfasilitasi hal ini, lakukan hal berikut:
- Menormalisasi elemen HTML. Elemen HTML diukur dalam piksel (misalnya, lebar 200 px). Namun, WebGL biasanya memperlakukan objek sebagai "persegi satuan", misalnya, berkisar dari -0,5 hingga 0,5. Jika Anda tidak menormalisasi, tombol 200 px akan terlihat 200 kali lebih besar. Langkah ini juga membalik sumbu Y, karena di CSS, arah ke bawah bernilai positif, tetapi di WebGL, arah ke atas bernilai positif.
- Mengonversi Matriks MVP WebGL menjadi Matriks DOM.
- Petakan ke area pandang kanvas. Langkah ini adalah fase "penskalaan ulang": langkah ini memperluas matematika ruang unit tersebut kembali agar sesuai dengan dimensi piksel CSS elemen
<canvas>Anda di layar, dan membalik sumbu Y kembali. Menghitung transformasi akhir. Kalikan matriks secara berurutan: Area pandang * MVP * Normalisasi. Menggabungkannya menjadi satu transformasi akhir akan menghasilkan "peta" yang memberi tahu browser tempat elemen HTML tersebut harus diletakkan agar sejajar dengan gambar 3D. Browser melakukan pembagian perspektif.
// 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); }
Kemudian, teruskan transformasi ke canvas.updateElementGeometry() sebagai canvasTransform. Tindakan ini akan memindahkan elemen HTML agar berada tepat di atas piksel yang dirender. Hal ini memastikan bahwa saat pengguna mengklik tombol atau memilih teks, mereka benar-benar mengklik elemen HTML yang sebenarnya. Browser juga menggunakan transformasi untuk batas aksesibilitas elemen, misalnya, untuk cincin fokus pembaca layar:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
Gunakan opsi ini untuk mengontrol hit testing:
- Setiap panggilan
updateElementGeometry()akan memindahkan elemen ke bagian atas urutan hit-testing. Pass{ preserveHitTestOrder: true }untuk mempertahankan posisinya. - Panggil
canvas.clearElementGeometry(element)untuk menghapus elemen dari hit testing, misalnya, saat Anda menyembunyikannya.
Three.js
Periksa apakah HTML-in-Canvas didukung di browser:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Buat geometri dan material kustom untuk konten HTML.
Teruskan elemen DOM ke THREE.HTMLTexture. Perender menambahkan elemen ke kanvasnya, dan menetapkan atribut yang diperlukan:
material.map = new THREE.HTMLTexture(element); mesh = new THREE.Mesh( geometry, material ); scene.add( mesh );Daftarkan mesh dengan
InteractionManageruntuk membuat elemen HTML menjadi interaktif. Tindakan ini membuat elemen tetap selaras dengan mesh, dan menyinkronkan geometri elemen dengan kanvas, sehingga klik dan pemilihan teks mengenai elemen HTML yang sebenarnya. Panggilinteractions.update()dalam loop animasi, sebelum rendering:
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 );
Kode contoh
Canvas 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>
Canvas 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);
}
Praktik Terbaik
- WAJIB: Periksa dukungan browser untuk HTML-in-Canvas API sebelum menggunakannya.
- WAJIB: Saat menggunakan WebGL atau WebGPU, selalu tambahkan atribut
content="drawable"ke elemen<canvas>. - WAJIB: Saat menggunakan WebGL atau WebGPU, tambahkan atribut
drawableke setiap elemen yang Anda gambar, termasuk turunan langsung kanvas. - WAJIB: Saat menggunakan WebGL atau WebGPU, gunakan pengendali peristiwa
onpaintuntuk merender konten HTML ke kanvas. - WAJIB: Saat menggunakan WebGL, alokasikan buffer tekstur terlebih dahulu dengan
texImage2D()sekali (saat diinisialisasi atau saat ukuran elemen berubah). Alokasikan ulang tekstur hanya saat ukuran berubah, dan tetapkanTEXTURE_MIN_FILTERkeLINEAR. - WAJIB: Saat menggunakan WebGL, gunakan metode
texElementSubImage2Duntuk merender konten HTML ke tekstur yang telah Anda alokasikan sebelumnya dengantexImage2D(), yang berukuran daricaptureElementImage(). - WAJIB: Saat menggunakan WebGPU, gunakan metode
drawElementImageToTextureuntuk merender konten HTML ke kanvas. Ukur tekstur daricaptureElementImage(), dan buat ulang hanya saat ukurannya berubah. Buat tekstur dengan penggunaanCOPY_DSTdanRENDER_ATTACHMENT, lalu teruskan ukuran tekstur sebagaisize. - WAJIB: Saat menggunakan WebGL atau WebGPU, sinkronkan geometri elemen HTML dengan konten yang dirender dengan memanggil
canvas.updateElementGeometry(element, { canvasTransform }). - WAJIB: Saat menggunakan Three.js, daftarkan mesh dengan
InteractionManager, dan panggilinteractions.update()dalam loop animasi, sehingga elemen HTML menerima input. - WAJIB: Amati ukuran layar dan perbarui ukuran kanvas agar sesuai dengan piksel perangkat, misalnya, dengan menggunakan
ResizeObserver. - JANGAN menyematkan konten lintas origin di kanvas, karena tidak didukung.
- JANGAN melakukan inisialisasi
ResizeObserverdalam pengendali peristiwaonpaint, karena dapat menyebabkan kebocoran memori.
Strategi penggantian
HTML-in-Canvas API saat ini tidak didukung di semua browser modern, sehingga strategi penggantian biasanya diperlukan.
Namun, mengingat manfaat peningkatan performa API ini, HTML-in-Canvas harus digunakan jika browser mendukungnya.
Strategi penggantian bergantung pada kasus penggunaan. Misalnya, untuk konten HTML interaktif di kanvas, jika HTML-in-Canvas tidak didukung, tempatkan konten HTML di atas kanvas menggunakan CSS.
Polyfill HTML-in-Canvas
Gunakan skrip polyfill berikut untuk meniru HTML-in-Canvas API di browser yang tidak mendukungnya.
Instal atau sematkan library:
# Install npm install three-html-render# Embed <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>Jalankan metode
installHtmlInCanvasPolyfill()untuk menerjemahkan HTML-in-Canvas.