diff --git a/docs/concepts/frame-adapters.mdx b/docs/concepts/frame-adapters.mdx index 63388431a..12cb0d5b1 100644 --- a/docs/concepts/frame-adapters.mdx +++ b/docs/concepts/frame-adapters.mdx @@ -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. diff --git a/packages/core/src/runtime/adapters/three.test.ts b/packages/core/src/runtime/adapters/three.test.ts index ac886f889..6b3f45c74 100644 --- a/packages/core/src/runtime/adapters/three.test.ts +++ b/packages/core/src/runtime/adapters/three.test.ts @@ -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", () => { @@ -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 }); @@ -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(); diff --git a/packages/core/src/runtime/adapters/three.ts b/packages/core/src/runtime/adapters/three.ts index 49704e50c..55a0e75ad 100644 --- a/packages/core/src/runtime/adapters/three.ts +++ b/packages/core/src/runtime/adapters/three.ts @@ -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; @@ -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 } @@ -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)[]; +} diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index ceb2ece0e..2518c17a5 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -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; diff --git a/skills/three/SKILL.md b/skills/three/SKILL.md index 72d1de64f..c563c44a9 100644 --- a/skills/three/SKILL.md +++ b/skills/three/SKILL.md @@ -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 ` @@ -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.