threlte logo
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.