import { BufferAttribute, BufferGeometry, Color, DynamicDrawUsage, Group, LineBasicMaterial, LineSegments, MathUtils, Points, ShaderMaterial } from 'three'; import type { Vector3 } from 'three'; import { PRECIPITATION as P } from '../constants/precipitation.ts'; /** Bounded, reusable particles around the viewing camera, in world coordinates. */ export function createPrecipitation(heightAt: (x: number, z: number) => number) { const group = new Group(); group.name = 'weather-precipitation'; const rainMaterial = new LineBasicMaterial({ color: '#b9cddd', transparent: true, depthWrite: false }); const snowMaterial = new ShaderMaterial({ transparent: true, depthWrite: false, uniforms: { tint: { value: new Color() }, opacity: { value: P.snow.opacity }, pointScale: { value: 1 } }, vertexShader: ` uniform float pointScale; void main() { vec4 viewPosition = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * viewPosition; gl_PointSize = clamp(${P.snow.size} * pointScale * projectionMatrix[1][1] / max(0.1, -viewPosition.z), 1.0, 24.0); }`, fragmentShader: ` uniform vec3 tint; uniform float opacity; void main() { float radius = length(gl_PointCoord - 0.5) * 2.0; float alpha = (1.0 - smoothstep(0.3, 1.0, radius)) * opacity; if (alpha < 0.01) discard; gl_FragColor = vec4(tint, alpha); #include #include }`, }); const layers = ([false, true] as const).map(snow => { const settings = snow ? P.snow : P.rain; const vertices = snow ? 1 : 2; const geometry = new BufferGeometry(); const attribute = new BufferAttribute(new Float32Array(settings.maxParticles * vertices * 3), 3).setUsage(DynamicDrawUsage); geometry.setAttribute('position', attribute); geometry.setDrawRange(0, 0); const mesh = snow ? new Points(geometry, snowMaterial) : new LineSegments(geometry, rainMaterial); // The buffer moves with the camera; avoid recomputing its bounds every frame. mesh.frustumCulled = false; mesh.visible = false; group.add(mesh); return { snow, settings, vertices, mesh, attribute, positions: new Float32Array(settings.maxParticles * 3), ground: new Float32Array(settings.maxParticles), seeds: Float32Array.from({ length: settings.maxParticles }, () => Math.random()), count: 0 }; }); let elapsed = 0; const span = P.radius * 2; const wrap = (value: number, center: number) => MathUtils.euclideanModulo(value - center + P.radius, span) + center - P.radius; return { group, update(delta: number, camera: Vector3, rain: number, snowfall: number, daylight: number, viewportHeight: number) { const dt = Math.min(Math.max(delta, 0), P.maxDelta); elapsed += dt; rainMaterial.color.setRGB(0.65, 0.75, 0.85).multiplyScalar(0.25 + daylight * 0.75); snowMaterial.uniforms.tint.value.setRGB(0.9, 0.95, 1).multiplyScalar(0.25 + daylight * 0.75); snowMaterial.uniforms.pointScale.value = viewportHeight / 2; for (const layer of layers) { const { snow, settings, vertices, mesh, attribute, positions, ground, seeds } = layer; const rate = snow ? snowfall : rain; const strength = Math.sqrt(MathUtils.clamp(rate / settings.fullRate, 0, 1)); const count = Math.floor(settings.maxParticles * strength); mesh.visible = count > 0; mesh.geometry.setDrawRange(0, count * vertices); if (!snow) rainMaterial.opacity = P.rain.opacity + strength * 0.3; for (let i = 0; i < count; i++) { const offset = i * 3, seed = seeds[i]; const fresh = i >= layer.count; let x = fresh ? camera.x + (Math.random() - 0.5) * span : positions[offset]; let z = fresh ? camera.z + (Math.random() - 0.5) * span : positions[offset + 2]; if (snow) { x += Math.sin(elapsed + seed * Math.PI * 2) * P.snow.drift * dt; z += Math.cos(elapsed * 0.7 + seed * Math.PI * 2) * P.snow.drift * dt; } x = wrap(x, camera.x); z = wrap(z, camera.z); if (fresh || snow || x !== positions[offset] || z !== positions[offset + 2]) ground[i] = heightAt(x, z); const bottom = Math.max(camera.y - P.belowCamera, ground[i] + 0.05); const top = Math.max(bottom + 0.1, camera.y + P.height - P.belowCamera); const speed = settings.speed * (0.8 + seed * 0.4) * (snow ? 1 : 1 + strength * 0.4); let y = fresh ? MathUtils.lerp(bottom, top, seed) : positions[offset + 1] - speed * dt; if (y < bottom || y > top) y = top; positions[offset] = x; positions[offset + 1] = y; positions[offset + 2] = z; attribute.setXYZ(i * vertices, x, y, z); if (!snow) attribute.setXYZ(i * vertices + 1, x, y + P.rain.streak * (0.6 + strength), z); } if (count) attribute.needsUpdate = true; layer.count = count; } return layers.some(layer => layer.mesh.visible); }, dispose() { for (const layer of layers) layer.mesh.geometry.dispose(); rainMaterial.dispose(); snowMaterial.dispose(); group.removeFromParent(); }, }; }