motion.ts

examples/fractals/motion.ts · raw · open the example · all examples · site home

Exports: MOTIONSMotionNameMotion

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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// How the scene moves on its own, once nobody has touched it for a while
// (it eases in over 2 s and out within half a second of any input), after
// the Spark lofi example's idle drift:
//   idle sway  the fractal drifts on three slow sines and yaws a little
//              (lofi's idle bee, scaled to the viewing distance)
//   float      the fractal turns slowly about its axis and bobs
//   turntable  the camera circles the target
//   drift      the camera circles, rises and sinks, nears and backs off
//   still      nothing moves
// The fractal's frame is `holder` (its objects' matrixWorld), eased toward
// where the motion wants it, so switching motions never snaps; `spin` (a
// yaw the page's shots drive) is applied on top, unsmoothed. Camera
// motions ease out while the camera is held (a tween or a shot moves it).
// In a view baked from the camera every motion but still becomes a slow
// look around from where the camera stands (moving the eye would show
// surfaces the bake never saw).
import * as THREE from "three";
import type { CameraControls } from "@sparkjsdev/radix";

export const MOTIONS = ["idle sway", "float", "turntable", "drift", "still"] as const;
export type MotionName = (typeof MOTIONS)[number];

const IDLE_AFTER = 4;

export class Motion {
  readonly holder = new THREE.Object3D();
  mode: MotionName = "idle sway";
  speed = 1;
  // Extra yaw of the fractal, radians; once released, it eases to the
  // nearest whole turn
  spin = 0;
  spinReleased = false;
  // Called on any input (the page cancels camera moves of its own)
  onInput?: () => void;
  private countdown = 0.5;
  private blend = 0;
  private cameraBlend = 1;
  private time = 0;
  private turn = 0;
  private readonly position = new THREE.Vector3();
  private readonly rotation = new THREE.Quaternion();
  private readonly wanted = new THREE.Quaternion();
  private readonly euler = new THREE.Euler();
  private readonly spun = new THREE.Quaternion();
  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);

  constructor(canvas: HTMLElement) {
    const touch = () => {
      this.countdown = IDLE_AFTER;
      this.onInput?.();
    };
    for (const type of ["pointerdown", "wheel"] as const) {
      canvas.addEventListener(type, touch, { passive: true });
    }
    addEventListener("keydown", (event) => {
      if (!(event.target as HTMLElement).closest("input, select, textarea") && ["w", "a", "s", "d", "q", "e", "arrowup", "arrowdown", "arrowleft", "arrowright"].includes(event.key.toLowerCase())) {
        touch();
      }
    });
  }

  setMode(mode: MotionName) {
    this.mode = mode;
  }

  // Whether nobody has touched the controls for a while
  get idle() {
    return this.countdown < 0;
  }

  update(seconds: number, camera: THREE.PerspectiveCamera, controls: CameraControls, { held = false, baked = false } = {}) {
    this.countdown -= seconds;
    this.blend = this.countdown < 0 ? Math.min(1, this.blend + seconds / 2) : Math.max(0, this.blend - seconds * 2);
    this.cameraBlend = held ? Math.max(0, this.cameraBlend - seconds * 1.5) : Math.min(1, this.cameraBlend + seconds / 2);
    const b = THREE.MathUtils.smoothstep(this.blend, 0, 1);
    const dt = seconds * this.speed * b;
    this.time += seconds * this.speed;
    const t = this.time;
    const mode = baked && this.mode !== "still" ? "look around" : this.mode;
    const reach = 0.2 * Math.min(controls.distance / 3.4, 1.5);

    this.position.set(0, 0, 0);
    this.euler.set(0, 0, 0);
    if (mode === "idle sway") {
      this.position.set(Math.sin(t * 0.3), Math.sin(t * 0.2), Math.sin(t * 0.5)).multiplyScalar(reach * b);
      this.euler.set(0, 0.1 * Math.sin(t * 0.37) * b, 0);
    } else if (mode === "float") {
      this.turn += dt * 0.18;
      this.position.set(0, Math.sin(t * 0.6) * reach * 0.35 * b, 0);
      this.euler.set(0.06 * Math.sin(t * 0.23) * b, this.turn, 0.04 * Math.sin(t * 0.31) * b);
    } else if (mode === "look around" && !held) {
      // Turns of the camera in place, on the derivatives of slow sines
      camera.rotateOnWorldAxis(this.up, 0.035 * 0.37 * Math.cos(t * 0.37) * dt);
      camera.rotateX(0.02 * 0.29 * Math.cos(t * 0.29) * dt);
    } else if ((mode === "turntable" || mode === "drift") && this.cameraBlend > 0) {
      const step = dt * this.cameraBlend;
      controls.getTarget(this.target);
      this.offset.copy(camera.position).sub(this.target).applyAxisAngle(this.up, step * (mode === "drift" ? 0.06 : 0.09));
      if (mode === "drift") {
        // Elevation and distance on slow sines (their derivatives, so the
        // user's pose stays the center)
        const elevation = Math.asin(THREE.MathUtils.clamp(this.offset.y / this.offset.length(), -1, 1));
        const rise = 0.3 * 0.11 * Math.cos(t * 0.11) * step;
        if (Math.abs(elevation + rise) < 1.2) {
          this.axis.crossVectors(this.up, this.offset).normalize();
          this.offset.applyAxisAngle(this.axis, -rise);
        }
        this.offset.multiplyScalar(Math.exp(0.12 * 0.17 * Math.cos(t * 0.17) * step));
      }
      camera.position.copy(this.target).add(this.offset);
      camera.lookAt(this.target);
    }

    // Ease toward the wanted pose, then the spin on top
    const ease = 1 - Math.exp(-seconds * 4);
    if (this.spinReleased) {
      const whole = Math.round(this.spin / (Math.PI * 2)) * Math.PI * 2;
      this.spin += (whole - this.spin) * (1 - Math.exp(-seconds * 2.5));
      if (Math.abs(whole - this.spin) < 1e-3) {
        this.spin = 0;
        this.spinReleased = false;
      }
    }
    this.holder.position.lerp(this.position, ease);
    this.rotation.slerp(this.wanted.setFromEuler(this.euler), ease);
    this.holder.quaternion.copy(this.rotation).premultiply(this.spun.setFromAxisAngle(this.up, this.spin));
    this.holder.updateMatrixWorld(true);
  }
}