Skill 18 · Hyperframes Animation
Subchapter 18.17
adapters/three.mdMarkdown6 KBView on GitHub
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.
hf-seek event and render exactly that time.requestAnimationFrame or renderer.setAnimationLoop as the source of truth for render-critical motion.data-duration="<seconds>" on the root [data-composition-id] element. Unlike CSS/WAAPI/Lottie, the three adapter has no duration auto-inference — it only forwards time via hf-seek/__hfThreeTime, it doesn’t inspect your scene for an AnimationClip/AnimationMixer length. Without data-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 lint errors on this (root_composition_missing_duration_source).The adapter sets window.__hfThreeTime and dispatches new CustomEvent("hf-seek", { detail: { time } }) on each seek.
<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;
}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.
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 drawableRegister 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.
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.
time.Date.now(), performance.now(), or clock deltas to update scene state.After editing a Three.js composition:
npx hyperframes lint
npx hyperframes checkpackages/core/src/runtime/adapters/three.ts.data-duration is required here specifically (no auto-inference for this adapter): packages/core/src/runtime/init.ts (resolveAdapterDurationFloorSeconds) and the CSS/WAAPI/Lottie adapters’ getInferredDurationSeconds, which the three adapter deliberately does not implement.WebGLRenderer docs: https://threejs.org/docs/pages/WebGLRenderer.html (opens in a new tab)AnimationMixer.setTime() docs: https://threejs.org/docs/pages/AnimationMixer.html (opens in a new tab)