在 3D 场景中嵌入可交互 HTML:modern-web-guidance HTML-in-Canvas 实战指南
在 3D 场景中嵌入可交互 HTML:modern-web-guidance HTML-in-Canvas 实战指南
本指南围绕 modern-web-guidance 中的 interactive-content-in-3d-scenes 这一主题,说明如何借助 HTML-in-Canvas API 把真实 DOM 投影进 WebGL、WebGPU 或 Three.js 的 3D 场景,并让按钮、输入框、文本选择保持原生可交互。读完你将掌握特性检测、设备像素对齐、纹理上传与复用、DOMMatrix 几何同步这几个环节的完整实现方式,并了解仓库中配套示例、评分脚本与规范约束之间的关系。
核心原理:DOM 仍在布局树中,所以交互是“真”的
HTML-in-Canvas 允许在 <canvas> 内部直接渲染真实 DOM。用于 WebGL、WebGPU、Three.js 等 3D 渲染上下文时,只要给 canvas 加上 content="drawable",并给需要绘制的后代元素加上 drawable,这些元素就会被投影到 3D 场景中。
关键价值不在于“画得像”,而在于这些 HTML 元素始终是活跃 DOM 布局树的一部分。因此用户可以原生地点击按钮、选中文字、触发表单控件与焦点态,不需要射线检测(raycasting)、不需要手写命中和事件转发逻辑。仓库中原始说明见 guide.md。
一个重要的结构语义:带 drawable 的元素会捕获自身子树,但嵌套的、同样带 drawable 的后代会被单独捕获,不并入父级快照。这决定了纹理粒度与几何同步粒度:
<canvas id="canvas" content="drawable">
<div id="html-content" drawable></div>
</canvas>
第一步:特性检测与浏览器支持
任何使用前都必须做特性检测,这是指南中的 MANDATORY 约束:
if ('content' in HTMLCanvasElement.prototype) {
// Use HTML in Canvas API
} else {
// Use fallback strategy
}
[!NOTE] HTML-in-Canvas is supported from Chrome 157.
检测的落点是 HTMLCanvasElement.prototype 上是否存在 content 这个成员,而不是检测某个具体方法,这样同一段代码在 WebGL、WebGPU、Three.js 三种路径下可以共用同一个判断。
在仓库层面,这一能力通过 web feature id canvas-html 被索引:指南 frontmatter 中声明了 web-feature-ids: - canvas-html(guide.md),features/origin-trials.json 中登记了该 feature 的 chromestatus 条目。指南正文里出现的 {{ BASELINE_STATUS("canvas-html") }} 是构建期宏,由 src/core/macros.ts 注册的 BASELINE_STATUS 处理器调用 src/core/baseline.ts 的 getStatusMessage() 解析成实际 Baseline 文案;因此在仓库的 Markdown 源文件里看到的是宏调用,而不是硬编码的兼容性结论。这也是 src/core/guide-validation.ts 会强制检查的规则:不允许手写 Baseline 可用性结论,必须走宏。
第二步:让 canvas 尺寸对齐设备像素
模糊的根源是 canvas 的 backing store(canvas grid / device pixels)尺寸与 CSS 显示尺寸不一致。指南给出的做法是用 ResizeObserver 观测,优先取 devicePixelContentBoxSize,退化时用 contentRect × devicePixelRatio:
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.captureElementImage() 返回的 width/height 就是 canvas grid 像素。示例 demo 在尺寸变化后还会显式标记“需要重新上传纹理”并请求一次 paint(demo.html),因为元素在 canvas grid 中的像素尺寸随画布尺寸一起变了。
同时要注意:不要在 onpaint 里创建 ResizeObserver,指南明确把它列为禁止项,原因是可能造成内存泄漏。
第三步:在 paint 事件里取得元素快照
元素只有在 canvas 已经绘制过它之后才能被上传。canvas.requestPaint() 用于请求一次 paint 事件;captureElementImage() 在元素尚无 paint 记录时会抛错,此时应请求绘制并在下一个 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;
}
三个细节值得注意:
- 尺寸要用
Math.ceil()向上取整,因为它可能是小数; - 拿到尺寸后立即
elementImage.close(),避免长期持有图像资源; - 宽或高为 0 时返回
null,交由调用方跳过本次上传。
在 requestAnimationFrame 渲染循环中,需要每帧调用 canvas.requestPaint();event.changedElements 列出渲染发生变化的元素,只有它包含目标元素时才应重新上传纹理,避免每帧做一次无意义的 GPU 拷贝:
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
};
第四步:WebGL 与 WebGPU 的纹理上传
WebGL:先预分配,再子图上传
WebGL 路径分两步。第一步用 texImage2D() 且以 null 代替像素数据来预分配纹理存储:它只按给定尺寸与格式保留 GPU 内存,不上传任何像素。规则有三条:
- 纹理尺寸来自
captureElementImage(); - 只在首次上传或尺寸变化时分配 level 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 };
第二步用 texElementSubImage2D 把元素渲染进纹理。要点是:配置里传入 { width, height },这样拷贝会填满已分配的纹理,无论 canvas 如何缩放;尺寸变化时先重新分配再上传。
// 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);
}
};
注意只分配了 level 0(没有 mipmap 链),因此 TEXTURE_MIN_FILTER 必须是 LINEAR 这类不依赖 mipmap 的过滤方式——这也是指南把“设置 TEXTURE_MIN_FILTER 为 LINEAR”列为强制项的原因。示例 demo 还设置了 CLAMP_TO_EDGE 的 wrap 模式,并用 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) 把图像像素翻转为 WebGL 期望的自下而上顺序(demo.html)。
WebGPU:不能 resize,只能重建
WebGPU 用 drawElementImageToTexture。它的约束和 WebGL 不同,值得逐条对照:
- 创建纹理时 usage 需要
COPY_DST与RENDER_ATTACHMENT,若要在 shader 中采样还要加TEXTURE_BINDING;usage 写错会以 WebGPU validation error 的形式报出,而不是抛异常(因此try/catch抓不到); - 传入
size:不传size时较大的元素会被裁剪到纹理内,传了size则会缩放填满; - WebGPU 纹理无法 resize,元素尺寸变化时必须重建纹理,并重建引用它的 bind group。
// 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);
}
};
第五步:几何同步——把 DOM 放回它被画出的位置
纹理只解决了“看起来在 3D 里”。要让点击、文本选区、焦点环、屏幕阅读器的无障碍边界都落在正确位置,必须计算一个 DOMMatrix,把元素 border box(CSS 像素)映射到 canvas(CSS 像素),再交给 canvas.updateElementGeometry()。浏览器内部会自己完成从 3D 坐标空间到 CSS 坐标空间的视口变换(含透视除法)。
矩阵推导分四步,顺序不能乱:
- 归一化:HTML 元素以像素计量(例如 200px 宽),而 WebGL 通常把物体当作 -0.5 到 0.5 的“单位方块”。不归一化,200px 的按钮就会显得大 200 倍。这一步同时翻转 Y 轴——CSS 向下为正,WebGL 向上为正。
- 把 WebGL MVP 矩阵转成 DOMMatrix。
- 映射到 canvas 视口:把单位空间重新拉伸回 canvas 的 CSS 像素尺寸,并把 Y 轴翻回去。
- 按 视口 × MVP × 归一化 相乘,得到最终变换;浏览器负责透视除法。
// 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);
}
关于链式顺序的理解:DOMMatrix 的 scale()/translate() 是在当前矩阵上右乘,所以链式调用的语义是“后写的方法先作用于点”。这正是注释中“先把原点平移到 (-width/2, -height/2),再缩放”的含义;相乘阶段则严格按 视口 * MVP * 归一化 组合,使一个 -0.5~0.5 的四边形最终贴合到指定屏幕区域。
然后把这个变换作为 canvasTransform 传入:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
命中测试顺序有两个可调项:
- 每次调用
updateElementGeometry()都会把元素移到命中测试顺序的顶端;传{ preserveHitTestOrder: true }可保持原位置; - 调用
canvas.clearElementGeometry(element)可把元素从命中测试中移除,例如元素被隐藏时。
Three.js:HTMLTexture 与 InteractionManager
Three.js 路径把上述工作交给框架封装,步骤更短:创建几何体与材质,把 DOM 元素传给 THREE.HTMLTexture(渲染器会负责把该元素加入其 canvas 并设置所需属性),再通过 InteractionManager 注册 mesh,让元素与 mesh 保持对齐、几何与 canvas 同步,从而让点击与文本选择命中真实 DOM。
material.map = new THREE.HTMLTexture(element);
mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );
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 );
interactions.update() 必须在渲染前、且每帧都在动画循环里调用;这是让 HTML 元素持续接收输入的强制要求。完整可运行片段见原指南的 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);
}
仓库中的完整示例:旋转立方体上的可交互表单
guides/visual-design/interactive-content-in-3d-scenes/demo.html 是一个可直接对照的端到端实现:一个 400×400 的 HTML 表单被当作纹理贴在旋转立方体上,旋转过程中表单仍可点击,获得焦点后旋转暂停以便输入。
几个实现层面的设计点,值得在自己的项目里复用:
1. 用 outline 而不是 border 描边 canvas。 demo 的注释写得很直接:outline 不参与布局,能保持 canvas 的 border box 与 content box 对齐,于是表单的 DOM 位置和它被绘制的位置一致(demo.html)。如果用 border,canvas 的内容盒会内缩,几何同步就可能差出边框宽度。
2. 元素尺寸与可读性协同设计。 立方体每个面小于表单本身,因此 demo 把表单字号放大到 28px、标题 40px,保证纹理贴到面上仍然可读(demo.html)。
3. 只重传发生变化的元素。 paint 回调里先判断 textureNeedsUpload 或 event.changedElements.includes(draw_element),再上传;上传失败(尚无 paint 记录)时把标记保持为需要上传,下次 paint 再试:
canvas.onpaint = (event) => {
// Only re-upload the texture when the form's rendering changed, for
// example, when users type, or when a focus ring is shown.
if (textureNeedsUpload || event.changedElements.includes(draw_element)) {
try {
textureNeedsUpload = !uploadElementWebGL(gl, canvas, texture, draw_element, textureState);
} catch (err) {
console.error('texElementSubImage2D copy failed:', err);
}
}
// Nothing to draw until the form is uploaded for the first time.
if (textureState.width === 0) return;
// ...
};
4. 同一份 DOM 只能待在一个位置,所以选择“最正对相机的那个面”。 表单被贴到了六个面上,但 DOM 元素本身只有一份几何。demo 用 sideFaceMatrix(index) 把单位四边形旋到指定侧面,用 frontFaceIndex(rotation) 按当前旋转角度算出最正对相机的面,再合成该面的 MVP 传给 updateElementGeometry:
// Maps the -0.5 to 0.5 quad that computeCanvasTransform() expects onto a side
// face of the cube: 0 is the front face, and 1, 2, and 3 are the right, back,
// and left faces.
function sideFaceMatrix(index) {
return new DOMMatrix()
.rotateAxisAngle(0, 1, 0, 90 * index)
.translate(0, 0, 1)
.scale(2, 2, 1);
}
// Returns the index of the side face that faces the camera the most, for a
// cube rotated by `rotation` degrees around the Y axis.
function frontFaceIndex(rotation) {
return ((Math.round(-rotation / 90) % 4) + 4) % 4;
}
最终合成与同步:
const faceMVP = projectionMatrix
.multiply(modelViewMatrix)
.multiply(sideFaceMatrix(frontFaceIndex(cubeRotation)));
canvas.updateElementGeometry(draw_element, {
canvasTransform: computeCanvasTransform(canvas, draw_element, faceMVP.toFloat32Array()),
});
这里 toFloat32Array() 与前面 new DOMMatrix(Array.from(mvp)) 用的是同一套列主序约定(demo 中的 perspective() 注释也强调 DOMMatrix 按列主序接收 16 个值,与 WebGL 一致),因此矩阵在两个方向转换时不会错位。
5. 尊重动效偏好与输入状态。 动画循环在 prefers-reduced-motion: reduce 或表单处于 :focus-within 时停止累加旋转角度:
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
function animate() {
if (!reducedMotion.matches && !draw_element.matches(':focus-within')) {
cubeRotation = (cubeRotation + 0.5) % 360;
}
canvas.requestPaint();
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
6. 表单就是普通表单。 因为 DOM 是真实的,提交逻辑直接监听 submit、用 FormData 读取输入并写入 <output>,不需要任何与 3D 相关的胶水代码(demo.html)。
7. 降级路径同时检测两项条件。 demo 的入口判断是 'content' in HTMLCanvasElement.prototype && gl,两者任一不满足就隐藏 canvas、把表单节点移到 canvas 之后,并显示提示文案(demo.html)。这比只检测 API 更稳妥,因为 WebGL 2 上下文也可能创建失败。
反例剖析:negative-demo 里的五个典型错误
guides/visual-design/interactive-content-in-3d-scenes/negative-demo.html 保留了旧式“把元素直接塞进 texImage2D”的写法,它与正确实现形成鲜明对照,覆盖的错误恰好对应指南的强制项:
| 反例写法 | 问题 | 正确做法 |
|---|---|---|
<canvas id="gl-canvas"> 缺少 content="drawable" |
后代 HTML 不会被布局并暴露给浏览器特性 | 给 canvas 加 content="drawable" |
<div id="draw_element"> 缺少 drawable |
元素不会被当作可绘制子树捕获 | 每个要绘制的元素加 drawable |
gl.texImage2D(..., draw_element) 在 animate() 中每帧调用 |
没有预分配、没有尺寸比较,且上传时机不在 paint 事件里 | 用 captureElementImage() 取尺寸 + texImage2D(null) 预分配 + 在 onpaint 中 texElementSubImage2D |
canvas 宽高设为 100%,未做设备像素对齐 |
纹理模糊、尺寸随布局抖动 | 用 ResizeObserver 设置 canvas.width/height 为设备像素 |
元素内嵌 https://example.com/image.png 与外部脚本 |
跨域内容不支持嵌入 canvas,可能污染画布;外部依赖增加不确定性 | 不嵌入跨域内容,本地化资源 |
其中“每帧整块重传”是性能上最直接的损失:正确实现只在元素尺寸变化时重新分配纹理,只在 changedElements 命中时才重新上传,其余帧只做绘制。
强制规范与评分维度
仓库把这份指南的约束固化成了可执行的检查。expectations.md 用 MUST 列表描述了 11 条硬性要求,grader.ts 则把它们变成 Playwright 测试,逐一验证:
- 使用 API 前做特性检测(正则匹配
'content' in); <canvas>带content="drawable";- canvas 的直接子元素带
drawable; - 渲染发生在绘制回调中(
onpaint/requestPaint出现); - 上传路径使用
texElementSubImage2D或drawElementImageToTexture; - 纹理按
captureElementImage()的尺寸分配(源码中需同时出现captureElementImage、Math.ceil与分配调用); - 纹理只在尺寸变化时重新分配(存在尺寸比较表达式);
- 每个绘制元素都调用
updateElementGeometry且传入canvasTransform; - 使用
ResizeObserver观测屏幕尺寸变化; - 在不支持的环境下存在降级策略,且删除全部相关 API 后页面不产生运行时报错。
测试的实现方式也很有参考价值:beforeEach 会注入 polyfill 桩,为 content、captureElementImage、requestPaint、updateElementGeometry 提供可观测替身,并包装 texElementSubImage2D / drawElementImageToTexture 记录是否被调用(grader.ts)。最后一项降级测试会删除这些成员后加载页面,断言 pageerror 为空(忽略画布污染类错误),再检查代码中是否存在 three-html-render 或 installHtmlInCanvasPolyfill 等降级线索(grader.ts)。
对照这份清单检查自己的实现,比凭直觉调试更高效。
降级策略与 polyfill
由于 HTML-in-Canvas 尚未在所有现代浏览器中可用,降级是必需项,而不是可选项。指南给出的原则是:如果浏览器支持,就应当使用它(因为有性能收益);降级方案取决于用例——对于“canvas 中的可交互 HTML 内容”,若不支持,就把 HTML 内容用 CSS 叠放在 canvas 之上,视觉上接近、交互完全可用。这正是 demo 采用的路径。
如果需要在不支持的浏览器中模拟 API,可以使用指南提供的 polyfill:
# Install
npm install three-html-render
指南同时给出了内嵌脚本的方式(加载该包的 dist/polyfill.js 构建产物)以及随后调用 installHtmlInCanvasPolyfill() 完成翻译。使用前请以对应版本的包文档为准,确认其覆盖的 API 范围是否满足你的实现。
最佳实践清单
以下条目直接对应指南的强制项与禁止项,可作为上线前自查表:
- 必须在使用前检测
'content' in HTMLCanvasElement.prototype; - 必须给
<canvas>加content="drawable"(WebGL / WebGPU 路径); - 必须给每个要绘制的元素(含 canvas 的直接子元素)加
drawable; - 必须在
onpaint处理器中完成 HTML 内容到 canvas 的渲染; - 必须在 WebGL 中用
texImage2D()预分配纹理缓冲(初始化时或尺寸变化时),仅在尺寸变化时重新分配,并设置TEXTURE_MIN_FILTER = LINEAR; - 必须在 WebGL 中用
texElementSubImage2D上传,尺寸来自captureElementImage(); - 必须在 WebGPU 中用
drawElementImageToTexture,按captureElementImage()尺寸创建纹理、仅在尺寸变化时重建,usage 包含COPY_DST与RENDER_ATTACHMENT,并传入size; - 必须调用
canvas.updateElementGeometry(element, { canvasTransform })让 DOM 位置、命中测试与无障碍边界和渲染内容一致; - 必须在 Three.js 中把 mesh 注册到
InteractionManager,并在动画循环中调用interactions.update(); - 必须观测屏幕尺寸并把 canvas 尺寸更新到设备像素(例如
ResizeObserver); - 不要在 canvas 中嵌入跨域内容;
- 不要在
onpaint中初始化ResizeObserver。
小结
这套方案的骨架可以压缩成一条链路:content="drawable" 让后代 DOM 进入布局并被标记为可绘制 → ResizeObserver 把 canvas grid 对齐设备像素 → requestPaint() / onpaint 提供绘制时机 → captureElementImage() 给出以 canvas grid 像素计的尺寸 → texElementSubImage2D(WebGL)或 drawElementImageToTexture(WebGPU)把快照写入纹理 → 归一化、MVP、视口三段矩阵合成 canvasTransform → updateElementGeometry() 把真实 DOM 贴回它被画出的位置。Three.js 路径把纹理与几何同步分别封装为 THREE.HTMLTexture 与 InteractionManager。规范层面的要求、评分脚本与可运行示例都在 guides/visual-design/interactive-content-in-3d-scenes 目录内,可以逐项对照实现与验证。想继续延伸,可以阅读同一特性下的 WebGL shader 应用指南 和 把 canvas 内容暴露给浏览器特性,它们分别覆盖“视觉效果”和“可访问性/可检索性”两个方向。