Particles from an emitter

A fountain throws sparks up from one point, forty a second, each falling back and fading over a second and a half. Every particle the clip will ever have is made beside the scene, with its birth time and its speeds from random(seed), and each one is a function of its age, so a seek to any time draws the right sparks.

<div id="stage" data-composition-id="sparks"     data-start="0" data-duration="5" data-width="1280" data-height="720" data-fps="30">
    <div class="layer" data-component="fountain" data-start="0" data-duration="5">
        <div data-pool="sparks" data-key="id">
            <template><i class="spark" data-bind-style="style"></i></template>
        </div>
    </div>
</div>
const { scene, random, styleAt } = wildflower.kinetic;

const DURATION = 5;
const RATE = 40;      // particles a second
const LIFE = 1.5;     // seconds each one lives
const G = 900;        // px/s², pulling them back down
const SLOTS = Math.ceil(RATE * LIFE) + 1;

const rnd = random(9);
const SPARKS = Array.from({ length: Math.ceil(RATE * DURATION) }, (_, i) => ({
    born: i / RATE, vx: -120 + 240 * rnd(), vy: -620 - 160 * rnd(), size: 4 + 6 * rnd(),
}));
// A hidden slot writes the same properties a shown one does, so nothing a
// spark set at another time is left on it.
const HIDDEN = { width: '0px', height: '0px', opacity: '0', transform: 'none' };

scene('fountain', {
    pools: { sparks: {} },
    init() { for (let s = 0; s < SLOTS; s++) this.pools.sparks.add({ id: s, style: HIDDEN }); },
    seek(local) {
        const n = Math.floor(local * RATE + 1e-9);   // the newest particle born by now
        for (let s = 0; s < SLOTS; s++) {
            const p = SPARKS[n - ((((n - s) % SLOTS) + SLOTS) % SLOTS)];   // the newest one in slot s
            const age = p ? local - p.born : -1;
            if (!(age >= 0 && age < LIFE)) { this.pools.sparks.update(s, { style: HIDDEN }); continue; }
            const x = 640 + p.vx * age, y = 620 + p.vy * age + 0.5 * G * age * age;
            this.pools.sparks.update(s, {
                style: { width: p.size + 'px', height: p.size + 'px', opacity: (1 - age / LIFE).toFixed(3), ...styleAt(local, { translateX: x, translateY: y }) },
            });
        }
    },
});
html, body { margin: 0; background: #0a0b10; }

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

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

.spark { position: absolute; left: -4px; top: -4px; border-radius: 50%; background: #f2c14e; }

[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
Particles from an emitter

  • A pool holds a fixed number of slots, and particle i lives in slot i % SLOTS, so no two live particles share one.
  • SLOTS is the rate times the life, rounded up, plus one. A particle born just as the last one in its slot reaches the end of its life would otherwise find that one still alive by a rounding error, and one of the two would not be drawn.
  • Before the first particle, and in a slot whose index is above the newest particle's, the lookup finds no particle and the slot is hidden.
  • update() writes the properties it is given and leaves the rest, so a hidden slot writes every property a shown one does. With opacity alone, a slot keeps the size and place of whatever spark it last showed, and a frame depends on the frames seeked before it, which check() reports.
  • A burst is the same with every birth time equal, and a drift reads noise() at the particle's age.