Advanced
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.
TSL is written against node materials, which Threlte reaches through the
webgpu export of @threlte/core — see
WebGPU for setting that up.
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.