Course outline
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.
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
Cut the busy time
In the meter example, change
busy(20)tobusy(5). It printsmedian is above the budget: false, andframes over the 16.67 ms budget: 0 of 30(on some machines1 of 30: the first frame also compiles the shader). 5 ms of work plus one cheap render fits in 16.67 ms. - 2
Double the meshes
In the first example, change the list
[0, 1, 10, 100, 1000]to[2000]. It prints2000 meshes -> 2000 draw calls, 160000 triangles | geometries in memory: 1: the cost doubles, and the memory is still one geometry.
Code & diagrams
The counts are exact on every machine. Watch the draw calls figure in the status bar too.
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 msOnly 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.
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 msBreak 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).
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.autoResetis true, so the render counters restart at everyrender()call. With an effect chain (Phase 15) that renders several times per frame, you see only the last pass unless you setautoReset = falseand callrenderer.info.reset()yourself once per frame. The build'sbudget.jsdoes this.
Remember this
- 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
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
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 timingrender()withperformance.now()measures only the CPU side. The GPU runs behind, and a GPU-bound page can show a tinyrender()time and still be slow. - 4
**Tool 1:
renderer.info.** It counts the work sent in the lastrender()call:render.calls(draw calls),render.triangles, and what lives in GPU memory:memory.geometries,memory.textures, plusprograms. 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
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
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
Why can timing renderer.render() with performance.now() mislead you?
How do you tell a CPU-bound page from a GPU-bound one with no special tools?
Practice
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.
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.infoExplain why
render()time is not GPU timeDecide CPU-bound or GPU-bound by changing the canvas size