clone

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

main
marbles / src / client / trail.ts
trail.ts 5.8K
1import * as THREE from "three";23// Camera-facing ribbon of recent ball-center positions: thick at the head,4// tapering to nothing at the tail (THREE.Line can't do width, so this is a5// triangle strip whose side vectors face the camera each frame).6const MAX_POINTS = 96;7const MIN_STEP = 0.06; // world units between samples8const JUMP_RESET = 4; // a respawn/warp teleports the marble; don't streak9const HEAD_ALPHA = 0.85;1011export interface TrailOptions {12 maxPoints?: number;13 minStep?: number;14 jumpReset?: number;15 headAlpha?: number;16 additive?: boolean;17 renderOrder?: number;18}1920export interface Trail {21 obj: THREE.Mesh;22 setWidth(w: number): void;23 update(pos: THREE.Vector3, camera: THREE.Camera): void;24 /** Redraw retained history without sampling a new point (afterglow). */25 fade(camera: THREE.Camera, opacity: number): void;26 reset(): void;27 dispose(): void;28}2930export function createTrail(31 color: THREE.ColorRepresentation = 0x7fd4ff,32 options: TrailOptions = {},33): Trail {34 const maxPoints = Math.max(2, Math.floor(options.maxPoints ?? MAX_POINTS));35 const minStep = options.minStep ?? MIN_STEP;36 const jumpReset = options.jumpReset ?? JUMP_RESET;37 const headAlpha = options.headAlpha ?? HEAD_ALPHA;38 const positions = new Float32Array(maxPoints * 2 * 3);39 const colors = new Float32Array(maxPoints * 2 * 4);40 const index: number[] = [];41 for (let i = 0; i < maxPoints - 1; i++) {42 const a = i * 2;43 index.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);44 }45 const geometry = new THREE.BufferGeometry();46 geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));47 geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4));48 geometry.setIndex(index);49 geometry.setDrawRange(0, 0);50 const material = new THREE.MeshBasicMaterial({51 vertexColors: true,52 transparent: true,53 depthWrite: false,54 side: THREE.DoubleSide,55 blending: options.additive ? THREE.AdditiveBlending : THREE.NormalBlending,56 });57 const mesh = new THREE.Mesh(geometry, material);58 mesh.name = "marble-trail";59 mesh.frustumCulled = false;60 mesh.renderOrder = options.renderOrder ?? 5;6162 const tint = new THREE.Color(color);63 const history: THREE.Vector3[] = []; // newest first64 // `history[0]` follows the live marble every frame so the ribbon stays65 // attached. Sampling distance must therefore use a separate committed66 // anchor; comparing against the moving head prevents slow/kinematic motion67 // from ever accumulating MIN_STEP and stretches one old segment forever.68 const sampleAnchor = new THREE.Vector3();69 let hasSampleAnchor = false;70 let headWidth = 0.2;7172 const camPos = new THREE.Vector3();73 const dir = new THREE.Vector3();74 const view = new THREE.Vector3();75 const side = new THREE.Vector3();76 const previousSide = new THREE.Vector3();7778 function rewrite(camera: THREE.Camera, opacity = 1): void {79 camera.getWorldPosition(camPos);80 const last = history.length - 1;81 let hasPreviousSide = false;82 for (let i = 0; i < history.length; i++) {83 const p = history[i];84 // Central difference along the trail; degenerate at a single point.85 dir.copy(history[Math.max(0, i - 1)]).sub(history[Math.min(last, i + 1)]);86 view.copy(p).sub(camPos);87 side.crossVectors(dir, view);88 if (side.lengthSq() > 1e-10) side.normalize();89 else if (hasPreviousSide) side.copy(previousSide);90 else side.set(0, 1, 0);91 // Camera-facing strips can choose either side-vector sign. Keep it92 // continuous down the ribbon so sharp bends never twist the strip.93 if (hasPreviousSide && side.dot(previousSide) < 0) side.negate();94 previousSide.copy(side);95 hasPreviousSide = true;96 // Taper over the history that ACTUALLY exists. Using maxPoints here97 // made every short/new trail end at nearly full width, then snap off.98 const fade = last <= 0 ? 0 : 1 - i / last;99 side.multiplyScalar(headWidth * fade * 0.5);100 positions[i * 6] = p.x + side.x;101 positions[i * 6 + 1] = p.y + side.y;102 positions[i * 6 + 2] = p.z + side.z;103 positions[i * 6 + 3] = p.x - side.x;104 positions[i * 6 + 4] = p.y - side.y;105 positions[i * 6 + 5] = p.z - side.z;106 const alpha = headAlpha * Math.max(0, opacity) * fade * fade;107 for (const v of [i * 8, i * 8 + 4]) {108 colors[v] = tint.r;109 colors[v + 1] = tint.g;110 colors[v + 2] = tint.b;111 colors[v + 3] = alpha;112 }113 }114 geometry.attributes.position.needsUpdate = true;115 geometry.attributes.color.needsUpdate = true;116 geometry.setDrawRange(0, Math.max(0, (history.length - 1) * 6));117 }118119 return {120 obj: mesh,121 setWidth(w: number): void {122 headWidth = w;123 },124 update(pos: THREE.Vector3, camera: THREE.Camera): void {125 const head = history[0];126 if (head && pos.distanceTo(head) > jumpReset) {127 history.length = 0;128 hasSampleAnchor = false;129 }130 if (!hasSampleAnchor) {131 sampleAnchor.copy(pos);132 hasSampleAnchor = true;133 }134 if (135 !history[0] || pos.distanceToSquared(sampleAnchor) >= minStep * minStep136 ) {137 history.unshift(pos.clone());138 sampleAnchor.copy(pos);139 if (history.length > maxPoints) history.length = maxPoints;140 } else {141 // Keep the visual head attached without moving the sampling anchor.142 history[0].copy(pos);143 }144 rewrite(camera);145 },146 fade(camera: THREE.Camera, opacity: number): void {147 if (opacity <= 0) {148 history.length = 0;149 hasSampleAnchor = false;150 geometry.setDrawRange(0, 0);151 return;152 }153 rewrite(camera, opacity);154 },155 reset(): void {156 history.length = 0;157 hasSampleAnchor = false;158 geometry.setDrawRange(0, 0);159 },160 dispose(): void {161 geometry.dispose();162 material.dispose();163 },164 };165}