← All articles

Use path tracing in Three.js

Updated on . Tested with three.js r180, R3F 9 and drei 10.

Path tracing makes your three.js scenes look more realistic, which is very useful in a model configurator setting. Say a buyer has just configured a sofa, and you want a great image for their cart. By path tracing the scene, you simulate how light actually bounces around in real life, and you end up with an image that looks like a professional photo.

The same room corner twice. In the three.js viewport the floor and the wall are darker and the shadow under the sofa is black. Path traced, light bounces off the rug and the walls, so the room is brighter and the shadows are lighter

To path trace your three.js scene directly in the browser, do the following:

  1. Install three-gpu-pathtracer and three-mesh-bvh.
  2. Create a WebGLPathTracer with your WebGLRenderer.
  3. Call pathTracer.setScene(scene, camera) after your models and your HDRI have loaded.
  4. Call pathTracer.renderSample() every frame instead of renderer.render().
  5. Call pathTracer.updateCamera() when the camera moves, and pathTracer.updateMaterials() when a material changes.

The picture starts grainy and gets cleaner every frame, so you need to wait a few seconds before it is ready to save.

Set up the path tracer

Install the path tracer and the library it uses to find what each ray hits.

npm install three-gpu-pathtracer three-mesh-bvh

Then build your scene as usual and hand it to a WebGLPathTracer once everything has loaded.

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
import { WebGLPathTracer } from 'three-gpu-pathtracer';
const gltf = await new GLTFLoader().loadAsync('/demo/models/Sofa_01/Sofa_01_2k.glb');
sofa.add(gltf.scene);
sofa.position.set(-0.35, 0, -0.6);
const pathTracer = new WebGLPathTracer(renderer);
pathTracer.setScene(scene, camera);

renderer.setAnimationLoop(() => {
  pathTracer.renderSample();
});

setScene reads every mesh, material, texture and light in the scene and builds the data the path tracer needs. It takes a moment on a big model, so call it once after loading, not every frame. Until the first few samples are ready, renderSample() draws the scene the normal three.js way, so the canvas is never empty.

A room corner path traced in three.js. A sofa stands against a light blue wall behind an orange rug, with a light oak side table and a white vase beside it

Light the scene with an HDRI and a sun

The path tracer lights the scene with scene.environment and the lights in it, so an HDRI and one directional light are enough for a room.

const environment = await new HDRLoader().loadAsync('/hdri/studio.hdr');
environment.mapping = THREE.EquirectangularReflectionMapping;
scene.environment = environment;
scene.environmentIntensity = 0.2; // the sun does the lighting, the studio HDRI only fills
const sun = new THREE.DirectionalLight('#ffe2c0', 3.5);
sun.position.set(3.2, 1.8, 0.8);

Give the path tracer the HDRI itself. It does not need PMREMGenerator, which only blurs the map for the three.js viewport. It traces DirectionalLight, PointLight, SpotLight and RectAreaLight. It leaves out AmbientLight and HemisphereLight, so light the room with the HDRI instead.

You do not need shadow maps for the path traced picture. Every object casts a real shadow. To stop one object from casting a shadow, set castShadow = false on its material.

The picture is grainy at first

Each sample adds another set of light paths to the picture, so it starts grainy and the grain fades as the samples add up.

pathTracer.samples tells you how many samples the picture has. By default the path tracer splits each sample into 3 by 3 tiles, one tile a frame, so the page stays responsive while it traces. To trace faster on a fast GPU, set pathTracer.tiles.set(1, 1).

To make it lighter on a phone or an older laptop, do the following.

  1. Set pathTracer.renderScale to 0.5 to trace at half the canvas size.
  2. Lower pathTracer.bounces from its default of 10. Fewer bounces trace faster and make the corners darker.
  3. Set pathTracer.filterGlossyFactor to around 0.5 if you see bright single pixels on shiny surfaces.

Update the picture when something changes

The path tracer keeps adding samples to the same picture, so tell it when the camera, a material, a light or the environment changes. It then starts the picture again.

// The camera moved, so start the picture again from the new view.
function cameraMoved() {
  pathTracer.updateCamera();
}

// A shopper picked another rug color, so trace the picture again with it.
function setRugColor(color: string) {
  rugMaterial.color.set(color);
  pathTracer.updateMaterials();
}

Each change has its own call.

You changedCall
The camera's position, rotation, fov or aspectpathTracer.updateCamera()
A color, a texture or a setting on a materialpathTracer.updateMaterials()
A light's color, intensity or positionpathTracer.updateLights()
scene.environment, scene.background or their intensitypathTracer.updateEnvironment()
A mesh added, removed or moved, or a new materialpathTracer.setScene(scene, camera)

Right after a change the picture is grainy again, as it was at the start.

Save the path traced picture

Wait until pathTracer.samples hits your target number. Then draw one more sample and read the canvas in the same task.

// Wait for enough samples, then save the canvas as a PNG.
async function savePathTracedImage(samples: number): Promise<Blob> {
  while (pathTracer.samples < samples) {
    await new Promise(requestAnimationFrame);
  }

  // Draw the latest sample and read the canvas in the same task, before the browser clears it.
  pathTracer.renderSample();
  return new Promise((resolve, reject) => {
    renderer.domElement.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))), 'image/png');
  });
}

By default three.js creates its renderer with preserveDrawingBuffer: false because it's more performant. That is why the code reads the canvas right after it draws. The three.js screenshot article explains how to save a bigger picture and a transparent background.

In React Three Fiber

Wrap WebGLPathTracer in a small component that traces in useFrame and restarts when OrbitControls moves the camera.

function PathTracer() {
  const gl = useThree((state) => state.gl);
  const scene = useThree((state) => state.scene);
  const camera = useThree((state) => state.camera);
  const pathTracer = useRef<WebGLPathTracer | null>(null);

  useEffect(() => {
    const tracer = new WebGLPathTracer(gl);
    tracer.setScene(scene, camera);
    pathTracer.current = tracer;
    return () => tracer.dispose();
  }, [gl, scene, camera]);

  // A priority above 0 tells R3F to stop drawing the scene itself.
  useFrame(() => pathTracer.current?.renderSample(), 1);

  return <OrbitControls target={[-0.05, 0.45, -0.4]} onChange={() => pathTracer.current?.updateCamera()} />;
}

Put <PathTracer /> last inside the <Canvas>, after the models and drei's <Environment>. The canvas waits for the models to load, so the path tracer sees the whole scene when it starts.

<Environment files="/hdri/studio.hdr" environmentIntensity={0.2} />

drei has no path tracing component. pmndrs publishes @react-three/gpu-pathtracer as a ready-made wrapper, but its version 0.3.2 depends on three-gpu-pathtracer 0.0.23, one release behind the one used here.

The drawbacks of path tracing in the browser

Path tracing in the browser runs on your visitor's own device, so how fast the picture gets clean depends on their GPU. A desktop with a good graphics card clears the grain in a few seconds. A laptop takes longer, and a phone can take much longer while it gets warm and drains the battery.

The page also has to stay open the whole time. If the visitor switches to another tab, the browser stops drawing frames and the tracing stops with them. While it traces, the GPU is busy with samples, so the rest of the page feels slower, and the path tracing shader takes a few seconds to compile before the first sample shows.

That is fine for a preview on a desktop. It is hard to rely on when every shopper needs a clean picture of what they built, on whatever device they have.

How to make it look even better on every device

three-gpu-pathtracer in the browser and the same scene rendered by Bakery3

Render the picture on a server instead of on the shopper's device. Bakery3 renders the same scene on a GPU server with a path tracer, and that takes care of each drawback above:

  • The picture is as clean on a phone as on a desktop, because the shopper's device only draws the normal three.js viewport.
  • The phone stays cool and keeps its battery.
  • The render keeps going on the server when the shopper switches tabs.
  • The page stays responsive, with no path tracing shader to compile first.

The serverside renderer takes your scene including objects, materials, HDRI and lights, and hands you back a rendered image. Connect it once, then ask for an image whenever a shopper change a configuration.

// An API key while you test. In production, use a token from your server instead:
// https://bakery3.com/docs#let-your-users-render-without-exposing-your-api-key
const bakery3 = createBakery3({ apiKey: 'bk_sk_…' });
bakery3.attach({ scene, camera, renderer });
async function renderOnServer(): Promise<string> {
  const job = await bakery3.render({ width: 1200, background: { type: 'color', color: '#e9e7e2' } });
  const { url } = await job.result();
  return url;
}

An HDRI you load yourself needs one more line, so the render is lit by the same file as the viewport.

rememberEnvironmentSource(environment, '/hdri/studio.hdr');

Render image covers the size, the quality and the background, and the quickstart connects your scene in a few lines.

Common questions

Does three.js have a built-in path tracer?

No. The three.js WebGLRenderer draws each frame in real time and simplifies how light bounces. The three-gpu-pathtracer library by Garrett Johnson adds a path tracer that runs on the same WebGLRenderer, using WebGL 2 and three-mesh-bvh.

Is three-gpu-pathtracer real time?

Not the same way the three.js viewport is. It adds one sample to the image each frame, so the image looks grainy at first and clears up over a few seconds. When you move the camera or change a material, it starts the image over.

Which lights does three-gpu-pathtracer support?

It traces DirectionalLight, PointLight, SpotLight and RectAreaLight, plus the scene's environment map. AmbientLight and HemisphereLight are left out, so light the scene with an HDRI on scene.environment instead.

How do I save a path traced image from three.js?

Wait until pathTracer.samples hits your target number. Then call pathTracer.renderSample() one more time. Right after that, call canvas.toBlob() in the same task. Reading the canvas later returns an empty picture unless the renderer was created with preserveDrawingBuffer: true.

Can I path trace a three.js scene on a server?

Yes. A path tracer needs a GPU and time, so a server works well when the image is for a cart, an email, or a product page. Bakery3 reads your three.js scene in the browser, renders it on its own GPUs, and returns the image.