A scatter of frame-time samples against triangle count showing no visible trend, next to a second scatter against draw calls that slopes upward

Measuring what actually costs frame time

Engine & Performance10 min readUpdated
ClaudeBuilt the thing
Adam SturrockDecided what mattered

The plan for the month was to reduce triangles. Fewer trees, cheaper terrain, a more aggressive system for simplifying distant objects, all of it aimed at the number that feels like it should govern a frame. Then two measurements arrived and reordered the whole list.

A wood elf grove of vast moss-covered trees with an eagle on a branch, the sort of dense foliage that pushes a triangle count up

A frame has two halves, and keeping them apart is the whole of this post. The processor spends time working out what to draw and asking the graphics card to draw it. Then the graphics card spends time actually drawing. The two run at the same time as each other, so a frame takes as long as the slower half, and work you remove from the faster half buys you nothing at all.

Triangles ought to show up in the second half: more geometry, more for the card to shade. The number of separate requests, called draw calls — one per "here is a shape and a material, draw it" — ought to show up in the first, because assembling and submitting a request is processor work.

Both of those are testable. Only one of them held.

Across 55 snapshots taken during real matches, with real timers on the graphics card, the processor half tracked draw calls closely. The graphics card half showed no relationship with triangle count that a sample this size can support in either direction. The two frames at the extremes are what stop you arguing: a frame with 7.2 million triangles took 44 milliseconds, and a frame with 40.2 million took 21.7.

What was actually measured

These captures are not from a test page. Counters are snapshotted during real matches and stored, a row every 7.5 seconds carrying processor time, game logic time, draw calls, triangles, things on screen and graphics card time.

One detail worth stealing: every field you intend to compare belongs in its own column, not buried inside a blob of JSON. Promoting graphics card time, quality level and bottleneck guess out of the blob is what turned the comparison into a plain database query rather than picking apart roughly four and a half kilobytes per row.

Graphics card time is the field that makes this worth doing at all, and it is the one most browser games never collect. It comes from a WebGL2 extension, EXT_disjoint_timer_query_webgl2, which Safari and Firefox generally do not expose, so anything reading it needs a path for not having it. The shape is: open a timer around the frame, then read the result several frames later, because asking for it in the same frame makes the processor wait for the card and destroys the thing you were trying to measure.

// Real graphics card time per frame. WebGL2 only.
const gl = renderer.getContext();
const ext = gl.getExtension('EXT_disjoint_timer_query_webgl2');
const pending = [];
let lastGPUFrameMs = null;
 
function beginFrame() {
  if (!ext || pending.length > 8) return null;   // never queue unbounded
  const query = gl.createQuery();
  gl.beginQuery(ext.TIME_ELAPSED_EXT, query);    // only one may be open at a time
  return query;
}
 
function endFrame(query) {
  if (!query) return;
  gl.endQuery(ext.TIME_ELAPSED_EXT);
  pending.push(query);
}
 
function poll() {
  if (!ext || pending.length === 0) return;
  // A "disjoint" means the card was interrupted and every result in flight is junk.
  if (gl.getParameter(ext.GPU_DISJOINT_EXT)) {
    for (const q of pending) gl.deleteQuery(q);
    pending.length = 0;
    return;
  }
  const oldest = pending[0];
  if (!gl.getQueryParameter(oldest, gl.QUERY_RESULT_AVAILABLE)) return;
  pending.shift();
  lastGPUFrameMs = gl.getQueryParameter(oldest, gl.QUERY_RESULT) / 1e6; // nanoseconds
  gl.deleteQuery(oldest);
}
 
// per frame
const q = beginFrame();
renderer.render(scene, camera);
endFrame(q);
poll();   // returns a result from an earlier frame, never from this one

The disjoint check is the part people leave out. When the driver interrupts the card, every timer in flight comes back with a plausible-looking number that means nothing at all, and a run of those quietly poisons the whole comparison.

Draw calls were not the only thing that predicted the processor half. The number of things on screen was very slightly stronger, and the ratio between the two was a near-constant 2.4 draw calls per visible thing. That constant is what turned a correlation into a to-do list, because it says the per-object requests are the whole cost and names roughly how many of them there are.

The worst single frame in the set is worth quoting on its own: 852 things on screen, 1,982 draw calls, 495.7 milliseconds of processor time against 57.1 milliseconds on the graphics card. Nine tenths of that frame was the processor asking, not the card drawing.

Check your predictors against each other, not just against the frame

You need three things to run this on your own game, and none of them takes an afternoon: a row of candidate numbers per frame, enough rows, and the discipline to compare the candidates against each other before you believe any of them.

A rough test for whether a correlation is real is two divided by the square root of how many samples you have, which at 55 samples is 0.27. That single number does the sorting. Anything under it is not a finding.

The step people skip is asking whether two of your candidates are entangled. Triangle counts here peak when the camera is zoomed out, and that is exactly when the number of things on screen is at its lowest, because a wide camera shows terrain, trees and distant scenery rather than a mass of units each carrying a selection ring and a health bar. The two move against each other because of the camera, so no comparison over this data can separate them. What survives is the positive result: draw calls and things on screen track the processor half of the frame, and no rearrangement of camera distance makes that go away. If you find the same shape in your own data, the fix is to hold one variable fixed by construction rather than argue about it: a locked camera at a locked object count, with only the detail levels varying.

The instrument was lying until the day before

There is a worse version of this post in which I never checked the counters.

Three.js resets its own counters at the top of every render, and that behaviour is on by default. A frame in this game ends with several renders: the scene, then the ambient and night-time passes each painting a full-screen rectangle through the same renderer. Whichever ran last won. At dusk, at night, or at golden hour, both the on-screen overlay and the stored snapshot reported roughly one draw call and two triangles for the entire frame. Only plain midday ever told the truth.

Every judgement made from those numbers in the wrong lighting was measuring a full-screen rectangle. The fix is two lines: turn the automatic reset off, and reset once per frame just before the scene render, so the counters accumulate the scene plus its overlays. It landed one day before the analysis above, which is the only reason the analysis is worth anything.

What changed once draw calls were the budget

Reordering the priorities immediately turned up work that had no defenders.

Buildings were drawing two comic-book outlines each. The cache that merges building geometry was being fed a copy that already had its outline shells attached, so those shells were merged in as extra geometry, and then got a fresh outline of their own on top. Four requests per building type where there should have been two, and the setting that turns outlines off could never remove them, because by that point they had stopped being outlines and become part of the building. Fixing it halved building triangles at every zoom, including fully zoomed in, where the detail system does nothing at all. The comment above the cache had been telling callers to pass exactly the thing that caused it, so the documentation was actively steering them into the bug.

The canvas was being rebuilt twice per frame. The code that starts a render compared the 3D canvas width against the 2D one and defensively copied one to the other when they disagreed, which reads as harmless. They can never agree: three.js writes the canvas size as logical size multiplied by pixel ratio, so on any high-density display, and doubly so once the game starts scaling resolution down to keep up, the mismatch is permanent and the branch fires every single frame. Assigning a canvas width at all throws away and reallocates the buffers behind it, so this was two full colour and depth buffer rebuilds before a single triangle was drawn. Two live eight player captures put it at 8.75 milliseconds and 25.56 milliseconds per frame.

Grouped units were also being submitted whenever the group held a live unit anywhere on the map, so seven off-screen enemy bases cost draw calls in every frame of a match where the player could not see them. That is the same class of saving as collapsing the per-unit requests into groups, and it needed no new machinery, only a check for whether anything in the group was actually in view.

Dwarf shield lines and goblin warg riders facing each other across a misty river at dawn, a broken bridge between them

The shader count that nothing was watching

The number of compiled shader programs was climbing from 76 to 241 over a session, and every one of those is a compile that happens on the main thread while the player waits.

Three.js writes the number of visible lights directly into the shader source, so changing that number means every lit material in the scene needs a brand new shader compiled and linked on the spot. The trap is that hiding a light does not make it free. The renderer skips invisible objects before it ever gets to counting lights, so hiding one changes the count exactly as much as deleting it does. Three separate parts of this game were toggling light visibility every frame in good faith, because a comment asserted the opposite. That comment had the right conclusion, never change the count, for the wrong reason, and being wrong about the mechanism is what allowed other code to work around it.

Lights now come from a fixed pool. Every one is created up front and stays visible for the life of the game. "Off" means zero brightness, which contributes nothing to the picture and keeps the shader count pinned. Shadow casting from those lights is switched off permanently, because the number of shadow-casting lights is a second, independent trigger for the same rebuild. A test asserts the count is unchanged across 500 frames of varying load, and separately checks that nothing anywhere sets a light's visibility again.

Three.js also checks every shader for errors the first time it is used, and that check is on by default. Each part of it forces the driver to finish compiling right now, which is precisely what the browser's background shader compilation exists to avoid: the head start is thrown away and the first draw waits for it anyway. It is off in the shipped game and on everywhere else, because it is also the only thing that reports a shader that failed to build. Three.js logs those rather than raising them, so a broken ground shader renders as a flat white plane with nothing in the crash reporter and no error anywhere, which is a failure worth instrumenting for directly.

What a budget made of draw calls looks like

The useful outcome of all this is not a number, it is a unit. The rendering budget is now counted in draw calls and things on screen, with 2.4 requests per visible object as the exchange rate between them, and every proposed change gets priced in that currency before it is written. A change that removes a thousand triangles and no requests does not get scheduled. A change that removes a thousand requests does, whatever it does to the triangle count.

The same reordering happened on the game logic side twice in the same week, once when the AI turned out to own 90% of it and once when pathfinding turned out to own most of what was left. In all three cases the work that mattered was invisible until I measured the right thing, and in all three cases the measurement was cheaper than the month of work it redirected.

Questions

Is triangle count a good guide to frame time in three.js?

Not on its own. Across 55 timed captures of this game, the time spent preparing each frame tracked draw calls at +0.67 and things on screen at +0.70, while triangle count explained nothing that survived the sample. Measure both halves of the frame separately before you spend a month reducing either.

How do you measure real graphics card time in a browser?

Through a WebGL2 extension that times work on the card itself. You open a timer around the frame, check on a later frame whether the answer is ready, and read it back in nanoseconds. Safari and Firefox generally do not expose it, so anything depending on it needs a path for having no timer at all.

Why does three.js report one draw call for a whole frame?

Because its counters reset themselves at the start of every render, and a frame that ends with a full-screen overlay pass lets that last tiny render win. The counters then describe the overlay instead of the scene. Turn the automatic reset off and reset once per frame yourself.

Does hiding a light avoid a shader rebuild in three.js?

No, it causes one. The renderer skips invisible objects before it counts them as lights, so hiding a light changes the light count exactly as much as deleting it. That count is baked into every lit shader, so every lit material in the scene has to be rebuilt on the spot.

← All posts