Command Palette

Search for a command to run...

Hectal
Course outline
Topic 8.3Intermediate ~21 min · step 3 of 6

HTML Labels over 3D

In one line

Labels are easiest as real HTML placed over the canvas. CSS2DRenderer does the placing; project() lets you do it yourself. You handle labels behind the camera, off-screen, hidden behind the bike and far too many of them.

0/6 · 0%

Think of it like this

Sticky notes on a glass display case. You stick each note on the glass where it lines up with the part behind it. Walk around the case and you have to move every note to keep it lined up. A note behind the case, or off to the side, you take off.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

CSS2DObject
An Object3D that carries an HTML element. Where the object goes in 3D, the element follows on screen.
project
Turn a 3D world point into normalised device coordinates for a camera: point.project(camera).
occlusion
One object hiding another from the camera's point of view.
pointer-events: none
A CSS rule that makes an element ignore clicks and taps, so they reach what is underneath.

Step by step

01Add a layer on top of the canvas

Create CSS2DRenderer, set its size, and append its domElement after the canvas. Style it position: absolute; top: 0; left: 0; pointer-events: none.

02Make a label and attach it

Build a div with your text and class, wrap it in new CSS2DObject(div), set label.position (for example (0, 0.6, 0), above the object), and object.add(label).

03Render both layers every frame

renderer.render(scene, camera) then labelRenderer.render(scene, camera). Without the second call the divs are never put in the page.

04Check occlusion yourself

Shoot a ray from the camera to each label. If anything solid is nearer than the label, add a class that fades it.

Try it yourself

  1. 1

    Move the wall in front of the lamp

    In the labels example change wall.position.set(-1.6, 0, 1); to wall.position.set(1.4, 0, 1);. The output becomes Wall visible, Bell visible, Headlight behind something: faded. A label fades only when something solid sits between it and the camera.

  2. 2

    Move the camera and re-read the table

    In the projection example change camera.position.set(0, 0.5, 5) to camera.position.set(0, 0.5, 15). The names in the table describe the old camera, so read the numbers instead. The point at z = 9 is now in front of the camera, so behind the camera prints pixel 400 200 | x,y on screen: true | depth ok: true | show label: true. far off to the right moves to pixel 690 and is on screen. Only beyond the far plane still prints depth ok: false, because z = −60 is now 75 away and the far plane is at 50.

Code & diagrams

Labels with CSS2DRenderer, and one hidden behind a wall New tab

The blue wall hides the ball behind it. The Bell label (the ball) fades because a ray from the camera to it hits the wall first.

idle

Console

Run the scene to see its console.

Expected console output

Wall           visible
Bell           behind something: faded
Headlight      visible
label divs in the page: 3
a label's CSS transform is set by CSS2DRenderer: true
Projecting by hand: behind the camera, off-screen, too many New tab

No drawing here: the example is the numbers. Read the two checks separately: x and y alone say a point behind the camera is on screen.

idle

Console

Run the scene to see its console.

Expected console output

straight ahead         pixel   400  200 | x,y on screen: true  | depth ok: true  | show label: true
high and to the right  pixel   545  132 | x,y on screen: true  | depth ok: true  | show label: true
far off to the right   pixel  1269  200 | x,y on screen: false | depth ok: true  | show label: false
behind the camera      pixel   400  200 | x,y on screen: true  | depth ok: false | show label: false
behind, to the left    pixel   641  200 | x,y on screen: true  | depth ok: false | show label: false
beyond the far plane   pixel   400  200 | x,y on screen: true  | depth ok: false | show label: false
500 label spots -> on screen: 152 | drawn (nearest 12): 12

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Check only x and y

In the projection example change s.xyOnScreen && s.depthOk in the show function to just s.xyOnScreen.

terminal
$ Run
── what you'll see ──
behind the camera pixel 400 200 | x,y on screen: true | depth ok: false | show label: true
behind, to the left pixel 641 200 | x,y on screen: true | depth ok: false | show label: true
beyond the far plane pixel 400 200 | x,y on screen: true | depth ok: false | show label: true
Only the three lines that changed are shown.

Myth vs fact

Myth

A label that is behind a part is hidden automatically.

Fact

Nothing in three.js hides it. You raycast from the camera to the label and fade or hide it yourself, as the example does.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    CSS2DRenderer writes a transform per label per frame. That is cheaper than moving elements with left and top, but it is still work for the browser. For labels that never move relative to the object, a drawn sprite is cheaper, at the cost of blur and no text for assistive technology.

  • ▸

    Labels are real DOM, so they can carry aria-labels, but a label that moves under a screen reader cursor is hard to follow. The build keeps part names in a static list of buttons (Topics 8.4 and 8.6) and adds no floating labels.

Remember this

  1. 1

    The problem this solves: text drawn inside 3D (on a sprite or a texture) is blurry, hard to style, and invisible to a screen reader. A normal HTML element is sharp, styled with CSS, selectable and readable. The job is to keep that element on top of the right 3D point as the camera moves.

  2. 2

    CSS2DRenderer. Import it from three/addons/renderers/CSS2DRenderer.js. You make new CSS2DRenderer(), call setSize(width, height), and add its domElement over the canvas with position: absolute and pointer-events: none, so clicks pass through to the canvas. A CSS2DObject(element) wraps an HTML element and is added to any Object3D, so the label follows that object. Each frame, labelRenderer.render(scene, camera) moves every element with a CSS transform. The elements join the page only when render runs.

  3. 3

    What CSS2DRenderer does and does not do. It hides a label whose depth is outside −1 … 1 (behind the camera, or beyond the far plane) and orders them front to back with z-index. It does not hide labels hidden behind other objects. Call labelRenderer.setSize on resize, next to the main renderer.

  4. 4

    Projecting by hand. point.clone().project(camera) runs the point through the camera's view and projection matrices (Topic 2.4's spaces) and returns NDC. Convert to pixels with x = (ndc.x * 0.5 + 0.5) * width and y = (−ndc.y * 0.5 + 0.5) * height.

  5. 5

    The behind-the-camera trap. A point behind the camera still gets an x and y, mirrored through the middle, and sometimes they are inside the screen. Checking only |x| ≤ 1 and |y| ≤ 1 shows a label in the middle of the screen for something behind you. Also check the depth: ndc.z must be between −1 and 1.

  6. 6

    Hidden behind the bike. Neither method knows about occlusion. To fade a label whose point is behind a part, raycast from the camera to the label's position and set raycaster.far to that distance: any hit closer than the label means it is hidden (Topic 8.1). Anchor a label above its own object, not inside it, or the label is hidden by its own part.

Explain it without notes

01

Why can a label for a point behind the camera still appear in the middle of the screen if you only check x and y after project()?

02

Why does the showroom's label layer need pointer-events: none?

Practice

01

Write labelPosition(point, camera, width, height) that returns { x, y } in pixels, or null if the point is off screen or behind the camera.

02

Write nearestLabels(points, camera, count) that keeps only the count nearest points that are in front of the camera.

Done when you can

  • Overlay HTML on the canvas with CSS2DRenderer and pointer-events: none

  • Project a point to pixels by hand and flip y

  • Reject points behind the camera by their depth, not by x and y

  • Hide a label behind a part with a ray whose far is the label's distance