Advanced
WebGPU and TSL
WebGPU
@threlte/core ships a webgpu export that mirrors the regular library but
swaps the default renderer for WebGPURenderer, awaits renderer.init()
internally, and points <T> at three/webgpu so node materials (e.g.
<T.MeshPhysicalNodeMaterial />) resolve out of the box.
useThrelte from the webgpu export is also updated to have WebGPURenderer types.
<script>
import Scene from './Scene.svelte'
import { Canvas } from '@threlte/core/webgpu'
</script>
<Canvas>
<Scene />
</Canvas>
<script>
import { T } from '@threlte/core/webgpu'
</script>
<T.Mesh>
<T.BoxGeometry />
<T.MeshPhysicalNodeMaterial />
</T.Mesh>
<script lang="ts">
import { Canvas } from '@threlte/core/webgpu'
import Scene from './Scene.svelte'
</script>
<div>
<Canvas>
<Scene />
</Canvas>
</div>
<style>
div {
height: 100%;
}
</style>
<script lang="ts">
import { T, useTask, useThrelte } from '@threlte/core/webgpu'
import { OrbitControls } from '@threlte/extras'
import Stats from 'three/addons/libs/stats.module.js'
import * as THREE from 'three/webgpu'
const { scene, 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.BundleGroup()
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, demonstrating WebGPU render bundles.
WebGPU is still young and has limited availability across major
browsers. Three.js’s WebGPURenderer will fall back to WebGL
when WebGPU is not available.
Customizing the renderer
To configure the renderer (e.g. force a specific backend), pass a createRenderer
factory. The <Canvas> still awaits init() for you.
<script>
import Scene from './Scene.svelte'
import { Canvas } from '@threlte/core/webgpu'
import { WebGPURenderer } from 'three/webgpu'
</script>
<Canvas
createRenderer={(canvas) => {
return new WebGPURenderer({
canvas,
antialias: true,
forceWebGL: false
})
}}
>
<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.
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 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 that you can use or adapt for your project.
<script lang="ts">
import Scene from './Scene.svelte'
import { Canvas } from '@threlte/core/webgpu'
import { Checkbox, Color, Folder, Pane, Slider } from 'svelte-tweakpane-ui'
import { ACESFilmicToneMapping, MathUtils } from 'three'
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>
<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 toneMapping={ACESFilmicToneMapping}>
<Scene
{rotate}
{arcAngle}
{sliceColor}
{startAngle}
/>
</Canvas>
<script lang="ts">
import SliceMaterial from './SliceMaterial.svelte'
import type { ColorRepresentation, Mesh } from 'three/webgpu'
import { DoubleSide, Group } from 'three/webgpu'
import { Environment, OrbitControls, useDraco, useGltf } from '@threlte/extras'
import { T, useTask, useThrelte } from '@threlte/core/webgpu'
type SceneProps = {
arcAngle: number
rotate: boolean
sliceColor: ColorRepresentation
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 { scene } = useThrelte()
scene.backgroundBlurriness = 0.5
let rotation = $state(0)
useTask(
(delta) => {
rotation += 0.1 * delta
},
{ running: () => rotate }
)
const metalness = 0.5
const roughness = 0.25
const envMapIntensity = 0.5
const color = '#858080'
const group = new Group()
</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
enableZoom={false}
/>
</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
is={group}
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>
<T.Mesh
position.x={-4}
position.y={-3}
position.z={-4}
oncreate={(ref) => {
ref.lookAt(group.position)
}}
scale={10}
receiveShadow
>
<T.PlaneGeometry />
<T.MeshStandardMaterial color={0xaa_aa_aa} />
</T.Mesh>
<script
lang="ts"
module
>
const defaultStartAngle = 0
const defaultArcAngle = 0.5 * Math.PI
const defaultColor = 'black'
</script>
<script lang="ts">
import type { SliceMaterialProps } from './types'
import { T } from '@threlte/core/webgpu'
import { atan, Fn, frontFacing, If, output, PI2, positionLocal, uniform, vec4 } from 'three/tsl'
import { Color } from 'three/webgpu'
let {
arcAngle = defaultArcAngle,
sliceColor = defaultColor,
startAngle = defaultStartAngle,
ref = $bindable(),
...props
}: SliceMaterialProps = $props()
const uArcAngle = uniform(defaultArcAngle)
const uColor = uniform(new Color(defaultColor))
const uStartAngle = uniform(defaultStartAngle)
const angle = atan(positionLocal.y, positionLocal.x).sub(uStartAngle).mod(PI2)
const inAngle = angle.greaterThan(0).and(angle.lessThan(uArcAngle))
const outputNodeFn = Fn(() => {
inAngle.discard()
If(frontFacing.not(), () => {
output.assign(vec4(uColor, 1.0))
})
return output
})
const shadow = vec4(0.0, 0.0, 0.0, 1.0)
const castShadowNodeFn = Fn(() => {
inAngle.discard()
return shadow
})
$effect(() => {
uArcAngle.value = arcAngle
uColor.value.set(sliceColor)
uStartAngle.value = startAngle
})
</script>
<T.MeshPhysicalNodeMaterial
outputNode={outputNodeFn()}
castShadowNode={castShadowNodeFn()}
bind:ref
{...props}
/>
import type { Props } from '@threlte/core'
import type { ColorRepresentation, MeshPhysicalNodeMaterial } from 'three/webgpu'
export type SliceMaterialProps = Props<MeshPhysicalNodeMaterial> & {
arcAngle?: number
sliceColor?: ColorRepresentation
startAngle?: number
}
Nodes
The material’s
nodes
can be directly assigned like any other prop on the <T> component.
<T.MeshPhysicalNodeMaterial
{outputNode}
castShadowNode={Fn(() => {
/* ... */
})()}
/>
Or can create the material in the script tag and use <T>’s is prop to
attach the material.
<script>
const material = new MeshPhysicalNodeMaterial()
material.outputNode = outputNode
material.castShadowNode = Fn(() => {
/* ... */
})()
</script>
<T is={material} />
Node materials give you the ability to modify builtin materials. In the
sliced gear example, two nodes are modified; the outputNode and the
castShadowNode. 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 uColor uniform. The material needs its side set to
THREE.DoubleSide otherwise they will be culled if they are facing
away from the camera. Any fragment that is discarded in the shadowNode will not
cast shadows.