Three.js and canvas
Draw a canvas or a three.js scene as a pure function of the seek time, so every render gives the same frames.
A canvas, a WebGL canvas and a three.js scene all work on a vawe pageA page is one HTML file, films/<name>/page.html, that describes a whole film.. The rule is the same for each: the page paints frame t as a pure function of t. The renderer calls your code once for each frame, in any order, and takes a picture.
Why the rule exists
A normal canvas page runs a requestAnimationFrame loop and reads the real clock. The cube is then at a different angle each time a screenshot lands. In a test, a cube spun from clock.getElapsedTime(). Thirty screenshots on one page and thirty on a fresh page gave 30 pairs of different hashes.
The renderer fixes this in two ways. It seeks the page instead of playing it. It also replaces the clock, so code that reads the time sees the seek time.
How the seek reaches your code
For each frame, core/engine/page-seek.js runs these steps in order:
- Set the virtual clockThe virtual clock is the code in core/engine/page-clock.js that controls Date, requestAnimationFrame, timers and Math.random during a render. to
t. - Call
window.seek(t), if the page defines it. - Seek every CSS and Web Animation and every SVG animation.
- Call every
vawe.onFramehook witht.
The renderer waits for a promise that window.seek or a hook returns. So a hook may decode a texture or build geometry before the picture is taken. See Time and seek.
The virtual clock and random
core/engine/page-clock.js runs before any page script. It replaces these:
| API | What the page sees |
|---|---|
Date, performance.now | film time in milliseconds |
requestAnimationFrame | callbacks run once for each seek, with the film time |
setTimeout, setInterval | fire when a seek passes their due time, in due order |
Math.random | a mulberry32 generator, reseeded from t on every seek |
Math.random is a function of the time. At the same t it gives the same sequence, and at the next frame a different sequence. A particle placed with Math.random() therefore jumps on every frame.
Use a seeded generator instead. rng(seed) in core/motion/springs.js returns a function with uniform numbers in [0, 1). The same seed gives the same sequence, in any render order. noise1(x, seed) gives smooth noise in [-1, 1].
import { rng } from '../../core/motion/springs.js';
const random = rng(7);
const dots = Array.from({ length: 80 }, () => ({ x: random(), y: random(), r: 3 + random() * 9 }));Build the list once, outside the seek. Pose it from t inside the seek.
A canvas 2D page
<meta name="duration" content="4">
<canvas id="c"></canvas>
<script type="module">
import { rng } from '../../core/motion/springs.js';
const canvas = document.getElementById('c');
canvas.width = innerWidth;
canvas.height = innerHeight;
const ctx = canvas.getContext('2d');
const random = rng(7);
const dots = Array.from({ length: 80 }, () => ({ x: random(), y: random(), r: 3 + random() * 9 }));
window.seek = (t) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
for (const d of dots) {
ctx.beginPath();
ctx.arc(d.x * canvas.width, ((d.y + t * 0.05 * d.r) % 1) * canvas.height, d.r, 0, Math.PI * 2);
ctx.fill();
}
};
</script>Every dot is placed from t alone. Frame 400 does not need frame 399. For springs and tables, see Motion.
A three.js page
Load the vendored three.js through an import map. assets/vendor/ holds three.js r185 as an ES module, and its two files must sit side by side.
<meta name="duration" content="3">
<script type="importmap">{"imports":{"three":"/assets/vendor/three.module.min.js"}}</script>
<canvas id="gl"></canvas>
<script type="module">
import * as THREE from 'three';
window.THREE = THREE;
import '/core/engine/page-api.js';
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('gl'), antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(1);
renderer.setSize(innerWidth, innerHeight, false);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 0, 6);
vawe.three.studio(renderer, scene, { kind: 'metal' });
const cube = new THREE.Mesh(new THREE.BoxGeometry(1.6, 1.6, 1.6), vawe.three.material('chrome'));
scene.add(cube);
vawe.onFrame((t) => {
cube.rotation.set(0.4 + t * 0.5, t * 1.6, 0);
cube.position.y = Math.sin(t * 2.1) * 0.25;
renderer.render(scene, camera);
});
</script>preserveDrawingBuffer: truelets the capture read the canvas after the draw call. Without it the capture can read a cleared buffer.setPixelRatio(1)fixes the frame size. A draft captures at device scale 0.5, but the CSS viewport is the same size as in the final.window.THREE = THREElets thevawe.threehelpers find the library. They never import it themselves.- Import
core/engine/page-api.jsto getvawe.threeandvawe.value.vawe.onFrameexists without that import. - Pose every object from
t. Do not useTHREE.Clock,getDelta, anAnimationMixerorMath.random.
The helpers
| helper | what it does |
|---|---|
vawe.three.studio(renderer, scene, { kind }) | Adds one lighting rig of four lights and a procedural room. kind is 'metal' (default) or 'glass'. |
vawe.three.extrude(pathD, { depth, bevel, scale }) | Makes a solid from an SVG path. It reads M, L, H, V, C, Q and Z, and throws on an arc. |
vawe.three.material(preset) | A tuned material: chrome, iridescent, glass, frostedGlass, metal or matte. |
vawe.value(row, t) | Samples a timing row { at, dur, from, to, ease } as a number, so a 3D scale and an HTML transform share one timing. |
Mix canvas and HTML
Put text in HTML above the canvas, not in the canvas. The text checks read only DOM text, and the fonts of the page load before the picture. See Text and fonts. A element.animate() entrance seeks with the same clock as your canvas code, so one timing system drives both. films/examples/three-star/page.html does this: a star in three.js and one HTML word on a Web Animations entrance.
To film the canvas like a real camera, give the canvas to the lens.
import { lens, canvasSource } from '../../core/surfaces/lens.js';
const cam = lens(canvasSource(renderer.domElement), { look: 'film-halation' });
vawe.onFrame(async (t) => { renderer.render(scene, camera); await cam.draw(t); });See Lens and effects.
Load assets without a black first frame
The renderer waits for image decodes, fetch calls and document.fonts. It waits for nothing else. A module-level await of another promise, such as a dynamic import(), lets the renderer paint frame 0 before your hook exists. That frame is black.
- Load an image with
img.decode()and build the scene inside the first hook call. - Or register the hook first and
awaitinside it.
Check the page
bin/vawe check anim-traps films/<name>/page.htmlFor a canvas page the gate reads the source and reports:
- a
requestAnimationFramecall,performance.now()orDate.now() THREE.Clockor.getDelta()- an unseeded
Math.random() setTimeoutorsetInterval- a CSS
transitionused for motion - a
WebGLRendererbuilt withoutpreserveDrawingBuffer - a page that draws WebGL and registers no
onFramehook
Read Determinism for the other traps.
Limits
- The checks cannot read text inside a canvas.
- A scene that builds a large mesh pays for it on the first seek of each render slice. Build once, and reuse the object.
- A fast three.js film costs more in the final, because motion blur adds subframes. See Motion blur.
Determinism
Image to particles
Lens and effects
The page API
From the blogThree.js to MP4: render a scene frame by frame · Seed Math.random and freeze Date.now in JavaScript · Image to particles in three.js: a step-by-step guide