Synchronizing Web Animations API Easing with a GPU Render Loop
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:
- Each scalar the clip animates leases a custom property from a shared pool
(
--dyn0,--dyn1, …), registered once viaCSS.registerProperty({ syntax: '<number>', inherits: false }). - The property lives on a value host: a hidden
<div>leased per animated object, kept style-computed withvisibility:hiddenandcontain:strict— neverdisplay:none, which stops style computation and breaks both WAAPI andgetComputedStyle. - A native
Animation(KeyframeEffect(...))plays ondocument.timeline, interpolating the property between keyframe values with whatever CSS easing the clip declares. - One engine frame callback issues one
getComputedStyleread 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.
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.
- Register — members declare membership before anyone is ready to animate.
- Submit — each member independently submits its clip, guarded against double-submission and non-members.
- Barrier release — the last submission triggers playback of all effects as one unit.
- 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
- Event Propagation in 3D: From Screen Space to Scene Records — the input half of the same story: how clicks reach 3D-rendered content.
- Functional 3D vs. Decorative 3D Web Interfaces — why animated, serializable state — named clips and JSON state machines — is a defining trait of Functional 3D.
- Mapping CSS Box Models and Grid Layouts to 3D Coordinates — the layout solver whose FLIP re-arrangements these rendezvous groups play.
- What Is a 3D Spatial Web Interface? — the architecture this animation model belongs to.
- The Scene Document Model: Document Structure for 3D Elements — the model whose elements these clips animate; named clips and per-element state machines are its state layer.