Three.js First-Frame Jank: Why compileAsync Alone Doesn't Fix the Pause
Recently I've been working on a 3D fishing webpage. The shore has trees and characters, the water surface needs reflections, and the scene also has shadows enabled.
When entering the fishing spot, there was a problem: the loading screen was already displayed, but the animation would pause for a moment. Looking at Network, the models and textures weren't necessarily still downloading.
After inspecting the initialization flow, I found that just focusing on compileAsync isn't enough. Terrain computation, texture uploads, and shader program preparation for different render paths can all pile up before the first draw.
Below I break it down according to the actual project implementation, using Three.js 0.186.1 and TypeScript.
1. First figure out which step is stuck
After resources finish downloading, before the frame actually starts drawing, there is still this work:
| Work | Example in this project |
|---|---|
| CPU computation | terrain height, slope, vertex colors, normals |
| Texture initialization | upload of shore, tree, and character textures |
| Shader compilation | programs needed for main view, offscreen reflection, and shadows |
| Actual drawing | reflection, shadows, and main scene under the main camera |
compileAsync handles program precompilation, but it can't do all the earlier work for us. Wrapping terrain generation in async doesn't automatically make its synchronous loops multithreaded.
My terrain computation checks elapsed time every 256 vertices; if it exceeds about 8ms, it waits for the next animation frame callback and continues. This is a soft budget: subsequent computeVertexNormals() still runs synchronously, so you can't say the whole initialization is limited to 8ms.
2. Image read done may not mean the texture is uploaded
Texture file decode completion doesn't mean the GPU-side texture is ready. If you wait until the first render() to initialize a batch of textures, the overhead will appear all at once.
After the scene is assembled, I collect textures, deduplicate with a Set, and initialize each one. The scheduling part is kept below; textures is passed by the caller:
import * as THREE from 'three';
const nextFrame = () =>
new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
async function prepareTextures(
renderer: THREE.WebGLRenderer,
textures: Iterable<THREE.Texture>,
cancelled: () => boolean,
): Promise<boolean> {
for (const texture of new Set(textures)) {
if (cancelled()) return false;
renderer.initTexture(texture);
await nextFrame();
}
return !cancelled();
}
initTexture is a public API for initializing textures ahead of time, avoiding waiting until the first render to handle decode and upload. Three.js API reference
Currently the project collects textures directly referenced by materials, and textures used as uniform values directly in ShaderMaterial. Structures like texture arrays need separate handling.
Two things are easy to misunderstand:
- Initializing a single texture can still block. If one image is very large, you also need to check dimensions and format.
await nextFrame()schedules work across frames, it doesn't mean the GPU work is done. rAF callbacks run before the repaint.
3. Main view precompiled — what about the water reflection?
After textures, I use renderer.compileAsync(scene, camera) to prepare material programs. Before calling it, lights, environment, and materials need to be configured. Three.js precompile reference
But this fishing spot has an extra path: the main view outputs to the Canvas, and the water reflection outputs to an offscreen render target.
Checking the locked Three.js source code, the current render target participates in choosing the output color space, and corresponding parameters participate in generating the program cache key. The main view's program being prepared doesn't guarantee the offscreen path will reuse it.
So after precompiling the main scene, I switch to a small offscreen target and prepare the output paths the current project needs again. The function below shares the THREE import with the previous section:
async function prepareMainAndOffscreen(
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera,
cancelled: () => boolean,
): Promise<boolean> {
if (cancelled()) return false;
await renderer.compileAsync(scene, camera);
if (cancelled()) return false;
const previous = renderer.getRenderTarget();
const target = new THREE.WebGLRenderTarget(1, 1);
try {
renderer.setRenderTarget(target);
await renderer.compileAsync(scene, camera);
return !cancelled();
} finally {
renderer.setRenderTarget(previous);
target.dispose();
}
}
This 1×1 target is only used to switch the compilation environment; it doesn't render a full reflection, and it certainly doesn't reduce the real reflection cost to a single pixel. If you switch to another offscreen configuration, camera, or postprocessing, you need to re-check whether the program matches.
Shadows need separate attention. For example, leaves have alpha cutoff, so the depth path must consider map, alphaMap, alphaTest, and face direction. The project creates a temporary depth material proxy for relevant shadow-casting objects, keeps a reference after precompilation, and releases it after the first real shadow draw. The simplified function above only shows the main view and offscreen part.
Also, compileAsync uses KHR_parallel_shader_compile, which mainly provides non-blocking compilation status queries. Compilation itself still has a cost, and extension support needs consideration. MDN extension reference
4. During warm-up, don't let the normal loop draw first
There's another prerequisite for frame-splitting preparation: the normal render loop must not keep drawing a scene that isn't ready, otherwise the first upload and compilation could happen prematurely.
I use a preparing state to block normal scene updates and drawing. During the preparation phase, the existing cover and entrance animation are shown. The flow is:
Show entrance UI
→ Load resources, chunk-generate terrain
→ Assemble scene
→ Initialize textures, precompile relevant programs
→ Draw once with the real camera and check context
→ End preparing, resume normal loop
The entry flow takes exclusive control of the renderer to avoid interruptions during offscreen target switching. When the user exits, each async step checks the cancellation state; temporary targets and materials need cleanup. On load failure, a retry entry point is given.
This doesn't mean "after warming up, no device stutters." GPU buffer upload, model processing, and synchronous computation may still leave long tasks, so you need to continue looking at Performance records. Results from a development machine also can't replace testing on a typical integrated-graphics computer.
The most useful change this time was splitting the first-frame preparation into clear stages. In the future, if I add more trees or change a texture and hit a pause, I can trace through this flow.
This article comes from my developed 闲钓小栈 (Idle Fishing Lodge), currently a single-player 3D fishing and learning preview for desktop.
Experience URL: 闲钓小栈.