Files
cycle-atlas/.agents/skills/hyperframes-animation/adapters/three.md
T
2026-10-14 21:51:46 +00:00

8.0 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-seek event and render exactly that time.
  • 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.
  • Always set 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.

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.

Camera Cuts Inside One Canvas

When one scene switches camera shots by time, declare each cut once and read that constant everywhere: in the camera's shot selector and in every object's visibility window. A window written as its own literal near the cut (t > 22.9 against a cut at t < 23) shows the next shot's objects in the last frames of the previous shot. At 30 fps that is a two-frame pop-in, which one still per shot will not show.

const CUT_B = 23;
const CUT_C = 35;

function shotFor(t) {
  if (t < CUT_B) return shotA(t);
  if (t < CUT_C) return shotB(t);
  return shotC(t);
}

function renderAt(t) {
  const shot = shotFor(t);
  camera.position.copy(shot.position);
  camera.lookAt(shot.target);
  shotBProps.forEach((mesh) => {
    mesh.visible = t >= CUT_B && t < CUT_C;
  });
  renderer.render(scene, camera);
}

Verify each cut on the last frame before it and the first frame after it, not only somewhere inside each shot. The last frame before a cut is (Math.ceil(cut * fps) - 1) / fps: for a cut at 23 s in a 30 fps render, npx hyperframes snapshot --at 22.9667,23 --no-end.

Lighting Lit Meshes

A MeshStandardMaterial lit by one PointLight or DirectionalLight, with no ambient light, environment map or emissive color, gets no light on its unlit side. When the camera faces that light, the mesh shows only that side and renders as a flat black cutout. Add a fill that stands in for the environment's bounce light, such as a HemisphereLight in the colors of the surrounding glow. If the key light's brightness changes over time, drive both lights from the same source at a fixed ratio, tuned by eye. Their intensity units differ, so do not copy the key light's number onto the fill.

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-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.
  • Three.js WebGLRenderer docs: https://threejs.org/docs/pages/WebGLRenderer.html
  • Three.js AnimationMixer.setTime() docs: https://threejs.org/docs/pages/AnimationMixer.html