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>
84 lines
2.8 KiB
TypeScript
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),
|
|
}
|
|
}
|