在 3D 场景中嵌入可交互 HTML:modern-web-guidance HTML-in-Canvas 实战指南

原创2026-11-12 23:59:221,741 阅读
文章标签:前端文档AI 技能AI 评测Agent 评测人工智能RAGCLI提示工程

在 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 坐标空间的视口变换(含透视除法)。

矩阵推导分四步,顺序不能乱:

  1. 归一化:HTML 元素以像素计量(例如 200px 宽),而 WebGL 通常把物体当作 -0.5 到 0.5 的“单位方块”。不归一化,200px 的按钮就会显得大 200 倍。这一步同时翻转 Y 轴——CSS 向下为正,WebGL 向上为正。
  2. 把 WebGL MVP 矩阵转成 DOMMatrix。
  3. 映射到 canvas 视口:把单位空间重新拉伸回 canvas 的 CSS 像素尺寸,并把 Y 轴翻回去。
  4. 按 视口 × 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 内容暴露给浏览器特性,它们分别覆盖“视觉效果”和“可访问性/可检索性”两个方向。

登录后查看全文
modern-web-guidance-src