424 lines
16 KiB
JavaScript
424 lines
16 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;
|
||
}
|
||
|
||
// ---------- Palm trees ----------
|
||
const palmMat = new THREE.LineBasicMaterial({ color: 0x00e5ff, transparent: true, opacity: 0.7 });
|
||
|
||
function makePalmTree() {
|
||
const group = new THREE.Group();
|
||
|
||
// Trunk
|
||
const trunkGeo = new THREE.CylinderGeometry(0.08, 0.15, 3.2, 5, 1);
|
||
const trunk = new THREE.LineSegments(new THREE.EdgesGeometry(trunkGeo), palmMat);
|
||
trunk.rotation.z = 0.12;
|
||
group.add(trunk);
|
||
|
||
// Fronds — 5 cone leaves spread around trunk top, long and drooping
|
||
for (let i = 0; i < 5; i++) {
|
||
const frondGeo = new THREE.ConeGeometry(0.75, 2.8, 4, 1);
|
||
const frond = new THREE.LineSegments(new THREE.EdgesGeometry(frondGeo), palmMat);
|
||
frond.position.y = 1.4; // half of cone height, so base sits at pivot
|
||
|
||
const pivot = new THREE.Object3D();
|
||
pivot.position.set(0, 3.2, 0); // trunk top
|
||
pivot.rotation.z = -1.15; // droop outward ~66°
|
||
pivot.rotation.y = (Math.PI * 2 / 5) * i;
|
||
pivot.add(frond);
|
||
group.add(pivot);
|
||
}
|
||
|
||
return group;
|
||
}
|
||
|
||
// 4 columns × 4 rows = 16 trees; adjacent columns staggered by half-step
|
||
const PALM_COLS = [
|
||
{ x: +10, zOffset: 0 },
|
||
{ x: +14, zOffset: -7 },
|
||
{ x: -10, zOffset: 0 },
|
||
{ x: -14, zOffset: -7 },
|
||
];
|
||
const PALM_Z_ROWS = [-5, -19, -33, -47];
|
||
const palmTrees = [];
|
||
for (const col of PALM_COLS) {
|
||
for (const zRow of PALM_Z_ROWS) {
|
||
const tree = makePalmTree();
|
||
tree.position.set(col.x, 0, zRow + col.zOffset);
|
||
tree.userData.baseX = col.x; // restore X to this lane on recycle
|
||
scene.add(tree);
|
||
palmTrees.push(tree);
|
||
}
|
||
}
|
||
|
||
// ---------- Flyby wireframe vehicles ----------
|
||
function rnd(a, b) { return a + Math.random() * (b - a); }
|
||
|
||
function makeVehicleMat() {
|
||
// Each vehicle gets its own material so color can be changed at spawn time.
|
||
return new THREE.LineBasicMaterial({ color: 0x00e5ff, transparent: true, opacity: 0.9 });
|
||
}
|
||
|
||
function addEdges(group, geo, mat) {
|
||
group.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo), mat));
|
||
}
|
||
|
||
function makeAirplane() {
|
||
const mat = makeVehicleMat();
|
||
const g = new THREE.Group();
|
||
addEdges(g, new THREE.BoxGeometry(4.0, 0.4, 0.6), mat); // fuselage
|
||
const lw = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(2.2, 0.12, 0.9)), mat);
|
||
lw.position.set(-1.8, 0, 0); g.add(lw); // left wing
|
||
const rw = lw.clone(); rw.position.set(1.8, 0, 0); g.add(rw); // right wing
|
||
const vf = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(0.1, 0.7, 0.45)), mat);
|
||
vf.position.set(-1.9, 0.3, 0); g.add(vf); // vertical tail fin
|
||
const hs = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.0, 0.1, 0.35)), mat);
|
||
hs.position.set(-1.9, 0.1, 0); g.add(hs); // horizontal stabiliser
|
||
return { group: g, mat };
|
||
}
|
||
|
||
function makeUFO() {
|
||
const mat = makeVehicleMat();
|
||
const g = new THREE.Group();
|
||
addEdges(g, new THREE.CylinderGeometry(1.8, 1.8, 0.35, 8, 1), mat); // main disc
|
||
const dome = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.SphereGeometry(0.8, 6, 4)), mat);
|
||
dome.position.set(0, 0.35, 0); g.add(dome); // top dome
|
||
const rim = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.CylinderGeometry(2.1, 2.1, 0.1, 8, 1)), mat);
|
||
rim.position.set(0, -0.1, 0); g.add(rim); // outer rim
|
||
return { group: g, mat };
|
||
}
|
||
|
||
function makeCar() {
|
||
const mat = makeVehicleMat();
|
||
const g = new THREE.Group();
|
||
addEdges(g, new THREE.BoxGeometry(2.8, 0.55, 1.2), mat); // body
|
||
const roof = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.4, 0.5, 1.0)), mat);
|
||
roof.position.set(-0.2, 0.52, 0); g.add(roof);
|
||
const wheelPositions = [[0.9, -0.2, 0.65], [0.9, -0.2, -0.65], [-0.9, -0.2, 0.65], [-0.9, -0.2, -0.65]];
|
||
for (const [x, y, z] of wheelPositions) {
|
||
const w = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.CylinderGeometry(0.3, 0.3, 0.2, 6, 1)), mat);
|
||
w.rotation.z = Math.PI / 2;
|
||
w.position.set(x, y, z);
|
||
g.add(w);
|
||
}
|
||
return { group: g, mat };
|
||
}
|
||
|
||
function makeSpaceship() {
|
||
const mat = makeVehicleMat();
|
||
const g = new THREE.Group();
|
||
const front = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(2.0, 0.4, 0.8)), mat);
|
||
front.position.set(0.75, 0, 0); g.add(front);
|
||
const rear = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.5, 0.7, 1.4)), mat);
|
||
rear.position.set(-0.8, 0, 0); g.add(rear);
|
||
const cockpit = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(0.5, 0.3, 0.5)), mat);
|
||
cockpit.position.set(0.9, 0.35, 0); g.add(cockpit);
|
||
// swept wings
|
||
for (const side of [1, -1]) {
|
||
const wing = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.6, 0.1, 0.65)), mat);
|
||
wing.rotation.y = side * 0.35;
|
||
wing.position.set(-0.5, -0.1, side * 0.9); g.add(wing);
|
||
const nacelle = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(0.4, 0.3, 0.3)), mat);
|
||
nacelle.position.set(-1.6, 0, side * 0.55); g.add(nacelle);
|
||
}
|
||
return { group: g, mat };
|
||
}
|
||
|
||
function makeHelicopter() {
|
||
const mat = makeVehicleMat();
|
||
const g = new THREE.Group();
|
||
addEdges(g, new THREE.BoxGeometry(2.0, 0.8, 0.9), mat); // cabin
|
||
const tail = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.8, 0.2, 0.25)), mat);
|
||
tail.position.set(-1.8, 0.2, 0); g.add(tail);
|
||
const mainRotor = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.CylinderGeometry(2.0, 2.0, 0.05, 8, 1)), mat);
|
||
mainRotor.position.set(0, 0.7, 0); g.add(mainRotor);
|
||
const tailRotor = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.CylinderGeometry(0.45, 0.45, 0.06, 6, 1)), mat);
|
||
tailRotor.rotation.z = Math.PI / 2;
|
||
tailRotor.position.set(-2.7, 0.35, 0.15); g.add(tailRotor);
|
||
for (const side of [1, -1]) {
|
||
const skid = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.6, 0.1, 0.12)), mat);
|
||
skid.position.set(0, -0.5, side * 0.5); g.add(skid);
|
||
}
|
||
return { group: g, mat };
|
||
}
|
||
|
||
function makeRocket() {
|
||
const mat = makeVehicleMat();
|
||
const g = new THREE.Group();
|
||
// Build pointing along local Y, then rotate group so it flies along X
|
||
addEdges(g, new THREE.CylinderGeometry(0.22, 0.28, 3.0, 6, 1), mat);
|
||
const nose = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.ConeGeometry(0.25, 0.85, 6, 1)), mat);
|
||
nose.position.set(0, 1.93, 0); g.add(nose);
|
||
for (let i = 0; i < 3; i++) {
|
||
const fin = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(0.08, 0.7, 0.5)), mat);
|
||
fin.rotation.y = (Math.PI * 2 / 3) * i;
|
||
fin.position.set(Math.sin((Math.PI * 2 / 3) * i) * 0.28, -1.1, Math.cos((Math.PI * 2 / 3) * i) * 0.28);
|
||
g.add(fin);
|
||
}
|
||
g.rotation.z = Math.PI / 2; // point along X so xVelocity moves it naturally
|
||
return { group: g, mat };
|
||
}
|
||
|
||
function makeMotorcycle() {
|
||
const mat = makeVehicleMat();
|
||
const g = new THREE.Group();
|
||
for (const x of [0.85, -0.75]) {
|
||
const wheel = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.CylinderGeometry(0.5, 0.5, 0.2, 8, 1)), mat);
|
||
wheel.rotation.z = Math.PI / 2;
|
||
wheel.position.set(x, 0, 0); g.add(wheel);
|
||
}
|
||
const frame = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.5, 0.6, 0.25)), mat);
|
||
frame.position.set(0, 0.5, 0); g.add(frame);
|
||
const seat = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(0.7, 0.15, 0.3)), mat);
|
||
seat.position.set(-0.1, 0.9, 0); g.add(seat);
|
||
const bar = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(0.3, 0.08, 0.7)), mat);
|
||
bar.position.set(0.7, 0.85, 0); g.add(bar);
|
||
g.scale.setScalar(1.5);
|
||
return { group: g, mat };
|
||
}
|
||
|
||
const FLYBY_COLORS = [0x00e5ff, 0xff2bd6, 0x8f5bff];
|
||
|
||
const flybyPool = [makeAirplane, makeUFO, makeCar, makeSpaceship, makeHelicopter, makeRocket, makeMotorcycle]
|
||
.map(fn => {
|
||
const r = fn();
|
||
r.group.visible = false;
|
||
scene.add(r.group);
|
||
return r;
|
||
});
|
||
|
||
const flyby = {
|
||
active: null,
|
||
xVel: 0,
|
||
nextAt: performance.now() + rnd(5000, 12000),
|
||
colorIdx: 0,
|
||
};
|
||
|
||
function spawnFlyby() {
|
||
const entry = flybyPool[Math.floor(Math.random() * flybyPool.length)];
|
||
const goLeft = Math.random() < 0.5;
|
||
entry.mat.color.setHex(FLYBY_COLORS[flyby.colorIdx++ % FLYBY_COLORS.length]);
|
||
const z = rnd(-6, -45);
|
||
// Closer passes move faster so they feel properly scaled in speed.
|
||
const dist = Math.abs(camera.position.z - z);
|
||
const speed = rnd(7, 14) * (55 / dist);
|
||
entry.group.position.set(goLeft ? 65 : -65, rnd(5, 13), z);
|
||
entry.group.rotation.set(0, 0, 0);
|
||
entry.group.visible = true;
|
||
flyby.active = entry;
|
||
flyby.xVel = goLeft ? -speed : speed;
|
||
}
|
||
|
||
// ---------- 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() {
|
||
const spread = Math.PI * 0.9;
|
||
state.targetAngle = (Math.random() - 0.5) * spread;
|
||
state.nextTurnAt = performance.now() + 10000;
|
||
}
|
||
|
||
window.briTunesBg = {
|
||
setBeat(v) {
|
||
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);
|
||
grid.position.x = ((state.scrollX % step) + step) % step;
|
||
grid.position.z = ((state.scrollZ % step) + step) % step;
|
||
|
||
// Scroll palm trees with the grid (same heading); recycle when behind camera
|
||
for (const t of palmTrees) {
|
||
t.position.x += dt * speed * Math.sin(state.dirAngle);
|
||
t.position.z += dt * speed * Math.cos(state.dirAngle);
|
||
if (t.position.z > 15) {
|
||
t.position.z = -54;
|
||
t.position.x = t.userData.baseX; // snap back to column lane on recycle
|
||
}
|
||
}
|
||
|
||
// Flyby objects
|
||
if (!flyby.active && now >= flyby.nextAt) spawnFlyby();
|
||
if (flyby.active) {
|
||
flyby.active.group.position.x += flyby.xVel * dt;
|
||
flyby.active.group.rotation.y += 0.3 * dt;
|
||
flyby.active.group.rotation.z += 0.12 * dt;
|
||
if (Math.abs(flyby.active.group.position.x) > 72) {
|
||
flyby.active.group.visible = false;
|
||
flyby.active = null;
|
||
flyby.nextAt = now + rnd(5000, 12000);
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
renderer.render(scene, camera);
|
||
} else {
|
||
requestAnimationFrame(frame);
|
||
}
|
||
}
|