26 · 给三维设备添加 HTML 信息标签

返回系列目录

需求与输入

设备看板需要在阀门顶部显示名称。相机移动时文字跟随设备;canvas 不一定铺满窗口,还可能位于滚动页面中。本例使用 README 的 createDemo() 外壳,onFrame((dt, elapsed) => {}) 每帧在渲染前调用。示例中的 DOM 标签只用于说明,不修改项目网页。

从挂点到页面

标签挂点最适合放在设备局部空间:设备移动、旋转或缩放时它自然跟随。转换顺序是局部点 → 世界点 → NDC → CSS 像素。Three.js 中相机观察方向是局部 −Z,因此相机空间 z >= 0 的点位于相机后方或相机平面上,不应显示。

透视除法后 NDC 的 x、y 范围为 −1 到 1,而 DOM 的原点在左上方,所以:

xcss=left+(xndc+1)width/2,ycss=top+(1−yndc)height/2. x_{css}=left+(x_{ndc}+1)width/2,\qquad y_{css}=top+(1-y_{ndc})height/2.

这里 width 必须来自 getBoundingClientRect(),不能使用受 DPR 影响的 canvas.width。固定定位标签使用视口坐标;绝对定位到文档时还需加页面滚动量。

完整核心代码

import { createDemo } from './runtime.js';

const { THREE, scene, camera, renderer, onFrame, onDispose } = createDemo();
const device = new THREE.Mesh(
  new THREE.BoxGeometry(2, 2, 2),
  new THREE.MeshNormalMaterial()
);
scene.add(device);
const anchorLocal = new THREE.Vector3(0, 1.3, 0);
const world = new THREE.Vector3();
const view = new THREE.Vector3();
const ndc = new THREE.Vector3();
const label = document.createElement('div');
label.textContent = '阀门 A';
Object.assign(label.style, {
  position: 'fixed',
  pointerEvents: 'none',
  transform: 'translate(-50%, -100%)',
  color: '#fff',
  background: '#18202b',
  padding: '4px 8px'
});
document.body.appendChild(label);

onFrame(() => {
  device.updateWorldMatrix(true, false);
  camera.updateWorldMatrix(true, false);
  world.copy(anchorLocal);
  device.localToWorld(world);
  view.copy(world).applyMatrix4(camera.matrixWorldInverse);
  ndc.copy(world).project(camera);
  const visible = view.z < 0 &&
    Math.abs(ndc.x) <= 1 && Math.abs(ndc.y) <= 1 &&
    ndc.z >= -1 && ndc.z <= 1;
  label.hidden = !visible;
  if (!visible) return;
  const rect = renderer.domElement.getBoundingClientRect();
  label.style.left = `${rect.left + (ndc.x + 1) * rect.width / 2}px`;
  label.style.top = `${rect.top + (1 - ndc.y) * rect.height / 2}px`;
});

onDispose(() => label.remove());

project() 修改接收者,因此保留独立的 world,后续遮挡查询还会使用它。相机更新必须发生在投影之前,尤其是本帧刚修改相机或父节点的情况。

验证、限制与替代方案

将 canvas 放进有边距的容器、滚动页面、切换窄屏、修改设备父节点变换,标签都应保持挂点一致。把设备移到相机后方,文字应隐藏。

本例只检查视锥,不检查遮挡。设备被墙挡住时标签仍显示。需要遮挡时,可从相机向挂点发射射线,将最近遮挡距离与挂点距离比较;避免把设备自身当成无条件遮挡物。大量标签不宜每帧逐个做 DOM 测量和射线检测:共享一次 canvas 矩形读取,降低遮挡更新频率,或采用 GPU 绘制文字。无障碍、可选择文本通常更适合 DOM。

练习(暂不提供答案)

  1. 将设备加入旋转且非均匀缩放的父组,验证挂点是否仍正确。
  2. 将标签改为容器内的绝对定位,推导对应坐标公式。
  3. 添加一堵墙,实现遮挡隐藏,并区分设备自身与墙的命中。
  4. 给 100 个设备添加标签,将矩形读取移出逐设备循环并观察性能。