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

WobblingTorus.svelte
<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:

AnchoredPlant.svelte
<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>

Component Signature

Props

name
type
required
default
description

anchor
number
no
Position along `axis` where the mesh stays anchored. Wobble amplitude scales with each vertex distance from this plane. When omitted, every vertex wobbles equally.

axis
Vector3Tuple
no
[0, 1, 0]
The wobble "up" direction in local geometry space. Twist happens around this axis; bend tilts perpendicular to it.

bendiness
number
no
0
Blends from an axial twist (0) to a directional bend pivoted at the anchor (1).

drift
number
no
0
Bend-direction wander. 0 holds the bend direction steady; 1 lets it sweep the full circle over time. Only used when `bendiness > 0` and `forceDirection` is unset.

factor
number
no
1
How strongly vertices are displaced. Roughly the maximum rotation angle (in radians) at full weight.

forceDirection
Vector3Tuple
no
Direction the bend leans toward, in local geometry space. The component along `axis` is projected out. When omitted, the direction drifts on its own (see `drift`).

frequency
number
no
1
How tight the per-vertex wobble pattern is. Increase for large geometry, decrease for small. Default is tuned for ~1–3 unit meshes.

material
Material | Material[]
no
The material to wobble. When omitted, the parent mesh material (read once at mount) is patched. Pass a bound material ref to react to runtime material swaps.

noise
number
no
0
Per-vertex variation. 0 keeps every vertex in lockstep on a clean sine; 1 has neighbours fall out of phase from a noise field.

pulse
number
no
0
Amplitude pulsation. 0 keeps the wobble steady; 1 lets the whole mesh swell and fade over time.

shadow
boolean
no
true
Whether the shadow silhouette follows the wobble via attached custom depth/distance materials. Set to false to skip — useful if the mesh does not cast shadows, or you manage those materials yourself.

speed
number
no
1
How fast the wobble animates. Ignored when `time` is provided.

time
number
no
Drive the clock yourself, in seconds. When set, `speed` is ignored. Useful for syncing many wobbles to one timeline or scrubbing through poses.