ALDH
ALDH

R3F scene performance, part 1: measure before optimising

The r3f-monitor panel

The usual reasons a React Three Fiber scene gets slow, and how to use r3f-monitor to tell which one you are dealing with before changing anything.

What one frame costs

A 60 Hz display redraws 60 times a second, which leaves each frame 16.7 ms to finish. A 120 Hz display leaves only 8.3 ms. Inside that window a React Three Fiber scene has two jobs to complete.

The first one is on the CPU. JavaScript runs the useFrame callbacks, updates the position and matrix of every object, and then three.js walks the scene to send drawing commands to the graphics card. Each of those commands is a draw call.

The second one is on the GPU. The graphics card transforms every vertex, then shades every pixel the geometry covers.

If either side goes over the time budget, the frame is late and the scene stutters. The awkward part is that the two sides get slow for very different reasons, while the symptom looks exactly the same.

The usual causes

Too many draw calls

Each mesh with its own material is usually one draw call. Before every one, the CPU has to set up state: bind the shader, bind the textures, upload the uniforms. A thousand identical boxes, each as its own <mesh>, become a thousand rounds of setup, even though the GPU draws each box almost instantly.

Too many triangles

Models exported straight from a modelling tool tend to carry more detail than the screen can show. A distant object covering a few dozen pixels can still be drawn with hundreds of thousands of triangles.

Too many pixels to shade

Shading cost grows with the number of pixels, not the number of objects. Three things usually push it up:

  • A high dpr. A phone with a pixel ratio of 3 shades 9 times as many pixels as dpr 1.
  • Overlapping transparent materials, which shade the same pixel several times.
  • Post-processing. Every effect adds at least one more full-screen pass.

Lights and shadows

Every shadow-casting light forces the scene to be drawn one more time into a shadow map before the main frame is drawn. A point light needs six, one per direction. A few shadowed lights are enough to double or triple the work on both the CPU and the GPU.

Large textures

The size of an image file says little about its real cost. A 4096 × 4096 JPG may be a few MB on disk, but on the GPU it is decompressed to about 64 MB, or about 85 MB with mipmaps. A mobile device that runs out of graphics memory will stutter badly or lose the context altogether.

Shader compilation

Every material variant is a shader program that has to be compiled the first time it appears. This blocks the main thread, and it is why a scene often hitches right after it opens or when a new object comes into view.

Extra work in the loop

useFrame runs on every frame, so a small mistake in it is multiplied by 60 each second. The two most common ones are calling setState inside useFrame, which makes React re-render constantly, and creating new objects every frame, which makes the garbage collector run more often.

Resources that are never released

R3F disposes of what you declare in JSX when the component unmounts. Resources you create yourself with new, or load through a loader that keeps them cached, are not covered. Without a dispose() call for those, GPU memory grows with every model swap or page change, and the scene gets slower the longer it is used.

Why you need something to look with

All eight causes produce the same symptom: FPS goes down. Guess the wrong cause and you apply the wrong fix. Lowering dpr does nothing when the bottleneck is a thousand draw calls, and merging meshes is useless when the GPU is choking on post-processing.

Browser tools show how long JavaScript ran, but not how many draw calls or triangles the scene has, or how many milliseconds the GPU spent on a frame. To find out you have to ask the renderer itself, while the scene is running.

r3f-monitor

r3f-monitor is a library I wrote to do that: a panel of numbers sitting on top of the scene, updating in real time.

npm install r3f-monitor

Put <PerfMonitor /> anywhere inside <Canvas>:

import { Canvas } from "@react-three/fiber";
import { PerfMonitor } from "r3f-monitor";

export default function App() {
  return (
    <Canvas>
      <PerfMonitor />
      {/* your scene */}
    </Canvas>
  );
}

The panel appears in the top-right corner. You can try it right away on CodeSandbox.

A few options you will reach for:

<PerfMonitor
  position="bottom-left" // top-right (default), top-left, bottom-right, bottom-left
  displayType="classic" // "tab" (default) or "classic"
  minimal={false} // condensed view with the main numbers only
/>

tab splits the data across tabs and suits close inspection. classic puts everything in one small panel that stays on screen.

Reading the numbers

Time for one frame

  • FPS: frames per second, computed over exactly the last second, so it reads as a fractional value such as 59.8 instead of flipping between 59 and 60.
  • CPU: JavaScript time for one frame, in ms. It covers every useFrame callback and the three.js render call.
  • GPU: time the graphics card actually spent drawing the frame, in ms.

The GPU value needs a caveat. The browser only hands back the measurement after the GPU has finished, so it always trails the CPU value next to it by a few frames. Some browsers do not allow the measurement at all, in which case GPU reads 0 and every other number keeps working.

Work sent to the GPU

  • DRAWS: draw calls in the frame just rendered.
  • TRIANGLES, LINES, POINTS: the geometry actually drawn. Anything outside the camera's view is not counted.
  • SHADERS: compiled shader programs.

Memory

  • MEM: JavaScript heap in use, in MB. Only available in Chromium-based browsers.
  • VRAM: GPU memory used by textures and geometries, in MB.
  • GEOMETRIES, TEXTURES: resources currently living on the GPU.

On WebGL, VRAM is an estimate, because the browser offers no way to ask how much memory the GPU is using. The library adds up the size of each geometry's data arrays and computes textures as width × height × 4 bytes, times 1.33 when mipmaps are generated. It leaves out render targets, so use it to compare before and after a change rather than as an absolute figure.

From numbers to causes

The first step is always CPU against GPU, because it tells you which half of the list of causes to search.

  • High CPU, low GPU: the bottleneck is in JavaScript. Look at DRAWS. If it is in the thousands, that is your answer. If not, review what runs inside useFrame.
  • High GPU, low CPU: the device is limited by drawing. High TRIANGLES means the geometry is too heavy. Normal TRIANGLES means the problem is the number of pixels being shaded: dpr, transparent materials, post-processing.
  • DRAWS and TRIANGLES several times higher than expected: the scene is being drawn more than once per frame, usually because of shadows or post-processing.
  • A hitch on entering the scene, then smooth: check whether SHADERS goes up at that moment.
  • GEOMETRIES, TEXTURES or VRAM climbing during use: some resource is not being released.
  • VRAM unusually large for how complex the scene is: almost certainly textures.

When you know the direction but not the culprit, turn on deepAnalyze:

<PerfMonitor deepAnalyze />

The panel gains a list of shader programs, each with the meshes using it and its share of the frame's triangles. Click the eye icon to hide that group of meshes. If hiding a group makes FPS jump, you have found the place. The feature makes every material recompile its shader once, so only enable it while investigating.

Next part

This post stops at seeing the problem. Part 2 will cover how to deal with each cause: fewer draw calls, lighter geometry, keeping the number of shaded pixels under control, and managing memory.

In the meantime, you can look at the source, the npm package and the docs site for r3f-monitor.