Three.js From Zero · Article s7-03
S7-03 Worldspace UI
Season 7 · Article 03
Worldspace UI & Floating Labels
Labels that stick to 3D objects. Project world position to screen each frame, position a DOM element there. Depth-aware fade. Occlusion test. Five-line pattern.
1. The core pattern
const labels = [
{ obj: mesh1, el: document.getElementById('label-1'), text: 'Sphere' },
...
];
function tickLabels() {
const v = new THREE.Vector3();
for (const { obj, el } of labels) {
obj.getWorldPosition(v);
v.project(camera);
const sx = (v.x * 0.5 + 0.5) * w;
const sy = (1 - (v.y * 0.5 + 0.5)) * h;
el.style.transform = `translate(${sx}px, ${sy}px)`;
el.style.opacity = v.z < 1 ? '1' : '0';
}
}
2. Depth fade
Labels far behind the camera shouldn't show. Labels behind other objects (occluded) should fade.
// Behind camera
const visible = v.z < 1 && v.z > -1;
// Occlusion: raycast from camera to label position
ray.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera);
const hits = ray.intersectObjects(scene.children, true);
const occluded = hits[0]?.object !== labels[i].obj && hits[0]?.distance < objDistance;
el.style.opacity = (visible && !occluded) ? '1' : '0.3';
3. Live demo — toggleable labels
–
4. CSS3DRenderer alternative
If you want true 3D transforms (perspective, rotation with the scene), use CSS3DRenderer. Each label is a CSS3DObject attached to a parent mesh. Matrix3D transforms handle everything.
Downside: requires a second renderer alongside WebGL. Z-ordering between them is tricky.
5. Label clustering
When 50 labels overlap, use clustering like Google Maps:
- Group labels within 50px of each other.
- Show a single "+N" marker until user zooms in.
- Rebuild clusters on camera movement (throttle — don't do every frame).
6. Leader lines
Annotation tools (CAD, medical): label sits in screen space but connects to 3D point via a line. Two parts:
- Label DOM element at fixed screen position.
- SVG line element updated each frame to connect (sx, sy) to projected anchor point.
7. Performance
- 100 labels: smooth. DOM reflow is cheap for transform changes.
- 500+: consider canvas-rendered labels or a clustering strategy.
- Use
transform: translate3d(...)(notleft/top) for compositor-fast updates.
8. Takeaways
- project(camera) + inverse-NDC = screen position.
- Update DOM element's transform each frame.
- v.z < 1 for "in front of camera" check.
- Raycast against scene for occlusion.
- Cluster at high label counts.