Course outline
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().
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
WebGLRenderermethod 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
Change a colour instead
In the first example, replace
scene.children[1].rotation.y = 0.5;withmaterial.color.set("#2a9df4");. Frame 3:11 WebGL callswith oneuniform3f, one upload for the shared material.Then try
scene.children[1].material = new THREE.MeshStandardMaterial({ color: "#2a9df4" });. Still 11 calls and oneuniform3f: 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
Reset before your draw
In the second example, move
if (reset) renderer.resetState();aboveours();. 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
Counts, the same on every GPU. The wrapper only counts; three.js doesn't notice it.
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: 1Measured on the GPU: three.js's second draw runs with our program and our VAO, which has no index buffer, so `drawElements` fails.
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 noneBreak 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().
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?".
- ▸
WebGPURendererapplies 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
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
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 auniformMatrix4fv(its model-view matrix), abindVertexArrayand adrawElements, plus aclear. NouseProgram, no colour, no light: three.js remembers what the GPU has. - 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: oneuniform3f. 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
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
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 nextdrawElementsruns with your program and VAO, fails withINVALID_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
What does three.js send to WebGL on the first frame and on later frames, and why?
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
Print how many useProgram calls each frame makes in the first example, and explain the numbers.
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