Threenix DevelopersGet the Agent Skill

Guide

Refactor a React Three Fiber WebGL project to WebGPU

React Three Fiber v10 renders through the Three.js WebGPU renderer, and that changes two things in a project: how the canvas is created, and how every custom shader is written. This guide takes a working WebGL scene to a WebGPU one — new canvas, TSL background, GPU particles, no GLSL left behind — then reviews and optimises the result with Threenix agent skills.

Short answer

The refactor is three changes. Import Canvas from @react-three/fiber/webgpu and pass renderer options through the renderer prop instead of gl. Rewrite each GLSL program as a TSL node graph bound to a node material slot. Delete the .frag and .vert files together with the bundler rules that loaded them. The scene graph, camera, controls, loaders, and built-in materials stay as they are.

Published
Stack
React Three Fiber v10 (WebGPU entry), three 0.185, React 19
Shader language
TSL nodes from three/tsl
Finished state
No .frag or .vert file left in the project

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.

ConcernWebGLReact Three Fiber v10 WebGPU
Canvas entry pointimport { Canvas } from '@react-three/fiber'import { Canvas } from '@react-three/fiber/webgpu'
Renderer optionsgl={{ antialias: true }}renderer={{ antialias: true, forceWebGL: false }}
Shader languageGLSL in .frag and .vert filesTSL node graphs from three/tsl
Custom materialsShaderMaterial, onBeforeCompileNode materials, colorNode, positionNode, opacityNode
Scene backgroundFullscreen quad with a fragment shaderscene.backgroundNode
Post-processingEffectComposer and ShaderPassPostProcessing or RenderPipeline with TSL display nodes
Per-particle motionCPU loops writing buffer attributesinstancedArray with Fn(...)().compute()
Uniforms in ReactuseMemo(() => 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.

Terminal
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/three on the same version as three; 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.

Intermediate step
// 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.

Skills CLI
npx skills add prag-matt-ic/threenix-plugin
ChatGPT / Codex
codex plugin marketplace add prag-matt-ic/threenix-plugin
codex plugin add threenix@threenix
Claude Code
claude plugin marketplace add prag-matt-ic/threenix-plugin
claude plugin install threenix@threenix

Start 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.

Before — WebGL canvas
// 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>
)
After — WebGPU 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:

  • Canvas now comes from @react-three/fiber/webgpu. That entry point types useThree and useFrame against the WebGPU root state, so state.renderer is a WebGPURenderer with no cast.
  • gl became renderer, typed as WebGPURendererParameters. Every leftover gl prop is now a type error, which turns tsc into your migration checklist.
  • The renderer defaults change: forceWebGL: false selects the WebGPU backend, powerPreference: 'high-performance' asks for the discrete GPU, and stencil: false drops 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.

Before — fullscreen quad and background.frag
// 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>
)
After — scene backgroundNode
// 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-node to have the skill copy its BackgroundNode.tsx reference into your scene and replace only the example graph inside the creator. Keep the graph in three/tsl, return it as backgroundNode, 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:

Driving a TSL graph from React
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.

After — sampled once, animated in a compute pass
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. MeshSurfaceSampler distributes points across the mesh by triangle area, so 4,096 samples describe a silhouette rather than a simulation.
  • Motion moves to a compute pass: instancedArray owns 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 useBuffers so 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.

Find remaining GLSL
# Nothing should match once the refactor is finished.
find . \( -name '*.frag' -o -name '*.vert' -o -name '*.glsl' \) -not -path '*/node_modules/*'
Find GLSL escape hatches
# GLSL escape hatches that keep a shader on the WebGL backend.
grep -rn "ShaderMaterial\|RawShaderMaterial\|onBeforeCompile\|glslVersion" src

Then 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:

GLSLTSL
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 chunksImport 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 patchingNode 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.

  1. Adapter present. navigator.gpu exists and the scene mounts without the unsupported-browser message.
  2. 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.
  3. Visual parity. Same camera, same DPR, same tone mapping as the WebGL build. Then compare renderer.info draw calls and triangles — and reset it yourself first. The WebGPU renderer does not call info.reset() per render the way WebGLRenderer does, so a bare read is a running total for the lifetime of the renderer rather than a frame.
  4. No GLSL left. The find and grep commands from step 5 return nothing.
  5. Types clean. tsc --noEmit passes. Leftover gl props, WebGL-only renderer options, and mis-typed node graphs all surface here.
  6. First reveal smooth. WebGPU compiles pipelines on first use, so a section that first appears mid-scroll can hitch. Run $add-scene-warmup to 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:

Codex
$best-practices @src/scene/Experience.tsx

The 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.
  • useFrame allocating — a new Vector3, 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 near and far are 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:

Codex
$optimize-tsl @src/scene/Background.tsx

The 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.

Before — repeated fragment work
// 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))
After — cached and hoisted
// 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 GitHub
  • Add 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 GitHub
  • Add 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 GitHub
  • Add 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 GitHub
  • Review 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 GitHub
  • Optimize 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.

Terminal
npx skills add prag-matt-ic/threenix-plugin

Further reading