---
title: External ticker
description: Drive the vgpu clock from GSAP, Motion, an XR frame callback or a fixed timestep with clock(gpu).advance(dt).
---

# External ticker



# 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.

```ts
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:

```ts
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:

```ts
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:

```ts
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.

```ts
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()` before `frame()` is the pattern. Calling `advance()` twice before a single frame accumulates both deltas into `time` and leaves `deltaTime` at 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.
* **`frameCount` counts frames, not advances.** It only moves inside `frame()` / `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 throw `VGPU-CLOCK-DELTA-INVALID` instead 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 and `time.time` inside it always reads the current value.

## See also

* [Frames](/docs/guides/concepts-frames) — what `frame(gpu, cb)` and `frameLoop(gpu, cb)` actually do.
* `clock` — the full API of the clock, including its error codes.


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)