WebGL2 · compile keys · timed, not profiled

Program count

Eight materials, lit by a number of lights you control. In compile key mode the light count is baked into the shader source, so moving the slider recompiles all eight programs and drops a frame. In uniform mode the source never changes and the extra lights are turned off with a zero. Watch the trace under the canvas, then read what the second mode costs you instead.

light count is a
compile key 8 materials · 1,024 instances ···
Programs linked0

Distinct shader programs this page has built since load.

Last key changenone yet

Wall time the eight rebuilds took, including the blocking status query.

Worst frame, last 4s···

The frame a visitor actually feels. Reset by every mode switch.

Steady-state···

Frame rate once nothing is recompiling. This is where mode two pays.

What this is not. This is not three.js and it is not a benchmark of it. It is a hand-written renderer that reproduces the one behaviour under discussion, which is a light count reaching the shader as a compile-time constant. The absolute milliseconds below depend entirely on your driver, and a machine with a warm shader cache will show a much smaller stall on the second pass than the first. That difference is reported too.
01 · the key

A shader is cached by its source, so anything in the source is a cache key

A renderer that supports a variable number of lights has two choices. It can loop to a uniform, or it can write the count into the source and let the compiler unroll a fixed loop. The second is faster to run and it is what three.js does, which means the light count is part of the shader's identity. Change it and you have asked for a different program.

The trap is that nothing about the calling code looks like a compile. You set light.visible = false. That is a boolean on a scene object. Three frames later eight materials come back from the driver and the frame is gone. The reported fix is real and it is one line: leave the light in the scene and set its intensity to zero, so the count never moves.

The generalisation matters more than the instance. Light count is one key. So is whether a material has a normal map, whether the geometry has vertex colours, whether fog is on, whether skinning is on, and the size of any array the shader loops over. Vary uniforms at runtime. Never vary the source. If a control in your UI changes a #define, that control is a stall.

// The key is the string. These are two different programs.
#define NLIGHTS 4     // ← the slider writes this line
uniform vec3  uLightPos[NLIGHTS];
uniform float uLightInt[NLIGHTS];

for (int i = 0; i < NLIGHTS; i++) { /* unrolled by the compiler */ }

// Holding the key still: one program, forever, at the maximum count.
#define NLIGHTS 16    // ← never changes
for (int i = 0; i < NLIGHTS; i++) {
  // uLightInt[i] is 0.0 for the ones you "turned off".
  // The maths still runs. That is the bill for mode two.
}
02 · where the stall actually is

compileShader() is innocent. The blocking call is the one that asks a question

This is the part that sends people down the wrong hole for an afternoon. Nearly every modern driver compiles lazily and on another thread. gl.compileShader() hands the source over and returns. gl.linkProgram() queues the link and returns. Both look free in a profile, because both are free at the moment you call them.

The bill arrives the first time anything needs an answer. gl.getProgramParameter(prog, gl.LINK_STATUS) cannot be answered until the driver has finished, so it blocks, on the main thread, for however long the real work takes. The three numbers below are measured separately on every rebuild, from the same eight programs, so you can see the shape rather than take my word for it.

compileShader × 16

···

Both shaders for all eight materials. Almost always noise.

linkProgram × 8

···

Queued, not performed. Also almost always noise.

LINK_STATUS query × 8

···

The blocking one. On most machines this is the entire stall.

Which gives a real mitigation rather than a superstition, and it is the same one that answers the twenty-second TSL cold start: do not ask the question on the frame you need the answer. Compile every variant you will ever need behind the preloader, or, where the extension exists, poll COMPLETION_STATUS_KHR instead, which answers without blocking and lets you keep drawing the old material until the new one is genuinely ready.

01

Parallel compile support

detecting…

02

Nothing throws

A recompile is a successful operation. There is no warning, no error and no console entry to grep for. The only signal is a frame that took too long, in a session you were not recording.

03

Warm caches lie to you

Compile the same source twice and the driver may hand back a cached binary in a fraction of the time. Your second measurement is not your users' first one. Press drop every program to see both.

03 · the fix is not free

You are trading a compile stall for permanent shader work

The source stops at "set intensity to zero", and for their case that is the right call. It is worth saying out loud what the trade is, because nobody does. Holding the key still means compiling once at the maximum light count and running that loop every fragment, for every light, forever, including the ones you turned off. A zero intensity does not skip the iteration. It multiplies the result by nothing after doing all the work.

Both modes above render an identical image at the same light count. The frame rates are not identical, and the gap widens as you lower the slider, because mode one shrinks its loop to match while mode two never does. On a scene with two lights visible and a ceiling of sixteen, mode two is doing eight times the lighting maths for the same picture.

To fill both boxes below: set the slider, wait a second, then switch modes and wait again. Moving the slider clears both figures, on purpose. A reading taken at three lights and a reading taken at fourteen are not a comparison, and leaving the stale one on screen next to the fresh one is how that mistake gets made.

compile-key mode, steady

not measured

Loop is exactly as long as the light count. Costs a stall whenever the count moves.

uniform mode, steady

not measured

Loop is always at the ceiling. Never stalls, never gets cheaper.

So the honest rule is narrower than the one going around. Hold the key still for anything a user can change mid-scene, because a dropped frame during an interaction is worth far more than a few percent of steady frame time. Let the key vary for things fixed at load, and compile those variants before the first frame, where a stall costs nothing because nothing is moving yet.

04 · receipt

Verify it yourself

> measured on this machine, this visit
[WEBGL] detecting
[RENDER] detecting
[SCENE] 8 materials · 1,024 instances · one draw call per material
[KEYS] compile-key mode can reach 128 distinct programs (8 materials × 16 counts)
[PARALLEL] KHR_parallel_shader_compile: detecting
[TIMING] no key change yet, move the lights slider in compile-key mode
[COLD] first build of each program this session
[NETWORK] 0 outbound requests since load, measured by PerformanceObserver
[ASSETS] 0 CDNs · 0 webfonts · 0 analytics · single HTML file · works offline
[LIMIT] Absolute milliseconds are driver figures, not browser figures. Compare the two modes, not this machine against someone else's.
[LIMIT] performance.now() is coarsened for security. A sub-millisecond reading here means "too small to resolve", not zero.
[LIMIT] The trace under the canvas is main-thread frame delta, which includes anything else your browser was doing.
Back to the Labs →