Command Palette

Search for a command to run...

Hectal
Course outline
Topic 14.5Advanced ~20 min · step 5 of 6

When to Drop to the Raw API

In one line

three.js turns a scene into WebGL calls: 156 on the first frame of a small scene (compiling, uploading), 10 on later ones, skipping everything that didn't change. Raw GPU code is worth it for small special jobs, custom renderers and compute; when it shares three.js's context, call renderer.resetState().

0/6 · 0%

Think of it like this

A taxi with a good driver. Most days you say where to go. Sometimes you drive yourself: a road the driver doesn't know, or a hop too short to call a taxi. If you borrow the driver's car, you put the seat and mirrors back.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

state cache
three.js's record of what the GPU has bound and set, used to skip calls that change nothing.
uniform cache
three.js's record of each uniform's last value per program; it uploads only changes.
resetState()
A WebGLRenderer method that clears three.js's state cache, so it sets everything again on the next render.
custom renderer
Your own drawing code for one kind of data, written against the raw API.

Step by step

01Count the first frame

Frame 1's top calls are setup: 21 bindBuffer, 14 getActiveUniform, 14 getUniformLocation, 12 createBuffer, 12 bufferData. That is why the first frame of anything new is the expensive one.

02Count a normal frame

Frame 2 is 10 calls; draw calls: 3 matches the status bar.

03Share the context, then break it

The second example draws three.js's blue square, then an orange triangle with its own program and VAO, then lets three.js draw again: without resetState() the square is gone, with it both appear.

04Decide for the rear light

The glow is a small 2D job that never changes the scene, and Ama wants it on both APIs: raw code on its own canvas fits. Bloom on the headlight fits three.js's effect chain instead (Phase 15).

Try it yourself

  1. 1

    Change a colour instead

    In the first example, replace scene.children[1].rotation.y = 0.5; with material.color.set("#2a9df4");. Frame 3: 11 WebGL calls with one uniform3f, one upload for the shared material.

    Then try scene.children[1].material = new THREE.MeshStandardMaterial({ color: "#2a9df4" });. Still 11 calls and one uniform3f: the new material compiles to the same program, and three.js sorts opaque objects by material, so the two orange meshes draw together and the colour changes once.

  2. 2

    Reset before your draw

    In the second example, move if (reset) renderer.resetState(); above ours();. Same output: an empty cache makes three.js bind its own program whatever is bound. A reset anywhere between three.js's renders works; no reset is the bug.

Code & diagrams

Every WebGL call three.js makes for a frame New tab

Counts, the same on every GPU. The wrapper only counts; three.js doesn't notice it.

idle

Console

Run the scene to see its console.

Expected console output

frame 1: 156 WebGL calls (43 kinds) | bindBuffer 21, getActiveUniform 14, getUniformLocation 14, createBuffer 12, bufferData 12, enableVertexAttribArray 6
frame 2: 10 WebGL calls (4 kinds) | uniformMatrix4fv 3, bindVertexArray 3, drawElements 3, clear 1
frame 3: 12 WebGL calls (5 kinds) | uniformMatrix4fv 3, bindVertexArray 3, drawElements 3, uniformMatrix3fv 2, clear 1
draw calls: 3 | programs: 1
Your code and three.js in one context New tab

Measured on the GPU: three.js's second draw runs with our program and our VAO, which has no index buffer, so `drawElements` fails.

idle

Console

Run the scene to see its console.

Expected console output

without resetState(): square empty | triangle orange | GL error INVALID_OPERATION
with resetState():    square blue | triangle orange | GL error none

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Share the context without resetting

In your project, draw with renderer.getContext() after renderer.render every frame, and never call renderer.resetState().

terminal
$ Open the console in Chrome
── what you'll see ──
[.WebGL-0x…] GL_INVALID_OPERATION: glDrawElements: Must have element array buffer bound.
A console warning from Chrome itself (measured; the hex number differs per run). On screen, three.js's objects vanish, like the square in the example.

Myth vs fact

Myth

Raw WebGL or WebGPU is always faster than three.js.

Fact

For general scenes three.js already sends close to the minimum: 10 calls for a 3-mesh frame. Raw code wins when it can skip work three.js must do for generality.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Spector.js (a browser extension) records every WebGL call of one frame with the state and textures at each step: the tool for "which draw made this wrong pixel?".

  • ▸

    WebGPURenderer applies the same idea to WebGPU: pipelines and bind groups cached by a key from the material and object. Its first-frame cost is pipeline creation; renderer.compileAsync(scene, camera) moves it before the first visible frame.

Remember this

  1. 1

    The problem this solves: Tomás asked whether to drop three.js for raw WebGPU. The right question is "for which job?", and knowing what three.js does per frame lets you answer with numbers.

  2. 2

    What three.js sends, measured. The first example counts every WebGL call during renderer.render() for 3 meshes and a light. Frame 1: 156 calls of 43 kinds: 12 buffers created and filled, the program compiled and linked, every uniform's location looked up. Frame 2: 10 calls: per mesh a uniformMatrix4fv (its model-view matrix), a bindVertexArray and a drawElements, plus a clear. No useProgram, no colour, no light: three.js remembers what the GPU has.

  3. 3

    Only what changed is sent. Turn one mesh: frame 3 adds two uniformMatrix3fv (normal matrices that differ from the cache). Change the shared material's colour: one uniform3f. That bookkeeping (state cache, uniform cache, a VAO per geometry, opaque objects sorted by material, so draws that share a program stay together) is what you would rewrite yourself.

  4. 4

    When raw code wins: a small fixed job beside three.js (a 2D overlay like the build's glow, a debug view); a custom renderer for one kind of data, such as millions of points, where libraries like deck.gl or regl sit closer to the API; compute with no scene at all (WebGPU compute shaders). When it loses: anything with many materials, lights, shadows, loading, picking and two backends: that is three.js's job, tested on thousands of devices.

  5. 5

    Sharing one context. renderer.getContext() lets your code draw into three.js's canvas, with its depth buffer. But three.js's state cache now lies: its next drawElements runs with your program and VAO, fails with INVALID_OPERATION, and its object disappears (the second example). renderer.resetState() makes three.js forget its cache and set everything again. The build avoids the clash with its own canvas (Topic 14.6).

Explain it without notes

01

What does three.js send to WebGL on the first frame and on later frames, and why?

02

You must draw a custom effect into three.js's canvas with raw WebGL. What can go wrong, and how do you prevent it?

Practice

01

Print how many useProgram calls each frame makes in the first example, and explain the numbers.

02

Would a second three.js program in the second example's scene remove the need for resetState()?

Done when you can

  • You can count a frame's WebGL calls and explain them

  • You know why the first frame costs more

  • You call renderer.resetState() after raw calls in a shared context

  • You can argue when raw code is worth it