Bri-Tunes/public/js/bg-grid.js

200 lines
6.4 KiB
JavaScript

// 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);
}
}