在 3D 場景中啟用互動式 HTML 內容

網頁功能: Canvas 中的 HTML

HTML-in-Canvas API 可直接在畫布元素中算繪實際 DOM。套用至 WebGL、WebGPU 或 Three.js 等 3D 算繪情境時,只要在畫布中加入 content="drawable" 屬性,並在子系 HTML 元素中加入 drawable 屬性,就能將這些元素完美投射到 3D 場景中。最重要的是,由於 HTML 元素仍是有效 DOM 版面配置樹狀結構的一部分,因此會保留完整的互動性,讓使用者能以原生方式點選按鈕、選取文字及觸發焦點狀態,不必使用複雜的光線投射或自訂事件處理。

導入方式

WebGL 和 WebGPU

使用 WebGL 或 WebGPU 時,請按照下列步驟操作:

  1. 檢查瀏覽器是否支援 HTML-in-Canvas:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. 在 <canvas> HTML 元素中加入 content="drawable" 屬性,初始化 <canvas> 以支援子系 HTML 元素。將 HTML 內容放入 <canvas> 元素中,並在繪製的每個元素中加入 drawable 屬性。具有 drawable 的元素會擷取其子樹狀結構,但同樣是 drawable 的巢狀子系除外:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. 配合裝置縮放比例係數調整畫布格線,避免模糊:

    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. 在 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
};
  1. 同步處理元素幾何圖形。

計算 DOMMatrix,將元素的邊框方塊 (以 CSS 像素為單位) 對應至畫布 (以 CSS 像素為單位)。瀏覽器需要使用可視區域轉換,從 3D 座標空間對應至 CSS 座標空間。如要簡化這項作業,請按照下列步驟操作:

  • 正規化 HTML 元素。HTML 元素的大小以像素為單位 (例如 200 像素寬)。不過,WebGL 通常會將物件視為「單位正方形」,例如範圍從 -0.5 到 0.5。如果不進行正規化,200 像素的按鈕看起來會大 200 倍。這個步驟也會翻轉 Y 軸,因為在 CSS 中,向下為正,但在 WebGL 中,向上為正。
  • 將 WebGL MVP 矩陣轉換為 DOM 矩陣。
  • 對應至畫布可視區域。這個步驟是「重新縮放」階段:將單位空間數學運算結果重新拉伸,以符合螢幕上 <canvas> 元素的 CSS 像素尺寸,並將 Y 軸翻轉回來。
  • 計算最終轉換。依序將矩陣相乘:可視區域 * MVP * 正規化。將這些轉換合併為最終轉換,就會產生「對應」,確切指出 HTML 元素應位於何處,才能與 3D 繪圖對齊。瀏覽器會執行透視除法。

    // 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);
    }
    

然後將轉換傳遞至 canvas.updateElementGeometry() 做為 canvasTransform。這會將 HTML 元素移至其算繪像素的正上方。這可確保使用者點選按鈕或選取文字時,實際點擊的是真正的 HTML 元素。瀏覽器也會使用轉換功能,設定元素的無障礙功能邊界,例如螢幕閱讀器焦點環:

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

使用下列選項控制點擊測試:

  • 每次呼叫 updateElementGeometry(),都會將元素移至點擊測試順序的頂端。傳遞 { preserveHitTestOrder: true } 以保留其位置。
  • 呼叫 canvas.clearElementGeometry(element),從點擊測試中移除元素,例如隱藏元素時。

Three.js

  1. 檢查瀏覽器是否支援 HTML-in-Canvas:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. 為 HTML 內容建立自訂幾何圖形和材質。

  3. 將 DOM 元素傳遞至 THREE.HTMLTexture。轉譯器會將元素新增至畫布,並設定必要屬性:

      material.map = new THREE.HTMLTexture(element);
      mesh = new THREE.Mesh( geometry, material );
      scene.add( mesh );
    
  4. 使用 InteractionManager 註冊網格,讓 HTML 元素可供互動。這項功能可讓元素與網格對齊,並將元素幾何圖形與畫布同步,確保點擊和文字選取動作能正確套用至實際的 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

<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 前,請先確認瀏覽器是否支援。
  • 必要:使用 WebGL 或 WebGPU 時,請務必在 <canvas> 元素中加入 content="drawable" 屬性。
  • 必要:使用 WebGL 或 WebGPU 時,請在繪製的每個元素 (包括畫布的直接子項) 中加入 drawable 屬性。
  • 必要:使用 WebGL 或 WebGPU 時,請使用 onpaint 事件處理常式,將 HTML 內容算繪至畫布。
  • 必要:使用 WebGL 時,請先用 texImage2D() 預先分配紋理緩衝區一次 (在初始化或元素大小變更時)。只有在大小變更時才重新分配紋理,並將 TEXTURE_MIN_FILTER 設為 LINEAR。
  • 必要:使用 WebGL 時,請利用 texElementSubImage2D 方法,將 HTML 內容算繪至您預先使用 texImage2D() 分配的紋理,大小為 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,因此通常需要備用策略。

不過,如果瀏覽器支援 HTML-in-Canvas,建議使用這項 API,因為能提升效能。

備用策略取決於用途。舉例來說,如果 Canvas 不支援 HTML-in-Canvas,請使用 CSS 將 HTML 內容放在 Canvas 上方,以在 Canvas 中加入互動式 HTML 內容。

HTML-in-Canvas polyfill

在不支援 HTML-in-Canvas API 的瀏覽器中,請使用下列 Polyfill 指令碼模擬該 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。