What changes in a WebGL to WebGPU refactor
Most of a Three.js project never mentions the renderer, so most of it does not move. Geometry, materials created from the built-in types, glTF loading, animations, and the raycaster all behave the same through WebGPURenderer. What changes is the canvas entry point and anything you wrote as a shader.
| Concern | WebGL | React Three Fiber v10 WebGPU |
|---|---|---|
| Canvas entry point | import { Canvas } from '@react-three/fiber' | import { Canvas } from '@react-three/fiber/webgpu' |
| Renderer options | gl={{ antialias: true }} | renderer={{ antialias: true, forceWebGL: false }} |
| Shader language | GLSL in .frag and .vert files | TSL node graphs from three/tsl |
| Custom materials | ShaderMaterial, onBeforeCompile | Node materials, colorNode, positionNode, opacityNode |
| Scene background | Fullscreen quad with a fragment shader | scene.backgroundNode |
| Post-processing | EffectComposer and ShaderPass | PostProcessing or RenderPipeline with TSL display nodes |
| Per-particle motion | CPU loops writing buffer attributes | instancedArray with Fn(...)().compute() |
| Uniforms in React | useMemo(() => uniform(0), []) | useUniform, useUniforms, useLocalNodes |
Two rows carry almost all the work: the shader language and the custom materials. Everything else is a prop rename or an optional upgrade, and the type checker finds the prop renames for you.
Before you start
Install the versions the refactor targets. The WebGPU canvas, renderer options, and TSL hooks below ship in the React Three Fiber v10 alpha entry point.
npm install @react-three/fiber@alpha three
npm install --save-dev @types/three- React Three Fiber v10 requires React 19, and the WebGPU entry is published as an alpha. Pin the exact version you test with instead of tracking the tag.
- Keep
@types/threeon the same version asthree; the TSL node types are what make the shader refactor type-checked rather than string-typed. - Feature-detect before you mount the canvas, so a browser without WebGPU gets a message instead of a blank surface:
Support check // Feature-detect before you mount the canvas: "gpu" is absent in browsers without WebGPU. const isWebGPUSupported = 'gpu' in navigator - Browser support today: Chrome and Edge have shipped WebGPU since version 113, Safari supports it from 26 on macOS and iOS, and Firefox still ships it disabled by default. Check the current support table before you drop a fallback.
De-risk the work: ship TSL first, switch the backend second
TSL is not WebGPU-only. The same node graph compiles to GLSL when the renderer runs on the WebGL2 fallback, so forceWebGL: true lets you land the whole shader rewrite, verify it against your existing WebGL build, and only then flip the backend. It also keeps Firefox visitors rendering while that browser finishes enabling WebGPU by default.
// forceWebGL keeps three's WebGL2 backend behind the same renderer, scene, and TSL graph.
<Canvas renderer={{ forceWebGL: true }}>Step 1
Install the Threenix plugin
Every step from here can be driven by a coding agent. The Threenix skills are free and open source, and installing them is a single command per project.
npx skills add prag-matt-ic/threenix-plugincodex plugin marketplace add prag-matt-ic/threenix-plugin
codex plugin add threenix@threenixclaude plugin marketplace add prag-matt-ic/threenix-plugin
claude plugin install threenix@threenixStart a new session after installing so the agent picks the skills up, then invoke one by name: $best-practices in Codex, /threenix:best-practices with the Claude Code plugin, or /best-practices in Cursor and the Skills CLI. The published skill list lives on skills.sh and in the plugin repository.
One thing to know before you start invoking them: the component skills assume the project already renders through @react-three/fiber/webgpu. $add-webgpu-canvas, $add-background-node, and $add-mesh-surface-sampled-particles each stop and report that migrating renderers is out of scope when they find a WebGL canvas. Step 2 is that migration, and it is the one step this guide owns rather than a skill. Do it first, and the skills will find the canvas they expect.
Step 2
Refactor the scene to the WebGPU renderer canvas
The canvas is the smallest change with the largest consequence: the import path decides which renderer, which hooks, and which renderer options your scene gets.
// WebGL: the classic entry point
import { Canvas } from '@react-three/fiber'
export const Scene = () => (
<Canvas
camera={{ fov: 45, near: 0.5, far: 120 }}
dpr={[1, 2]}
gl={{ antialias: true, powerPreference: 'high-performance' }}>
<Experience />
</Canvas>
)// WebGPU: same Canvas, the WebGPU entry point and the renderer prop
import { Canvas } from '@react-three/fiber/webgpu'
export const Scene = () => (
<Canvas
camera={{ fov: 45, near: 0.5, far: 120 }}
dpr={[1, 2]}
renderer={{
antialias: true,
forceWebGL: false,
powerPreference: 'high-performance',
stencil: false,
}}>
<Experience />
</Canvas>
)Three things changed, and only three:
Canvasnow comes from@react-three/fiber/webgpu. That entry point typesuseThreeanduseFrameagainst the WebGPU root state, sostate.rendereris aWebGPURendererwith no cast.glbecamerenderer, typed asWebGPURendererParameters. Every leftoverglprop is now a type error, which turnstscinto your migration checklist.- The renderer defaults change:
forceWebGL: falseselects the WebGPU backend,powerPreference: 'high-performance'asks for the discrete GPU, andstencil: falsedrops a buffer the scene does not use.
The canonical version of this component is the add-webgpu-canvas reference: a thin wrapper around the WebGPU Canvas that keeps the same public props, defaults dpr to [1, 2], wraps children in Suspense, and deliberately ships no WebGL fallback. Copy it into your shared component location rather than hand-rolling a variant, and keep DOM overlays outside the canvas — its parent still needs a definite height.
Mount exactly one canvas. If your route still renders the old WebGL canvas, remove it in the same change; two mounted canvases mean two renderers, two scenes, and double the GPU memory for the same picture.
Step 3
Replace the background node shader with TSL
A background is the cheapest shader to migrate and the clearest demonstration of what TSL removes. Nothing about the visual changes; the delivery does.
// background.frag + a fullscreen quad, with uTime written from React every frame
const uniforms = useMemo(() => ({ uTime: { value: 0 } }), [])
useFrame((_, delta) => {
uniforms.uTime.value += delta
})
return (
<mesh>
<planeGeometry args={[2, 2]} />
<shaderMaterial fragmentShader={fragmentShader} uniforms={uniforms} vertexShader={vertexShader} />
</mesh>
)// backgroundNode on the scene: no quad, no material, no extra draw call
import { useLocalNodes } from '@react-three/fiber/webgpu'
import type { FC } from 'react'
import { mix, mx_noise_float, screenUV, time, vec3 } from 'three/tsl'
const Background: FC = () => {
const { backgroundNode } = useLocalNodes(() => {
// `time` is a TSL node, so the animation needs no per-frame uniform write.
const noise = mx_noise_float(screenUV.mul(2).add(time)).mul(0.5).add(0.5)
return {
backgroundNode: mix(vec3(0.04, 0.02, 0.08), vec3(0.37, 0, 0.66), noise),
}
})
return <primitive attach="backgroundNode" object={backgroundNode} />
}scene.backgroundNode is a scene slot rather than a mesh: no quad geometry, no material, no extra draw call, and no per-frame uniform bookkeeping. time and screenUV are nodes, so the animation the fragment shader used to compute by hand now arrives for free.
- Run
$add-background-nodeto have the skill copy itsBackgroundNode.tsxreference into your scene and replace only the example graph inside the creator. Keep the graph inthree/tsl, return it asbackgroundNode, and keep<primitive attach="backgroundNode" object={backgroundNode} />. - Render it once, as a child of the canvas. Do not add a full-screen mesh, a second canvas, or a post-processing pass just to draw a background.
- Reuse the scene's existing textures where they fit instead of loading new ones; the creator can read them directly.
Values that change at runtime belong in scoped uniforms, read through the creator's CreatorState and written imperatively:
const { uSunCharge } = useUniforms({ uSunCharge: 0 }, 'sun')
const { backgroundNode } = useLocalNodes(({ uniforms }: CreatorState) => {
const { uSunCharge } = uniforms.scope('sun')
return { backgroundNode: createSunGlow(uSunCharge) }
})
useFrame(() => {
uSunCharge.value = readChargeFromStore()
})Read uniforms inside the creator and update .value in useFrame or an effect. Never branch on .value while building the graph — the branch would evaluate once, in JavaScript, and never react again. Use select, step, or smoothstep when the choice has to happen on the GPU.
Step 4
Replace the mesh surface sampled particles with a TSL compute version
Particles are where the refactor pays for itself. The WebGL version usually samples the mesh on the CPU and then keeps writing positions from a loop or a vertex shader; the TSL version samples once and moves every per-particle decision onto the GPU.
import { useFrame, useLocalNodes, useThree } from '@react-three/fiber/webgpu'
import { Fn, If, deltaTime, hash, instanceIndex, instancedArray, smoothstep, vertexStage } from 'three/tsl'
const PARTICLE_COUNT = 4096
const LOOP_DISTANCE = 0.2
// Sampling is a one-time CPU cost after the model loads.
const particleBuffers = useMemo(() => {
const sampled = sampleMeshSurface(mesh, PARTICLE_COUNT)
return {
initialPositionBuffer: instancedArray(sampled, 'vec3'),
positionBuffer: instancedArray(sampled.slice(), 'vec3'),
}
}, [mesh])
const createParticleNodes = useCallback(() => {
const { initialPositionBuffer, positionBuffer } = particleBuffers
// One GPU invocation per particle replaces the per-particle CPU loop.
const updateParticles = Fn(() => {
const initialPosition = initialPositionBuffer.element(instanceIndex)
const position = positionBuffer.element(instanceIndex)
const riseSpeed = hash(instanceIndex).mul(0.3).add(0.1)
position.z.subAssign(deltaTime.mul(riseSpeed))
If(position.z.lessThan(initialPosition.z.sub(LOOP_DISTANCE)), () => {
position.assign(initialPosition)
})
})().compute(PARTICLE_COUNT)
const lifeProgress = initialPositionBuffer
.element(instanceIndex)
.z.sub(positionBuffer.element(instanceIndex).z)
.div(LOOP_DISTANCE)
return {
opacityNode: vertexStage(smoothstep(0.6, 1, lifeProgress).oneMinus()),
positionNode: positionBuffer.toAttribute(),
updateParticles,
}
}, [particleBuffers])
const particles = useLocalNodes(createParticleNodes)
const renderer = useThree((state) => state.renderer)
useFrame(() => renderer.compute(particles.updateParticles), {
phase: 'update',
fps: 60,
drop: true,
})- Sampling stays on the CPU and happens once, after the model loads.
MeshSurfaceSamplerdistributes points across the mesh by triangle area, so 4,096 samples describe a silhouette rather than a simulation. - Motion moves to a compute pass:
instancedArrayowns the particle data,Fn(...)().compute(count)runs one GPU invocation per particle, and the render material reads the result back as a vertex attribute with.toAttribute(). - Keep two buffers — an immutable rest position and a mutable position. The loop reset needs the rest shape, and rebuilding it from a stored copy every frame would put the work straight back on the CPU.
- Use
vertexStage()for values the fragment stage never reads, and delete the CPU loop that wrote positions each frame. If the value only ever enters the vertex stage, it should never become a varying. - Compute-owned buffers are invisible to Three's disposal paths. If the component can unmount or change its particle count at runtime, register the buffers with
useBuffersso you can dispose them yourself.
Run $add-mesh-surface-sampled-particles rather than writing the component from scratch. The skill calls get_component_reference on the Threenix MCP server with { "slug": "mesh-surface-sampled-particles" } and writes the returned files at their relative paths. Do not flatten or rename those paths: the sampler and phoenix_compressed.glb resolve relative to the component, and the returned integration notes describe how to mount it inside your existing canvas.
Preview the finished component on the mesh surface sampled particles page before you integrate it.
Step 5
Delete every .frag and .vert file
This is the step that proves the refactor is finished, and it is not cosmetic. GLSL cannot run on the WebGPU backend: Three compiles TSL node graphs to WGSL, and its node material registry maps the built-in material types to their node equivalents, so ShaderMaterial and RawShaderMaterial have no counterpart to compile into. GLSL is parsed only by the WebGL fallback backend — which is exactly the path forceWebGL: true selects. A leftover .frag file therefore works right up until the moment you switch backends.
# Nothing should match once the refactor is finished.
find . \( -name '*.frag' -o -name '*.vert' -o -name '*.glsl' \) -not -path '*/node_modules/*'# GLSL escape hatches that keep a shader on the WebGL backend.
grep -rn "ShaderMaterial\|RawShaderMaterial\|onBeforeCompile\|glslVersion" srcThen remove the machinery that loaded them: vite-plugin-glsl, raw-loader or asset/source rules, glslify, and any bundler rule matching .frag or .vert. Dead loader configuration is the kind of thing that quietly returns six months later.
Each GLSL construct has a direct TSL equivalent:
| GLSL | TSL |
|---|---|
| uniform float uTime; | The time node, or useUniform / useUniforms |
| varying vec2 vUv; | varying(uv()), or vertexStage() when only the vertex stage reads it |
| gl_FragColor = value; | Return the node from the material slot, for example colorNode |
| texture2D(map, vUv) | texture(map, uv()) |
| #include <common> and helper chunks | Import the node from three/tsl: mix, smoothstep, clamp, PI |
| if (x > 0.5) { ... } | select, step, or mix; keep If() for real control flow |
| for (int i = 0; i < n; i++) | Loop(n, ({ i }) => { ... }) |
| gl_Position = ... | material.positionNode |
| onBeforeCompile string patching | Node slots on the material; there is no string injection |
One rename is easy to get wrong. positionLocal is a varying that the node material assigns after custom nodes run, so a colour or opacity node that reads it compiles to a use-before-assign and rasterises at garbage positions. Read positionGeometry instead, and leave positionNode as the only node that writes position.
Step 6
Verify the WebGPU build
Verify before you review. The checks below catch the failures a WebGL build cannot show you.
- Adapter present.
navigator.gpuexists and the scene mounts without the unsupported-browser message. - Console clean. Three reports node build failures when a graph is invalid. A black frame with no error is usually a graph that evaluates to zero: check
opacityNode,maskNode, and anything multiplied into the final colour. - Visual parity. Same camera, same DPR, same tone mapping as the WebGL build. Then compare
renderer.infodraw calls and triangles — and reset it yourself first. The WebGPU renderer does not callinfo.reset()per render the wayWebGLRendererdoes, so a bare read is a running total for the lifetime of the renderer rather than a frame. - No GLSL left. The
findandgrepcommands from step 5 return nothing. - Types clean.
tsc --noEmitpasses. Leftoverglprops, WebGL-only renderer options, and mis-typed node graphs all surface here. - First reveal smooth. WebGPU compiles pipelines on first use, so a section that first appears mid-scroll can hitch. Run
$add-scene-warmupto precompile the scene pass and hide the first frame.
Step 7
Review the refactor with the best-practices skill
Once the WebGPU build is working, point the review skill at the files you changed and let it refactor them:
$best-practices @src/scene/Experience.tsxThe skill reads the referenced file, lists where it violates the Threenix Three.js checklist — allocation inside loops, work in the render loop, mounting and unmounting objects, React state in hot paths, shared resources and cached loaders, camera frustum, drawing-buffer options, light and shadow cost, material selection, texture sizing, draw calls — and then applies the fixes. It is a review with edits, not a report.
What it usually finds in a freshly migrated WebGPU scene:
- Uniforms written every frame whose value only changes on an event.
useFrameallocating — a newVector3, a fresh array — where a memoised scratch object would do.- Components mounted and unmounted to hide them, where
visible={false}keeps the pipeline compiled and avoids a shader rebuild. - Materials keyed on a node
uuid, which remounts the material on every render and throws away allocations for no reason. - A camera whose
nearandfarare far wider than the scene needs.
Treat the findings as work items, then confirm the cost with the measurements in the next step rather than trusting the review alone.
Step 8
Optimise the TSL with the optimize-tsl skill
With the code correct, hand the TSL graphs to the optimiser. It is the skill that knows which stage your shader is paying for:
$optimize-tsl @src/scene/Background.tsxThe skill maps heavy work to the stage that runs it, audits the graph for unused nodes, uniforms and varyings, implicit conversions, repeated expressions that should be cached, trig and division in the fragment stage, and duplicate texture fetches. Then it refactors: hoist with toVar() and toConst(), move work into vertexStage() when the fragment stage does not need it, replace branching with mix, step, or smoothstep, keep reusable helpers in Fn(), and update uniforms through their own update hooks instead of captured mutable state.
// Fragment stage: the same noise graph is evaluated three times, twice identically.
const a = mx_noise_float(uv().mul(4).add(time))
const b = mx_noise_float(uv().mul(4).add(time))
const c = mx_noise_float(uv().mul(4).sub(time))// One evaluation cached, the shared UV math hoisted, the second sample reused.
const scaledUv = uv().mul(4).toVar()
const noise = mx_noise_float(scaledUv.add(time)).toVar()
const c = mx_noise_float(scaledUv.sub(time))
return { colorNode: vec3(noise, c, noise.mul(c)) }It also covers particle storage: packing a vec3 lane together with a scalar into one vec4 saves both bytes and a binding, because WGSL aligns a vec3 storage element to a 16-byte stride, and core WebGPU exposes eight storage buffers per shader stage by default.
Measure rather than assume. The skill treats pass timing as a hypothesis about the bottleneck, captures a baseline before editing, and reports measured results separately from impact estimates. Its benchmark guide documents the deterministic local runner if you want numbers instead of an argument.
Frequently asked questions
Do I have to rewrite my whole scene to move from WebGL to WebGPU?
No. The scene graph, cameras, controls, glTF loading, animations, and raycaster work the same through the WebGPU renderer. Two things change: the canvas entry point and renderer options, and every custom shader, because GLSL programs cannot be compiled for the WebGPU backend. Built-in materials keep working, since Three maps each built-in material type to its node material equivalent.
What replaces ShaderMaterial and onBeforeCompile in WebGPU?
Node materials and TSL. Instead of assigning GLSL strings, you assign nodes to slots such as colorNode, positionNode, opacityNode, maskNode, or scaleNode, and you build those nodes with the functions in three/tsl. There is no onBeforeCompile equivalent, because there is no shader string to patch: you own the graph.
Can I still use GLSL with the WebGPU renderer?
Not on the native WebGPU backend. Three compiles TSL node graphs to WGSL, and its node material registry maps the built-in material types only, so ShaderMaterial and RawShaderMaterial have no node equivalent and cannot be compiled for WebGPU. GLSL is parsed only by the WebGL fallback backend, which is why a refactor that leaves .frag and .vert files behind is not finished.
Does TSL still work for visitors without WebGPU?
Yes. The same node graph compiles to GLSL when the renderer runs on the WebGL2 fallback, so setting forceWebGL: true gives you one codebase on both backends. That is a useful intermediate step: rewrite the shaders in TSL, ship on the WebGL backend, then flip forceWebGL to false once you have verified the WebGPU build.
Is the React Three Fiber v10 WebGPU renderer production ready?
React Three Fiber v10 is published as an alpha, so pin the exact version you test with rather than tracking the tag. The entry point, hooks, and prop utilities used in this guide are the ones shipped in 10.0.0-alpha.5. Treat an upgrade as a tested change: type-check, then render the scene, because alpha releases can add or rename hooks between versions.
Will the WebGPU renderer make my scene faster?
Not by itself. WebGPU mainly lowers per-draw-call CPU overhead and unlocks compute shaders for particles and simulation. A scene that is fragment-bound, over-drawn, or limited by texture bandwidth behaves much the same on both backends. Measure before and after: read draw calls and triangles from renderer.info, and remember that the WebGPU renderer does not reset renderer.info per render the way WebGLRenderer does, so call renderer.info.reset() yourself before you compare.
Which browsers support WebGPU today?
Chrome and Edge have shipped WebGPU since version 113, Safari supports it from 26 on macOS and iOS, and Firefox still ships it disabled by default. That last gap is the reason to keep the forceWebGL path available if Firefox matters to your audience. Check the current support table on caniuse.com before you drop a fallback.
Skills used in this guide
Six of the eleven published Threenix skills do work in this refactor. Each one is a Markdown workflow your agent reads, and each links to its source on GitHub.
Add a WebGPU canvas
Create a React Three Fiber WebGPU canvas from a proven starting point.
Step 2. Supplies the canonical WebGPUCanvas reference, including the renderer defaults and the Suspense boundary.
$add-webgpu-canvasSKILL.md on GitHubAdd a TSL background
Add a custom TSL background to an existing WebGPU React Three Fiber scene.
Step 3. Copies the BackgroundNode reference and replaces its example graph with your background.
$add-background-nodeSKILL.md on GitHubAdd mesh surface sampled particles
Add instanced particles sampled across a model surface to a WebGPU scene.
Step 4. Fetches the canonical particle component from the Threenix MCP server instead of reconstructing it.
$add-mesh-surface-sampled-particlesSKILL.md on GitHubAdd scene warmup
Precompile a WebGPU scene pass so hidden content reveals without stutter.
Step 6. Precompiles the scene pass so a hidden WebGPU section does not hitch when it first appears.
$add-scene-warmupSKILL.md on GitHubReview Three.js best practices
Improve the performance and clarity of Three.js and React Three Fiber code.
Step 7. Reviews the migrated Three.js and React Three Fiber code for performance and clarity.
$best-practicesSKILL.md on GitHubOptimize a TSL shader
Make TSL node graphs faster and clearer without changing their visible output.
Step 8. Audits the TSL node graphs and removes redundant GPU work.
$optimize-tslSKILL.md on GitHub
Start the refactor
Install the plugin, then run the steps above in order on your own project. The first three steps get you to a rendering WebGPU scene; the last two make it fast.
npx skills add prag-matt-ic/threenix-pluginFurther reading
- Three.js Shading Language documentation — the language reference for every node used above.
- React Three Fiber v10 WebGPU overview — the upstream renderer entry point and migration notes.
- React Three Fiber TSL hooks —
useUniform,useLocalNodes, and the rest of the hook set. - WebGPU browser support — the table to check before deciding on a fallback.