Animating
A Graphics3D is rebuilt from scratch every frame, so animation is just reading changing values while you build it.
const spin = createSignal(0);
<View3D graphics3D={() => new Graphics3D()
.perspective({ position: [0, 2, 6], lookAt: 0 })
.directional({ intensity: 2, position: [4, 6, 3] })
.box({ width: 2, color: 'tomato', rotation: [0, spin(), 0] })}
/>
yield* spin(360, 2, easeInOut('quad'));
Nothing accumulates, so frame 10 looks the same whether you played to it or scrubbed back to it.
Signals need a lerp for non-numbers
A signal holding a number interpolates for free. A signal holding anything else needs an explicit lerp, or it will snap at the end of the tween instead of interpolating. There is no error, so this is easy to miss.
import { createSignal, lerpVector3, lerpEuler3, slerpQuaternion } from 'motion-script';
const spin = createSignal(0); // fine
const pos = createSignal({ x: 0, y: 0, z: 0 }, lerpVector3); // needs the lerp
const rot = createSignal({ x: 0, y: 0, z: 0 }, lerpEuler3); // needs the lerp
const quat = createSignal(IDENTITY_QUATERNION, slerpQuaternion); // needs the lerp
Use slerpQuaternion with a quaternion for tumbles where Euler interpolation would gimbal lock.
There is no t
A graphics3D={() => ...} prop is a reactive binding. It re-evaluates when the signals it reads change. clock.elapsed is a plain field, not a signal, so this computes once and freezes:
// Wrong. Runs once, then never again. No error, just a still image.
graphics3D={() => build(this.clock.elapsed)}
Two correct options:
- Drive the motion from a tweened signal. This also puts it on the timeline, so it scrubs.
- Read
this.clock.elapsedinsidebuildGraphics3D(), which does re-run every frame.
class Spinner extends View3D {
protected override buildGraphics3D(): Graphics3D {
const t = this.clock.elapsed;
return new Graphics3D()
.perspective({ position: [0, 2, 6], lookAt: 0 })
.box({ width: 2, rotation: [0, t * 90, 0] });
}
}
Prefer signals over the clock
A signal is usually the better choice even when the clock would work. A tweened signal is part of the timeline, so it scrubs, exports and previews consistently, and its speed is described in the scene rather than derived from wall time.
const phase = createSignal(0);
// A travelling wave, driven linearly across the scene
yield* phase(20, 4, linear());
What is cheap to change
Not every field costs the same per frame.
| Kind of change | Cost | Notes |
|---|---|---|
| Transforms | Free | position, rotation, scale, lookAt are in-place writes |
| Material and light values | Free | Colours, intensities, roughness, opacity |
| Shader uniform values | Free | The program is not recompiled |
| Geometry parameters | Expensive | Geometries are immutable, so the mesh is reallocated every frame |
| Structural material fields | Expensive | Recompiles the shader program |
The trap is geometry. .box({ width: signal() }) rebuilds the geometry on every frame. Scale the object instead:
// Slow
.box({ width: size() })
// Fast, same result
.box({ width: 1, scale: [size(), 1, 1] })
The same applies to sphere radius, torus tube and every other geometry parameter. Model the change as a transform where you can.
Structural material fields are ones that change how the shader is built rather than what it is fed, such as vertexColors, flatShading and turning shadows on. Set them once rather than tweening them.
Keys
The renderer caches one live object per operation and mutates it between frames rather than rebuilding it. Identity comes from the operation's structural path: its group() nesting plus its index within that group. That is stable for a builder that emits the same operations in the same order every frame, which is the normal case.
When your builder emits operations conditionally, set key so identity follows the logical object rather than the slot:
if (showFloor) {
g3.plane({ width: 60, height: 60, rotation: [-90, 0, 0], key: 'floor' });
}
screens.forEach(screen => {
g3.group({ position: [screen.x, 0, 0], key: `monitor:${screen.id}` }, m => m
.box({ width: 4, height: 2.4, color: '#15181f' }));
});
Without a key, inserting an operation renumbers every later slot and forces the rest of the cache to rebuild.
Cameras animate like anything else
The camera is a value in the same builder, so orbiting is just arithmetic on a signal:
const angle = createSignal(0); // degrees
graphics3D={() => {
const a = (angle() * Math.PI) / 180;
return new Graphics3D()
.perspective({ position: [Math.cos(a) * 12, 5, Math.sin(a) * 12], lookAt: 0, fov: 45 })
.directional({ intensity: 2, position: [4, 6, 3] })
.torusKnot({ radius: 1.5, tube: 0.4, color: 'tomato' });
}}
yield* angle(360, 6, easeInOut('quad'));
When an object moves, aim the camera at it rather than at the origin, or it will leave the frame:
.perspective({ position: [0, 2.5, 6], lookAt: lift(), fov: 45 })
Animating a component's props
For a reusable component, put the knobs on the node with @property and animate them with .to(), exactly as for any other custom node:
class Turntable extends View3D<TurntableProps> {
@property({ default: 0 }) declare orbit: number;
@property({ default: 8 }) declare zoom: number;
protected override buildGraphics3D(): Graphics3D {
const a = (this.orbit * Math.PI) / 180;
return new Graphics3D()
.perspective({ position: [Math.cos(a) * this.zoom, 2, Math.sin(a) * this.zoom], lookAt: 0 })
.directional({ intensity: 2, position: [4, 6, 3] })
.torus({ radius: 1.5, tube: 0.4, color: 'tomato' });
}
}
yield* table().to({ orbit: 180, zoom: 5 }, 4, easeInOut('quad'));
Non-numeric props need a tween, including booleans, since .to() only drives keys that are numeric or carry one. See Properties.