← Learn

Synchronizing Web Animations API Easing with a GPU Render Loop

Software 4 Senses LLC · Last updated

A WebGPU scene has no DOM elements to animate, yet the browser already ships a production-grade animation engine: the Web Animations API (WAAPI). The bridge between the two is a registered, typed CSS custom property on a hidden element — the browser eases its value along the timeline, the render loop reads it back once per frame with getComputedStyle, and the engine writes the result into GPU buffers. The browser does the easing math; the engine does the GPU writes.

Let the browser do the easing math

The mechanism is a leased CSS custom property per animated scalar channel, driven by a real WAAPI animation and sampled by the render loop. In the Software 4 Senses 3D Spatial Web Studio's engine, a clip — a named keyframe sequence attached to one scene object — compiles down to exactly this substrate. Clip targets are Scene Document Model elements: any engine record — solid, group, light, or camera display — is animatable without a wrapper element. The loop, as a procedure:

  1. Each scalar the clip animates leases a custom property from a shared pool (--dyn0, --dyn1, …), registered once via CSS.registerProperty({ syntax: '<number>', inherits: false }).
  2. The property lives on a value host: a hidden <div> leased per animated object, kept style-computed with visibility:hidden and contain:strict — never display:none, which stops style computation and breaks both WAAPI and getComputedStyle.
  3. A native Animation(KeyframeEffect(...)) plays on document.timeline, interpolating the property between keyframe values with whatever CSS easing the clip declares.
  4. One engine frame callback issues one getComputedStyle read per animated object per frame, collects every eased channel, and pushes the values through the engine's direct record-write paths into the GPU scene buffers.

The typed registration in step 1 is load-bearing: a registered <number> property is numerically interpolated by the browser, while an unregistered custom property snaps discretely at the 50% mark. Property names are generic and pooled because CSS.registerProperty is permanent for the page's lifetime — embedding object UUIDs in names would leak one registration per add/remove cycle forever, so names are leased and recycled. The value host is interpolation plumbing, not a state store: the authoritative animated values live in the engine's scene records, where step 4 writes them.

Keyframes themselves are plain parameter bags — the same shapes the engine's command API accepts — so a clip is data, not code. Timing options are WAAPI's own (defaults: 300 ms, linear, one iteration, fill:'forwards'), and infinite repetition is spelled 'infinite' on the JSON wire because raw Infinity would JSON-encode as null.

Two synchronization details matter once the eased value reaches the engine. The sampler writes through a direct record-write path, not the public object handle, whose 16 ms write throttle beats against the ~16.7 ms frame cadence and drops frames stochastically — a frame-paced sampler needs no second throttle. And the engine's document-change counter bumps once per clip end or cancel, never per frame: a 700 ms tween is one document change, not forty. Vector and color keyframes expand to all their scalar channels, with missing components filled from live state at play time, because the apply paths zero-fill missing axes and a partial {position:{x}} write would otherwise teleport y/z to zero.

This technique requires CSS.registerProperty (Chromium, Safari 16.4+, Firefox 128+); clip definition fails loudly where it is absent. The engine already requires WebGPU, a narrower requirement, so the substrate adds no new browser constraint.

The 'current' keyframe: analog-first mutation

In the Studio, the convention for changing a scene object programmatically is a clip from the live value to the target — written ['current', target] — so the change reads as motion, not a jump. 'current' is a reserved keyword usable as a keyframe: a sentinel resolved from the object's live state at the moment play() is called, not at definition time — the same clip definition means "glide from wherever you are to here" no matter when it plays.

Analog-first mutation — the convention that programmatic state changes travel through animation clips (['current', target] plus play) rather than instant property sets, so every change is continuous motion from the live value. In the Studio's engine no instant setter is exported on the navigation surface at all: glides are the only verbs.

An API-driven scene wants this as its default grammar for a structural reason: the code issuing mutations is not watching the screen frame by frame. A human hand-tuning a scene notices when a card jumps; a script or an authoring tool does not. Making the continuous form the default means a discontinuity has to be asked for, and because 'current' resolves late, a command issued mid-flight simply starts a new glide from wherever the previous one had gotten to.

The clip store is defensive in the same spirit: redefining an existing clip name replaces it and returns the replaced definition — an undo in hand — a rule added after a live incident where re-authoring over an existing clip name silently destroyed an authored phase offset. A running replaced clip is cancelled with its values held, not left forked on shared channels.

Interruption and retargeting

An in-flight transition is retargeted by playing a new ['current', target] clip: the sentinel resolves from the live, mid-flight value, so the new glide departs exactly where the old one was interrupted, with no snap and no completion-waiting — interruption is not a special mode but the same verb as starting.

Landing is handled from data, not from sampling. When a clip finishes naturally, the engine writes the resolved terminal keyframe exactly — accounting for playback direction and iteration parity — instead of trusting one last style read, because fill:'none' reverts computed style the instant an animation finishes and a sampling race would land the object one frame short of its target.

The same discipline covers accessibility: under prefers-reduced-motion, the Studio's layout layer collapses re-arrangement transitions to cuts — cancelling any in-flight clip and writing each clip's terminal frame directly, rather than playing the animation. Because the end state comes from the target frame data either way, the cut and the glide land on identical final values — reduced motion changes the journey, never the destination.

Rendezvous groups: barrier synchronization for animation

A rendezvous group is a barrier: members register up front, each independently submits its clip when ready, and the last submission releases every clip as one phase-locked unit, after which the group resets for reuse. It is the animation analogue of a thread barrier, and it exists because multi-object choreography — a grid of cards re-arranging into a carousel — reads as one gesture only if every participant departs on the same frame.

  1. Register — members declare membership before anyone is ready to animate.
  2. Submit — each member independently submits its clip, guarded against double-submission and non-members.
  3. Barrier release — the last submission triggers playback of all effects as one unit.
  4. Finish — completion callbacks run, then the group resets for reuse.

The phase lock itself does not depend on any grouped-playback API. WAAPI Level 2 proposes a GroupEffect for exactly this, and the engine uses it where a page provides it — but it sits behind a runtime feature check, and standard Chromium does not ship it. The path that actually runs in ordinary browsers assigns every member an identical explicit startTime on the shared document timeline, which is functionally the same phase lock: all animations are retroactively synchronized to one clock instant regardless of when each was constructed.

The instructive edge case is departure, not arrival. A member removed from the scene while the group is still gathering withdraws — its registration and submission are dropped and the barrier re-evaluates, since a withdrawal can itself be the last arrival; without this rule the group deadlocks waiting for a participant that no longer exists. Completion callbacks fire only if every member finished naturally — any cancellation suppresses them. The layout layer leans on this protocol for FLIP-style re-arrangement: solve the target layout instantly, author one pose clip per card as ['current', target], and play them as one rendezvous group, interruptible mid-flight by the same retargeting rule as any other clip. Named clips and per-element state machines are the Scene Document Model's state story — where a DOM element holds its own state, a 3D element holds clips and a machine — and rendezvous groups coordinate that state across elements.

Autonomous glides on a leased CSS custom property

The same substrate that animates objects can animate the scroll position itself: the engine's rail navigation drives its autonomous moves — snap settles and programmatic glides — with one real WAAPI animation on one leased custom property whose value is the scroll cursor. Every camera pose and bound world translation on a rail is a pure function of that scalar, so easing one number eases the whole stage.

The payoff is closed-form, time-parameterized easing: the browser evaluates the easing curve against the timeline rather than integrating velocity step by step, so every sampled value sits exactly on the curve regardless of frame jitter — a dropped frame delays the read, not the trajectory. Once per frame, before the scroll dynamics ticks, the controller reads the property and syncs the dynamics to the on-curve position, so the dynamics layer stays truthful about where the cursor is even while it isn't the one driving.

Interruption follows the direction of the user's intent. Input in the same direction as the glide steals control instantly — the animation cancels and direct tracking resumes from the on-curve position. Opposing input reverses the transition natively via the animation's own reverse(): the easing mirrors back from the current time, position-continuous — the natural "break/undo" gesture. Glide durations are distance-scaled design defaults (320 ms per 1000 rail units, clamped to 200–900 ms), and camera-driving moves get a duration floor of 2× distance over the certified scrub velocity, because a time-eased move's peak velocity is roughly twice its average for standard beziers. These are shipped, overridable engine defaults, not performance measurements.

Related reading