在 3D 场景中启用交互式 HTML 内容

Web 功能: 画布中的 HTML

HTML-in-Canvas API 允许直接在 canvas 元素内渲染真实 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);
    }
    

然后,将转换作为 canvasTransform 传递给 canvas.updateElementGeometry()。这会将 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 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() 预分配纹理缓冲区一次(在初始化时或元素大小发生变化时)。仅在大小发生变化时重新分配纹理,并将 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,因此通常需要采用后备策略。

不过,鉴于此 API 带来的性能提升,如果浏览器支持 HTML-in-Canvas,则应使用该 API。

后备策略取决于用例。例如,对于画布中的交互式 HTML 内容,如果不支持 HTML-in-Canvas,请使用 CSS 将 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。