Three.js From Zero · Article s7-01
S7-01 3D UI Mental Model
3D UI Mental Model
Three places your UI can live: HTML overlay on top of the canvas, 2D canvas drawn as a texture on a mesh, or actual 3D meshes (buttons, panels). Each has a different cost-quality-flexibility curve.
1. The three layers
| Layer | Implementation | Cost | Use when… |
|---|---|---|---|
| Screen-space HTML | <div> over canvas | ~0 (browser handles) | HUDs, menus, forms |
| Worldspace HTML | CSS3DRenderer / html2canvas | Moderate | Floating labels at 3D positions |
| Canvas-texture UI | Draw 2D to canvas → map to mesh | Low | Screens IN the 3D scene (terminals) |
| True 3D mesh UI | BoxGeometry + PlaneGeometry buttons | Higher | VR, spatial layouts |
2. Screen-space HTML (the default)
<div id="canvas-host">
<canvas></canvas>
<div class="hud">HP: 100 / 100</div>
</div>
Absolutely positioned HTML over the 3D canvas. Full CSS, full a11y, full React. Browser composites for free. This covers 90% of UI needs in any 3D app.
3. Worldspace HTML — CSS3DRenderer
Three.js ships CSS3DRenderer. Each CSS3DObject is a DOM element, positioned with matrix3d transforms so it tracks 3D space.
import { CSS3DRenderer, CSS3DObject } from 'three/addons/renderers/CSS3DRenderer.js';
const cssRenderer = new CSS3DRenderer();
const cssObj = new CSS3DObject(document.querySelector('#label-1'));
cssObj.position.set(0, 2, 0);
cssScene.add(cssObj);
Pro: real text, selectable, real inputs work. Con: two renderers, two DOM trees, z-ordering quirks.
4. Canvas-texture — 2D as a 3D surface
const canvas = document.createElement('canvas');
canvas.width = 1024; canvas.height = 512;
const ctx = canvas.getContext('2d');
// Draw UI
ctx.fillStyle = '#333';
ctx.fillRect(0, 0, 1024, 512);
ctx.fillStyle = '#fff';
ctx.fillText('In-scene terminal', 20, 40);
const tex = new THREE.CanvasTexture(canvas);
const screen = new THREE.Mesh(new THREE.PlaneGeometry(2, 1), new THREE.MeshBasicMaterial({ map: tex }));
scene.add(screen);
The CANVAS stays in sync — redraw and call tex.needsUpdate = true. Cost per frame: whatever the 2D canvas draws.
5. True 3D mesh UI
Buttons as meshes. Panels as planes. For VR especially. Raycast per frame; apply hover state.
const btn = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 0.15, 0.05),
new THREE.MeshStandardMaterial({ color: 0x4070c0 })
);
btn.userData.onClick = () => console.log('pressed');
// In raycast handler:
if (intersect[0].object === btn) intersect[0].object.userData.onClick();
6. Mixing the layers (common pattern)
A typical 3D product viewer:
- Screen-space HTML: toolbar, filters, share button. Any standard UI.
- Worldspace HTML or canvas-texture labels: "Seat 2C" floating on features.
- 3D mesh interactables: rotation handles, measurement probes.
7. Live demo — all three in one scene
HTML HUD top-right. Worldspace floating labels. Raycast-interactive 3D button in the scene.
Mode: HUD + world label + 3D button
(The blue labels above objects and the HUD box are the three UI layers.)
8. Performance notes
- Screen-space HTML: free. Avoid high-update rate (like FPS) via throttle.
- CSS3DRenderer: extra DOM reflow cost. Keep object count < 50.
- CanvasTexture: 2D redraw cost. Only update when data changes.
- 3D meshes: one raycast per frame, cheap.
9. Takeaways
- Default to screen-space HTML. 90% of cases.
- CSS3DRenderer for worldspace text (labels, annotations).
- CanvasTexture for UI "inside" the 3D scene (terminal screens, billboards).
- True 3D meshes for VR and spatial interactions.
- Mix all three when the app is complex.