200 lines
6.4 KiB
JavaScript
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);
|
|
}
|
|
}
|