常规 <canvas> 内容不会向屏幕阅读器、索引、翻译工具、无障碍辅助工具、页内查找、打印等浏览器功能公开。借助 HTML in canvas,您可以直接在画布元素中呈现真实的 DOM。向 <canvas> HTML 元素添加 content="drawable" 属性会布局其后代 HTML 元素,而 drawable 属性会标记您可以在画布的渲染上下文中渲染的元素。您可以使用它在画布中设置文本样式和布局,向浏览器功能(例如无障碍功能、翻译或页内查找)公开画布内容,以及将 2D 和 3D 效果应用于 HTML。
如何实现
检查浏览器是否支持 HTML-in-Canvas:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }将
content="drawable"属性添加到<canvas>HTML 元素中。将 HTML 内容放入
<canvas>元素中,并为您绘制的每个元素添加drawable属性。具有drawable的元素会捕获其子树,但同样具有drawable的嵌套后代除外。<canvas id="canvas" content="drawable"> <div id="html-content" drawable></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);在
canvas.onpaint事件处理脚本中将 HTML 内容渲染到画布。只有在画布绘制元素后,才能绘制元素:调用canvas.requestPaint()以请求paint事件。
- 在 2D 上下文中,使用
drawElementImage方法。它会绘制drawable元素及其可绘制子树的最后一个快照,从元素的边框开始,在 CSS 转换之前:
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
ctx.drawElementImage(form_element, 0, 0);
};
- 在 WebGL 和 WebGPU 上下文中,您需要将元素复制到纹理中。从
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) {
// Redraw the element, or update its texture, as shown above
}
// In WebGL and WebGPU, render the scene, and sync the element geometry as shown in step 6
};
- 同步元素几何图形。
浏览器需要知道 HTML 元素的绘制位置,以便其 DOM 位置与绘制的像素相匹配。例如,点击测试(用于点击和文本选择)和无障碍功能会使用此几何图形:几何图形未更新的 drawable 元素仍会向无障碍功能树公开,但没有几何图形信息。画布仅对使用 2D drawElementImage() 方法绘制或使用 updateElementGeometry() 注册的元素进行点击测试。
对于 2D 上下文,浏览器会自动同步元素。
drawElementImage()会将 DOM 点击测试边界和屏幕阅读器焦点环与绘制的坐标对齐,因此您无需设置style.transform。如需手动管理几何图形(例如,当您多次绘制同一元素时),请将{ preserveElementGeometry: true }传递给drawElementImage(),并使用canvas.updateElementGeometry()应用转换。对于使用 WebGL 或 WebGPU 的 3D 情况,画布无法判断 HTML 元素的绘制位置,因此您必须告知它。计算一个
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)可从点击测试中移除某个元素,例如在您隐藏该元素时。
示例代码
2D 画布
<canvas id="canvas" content="drawable" style="width: 400px; height: 200px;">
<div id="ui-element" drawable>
<p>
This text is rendered inside the canvas but is present in the DOM tree.
</p>
<input type="email" name="email" placeholder="enter your email" />
<button type="button">Submit</button>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const uiElement = document.getElementById("ui-element");
canvas.onpaint = () => {
ctx.reset();
// Draw the HTML element at x:0, y:0. The browser syncs the DOM location
// with the drawn location.
ctx.drawElementImage(uiElement, 0, 0);
};
// Handle resizing to match device pixels
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);
canvas.requestPaint();
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== "undefined" &&
"devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox
? { box: "device-pixel-content-box" }
: {};
observer.observe(canvas, options);
</script>
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 5.
// computeCanvasTransform() is defined in step 6.
// 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 5.
// computeCanvasTransform() is defined in step 6.
// 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>
最佳做法
- 强制要求:在使用 HTML-in-Canvas API 之前,请先检查浏览器是否支持该 API。
- 强制性要求:始终向
<canvas>元素添加content="drawable"属性。 - 强制性:向您绘制的每个元素(包括画布的直接子元素)添加
drawable属性。 - 强制性要求:使用
onpaint事件处理脚本将 HTML 内容呈现到画布上。 - 强制性:在 2D 中使用
drawElementImage方法,或在 WebGPU 中使用drawElementImageToTexture方法,将 HTML 内容渲染到画布上。 - 强制性:在 WebGL 中,使用
texImage2D()预分配纹理缓冲区一次(在初始化时或元素大小发生变化时)。仅在大小发生变化时重新分配纹理,并将TEXTURE_MIN_FILTER设置为LINEAR。 - 强制性要求:在 WebGL 中,使用
texElementSubImage2D方法将 HTML 内容渲染到您使用texImage2D()预分配的纹理中,纹理大小从captureElementImage()开始。 - 强制性:在 WebGPU 中,根据
captureElementImage()调整纹理大小,并且仅在大小发生变化时重新创建纹理。创建具有COPY_DST和RENDER_ATTACHMENT用法的纹理,并将纹理大小作为size传递。 - 强制要求:将 HTML 元素的几何图形与呈现的内容同步。在 2D 模式下,
drawElementImage会自动执行此操作。在 WebGL 和 WebGPU 中,调用canvas.updateElementGeometry(element, { canvasTransform })。 - 强制要求:使用
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。
安装或嵌入库:
# Install npm install three-html-render# Embed <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>运行
installHtmlInCanvasPolyfill()方法以翻译 HTML-in-Canvas。