@threlte/extras
<Wobble>
<Wobble> adds a time-varying vertex displacement to whatever material is already on its parent
mesh. Because it patches the material rather than replacing it, you can wobble any
MeshStandardMaterial, MeshPhysicalMaterial, MeshToonMaterial, etc. —
pick the look you want, then drop <Wobble> in to make it move.
<script lang="ts">
import { Canvas } from '@threlte/core'
import { Pane, Slider, Point, List, Checkbox, Folder } from 'svelte-tweakpane-ui'
import Scene from './Scene.svelte'
type Subject = 'plant' | 'orb' | 'flowers'
type Vec3 = [number, number, number]
interface Preset {
speed: number
factor: number
frequency: number
noise: number
pulse: number
drift: number
bendiness: number
axis: Vec3
anchorEnabled: boolean
anchor: number
forceDirectionEnabled: boolean
forceDirection: Vec3
timeEnabled: boolean
time: number
}
const defaults: Omit<
Preset,
'speed' | 'factor' | 'noise' | 'pulse' | 'drift' | 'bendiness' | 'anchorEnabled' | 'anchor'
> = {
frequency: 1,
axis: [0, 1, 0],
forceDirectionEnabled: false,
forceDirection: [1, 0, 0],
timeEnabled: false,
time: 0
}
const presets: Record<Subject, Preset> = {
plant: {
...defaults,
speed: 2.5,
factor: 0.3,
noise: 0.4,
pulse: 0.4,
drift: 0.4,
bendiness: 0.4,
anchorEnabled: true,
anchor: 0.76
},
orb: {
...defaults,
speed: 2.5,
factor: 3,
noise: 0.1,
pulse: 0.1,
drift: 0.1,
bendiness: 0.5,
anchorEnabled: false,
anchor: 0
},
flowers: {
...defaults,
speed: 5,
factor: 3,
noise: 0.75,
pulse: 0.75,
drift: 0.75,
bendiness: 1,
anchorEnabled: true,
anchor: 0
}
}
let subject = $state<Subject>('plant')
let options = $state(presets.plant)
$effect(() => {
options = presets[subject]
})
</script>
<div>
<Canvas>
<Scene
{subject}
{...options}
anchor={options.anchorEnabled ? options.anchor : undefined}
forceDirection={options.forceDirectionEnabled ? options.forceDirection : undefined}
time={options.timeEnabled ? options.time : undefined}
/>
</Canvas>
</div>
<Pane
title="Wobble"
position="fixed"
>
<List
bind:value={subject}
label="subject"
options={{ Plant: 'plant', Orb: 'orb', Flowers: 'flowers' }}
/>
<Slider
bind:value={options.speed}
label="speed"
min={0}
max={5}
step={0.01}
/>
<Slider
bind:value={options.factor}
label="factor"
min={0}
max={3}
step={0.01}
/>
<Slider
bind:value={options.frequency}
label="frequency"
min={0.1}
max={5}
step={0.01}
/>
<Slider
bind:value={options.noise}
label="noise"
min={0}
max={1}
step={0.01}
/>
<Slider
bind:value={options.pulse}
label="pulse"
min={0}
max={1}
step={0.01}
/>
<Slider
bind:value={options.drift}
label="drift"
min={0}
max={1}
step={0.01}
/>
<Slider
bind:value={options.bendiness}
label="bendiness"
min={0}
max={1}
step={0.01}
/>
<Point
bind:value={options.axis}
label="axis"
min={-1}
max={1}
step={0.01}
/>
<Folder title="anchor">
<Checkbox
bind:value={options.anchorEnabled}
label="enabled"
/>
<Slider
bind:value={options.anchor}
label="along axis"
min={-2}
max={4}
step={0.01}
disabled={!options.anchorEnabled}
/>
</Folder>
<Folder title="forceDirection">
<Checkbox
bind:value={options.forceDirectionEnabled}
label="enabled"
/>
<Point
bind:value={options.forceDirection}
label="xyz"
min={-1}
max={1}
step={0.01}
disabled={!options.forceDirectionEnabled}
/>
</Folder>
<Folder title="time">
<Checkbox
bind:value={options.timeEnabled}
label="external"
/>
<Slider
bind:value={options.time}
label="seconds"
min={0}
max={30}
step={0.01}
disabled={!options.timeEnabled}
/>
</Folder>
</Pane>
<style>
div {
height: 100%;
}
</style>
<script lang="ts">
import type { Mesh, MeshStandardMaterial } from 'three'
import { T } from '@threlte/core'
import {
Wobble,
Environment,
Instance,
InstancedMesh,
OrbitControls,
RadialGradientTexture,
useGltf,
SoftShadows,
Wireframe
} from '@threlte/extras'
interface Plant {
nodes: {
concrete_pot_lambert3_0: Mesh
plant_lambert2_0: Mesh
}
materials: {
lambert2: MeshStandardMaterial
lambert3: MeshStandardMaterial
}
}
interface Flower {
nodes: {
Blossom: Mesh
Stem: Mesh
}
materials: any
}
let {
subject = 'plant',
speed = 1,
factor = 0.5,
frequency = 1,
noise = 0,
pulse = 0,
drift = 0,
bendiness = 0,
axis = [0, 1, 0],
anchor,
forceDirection,
time
}: {
subject?: 'plant' | 'orb' | 'flowers'
speed?: number
factor?: number
frequency?: number
noise?: number
pulse?: number
drift?: number
bendiness?: number
axis?: [number, number, number]
anchor?: number
forceDirection?: [number, number, number]
time?: number
} = $props()
const plantGltf = useGltf<Plant>('/models/rhyzome_plant-baked.glb')
const flowerGltf = useGltf<Flower>('/models/Flower.glb')
// Scattered flower placements.
const flowerPlacements = Array.from({ length: 20 }, (_, i) => {
const angle = (i / 10) * Math.PI * 2 + Math.random() * 0.4
const radius = 0.3 + Math.random()
return {
x: Math.cos(angle) * radius,
z: Math.sin(angle) * radius,
scale: 2 + Math.random() * 1.5,
rotation: Math.random() * Math.PI * 2
}
})
</script>
<T.PerspectiveCamera
makeDefault
position={[0, 7, 7]}
fov={35}
/>
<OrbitControls
enableDamping
enableZoom={false}
target.y={1.7}
/>
<T.DirectionalLight
position={[1, 5, 1]}
intensity={4}
castShadow
shadow.mapSize.width={1024}
shadow.mapSize.height={1024}
shadow.camera.left={-4}
shadow.camera.right={4}
shadow.camera.top={4}
shadow.camera.bottom={-4}
shadow.camera.near={0.5}
shadow.camera.far={20}
/>
<Environment url="/textures/equirectangular/hdr/industrial_sunset_puresky_1k.hdr" />
<SoftShadows
size={10}
samples={10}
focus={1.5}
/>
<T.Mesh
rotation.x={-Math.PI / 2}
receiveShadow
>
<T.CircleGeometry args={[6, 64]} />
<T.MeshStandardMaterial
transparent
roughness={0}
>
<RadialGradientTexture
outerRadius={256}
stops={[
{ offset: 0, color: 'white' },
{ offset: 0.7, color: 'rgba(255, 255, 255, 0)' }
]}
/>
</T.MeshStandardMaterial>
</T.Mesh>
{#if subject === 'plant' && $plantGltf}
<T.Mesh
castShadow
receiveShadow
>
<T is={$plantGltf.nodes.concrete_pot_lambert3_0.geometry} />
<T is={$plantGltf.materials.lambert3} />
</T.Mesh>
<T.Mesh
castShadow
receiveShadow
>
<T is={$plantGltf.nodes.plant_lambert2_0.geometry} />
<T
is={$plantGltf.materials.lambert2}
roughness={0.4}
/>
<Wobble
{speed}
{factor}
{frequency}
{noise}
{pulse}
{drift}
{bendiness}
{axis}
{anchor}
{forceDirection}
{time}
/>
</T.Mesh>
{:else if subject === 'orb'}
<T.Mesh
position.y={1.5}
castShadow
receiveShadow
>
<T.SphereGeometry args={[1, 32, 32]} />
<T.MeshStandardMaterial
color="#ff7755"
roughness={0.1}
/>
<Wobble
{speed}
{factor}
{frequency}
{noise}
{pulse}
{drift}
{bendiness}
{axis}
{anchor}
{forceDirection}
{time}
/>
<Wireframe />
</T.Mesh>
{:else if subject === 'flowers' && $flowerGltf}
<InstancedMesh
castShadow
receiveShadow
limit={flowerPlacements.length}
>
<T is={$flowerGltf.nodes.Stem.geometry} />
<T.MeshStandardMaterial color="#3d7a3a" />
<Wobble
{speed}
{factor}
{frequency}
{noise}
{pulse}
{drift}
{bendiness}
{axis}
{anchor}
{forceDirection}
{time}
/>
{#each flowerPlacements as f}
<Instance
position.x={f.x}
position.z={f.z}
scale={f.scale}
rotation.y={f.rotation}
/>
{/each}
</InstancedMesh>
<InstancedMesh
castShadow
receiveShadow
limit={flowerPlacements.length}
>
<T is={$flowerGltf.nodes.Blossom.geometry} />
<T.MeshStandardMaterial color="#ff5599" />
<Wobble
{speed}
{factor}
{frequency}
{noise}
{pulse}
{drift}
{bendiness}
{axis}
{anchor}
{forceDirection}
{time}
/>
{#each flowerPlacements as f}
<Instance
position.x={f.x}
position.z={f.z}
scale={f.scale}
rotation.y={f.rotation}
/>
{/each}
</InstancedMesh>
{/if}
Rhyzome Plant by Blizzy
Examples
Basic Example
<script lang="ts">
import { T } from '@threlte/core'
import { Wobble } from '@threlte/extras'
</script>
<T.Mesh>
<T.TorusGeometry />
<T.MeshStandardMaterial color="#ff6b6b" />
<Wobble
speed={2}
factor={2}
/>
</T.Mesh>
The wobble is computed per-vertex, so meshes need enough geometric detail for the deformation to look smooth.
Anchoring the base
Pass anchor to pin a plane that stays put while everything else wobbles around it. Amplitude
grows with distance from that plane — what you want for a plant whose base shouldn’t slide
around in its pot:
<T.Mesh castShadow>
<T is={plantGeometry} />
<T.MeshStandardMaterial color="#4a7a3a" />
<Wobble
speed={1}
factor={0.5}
anchor={plantBaseY}
/>
</T.Mesh>
Shadows
Shadows wobble alongside the visible mesh — <Wobble> attaches matching customDepthMaterial
and customDistanceMaterial to the parent so the shadow silhouette tracks the deformation.
Set shadow to false to disable this.
<Wobble shadow={false} />
Pointing the bend with forceDirection
When bendiness > 0, setting forceDirection produces a bend from a specific side:
<Wobble
factor={1}
bendiness={1}
forceDirection={[1, 0, 0]}
/>
Sideways objects
Default axis is [0, 1, 0] — Y-up. Pass a different axis for models whose “up” is along X or
Z:
<T.Mesh>
<T is={vineGeometry} />
<T.MeshStandardMaterial />
<Wobble
axis={[1, 0, 0]}
bendiness={1}
factor={0.4}
/>
</T.Mesh>
Tuning frequency for geometry size
The default frequency is tuned for ~1–3 unit meshes. On much larger geometry the wobble looks
uniform across the whole shape; on tiny ones it gets too chaotic. Adjust to taste:
<!-- Tall building-sized mesh -->
<Wobble
factor={1}
frequency={0.1}
/>
Driving time externally
Provide time (in seconds) to drive the clock yourself. Useful for syncing many <Wobble>s to
a single timeline, scrubbing back and forth, or pausing:
<script lang="ts">
let clock = $state(0)
// ...your own time source...
</script>
<T.Mesh>
<T.SphereGeometry />
<T.MeshStandardMaterial />
<Wobble
time={clock}
factor={1}
/>
</T.Mesh>
Reacting to material swaps
By default <Wobble> patches the parent mesh’s material once when it mounts. If you swap
materials at runtime, pass it in:
<script lang="ts">
let { material } = $props()
</script>
<T.Mesh>
<T.SphereGeometry />
<T is={material} />
<Wobble
{material}
speed={2}
factor={1}
/>
</T.Mesh>