threlte logo
@threlte/extras

useTrailTexture

A hook that creates a canvas-based trail texture driven by pointer movement. The texture can be used as a displacement map, alpha map, or in any other way a Texture can be used.

<script lang="ts">
  import { Canvas } from '@threlte/core'
  import Scene from './Scene.svelte'
  import { Pane, Folder, Slider, List } from 'svelte-tweakpane-ui'
  import * as easings from 'svelte/easing'

  let size = $state(64)
  let maxAge = $state(750)
  let radius = $state(0.3)
  let intensity = $state(0.2)
  let interpolate = $state(0)
  let smoothing = $state(0)
  let minForce = $state(0.3)
  let amount = $state(0.1)
  let easeName = $state<keyof typeof easings>('circOut')

  const easingOptions: Record<string, keyof typeof easings> = {
    linear: 'linear',
    circOut: 'circOut',
    cubicOut: 'cubicOut',
    quadOut: 'quadOut',
    expoOut: 'expoOut',
    elasticOut: 'elasticOut',
    bounceOut: 'bounceOut'
  }

  const ease = $derived(easings[easeName])
</script>

<div>
  <Pane
    position="fixed"
    title=""
  >
    <Slider
      label="size"
      bind:value={size}
      min={8}
      max={256}
      step={8}
    />
    <Slider
      label="maxAge"
      bind:value={maxAge}
      min={300}
      max={1000}
      step={50}
    />
    <Slider
      label="radius"
      bind:value={radius}
      min={0}
      max={1}
      step={0.01}
    />
    <Slider
      label="intensity"
      bind:value={intensity}
      min={0}
      max={1}
      step={0.1}
    />
    <Slider
      label="interpolate"
      bind:value={interpolate}
      min={0}
      max={2}
      step={1}
    />
    <Slider
      label="smoothing"
      bind:value={smoothing}
      min={0}
      max={0.99}
      step={0.01}
    />
    <Slider
      label="minForce"
      bind:value={minForce}
      min={0}
      max={1}
      step={0.1}
    />
    <List
      label="ease"
      bind:value={easeName}
      options={easingOptions}
    />
    <Folder title="Displacement">
      <Slider
        label="amount"
        bind:value={amount}
        min={0}
        max={0.5}
        step={0.01}
      />
    </Folder>
  </Pane>
  <Canvas>
    <Scene
      {size}
      {maxAge}
      {radius}
      {intensity}
      {interpolate}
      {smoothing}
      {minForce}
      {amount}
      {ease}
    />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
    background-color: #20222b;
  }
</style>
<script lang="ts">
  import { T } from '@threlte/core'
  import { useTrailTexture, interactivity } from '@threlte/extras'
  import { ShaderMaterial, Color, DoubleSide, type Texture } from 'three'

  interactivity()

  let {
    size = 64,
    maxAge = 750,
    radius = 0.3,
    intensity = 0.2,
    interpolate = 0,
    smoothing = 0,
    minForce = 0.3,
    amount = 0.1,
    ease
  }: {
    size?: number
    maxAge?: number
    radius?: number
    intensity?: number
    interpolate?: number
    smoothing?: number
    minForce?: number
    amount?: number
    ease?: (t: number) => number
  } = $props()

  const { texture, onPointerMove } = useTrailTexture(() => ({
    size,
    radius,
    maxAge,
    intensity,
    interpolate,
    smoothing,
    minForce,
    ease
  }))

  function createMaterial(map: Texture) {
    return new ShaderMaterial({
      uniforms: {
        map: { value: map },
        color: { value: new Color('turquoise') },
        color2: { value: new Color('magenta') },
        amount: { value: amount }
      },
      vertexShader: `
        uniform sampler2D map;
        uniform float amount;
        varying float vDisplace;
        void main() {
          float displace = texture2D(map, uv).r;
          vDisplace = displace;
          vec3 pos = position;
          pos.z += displace * amount;
          gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
        }
      `,
      fragmentShader: `
        uniform vec3 color;
        uniform vec3 color2;
        varying float vDisplace;
        void main() {
          vec3 col = mix(color, color2, vDisplace);
          gl_FragColor = vec4(col, 1.0);
        }
      `,
      wireframe: true,
      side: DoubleSide
    })
  }

  const material = createMaterial(texture)

  $effect(() => {
    material.uniforms.amount!.value = amount
  })
</script>

<T.PerspectiveCamera
  makeDefault
  position={[0, 0, 2.5]}
  fov={45}
/>

<T.Group rotation.x={-Math.PI * 0.3}>
  <T.Mesh
    rotation.z={Math.PI * 0.2}
    onpointermove={onPointerMove}
  >
    <T.PlaneGeometry args={[2, 2, 32, 32]} />
    <T is={material} />
  </T.Mesh>
</T.Group>
<script lang="ts">
  import { Canvas } from '@threlte/core'
  import Scene from './Scene.svelte'
  import { Pane, Slider, List } from 'svelte-tweakpane-ui'
  import * as easings from 'svelte/easing'

  let size = $state(256)
  let maxAge = $state(5000)
  let radius = $state(0.05)
  let intensity = $state(1)
  let interpolate = $state(2)
  let smoothing = $state(0.9)
  let minForce = $state(0)
  let easeName = $state<keyof typeof easings>('circOut')

  const easingOptions: Record<string, keyof typeof easings> = {
    linear: 'linear',
    circOut: 'circOut',
    cubicOut: 'cubicOut',
    quadOut: 'quadOut',
    expoOut: 'expoOut',
    elasticOut: 'elasticOut',
    bounceOut: 'bounceOut'
  }

  const ease = $derived(easings[easeName])
</script>

<div>
  <Pane
    position="fixed"
    title=""
  >
    <Slider
      label="size"
      bind:value={size}
      min={8}
      max={256}
      step={8}
    />
    <Slider
      label="maxAge"
      bind:value={maxAge}
      min={300}
      max={5000}
      step={100}
    />
    <Slider
      label="radius"
      bind:value={radius}
      min={0}
      max={1}
      step={0.01}
    />
    <Slider
      label="intensity"
      bind:value={intensity}
      min={0}
      max={1}
      step={0.1}
    />
    <Slider
      label="interpolate"
      bind:value={interpolate}
      min={0}
      max={5}
      step={1}
    />
    <Slider
      label="smoothing"
      bind:value={smoothing}
      min={0}
      max={0.99}
      step={0.01}
    />
    <Slider
      label="minForce"
      bind:value={minForce}
      min={0}
      max={1}
      step={0.1}
    />
    <List
      label="ease"
      bind:value={easeName}
      options={easingOptions}
    />
  </Pane>

  <Canvas>
    <Scene
      {size}
      {maxAge}
      {radius}
      {intensity}
      {interpolate}
      {smoothing}
      {minForce}
      {ease}
    />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
    background-color: rgb(24 24 27);
  }
</style>
<script lang="ts">
  import { T, useTask, isInstanceOf } from '@threlte/core'
  import { useTrailTexture, useTexture, transitions, createTransition } from '@threlte/extras'
  import { cubicInOut } from 'svelte/easing'
  import { SimplexNoise } from 'three/examples/jsm/Addons.js'

  transitions()

  const paintings = [
    '/textures/paintings/klimt.jpg',
    '/textures/paintings/vangogh.jpg',
    '/textures/paintings/caravaggio.jpg',
    '/textures/paintings/swan.jpg'
  ]

  const allLoaded = Promise.all(paintings.map((src) => useTexture(src)))

  let {
    size = 256,
    maxAge = 3500,
    radius = 0.2,
    intensity = 1,
    interpolate = 2,
    smoothing = 0.9,
    minForce = 0.3,
    ease
  }: {
    size?: number
    maxAge?: number
    radius?: number
    intensity?: number
    interpolate?: number
    smoothing?: number
    minForce?: number
    ease?: (t: number) => number
  } = $props()

  const { texture: trailTexture, setTrail } = useTrailTexture(() => ({
    size,
    radius,
    maxAge,
    intensity,
    interpolate,
    smoothing,
    minForce,
    ease
  }))

  const fade = createTransition((ref) => {
    if (!isInstanceOf(ref, 'Material')) return
    ref.transparent = true
    ref.needsUpdate = true
    return {
      duration: 1500,
      easing: cubicInOut,
      tick: (t) => {
        ref.opacity = t
      }
    }
  })

  const noise = new SimplexNoise()

  let index = $state(0)
  let elapsed = 0
  const swapInterval = 6
  let time = 0

  useTask((delta) => {
    time += delta * 0.5

    const x = 0.5 + noise.noise(time, 0) * 0.4
    const y = 0.5 + noise.noise(0, time) * 0.4
    setTrail(x, y)

    elapsed += delta
    if (elapsed >= swapInterval) {
      elapsed = 0
      index = (index + 1) % paintings.length
    }
  })

  let fgIndex = $derived((index + 1) % paintings.length)
</script>

<T.PerspectiveCamera
  makeDefault
  position={[0, 0, 1.8]}
  fov={45}
/>

{#await allLoaded then maps}
  {#key index}
    <T.Mesh>
      <T.PlaneGeometry args={[1.6, 1.6]} />
      <T.MeshBasicMaterial
        map={maps[index]}
        transparent
        transition={fade}
      />
    </T.Mesh>
  {/key}

  {#key fgIndex}
    <T.Mesh position.z={0.001}>
      <T.PlaneGeometry args={[1.6, 1.6]} />
      <T.MeshBasicMaterial
        map={maps[fgIndex]}
        transparent
        alphaMap={trailTexture}
        transition={fade}
      />
    </T.Mesh>
  {/key}

  <!-- Trail-revealed next painting on top -->
{/await}

Usage

Pass the returned onPointerMove handler to a mesh and use the texture as a displacement map:

<script lang="ts">
  import { T } from '@threlte/core'
  import { useTrailTexture, interactivity } from '@threlte/extras'

  interactivity()

  const { texture, onPointerMove } = useTrailTexture(() => ({
    size: 512,
    radius: 0.3,
    maxAge: 750,
    intensity: 0.4
  }))
</script>

<T.Mesh onpointermove={onPointerMove}>
  <T.PlaneGeometry args={[3, 3, 128, 128]} />
  <T.MeshStandardMaterial
    displacementMap={texture}
    displacementScale={0.3}
  />
</T.Mesh>

Programmatic Usage

Use setTrail to drive the trail from any source, not just pointer events.

<script lang="ts">
  import { Canvas } from '@threlte/core'
  import Scene from './Scene.svelte'
  import { Pane, Slider, List } from 'svelte-tweakpane-ui'
  import * as easings from 'svelte/easing'

  let size = $state(256)
  let maxAge = $state(5000)
  let radius = $state(0.05)
  let intensity = $state(1)
  let interpolate = $state(2)
  let smoothing = $state(0.9)
  let minForce = $state(0)
  let easeName = $state<keyof typeof easings>('circOut')

  const easingOptions: Record<string, keyof typeof easings> = {
    linear: 'linear',
    circOut: 'circOut',
    cubicOut: 'cubicOut',
    quadOut: 'quadOut',
    expoOut: 'expoOut',
    elasticOut: 'elasticOut',
    bounceOut: 'bounceOut'
  }

  const ease = $derived(easings[easeName])
</script>

<div>
  <Pane
    position="fixed"
    title=""
  >
    <Slider
      label="size"
      bind:value={size}
      min={8}
      max={256}
      step={8}
    />
    <Slider
      label="maxAge"
      bind:value={maxAge}
      min={300}
      max={5000}
      step={100}
    />
    <Slider
      label="radius"
      bind:value={radius}
      min={0}
      max={1}
      step={0.01}
    />
    <Slider
      label="intensity"
      bind:value={intensity}
      min={0}
      max={1}
      step={0.1}
    />
    <Slider
      label="interpolate"
      bind:value={interpolate}
      min={0}
      max={5}
      step={1}
    />
    <Slider
      label="smoothing"
      bind:value={smoothing}
      min={0}
      max={0.99}
      step={0.01}
    />
    <Slider
      label="minForce"
      bind:value={minForce}
      min={0}
      max={1}
      step={0.1}
    />
    <List
      label="ease"
      bind:value={easeName}
      options={easingOptions}
    />
  </Pane>

  <Canvas>
    <Scene
      {size}
      {maxAge}
      {radius}
      {intensity}
      {interpolate}
      {smoothing}
      {minForce}
      {ease}
    />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
    background-color: rgb(24 24 27);
  }
</style>
<script lang="ts">
  import { T, useTask, isInstanceOf } from '@threlte/core'
  import { useTrailTexture, useTexture, transitions, createTransition } from '@threlte/extras'
  import { cubicInOut } from 'svelte/easing'
  import { SimplexNoise } from 'three/examples/jsm/Addons.js'

  transitions()

  const paintings = [
    '/textures/paintings/klimt.jpg',
    '/textures/paintings/vangogh.jpg',
    '/textures/paintings/caravaggio.jpg',
    '/textures/paintings/swan.jpg'
  ]

  const allLoaded = Promise.all(paintings.map((src) => useTexture(src)))

  let {
    size = 256,
    maxAge = 3500,
    radius = 0.2,
    intensity = 1,
    interpolate = 2,
    smoothing = 0.9,
    minForce = 0.3,
    ease
  }: {
    size?: number
    maxAge?: number
    radius?: number
    intensity?: number
    interpolate?: number
    smoothing?: number
    minForce?: number
    ease?: (t: number) => number
  } = $props()

  const { texture: trailTexture, setTrail } = useTrailTexture(() => ({
    size,
    radius,
    maxAge,
    intensity,
    interpolate,
    smoothing,
    minForce,
    ease
  }))

  const fade = createTransition((ref) => {
    if (!isInstanceOf(ref, 'Material')) return
    ref.transparent = true
    ref.needsUpdate = true
    return {
      duration: 1500,
      easing: cubicInOut,
      tick: (t) => {
        ref.opacity = t
      }
    }
  })

  const noise = new SimplexNoise()

  let index = $state(0)
  let elapsed = 0
  const swapInterval = 6
  let time = 0

  useTask((delta) => {
    time += delta * 0.5

    const x = 0.5 + noise.noise(time, 0) * 0.4
    const y = 0.5 + noise.noise(0, time) * 0.4
    setTrail(x, y)

    elapsed += delta
    if (elapsed >= swapInterval) {
      elapsed = 0
      index = (index + 1) % paintings.length
    }
  })

  let fgIndex = $derived((index + 1) % paintings.length)
</script>

<T.PerspectiveCamera
  makeDefault
  position={[0, 0, 1.8]}
  fov={45}
/>

{#await allLoaded then maps}
  {#key index}
    <T.Mesh>
      <T.PlaneGeometry args={[1.6, 1.6]} />
      <T.MeshBasicMaterial
        map={maps[index]}
        transparent
        transition={fade}
      />
    </T.Mesh>
  {/key}

  {#key fgIndex}
    <T.Mesh position.z={0.001}>
      <T.PlaneGeometry args={[1.6, 1.6]} />
      <T.MeshBasicMaterial
        map={maps[fgIndex]}
        transparent
        alphaMap={trailTexture}
        transition={fade}
      />
    </T.Mesh>
  {/key}

  <!-- Trail-revealed next painting on top -->
{/await}
<script lang="ts">
  import { useTask } from '@threlte/core'
  import { useTrailTexture } from '@threlte/extras'

  const { texture, setTrail } = useTrailTexture()

  let time = 0
  useTask((delta) => {
    time += delta
    const x = 0.5 + Math.cos(time) * 0.3
    const y = 0.5 + Math.sin(time) * 0.3
    setTrail(x, y)
  })
</script>

Options

const {
  // CanvasTexture updated every frame with the trail
  texture,
  // Event handler to pass to onpointermove
  onPointerMove,
  // Programmatically add a trail point at UV coordinates
  setTrail
} = useTrailTexture(() => ({
  // Texture resolution in pixels (default: 256)
  size: 256,
  // Max lifetime of trail points in ms (default: 750)
  maxAge: 750,
  // Radius of the trail brush, 0-1 (default: 0.3)
  radius: 0.3,
  // Opacity of trail points, 0-1 (default: 0.2)
  intensity: 0.2,
  // Interpolated points between sparse pointer events (default: 0)
  interpolate: 0,
  // Moving average smoothing factor for pointer force (default: 0)
  smoothing: 0,
  // Minimum pointer force threshold (default: 0.3)
  minForce: 0.3,
  // Canvas blend mode (default: 'screen')
  blend: 'screen' as GlobalCompositeOperation,
  // Easing function for intensity falloff (default: easeCircleOut)
  // Compatible with svelte/easing functions, e.g. cubicOut
  ease: (t: number) => Math.sqrt(1 - Math.pow(t - 1, 2))
}))