import * as THREE from 'three'; import type { SceneResourceBag } from '@/scene/resources'; import { SUN_DISTANCE_Z } from './metrics'; /** Three authored streaks, batched into six triangles. No simulation or textures. */ export function createArrivalMeteors(resources: SceneResourceBag) { const flights = [ { x: 160, y: 335, dx: -250, dy: -100, delay: 0.1, duration: 1.8, length: 116 }, { x: 280, y: 300, dx: -240, dy: -90, delay: 0.9, duration: 1.8, length: 95 }, { x: 100, y: 345, dx: -205, dy: -115, delay: 1.7, duration: 1.9, length: 130 }, ]; const positions: number[] = []; const uvs: number[] = []; const timing: number[] = []; const travel: number[] = []; const corners = [[0, 0], [1, 0], [0, 1], [1, 0], [1, 1], [0, 1]] as const; for (const flight of flights) { const distance = Math.hypot(flight.dx, flight.dy); const dx = flight.dx / distance; const dy = flight.dy / distance; for (const [u, v] of corners) { positions.push( flight.x + (u - 1) * flight.length * dx - (v - 0.5) * 7 * dy, flight.y + (u - 1) * flight.length * dy + (v - 0.5) * 7 * dx, 0, ); uvs.push(u, v); timing.push(flight.delay, flight.duration); travel.push(flight.dx, flight.dy); } } const geometry = resources.own('geometry', new THREE.BufferGeometry()); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); geometry.setAttribute('aFlight', new THREE.Float32BufferAttribute(timing, 2)); geometry.setAttribute('aTravel', new THREE.Float32BufferAttribute(travel, 2)); const material = resources.own('material', new THREE.ShaderMaterial({ uniforms: { uTime: { value: -1 } }, vertexShader: ` uniform float uTime; attribute vec2 aFlight; attribute vec2 aTravel; varying vec2 vUv; varying float vLife; void main() { float phase = (uTime - aFlight.x) / aFlight.y; vLife = smoothstep(0.0, 0.18, phase) * (1.0 - smoothstep(0.72, 1.0, phase)); vUv = uv; vec3 point = position + vec3(aTravel * clamp(phase, 0.0, 1.0), 0.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(point, 1.0); } `, fragmentShader: ` varying vec2 vUv; varying float vLife; void main() { float across = abs(vUv.y * 2.0 - 1.0); float taper = pow(vUv.x, 1.4); float core = (1.0 - smoothstep(0.06, 0.28, across)) * taper; float glow = (1.0 - smoothstep(0.0, 1.0, across)) * taper; float head = (1.0 - smoothstep(0.0, 1.0, length(vec2((vUv.x - 0.965) * 30.0, across)))); float alpha = vLife * max(glow * 0.8, head); if (alpha < 0.005) discard; vec3 light = mix(vec3(0.22, 0.62, 1.0), vec3(1.0, 0.94, 0.8), max(core, head)); gl_FragColor = vec4(light * 1.8, alpha); #include } `, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true, toneMapped: false, })); const mesh = new THREE.Mesh(geometry, material); mesh.name = 'arrival-meteors'; mesh.position.z = SUN_DISTANCE_Z + 8; // Flight displacement lives in the shader; visibility is explicitly bounded. mesh.frustumCulled = false; mesh.visible = false; let warmed = false; return { mesh, update(age: number, active: boolean) { // One invisible startup draw warms the exact live pipeline (including // its post-processing color space), so arrival never compiles a variant. const playing = active && age >= 0 && age < 4; mesh.visible = playing || !warmed; if (mesh.visible) material.uniforms.uTime.value = playing ? age : -1; warmed = true; } }; }