Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/concepts/frame-adapters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ First-party runtime adapters:
| Anime.js | `instance.seek(timeMs)` for animations registered on `window.__hfAnime` | `/animejs` |
| CSS keyframes | Browser `Animation.currentTime`, with paused negative-delay fallback | `/css-animations` |
| Lottie / dotLottie | `goToAndStop(timeMs, false)`, raw-frame setters, or player seek APIs | `/lottie` |
| Three.js / WebGL | `hf-seek` events plus `window.__hfThreeTime` for deterministic scene rendering | `/three` |
| Three.js / WebGL | `window.__hfThreeRender` callbacks (with `hf-seek` events and `window.__hfThreeTime` as fallbacks) | `/three` |
| Web Animations API | `document.getAnimations()` and `animation.currentTime` | `/waapi` |

Community adapters are welcome -- if it can seek by frame, it belongs in Hyperframes.
Expand Down
104 changes: 103 additions & 1 deletion packages/core/src/runtime/adapters/three.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createThreeAdapter } from "./three";

const threeWindow = window as Window & { __hfThreeTime?: number };
const threeWindow = window as Window & {
__hfThreeTime?: number;
__hfThreeRender?: ((time: number) => void)[];
};

describe("three adapter", () => {
beforeEach(() => {
delete threeWindow.__hfThreeTime;
delete threeWindow.__hfThreeRender;
});

it("has correct name", () => {
Expand All @@ -29,12 +33,102 @@ describe("three adapter", () => {
expect(detail.time).toBe(3);
});

it("seek invokes every callback in __hfThreeRender registry", () => {
const adapter = createThreeAdapter();
const cbA = vi.fn();
const cbB = vi.fn();
threeWindow.__hfThreeRender = [cbA, cbB];
adapter.seek({ time: 4 });
expect(cbA).toHaveBeenCalledWith(4);
expect(cbB).toHaveBeenCalledWith(4);
});

it("registry callbacks fire before hf-seek event", () => {
const adapter = createThreeAdapter();
const order: string[] = [];
threeWindow.__hfThreeRender = [() => order.push("callback")];
const handler = () => order.push("event");
window.addEventListener("hf-seek", handler);
adapter.seek({ time: 1 });
window.removeEventListener("hf-seek", handler);
expect(order).toEqual(["callback", "event"]);
});

it("a throwing callback does not block sibling callbacks or the event", () => {
const adapter = createThreeAdapter();
const cbA = vi.fn(() => {
throw new Error("boom");
});
const cbB = vi.fn();
threeWindow.__hfThreeRender = [cbA, cbB];
const handler = vi.fn();
window.addEventListener("hf-seek", handler);
expect(() => adapter.seek({ time: 2 })).not.toThrow();
window.removeEventListener("hf-seek", handler);
expect(cbA).toHaveBeenCalledWith(2);
expect(cbB).toHaveBeenCalledWith(2);
expect(handler).toHaveBeenCalled();
});

it("ignores non-array __hfThreeRender without throwing", () => {
const adapter = createThreeAdapter();
// Simulate a composition that mistakenly assigned a non-array value.
(threeWindow as unknown as { __hfThreeRender?: unknown }).__hfThreeRender = {
push: () => {},
};
expect(() => adapter.seek({ time: 1 })).not.toThrow();
expect(threeWindow.__hfThreeTime).toBe(1);
});

it("non-function entries in registry are skipped", () => {
const adapter = createThreeAdapter();
const cb = vi.fn();
threeWindow.__hfThreeRender = [
null as unknown as (time: number) => void,
undefined as unknown as (time: number) => void,
cb,
];
expect(() => adapter.seek({ time: 6 })).not.toThrow();
expect(cb).toHaveBeenCalledWith(6);
});

it("late-registered callbacks receive subsequent seeks", () => {
const adapter = createThreeAdapter();
adapter.seek({ time: 1 });
// Composition imports finish and pushes its renderer after the first seek.
const cb = vi.fn();
threeWindow.__hfThreeRender = threeWindow.__hfThreeRender ?? [];
threeWindow.__hfThreeRender.push(cb);
adapter.seek({ time: 2 });
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith(2);
// __hfThreeTime is the canonical fallback for the missed first seek.
expect(threeWindow.__hfThreeTime).toBe(2);
});

it("callbacks registered after revert are still invoked on subsequent seeks", () => {
const adapter = createThreeAdapter();
adapter.revert!();
const cb = vi.fn();
threeWindow.__hfThreeRender = [cb];
adapter.seek({ time: 7 });
expect(cb).toHaveBeenCalledWith(7);
});

it("seek clamps negative time to 0", () => {
const adapter = createThreeAdapter();
adapter.seek({ time: -10 });
expect(threeWindow.__hfThreeTime).toBe(0);
});

it("seek clamps negative time to 0 for callbacks too", () => {
const adapter = createThreeAdapter();
const cb = vi.fn();
threeWindow.__hfThreeRender = [cb];
adapter.seek({ time: -5 });
expect(cb).toHaveBeenCalledWith(0);
});

it("pause retains last forced time", () => {
const adapter = createThreeAdapter();
adapter.seek({ time: 7 });
Expand All @@ -57,6 +151,14 @@ describe("three adapter", () => {
// After revert, forcedTime and lastForcedTime are reset
});

it("revert does not clear __hfThreeRender", () => {
const adapter = createThreeAdapter();
const cb = vi.fn();
threeWindow.__hfThreeRender = [cb];
adapter.revert!();
expect(threeWindow.__hfThreeRender).toEqual([cb]);
});

it("discover is a no-op", () => {
const adapter = createThreeAdapter();
expect(() => adapter.discover()).not.toThrow();
Expand Down
54 changes: 50 additions & 4 deletions packages/core/src/runtime/adapters/three.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
import type { RuntimeDeterministicAdapter } from "../types";

/**
* Three.js / WebGL deterministic adapter.
*
* Compositions can render frames driven by HyperFrames time using either:
*
* 1. **Direct render-callback registry (recommended).** Push a `(time) => void`
* callback into `window.__hfThreeRender`. The adapter invokes every
* registered callback synchronously on each seek, before the legacy event
* is dispatched:
* ```js
* window.__hfThreeRender = window.__hfThreeRender || [];
* window.__hfThreeRender.push(renderAt);
* renderAt(window.__hfThreeTime || 0);
* ```
* This mirrors the `__hfAnime` / `__hfLottie` registry convention used by
* sibling adapters and is robust against listener-registration ordering and
* execution-context isolation that can swallow `CustomEvent` dispatches
* during render mode (see #584).
*
* 2. **Legacy `hf-seek` event.** A `CustomEvent("hf-seek", { detail: { time } })`
* is dispatched on `window` after every callback runs. Existing
* compositions using `window.addEventListener("hf-seek", ...)` keep working
* unchanged.
*
* `window.__hfThreeTime` is also written on every seek for compositions that
* poll the latest time outside of either dispatch path.
*/
export function createThreeAdapter(): RuntimeDeterministicAdapter {
let forcedTime: number | null = null;
let lastForcedTime = 0;
Expand All @@ -8,11 +35,23 @@ export function createThreeAdapter(): RuntimeDeterministicAdapter {
name: "three",
discover: () => {},
seek: (ctx) => {
forcedTime = Math.max(0, Number(ctx.time) || 0);
lastForcedTime = forcedTime;
window.__hfThreeTime = forcedTime;
const time = Math.max(0, Number(ctx.time) || 0);
forcedTime = time;
lastForcedTime = time;
(window as ThreeAdapterWindow).__hfThreeTime = time;
const callbacks = (window as ThreeAdapterWindow).__hfThreeRender;
if (Array.isArray(callbacks)) {
for (const cb of callbacks) {
try {
if (typeof cb === "function") cb(time);
} catch {
// ignore per-callback failures — keep iterating so one broken
// composition layer can't starve sibling layers of seeks.
}
}
}
try {
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: forcedTime } }));
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time } }));
} catch {
// ignore custom event failures
}
Expand All @@ -28,6 +67,13 @@ export function createThreeAdapter(): RuntimeDeterministicAdapter {
revert: () => {
forcedTime = null;
lastForcedTime = 0;
// Don't clear __hfThreeRender — callbacks are owned by the composition.
},
};
}

interface ThreeAdapterWindow extends Window {
__hfThreeTime?: number;
/** Render callbacks registered by compositions for the adapter to invoke on each seek. */
__hfThreeRender?: ((time: number) => void)[];
}
10 changes: 10 additions & 0 deletions packages/core/src/runtime/window.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ declare global {
__HF_FPS?: number;
__HF_MAX_DURATION_SEC?: number;
__hfThreeTime?: number;
/**
* Three.js render callbacks registered by compositions.
* The adapter invokes every callback with the current time on each seek,
* before dispatching the legacy `hf-seek` event.
*
* Push your render function here:
* window.__hfThreeRender = window.__hfThreeRender || [];
* window.__hfThreeRender.push(renderAt);
*/
__hfThreeRender?: ((time: number) => void)[];
__HF_PICKER_API?: HyperframePickerApi;
gsap?: {
timeline: (params?: { paused?: boolean }) => RuntimeTimelineLike;
Expand Down
36 changes: 31 additions & 5 deletions skills/three/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,21 @@ HyperFrames supports Three.js through its `three` runtime adapter. The adapter d

- Create the scene, camera, renderer, materials, and assets synchronously when possible.
- Render from HyperFrames time, not wall-clock time.
- Listen for the `hf-seek` event and render exactly that time.
- Register a render callback so HyperFrames seeks reach your scene on every frame.
- Load models, textures, and HDRIs before render-critical seeking. Do not fetch them at seek time.
- Avoid `requestAnimationFrame` or `renderer.setAnimationLoop` as the source of truth for render-critical motion.

The adapter sets `window.__hfThreeTime` and dispatches `new CustomEvent("hf-seek", { detail: { time } })` on each seek.
On each seek the adapter:

1. Sets `window.__hfThreeTime` to the current time.
2. Invokes every callback in `window.__hfThreeRender` with the current time.
3. Dispatches `new CustomEvent("hf-seek", { detail: { time } })` on `window`.

Prefer the `__hfThreeRender` registry — it mirrors the `__hfAnime` / `__hfLottie`
pattern used by sibling adapters and is robust against listener-registration
ordering issues during render mode (compositions whose `<script type="module">`
imports finish later than the first seek). The `hf-seek` event remains
supported for backward compatibility but is best treated as a fallback.

## Basic Pattern

Expand Down Expand Up @@ -47,9 +57,8 @@ The adapter sets `window.__hfThreeTime` and dispatches `new CustomEvent("hf-seek
renderer.render(scene, camera);
}

window.addEventListener("hf-seek", (event) => {
renderAt(event.detail.time);
});
window.__hfThreeRender = window.__hfThreeRender || [];
window.__hfThreeRender.push(renderAt);

renderAt(window.__hfThreeTime || 0);
</script>
Expand All @@ -72,10 +81,27 @@ function renderAt(time) {
mixer.setTime(time);
renderer.render(scene, camera);
}

window.__hfThreeRender = window.__hfThreeRender || [];
window.__hfThreeRender.push(renderAt);
```

If several mixers exist, seek all of them from the same `time`.

## Multiple Layers

`window.__hfThreeRender` is an array — every layer can register its own
render callback and they all receive the same time on every seek:

```js
window.__hfThreeRender = window.__hfThreeRender || [];
window.__hfThreeRender.push(renderForeground);
window.__hfThreeRender.push(renderBackground);
```

The adapter swallows callback errors so one broken layer cannot starve the
others.

## Good Uses

- Deterministic 3D objects, product spins, particles with seeded data, and shader plates.
Expand Down