Three.js HUD Tutorial: renderOrder and depthTest

Creating a Heads-Up Display (HUD) in Three.js requires rendering 2D or 3D overlay elements on top of a 3D scene without them clipping into world geometry. This article explains how to utilize the renderOrder property alongside depthTest to construct an interactive, high-performance HUD layer within a single Three.js scene and render loop.

The Core Concept: Depth Testing and Render Order

By default, Three.js uses the WebGL depth buffer to determine which objects are in front of others based on their actual 3D distance from the camera. For a HUD, we must override this behavior so that HUD elements always draw on top of the 3D world, regardless of their physical coordinate positions. This is achieved using two properties: Material.depthTest and Object3D.renderOrder.

1. Disabling Depth Testing

The depthTest property determines whether an object is rendered based on the depth buffer. Setting this to false ensures that the HUD object is drawn without checking if other 3D objects are physically in front of it.

const hudMaterial = new THREE.MeshBasicMaterial({
  color: 0xff0000,
  depthTest: false,
  depthWrite: false // Prevents this object from writing to the depth buffer
});

Setting depthWrite to false is also highly recommended. It ensures that the HUD element does not block other HUD elements from rendering correctly if they overlap.

2. Controlling the Render Queue with renderOrder

Disabling depthTest is only half the solution. If the GPU renders the HUD element before it renders a background wall, the wall will overwrite the HUD pixel data. To guarantee that the HUD is drawn last, you must manipulate the renderOrder property.

By default, all Object3D instances have a renderOrder of 0. Three.js sorts and renders objects with lower renderOrder values first. By assigning a high positive integer to your HUD elements, you force Three.js to render them after the rest of the 3D scene has been drawn.

const hudGeometry = new THREE.PlaneGeometry(2, 0.5);
const hudMesh = new THREE.Mesh(hudGeometry, hudMaterial);

// Force the HUD mesh to render after the 3D world scene
hudMesh.renderOrder = 999; 

// Position the HUD relative to the camera
hudMesh.position.set(0, 2, -5); 
camera.add(hudMesh); // Parent to camera so it moves with the view

Creating the Interactive HUD Layer

To build a fully functioning interactive HUD, follow these structural steps:

Step 1: Parent the HUD to the Camera

To keep the HUD locked to the screen, add the HUD elements directly as children of the PerspectiveCamera or OrthographicCamera.

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
scene.add(camera); // Add camera to scene to allow camera children to render

Step 2: Define HUD Elements

Create your HUD meshes, apply materials with disabled depth testing, and assign a high renderOrder.

const buttonGeo = new THREE.RingGeometry(0.2, 0.3, 32);
const buttonMat = new THREE.MeshBasicMaterial({ 
  color: 0x00ff00, 
  depthTest: false, 
  depthWrite: false 
});

const hudButton = new THREE.Mesh(buttonGeo, buttonMat);
hudButton.position.set(-1.5, 1, -3); // Position in camera space
hudButton.renderOrder = 1000;

camera.add(hudButton);

Step 3: Handling Raycasting for Interactivity

Because the HUD elements are physically positioned in 3D space, standard Three.js raycasting can be used to handle clicks or hover states. However, because the HUD elements are rendered on top but may physically reside behind world geometry, you must filter your raycast results.

When raycasting, prioritize the HUD layer by checking intersections against your HUD objects first.

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

window.addEventListener('pointerdown', (event) => {
  // Normalize mouse coordinates
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

  raycaster.setFromCamera(mouse, camera);

  // Cast specifically against HUD elements first
  const hudIntersects = raycaster.intersectObjects([hudButton]);

  if (hudIntersects.length > 0) {
    console.log("HUD Button Clicked!");
    // Trigger button action
    hudButton.material.color.setHex(0xffffff);
  } else {
    // Perform standard world scene raycasting if no HUD elements were clicked
    const worldIntersects = raycaster.intersectObjects(scene.children, true);
    if (worldIntersects.length > 0) {
      console.log("World Object Clicked!");
    }
  }
});

Using renderOrder and depthTest: false is a clean, single-renderer solution for creating interactive HUDs without the performance overhead of managing a second overlay scene and a separate camera.