WebGLRenderer Pipeline
The complete journey from renderer.render(scene, camera) to gl.drawElements() — every subsystem that fires and in what order.
WebGLRenderer Overview WebGLRenderer.js
WebGLRenderer is the orchestrator of the entire pipeline — it owns the WebGL context, manages all GPU state, coordinates shader compilation, and drives the render loop.
At construction it initializes ~20 sub-systems:
const renderer = new THREE.WebGLRenderer({ antialias: true });
// Internally creates:
// WebGLBufferRenderer — calls gl.drawArrays / gl.drawElements
// WebGLIndexedBufferRenderer
// WebGLAttributes — uploads BufferAttributes to GPU
// WebGLBindingStates — manages VAOs (vertex array objects)
// WebGLTextures — uploads textures, manages texture units
// WebGLPrograms — compiles and caches GLSL shader programs
// WebGLRenderLists — opaque/transparent object sorting
// WebGLRenderStates — per-render light & shadow state
// WebGLShadowMap — renders shadow depth maps
// WebGLState — thin wrapper around gl.enable/disable/blend/depth
// WebGLUniforms — uploads uniform values to shader programs
// ...and more
The render() Method — Full Pipeline L1609
webglcontextlost event and stops rendering until restored.scene.updateMatrixWorld() walks the entire scene graph and recomputes every node's matrixWorld (see Scene Graph). Then camera.updateMatrixWorld() adds the view matrix. Finally the combined VP matrix is built and the frustum planes are extracted.
scene.updateMatrixWorld();
camera.updateMatrixWorld();
// VP matrix for frustum culling:
_projScreenMatrix.multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
);
_frustum.setFromProjectionMatrix( _projScreenMatrix );
RenderState object (holding light data) and a RenderList (the sorted draw list) are retrieved from pool caches (keyed by scene + camera depth). Both are reset/initialized. A stack is used so nested render calls (e.g. rendering to a texture inside a render) work correctly.function projectObject( object, camera, groupOrder, sortObjects ) {
if ( !object.visible ) return; // skip invisible subtrees
// Layer test (camera.layers bitmask)
const visible = object.layers.test( camera.layers );
if ( visible ) {
if ( object.isLight ) {
currentRenderState.addLight( object ); // collect for lighting
} else if ( object.isMesh || object.isLine || object.isPoints ) {
// Frustum cull via bounding sphere
if ( !_frustum.intersectsObject( object ) ) return;
// Compute clip-space Z for depth sorting
_vector4.setFromMatrixPosition( object.matrixWorld );
_vector4.applyMatrix4( _projScreenMatrix );
const z = _vector4.z; // depth in clip space
// Push to opaque / transmissive / transparent list
currentRenderList.push(
object, geometry, material, groupOrder, z, group
);
}
}
// Recurse regardless of layer (children may be on different layers)
for ( let i = 0; i < object.children.length; i++ ) {
projectObject( object.children[i], camera, groupOrder, sortObjects );
}
}
if ( sortObjects ) {
currentRenderList.sort( _opaqueSort, _transparentSort );
}
// _opaqueSort: groupOrder → renderOrder → z ASC → id
// _transparentSort: groupOrder → renderOrder → z DESC → id
Opaque objects sorted front-to-back: early Z-rejection saves fragment shader work on obscured pixels. Transparent objects sorted back-to-front: required for correct alpha blending (painter's algorithm).
shadowMap.render(shadowsArray, scene, camera) — for each shadow-casting light:1. Set the render target to the light's shadow map texture (depth buffer)
2. Render the scene using
MeshDepthMaterial (overrideMaterial) from the light's point of view3. The resulting depth texture is later sampled in the main pass to test whether each fragment is in shadow
This is done before the main pass so shadow maps are ready when lighting uniforms are computed.
currentRenderState.setupLights() compiles the collected lights into flat arrays ready for uniform upload: ambient sum, directional light array, point light array, etc. This happens once per render call, not per object.renderObjects( currentRenderList.opaque, scene, camera );
renderObjects( currentRenderList.transmissive, scene, camera );
renderObjects( currentRenderList.transparent, scene, camera );
Opaque first (Z-prepass benefit), then transmissive (objects with refraction), then transparent (alpha-blended).
function renderObject( object, scene, camera, geometry, material, group ) {
object.onBeforeRender( renderer, scene, camera, geometry, material, group );
// Compute model-view and normal matrices (CPU side, once per object)
object.modelViewMatrix.multiplyMatrices(
camera.matrixWorldInverse,
object.matrixWorld
);
object.normalMatrix.getNormalMatrix( object.modelViewMatrix );
// Get or compile shader program
const program = setProgram( camera, scene, geometry, material, object );
// Double-sided transparency needs two passes
if ( material.transparent && material.side === DoubleSide ) {
// Pass 1: BackSide
material.side = BackSide;
renderBufferDirect( camera, scene, geometry, material, object, group );
// Pass 2: FrontSide
material.side = FrontSide;
renderBufferDirect( camera, scene, geometry, material, object, group );
material.side = DoubleSide; // restore
} else {
renderBufferDirect( camera, scene, geometry, material, object, group );
}
object.onAfterRender( renderer, scene, camera, geometry, material, group );
}
// 1. Apply GL state (depth, blend, stencil, face culling)
state.setMaterial( material, frontFaceCW );
// 2. Bind geometry's VAO; upload any dirty attributes
bindingStates.setup( object, material, program, geometry, index );
// 3. Upload uniforms to the active program
// (matrices, material params, lights, textures, shadows)
program.getUniforms().upload( gl, uniformsList, camera );
// 4. Determine draw range
const drawStart = Math.max( drawRange.start, group?.start ?? 0 );
const drawCount = Math.min( drawRange.count, group?.count ?? Infinity );
// 5. Execute the draw call
if ( index !== null ) {
// Indexed geometry: vertices can be shared between triangles
renderer.renderInstances( drawStart, drawCount, instanceCount );
// → gl.drawElementsInstanced( mode, count, type, offset, instanceCount )
// → or gl.drawElements( mode, count, type, offset )
} else {
// Non-indexed: sequential vertices
renderer.render( drawStart, drawCount );
// → gl.drawArrays( mode, first, count )
}
The primitive mode is: gl.TRIANGLES for Mesh, gl.LINES/gl.LINE_STRIP for Line, gl.POINTS for Points.
Animation Loop WebGLAnimation.js
renderer.setAnimationLoop(callback) wraps requestAnimationFrame. The callback receives the current timestamp in milliseconds.
// Internal WebGLAnimation.js
let animationLoop = null;
let requestId = null;
function onAnimationFrame( time, frame ) {
animationLoop( time, frame );
requestId = xr.isPresenting
? xr.getSession().requestAnimationFrame( onAnimationFrame )
: requestAnimationFrame( onAnimationFrame );
}
// Public API:
renderer.setAnimationLoop( callback ) {
animationLoop = callback;
if ( callback === null ) {
cancelAnimationFrame( requestId );
} else {
requestId = requestAnimationFrame( onAnimationFrame );
}
}
In XR (VR/AR) mode, xr.getSession().requestAnimationFrame is used instead — it syncs with the headset's display refresh rate and provides an XRFrame with pose data.
DOMHighResTimeStamp that requestAnimationFrame provides — milliseconds since page load, with sub-millisecond precision. This is why the README example divides by 1000 or 2000 to get slow rotation speeds.RenderList — How Objects Are Sorted WebGLRenderLists.js
The pool reuse (renderItems[]) avoids creating new objects each frame, which would stress the garbage collector and cause frame time spikes.
WebGL State Management WebGLState.js
WebGL has expensive state-change calls. WebGLState shadows all GL state in JavaScript and only calls the WebGL API when the value actually changes:
// WebGLState.js — example: setBlending
function setBlending( blending, blendEquation, blendSrc, blendDst, ... ) {
if ( blending === NoBlending ) {
if ( currentBlendingEnabled ) {
gl.disable( gl.BLEND ); // only calls GL if state changed
currentBlendingEnabled = false;
}
return;
}
if ( ! currentBlendingEnabled ) {
gl.enable( gl.BLEND );
currentBlendingEnabled = true;
}
if ( blending !== currentBlending || ... ) {
// Only update if different from last draw
gl.blendEquationSeparate( ... );
gl.blendFuncSeparate( ... );
currentBlending = blending;
}
}
This pattern is applied to: blend mode, depth test/write, stencil, face culling, line width, scissor, viewport, and more. It's a critical optimization — redundant GL state changes are one of the top causes of WebGL performance issues.
Render Targets — Rendering Off-Screen
A WebGLRenderTarget redirects rendering into a texture instead of the canvas. This is the foundation for post-processing, shadow maps, reflections, and SSAO.
// Create a render target (texture)
const target = new THREE.WebGLRenderTarget( 512, 512, {
minFilter: THREE.LinearFilter,
magFilter: THREE.NearestFilter,
format: THREE.RGBAFormat,
});
// Render scene into the texture
renderer.setRenderTarget( target );
renderer.render( scene, camera );
// Back to canvas
renderer.setRenderTarget( null );
renderer.render( scene, camera );
// Use the texture
const material = new THREE.MeshBasicMaterial({ map: target.texture });
Internally, three.js binds the render target's framebuffer object (FBO) before rendering. The FBO directs draw output to the attached texture instead of the default framebuffer (canvas).
Key Renderer Configuration
| Option / Method | Description |
|---|---|
| antialias: true | Request MSAA from the browser. Smooths triangle edges but increases GPU memory. |
| setPixelRatio(ratio) | Scale canvas for HiDPI screens. Use Math.min(window.devicePixelRatio, 2) — 3× and 4× are wasteful. |
| setSize(w, h) | Resize the canvas and WebGL viewport. |
| shadowMap.enabled = true | Enable shadow rendering. Also set light.castShadow and mesh.castShadow / receiveShadow. |
| shadowMap.type | PCFSoftShadowMap (default soft), VSMShadowMap (variance, blurrable), BasicShadowMap (hard edges, cheap). |
| outputColorSpace | SRGBColorSpace (default) — ensures textures and output match sRGB display expectations. |
| toneMapping | ACESFilmicToneMapping (cinematic), LinearToneMapping (none), ReinhardToneMapping — for HDR → LDR conversion. |
| sortObjects | Default true. Set false only if you manage draw order entirely via renderOrder yourself. |
External References
- WebGL Fundamentals — How It Works
- Khronos — OpenGL Rendering Pipeline Overview
- MDN — WebGL Best Practices — state changes, draw calls, buffer updates
- LearnOpenGL — Framebuffers — render-to-texture foundation
- LearnOpenGL — Shadow Mapping
- Three.js Docs — WebGLRenderer
- src/renderers/webgl/WebGLState.js — GL state shadow
- src/renderers/webgl/WebGLPrograms.js — shader cache & compilation