shots.ts
examples/fractals/shots.ts · raw · open the example · all examples · site home
Exports: SHOTSShotNameDirector
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
// Camera shots: what the view does on its own after a transition lands,
// until the next one starts or the user takes over.
// hold nothing more than the idle motion
// spin the fractal turns once around, quickly, and settles
// zoom the camera closes in for a few seconds, then backs out
// orbit the camera circles the whole shot long
// truck the camera slides sideways and back, the target with it
// crane the camera rises over the fractal
// push the camera slowly closes in and stays
// Shots move the camera by increments (each frame, the change of a smooth
// curve since the last), so they never undo the user's own moves.
import * as THREE from "three";
import type { CameraControls } from "@sparkjsdev/radix";
import type { Motion } from "./motion.js";
export const SHOTS = ["hold", "spin", "zoom", "orbit", "truck", "crane", "push"] as const;
export type ShotName = (typeof SHOTS)[number];
const smoother = (x: number) => THREE.MathUtils.smootherstep(x, 0, 1);
const easeInOut = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2);
export class Director {
name: ShotName = "hold";
private time = 0;
private running = false;
private direction = 1;
// The curve's value at the last frame (scale, angle or offset)
private last = 0;
private readonly target = new THREE.Vector3();
private readonly offset = new THREE.Vector3();
private readonly axis = new THREE.Vector3();
private readonly up = new THREE.Vector3(0, 1, 0);
start(name: ShotName) {
this.name = name;
this.time = 0;
this.last = 0;
this.direction = Math.random() < 0.5 ? -1 : 1;
this.running = name !== "hold";
}
stop(motion?: Motion) {
if (motion && this.running && this.name === "spin") {
motion.spinReleased = true;
}
this.running = false;
}
// Whether the camera is the shot's (camera motions ease out)
get holds() {
return this.running && this.name !== "spin";
}
update(seconds: number, camera: THREE.PerspectiveCamera, controls: CameraControls, motion: Motion) {
if (!this.running) {
return;
}
this.time += seconds;
const t = this.time;
controls.getTarget(this.target);
this.offset.copy(camera.position).sub(this.target);
// Each shot: its curve now, applied as the change since the last frame
const advance = (value: number) => {
const delta = value - this.last;
this.last = value;
return delta;
};
if (this.name === "spin") {
motion.spinReleased = false;
const x = Math.min(t / 3.2, 1);
motion.spin = this.direction * Math.PI * 2 * easeInOut(x);
if (x >= 1) {
motion.spin = 0;
this.running = false;
}
return;
}
if (this.name === "zoom" || this.name === "push") {
// Log of the distance scale: in, hold, out (zoom) or in to stay (push)
const depth = this.name === "zoom" ? Math.log(0.5) : Math.log(0.72);
const curve = this.name === "zoom" ? smoother(Math.min(t / 3, 1)) - smoother(THREE.MathUtils.clamp((t - 6.5) / 3, 0, 1)) : smoother(Math.min(t / 12, 1));
this.offset.multiplyScalar(Math.exp(advance(depth * curve)));
this.running = this.name === "zoom" ? t < 9.5 : t < 12;
} else if (this.name === "orbit") {
// Eases up to a slow circle and keeps going; a gentle rise and fall
const angle = this.direction * 0.11 * (t - 2 + 2 * Math.exp(-t / 2));
this.offset.applyAxisAngle(this.up, advance(angle));
this.axis.crossVectors(this.up, this.offset).normalize();
this.offset.applyAxisAngle(this.axis, -0.04 * 0.2 * Math.cos(t * 0.2) * seconds * Math.min(t / 2, 1));
} else if (this.name === "truck") {
// Out to one side and back over 16 s, turning a little toward it
const side = this.direction * 0.22 * Math.min(this.offset.length() / 3.4, 1.4) * (1 - Math.cos((Math.PI * 2 * Math.min(t, 16)) / 16)) / 2;
this.target.add(this.axis.crossVectors(this.offset, this.up).normalize().multiplyScalar(advance(side)));
this.running = t < 16;
} else if (this.name === "crane") {
const angle = THREE.MathUtils.degToRad(28) * smoother(Math.min(t / 12, 1));
const elevation = Math.asin(THREE.MathUtils.clamp(this.offset.y / this.offset.length(), -1, 1));
const delta = advance(angle);
if (elevation + delta < 1.25) {
this.axis.crossVectors(this.up, this.offset).normalize();
this.offset.applyAxisAngle(this.axis, -delta);
}
this.running = t < 12;
}
camera.position.copy(this.target).add(this.offset);
controls.lookAt(this.target);
}
}