Three.js From Zero · Article s11-12
S11-12 R3F v10
R3F v10 — WebGPU + TSL Hooks Deep Dive
React Three Fiber v10 alpha shipped in early 2026 with a real WebGPURenderer path and three new hooks — useUniforms, useNodes, usePostProcessing — that turn TSL into a first-class React idiom. Here is what changed, why it matters, and how a r170 ShaderMaterial migrates without a rewrite.
1. The shape of the change
R3F v9 was a great Three.js renderer. It assumed WebGLRenderer, used JSX to mount a scene graph, and pretended shaders weren't a first-class concern — you opened a <shaderMaterial>, set vertexShader + fragmentShader as strings, and wired uniforms through refs. This worked, but it wasn't React-native. Every shader update meant juggling a ref, a uniforms object, and an effect.
R3F v10 alpha (paired with React 19, Three r172+, and the three/webgpu entrypoint) does three things at once:
- WebGPURenderer is a swap, not a rewrite. One prop on
<Canvas>:gl={{ backend: 'webgpu' }}(orrenderer={WebGPURenderer}in the explicit form). - TSL becomes the default shader path. Node materials (
MeshStandardNodeMaterial,MeshBasicNodeMaterial) are the lingua franca, with new hooks for authoring graphs inline. - Uniforms get React semantics.
useUniformsties uniform values to React state without a manual ref dance.
None of this is mandatory. If you ship to a WebGL-only audience tomorrow, R3F v10 still renders WebGL — the WebGPU path is opt-in, and TSL transpiles to GLSL when the WebGL backend is active. That is the whole pitch: one codebase, two backends.
@react-three/fiber release is v9.6.0, while v10.0.0-alpha.1 remains prerelease. Newer Three.js releases also moved WebGPURenderer exports to three and TSL helpers to three/tsl, so treat the snippets below as alpha-era guidance and pin versions before copying them into production.2. Recap: TSL in 60 seconds
TSL (Three.js Shading Language) lets you author shaders as JS function graphs that compile to WGSL on WebGPU and GLSL on WebGL. We covered the basics in S4-10; the short version:
import { uv, vec3, mix, sin, time } from 'three/tsl';
import { MeshBasicNodeMaterial } from 'three/webgpu';
const mat = new MeshBasicNodeMaterial();
mat.colorNode = mix(
vec3(1, 0.2, 0.4),
vec3(0.2, 0.4, 1),
sin(uv().x.mul(10).add(time))
);
No GLSL strings. No uniform binding. The graph is the shader.
3. useUniforms — uniforms as React state
The old way: declare uniforms once, mutate via ref each frame, hope nothing tears.
// R3F v9 / vanilla pattern
const uniforms = useMemo(() => ({
uTime: { value: 0 },
uColor: { value: new THREE.Color('#10b981') },
}), []);
useFrame((_, dt) => { uniforms.uTime.value += dt; });
return <mesh>
<shaderMaterial uniforms={uniforms} vertexShader={...} fragmentShader={...} />
</mesh>;
The R3F v10 way: useUniforms returns a node-bound object you can pass straight into TSL.
import { useUniforms } from '@react-three/fiber';
import { uv, mix, sin } from 'three/tsl';
function Wave({ color = '#10b981', speed = 1 }) {
const u = useUniforms({
time: 0, // number — auto wraps in float()
color: color, // color — auto wraps in vec3()
speed: speed, // number
});
// u.time is a TSL node. So is u.color, u.speed.
const colorNode = mix(
u.color,
u.color.mul(0.3),
sin(uv().x.mul(10).add(u.time.mul(u.speed)))
);
useFrame((_, dt) => { u.time.value += dt; });
return <mesh>
<sphereGeometry />
<meshBasicNodeMaterial colorNode={colorNode} />
</mesh>;
}
What happened:
useUniformsinfers the GPU type from the JS value (numbers → float, hex colors → vec3, arrays of length 3 → vec3, etc.).- Each entry is returned as a TSL node, so it composes directly into the graph without
uniform()wrappers. u.foo.value = xupdates without re-rendering React. Mutating fromuseFrameis cheap.- Updating the React prop (
color) flows through automatically; the hook diffs and assignsu.color.valuefor you.
4. useNodes — the graph as a hook
You will frequently want to build the same node graph across several materials. useNodes memoizes the graph by dependency and returns the resulting nodes:
import { useNodes, useUniforms } from '@react-three/fiber';
import { uv, mix, sin, mx_noise_float, vec3 } from 'three/tsl';
function useStripes({ scale = 4, speed = 1 } = {}) {
const u = useUniforms({ time: 0, scale, speed });
return useNodes(() => {
const p = uv().mul(u.scale);
const wave = sin(p.x.add(u.time.mul(u.speed)));
const noise = mx_noise_float(p.mul(2));
return {
colorNode: mix(vec3(0.06, 0.5, 0.36), vec3(1, 1, 1), wave.mul(noise)),
timeUniform: u.time, // expose for animation
};
}, [u.scale, u.speed]);
}
function StripeBall() {
const { colorNode, timeUniform } = useStripes({ scale: 6, speed: 0.5 });
useFrame((_, dt) => { timeUniform.value += dt; });
return <mesh><sphereGeometry />
<meshStandardNodeMaterial colorNode={colorNode} />
</mesh>;
}
The dependency array is real React. Bumping scale rebuilds the graph; not bumping it keeps the same compiled pipeline. That matters: as three.js#32735 documented, careless TSL graph creation thrashes the WebGPU pipeline cache. useNodes bakes the cache key into React's dependency model.
5. Live demo — the equivalent in r170 + ShaderMaterial
R3F v10 alpha + WebGPU is not yet runnable in every browser. So this article ships the same effect down-rev: r170 + a tiny ShaderMaterial that mirrors the TSL graph above. When you read the JSX form in §3-4, picture this same surface — it is what the new hooks compile to.
The demo runs an animated noise sphere — value-noise stripes, domain-warped, mixed between two colors. The fragment shader logic is roughly what TSL emits for the §4 graph. In v10 alpha you would write the same graph in JS and let the renderer pick WebGL or WebGPU; the ergonomics improve, the visual stays the same.
6. usePostProcessing — the third hook
Less hyped, equally useful. R3F v10 unifies the two postprocessing libraries (Vanruesc's postprocessing and Three's TSL-based PostProcessing node) under a single hook:
import { usePostProcessing } from '@react-three/fiber';
import { bloom, vignette, output } from 'three/tsl/effects';
function PostFX() {
usePostProcessing(({ pass }) => {
pass(output)
.pipe(bloom({ threshold: 0.9, intensity: 0.6 }))
.pipe(vignette({ darkness: 0.4, offset: 0.5 }));
});
return null;
}
Under the hood, usePostProcessing wires a TSL post chain into the renderer's frame graph. On WebGL it falls back to EffectComposer. On WebGPU the chain runs as a node post-pass — the renderer batches passes and avoids redundant resolves.
7. Migration — ShaderMaterial → TSL node material
You have a v9 R3F app with a <shaderMaterial> doing animated noise. Migrating to v10 is a five-step recipe:
- Inventory uniforms. Each uniform becomes a key in
useUniforms. Numbers stay numbers. Colors stay colors. Vectors stay arrays. - Inventory varyings.
vUv→uv().vNormal→normalWorld.vWorldPos→positionWorld. They are TSL nodes already. - Translate fragment math. GLSL
mix(a, b, t)→ TSLmix(a, b, t).a + b→a.add(b).a * b→a.mul(b). The naming is intentionally close. - Swap material.
<shaderMaterial>→<meshStandardNodeMaterial colorNode={...}>. Or basic, or physical. The "node" version of every Three material exists. - Test on both backends. Run the WebGL backend first (works everywhere), then flip
backend: 'webgpu'and verify visually. TSL is mostly portable; the §10 caveats are the gotchas.
Tiny migration example
// BEFORE (R3F v9 + ShaderMaterial)
function Old() {
const ref = useRef();
const u = useMemo(() => ({ uTime: { value: 0 }, uTint: { value: new THREE.Color('#10b981') } }), []);
useFrame((_, dt) => { u.uTime.value += dt; });
return <mesh ref={ref}>
<sphereGeometry />
<shaderMaterial uniforms={u}
vertexShader={`varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`}
fragmentShader={`uniform float uTime; uniform vec3 uTint; varying vec2 vUv;
void main(){ float w = sin(vUv.x*10.0 + uTime); gl_FragColor = vec4(mix(uTint, uTint*0.3, w), 1.0); }`} />
</mesh>;
}
// AFTER (R3F v10 alpha + TSL)
function New() {
const u = useUniforms({ time: 0, tint: '#10b981' });
const colorNode = mix(u.tint, u.tint.mul(0.3), sin(uv().x.mul(10).add(u.time)));
useFrame((_, dt) => { u.time.value += dt; });
return <mesh>
<sphereGeometry />
<meshBasicNodeMaterial colorNode={colorNode} />
</mesh>;
}
Same shader. Half the lines. No string parsing. Hot reload now updates the graph instead of restarting the WebGL program.
8. WebGPURenderer caveats — the small print
R3F v10 is the easy half. The WebGPU backend has rough edges that the alpha period exists to sand down:
- Pipeline caching. Three.js compiles a new pipeline per unique node-id signature. Build the graph inside
useNodeswith stable deps; otherwise, every render builds a new pipeline (which can stutter on first paint). - Indirect draw.
drawIndirect/drawIndexedIndirectare not first-class on the node renderer yet (issue #28389). GPU-driven culling still requires a custom pass. - Bind group budgets. Many small materials with many small uniforms produce many bind groups. Group uniforms by update frequency and prefer one larger material per scene over fifty bespoke ones.
- Integer types. WGSL is stricter than GLSL. Wrap loop counters in
int()/uint(). Maxime Heckel's TSL field guide is the up-to-date matrix. - Error messages. WebGPU validation errors print in browser devtools, not in the canvas. Open the console early and often.
9. Browser support — April 28, 2026 snapshot
| Browser | WebGPU | R3F v10 verdict |
|---|---|---|
| Chrome / Edge desktop 113+ | Stable | Ship-ready |
| Chrome Android 14+ (most devices) | Stable | Ship-ready, watch thermals |
| Safari 17.4+ macOS | Stable since 2024 | Ship-ready |
| Safari iOS 17.4+ | Stable | Ship-ready, A12+ devices |
| Firefox 126+ desktop | Stable behind flag, default in nightly | WebGL fallback recommended |
| Firefox Android | Not yet | WebGL fallback only |
| Quest Browser | Partial (WebXR + WebGPU don't co-exist on every build) | WebGL for XR; WebGPU for non-XR sessions |
| Safari visionOS 2.x | WebGPU shipping; WebXR AR still gated | WebGPU works, AR doesn't |
The practical answer: ship WebGL by default, opt into WebGPU when the 'gpu' in navigator probe succeeds, fall back gracefully. R3F v10 has a backend="auto" mode that does exactly this.
10. The TSL-WebGL gap (and why the demo above runs WebGL)
TSL is portable in most cases. Some nodes are WebGPU-only (compute, storage buffers, workgroupBarrier); some are WebGL-only (legacy VARYING_ intrinsics). The portable subset — the one that compiles cleanly to both — is huge: noise, math, control flow, sampling, mix/blend. Articles like this one stay there.
The above demo uses r170's classic ShaderMaterial because the article needs to run in any browser today, including ones where the v10 alpha + WebGPU stack might choke. The shader logic is what TSL would emit. Migrating it to TSL is a one-evening exercise once your project is on R3F v10.
11. When to adopt R3F v10
- Greenfield app, ships in late 2026. Yes — author in TSL, ship on auto-backend. The hooks alone justify the move.
- Existing R3F v9 production app. Wait until v10 stable. Migration is small but alpha churn is real.
- Configurator with WebGL-only audience requirements. Adopt anyway — TSL still emits GLSL. You get the authoring benefits without changing the runtime.
- WebGPU compute showcase. Yes, immediately. This is where v10 shines:
useNodes+storage()+compute()is a much cleaner authoring story than v9 with manual GPGPU FBO pings.
12. Takeaways
- R3F v10 alpha turns TSL into the React-native shader path.
useUniformsbinds JS values to TSL nodes;useNodesmemoizes graphs;usePostProcessingunifies the post chain.- Migrating from
<shaderMaterial>to a node material is a five-step recipe and usually halves the line count. - WebGPU is opt-in. WebGL stays the default until you flip the backend.
- The portable TSL subset is large enough that you author once and let the runtime pick the backend.
- R3F v10 is still prerelease as of April 28, 2026 — pin the version and expect API churn.