Mengaktifkan konten HTML interaktif dalam tampilan 3D

Fitur web: HTML di kanvas

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:

  1. Periksa apakah HTML-in-Canvas didukung di browser:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Lakukan inisialisasi <canvas> untuk mendukung elemen HTML turunan dengan menambahkan atribut content="drawable" ke elemen HTML <canvas>. Tempatkan konten HTML Anda di dalam elemen <canvas>, dan tambahkan atribut drawable ke setiap elemen yang Anda gambar. Elemen dengan drawable menangkap subhierarkinya, kecuali turunan bertingkat yang juga merupakan drawable:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. 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);
    
  4. Render konten HTML ke tekstur di dalam pengendali peristiwa canvas.onpaint. Elemen hanya dapat diupload setelah kanvas merendernya: panggil canvas.requestPaint() untuk meminta peristiwa paint.

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 memanggil texImage2D() dengan null, 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.
// 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.
// 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_DST dan RENDER_ATTACHMENT, lalu tambahkan TEXTURE_BINDING untuk mengambil sampelnya di shader. Penggunaan yang salah dilaporkan sebagai error validasi WebGPU, bukan sebagai pengecualian.
    • Teruskan ukuran tekstur sebagai size. Tanpa size, elemen yang lebih besar akan dipangkas ke tekstur. Dengan size, elemen diskalakan untuk mengisinya.
    • Tekstur WebGPU tidak dapat diubah ukurannya. Buat ulang tekstur saat ukuran elemen berubah, dan buat ulang bind group yang mereferensikannya.
// 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
};
  1. 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

  1. Periksa apakah HTML-in-Canvas didukung di browser:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Buat geometri dan material kustom untuk konten HTML.

  3. 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 );
    
  4. Daftarkan mesh dengan InteractionManager untuk 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. Panggil interactions.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 drawable ke setiap elemen yang Anda gambar, termasuk turunan langsung kanvas.
  • WAJIB: Saat menggunakan WebGL atau WebGPU, gunakan pengendali peristiwa onpaint untuk 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 tetapkan TEXTURE_MIN_FILTER ke LINEAR.
  • WAJIB: Saat menggunakan WebGL, gunakan metode texElementSubImage2D untuk merender konten HTML ke tekstur yang telah Anda alokasikan sebelumnya dengan texImage2D(), yang berukuran dari captureElementImage().
  • WAJIB: Saat menggunakan WebGPU, gunakan metode drawElementImageToTexture untuk merender konten HTML ke kanvas. Ukur tekstur dari captureElementImage(), dan buat ulang hanya saat ukurannya berubah. Buat tekstur dengan penggunaan COPY_DST dan RENDER_ATTACHMENT, lalu teruskan ukuran tekstur sebagai size.
  • 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 panggil interactions.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 ResizeObserver dalam pengendali peristiwa onpaint, 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.

  1. 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>
    
  2. Jalankan metode installHtmlInCanvasPolyfill() untuk menerjemahkan HTML-in-Canvas.