Command Palette

Search for a command to run...

Hectal
Course outline
Topic 17.1Advanced ~19 min · step 1 of 7

The Frame Budget

In one line

A screen that refreshes 60 times a second gives your code 16.67 ms per picture, split between the CPU (your JavaScript and three.js's bookkeeping) and the GPU (the pixels). Performance work starts with measuring which side is late.

0/7 · 0%

Think of it like this

A restaurant kitchen with a ticket every 16.67 milliseconds. The cook (the CPU) reads the order and prepares it. The oven (the GPU) does the long cooking. If either is late, the plate misses the pass and the customer sees the same dish twice: a dropped frame.

Words you'll meet

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

frame budget
The time you have to produce one picture: 1000 ms divided by the refresh rate.
draw call
One command to the GPU to draw one mesh. Each costs CPU time, however small the mesh.
CPU-bound
Slow because the JavaScript and driver work per frame takes too long (many draw calls, heavy updates).
GPU-bound
Slow because the GPU cannot shade all the pixels or triangles in time (big canvas, heavy shaders, overdraw).
dropped frame
A frame that missed its deadline, so the screen shows the old picture for another refresh.

Step by step

01Count first

Run the first example: 1000 meshes are 1000 draw calls, and the geometry is in memory only once.

02Time your own frame

The second example adds 20 ms per frame to pretend to be a slow phone: all 30 frames are over budget. The meter measures JavaScript only.

03Decide which side is late

Shrink the canvas to a quarter of the pixels. If the frame rate jumps, you were GPU-bound. If not, you were CPU-bound. Topics 17.2 and 17.3 attack the CPU side, 17.5 the pixel side.

04Keep a budget

Write limits down (draw calls, triangles, textures) and check them in code. The build at the end of this phase does exactly that, once a second.

Try it yourself

  1. 1

    Cut the busy time

    In the meter example, change busy(20) to busy(5). It prints median is above the budget: false, and frames over the 16.67 ms budget: 0 of 30 (on some machines 1 of 30: the first frame also compiles the shader). 5 ms of work plus one cheap render fits in 16.67 ms.

  2. 2

    Double the meshes

    In the first example, change the list [0, 1, 10, 100, 1000] to [2000]. It prints 2000 meshes -> 2000 draw calls, 160000 triangles | geometries in memory: 1: the cost doubles, and the memory is still one geometry.

Code & diagrams

Count the work: meshes become draw calls New tab

The counts are exact on every machine. Watch the draw calls figure in the status bar too.

idle

Console

Run the scene to see its console.

Expected console output

    0 meshes ->     0 draw calls,      0 triangles | geometries in memory: 0
    1 meshes ->     1 draw calls,     80 triangles | geometries in memory: 1
   10 meshes ->    10 draw calls,    800 triangles | geometries in memory: 1
  100 meshes ->   100 draw calls,   8000 triangles | geometries in memory: 1
 1000 meshes ->  1000 draw calls,  80000 triangles | geometries in memory: 1
frame budget at 60 Hz: 16.67 ms | at 120 Hz: 8.33 ms
A frame meter, and what it cannot see New tab

Only counts are printed: the actual milliseconds differ on every machine. Each frame takes at least 20 ms here, because the busy loop asks for it.

idle

Console

Run the scene to see its console.

Expected console output

frames measured: 30
frames over the 16.67 ms budget: 30 of 30
median is above the budget: true
a 120 Hz screen allows 8.33 ms; a 30 fps cap allows 33.33 ms

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

Trusting the CPU timer when the GPU is the slow side

In the meter example, change busy(20) to busy(5), and renderer.setSize(300, 150, false) to renderer.setSize(4096, 4096, false).

terminal
$ Run
── what you'll see ──
frames over the 16.67 ms budget: 1 of 30
median is above the budget: false
The meter says the page is fine, but a 4096 x 4096 canvas has about 370 times the pixels of 300 x 150: on a real GPU the frame rate drops, and the status bar shows it. (The single late frame is the first one, which also compiles the shader.)

Myth vs fact

Myth

If it runs at 60 fps on my laptop, it is fast enough.

Fact

A laptop GPU can be fifty times faster than a cheap phone's, and phones also slow down when hot. Measure on the weakest device you promise to support.

Pro corner

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

  • ▸

    renderer.info.autoReset is true, so the render counters restart at every render() call. With an effect chain (Phase 15) that renders several times per frame, you see only the last pass unless you set autoReset = false and call renderer.info.reset() yourself once per frame. The build's budget.js does this.

Remember this

  1. 1

    The problem this solves: "it is slow" is not something you can fix. "The CPU takes 24 ms per frame because of 900 draw calls" is. This topic gives you the vocabulary and the three tools to turn a feeling into a number.

  2. 2

    The budget. One second divided by the refresh rate: 16.67 ms at 60 Hz, 8.33 ms at 120 Hz, 33.3 ms if you choose to run at 30 fps. All of your frame work has to fit, including the browser's own work. Miss it and the browser shows the previous picture again.

  3. 3

    CPU time and GPU time. renderer.render() does not draw. It walks the scene, works out what is visible, and queues commands. It returns when the commands are queued, not when the pixels exist. So timing render() with performance.now() measures only the CPU side. The GPU runs behind, and a GPU-bound page can show a tiny render() time and still be slow.

  4. 4

    **Tool 1: renderer.info.** It counts the work sent in the last render() call: render.calls (draw calls), render.triangles, and what lives in GPU memory: memory.geometries, memory.textures, plus programs. Counts are the same on every machine, so they are the best thing to compare before and after a change. The first example draws 0, 1, 10, 100 and 1000 meshes: every mesh is one draw call, even with the same geometry and material.

  5. 5

    Tool 2: a frame meter. Time your own JavaScript per frame with performance.now() and count the frames over the budget (the second example). It sees only the CPU side. It also sees nothing while the tab is in the background: the browser stops calling the animation loop there, so no frames are produced and none can be late.

  6. 6

    Tool 3: the browser's Performance panel. Record a few seconds in DevTools. The main-thread track shows your JavaScript and render(); the GPU track shows what the GPU did. In your own project, record on the cheapest phone you support (Chrome can inspect an Android phone over USB). Fast on your desktop proves nothing about a phone.

Explain it without notes

01

Why can timing renderer.render() with performance.now() mislead you?

02

How do you tell a CPU-bound page from a GPU-bound one with no special tools?

Practice

01

Write a function budgetFor(hz) that returns the frame budget in milliseconds and the draw-call count you could afford if each draw call cost 0.02 ms of CPU time and everything else took 6 ms.

02

Your page runs at 25 fps on a phone. Name the first experiment you run, and what each outcome means.

Done when you can

  • Say the frame budget at 60 and 120 Hz

  • Read draw calls, triangles, geometries and textures from renderer.info

  • Explain why render() time is not GPU time

  • Decide CPU-bound or GPU-bound by changing the canvas size