diff --git a/README.md b/README.md index c6d9e61..9659f7a 100644 --- a/README.md +++ b/README.md @@ -159,8 +159,9 @@ Single-letter keys clash with vim-key browser extensions (Vimium and the like), - `blob/`: the liquid artwork. One WebGL2 canvas moves into the playing card: an icosphere displaced by noise driven by those features (lobes, ripples, snare spikes, a ring after each kick), showing the artwork through its surface. Tempo, tags and key set its character - (`blob/style.ts`). It steps its quality down on slow devices and falls back to the plain - artwork without WebGL2, with reduced motion, or with visuals set to simple. + (`blob/style.ts`). Dragging it spins it; a flick keeps spinning and springs back to face + the front (`blob/spin.ts`). It steps its quality down on slow devices and falls back to the + plain artwork without WebGL2, with reduced motion, or with visuals set to simple. - `visual.ts`: runs both per frame, plus `--energy` (kick-driven) for the CSS glow. - `station.ts`: the radio sheet. - `color.ts`: each card's accent colour (`--card-accent`) from the artist image. diff --git a/web/index.html b/web/index.html index 9c33e71..460081c 100644 --- a/web/index.html +++ b/web/index.html @@ -186,7 +186,7 @@
m
mute
?
this help
-

Lock-screen and headphone buttons work too.

+

Drag the liquid artwork sideways to spin it. Lock-screen and headphone buttons work too.

Keys like r or x doing something else, such as reloading or closing the tab? A vim-keys browser extension (Vimium and the like) takes them first: turn it off for this site.

diff --git a/web/src/blob/blob.ts b/web/src/blob/blob.ts index 0e6a1c7..f4d8c93 100644 --- a/web/src/blob/blob.ts +++ b/web/src/blob/blob.ts @@ -5,6 +5,7 @@ import type { Features } from "../features"; import { icosphere } from "./mesh"; import { FRAGMENT, VERTEX } from "./shaders"; +import { Spin } from "./spin"; import type { Look } from "./style"; const FOV = (30 * Math.PI) / 180; @@ -22,6 +23,7 @@ const PUMP = 0.7; // size kick of a snare hit const PUMP_STIFF = 160; const PUMP_DAMPING = 12; const THROB = 0.09; // size swing with the bass line's envelope +const WHIRL = 15; // rad/s of hand spin at which it bulges and sloshes the most const SNARE_ATTACK = 0.03; // s; spikes grow in rather than pop const SNARE_RELEASE = 0.15; // s const TEXTURE_SIZE = 512; @@ -32,7 +34,7 @@ const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]]; const UNIFORMS = [ "uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop", "uBass", "uGrowl", - "uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer", + "uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uScale", "uLift", "uJelly", "uPointer", "uRot", "uFront", "uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat", ] as const; @@ -40,6 +42,8 @@ export class LiquidBlob { readonly canvas = document.createElement("canvas"); /// Called once if the GPU drops the context; the page then falls back to plain art. onLost: (() => void) | null = null; + /// Turning it by hand. + readonly spin = new Spin(); private gl: WebGL2RenderingContext; private u = {} as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>; private vao: WebGLVertexArrayObject; @@ -100,6 +104,9 @@ export class LiquidBlob { gl.uniformMatrix4fv(this.u.uProj, false, perspective(FOV, 1, 0.1, 20)); gl.uniform1f(this.u.uDist, DIST); gl.enable(gl.DEPTH_TEST); + // The edge fades into the page; the canvas is premultiplied. + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.clearColor(0, 0, 0, 0); } @@ -138,6 +145,7 @@ export class LiquidBlob { this.ready = false; this.morph = 0; this.slow = 0; + this.spin.reset(); const source = await artwork(art, initial).catch(() => artwork(null, initial)); if (gen !== this.gen || this.gl.isContextLost()) return false; const gl = this.gl; @@ -162,7 +170,7 @@ export class LiquidBlob { } /// Draw one frame. Returns true while it still moves without music (morphing in, - /// settling, following the pointer). + /// settling, spinning, following the pointer). frame(f: Features, dt: number, playing: boolean): boolean { const look = this.look; if (!look || !this.ready) return false; @@ -183,6 +191,7 @@ export class LiquidBlob { this.time += dt * look.flow * (playing ? 0.6 + 0.8 * f.wobble + 0.6 * f.drop : 0.25); const k = 1 - Math.exp(-dt / 0.15); for (let i = 0; i < 4; i++) this.pointer[i] += (this.pointerGoal[i] - this.pointer[i]) * k; + const spinning = this.spin.step(dt); const moving = this.bounce(f, dt, look); const u = this.u; @@ -208,12 +217,16 @@ export class LiquidBlob { gl.uniform1f(u.uHeat, look.heat); gl.uniform1f(u.uHue, look.hue); gl.uniform3fv(u.uAccent, this.accent); - gl.uniform4fv(u.uPointer, this.pointer); + // The pointer and the front are in view space; the noise turns with the drop. + const [px, py, pz, pw] = this.pointer; + gl.uniform4f(u.uPointer, ...this.spin.toObject(px, py, pz), pw); + gl.uniform3f(u.uFront, ...this.spin.toObject(0, 0, 1)); + gl.uniformMatrix3fv(u.uRot, false, this.spin.matrix); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.bindVertexArray(this.vao); gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0); - return this.morph < 1 || moving || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0; + return this.morph < 1 || moving || spinning || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0; } /// Step the springs and set the jump uniforms. True while they still move. @@ -236,11 +249,13 @@ export class LiquidBlob { const stretch = 1 + Math.min(0.18, Math.max(-0.15, jump[1] * STRETCH + Math.min(0, jump[0]) * 1.2)); // Each "womb" of the bass line swells the whole drop. const size = 1 + pump[0] + THROB * look.throb * f.bass; - const side = size / Math.sqrt(stretch); + // Spun fast it flattens out a little, like dough, and sloshes. + const whirl = Math.min(1, this.spin.speed / WHIRL); + const side = (size / Math.sqrt(stretch)) * (1 + 0.08 * whirl); const u = this.u; - this.gl.uniform3f(u.uScale, side, size * stretch, side); + this.gl.uniform3f(u.uScale, side, size * stretch * (1 - 0.06 * whirl), side); this.gl.uniform1f(u.uLift, Math.max(0, jump[0])); - this.gl.uniform1f(u.uJelly, Math.min(1, Math.abs(jump[1]) * 0.4 + Math.abs(pump[1]) * 0.6)); + this.gl.uniform1f(u.uJelly, Math.min(1, Math.abs(jump[1]) * 0.4 + Math.abs(pump[1]) * 0.6 + 0.5 * whirl)); return Math.abs(jump[0]) + Math.abs(jump[1]) + Math.abs(pump[1]) > 0.002; } diff --git a/web/src/blob/shaders.ts b/web/src/blob/shaders.ts index 079d90a..fed077f 100644 --- a/web/src/blob/shaders.ts +++ b/web/src/blob/shaders.ts @@ -1,6 +1,6 @@ // GLSL for the liquid artwork drop. The vertex shader moves a unit sphere with -// noise driven by the music; the fragment shader shows the artwork through the -// surface, with a rim in the card's accent colour. +// noise driven by the music and turns it as spin.ts says; the fragment shader shows +// the artwork through the surface, glossy with the card's accent colour. // 3D simplex noise by Ian McEwan and Stefan Gustavson (Ashima Arts), MIT licence: // https://github.com/ashima/webgl-noise @@ -72,10 +72,12 @@ uniform vec3 uScale; // squash and stretch uniform float uLift; // upwards uniform float uJelly; // extra wobble while a jump rings out, 0..1 uniform vec4 uPointer; // direction on the sphere, strength +uniform mat3 uRot; // turned by hand, object to view space +uniform vec3 uFront; // towards the viewer, in object space out vec3 vPos; // view space out vec3 vNormal; // view space -out vec2 vFlat; // front projection of the resting sphere, for the artwork +out vec3 vObj; // the resting sphere; its front projection carries the artwork ${NOISE} @@ -98,7 +100,7 @@ float disp(vec3 p) { float n = snoise(q * 2.4 - vec3(0.0, t * 0.6, t * 0.9)); float spikes = pow(max(n, 0.0), 3.0) * (0.05 + 0.25 * uSnare + 0.15 * uGrowl * uBass + 0.1 * uDrop) * uSpike; // After each kick a wave runs from the front of the drop round to the back. - float angle = acos(clamp(p.z, -1.0, 1.0)); + float angle = acos(clamp(dot(p, uFront), -1.0, 1.0)); float wave = exp(-pow((angle - uKickAge * 5.0) * 1.8, 2.0)) * exp(-uKickAge * 2.8) * 0.09 * uShock; float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0)); return clamp((lobes + sway + ripples + spikes + wave) * uGain + dent, -0.3, 0.4); @@ -119,9 +121,9 @@ void main() { vec3 s = shape(p); vec3 n = normalize(cross(shape(normalize(p + t1 * e)) - s, shape(normalize(p + t2 * e)) - s)); if (dot(n, p) < 0.0) n = -n; - vPos = s * uScale + vec3(0.0, uLift, -uDist); - vNormal = normalize(n / uScale); - vFlat = p.xy; + vPos = uRot * s * uScale + vec3(0.0, uLift, -uDist); + vNormal = normalize(uRot * n / uScale); + vObj = p; gl_Position = uProj * vec4(vPos, 1.0); } `; @@ -131,7 +133,7 @@ precision highp float; in vec3 vPos; in vec3 vNormal; -in vec2 vFlat; +in vec3 vObj; uniform sampler2D uArt; uniform vec3 uAccent; @@ -155,19 +157,31 @@ void main() { float fres = pow(1.0 - max(dot(n, v), 0.0), 3.0); // Looking through the surface bends the artwork like water does. vec2 bend = n.xy * (0.22 + 0.1 * uDrop) * uMorph; - vec2 uv = 0.5 + vec2(vFlat.x, -vFlat.y) * 0.48 - vec2(bend.x, -bend.y) * 0.5; + vec2 uv = 0.5 + vec2(vObj.x, -vObj.y) * 0.48 - vec2(bend.x, -bend.y) * 0.5; vec2 split = n.xy * (0.004 + 0.02 * uKick + 0.008 * uGrowl * uBass) * uMorph; - vec3 art = vec3(texture(uArt, uv + split).r, texture(uArt, uv).g, texture(uArt, uv - split).b); + // Turned sideways the projection smears; blur it into flowing colour instead. + float blur = 4.0 * pow(1.0 - abs(vObj.z), 3.0) * uMorph; + vec3 art = vec3(texture(uArt, uv + split, blur).r, texture(uArt, uv, blur).g, texture(uArt, uv - split, blur).b); art = mix(art, smoothstep(0.0, 1.0, art), 0.6 * uDrop); vec3 light = normalize(vec3(-0.5, 0.7, 0.6)); float spec = pow(max(dot(n, normalize(light + v)), 0.0), mix(40.0, 140.0, uMetal)) * mix(0.35, 0.8, uMetal); - vec3 rim = hueRotate(uAccent, uHue + uIri * (fres * 3.0 + uTime * 0.3)); + // The hue sways around the accent but never leaves it, so the drop stays in the + // card's colours. + vec3 rim = hueRotate(uAccent, uHue + uIri * (fres + 0.5 * sin(uTime * 0.3))); rim = mix(rim, vec3(1.0, 0.35, 0.1), uHeat * 0.6); vec3 col = art * mix(1.0, 0.45, fres * uMorph); col += rim * fres * (0.5 + 0.7 * uKick + 0.4 * uDrop + 0.5 * uBass) * uMorph; col += spec * uMorph; - outColor = vec4(col, 1.0); + // Gloss: a dark room lit by the accent from above, and one soft window. They stay + // put while the surface turns under them, which is what makes a spin read. + vec3 r = reflect(-v, n); + vec3 env = mix(vec3(0.015), uAccent * 0.5, pow(smoothstep(-0.4, 1.0, r.y), 2.0)); + env += 0.5 * smoothstep(0.92, 0.985, dot(r, normalize(vec3(-0.5, 0.7, 0.5)))); + col += env * (0.1 + 0.5 * fres) * uMorph; + // The very edge fades out, so the drop sits in the page instead of on it. + float a = 1.0 - 0.9 * smoothstep(0.8, 1.0, fres) * uMorph; + outColor = vec4(col * a, a); } `; diff --git a/web/src/blob/spin.ts b/web/src/blob/spin.ts new file mode 100644 index 0000000..e5c9374 --- /dev/null +++ b/web/src/blob/spin.ts @@ -0,0 +1,113 @@ +// Turning the drop by hand. While held it follows the finger; let go, it keeps the +// speed of the flick, slows down, and a weak spring wobbles it back to face the +// front. A hard flick carries it over the top of the spring a few times first. + +const RETURN = 7; // 1/s², spring towards facing the front +const FRICTION = 1.6; // 1/s +const MAX_SPEED = 30; // rad/s +const STALE = 60; // ms; a finger that rested this long before lifting does not fling + +type Quat = [number, number, number, number]; // x, y, z, w + +export class Spin { + /// Angular speed, rad/s, as a rotation vector in view space. + private vel = [0, 0, 0]; + private q: Quat = [0, 0, 0, 1]; + private held = false; + private turnedAt = 0; + /// Column-major, object to view space; kept in step with `q`. + readonly matrix = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + get speed(): number { + return Math.hypot(...this.vel); + } + + reset(): void { + this.q = [0, 0, 0, 1]; + this.vel = [0, 0, 0]; + this.held = false; + this.toMatrix(); + } + + hold(on: boolean): void { + this.held = on; + if (on || performance.now() - this.turnedAt > STALE) this.vel = [0, 0, 0]; + } + + /// The finger moved by `dx`, `dy` (radii of the drop, y up) at `at` (ms, as + /// `performance.now()`): the surface under it moves along. + turn(dx: number, dy: number, at: number): void { + const a = [-dy, dx, 0]; + this.q = normalize(mul(fromVector(a), this.q)); + const dt = (at - this.turnedAt) / 1000; + this.turnedAt = at; + if (dt > 0 && dt < 0.1) { + for (let i = 0; i < 3; i++) this.vel[i] += (a[i] / Math.max(dt, 0.004) - this.vel[i]) * 0.6; + const s = this.speed; + if (s > MAX_SPEED) this.vel = this.vel.map((v) => (v * MAX_SPEED) / s); + } + this.toMatrix(); + } + + /// Advance by `dt` seconds. True while it still moves. + step(dt: number): boolean { + if (this.held) return true; + if (this.speed < 0.03 && Math.hypot(...toVector(this.q)) < 0.005) { + if (this.q[3] !== 1) this.reset(); + return false; + } + // Small fixed steps keep the spring stable on slow frames. + for (let left = Math.min(dt, 0.1); left > 0; left -= 1 / 240) { + const h = Math.min(left, 1 / 240); + const off = toVector(this.q); + for (let i = 0; i < 3; i++) this.vel[i] += (-RETURN * off[i] - FRICTION * this.vel[i]) * h; + this.q = normalize(mul(fromVector(this.vel.map((v) => v * h)), this.q)); + } + this.toMatrix(); + return true; + } + + /// A view-space direction in object space. + toObject(x: number, y: number, z: number): [number, number, number] { + const m = this.matrix; + return [m[0] * x + m[1] * y + m[2] * z, m[3] * x + m[4] * y + m[5] * z, m[6] * x + m[7] * y + m[8] * z]; + } + + private toMatrix(): void { + const [x, y, z, w] = this.q; + this.matrix.set([ + 1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), + 2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), + 2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), + ]); + } +} + +function mul(a: Quat, b: Quat): Quat { + return [ + a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], + ]; +} + +function normalize(q: Quat): Quat { + const l = Math.hypot(...q); + return [q[0] / l, q[1] / l, q[2] / l, q[3] / l]; +} + +/// Rotation by `|v|` radians around `v`. +function fromVector(v: number[]): Quat { + const a = Math.hypot(v[0], v[1], v[2]); + const s = a < 1e-8 ? 0.5 : Math.sin(a / 2) / a; + return [v[0] * s, v[1] * s, v[2] * s, Math.cos(a / 2)]; +} + +/// The shortest rotation vector to `q`, so the spring always takes the short way. +function toVector(q: Quat): number[] { + const [x, y, z, w] = q[3] < 0 ? q.map((c) => -c) : q; + const s = Math.sqrt(Math.max(0, 1 - w * w)); + const k = s < 1e-6 ? 2 : (2 * Math.acos(Math.min(1, w))) / s; + return [x * k, y * k, z * k]; +} diff --git a/web/src/style.css b/web/src/style.css index 4e795da..7fc1d80 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -262,8 +262,22 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff; height: 200%; pointer-events: none; } -.card.liquid .art { background-image: none !important; background-color: transparent; box-shadow: none; scale: 1; } +/* The halo stays behind the drop and pulses with it; the text sits on top where + the drop spills over it. */ +.card.liquid .art { + z-index: 0; + background-image: none !important; + background-color: transparent; + box-shadow: 0 0 calc(30px + var(--energy) * 80px) color-mix(in oklab, var(--card-accent) calc(18% + var(--energy) * 40%), transparent); + scale: 1; +} +.card.liquid .content > :not(.art) { position: relative; z-index: 1; } +.card.liquid .title { text-shadow: 0 2px 16px #000b; } .card.liquid .initial { display: none; } +@media (pointer: fine) { + .card.liquid .art { cursor: grab; } + .card.turning, .card.turning .art { cursor: grabbing; } +} .paused-badge, .spinner { z-index: 1; } .audio-debug { diff --git a/web/src/visual.ts b/web/src/visual.ts index ce136a4..47a1dc3 100644 --- a/web/src/visual.ts +++ b/web/src/visual.ts @@ -15,6 +15,8 @@ import type { Player } from "./player"; export type VisualsSetting = "liquid" | "simple"; const VISUALS_KEY = "pl8inf.visuals"; +const SLOP = 8; // px before a press on the drop turns it +const REACH = 1.2; // how far past the art's radius the drop can be grabbed export class Visualizer { private raf = 0; @@ -26,6 +28,9 @@ export class Visualizer { private reduced = matchMedia("(prefers-reduced-motion: reduce)"); private setting: VisualsSetting = localStorage.getItem(VISUALS_KEY) === "simple" ? "simple" : "liquid"; private debug: ((f: Features) => void) | null = null; + /// A press on the drop; `on` once it moved enough to turn it. + private grab: { id: number; x: number; y: number; on: boolean } | null = null; + private spun = false; constructor( private player: Player, @@ -33,8 +38,17 @@ export class Visualizer { ) { document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start())); this.reduced.addEventListener("change", () => this.attach(this.card, true)); - for (const ev of ["pointermove", "pointerdown"]) document.addEventListener(ev, (e) => this.pointer(e as PointerEvent, true), { passive: true }); - for (const ev of ["pointerup", "pointercancel", "pointerleave"]) document.addEventListener(ev, (e) => this.pointer(e as PointerEvent, false), { passive: true }); + // Capturing, so a turn can keep its moves from the pager. + const on = { capture: true, passive: true }; + document.addEventListener("pointerdown", (e) => this.down(e), on); + document.addEventListener("pointermove", (e) => this.move(e), on); + for (const ev of ["pointerup", "pointercancel"]) document.addEventListener(ev, (e) => this.up(e as PointerEvent), on); + document.addEventListener("pointerleave", (e) => this.pointer(e, false), { passive: true }); + // A turn is not a tap: no play/pause after it. + document.addEventListener("click", (e) => { + if (this.spun) e.stopPropagation(); + this.spun = false; + }, true); if (new URLSearchParams(location.search).get("debug") === "audio") { import("./debug").then((m) => { this.debug = m.audioDebug(); }); } @@ -71,7 +85,8 @@ export class Visualizer { if (this.card === card && !again) return; if (this.card && this.card !== card) { this.card.el?.style.setProperty("--energy", "0"); - this.card.el?.classList.remove("liquid"); + this.card.el?.classList.remove("liquid", "turning"); + this.grab = null; } this.card = card; const blob = this.liquid(); @@ -105,16 +120,78 @@ export class Visualizer { this.raf = 0; } + /// The drop on screen: its centre and the art's radius, in px. Null while it + /// does not show. + private where(): { x: number; y: number; radius: number } | null { + const blob = this.blob; + if (!blob?.canvas.isConnected || !this.card?.el?.classList.contains("liquid")) return null; + const r = blob.canvas.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2, radius: r.width / SPILL / 2 }; + } + /// Pointer over the drop pushes a dent into it. private pointer(e: PointerEvent, on: boolean): void { - const blob = this.blob; - if (!blob?.canvas.isConnected) return; - const r = blob.canvas.getBoundingClientRect(); - const radius = r.width / SPILL / 2; - blob.setPointer((e.clientX - r.left - r.width / 2) / radius, -(e.clientY - r.top - r.height / 2) / radius, on); + const at = this.where(); + if (!at) return; + this.blob!.setPointer((e.clientX - at.x) / at.radius, -(e.clientY - at.y) / at.radius, on); if (on) this.start(); } + // Dragging the drop turns it. On touch only a sideways drag does, as up and + // down turn the page; a mouse can turn it any way. + + private down(e: PointerEvent): void { + this.pointer(e, true); + const at = this.where(); + if (this.grab || !at || (e.pointerType === "mouse" && e.button !== 0)) return; + if ((e.target as Element).closest("button, a, input, .wave-wrap")) return; + if (Math.hypot(e.clientX - at.x, e.clientY - at.y) > at.radius * REACH) return; + this.grab = { id: e.pointerId, x: e.clientX, y: e.clientY, on: false }; + } + + private move(e: PointerEvent): void { + this.pointer(e, true); + const g = this.grab; + const at = this.where(); + if (!g || e.pointerId !== g.id) return; + if (!at) { + this.grab = null; + return; + } + const dx = e.clientX - g.x, dy = e.clientY - g.y; + if (!g.on) { + if (Math.hypot(dx, dy) < SLOP) return; + if (e.pointerType !== "mouse" && Math.abs(dy) >= Math.abs(dx)) { + this.grab = null; + return; + } + g.on = true; + // Keep the moves when the mouse leaves the window. + document.documentElement.setPointerCapture(e.pointerId); + this.blob!.spin.hold(true); + this.card?.el?.classList.add("turning"); + } + e.stopPropagation(); + this.blob!.spin.turn(dx / at.radius, -dy / at.radius, performance.now()); + g.x = e.clientX; + g.y = e.clientY; + this.start(); + } + + private up(e: PointerEvent): void { + this.pointer(e, false); + const g = this.grab; + if (!g || e.pointerId !== g.id) return; + this.grab = null; + if (!g.on) return; + this.blob?.spin.hold(false); + this.card?.el?.classList.remove("turning"); + this.spun = e.type === "pointerup"; + // The click, if any, comes right after. + setTimeout(() => (this.spun = false), 350); + this.start(); + } + private frame = (now: number) => { this.raf = 0; const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 1 / 60;