diff --git a/app.js b/app.js index 6e85528..9b34a7f 100644 --- a/app.js +++ b/app.js @@ -126,6 +126,71 @@ window.addEventListener('resize', () => { renderer.setSize(window.innerWidth, window.innerHeight); }); +// === BREADCRUMB TRAIL === +const TRAIL_MAX = 100; +const TRAIL_LIFETIME = 12; // seconds before a crumb fades out +const TRAIL_INTERVAL = 0.25; // seconds between crumb placements +let lastCrumbTime = -TRAIL_INTERVAL; + +const trailPosArr = new Float32Array(TRAIL_MAX * 3); +const trailColArr = new Float32Array(TRAIL_MAX * 3); +const trailGeo = new THREE.BufferGeometry(); +trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPosArr, 3)); +trailGeo.setAttribute('color', new THREE.BufferAttribute(trailColArr, 3)); +trailGeo.setDrawRange(0, 0); + +const trailMat = new THREE.PointsMaterial({ + size: 2.5, + sizeAttenuation: true, + transparent: true, + opacity: 0.9, + vertexColors: true, +}); +const trailMesh = new THREE.Points(trailGeo, trailMat); +scene.add(trailMesh); + +// crumbs store positions in star-local space so they stay embedded in the star field +const crumbs = []; // { lx, ly, lz, born } + +function addCrumb(elapsed) { + // Project camera position into star-local space to track the viewing path + const invQ = stars.quaternion.clone().invert(); + const localCam = camera.position.clone().applyQuaternion(invQ); + crumbs.push({ lx: localCam.x, ly: localCam.y, lz: localCam.z, born: elapsed }); + if (crumbs.length > TRAIL_MAX) crumbs.shift(); + lastCrumbTime = elapsed; +} + +function updateTrail(elapsed) { + // Remove expired crumbs + while (crumbs.length > 0 && elapsed - crumbs[0].born > TRAIL_LIFETIME) { + crumbs.shift(); + } + + const n = crumbs.length; + for (let i = 0; i < n; i++) { + const c = crumbs[i]; + const age = elapsed - c.born; + const t = 1 - age / TRAIL_LIFETIME; + const fade = t * t; // quadratic fade for a softer tail + + trailPosArr[i * 3] = c.lx; + trailPosArr[i * 3 + 1] = c.ly; + trailPosArr[i * 3 + 2] = c.lz; + // Faint cyan-blue glow matching the accent palette + trailColArr[i * 3] = 0.1 * fade; + trailColArr[i * 3 + 1] = 0.5 * fade; + trailColArr[i * 3 + 2] = 1.0 * fade; + } + trailGeo.attributes.position.needsUpdate = true; + trailGeo.attributes.color.needsUpdate = true; + trailGeo.setDrawRange(0, n); + + // Trail rotates with the star field so crumbs stay embedded in space + trailMesh.rotation.x = stars.rotation.x; + trailMesh.rotation.y = stars.rotation.y; +} + // === ANIMATION LOOP === const clock = new THREE.Clock(); @@ -146,6 +211,12 @@ function animate() { // Subtle pulse on constellation opacity constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06; + // Breadcrumb trail — drop a crumb every interval + if (elapsed - lastCrumbTime >= TRAIL_INTERVAL) { + addCrumb(elapsed); + } + updateTrail(elapsed); + renderer.render(scene, camera); }