Performance
Stew Engine works a clip out once, before it plays, so each frame is small: look up, blend, write. Here is what keeps it light, and the tests that hold it there.
Push it
Every dot below has three tracks of its own (y, scale and fill), all in one clip on one player. Pick more dots and watch the numbers.
Tracks are counted in the clip, channels in the compiled program. The time per frame is measured live in your browser, around each frame the player draws: working out every value, then writing the ones that changed. The browser paints after that, and the frame rate shows whether it keeps up.
import { compile, createPlayer, frameClock, svgDrawer } from "@stew/engine";
import { timedClock } from "./timedClock";
import field from "./field.json";
// field.json: every dot's tracks, like dot-0.json
// svg: the <svg> holding the circles
// show(ms, fps): puts the numbers on the page
// 216 dots, 648 tracks,
// read once into 1,296 channels: a color takes four
const program = compile(field);
// each dot is written to the circle carrying its uid
const dots = new Map(Array.from(svg.querySelectorAll("[data-stew-uid]"),
el => [el.getAttribute("data-stew-uid"), [el]]));
const drawer = svgDrawer(program, uid => dots.get(uid) ?? null);
// the page's frame clock, timing each frame the player draws
const clock = timedClock(frameClock(), (ms, fps) => show(ms, fps));
createPlayer(program, drawer, { clock, repeat: -1 }).play();What a frame does
compile reads the clip once into a Program: flat typed arrays of key times, values and eases. Each animated number is a channel, and a color takes four: red, green, blue and alpha.
Each frame is then look up, blend, write. One loop finds every channel's two keys and eases between them, and each target's moves become one matrix. The drawer writes a value only when it differs from the last one it wrote. It never asks the page for anything while drawing: it found every element, and every stroke's length, when it was made.
compile(doc)Program- Once, before anything plays. Pure: data in, numbers out, no page access.
program.channelCountnumber- How many animated numbers the clip has. The demo above prints it.
createPose(program)Pose- The numbers one frame shows. A player makes one, then fills it again every frame.
evaluate(program, t, pose)- Look up and blend: every channel at
tseconds, then every target's matrix, intopose. Pure math: the same moment always gives the same pose. svgDrawer(program, bind)Drawer- Finds the elements each target is written to once, when it is made.
drawer.draw(pose)- Write: only what changed since the last draw. A transform is written only when its matrix changed.
One loop for everything
By default, every playing player ticks on the page's frame clock, frameClock(): one requestAnimationFrame loop they all share. A paused player stops ticking, and once nothing ticks, the loop stops asking for frames. A player with nothing to draw, nobody listening and no end to its run ticks only its first frame: its playhead still runs, read when asked.
The pointer is heard the same way: one handler on the window and one pass per frame, however many shapes follow it. A fast mouse sends more events than there are frames, so only the latest position is kept, and the followers hear it on the next frame. Once they settle, they write nothing.
A clock is only three things, so it can be wrapped. The demo above wraps the page's clock to time each frame: timedClock.ts in its code. A player can run on any clock: a manualClock moves only when told, for tests or anything stepped frame by frame, like a video. Clocks and the stage has the rest.
now()number- Seconds, from whatever zero the clock keeps.
onFrame(tick)() => void- Calls
tickevery frame with the frame's time, in seconds. Returns what stops it. tickingnumber- How many tick on it right now. 0 means it sleeps. Read only.
Nothing made per frame
Playing creates nothing new, however much moves. Values live in typed arrays made once, and each frame fills them in place. What a frame does make is the text of each value that changed, as it goes on the page.
Two tests hold it there. They run in Node, on stand-in elements, and count the memory clean-ups (garbage collections) while a scene runs for 100,000 frames or more. Each count is held against a plain run where only one-channel clips play: what the scene adds may be 3 clean-ups at most.
noGarbage.test.ts- 40 targets and 512 channels: moves, turns, colors, seven of the nine ease families, paths, keyed pivots, gradients and morphs. Evaluated for 200,000 frames, and played for 100,000 on a manual clock with a drawer that writes nothing. Then three players on one stage, held still, heard and cued, one gliding and one winding down.
noGarbageInteract.test.ts- 40 shapes following or fleeing a resting pointer, 20 held drags, a scroll scrub, a glide aimed again every frame, 10 node bends and 3 rules firing. Once settled, the followers, drags and bends write nothing.
Size
The engine builds into one minified module that imports nothing. engine/test/bundle.test.ts holds it to a budget and fails past it. It also checks that a clip with no pointer actions or rules ships without them. The player that exported components play on has a budget of its own, in player/test/bundle.test.ts.
the whole engine- 55.3 KB minified, 23.1 KB gzipped. Budget: 56 KB and 23.3 KB.
a clip, no interactions- Only
compile,createPlayer,svgDrawerandframeClock: 40.1 KB minified, 16.9 KB gzipped. Budget: 40.5 KB and 17.1 KB. an export's player- What exported components play on: the engine, and the scene around it (its start, actions, rules, wires and data). 98.3 KB minified, 38.8 KB gzipped. Budget: 118 KB and 45 KB.
Checked in two browsers
The animation check plays 468 cases in Chrome and in WebKit (Safari's engine), against positions and values worked out by hand. Stew Engine plays 433 of them on its own and passes every one. The other 35 belong to the downloads alone: their controls, and the Web Component.