// threejs-aurora.jsx // WebGL aurora background — shader-based animated gradient + floating particles. // Mounts a inside an absolute-positioned container, pauses rendering // when off-screen, and tears down cleanly on unmount. const AURORA_FRAG = ` precision highp float; varying vec2 vUv; uniform float uTime; float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } float noise(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y); } float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; } return v; } void main() { vec2 uv = vUv; float t = uTime * 0.06; // Deep night base — varies slightly with height vec3 deep = vec3(0.020, 0.018, 0.060); vec3 royal = vec3(0.140, 0.110, 0.380); vec3 violet = vec3(0.460, 0.320, 0.860); vec3 cyan = vec3(0.380, 0.620, 1.000); vec3 silver = vec3(0.940, 0.920, 1.000); vec3 col = mix(deep, royal, smoothstep(0.0, 0.85, uv.y)); col = mix(col, deep * 1.1, smoothstep(0.65, 1.0, uv.y)); // Aurora band 1 — slow vertical flow vec2 fp = uv * vec2(2.4, 1.4) + vec2(t * 0.5, t * 0.4); float n1 = fbm(fp); float band1 = smoothstep(0.50, 0.85, n1) * smoothstep(0.05, 0.45, uv.y) * smoothstep(0.95, 0.50, uv.y); col += violet * band1 * 0.65; // Aurora band 2 — counter-flow, cooler vec2 fp2 = uv * vec2(2.0, 2.0) + vec2(-t * 0.35, t * 0.55); float n2 = fbm(fp2 * 1.3 + n1 * 0.4); float band2 = smoothstep(0.55, 0.92, n2) * smoothstep(0.10, 0.55, uv.y) * smoothstep(1.05, 0.55, uv.y); col += cyan * band2 * 0.45; // Silver lining — diagonal sweep toward upper-right float diag = (1.0 - uv.x) + uv.y; // 0..2 float arc = exp(-pow((diag - 1.30) * 3.4, 2.0)); arc *= smoothstep(0.40, 1.00, uv.x + uv.y * 0.4); col += silver * arc * 0.55; // Distant glow disc upper-right vec2 g = uv - vec2(0.92, 0.04); float glow = exp(-dot(g, g) * 6.0); col += silver * glow * 0.45; // Subtle vignette vec2 c = uv - 0.5; col *= 1.0 - dot(c, c) * 0.35; // Gentle film grain so the gradient never reads as flat banding col += (hash(uv * 1024.0 + t) - 0.5) * 0.012; gl_FragColor = vec4(col, 1.0); } `; const POINTS_VERT = ` attribute float aSize; attribute float aPhase; uniform float uTime; varying float vAlpha; void main() { vec3 p = position; // gentle drift — each point has its own phase p.x += sin(uTime * 0.20 + aPhase * 6.2831) * 0.18; p.y += cos(uTime * 0.16 + aPhase * 4.7) * 0.14; p.z += sin(uTime * 0.12 + aPhase * 3.1) * 0.10; vec4 mv = modelViewMatrix * vec4(p, 1.0); gl_PointSize = aSize * 220.0 / max(0.6, -mv.z); gl_Position = projectionMatrix * mv; // fade with depth vAlpha = clamp(1.0 - (-mv.z) / 14.0, 0.0, 1.0); } `; const POINTS_FRAG = ` precision mediump float; varying float vAlpha; void main() { vec2 c = gl_PointCoord - 0.5; float d = length(c); // soft round point with hot core float a = smoothstep(0.50, 0.00, d); float core = smoothstep(0.18, 0.00, d) * 0.9; vec3 col = mix(vec3(0.78, 0.82, 1.0), vec3(1.0), core); gl_FragColor = vec4(col, a * vAlpha * 0.85); } `; function AuroraCanvas() { const containerRef = React.useRef(null); React.useEffect(() => { if (typeof THREE === 'undefined') return undefined; if (typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches) { return undefined; } const container = containerRef.current; if (!container) return undefined; const canvas = document.createElement('canvas'); canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block'; container.appendChild(canvas); let renderer; try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'low-power' }); } catch (e) { // No WebGL — leave the container empty and let the CSS gradient show through. if (canvas.parentNode) canvas.parentNode.removeChild(canvas); return undefined; } renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 100); camera.position.z = 6; // Aurora plane — large enough to fill the camera frustum at z=-2 const auroraGeom = new THREE.PlaneGeometry(28, 18, 1, 1); const auroraMat = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 } }, vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }', fragmentShader: AURORA_FRAG, depthWrite: false, depthTest: false, }); const aurora = new THREE.Mesh(auroraGeom, auroraMat); aurora.position.z = -2; scene.add(aurora); // Floating particles const N = 600; const positions = new Float32Array(N * 3); const sizes = new Float32Array(N); const phases = new Float32Array(N); for (let i = 0; i < N; i++) { positions[i * 3] = (Math.random() - 0.5) * 22; positions[i * 3 + 1] = (Math.random() - 0.5) * 12; positions[i * 3 + 2] = (Math.random() - 0.5) * 9 + 1; // bias toward camera sizes[i] = Math.random() * 0.045 + 0.010; phases[i] = Math.random(); } const pGeom = new THREE.BufferGeometry(); pGeom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); pGeom.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1)); pGeom.setAttribute('aPhase', new THREE.BufferAttribute(phases, 1)); const pMat = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 } }, vertexShader: POINTS_VERT, fragmentShader: POINTS_FRAG, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, }); const points = new THREE.Points(pGeom, pMat); scene.add(points); // Resize const resize = () => { const w = container.clientWidth; const h = container.clientHeight; if (w === 0 || h === 0) return; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, false); }; resize(); const ro = (typeof ResizeObserver !== 'undefined') ? new ResizeObserver(resize) : null; if (ro) ro.observe(container); else window.addEventListener('resize', resize); // Animate — pause when off-screen let raf = 0; let running = false; let onScreen = true; const clock = new THREE.Clock(); const tick = () => { if (!running) return; const t = clock.getElapsedTime(); auroraMat.uniforms.uTime.value = t; pMat.uniforms.uTime.value = t; points.rotation.y = t * 0.04; points.rotation.x = Math.sin(t * 0.07) * 0.06; renderer.render(scene, camera); raf = requestAnimationFrame(tick); }; const start = () => { if (!running) { running = true; clock.start(); tick(); } }; const stop = () => { running = false; cancelAnimationFrame(raf); }; const io = (typeof IntersectionObserver !== 'undefined') ? new IntersectionObserver((entries) => { onScreen = entries[0].isIntersecting; if (onScreen) start(); else stop(); }, { threshold: 0.01 }) : null; if (io) io.observe(container); else start(); const onVis = () => { if (document.hidden) stop(); else if (onScreen) start(); }; document.addEventListener('visibilitychange', onVis); return () => { stop(); document.removeEventListener('visibilitychange', onVis); if (ro) ro.disconnect(); else window.removeEventListener('resize', resize); if (io) io.disconnect(); auroraGeom.dispose(); auroraMat.dispose(); pGeom.dispose(); pMat.dispose(); renderer.dispose(); if (canvas.parentNode) canvas.parentNode.removeChild(canvas); }; }, []); return