Files
RUWIO/src/lib/wiggle.ts
T
thandClaude Opus 4.7 9e25cf155a 1.2.10: wiggle — deterministic procedural shake modifier per layer
Per-layer modifier that adds noise to the sampled position and
rotation each frame. Pairs thematically with kaleido: both stack
on top of the keyframe sample, both per-layer, both deterministic
so editor and exported runtime render identically.

Math (src/lib/wiggle.ts):
- hash32 + layerSeeds(layerId): three uncorrelated 32-bit seeds per
  layer, derived from the id so the same layer always wobbles the
  same way.
- wiggleNoise(seed, t): sum-of-three-sines in [-1, 1]. Smooth (C∞),
  cheap, no noise-lib dependency. Phase constants are irrational
  ratios picked so the streams don't collapse into a low-period
  pattern.
- applyWiggle(props, layer, time): adds amp * noise(seed, t*freq)
  to x / y / rotation. Defaults: 8px position, 2° rotation, 2Hz.

Wire-in:
- src/lib/interpolate.ts sampleLayer: post-processes the keyframe
  sample with applyWiggle. Every renderer goes through sampleLayer
  (canvas2d, Konva, button-state, clip-internal), so the modifier
  lights up everywhere from a single hook.
- src/lib/exportPlayer.ts engine: byte-for-byte JS mirror of
  hash32 / layerSeeds / wiggleNoise / applyWiggle, plus the same
  post-process branch in the engine's sample(). RUNTIME_VERSION
  31 → 32.

Data + store:
- Layer.wiggle?: { amplitude?, rotation?, frequency? }. All sub-
  fields optional with the documented defaults.
- store.setLayerWiggle(id, patch | null): patch object updates
  individual fields with magnitude clamping (≥ 0); null deletes
  the whole wiggle field.

UI (PropertiesPanel + i18n):
- New "Wiggle" section between Mask and Kaleidoscope. Toggle
  (PhWaveSine icon) seeds the pleasant defaults; three RangeSliders
  for position amplitude (0-64 px), rotation amplitude (0-45°),
  and frequency (0-10 Hz). Hidden for button-asset layers
  (consistent with Kaleido's hit-area constraint).

Tests (8 new under "wiggle — procedural shake modifier"):
- wiggleNoise determinism + [-1, 1] bounds across 4 seeds × ~100
  time points.
- layerSeeds stability + the three seeds are pairwise distinct;
  different ids yield different triples.
- applyWiggle on a layer without `wiggle` is a no-op (returns the
  input reference unchanged).
- sampleLayer with wiggle: drift visible across a 1s window and
  always within the configured amplitude bands.
- sampleLayer with wiggle is deterministic — same time, same drift.
- setLayerWiggle roundtrip + magnitude clamp + null-delete.
- PropertiesPanel Wiggle toggle writes the wiggle field with the
  documented defaults.
- Exported runtime carries wiggleNoise / applyWiggle / wiggleSeeds
  and bumps RUNTIME_VERSION to '32'.

Plan for the next two backlog items (motion-path overlay + canvas
eyedropper) is in claude.txt for later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:24:23 +02:00

84 lines
2.8 KiB
TypeScript

// Procedural shake modifier — adds deterministic noise to a layer's
// sampled props so it visibly "wobbles" over time. The same time
// always yields the same value (so the editor stage, render.ts
// renderer and exported runtime stay in lockstep), and three
// independent noise streams (x / y / rotation) keep the wobble from
// looking like a single 1D oscillation.
//
// Mirrored as plain JS inside exportPlayer.ts's engine source —
// keep the formulas in `wiggleNoise` and `applyWiggle` byte-for-byte
// equivalent on both sides.
import type { AnimatableProps, Layer } from '@/types/scene'
const DEFAULT_AMPLITUDE = 8
const DEFAULT_ROTATION = 2
const DEFAULT_FREQUENCY = 2
/** Stable 32-bit hash of a string. djb2-ish — good enough for
* picking three uncorrelated seeds per layer. */
function hash32(s: string): number {
let h = 5381
for (let i = 0; i < s.length; i++) {
h = ((h << 5) + h + s.charCodeAt(i)) | 0
}
return h >>> 0
}
/** Three independent seeds for the layer's x, y, rotation noise
* streams. Derived deterministically from the layer id so the same
* layer always wobbles the same way. */
export function layerSeeds(layerId: string): [number, number, number] {
const h = hash32(layerId)
return [
(h ^ 0x9e3779b9) >>> 0,
(h ^ 0x85ebca6b) >>> 0,
(h ^ 0xc2b2ae35) >>> 0,
]
}
/** Sum-of-sines noise in [-1, 1]. Smooth (each input maps to a
* C∞ output) and cheap. The phase constants are irrational ratios
* picked so the three components don't collapse into a low-period
* pattern. */
export function wiggleNoise(seed: number, t: number): number {
const a = Math.sin(seed * 12.9898 + t * 78.233)
const b = Math.sin(seed * 4.31 + t * 11.71)
const c = Math.sin(seed * 7.71 + t * 23.5)
return (a + b + c) / 3
}
/** Returns true when the layer has wiggle on AND at least one of
* the magnitudes is non-zero. */
export function hasWiggle(layer: Layer): boolean {
const w = layer.wiggle
if (!w) return false
const amp = w.amplitude ?? DEFAULT_AMPLITUDE
const rot = w.rotation ?? DEFAULT_ROTATION
return amp > 0 || rot > 0
}
/** Adds wiggle to a layer's sampled props in place — caller passes
* in a mutable copy. Does nothing when the layer has no wiggle. */
export function applyWiggle(
props: AnimatableProps,
layer: Layer,
time: number,
): AnimatableProps {
const w = layer.wiggle
if (!w) return props
const amp = w.amplitude ?? DEFAULT_AMPLITUDE
const rot = w.rotation ?? DEFAULT_ROTATION
const freq = w.frequency ?? DEFAULT_FREQUENCY
if (amp <= 0 && rot <= 0) return props
if (freq <= 0) return props
const [sx, sy, sr] = layerSeeds(layer.id)
const tt = time * freq
return {
...props,
x: props.x + amp * wiggleNoise(sx, tt),
y: props.y + amp * wiggleNoise(sy, tt),
rotation: props.rotation + rot * wiggleNoise(sr, tt),
}
}