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.
Distinct shader programs this page has built since load.
Wall time the eight rebuilds took, including the blocking status query.
The frame a visitor actually feels. Reset by every mode switch.
Frame rate once nothing is recompiling. This is where mode two pays.
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. }
compileShader() is innocent. The blocking call is the one that asks a questionThis 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.
Both shaders for all eight materials. Almost always noise.
Queued, not performed. Also almost always noise.
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.
detecting…
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.
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.
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.
Loop is exactly as long as the light count. Costs a stall whenever the count moves.
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.