隆重推出 HTML-in-Canvas API 源试用

Thomas Nattestad
Thomas Nattestad

多年来,Web 开发者在 Web 上构建复杂的、高度互动的视觉应用时,必须做出艰难的架构选择:是依赖 DOM 的丰富语义功能,还是直接渲染到 <canvas> 元素以获得低级图形性能?

借助新的实验性 **HTML-in-Canvas API** (现已在 源试用 中提供),您无需做出选择。借助此 API,您可以将 DOM 内容直接绘制到 2D 画布或 WebGL/WebGPU 纹理中,同时保持界面可互动、可访问,并与您喜爱的浏览器功能挂钩。通过将 HTML 与低级图形处理相结合,您可以打造以前无法实现的体验。

DOM 与画布

如需了解此新 API 的强大功能,不妨了解一下 DOM 和画布的相对优势。

DOM 是 Web 界面的主要组成部分。它提供开箱即用的文本布局解决方案,使用语义上可理解的内容来创建丰富的界面。这样,用户就可以在网页上无缝执行常见操作,例如突出显示文本以进行复制,或右键点击图片以保存图片。DOM 还与基本的浏览器功能集成:无障碍工具、翻译、网页内查找、阅读器模式、扩展程序、深色模式、浏览器缩放和自动填充。

画布(和 WebGL/WebGPU),另一方面,允许低级访问来驱动像素网格,以实现高度高级的 2D 和 3D 图形。游戏和复杂的 Web 应用(如 Google 文档或 Figma)需要这种高性能的低级访问。由于画布从根本上来说是一个像素网格,因此支持响应式文本等功能过去需要复杂的自定义界面逻辑,这会大幅增加软件包大小。至关重要的是,当界面被困在静态画布像素网格中时,所有集成到 DOM 中的强大浏览器功能都会完全中断。

将 DOM 引入画布的优势

HTML-in-Canvas API 是一座桥梁,可让您兼具两者的优势。通过将 HTML 放置在 <canvas> 元素内并同步其转换,您可以确保内容保持完全互动,并且所有浏览器集成都会自动运行。

通过让 DOM 在 <canvas> 元素内处理界面,您可以获得以下优势:

  • 文本布局和格式设置: 简化的文本布局和格式设置,包括应用了 CSS 样式的多行或双向文本。
  • 表单控件: 表现力强且更易于使用的表单控件,具有丰富的自定义选项。
  • 文本选择、复制/粘贴和右键点击: 用户可以在 3D 场景中突出显示文本,或以原生方式右键点击上下文菜单。
  • 文本选择、复制/粘贴和右键点击: 用户可以在 3D 场景中突出显示文本,或以原生方式右键点击上下文菜单。
  • 无障碍功能: 画布内呈现的内容会公开给无障碍功能树。无障碍功能系统可以像解析普通 HTML 一样解析界面,并将其公开给屏幕阅读器等系统。
  • Find-in-page:: 用户可以使用页内查找 (Ctrl/Cmd+F) 搜索文本,浏览器会直接在 WebGL 纹理中突出显示该文本。
  • Find-in-page:: 用户可以使用页内查找 (Ctrl/Cmd+F) 搜索文本,浏览器会直接在 WebGL 纹理中突出显示该文本。
  • 可编入索引且可与 AI 智能体交互: 网络爬虫和 AI 智能体可以无缝地为呈现到 2D 和 3D 场景中的文本编制索引并读取该文本。
  • 扩展程序集成: 浏览器扩展程序以原生方式运行。例如,文本替换扩展程序会自动更新在 3D 网格上呈现的文本。
  • 开发者工具集成: 您可以直接在 Chrome 开发者工具中检查画布内容,包括 WebGL/WebGPU 界面元素。在检查器中调整 CSS 样式,并观看其在 3D 纹理上即时更新!

宽泛应用场景

此 API 在多个领域释放了巨大的潜力:

  • 基于大型画布的应用: Google 文档、Miro 或 Figma 等大型 Web 应用现在可以将复杂的应用界面组件以原生方式渲染到其画布驱动的工作区中,从而提高无障碍功能并减少软件包大小。
  • 3D 场景和游戏: 营销网站、沉浸式 WebXR 体验和 Web 游戏现在可以将完全可互动的 Web 界面放置到 3D 场景中,例如使用真实 DOM 文本的 3D 书籍,或原生支持复制和粘贴的游戏内终端。

如何使用该 API

使用该 API 分为三个阶段:设置画布、渲染到画布中,以及更新 CSS 转换,以便浏览器知道元素在屏幕上的实际位置。

前提条件

HTML-in-Canvas API 在 Chrome 148 到 150 中处于源试用阶段。如需在您的网站上对其进行测试,请使用 Chrome Canary 版 149 或更高版本,并启用 chrome://flags/#canvas-draw-element 标志。如需为其他用户启用该 API,请注册参加源试用

第 1 步:基本画布设置

首先,将 layoutsubtree 属性添加到 <canvas> 标记。这样,浏览器就会知道画布内嵌套的内容,准备在画布内显示该内容,并将其公开给无障碍功能树。

<canvas id="canvas" style="width: 200px; height: 200px;" layoutsubtree>
  <div id="form_element">
    <label for="name">Name:</label> <input id="name" type="text">
  </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);

第 2 步:渲染

对于 2D 上下文,请使用 drawElementImage 方法。在 paint 事件内执行此操作,该事件会在元素重绘时触发,例如在文本突出显示或用户输入期间。务必使用返回值更新元素的 CSS 转换,以便互动功能继续正常运行。

const ctx = document.getElementById('canvas').getContext('2d');
const form_element = document.getElementById('form_element');
const canvas = document.getElementById('canvas');

canvas.onpaint = () => {
  ctx.reset();

  // Draw the form element at x:0, y:0
  let transform = ctx.drawElementImage(form_element, 0, 0);

  // Use the transform returned later on...
};

使用 WebGL 进行渲染

对于 WebGL,您可以使用 texElementImage2D。它的功能与 texImage2D 类似,但将 DOM 元素作为来源。

canvas.onpaint = () => {
  if (gl.texElementImage2D) {
    gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, form_element);
  }
};

使用 WebGPU 进行渲染

WebGPU 在设备队列上使用 copyElementImageToTexture 方法,类似于 copyExternalImageToTexture

canvas.onpaint = () => {
  root.device.queue.copyElementImageToTexture(
    valueElement,
    { texture: targetTexture }
  );
};

第 3 步:更新 CSS 转换

现在,您已将元素渲染到画布中,接下来需要向浏览器告知元素的位置。这样可确保画布与 DOM 的布局之间实现空间同步。这一点非常重要,因为浏览器需要正确将事件区域(例如用户点击或悬停的确切位置)与元素的渲染位置进行映射。

对于 2D 上下文,请将渲染调用返回的转换应用于 .style.transform property

const ctx = document.getElementById('canvas').getContext('2d');
const form_element = document.getElementById('form_element');
const canvas = document.getElementById('canvas');

canvas.onpaint = () => {
  ctx.reset();
  // Draw the form element at x:0, y:0
  let transform = ctx.drawElementImage(form_element, 0, 0);

  // Sync the DOM location with the drawn location
  form_element.style.transform = transform.toString();
};

对于 WebGL 或 WebGPU,元素的屏幕位置取决于着色器代码如何使用输出纹理,并且无法从画布渲染上下文中推断出来。不过,如果您的着色器程序使用典型的模型视图投影来绘制纹理,那么您可以使用新的便捷函数 element.getElementTransform() 来计算转换,该转换可以与 drawElementImage() 的返回值以相同的方式使用。为此,您需要执行以下操作:

  • 将 WebGL MVP 矩阵转换为 DOM 矩阵
  • 对 HTML 元素进行归一化。HTML 元素的大小以像素为单位(例如,宽度为 200 像素)。不过,WebGL 通常将对象视为“单位正方形”,例如范围从 0 到 1。如果您不进行归一化,您的 200 像素按钮看起来会大 200 倍。
  • 映射到画布视口。此步骤是“重新缩放”阶段:它将单位空间数学运算重新扩展,以匹配屏幕上 <canvas> 元素的实际像素尺寸。它还会翻转 Y 轴,因为在 WebGL 中,向上为正,但在 CSS 中,向下为正。
  • 计算最终转换。按以下顺序将矩阵相乘:Viewport * MVP * Normalization. 将它们合并为一个最终转换会生成一个“地图”,该地图会准确告知浏览器该 HTML 元素层应位于何处,以便与 3D 绘图对齐。
  • 将转换应用于 HTML 元素。这会将 HTML 元素层移动到其渲染的像素的正上方。这样可确保用户点击按钮或选择文本时,他们点击的是真实的 HTML 元素。
if (canvas.getElementTransform) {
  // 1. Convert WebGL MVP Matrix to DOM Matrix
  const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));

  // 2. Normalize the HTML element (pixels -> 1x1 unit square)
  const width = targetHTMLElement.offsetWidth;
  const height = targetHTMLElement.offsetHeight;

  const cssToUnitSpace = new DOMMatrix()
    .scale(1 / width, -1 / height, 1) // Shrink to unit size and flip Y
    .translate(-width / 2, -height / 2); // Center the element

  // 3. Map to the canvas viewport
  const clipToCanvasViewport = new DOMMatrix()
    .translate(canvas.width / 2, canvas.height / 2) // Move origin to center
    .scale(canvas.width / 2, -canvas.height / 2, 1); // Stretch to canvas dimensions

  // 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square)
  const screenSpaceTransform = clipToCanvasViewport
      .multiply(mvpDOM)
      .multiply(cssToUnitSpace);

  // 5. Apply to the transform
  const computedTransform = canvas.getElementTransform(targetHTMLElement, screenSpaceTransform);
  if (computedTransform) {
    targetHTMLElement.style.transform = computedTransform.toString();
  }
}

库和框架支持

一些热门库已发布对 HTML-in-Canvas 功能的支持。

Three.js

手动更新矩阵可能很繁琐,因此框架已开始支持此功能。Three.js 使用新的 THREE.HTMLTexture 提供 实验性支持

const material = new THREE.MeshBasicMaterial();
material.map = new THREE.HTMLTexture(uiElement); // Pass the DOM element

const geometry = new THREE.BoxGeometry(1, 1, 1);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

PlayCanvas

PlayCanvas 还使用其纹理 API 支持 HTML-in-Canvas:

// Wait for the 'paint' event to set the source
canvas.addEventListener('paint', () => {
    htmlTexture.setSource(htmlElement);
}, { once: true });
canvas.requestPaint();

// Keep up to date
canvas.addEventListener('paint', onPaintUpload);

const material = new pc.StandardMaterial();
material.diffuseMap = htmlTexture;
material.update();

演示

在试用演示之前,请确保您的环境已正确配置

多个演示 可作为使用该 API 的参考。我们已经看到社区提供了许多创意解决方案,从可翻译的 3D 书籍到通过玻璃着色器折射的界面元素:

  • 3D 书籍:使用 HTML 布局页面的 WebGL 渲染的 3D 书籍。用户可以使用 CSS 交换字体。由于它是基于 DOM 的,因此内置翻译功能可以立即运行,并且 AI 代理可以更轻松地提取文本。
  • 互动式 3D 界面:一个 WebGPU 果冻滑块,可根据底层 3D 模型折射光线,同时仍响应标准 HTML <input type="range"> 步属性。
  • 动画纹理:一个动态 3D 广告牌,使用 DOM 将动画 SVG 铅笔直接渲染到 WebGL 纹理中,而无需自定义动画循环。
  • 折射叠加层:一个由移动的 3D 光标扭曲的互动式排版层,但可以使用页内查找功能完全选择和搜索。

查看社区创建的演示集合。如果您希望将您的 HTML-in-Canvas 演示添加到此集合中,请创建拉取请求以添加它。

限制

虽然功能强大,但该 API 存在一些有意设置的限制:

  • 跨源内容: 出于安全和隐私原因,该 API 不适用于跨源 iframe 内容。
  • 主线程滚动: HTML-in-Canvas 是使用 JavaScript 绘制的,这意味着滚动和动画无法像在画布外一样独立于 JavaScript 进行更新。开发者应仔细考虑将滚动内容放入画布与让整个画布滚动之间的性能特征。

反馈

如果您正在试用 HTML-in-Canvas API,我们希望听到您的反馈!您可以注册参加 源试用,以便在您的网站上启用此功能(在实验阶段),帮助我们完善 API 设计。您还可以提交问题,提供任何反馈。

资源