Three.js First-Frame Jank: Why compileAsync Alone Doesn't Fix the Pause
First-frame jank in WebGL often gets misdiagnosed as a shader compilation problem. This walkthrough shows that terrain math, texture upload, and render-target-specific shader variants are equally likely bottlenecks, and it provides a concrete staging sequence plus the caveats that keep it honest.
When a Three.js scene stutters on the first frame, shader compilation is the usual suspect, and `renderer.compileAsync` seems like the cure. But in a fishing game with reflections and shadows, the pause persisted even after precompiling. The real work that still crowded the first draw was terrain geometry computation, texture upload to the GPU, and shader programs for the offscreen reflection path. `compileAsync` only prepares programs for the active render target; it does nothing for CPU math or texture initialization.
The fix was to stage everything before the normal render loop starts. Terrain generation got a soft budget: check elapsed time every 256 vertices and yield to the next animation frame if over ~8ms. Textures are deduplicated and initialized with `renderer.initTexture`, one per frame. After precompiling the main scene, the code switches to a 1×1 offscreen render target and precompiles again, because the output color space and program cache key depend on the current target. A `preparing` flag blocks the normal loop from drawing during this warm-up.
The author is explicit about limits: `await nextFrame` only spreads JavaScript scheduling, not GPU work; large textures can still block; and the `KHR_parallel_shader_compile` extension only provides non-blocking status queries, not free compilation. Warm-up on a dev machine doesn't guarantee smoothness on integrated GPUs.
Render target selection influences shader program caching, so a single `compileAsync` call can miss offscreen paths entirely — the 1×1 target trick is a cheap way to force those programs.
The soft budget pattern for terrain generation is a practical compromise: it smooths the main thread but doesn't guarantee a hard frame limit because synchronous operations remain.
Using `await nextFrame` between texture uploads is a JavaScript-level yield; it prevents one giant synchronous block, but the GPU work can still accumulate and cause a later stall.