Driving vgpu with an external ticker — GSAP/Motion/XR
By default vgpu owns the clock: every frame(gpu) moves clock(gpu).time forward by the wall-clock delta since the last frame, and frameLoop(gpu, cb) schedules those frames on requestAnimationFrame. That is the right default for a page whose only animation is the render.
It stops being the right default the moment something else already owns the timeline: a GSAP or Motion ticker, an XR session's frame callback, a physics loop with a fixed timestep, or a test that must produce the same pixels twice. Two clocks running side by side drift, and drift shows up as animation that stutters against everything else on the page.
The fix is one call. clock(gpu).advance(dtSeconds) moves the vgpu clock forward now, and claims that frame's tick: the next frame(gpu) counts the frame and runs its passes, but does not advance the clock again. One tick per frame, with the manual one winning.
import { init, clock, effect, frame, surface } from "vgpu";
declare const canvas: HTMLCanvasElement;
declare const gsap: { ticker: { add(cb: (time: number, deltaMs: number) => void): void } };
const gpu = await init();
const canvasSurface = surface(gpu, canvas);
const wave = effect(gpu, `
struct Params { time: f32 }
@group(0) @binding(0) var<uniform> params: Params;
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
return vec4f(uv, sin(params.time) * 0.5 + 0.5, 1.0);
}
`, { set: { params: { time: 0 } } });
// ---cut---
const time = clock(gpu);
// GSAP owns the rAF; vgpu renders inside its tick, on GSAP's delta.
gsap.ticker.add((_total, deltaMs) => {
time.advance(deltaMs / 1000); // the clock moves here...
wave.set({ params: { time: time.time } });
frame(gpu, (f) => f.pass(canvasSurface, wave)); // ...and not again here
});Note what disappears: there is no frameLoop(gpu, ...). The external ticker is the loop, and frame(gpu, cb) is the render — encode, submit, done.
Motion, XR, and anything else with a delta
Every ticker hands you the same thing under a different name, so the shape never changes:
import { init, clock, frame } from "vgpu/mock";
const gpu = await init();
const time = clock(gpu);
declare function render(): void;
// ---cut---
// Motion (frame + delta):
// frame.update(({ delta }) => { time.advance(delta / 1000); render(); });
// WebXR (absolute timestamps, one session frame at a time):
declare const session: { requestAnimationFrame(cb: (timestampMs: number, xrFrame: unknown) => void): number };
let previousMs: number | undefined;
const onXRFrame = (timestampMs: number) => {
time.advance(previousMs === undefined ? 0 : (timestampMs - previousMs) / 1000);
previousMs = timestampMs;
frame(gpu, () => render());
session.requestAnimationFrame(onXRFrame);
};
session.requestAnimationFrame(onXRFrame);The first XR frame advances by 0: there is no previous timestamp to measure against, and a made-up first delta is the classic source of a one-frame jump when the headset starts.
Timescale: slow motion is a multiplication
Because the delta is yours, scaling it is the whole feature — no separate "speed" uniform threaded through every shader, and no second clock:
import { init, clock, frameLoop } from "vgpu/mock";
const gpu = await init();
// ---cut---
const time = clock(gpu);
let timescale = 1; // 0 pauses, 0.25 is slow motion, 2 is fast forward
let previousMs = performance.now();
frameLoop(gpu, () => {
const nowMs = performance.now();
time.advance(((nowMs - previousMs) / 1000) * timescale);
previousMs = nowMs;
// ... render with time.time
});frameLoop still schedules the frames; it just no longer decides what a frame is worth. advance(0) is legal and is the honest way to pause: the clock stops, frames keep rendering, frameCount keeps counting.
Fixed timestep and determinism
A simulation that must not depend on frame rate advances in fixed steps and renders whatever the accumulator leaves behind:
import { init, clock, frame, frameLoop } from "vgpu/mock";
const gpu = await init();
declare function step(dt: number): void;
declare function render(): void;
// ---cut---
const time = clock(gpu);
const STEP = 1 / 120; // simulate at 120 Hz, render at display rate
let accumulator = 0;
let previousMs = performance.now();
frameLoop(gpu, () => {
const nowMs = performance.now();
accumulator += Math.min(0.25, (nowMs - previousMs) / 1000); // clamp: a hidden tab must not spiral
previousMs = nowMs;
let advanced = 0;
while (accumulator >= STEP) {
step(STEP);
accumulator -= STEP;
advanced += STEP;
}
time.advance(advanced); // one advance per frame, however many steps ran
render();
});The same technique makes headless renders reproducible: drop the wall clock entirely and advance by a constant.
import { init, clock, effect, frame, target } from "vgpu/mock";
const gpu = await init();
const scene = target(gpu, { size: [64, 64] });
const shader = effect(gpu, `@fragment fn fs_main() -> @location(0) vec4f { return vec4f(1); }`);
// ---cut---
const time = clock(gpu);
for (let i = 0; i < 90; i++) {
time.advance(1 / 60); // frame 90 always lands on t = 1.5s
frame(gpu, (f) => f.pass(scene, shader));
}
const pixels = await scene.read(); // same bytes on every machine, every run
void pixels;Rules of the technique
- One advance per frame.
advance()beforeframe()is the pattern. Callingadvance()twice before a single frame accumulates both deltas intotimeand leavesdeltaTimeat the last one — usually a bug in the ticker wiring. - Mixing is fine. Skip
advance()for a frame and that frame falls back to the wall-clock delta, measured from the previous tick. There is no mode to switch. frameCountcounts frames, not advances. It only moves insideframe()/frameLoop(), so it stays a reliable "how many times did we render".advance()takes seconds. Most tickers hand out milliseconds — divide by 1000. Negative or non-finite deltas throwVGPU-CLOCK-DELTA-INVALIDinstead of quietly running time backwards.- Read the clock, don't cache the numbers.
clock(gpu)returns the same live object every time;const time = clock(gpu)outside the loop andtime.timeinside it always reads the current value.