Skip to content

Commit acf4478

Browse files
committed
feat(skills): baked-sim rule — offline physics stepped at build time, replayed by frame index
Adds rules/baked-sim.md to hyperframes-animation: a seeded build-time simulation (verlet rope reference) written as per-frame data, shipped as a synchronous script include, and replayed by one ease:"none" driver doing an index lookup — no integration or RNG at runtime, seek-safe by construction. Adds the rules-index row and a boundary pointer in particle-burst.md (interacting bodies are baked; ballistic ones stay closed-form).
1 parent a92dc68 commit acf4478

4 files changed

Lines changed: 173 additions & 3 deletions

File tree

‎skills-manifest.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
"files": 17
2323
},
2424
"hyperframes-animation": {
25-
"hash": "4252b08a4905b5ba",
26-
"files": 121
25+
"hash": "b424d7c6b0d92851",
26+
"files": 122
2727
},
2828
"hyperframes-audio": {
2929
"hash": "84035d8ce2a61fbc",

‎skills/hyperframes-animation/rules-index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ A rule's own **Critical Constraints** section lists only what is SPECIFIC to tha
9696
<motion-blur-streak path="rules/motion-blur-streak.md">Fake directional velocity blur on a fast entrance / camera push-through — blur peaks at max speed, resolves to 0 at the settle. Two paths: SVG `feGaussianBlur` stdDeviation on the motion axis (proxy-tweened), or a deterministic echo/ghost trail that collapses into the lead. Entrances / mid-shot only. Tags: motion-blur, streak, velocity, ghost, echo, fast</motion-blur-streak>
9797
<waterfall-entry path="rules/waterfall-entry.md">Staggered ARRIVAL cascade — words/elements whip in from below, each starting before the previous settles, an accelerating wave that resolves composed. Title cards, segment openers, list intros. Binary 0→1 opacity via `tl.set` — never fade an arrival. Tags: entrance, cascade, stagger, kinetic-text, title-card, arrival, waterfall</waterfall-entry>
9898
<particle-burst path="rules/particle-burst.md">Deterministic particle / confetti events — confetti pop that bursts up and drifts down on gravity (optional instant-shrink), dot burst from behind text, glyph dissolve to particles. Fixed pool, index-seeded launch values, one `ease: "none"` driver whose onUpdate computes each particle as a pure ballistic function of time — scrub-safe mid-flight, ≤ ~40 particles. Tags: particles, confetti, burst, dissolve, ballistic, deterministic, punctuation</particle-burst>
99+
<baked-sim path="rules/baked-sim.md">Organic physics (rope, flag, cloth, debris, flock) never runs at render time — a build script steps the sim at `dt = 1/fps` with a seeded PRNG and writes per-frame data; playback is one `ease:"none"` driver doing `frames[round(t × fps)]`, an index lookup and nothing else. Synchronous script include (never fetched), ~60-frame pre-roll, frame 0 applied at setup. Tags: physics, simulation, baked, offline, verlet, rope, flag, seeded, replay, deterministic</baked-sim>
99100
<nudge-curve path="rules/nudge-curve.md">Slow-fast-slow three-phase group slide (power3.in ramp → linear burst → power4.out tail, 10/65/25 distance, tail ≥3× ramp-in) to reposition a composed group and reveal content during the burst. Tags: slide, reposition, group-motion, nudge, slow-fast-slow</nudge-curve>
100101
</rules>
101102

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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).

‎skills/hyperframes-animation/rules/particle-burst.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ metadata:
99

1010
Discrete flying particles as a one-shot event: a **confetti pop** that erupts upward and drifts back down on gravity, a **dot burst** radiating from behind a landing word, or a **glyph dissolve** where text breaks into particles that scatter and die. Particles are ephemeral garnish — born from a beat, fly, gone; they never become layout.
1111

12-
Boundaries: [css-marker-patterns.md](css-marker-patterns.md)'s burst mode is radiating **drawn lines** — a static accent, no flight. [press-release-spring.md](press-release-spring.md)'s release burst is **one blurred radial layer** faking an explosion — enough when a single glow pop will do. [center-outward-expansion.md](center-outward-expansion.md) moves **real layout elements** to final resting slots; particles have no destination, only physics and a death.
12+
Boundaries: [css-marker-patterns.md](css-marker-patterns.md)'s burst mode is radiating **drawn lines** — a static accent, no flight. [press-release-spring.md](press-release-spring.md)'s release burst is **one blurred radial layer** faking an explosion — enough when a single glow pop will do. [center-outward-expansion.md](center-outward-expansion.md) moves **real layout elements** to final resting slots; particles have no destination, only physics and a death. Bodies that **interact** — constraints, wind on a chain, collisions — are not ballistic: simulate them offline and replay by frame index ([baked-sim.md](baked-sim.md)).
1313

1414
## How It Works
1515

0 commit comments

Comments
 (0)