// Synthwave horizon grid background. Three.js module. import * as THREE from 'three'; const canvas = document.getElementById('bg-canvas'); if (canvas) { const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const scene = new THREE.Scene(); scene.fog = new THREE.Fog(0x05060d, 18, 80); const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 200); camera.position.set(0, 2.2, 10); camera.lookAt(0, 1.8, 0); const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true }); renderer.setClearColor(0x000000, 0); function resize() { const w = window.innerWidth; const h = window.innerHeight; const dpr = Math.min(window.devicePixelRatio || 1, 1.5); renderer.setPixelRatio(dpr); renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); } resize(); window.addEventListener('resize', resize); // ---------- Sunset backdrop (a large plane far behind the grid) ---------- const sunsetGeo = new THREE.PlaneGeometry(200, 120); const sunsetMat = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 } }, transparent: true, depthWrite: false, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` varying vec2 vUv; uniform float uTime; vec3 c1 = vec3(0.02, 0.01, 0.05); // deep space vec3 c2 = vec3(0.22, 0.08, 0.36); // violet (dimmed) vec3 c3 = vec3(0.40, 0.06, 0.34); // magenta (dimmed) vec3 c4 = vec3(0.42, 0.26, 0.12); // amber (dimmed) void main() { float y = vUv.y; // horizon around y = 0.45 vec3 col; if (y < 0.45) { col = mix(c1, c1 * 0.8, y / 0.45); } else if (y < 0.58) { col = mix(c4, c3, (y - 0.45) / 0.13); } else if (y < 0.78) { col = mix(c3, c2, (y - 0.58) / 0.20); } else { col = mix(c2, c1, (y - 0.78) / 0.22); } // subtle horizontal scan-bands over the sun float band = sin((vUv.y - 0.45) * 90.0) * 0.5 + 0.5; if (vUv.y > 0.46 && vUv.y < 0.58) { float mask = smoothstep(0.58, 0.46, vUv.y); col *= mix(1.0, 0.55, band * mask); } // Global darken so foreground text retains contrast col *= 0.75; gl_FragColor = vec4(col, 1.0); } `, }); const sunset = new THREE.Mesh(sunsetGeo, sunsetMat); sunset.position.set(0, 10, -60); scene.add(sunset); // ---------- Horizon line (thin cyan) ---------- const horizonGeo = new THREE.BufferGeometry(); horizonGeo.setAttribute( 'position', new THREE.Float32BufferAttribute([-100, 5.5, -55, 100, 5.5, -55], 3), ); const horizon = new THREE.Line( horizonGeo, new THREE.LineBasicMaterial({ color: 0x00e5ff, transparent: true, opacity: 0.9 }), ); scene.add(horizon); // ---------- Infinite recessing grid ---------- const GRID_SIZE = 200; const GRID_DIVS = 40; const gridGeo = buildGridGeometry(GRID_SIZE, GRID_DIVS); const gridMat = new THREE.LineBasicMaterial({ color: 0x8f5bff, transparent: true, opacity: 0.85, }); const grid = new THREE.LineSegments(gridGeo, gridMat); grid.rotation.x = -Math.PI / 2; grid.position.y = 0; scene.add(grid); function buildGridGeometry(size, divs) { const verts = []; const step = size / divs; const half = size / 2; for (let i = 0; i <= divs; i++) { const p = -half + i * step; // lines along X verts.push(-half, p, 0, half, p, 0); // lines along Y (which becomes Z after rotation) verts.push(p, -half, 0, p, half, 0); } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); return geo; } // ---------- Beat / bass reactivity surface ---------- const state = { beat: 0, // smoothed bass energy 0..1 scrollX: 0, // accumulated grid scroll on X scrollZ: 0, // accumulated grid scroll on Z dirAngle: 0, // current heading (radians, 0 = +Z toward camera) targetAngle: 0, // target heading we're easing toward nextTurnAt: performance.now() + 10000, lastT: performance.now(), visible: true, }; function pickNewDirection() { // Pick a random heading biased toward the forward hemisphere so the // horizon remains in frame, then ease toward it over the next second. const spread = Math.PI * 0.9; // ±~80° around forward state.targetAngle = (Math.random() - 0.5) * spread; state.nextTurnAt = performance.now() + 10000; } window.briTunesBg = { setBeat(v) { // called from player-viz.js state.beat = Math.max(state.beat * 0.6, Math.min(1, v)); }, }; document.addEventListener('visibilitychange', () => { state.visible = !document.hidden; }); function frame() { if (!state.visible) { requestAnimationFrame(frame); return; } const now = performance.now(); const dt = Math.min(0.05, (now - state.lastT) / 1000); state.lastT = now; // Decay beat state.beat *= 0.92; // Time to pick a new heading? if (now >= state.nextTurnAt) pickNewDirection(); // Ease current angle toward target const angleDelta = state.targetAngle - state.dirAngle; state.dirAngle += angleDelta * Math.min(1, dt * 1.2); // Scroll the grid in the current heading const baseSpeed = 6; const speed = baseSpeed * (1 + state.beat * 1.2); state.scrollX += dt * speed * Math.sin(state.dirAngle); state.scrollZ += dt * speed * Math.cos(state.dirAngle); const step = (GRID_SIZE / GRID_DIVS); // Keep scroll in [0, step) so the grid wraps seamlessly on both axes. grid.position.x = ((state.scrollX % step) + step) % step; grid.position.z = ((state.scrollZ % step) + step) % step; // Pulse color on beat (violet -> magenta) const mix = Math.min(1, state.beat); gridMat.color.setRGB(0.56 + mix * 0.44, 0.35 - mix * 0.2, 0.94); gridMat.opacity = 0.75 + mix * 0.25; horizon.material.opacity = 0.7 + mix * 0.3; sunsetMat.uniforms.uTime.value = now * 0.001; renderer.render(scene, camera); requestAnimationFrame(frame); } if (reduced) { // Render a single still frame and stop. renderer.render(scene, camera); } else { requestAnimationFrame(frame); } }