26 · 给三维设备添加 HTML 信息标签
需求与输入
设备看板需要在阀门顶部显示名称。相机移动时文字跟随设备;canvas 不一定铺满窗口,还可能位于滚动页面中。本例使用 README 的 createDemo() 外壳,onFrame((dt, elapsed) => {}) 每帧在渲染前调用。示例中的 DOM 标签只用于说明,不修改项目网页。
从挂点到页面
标签挂点最适合放在设备局部空间:设备移动、旋转或缩放时它自然跟随。转换顺序是局部点 → 世界点 → NDC → CSS 像素。Three.js 中相机观察方向是局部 −Z,因此相机空间 z >= 0 的点位于相机后方或相机平面上,不应显示。
透视除法后 NDC 的 x、y 范围为 −1 到 1,而 DOM 的原点在左上方,所以:
这里 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。
练习(暂不提供答案)
- 将设备加入旋转且非均匀缩放的父组,验证挂点是否仍正确。
- 将标签改为容器内的绝对定位,推导对应坐标公式。
- 添加一堵墙,实现遮挡隐藏,并区分设备自身与墙的命中。
- 给 100 个设备添加标签,将矩形读取移出逐设备循环并观察性能。