Skip to main content
Zubin KhavarianZubin Khavarian
Editorial illustration of a walnut sideboard with six candles in brass holders, five already lit and the last one waiting, a matchbox beside it.

Your Shader Stutter Happens at First Draw

Typo’s Voyage 🔗 is a 3D typing game I work on, built on plain three.js with a postprocessing chain. Frame rate was fine. Players still reported a hitch: the first time a mine went off, the first time the character respawned, the first time a fragile tile broke. One frame would hang for a noticeable moment and then everything carried on as if nothing had happened.

That shape, a single long frame the first time something appears, is almost always a WebGL program being linked. The fix is well known: warm your shaders up before the level starts. What’s less well known is that the obvious warm-up can do all of its work and still leave every stutter exactly where it was.

What’s actually blocking

three.js creates a GPU program for a material the first time that material is rendered. It compiles the vertex and fragment shaders, then calls gl.linkProgram().

That call usually doesn’t block. Browsers and drivers generally link in the background, so linkProgram() returns straight away and your frame carries on. The wait happens later, when three.js asks the program for its uniform locations. In three.js that lookup is deferred until the program’s first use, which is the first draw call that uses it. At that point the driver has to finish the link before it can answer, and your frame sits there until it does.

main threaddriverlinkProgram() returnslinking, off the main threadfirst draw waitsgetUniforms()16.7 mstime →
linkProgram() returns straight away. The bill arrives at the first draw that needs the program's uniforms.

So the stall isn’t at material creation, and it isn’t at new THREE.Mesh(). It lands on whichever frame first draws with that program. For an effect that only appears mid-level, that’s mid-level.

Why you can’t reproduce it

Chrome caches linked program binaries on disk, keyed by the shader source and the driver. The first visit pays the full link; a reload usually doesn’t. That’s why the hitch shows up for players and vanishes the moment you go looking for it.

Test on a fresh browser profile, and again after any shader change, because an edited shader is a new cache entry.

three.js has renderer.compile(scene, camera) for exactly this. It walks the scene, works out which program every material will need, and creates them all up front. Do it once the level is built, behind a loading screen or a transition, and the links land before anyone is playing.

// after every mesh, light and environment map is in place
renderer.compile(scene, camera);

Two details make this more useful than it looks. compile() walks the scene with a plain traverse, so objects with visible = false are included; a hidden hammer or a parked enemy gets warmed along with everything else. And programs are shared by signature, so several materials that differ only in their colors end up on one program. Whichever of them draws first pays for the link, which is a good argument for warming the whole scene rather than guessing which feature to pre-warm.

It needs to run late, though. Lighting and scene.environment feed into the program too, so if you compile before they’re final, you warm programs your frames will never ask for.

The catch: the color-space variant

This is the part that cost me the most time.

A three.js program’s cache key includes its output color space, and that value depends on where you’re drawing. With no render target bound, three.js uses renderer.outputColorSpace, which is sRGB, because the result is going to the screen. With a render target bound, it uses the linear working color space, because something downstream (your post-processing) will do the final conversion.

If you render through an effect composer, your scene never draws straight to the canvas. Every material draws into the composer’s render target, so every program your frames use is the linear variant.

Now look at the warm-up. Call renderer.compile() with nothing bound and three.js dutifully links the sRGB variant of every material. Nothing ever draws with those. Then the first real frame asks for the linear variant, doesn’t find it, and links it right there, mid-level, exactly as before.

compile(), nothing boundstandard · sRGB outlinked, never drawngame frame, into composerstandard · linear outneeded at first drawcompile(), target boundstandard · linear outalready linkedsame key
Same material, two programs. Warm up with the render target your frames actually draw into.

The warm-up did real work, doubled your program count, and moved the stutter precisely nowhere. It also fails silently, which is the dangerous part: a warm-up that links the wrong variant looks identical to one that works.

The fix is to bind the target your frames actually draw into while you compile:

function warmUpShaderPrograms(renderer, scene, camera, target) {
  const previous = renderer.getRenderTarget();
  renderer.setRenderTarget(target); // the composer's input buffer
  renderer.compile(scene, camera);
  renderer.setRenderTarget(previous);
}

In Voyage the target is the buffer the first render pass draws into. Any render target works, since the key only cares whether one is bound, but using the real one keeps the intent obvious.

Programs that come and go

The warm-up fixed the first-time stutters, but a few effects still hitched every single time, not just the first.

These were effects that build a fresh material per event (a respawn burst, a water splash, a mine blast) and dispose it when the effect ends. three.js reference-counts programs, and when the last material using a program is disposed, the program is destroyed. So every mine blast created its material, linked its program, drew, disposed, and threw the program away again. Chrome’s binary cache makes those repeat links cheaper, but the first draw still waits.

compile() can’t help either, because it only sees what’s in the scene when you call it, and these materials don’t exist yet.

The fix was a keep-alive: one hidden instance of each effect, parked in the scene for the whole level and never drawn. It holds the program’s reference count above zero, so the program is never destroyed, and because compile() ignores visible, the warm-up links it at load along with everything else. For it to work, the parked copy has to produce the same program key as a real one. That meant passing things like a waterline height as a uniform rather than baking it into the shader source, and pinning an explicit customProgramCacheKey on the one material that had its own.

One thing that doesn’t work: creating the material in a constructor and throwing it away. A material only acquires a program by being rendered or compiled as part of a scene. An orphaned material warms nothing.

Checking it without a profiler

You don’t need a performance trace to see whether this worked. renderer.info.programs lists every program three.js currently holds, and its length makes a very honest counter. Put it on a debug overlay and watch it through a level.

const linked = renderer.info.programs?.length ?? 0;

It should climb during loading, settle, and then not move again. Every time it goes up during play, a program just linked mid-game; if it goes up and then back down, something is creating and destroying the same program per event. In Voyage it settled at 38 and blipped to 39 on every respawn, fragile tile and mine hit, until the keep-alives went in.

loading3830respawnfragile tilemine hitwith keep-alives: flatwithout: +1 per eventtime →
renderer.info.programs.length across a level. Every blip on the amber line is a program linking mid-game.

It also catches the color-space mistake. Compare the settled count against a build without the warm-up: it should be about the same total, just reached earlier. If it’s roughly double, both variants are being linked and the render-target bind has gone missing.

compile or compileAsync?

three.js also has renderer.compileAsync(), which uses the KHR_parallel_shader_compile extension and resolves when every program reports it’s ready. It’s the right choice when something has to wait on the result, like holding a loading screen until shaders are done.

I used the synchronous compile(). Nothing in Voyage needed to know when the links finished, since the level reveal already runs for most of a second, which is plenty of time for them to land. compileAsync() also works by polling every material’s program on a 10ms timer, and if you tear the scene down mid-poll (say, the player skips straight to the next lesson) the poll can find a disposed material with no program and throw from inside a timer callback, leaving the promise hanging. If you use it, make sure teardown can’t race it.

What a warm-up doesn’t cover

compile() warms the materials in your scene graph and nothing else.

  • Shadow depth materials aren’t prepared, so a new shadow caster can still link its depth program at first draw. In Voyage every shadow caster is on screen from the first frame, so it never mattered.
  • Post-processing passes have their own materials outside the scene. If you enable a pass mid-game, it links when it first runs.
  • Lights are only collected if they’re visible, and the light setup is part of the program key. Add or toggle lights after the warm-up and you can invalidate what you warmed.

What I’d do on any three.js project

  • Call renderer.compile(scene, camera) once the scene is fully built, with lighting and environment final.
  • Bind the render target your frames draw into while you do it.
  • Keep one parked instance of anything that creates materials per event.
  • Watch renderer.info.programs.length through a real play session, on a fresh browser profile.

The frame rate was never the problem. The problem was a handful of single frames, each one paying for a link at the worst possible moment. Move those links behind the loading screen, make sure they’re the variants your frames actually use, and the level plays the way it did in your head.

Stay in touch

Don't miss out on new posts or project updates. Hit me up on X for updates, queries, or some good ol' tech talk.

Follow @zkmake
Zubin Khavarian, Principal Front-End EngineerWritten by