How to Use Three.js compileAsync Before Render Loop

When rendering 3D scenes in WebGL, shader compilation often causes a noticeable pause or “stutter” the first time a new material or object enters the camera’s view. This article demonstrates how to eliminate this lag by utilizing the asynchronous WebGLRenderer.compileAsync() method in Three.js, allowing you to force the compilation of all scene shaders prior to starting your render loop.

The Problem: Shader Compilation Stutter

By default, Three.js compiles shaders lazily. This means compilation occurs right when an object is about to be drawn to the screen for the first time. Because compiling GLSL shaders is a CPU-intensive task that blocks the main thread, this lazy compilation results in dropped frames (jank).

While the older renderer.compile(scene, camera) method compiles shaders upfront, it runs synchronously on the main thread, which can freeze the browser UI.

The Solution: Using compileAsync

The WebGLRenderer.compileAsync() method compiles all materials present in the scene asynchronously. By pairing this method with JavaScript’s async/await syntax, you can keep the browser responsive (e.g., displaying a loading spinner) and delay the start of your rendering loop until every shader is ready.

Implementation Code Example

Here is how to set up your initialization sequence to block the render loop until all shaders are fully compiled:

import * as THREE from 'three';

// 1. Setup basic scene, camera, and renderer
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 2. Add your meshes, lights, and complex materials to the scene
const geometry = new THREE.TorusKnotGeometry(10, 3, 100, 16);
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00, roughness: 0.1 });
const torusKnot = new THREE.Mesh(geometry, material);
scene.add(torusKnot);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(1, 1, 1).normalize();
scene.add(light);

camera.position.z = 30;

// 3. Define the render loop
function animate() {
    requestAnimationFrame(animate);
    
    // Rotate object to show smooth performance
    torusKnot.rotation.x += 0.01;
    torusKnot.rotation.y += 0.01;
    
    renderer.render(scene, camera);
}

// 4. Create an async initialization function
async function prepareAndRender() {
    const loadingScreen = document.getElementById('loading-screen');
    console.log('Compiling shaders asynchronously...');

    try {
        // Pre-compile all materials currently in the scene
        await renderer.compileAsync(scene, camera);
        
        console.log('All shaders compiled. Starting render loop.');
        
        // Hide loading screen UI
        if (loadingScreen) {
            loadingScreen.style.display = 'none';
        }

        // Start the render loop safely
        animate();
    } catch (error) {
        console.error('An error occurred during shader compilation:', error);
    }
}

// Start the process
prepareAndRender();

How It Works

  1. Scene Preparation: All meshes, lights, and materials must be added to the scene before calling compileAsync. The method works by parsing the existing scene graph and preparing the WebGL programs for everything it finds.
  2. Asynchronous Waiting: The await renderer.compileAsync(scene, camera) statement pauses the execution of the prepareAndRender function. However, because it is asynchronous, it does not freeze the browser’s main thread, allowing loading animations to continue playing.
  3. Delayed Loop Trigger: The animate() render loop function is only called after the promise returned by compileAsync successfully resolves. This guarantees that your first frame—and all subsequent frames—will render immediately without any compilation overhead.