|
| 1 | +--- |
| 2 | +name: baked-sim |
| 3 | +description: Organic physics (a rope, flag, cloth, debris, a flock) never runs at render time — a build script steps the simulation at dt = 1 / fps with a seeded PRNG and writes per-frame data; playback is one `ease:"none"` driver whose onUpdate does `frames[Math.round(t × fps)]`, an index lookup and nothing else. Seek-safe and deterministic by construction; shipped as a synchronous script include, never fetched. |
| 4 | +metadata: |
| 5 | + tags: physics, simulation, baked, offline, verlet, rope, cloth, flag, pennant, debris, seeded, prng, replay, frame-index, deterministic |
| 6 | +--- |
| 7 | + |
| 8 | +# Baked Sim |
| 9 | + |
| 10 | +A hanging pennant catching wind, a rope settling, a cloud of debris tumbling: motion that comes from a **simulation** — integration, constraints, forces — not from an ease. A simulation is stateful by nature (frame N depends on frame N−1), which is exactly what a seeked renderer cannot run. So the simulation runs **once, offline**, at build time, and the composition replays its output by frame index. The renderer sees a lookup, never an integrator. |
| 11 | + |
| 12 | +Boundaries: [particle-burst.md](particle-burst.md) needs no bake — each particle is a closed-form ballistic function of time and evaluates directly. Bake when the bodies **interact** (constraints, collisions, wind on a chain, flocking) or the step is iterative. A [sine-wave-loop.md](sine-wave-loop.md) idle is neither; keep it analytic. |
| 13 | + |
| 14 | +## How It Works |
| 15 | + |
| 16 | +1. **Bake** — a build script (`bake/bake-<name>.mjs`) steps the sim at `dt = 1 / FPS` for `PRE_ROLL + FRAMES` steps with a seeded PRNG (`mulberry32(SEED)`, never `Math.random`), fixed iteration counts, and per-frame forces computed from the frame number. It writes one entry per composition frame: an SVG path `d`, an array of transforms, a list of points. |
| 17 | +2. **Ship synchronously** — the output is a script that assigns one global (`window.__PENNANT = {...}`), loaded with a plain `<script src>` before the composition script. Never `fetch()`: an async load races the renderer's first-frame capture, and a seek to t=0 can land on empty data. |
| 18 | +3. **Replay** — one `ease: "none"` proxy tween spans the clip; its `onUpdate` computes `i = clamp(Math.round(proxy.f))` and applies `frames[i]`. No integration, no accumulated state, no RNG at runtime — a seek to any frame reads the same entry every time. |
| 19 | +4. **Pre-roll** — the bake runs `PRE_ROLL` steps (≈ 60 frames) before frame 0 and discards them, so the system is already hanging or settled at the first captured frame instead of falling into place. |
| 20 | + |
| 21 | +## Recipe |
| 22 | + |
| 23 | +```js |
| 24 | +// bake/bake-pennant.mjs — BUILD TIME ONLY. `node bake/bake-pennant.mjs` → assets/baked/pennant.js |
| 25 | +import { writeFileSync } from "node:fs"; |
| 26 | +const FPS = 30, |
| 27 | + DURATION_S = 7, |
| 28 | + FRAMES = FPS * DURATION_S, |
| 29 | + PRE_ROLL = 60; |
| 30 | +const N = 16, |
| 31 | + SEG = 26, |
| 32 | + GRAV = 210, |
| 33 | + DT = 1 / FPS, |
| 34 | + ITER = 15, |
| 35 | + SEED = 42; |
| 36 | +function mulberry32(seed) { |
| 37 | + let a = seed >>> 0; |
| 38 | + return () => { |
| 39 | + a |= 0; |
| 40 | + a = (a + 0x6d2b79f5) | 0; |
| 41 | + let t = Math.imul(a ^ (a >>> 15), 1 | a); |
| 42 | + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; |
| 43 | + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
| 44 | + }; |
| 45 | +} |
| 46 | +const rand = mulberry32(SEED); |
| 47 | +const gust = Array.from({ length: 24 }, () => (rand() - 0.5) * 2); // seeded gust knots, interpolated per frame |
| 48 | +const windAt = (f) => { |
| 49 | + const t = f / FPS, |
| 50 | + k = (f / FRAMES) * (gust.length - 1), |
| 51 | + i = Math.floor(k), |
| 52 | + x = k - i; |
| 53 | + const base = |
| 54 | + 680 + 260 * Math.sin((2 * Math.PI * t) / 3.6) + 140 * Math.sin((2 * Math.PI * t) / 1.3 + 1.2); |
| 55 | + return base + (gust[i] * (1 - x) + gust[Math.min(i + 1, gust.length - 1)] * x) * 320; |
| 56 | +}; |
| 57 | +const pts = Array.from({ length: N }, (_, i) => ({ |
| 58 | + x: i * SEG * 0.6, |
| 59 | + y: i * 6, |
| 60 | + px: i * SEG * 0.6, |
| 61 | + py: i * 6, |
| 62 | +})); |
| 63 | +const frames = []; |
| 64 | +for (let f = -PRE_ROLL; f < FRAMES; f++) { |
| 65 | + const wind = windAt(Math.max(0, f)); |
| 66 | + for (let i = 1; i < N; i++) { |
| 67 | + // verlet step, slight drag |
| 68 | + const p = pts[i], |
| 69 | + vx = (p.x - p.px) * 0.985, |
| 70 | + vy = (p.y - p.py) * 0.985; |
| 71 | + p.px = p.x; |
| 72 | + p.py = p.y; |
| 73 | + p.x += vx + wind * DT * DT; |
| 74 | + p.y += vy + GRAV * DT * DT; |
| 75 | + } |
| 76 | + for (let it = 0; it < ITER; it++) { |
| 77 | + // distance constraints, head pinned at the origin |
| 78 | + pts[0].x = 0; |
| 79 | + pts[0].y = 0; |
| 80 | + for (let i = 0; i < N - 1; i++) { |
| 81 | + const a = pts[i], |
| 82 | + b = pts[i + 1], |
| 83 | + dx = b.x - a.x, |
| 84 | + dy = b.y - a.y; |
| 85 | + const d = Math.hypot(dx, dy) || 1e-6, |
| 86 | + diff = (d - SEG) / d, |
| 87 | + wa = i === 0 ? 0 : 0.5, |
| 88 | + wb = i === 0 ? 1 : 0.5; |
| 89 | + a.x += dx * diff * wa; |
| 90 | + a.y += dy * diff * wa; |
| 91 | + b.x -= dx * diff * wb; |
| 92 | + b.y -= dy * diff * wb; |
| 93 | + } |
| 94 | + } |
| 95 | + if (f >= 0) frames.push(toPathD(pts)); |
| 96 | +} |
| 97 | +function toPathD(pts) { |
| 98 | + // a smooth quadratic through the segment midpoints: "M x0 y0 Q x1 y1 mx my … L xN yN" |
| 99 | + let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`; |
| 100 | + for (let i = 1; i < pts.length - 1; i++) { |
| 101 | + const mx = ((pts[i].x + pts[i + 1].x) / 2).toFixed(1); |
| 102 | + const my = ((pts[i].y + pts[i + 1].y) / 2).toFixed(1); |
| 103 | + d += ` Q ${pts[i].x.toFixed(1)} ${pts[i].y.toFixed(1)} ${mx} ${my}`; |
| 104 | + } |
| 105 | + return d + ` L ${pts[pts.length - 1].x.toFixed(1)} ${pts[pts.length - 1].y.toFixed(1)}`; |
| 106 | +} |
| 107 | +writeFileSync( |
| 108 | + new URL("../assets/baked/pennant.js", import.meta.url), |
| 109 | + `window.__PENNANT=${JSON.stringify({ fps: FPS, frames })};\n`, |
| 110 | +); |
| 111 | +``` |
| 112 | +
|
| 113 | +```html |
| 114 | +<!-- inside a standard scene clip; the baked data is a synchronous include BEFORE the composition script --> |
| 115 | +<svg width="700" height="700" viewBox="-20 -40 700 700"> |
| 116 | + <path id="rope" d="" fill="none" stroke="{accent}" stroke-width="14" stroke-linecap="round" /> |
| 117 | +</svg> |
| 118 | +<script src="assets/baked/pennant.js"></script> |
| 119 | +``` |
| 120 | +
|
| 121 | +```js |
| 122 | +const PEN = window.__PENNANT; // present before this script runs — no load event, no race |
| 123 | +const rope = document.getElementById("rope"); |
| 124 | +const LAST = PEN.frames.length - 1; |
| 125 | +const apply = (i) => rope.setAttribute("d", PEN.frames[i]); |
| 126 | +apply(0); // structural frame 0: the first captured frame never depends on the driver having fired |
| 127 | +const proxy = { f: 0 }; |
| 128 | +tl.to( |
| 129 | + proxy, |
| 130 | + { |
| 131 | + f: LAST, |
| 132 | + duration: LAST / PEN.fps, // f === k at every sampled frame k / fps — an off-by-one here shows the previous entry on half the frames |
| 133 | + ease: "none", |
| 134 | + onUpdate: () => apply(Math.max(0, Math.min(LAST, Math.round(proxy.f)))), // an index lookup, nothing else |
| 135 | + }, |
| 136 | + SCENE_AT, |
| 137 | +); |
| 138 | +``` |
| 139 | +
|
| 140 | +## Variations |
| 141 | +
|
| 142 | +- **Transform arrays** — bake `[x, y, rot]` per body per frame (debris, a flock) and write each element's `transform` in the same `onUpdate`; the pool is fixed at setup. |
| 143 | +- **Canvas draw** — bake point lists and draw them with `ctx` inside the driver: the canvas becomes a pure function of the frame index (paint frame 0 at setup). |
| 144 | +- **Two-rate bake** — simulate at a finer `dt` (`1 / (FPS × 2)`) for stiff constraints and write every second step; the composition still indexes at its own fps. |
| 145 | +- **Re-bake for direction** — a physically correct sim is not yet art-directed: the reference pennant hung limp until wind ≈ 3 × gravity, and streams at that ratio. Budget one bake iteration for the look, then freeze the seed. |
| 146 | +- **Designed motion on top** — the baked object still takes ordinary tweens (a fade-in, a settle, an exit); the bake owns only what the sim owns. |
| 147 | +
|
| 148 | +## Values |
| 149 | +
|
| 150 | +| token | range | notes | |
| 151 | +| --------- | ------------------------------ | ------------------------------------------------------------------------------------ | |
| 152 | +| FPS | = the composition's `data-fps` | one entry per composition frame; the driver's `round()` then never straddles two | |
| 153 | +| PRE_ROLL | 30–90 frames (default 60) | enough for the system to hang / settle before frame 0 | |
| 154 | +| ITER | 8–20 constraint passes | stiffness; more = stiffer rope, longer bake, same file size | |
| 155 | +| SEED | any fixed integer | change it to change the gusts; commit it with the data | |
| 156 | +| data size | ≲ 100–300 KB per baked object | a 210-frame, 16-point path is ~75 KB; past ~1 MB decimate the path or the body count | |
| 157 | +
|
| 158 | +## Critical Constraints |
| 159 | +
|
| 160 | +- **No integration at runtime** — the composition never steps, accumulates, or reads a previous frame; `frames[i]` is the whole runtime. A per-frame `+=` on baked state re-introduces the race the bake removed. |
| 161 | +- **Synchronous include** — the baked script is a `<script src>` that runs before the composition script; never `fetch()` / dynamic `import()` at runtime. |
| 162 | +- **Seeded and pinned** — a seeded PRNG in the bake, fixed `dt`, fixed iteration counts; no `Math.random`, no wall-clock, no host-dependent step. Re-running the bake reproduces the file byte for byte. |
| 163 | +- **Frame 0 is structural** — call the apply function once at setup; a tween evaluated exactly at its start edge may not fire `onUpdate` on the first seek. |
| 164 | +- **To change the motion, re-bake** — never a runtime knob that alters replayed data. |
| 165 | +- The bake script and its inputs ship beside the composition (`bake/`) so the data has provenance; the data file is what renders. |
| 166 | +
|
| 167 | +## See also |
| 168 | +
|
| 169 | +`particle-burst` (closed-form particles — no bake needed) · `svg-path-draw` (drawing a baked path over time) · `sine-wave-loop` (analytic idle, not a sim) · `../adapters/three.md` (seeded particles and shader plates on the GPU lane). |
0 commit comments