@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))
}))