clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

main
marbles / src / client / streamAnim.ts
streamAnim.ts 6.0K
1/**2 * Stream animations: segments and islands ASSEMBLE into the void and SINK3 * away, instead of popping. Purely visual, wall-clock-eased, per-piece:4 * a wave of construction flows along the direction of travel (each piece's5 * delay is its projection onto the segment's heading, plus a deterministic6 * per-position jitter). Generation and physics are untouched — the marble7 * only ever rides segments whose spawn wave finished long before it arrives8 * (the render window builds ~3 segments = ~75 units ahead).9 *10 * Two kinds of animated piece:11 * - plain Object3D children (track piece clones) — position/scale eased;12 * - instances inside an InstancedMesh (island blocks/caps/vegetation,13 * islandRender.ts) — each instance carries a base matrix in14 * `userData.animInstances` and is eased by rewriting its matrix, so the15 * per-piece wave survives instancing.16 */17import * as THREE from "three";18import type { AnimInstances } from "./islandRender.ts";1920/** Seconds of stagger per unit of distance along the travel direction. */21const STAGGER_PER_UNIT = 0.045;22/** Extra deterministic per-piece jitter (seconds). */23const JITTER = 0.12;24const SPAWN_DURATION = 0.45;25const SPAWN_DROP = 2.0;26const EXIT_DURATION = 0.4;27const EXIT_SINK = 1.6;2829interface ObjEntry {30 kind: "obj";31 obj: THREE.Object3D;32 baseY: number;33 baseScale: THREE.Vector3;34 delay: number;35 t: number;36}3738interface InstEntry {39 kind: "inst";40 mesh: THREE.InstancedMesh;41 index: number;42 base: THREE.Matrix4;43 delay: number;44 t: number;45}4647type Entry = ObjEntry | InstEntry;4849const tmpM = new THREE.Matrix4();50const tmpS = new THREE.Matrix4();5152function easeOutBack(t: number): number {53 const c1 = 1.70158;54 const c3 = c1 + 1;55 return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);56}5758function easeInCubic(t: number): number {59 return t * t * t;60}6162/** Cheap deterministic hash of a world position → [0, 1). Visual only. */63function posJitter(x: number, y: number, z: number): number {64 let h = Math.imul((x * 4 + 1024) | 0, 374761393) ^65 Math.imul((y * 4 + 2048) | 0, 668265263) ^66 Math.imul((z * 4 + 4096) | 0, 2246822519);67 h = Math.imul(h ^ (h >>> 13), 3266489917);68 return ((h ^ (h >>> 16)) >>> 0) / 4294967296;69}7071function delayFor(72 pos: THREE.Vector3,73 origin: THREE.Vector3,74 heading: THREE.Vector3,75): number {76 const proj = (pos.x - origin.x) * heading.x + (pos.z - origin.z) * heading.z;77 return Math.max(0, proj) * STAGGER_PER_UNIT +78 posJitter(pos.x, pos.y, pos.z) * JITTER;79}8081function collectEntries(82 group: THREE.Group,83 origin: THREE.Vector3,84 heading: THREE.Vector3,85): Entry[] {86 const out: Entry[] = [];87 for (const obj of group.children) {88 const anim = (obj as THREE.InstancedMesh).userData89 ?.animInstances as AnimInstances | undefined;90 if (anim && (obj as THREE.InstancedMesh).isInstancedMesh) {91 const mesh = obj as THREE.InstancedMesh;92 for (let i = 0; i < anim.base.length; i++) {93 out.push({94 kind: "inst",95 mesh,96 index: i,97 base: anim.base[i],98 delay: delayFor(anim.worldPos[i], origin, heading),99 t: 0,100 });101 }102 } else {103 out.push({104 kind: "obj",105 obj,106 baseY: obj.position.y,107 baseScale: obj.scale.clone(),108 delay: delayFor(obj.position, origin, heading),109 t: 0,110 });111 }112 }113 return out;114}115116/** Write an instance matrix = base offset by dy and scaled by s (about the117 * piece's own origin). */118function writeInstance(e: InstEntry, dy: number, s: number): void {119 tmpM.copy(e.base);120 tmpS.makeScale(s, s, s);121 tmpM.multiply(tmpS);122 tmpM.elements[13] += dy;123 e.mesh.setMatrixAt(e.index, tmpM);124 e.mesh.instanceMatrix.needsUpdate = true;125}126127export class SpawnAnim {128 private entries: Entry[];129130 constructor(131 group: THREE.Group,132 origin: THREE.Vector3,133 heading: THREE.Vector3,134 ) {135 this.entries = collectEntries(group, origin, heading);136 // First frame state: everything hidden above its target, tiny.137 for (const e of this.entries) {138 if (e.kind === "obj") {139 e.obj.position.y = e.baseY + SPAWN_DROP;140 e.obj.scale.copy(e.baseScale).multiplyScalar(0.001);141 } else {142 writeInstance(e, SPAWN_DROP, 0.001);143 }144 }145 }146147 /** Advance; returns true when every piece has settled. */148 update(dt: number): boolean {149 let done = true;150 for (const e of this.entries) {151 e.t += dt;152 const k = (e.t - e.delay) / SPAWN_DURATION;153 if (k <= 0) {154 done = false;155 continue;156 }157 if (k >= 1) {158 if (e.kind === "obj") {159 e.obj.position.y = e.baseY;160 e.obj.scale.copy(e.baseScale);161 } else {162 writeInstance(e, 0, 1);163 }164 continue;165 }166 done = false;167 const f = easeOutBack(k);168 const s = Math.max(0.001, Math.min(1, k * 1.6));169 if (e.kind === "obj") {170 e.obj.position.y = e.baseY + SPAWN_DROP * (1 - f);171 e.obj.scale.copy(e.baseScale).multiplyScalar(s);172 } else {173 writeInstance(e, SPAWN_DROP * (1 - f), s);174 }175 }176 return done;177 }178}179180export class ExitAnim {181 private entries: Entry[];182183 constructor(184 group: THREE.Group,185 origin: THREE.Vector3,186 heading: THREE.Vector3,187 ) {188 this.entries = collectEntries(group, origin, heading);189 }190191 /** Advance; returns true when every piece has sunk away. */192 update(dt: number): boolean {193 let done = true;194 for (const e of this.entries) {195 e.t += dt;196 const k = (e.t - e.delay) / EXIT_DURATION;197 if (k <= 0) {198 done = false;199 continue;200 }201 if (k >= 1) {202 if (e.kind === "obj") e.obj.visible = false;203 else writeInstance(e, -EXIT_SINK, 0.001);204 continue;205 }206 done = false;207 const f = easeInCubic(k);208 if (e.kind === "obj") {209 e.obj.position.y = e.baseY - EXIT_SINK * f;210 e.obj.scale.copy(e.baseScale).multiplyScalar(Math.max(0.001, 1 - f));211 } else {212 writeInstance(e, -EXIT_SINK * f, Math.max(0.001, 1 - f));213 }214 }215 return done;216 }217}