A simulation baked at load

Three balls drop and bounce, each losing a different share of its speed on every landing. A simulation steps forward in time while a render asks for frames in any order, so it never runs in a seek: beside the scene, each ball is stepped to the end of the clip at 1/240 s, finer than any frame, and its height at every step is kept in a typed array.

<div id="stage" data-composition-id="sim"     data-start="0" data-duration="4" data-width="1280" data-height="720" data-fps="30">
    <div class="layer" data-component="balls" data-start="0" data-duration="4">
        <div class="floor"></div>
        <div data-list="balls" data-key="key">
            <template><div class="ball" data-bind-style="style"></div></template>
        </div>
    </div>
</div>
const { scene, mix, styleAt } = wildflower.kinetic;

const DURATION = 4, DT = 1 / 240, STEPS = Math.ceil(DURATION / DT) + 1;
const FLOOR = 600;   // px, where a ball's bottom edge lands
const G = 2400;      // px/s²

const BALLS = [
    { x: 440, bounce: 0.8, color: '#5fd3c6' },
    { x: 640, bounce: 0.6, color: '#f2c14e' },
    { x: 840, bounce: 0.4, color: '#aa3de6' },
];

// Baked once: each ball's height at every step, from a drop at y 120.
for (const b of BALLS) {
    b.y = new Float32Array(STEPS);
    let y = 120, v = 0;
    for (let k = 0; k < STEPS; k++) {
        b.y[k] = y;
        v += G * DT;
        y += v * DT;
        if (y > FLOOR) { y = FLOOR - (y - FLOOR) * b.bounce; v = -v * b.bounce; }
    }
}

// The height at time t, between the two steps either side of it; held at the ends.
function heightAt(b, t) {
    const u = Math.min(STEPS - 1, Math.max(0, t / DT)), k = Math.min(STEPS - 2, Math.floor(u));
    return mix(b.y[k], b.y[k + 1], u - k);
}

scene('balls', {
    computed: {
        balls() {
            return BALLS.map((b, key) => ({ key, style: { background: b.color, ...styleAt(this.local, { translateX: b.x, translateY: heightAt(b, this.local) }) } }));
        },
    },
});
html, body { margin: 0; background: #0a0b10; }

#stage { position: relative; overflow: hidden; background: #0c0c10; }

.layer { position: absolute; inset: 0; }

.floor { position: absolute; left: 0; top: 600px; width: 1280px; height: 2px; background: #888; }

.ball { position: absolute; left: -22px; top: -44px; width: 44px; height: 44px; border-radius: 50%; }

[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
A simulation baked at load

  • The computed reads the two steps either side of the time and mixes them, so a seek to any frame, in any order, draws the same thing.
  • Any physics engine works this way when it runs at load with a fixed step and takes its randomness from random(seed).
  • A landing between two frames is never drawn. For a contact that has to be seen, choose the drop and the speeds so it lands on a whole frame, as A landing on a whole frame does.
  • When a learner's setting changes the result, bake again from a watcher on that store field.