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.
<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:
<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.
- Extend
<T>
with all the definitions fromthree/webgpu
by using theextend
function. Adding all of the definitions will increase the bundle size of your application because boththree
andthree/webgpu
will be imported in a non-tree-shakeable way.
<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>
- Use explicit imports for the objects, functions, and other classes that you use from
three/webgpu
. You can then use<T>
’sis
prop with those imports fromthree/webgpu
.
<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>
- Same as option #2 but using
extend
with the imports so that you can have<T.MeshPhysicalNodeMaterial />
etc…
<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>
<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.
three
and three/webgpu
don’t mix well
Careful! 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:
<script>
import { DirectionalLight, MeshPhysicalNodeMaterial } from 'three/webgpu'
// tell <T.DirectionalLight> to use the definition from `three/webgpu`
extend({ MeshPhysicalNodeMaterial, DirectionalLight })
</script>
<Canvas>
<Scene />
</Canvas>
<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.