* feat(core): hold render-ready on declared compute and a build-ready hold, not just duration Adds the compute and paint-and-idle inputs to the composition readiness gate, plus window.__hf.buildReady: a promise registry a piece registers when it has setup work no adapter can see (mesh building, shader compiles). The runtime holds render-ready on it and the player shows its loading state instead of a blank frame while it resolves. * refactor(core): dedupe the paint-and-idle readiness test setup Simplify pass on 23810da1b: fallow flagged the two paintAndIdleReadinessInput tests as a 12-line clone; extracted the shared doc/tracking setup. * fix(core): stop the readiness settled-tracker hanging on 2+ pending promises Promise.all allocates a new promise every call, so comparing ITS identity across repeat polls (isAdapterReadinessSettled, isBuildReadinessSettled) never observes settled once 2+ promises are combined: every poll rebuilds a new combined promise, re-arms, and republishes forever without the caller ever seeing true. Reproduced with two already-resolved window.__hf.buildReady entries, which is the exact multi-piece usage the new authoring doc describes. Fixes both trackers at once with a shared createSettledTracker that compares the source promise array by element instead of the Promise.all wrapper. * fix(core): cancel readiness inputs once settleCompositionReadiness settles computeReadinessInput's poll timer and paintAndIdleReadinessInput's rAF chain kept running after the shared timeout won the race, since nothing told the losing inputs to stop. One AbortSignal now threads through all three inputs and is aborted the instant the race settles. * docs(core): fix a stale sync-callback claim in settleCompositionReadiness paintAndIdleReadinessInput always has a frame to wait on for any real document, so the sync fast path this docstring described is no longer reachable with the default inputs except for a viewless document. Found by the simplify pass on this branch. * fix(core): clear a stale buildReady entry on runtime teardown A never-resolved window.__hf.buildReady promise from a torn-down composition survived into the next init in the same window (window.__hf is never reset), permanently blocking render-ready for whatever loaded next -- the exact black-screen class this branch fixes, reproduced through its own new mechanism. Found by the independent review. Also fixes a stale hyperframes-player.ts docstring (media/images/fonts had not been updated to compute + paint-and-idle) and documents the buildReady key-collision hazard in three.md. * fix(core): skip the compute readiness wait when no runtime ever ran computeReadinessInput polled window.__renderReady for the full shared timeout on any same-origin document with no HyperFrames runtime injected (a probe-only composition using native video/duration, or a blank test iframe) — nothing was ever going to set that flag. Checking window.__hf first (the same signal composition-probe.ts already treats as "has a runtime") lets it return null instead of waiting. * fix(player): debounce the loading overlay and diagnose compute timeouts paintAndIdleReadinessInput always has a frame to wait on for an attached iframe, so settleCompositionReadiness's synchronous-settle path is unreachable in practice now — the loading overlay showed on every single Play, even ones with nothing genuinely pending. Debounce the overlay itself instead: a wait that resolves fast never shows it. Also names compute (window.__renderReady) in the stuck-assets timeout diagnostic, which previously only re-scanned media, images and fonts. * fix(engine): name a stuck buildReady key in the zero-duration diagnostic A build promise a composition registers on window.__hf.buildReady that never resolves left the export path's zero-duration timeout naming nothing useful — hasHf/hasSeek/duration all looked fine, since the runtime was genuinely present and just waiting on that one promise. Probes buildReady's keys for pending ones and names them in the hint. * fix(engine): stop the buildReady diagnostic from crashing on a rejection A microtask race against a bare resolved promise rejected the whole diagnostic expression on any rejected buildReady entry (the runtime's own createSettledTracker rejects deliberately) and mis-sorted a settled non-native thenable as pending, since its extra adoption tick loses the race. Both measured directly, not inferred. Settle each key onto its own record past one macrotask instead: no throw, and pending/resolved/rejected are distinguished correctly. A rejected key gets its own hint, separate from a merely pending one. * fix(player): report a backgrounded document instead of overclaiming a paint bound MAX_PAINT_WAIT_MS only bounds a document that keeps producing frames; one that stops entirely (backgrounded tab suspends rAF, or fewer than 3 frames ever fire) still rides the full shared 8s timeout, same as before this input existed. Corrected the comments that claimed otherwise. That gap is exactly why the stuck-assets diagnostic left paint-and-idle unreported: there was no accurate way to say it might be the cause. Added documentHidden (doc.hidden) to the diagnostic instead, since a backgrounded tab is the reachable, directly observable case. * refactor(engine): simplify the buildReady diagnostic rewrite Skip the macrotask wait entirely when a composition registers no buildReady keys — the common case, since it's opt-in. Nothing to settle means nothing to wait on. Collapse buildZeroDurationDiagnostic's inline parameter type into the existing HfDiagnostic interface instead of maintaining two copies of the same shape. * docs(engine): note the setTimeout(0)-thenable gap the review found An independent adversarial review, executing hostile inputs rather than tracing, found the macrotask wait can still misreport a thenable that resolves via its own internal setTimeout(0), since the diagnostic's own timer is registered before the thenable's. Bounded and self-correcting (only feeds the message text, and the diagnostic re-runs every ~1s), so documenting the boundary rather than chasing an unbounded fix.
6.3 KiB
name, description
| name | description |
|---|---|
| hyperframes-three | Three.js and WebGL adapter patterns for HyperFrames. Use when creating deterministic Three.js scenes, WebGL canvas layers, AnimationMixer timelines, camera motion, shader-driven visuals, or canvas renders that respond to HyperFrames hf-seek events. |
Three.js for HyperFrames
HyperFrames supports Three.js through its three runtime adapter. The adapter does not own your scene. It publishes HyperFrames time and dispatches a seek event so your composition can render the exact frame.
Contract
- Create the scene, camera, renderer, materials, and assets synchronously when possible.
- Render from HyperFrames time, not wall-clock time.
- Listen for the
hf-seekevent and render exactly that time. - Load models, textures, and HDRIs before render-critical seeking. Do not fetch them at seek time.
- Avoid
requestAnimationFrameorrenderer.setAnimationLoopas the source of truth for render-critical motion. - Always set
data-duration="<seconds>"on the root[data-composition-id]element. Unlike CSS/WAAPI/Lottie, thethreeadapter has no duration auto-inference — it only forwards time viahf-seek/__hfThreeTime, it doesn't inspect your scene for anAnimationClip/AnimationMixerlength. Withoutdata-duration(and no GSAP timeline), the render engine has no way to know how long to capture and fails with "Composition has zero duration".npx hyperframes linterrors on this (root_composition_missing_duration_source).
The adapter sets window.__hfThreeTime and dispatches new CustomEvent("hf-seek", { detail: { time } }) on each seek.
Basic Pattern
<canvas id="three-layer"></canvas>
<script type="module">
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.181.2/+esm";
const canvas = document.getElementById("three-layer");
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
// Match these to your composition's frame size.
renderer.setSize(1920, 1080, false);
renderer.setPixelRatio(1);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, 1920 / 1080, 0.1, 100);
camera.position.set(0, 0, 6);
const mesh = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.4, 4),
new THREE.MeshStandardMaterial({ color: 0x64d2ff, roughness: 0.38 }),
);
scene.add(mesh);
scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 2));
function renderAt(time) {
mesh.rotation.y = time * 0.7;
mesh.rotation.x = Math.sin(time * 0.6) * 0.16;
renderer.render(scene, camera);
}
window.addEventListener("hf-seek", (event) => {
renderAt(event.detail.time);
});
renderAt(window.__hfThreeTime || 0);
</script>
#three-layer {
width: 100%;
height: 100%;
display: block;
}
Loading Addons (GLTFLoader, OrbitControls, etc.)
For anything under three/addons/, use an importmap so bare specifiers resolve. The HyperFrames lint recognizes both this form and the inline +esm import above — pick whichever your composition needs.
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.181.2/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
// ...
</script>
Pin the three version in both entries to the same value. Mixing versions across the map and bare imports causes silent breakage.
Heavy Setup (Large Meshes, Shader Compiles)
The runtime already waits for textures/models queued through Three's DefaultLoadingManager before publishing render-ready. It has no visibility into CPU-bound work you do yourself after assets load — building a large procedural mesh, compiling shaders, warming a pipeline. That work can leave the canvas blank for seconds after the runtime and player already say "ready".
If your setup does this kind of work, register a promise on window.__hf.buildReady (declared-compute hold: runtime waits, player shows its loading state instead of a blank frame):
window.__hf = window.__hf || {};
window.__hf.buildReady = window.__hf.buildReady || {};
window.__hf.buildReady["<your-piece-name>"] = buildScene(); // resolves once the scene is actually drawable
Register it synchronously, in the same script block that starts the build — same timing as DefaultLoadingManager, so the runtime's first readiness check already sees it. Only do this for setup an adapter cannot see; render-critical seeking still comes from hf-seek, not this hold. The key must be unique within the composition — a second registration under the same key silently replaces the first, dropping its hold.
AnimationMixer Pattern
For GLTF or authored clip animation, seek the mixer directly:
function renderAt(time) {
mixer.setTime(time);
renderer.render(scene, camera);
}
If several mixers exist, seek all of them from the same time.
Good Uses
- Deterministic 3D objects, product spins, particles with seeded data, and shader plates.
- Camera moves derived from
time. - GLTF animation clips when assets are local and loaded before validation completes.
Avoid
- Using
Date.now(),performance.now(), or clock deltas to update scene state. - Leaving render-critical work inside a free-running animation loop.
- Loading remote models or textures at render time.
- Device-pixel-ratio dependent output. Pin renderer size and pixel ratio for video renders.
- Post-processing passes that depend on previous frame history unless you can reconstruct state from time.
Validation
After editing a Three.js composition:
npx hyperframes lint
npx hyperframes check
Credits And References
- HyperFrames adapter source:
packages/core/src/runtime/adapters/three.ts. - Why
data-durationis required here specifically (no auto-inference for this adapter):packages/core/src/runtime/init.ts(resolveAdapterDurationFloorSeconds) and the CSS/WAAPI/Lottie adapters'getInferredDurationSeconds, which thethreeadapter deliberately does not implement. - Three.js
WebGLRendererdocs: https://threejs.org/docs/pages/WebGLRenderer.html - Three.js
AnimationMixer.setTime()docs: https://threejs.org/docs/pages/AnimationMixer.html