threlte logo
Advanced

WebGPU and TSL

The WebGPU specification is still in active development. WebGPU support in Three.js is in an early stage and is subject to frequent breaking changes. As of now, we do not recommend using WebGPU in production.

We highly recommend targeting version r171 onwards because of potential duplication and configuration issues.

WebGPU

To use Three.js’s WebGPU renderer, import it and then initialize it within your <Canvas>’s createRenderer prop.

App.svelte
<script>
  import Scene from './Scene.svelte'
  import { Canvas } from '@threlte/core'
  import { WebGPURenderer } from 'three/webgpu'
</script>

<Canvas
  createRenderer={(canvas) => {
    return new WebGPURenderer({
      canvas,
      antialias: true,
      forceWebGL: false
    })
  }}
>
  <Scene />
</Canvas>

WebGPU is still an experimental browser api and at the time of writing has limited availability across major browsers. For this reason, Three.js’s webgpu renderer fallbacks to webgl when webgpu is not available.

This same approach can be used to swap out the default renderer for any other custom renderer.

<script lang="ts">
  import { Canvas, extend } from '@threlte/core'
  import Scene from './Scene.svelte'
  import * as THREE from 'three/webgpu'

  extend(THREE)
</script>

<div>
  <Canvas
    createRenderer={(canvas) => {
      return new THREE.WebGPURenderer({
        canvas,
        antialias: true,
        forceWebGL: false
      })
    }}
  >
    <Scene />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { T, useTask, useThrelte, watch } from '@threlte/core'
  import { OrbitControls } from '@threlte/extras'
  import Stats from 'three/addons/libs/stats.module.js'
  import * as THREE from 'three/webgpu'

  const { scene, size, dom, invalidate } = useThrelte()

  scene.background = new THREE.Color(0xc1c1c1)

  let geometries: THREE.BufferGeometry[] = [
    new THREE.ConeGeometry(1.0, 2.0, 3, 1),
    new THREE.BoxGeometry(2.0, 2.0, 2.0),
    new THREE.PlaneGeometry(2.0, 2, 1, 1),
    new THREE.CapsuleGeometry(),
    new THREE.CircleGeometry(1.0, 3),
    new THREE.CylinderGeometry(1.0, 1.0, 2.0, 3, 1),
    new THREE.DodecahedronGeometry(1.0, 0),
    new THREE.IcosahedronGeometry(1.0, 0),
    new THREE.OctahedronGeometry(1.0, 0),
    new THREE.PolyhedronGeometry([0, 0, 0], [0, 0, 0], 1, 0),
    new THREE.RingGeometry(1.0, 1.5, 3),
    new THREE.SphereGeometry(1.0, 3, 2),
    new THREE.TetrahedronGeometry(1.0, 0),
    new THREE.TorusGeometry(1.0, 0.5, 3, 3),
    new THREE.TorusKnotGeometry(1.0, 0.5, 20, 3, 1, 1)
  ]

  const group = new THREE.Group()
  group.static = true

  const position = new THREE.Vector3()
  const rotation = new THREE.Euler()
  const quaternion = new THREE.Quaternion()
  const scale = new THREE.Vector3()
  const count = 3000

  function randomizeMatrix(matrix: THREE.Matrix4) {
    position.x = Math.random() * 80 - 40
    position.y = Math.random() * 80 - 40
    position.z = Math.random() * 80 - 40

    rotation.x = Math.random() * 2 * Math.PI
    rotation.y = Math.random() * 2 * Math.PI
    rotation.z = Math.random() * 2 * Math.PI

    quaternion.setFromEuler(rotation)

    const factorScale = 1
    scale.x = scale.y = scale.z = 0.35 * factorScale + Math.random() * 0.5 * factorScale

    return matrix.compose(position, quaternion, scale)
  }

  const randomizeRotationSpeed = (rotation: THREE.Euler) => {
    rotation.x = Math.random() * 0.05
    rotation.y = Math.random() * 0.05
    rotation.z = Math.random() * 0.05
    return rotation
  }

  for (let i = 0; i < count; i++) {
    const material = new THREE.MeshToonNodeMaterial({
      color: new THREE.Color(Math.random() * 0xffffff),
      side: THREE.DoubleSide
    })

    const child = new THREE.Mesh(geometries[i % geometries.length], material)
    randomizeMatrix(child.matrix)
    child.matrix.decompose(child.position, child.quaternion, child.scale)
    child.userData.rotationSpeed = randomizeRotationSpeed(new THREE.Euler())
    child.frustumCulled = false
    group.add(child)
  }

  const stats = new Stats()
  dom.appendChild(stats.dom)

  stats.begin()

  useTask(() => {
    stats.end()

    for (const child of group.children) {
      if (!child) return

      const { rotationSpeed } = child.userData

      child.rotation.set(
        child.rotation.x + rotationSpeed.x,
        child.rotation.y + rotationSpeed.y,
        child.rotation.z + rotationSpeed.z
      )
    }

    stats.begin()
  })
</script>

<T is={group} />

<T.PerspectiveCamera
  position.z={50}
  makeDefault
>
  <OrbitControls
    autoRotate
    enableZoom={false}
    autoRotateSpeed={1}
    onchange={invalidate}
  />
</T.PerspectiveCamera>

<T.DirectionalLight intensity={3.4} />

Adapted from this Three.js example.

The WebGPU renderer doesn’t immediately render. If the renderer you provide needs to delay rendering, you can defer rendering by initially setting the renderMode to manual like so:

App.svelte
<script>
  import { Canvas, T } from '@threlte/core@next'
  import { WebGPURenderer } from 'three/webgpu'
  let renderMode = $state('manual')
</script>

<Canvas
  {renderMode}
  createRenderer={(canvas) => {
    const renderer = new WebGPURenderer({
      canvas,
      antialias: true,
      forceWebGL: false
    })
    renderer.init().then(() => {
      renderMode = 'on-demand'
    })
    return renderer
  }}
>
  <Scene />
</Canvas>

Vite

WebGPU uses top-level async to determine WebGPU compatibility. Vite will often throw an error when it detects this.

To circumvent this issue, the following can be added to your Vite config.

// vite.config.js
optimizeDeps: {
  esbuildOptions: {
    target: 'esnext'
  }
},
build: {
  target: 'esnext'
}

Alternatively, vite-plugin-top-level-await can be used, although less success has been reported with this method.

TSL

A question that comes up often in Three.js development is “How do I extend Three.js’s materials?“. External libraries such as three-custom-shader-material use a find and replace solution to get this job done. Three.js has identified that it’s not an ideal solution and recommends using the Three.js Shading Language or TSL for short.

The example below is an adaptation of this Three.js example. There are many more TSL examples within Three.js that you can use or adapt for your project.

<script lang="ts">
  import Scene from './Scene.svelte'
  import { Canvas, extend } from '@threlte/core'
  import { Checkbox, Color, Folder, Pane, Slider } from 'svelte-tweakpane-ui'
  import { MathUtils } from 'three'
  import {
    DirectionalLight,
    MeshPhysicalNodeMaterial,
    MeshStandardMaterial,
    WebGPURenderer
  } from 'three/webgpu'

  extend({ DirectionalLight, MeshPhysicalNodeMaterial, MeshStandardMaterial })

  let arcAngleDegrees = $state(90)
  let startAngleDegrees = $state(60)
  let sliceColor = $state('#ff4500')
  let rotate = $state(true)

  const arcAngle = $derived(MathUtils.DEG2RAD * arcAngleDegrees)
  const startAngle = $derived(MathUtils.DEG2RAD * startAngleDegrees)
</script>

<div>
  <Pane
    position="fixed"
    title="slice shader"
  >
    <Checkbox
      bind:value={rotate}
      label="rotate"
    />
    <Folder title="uniforms">
      <Color
        bind:value={sliceColor}
        label="color"
      />
      <Slider
        bind:value={startAngleDegrees}
        min={0}
        max={360}
        step={1}
        label="start angle (degrees)"
      />
      <Slider
        bind:value={arcAngleDegrees}
        min={0}
        max={360}
        step={1}
        label="arc angle (degrees)"
      />
    </Folder>
  </Pane>
  <Canvas
    createRenderer={(canvas) => {
      return new WebGPURenderer({
        antialias: true,
        canvas,
        forceWebGL: false
      })
    }}
  >
    <Scene
      {rotate}
      {arcAngle}
      {sliceColor}
      {startAngle}
    />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import type { Mesh } from 'three/webgpu'
  import { Environment, OrbitControls, useDraco, useGltf } from '@threlte/extras'
  import { T, useTask, useThrelte } from '@threlte/core'
  import { ACESFilmicToneMapping, Color, DoubleSide, Vector3 } from 'three/webgpu'
  import SliceMaterial from './SliceMaterial.svelte'

  type SceneProps = {
    arcAngle: number
    rotate: boolean
    sliceColor: Color
    startAngle: number
  }

  let { arcAngle, rotate, sliceColor, startAngle }: SceneProps = $props()

  const dracoLoader = useDraco()
  const gltf = useGltf<{ nodes: { outerHull: Mesh; axle: Mesh; gears: Mesh }; materials: {} }>(
    '/models/gears.glb',
    { dracoLoader }
  )

  const gearsPosition = new Vector3()

  const { scene, toneMapping } = useThrelte()

  $effect(() => {
    const blurriness = scene.backgroundBlurriness
    const lastToneMapping = toneMapping.current

    scene.backgroundBlurriness = 0.5
    toneMapping.set(ACESFilmicToneMapping)

    return () => {
      scene.backgroundBlurriness = blurriness
      toneMapping.set(lastToneMapping)
    }
  })

  let rotation = $state(0)

  const { start, stop } = useTask(
    (delta) => {
      rotation += 0.1 * delta
    },
    { autoStart: false }
  )

  $effect(() => {
    if (rotate) {
      start()
    } else {
      stop()
    }
  })

  const metalness = 0.5
  const roughness = 0.25
  const envMapIntensity = 0.5
  const color = '#858080'
</script>

<Environment
  url="/textures/equirectangular/hdr/aerodynamics_workshop_1k.hdr"
  isBackground
/>

<T.PerspectiveCamera
  makeDefault
  position.x={-5}
  position.y={5}
  position.z={12}
>
  <OrbitControls enableDamping />
</T.PerspectiveCamera>

<T.DirectionalLight
  castShadow
  intensity={4}
  position.x={6.25}
  position.y={3}
  position.z={4}
  shadow.camera.near={0.1}
  shadow.camera.bottom={-8}
  shadow.camera.far={30}
  shadow.camera.left={-8}
  shadow.camera.normalBias={0.05}
  shadow.camera.right={8}
  shadow.camera.top={8}
  shadow.mapSize.x={2048}
  shadow.mapSize.y={2048}
/>

{#snippet mesh(mesh: Mesh)}
  <T
    is={mesh}
    castShadow
    receiveShadow
  >
    <T.MeshPhysicalMaterial
      {metalness}
      {roughness}
      {envMapIntensity}
      {color}
    />
  </T>
{/snippet}

<T.Group
  position.x={gearsPosition.x}
  position.y={gearsPosition.y}
  position.z={gearsPosition.z}
  rotation.y={rotation}
>
  {#await gltf then { nodes }}
    {@render mesh(nodes.axle)}
    {@render mesh(nodes.gears)}
    <T
      is={nodes.outerHull}
      castShadow
      receiveShadow
    >
      <SliceMaterial
        {arcAngle}
        {startAngle}
        {sliceColor}
        {metalness}
        {roughness}
        {envMapIntensity}
        {color}
        side={DoubleSide}
      />
    </T>
  {/await}
</T.Group>

<T.Mesh
  position.x={-4}
  position.y={-3}
  position.z={-4}
  oncreate={(ref) => {
    ref.lookAt(gearsPosition)
  }}
  scale={10}
  receiveShadow
>
  <T.PlaneGeometry />
  <T.MeshStandardMaterial color={0xaa_aa_aa} />
</T.Mesh>
<script lang="ts">
  import type { SliceMaterialProps } from './types'
  import { T } from '@threlte/core'
  import { color, uniform } from 'three/tsl'
  import { outputNodeFn, shadowNodeFn } from './tsl'

  const defaultStartAngle = 0
  const defaultArcAngle = 0.5 * Math.PI
  const defaultColor = 'black'

  let {
    arcAngle = defaultArcAngle,
    sliceColor = defaultColor,
    startAngle = defaultStartAngle,
    ref = $bindable(),
    children,
    ...props
  }: SliceMaterialProps = $props()

  const uArcAngle = uniform(defaultArcAngle)
  const uColor = uniform(color(defaultColor))
  const uStartAngle = uniform(defaultStartAngle)

  $effect.pre(() => {
    uArcAngle.value = arcAngle
    uColor.value.set(sliceColor)
    uStartAngle.value = startAngle
  })
</script>

<T.MeshPhysicalNodeMaterial
  outputNode={outputNodeFn({ startAngle: uStartAngle, arcAngle: uArcAngle, color: uColor })}
  castShadowNode={shadowNodeFn({ startAngle: uStartAngle, arcAngle: uArcAngle })}
  bind:ref
  {...props}
>
  {@render children?.({ ref })}
</T.MeshPhysicalNodeMaterial>
import type { AngleInputs } from './types'
import type { Node } from 'three/webgpu'
import type { NodeRepresentation, ShaderNodeObject } from 'three/tsl'
import { Fn, If, PI2, atan2, frontFacing, output, positionLocal, vec4 } from 'three/tsl'

const inAngle = Fn(
  ([position, startAngle, endAngle]: [
    ShaderNodeObject<Node>,
    NodeRepresentation,
    NodeRepresentation
  ]) => {
    const angle = atan2(position.y, position.x).sub(startAngle).mod(PI2).toVar()
    return angle.greaterThan(0).and(angle.lessThan(endAngle))
  }
)

export const outputNodeFn = Fn(
  ({ startAngle, arcAngle, color }: AngleInputs & { color: NodeRepresentation }) => {
    inAngle(positionLocal.xy, startAngle, arcAngle).discard()
    const finalOutput = output
    If(frontFacing.not(), () => {
      finalOutput.assign(vec4(color, 1))
    })
    return finalOutput
  }
)

export const shadowNodeFn = Fn(({ startAngle, arcAngle }: AngleInputs) => {
  inAngle(positionLocal.xy, startAngle, arcAngle).discard()
  return vec4(0, 0, 0, 1)
})
import type { Props } from '@threlte/core'
import type {
  ColorRepresentation,
  MeshPhysicalNodeMaterial,
  NodeRepresentation
} from 'three/webgpu'

export type SliceMaterialProps = Props<MeshPhysicalNodeMaterial> & {
  arcAngle?: number
  sliceColor?: ColorRepresentation
  startAngle?: number
}

export type AngleInputs = { startAngle: NodeRepresentation; arcAngle: NodeRepresentation }

Using the <T> catalogue

The <T> component uses all the exports from three. It will error on things like <T.MeshPhysicalNodeMaterial /> because the MeshPhysicalNodeMaterial class is an export of three/webgpu not three. You have a few options to work this out.

  1. Extend <T> with all the definitions from three/webgpu by using the extend function. Adding all of the definitions will increase the bundle size of your application because both three and three/webgpu will be imported in a non-tree-shakeable way.
App.svelte
<script>
  import Scene from './Scene.svelte'
  import { Canvas, extend } from '@threlte/core'
  import * as THREE from 'three/webgpu'

  extend(THREE)
</script>

<Canvas
  createRenderer={(canvas) => {
    return new THREE.WebGPURenderer({
      canvas,
      antialias: true,
      forceWebGL: false
    })
  }}
>
  <Scene />
</Canvas>
  1. Use explicit imports for the objects, functions, and other classes that you use from three/webgpu. You can then use <T>’s is prop with those imports from three/webgpu.
Scene.svelte
<script>
  import { T } from '@threlte/core'
  import { MeshPhysicalNodeMaterial } from 'three/webgpu'

	const material = new MeshPhysicalNodeMaterial()
</script>

<T.Mesh>
	<T.BoxGeometry>
	<T is={material}>
</T.Mesh>
  1. Same as option #2 but using extend with the imports so that you can have <T.MeshPhysicalNodeMaterial /> etc…
App.svelte
<script>
  import Scene from './Scene.svelte'
  import { Canvas, extend } from '@threlte/core'
  import { WebGPURenderer, MeshPhysicalNodeMaterial } from 'three/webgpu'

  extend({ MeshPhysicalNodeMaterial })
</script>

<Canvas
  createRenderer={(canvas) => {
    return new WebGPURenderer({
      canvas,
      antialias: true,
      forceWebGL: false
    })
  }}
>
  <Scene />
</Canvas>
Scene.svelte
<script>
  import { T } from '@threlte/core'
</script>

<T.Mesh>
  <T.BoxGeometry />
  <T.MeshPhysicalNodeMaterial />
</T.Mesh>

Options 2 and 3 will keep the bundle size of your application small but you’ll have to keep it updated as you go.

Careful! three and three/webgpu don’t mix well

You will need to overwrite some of the default <T> catalogue if you use three/webgpu. For example, if you’re using a MeshPhysicalNodeMaterial, you need to update any lighing classes you use like so:

App.svelte
<script>
  import { DirectionalLight, MeshPhysicalNodeMaterial } from 'three/webgpu'

  // tell <T.DirectionalLight> to use the definition from `three/webgpu`
  extend({ MeshPhysicalNodeMaterial, DirectionalLight })
</script>

<Canvas>
  <Scene />
</Canvas>
Scene.svelte
<script>
  import { T } from '@threlte/core'
</script>

<T.DirectionalLight />

<T.Mesh>
  <T.BoxGeometry />
  <T.MeshPhysicalNodeMaterial />
</T.Mesh>

This is because the exports from three/webgpu are different than those in three and make use of the additional features that node materials have.

An easy option for projects is to start with option #1 and then transition to the other options when bundle size becomes an issue or you need to ship to production.

Nodes

The nodes can be directly assigned like any other prop on the <T> component.

<T.MeshPhysicalNodeMaterial
  outputNode={Fn(([arg1, arg2]) => {
    /* ... */
  })(arg1, arg2)}
  shadowNode={Fn(([arg1, arg2]) => {
    /* ... */
  })(arg2, arg2)}
/>

Node materials give you the ability to modify three’s builtin materials. In the sliced gear example, two nodes are modified; the outputNode and the shadowNode. The outputNode is set up in such a way that it discards any fragments that are outside the permitted startAngle and arcAngle. If a fragment is not discarded and it is not front-facing, it is assigned the color in the color uniform. The material needs its side set to THREE.DoubleSide otherwise three.js will cull them out if they are facing away from the camera,

Any fragment that is discarded in the shadowNode will not cast shadows.

Updating Uniforms

If your node uses uniforms, they can be declared in the script tag of the component and updated via $effect or a callback.

For example, if your material uses elapsed time in a uniform, you can update the uniform inside a useTask callback.

The material in the example below demonstrates two ways to update uniforms. The uTime uniform is updated in useTask whereas uIntensity is updated in an $effect.

<script lang="ts">
  import Scene from './Scene.svelte'
  import { Canvas, extend } from '@threlte/core'
  import { Pane, Slider } from 'svelte-tweakpane-ui'
  import { MeshStandardNodeMaterial, WebGPURenderer } from 'three/webgpu'

  extend({ MeshStandardNodeMaterial })

  let emissiveIntensity = $state(0.5)
</script>

<div>
  <Pane
    title="updating uniforms"
    position="fixed"
  >
    <Slider
      bind:value={emissiveIntensity}
      label="emissive intensity"
      max={1}
      min={0}
      step={0.1}
    />
  </Pane>
  <Canvas
    createRenderer={(canvas) => {
      return new WebGPURenderer({
        antialias: true,
        canvas,
        forceWebGL: false
      })
    }}
  >
    <Scene {emissiveIntensity} />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { Fn, cos, float, uniform, vec4 } from 'three/tsl'
  import type { NodeRepresentation } from 'three/tsl'
  import { T, useTask } from '@threlte/core'
  import { useTexture, Environment, OrbitControls } from '@threlte/extras'

  const defaultEmissiveIntensity = 1

  let { emissiveIntensity = defaultEmissiveIntensity }: { emissiveIntensity: number } = $props()

  const uTime = uniform(0)
  const uIntensity = uniform(defaultEmissiveIntensity)

  $effect(() => {
    uIntensity.value = emissiveIntensity
  })

  let positionY = $state(0)

  useTask((delta) => {
    uTime.value += delta
    positionY = 0.1 * Math.sin(uTime.value)
  })

  const red = Fn(([time, intensity]: [NodeRepresentation, NodeRepresentation]) => {
    const r = float(1).add(cos(time)).mul(0.5)
    return vec4(r.mul(intensity), 0, 0, 1)
  })

  const texture = useTexture('/textures/sprites/pixel-sky.png')
</script>

<Environment url="/textures/equirectangular/hdr/industrial_sunset_puresky_1k.hdr" />

<T.PerspectiveCamera
  makeDefault
  position={2}
>
  <OrbitControls />
</T.PerspectiveCamera>

{#await texture then map}
  <T.Mesh
    rotation.z={Math.PI}
    position.y={positionY}
  >
    <T.SphereGeometry />
    <T.MeshStandardNodeMaterial
      {map}
      emissiveNode={red(uTime, uIntensity)}
    />
  </T.Mesh>
{/await}

Note that TSL has an oscSine function that oscillates on time that could also be used in the example above.