Three.js From Zero · Article s7-01

S7-01 3D UI Mental Model

Season 7 · Article 01

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

LayerImplementationCostUse when…
Screen-space HTML<div> over canvas~0 (browser handles)HUDs, menus, forms
Worldspace HTMLCSS3DRenderer / html2canvasModerateFloating labels at 3D positions
Canvas-texture UIDraw 2D to canvas → map to meshLowScreens IN the 3D scene (terminals)
True 3D mesh UIBoxGeometry + PlaneGeometry buttonsHigherVR, 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.

FPS: 60
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.