hmusman2804045-max
Complete Phase 7: Add Glassmorphism Frontend Dashboard, 3D WebGL Three.js motion, background glow effects, and update README
ec036fb
Raw
History Blame Contribute Delete
16.4 kB
/* =============================================================
bg3d.js — ambient motion layer
Three cooperating effects driven by ONE pointer listener and ONE
rAF loop:
1. a harmonic particle wave (Three.js)
2. slow embers drifting up through the same 3D volume
3. the DOM cursor spotlight + cursor-lit glass rims
The spotlight is deliberately handled here rather than in main.js:
it shares the pointer state and the frame loop with the WebGL layer,
so the page pays for one listener and one rAF instead of two.
Every piece degrades independently — no WebGL still leaves you with
a working spotlight, and prefers-reduced-motion collapses the whole
module to a single static frame.
============================================================= */
(function () {
"use strict";
var canvas = document.getElementById("bg-canvas");
var spotlight = document.getElementById("mouse-spotlight");
var root = document.documentElement;
var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var finePointer = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
/* ---------------------------------------------------------
Shared pointer state
--------------------------------------------------------- */
// Normalised (-1..1) for the camera, raw pixels for the spotlight.
var pointer = { nx: 0, ny: 0, px: window.innerWidth / 2, py: window.innerHeight / 2 };
var camTarget = { x: 0, y: 0 };
var spot = { x: pointer.px, y: pointer.py };
var lastSpot = { x: -9999, y: -9999 };
var pointerSeen = false;
var webglReady = false;
var frameId = null;
var running = false;
/* ---------------------------------------------------------
Particle wave configuration
--------------------------------------------------------- */
var AMOUNT_X = 76;
var AMOUNT_Y = 52;
var SEPARATION = 58;
var TOTAL = AMOUNT_X * AMOUNT_Y;
var DUST_COUNT = 340;
var DUST_SPAN_X = AMOUNT_X * SEPARATION;
var DUST_SPAN_Z = AMOUNT_Y * SEPARATION;
var DUST_FLOOR = -260;
var DUST_CEIL = 1500;
var scene, camera, renderer;
var geometry, material, particles;
var dustGeo, dustMat, dustPoints;
var positions, scales, colors;
var dustPos, dustVel, dustDrift;
var time = 0;
// Reusable scratch array so the per-frame wave math allocates nothing.
var rowWave = new Float32Array(AMOUNT_Y);
var COLOR_CYAN, COLOR_INDIGO, COLOR_VIOLET;
/* ---------------------------------------------------------
Shaders
--------------------------------------------------------- */
var WAVE_VERT = [
"attribute float aScale;",
"attribute vec3 aColor;",
"attribute float aGlow;",
"varying vec3 vColor;",
"varying float vFade;",
"varying float vGlow;",
"void main() {",
" vColor = aColor;",
" vGlow = aGlow;",
" vec4 mv = modelViewMatrix * vec4(position, 1.0);",
" vFade = clamp(1.0 - (-mv.z - 400.0) / 2200.0, 0.05, 1.0);",
" gl_PointSize = aScale * (170.0 / max(-mv.z, 1.0));",
" gl_Position = projectionMatrix * mv;",
"}"
].join("\n");
// Crests read hotter than troughs: aGlow lifts both the core colour and
// the alpha, so the wave reads as light moving through the grid.
var WAVE_FRAG = [
"varying vec3 vColor;",
"varying float vFade;",
"varying float vGlow;",
"void main() {",
" vec2 uv = gl_PointCoord - vec2(0.5);",
" float d = length(uv);",
" if (d > 0.5) discard;",
" float core = smoothstep(0.5, 0.0, d);",
" float alpha = smoothstep(0.5, 0.02, d);",
" vec3 lit = vColor * (0.75 + vGlow * 1.15) + vec3(core * vGlow * 0.35);",
" gl_FragColor = vec4(lit, alpha * vFade * (0.55 + vGlow * 0.65));",
"}"
].join("\n");
var DUST_VERT = [
"attribute float aScale;",
"attribute float aSeed;",
"attribute vec3 aColor;",
"uniform float uTime;",
"varying vec3 vColor;",
"varying float vAlpha;",
"void main() {",
" vColor = aColor;",
" vec4 mv = modelViewMatrix * vec4(position, 1.0);",
" float twinkle = 0.45 + 0.55 * sin(uTime * 1.7 + aSeed * 6.2831);",
" float depth = clamp(1.0 - (-mv.z - 300.0) / 2600.0, 0.0, 1.0);",
" vAlpha = twinkle * depth;",
" gl_PointSize = aScale * (150.0 / max(-mv.z, 1.0));",
" gl_Position = projectionMatrix * mv;",
"}"
].join("\n");
var DUST_FRAG = [
"varying vec3 vColor;",
"varying float vAlpha;",
"void main() {",
" vec2 uv = gl_PointCoord - vec2(0.5);",
" float d = length(uv);",
" if (d > 0.5) discard;",
" float soft = smoothstep(0.5, 0.0, d);",
" gl_FragColor = vec4(vColor, soft * soft * vAlpha * 0.85);",
"}"
].join("\n");
/* ---------------------------------------------------------
WebGL setup
--------------------------------------------------------- */
function initWebGL() {
if (!canvas || typeof window.THREE === "undefined") return false;
try {
renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: false,
alpha: true,
powerPreference: "high-performance"
});
} catch (err) {
console.warn("[bg3d] WebGL unavailable — particle layer disabled.", err);
return false;
}
COLOR_CYAN = new THREE.Color(0x22d3ee);
COLOR_INDIGO = new THREE.Color(0x6366f1);
COLOR_VIOLET = new THREE.Color(0x8b5cf6);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.setClearColor(0x000000, 0);
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0b0f19, 0.00055);
camera = new THREE.PerspectiveCamera(62, window.innerWidth / window.innerHeight, 1, 6000);
camera.position.set(0, 380, 1180);
buildWave();
buildDust();
canvas.classList.add("is-ready");
return true;
}
function buildWave() {
positions = new Float32Array(TOTAL * 3);
scales = new Float32Array(TOTAL);
colors = new Float32Array(TOTAL * 3);
var glow = new Float32Array(TOTAL);
var halfX = ((AMOUNT_X - 1) * SEPARATION) / 2;
var halfY = ((AMOUNT_Y - 1) * SEPARATION) / 2;
var maxDist = Math.sqrt(halfX * halfX + halfY * halfY);
var tint = new THREE.Color();
var i = 0;
var j = 0;
for (var ix = 0; ix < AMOUNT_X; ix++) {
for (var iy = 0; iy < AMOUNT_Y; iy++) {
var px = ix * SEPARATION - halfX;
var pz = iy * SEPARATION - halfY;
positions[i] = px;
positions[i + 1] = 0;
positions[i + 2] = pz;
// Cyan core → indigo mid-field → violet at the rim.
var dist = Math.sqrt(px * px + pz * pz) / maxDist;
tint.copy(COLOR_CYAN).lerp(COLOR_INDIGO, Math.min(dist * 1.3, 1));
tint.lerp(COLOR_VIOLET, Math.max(0, dist - 0.55) * 1.25);
colors[i] = tint.r;
colors[i + 1] = tint.g;
colors[i + 2] = tint.b;
scales[j] = 6;
glow[j] = 0.5;
i += 3;
j++;
}
}
geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("aScale", new THREE.BufferAttribute(scales, 1));
geometry.setAttribute("aColor", new THREE.BufferAttribute(colors, 3));
geometry.setAttribute("aGlow", new THREE.BufferAttribute(glow, 1));
material = new THREE.ShaderMaterial({
vertexShader: WAVE_VERT,
fragmentShader: WAVE_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending
});
particles = new THREE.Points(geometry, material);
particles.rotation.x = -0.12;
scene.add(particles);
}
function buildDust() {
dustPos = new Float32Array(DUST_COUNT * 3);
dustVel = new Float32Array(DUST_COUNT);
dustDrift = new Float32Array(DUST_COUNT);
var dScale = new Float32Array(DUST_COUNT);
var dSeed = new Float32Array(DUST_COUNT);
var dColor = new Float32Array(DUST_COUNT * 3);
var tint = new THREE.Color();
for (var k = 0; k < DUST_COUNT; k++) {
var p = k * 3;
dustPos[p] = (Math.random() - 0.5) * DUST_SPAN_X;
dustPos[p + 1] = DUST_FLOOR + Math.random() * (DUST_CEIL - DUST_FLOOR);
dustPos[p + 2] = (Math.random() - 0.5) * DUST_SPAN_Z;
// Rise rate and lateral sway rate, both per-ember.
dustVel[k] = 8 + Math.random() * 26;
dustDrift[k] = 0.25 + Math.random() * 0.7;
// Mostly cyan/violet with a few near-white embers for sparkle.
var mix = Math.random();
tint.copy(COLOR_CYAN).lerp(COLOR_VIOLET, mix);
if (mix > 0.86) tint.lerp(new THREE.Color(0xffffff), 0.55);
dColor[p] = tint.r;
dColor[p + 1] = tint.g;
dColor[p + 2] = tint.b;
dScale[k] = 2.5 + Math.random() * 6.5;
dSeed[k] = Math.random();
}
dustGeo = new THREE.BufferGeometry();
dustGeo.setAttribute("position", new THREE.BufferAttribute(dustPos, 3));
dustGeo.setAttribute("aScale", new THREE.BufferAttribute(dScale, 1));
dustGeo.setAttribute("aSeed", new THREE.BufferAttribute(dSeed, 1));
dustGeo.setAttribute("aColor", new THREE.BufferAttribute(dColor, 3));
dustMat = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 } },
vertexShader: DUST_VERT,
fragmentShader: DUST_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending
});
dustPoints = new THREE.Points(dustGeo, dustMat);
scene.add(dustPoints);
}
/* ---------------------------------------------------------
Per-frame simulation
--------------------------------------------------------- */
/**
* Two interfering harmonic pairs.
*
* The primary pair is separable — one term depends only on the column,
* the other only on the row — so the row half is hoisted into `rowWave`
* and computed AMOUNT_Y times per frame instead of TOTAL times. Only the
* secondary diagonal pair, which genuinely couples both axes, stays in
* the inner loop. That keeps this at ~2 trig calls per particle.
*/
function updateWave() {
var iy, ix;
for (iy = 0; iy < AMOUNT_Y; iy++) {
rowWave[iy] = Math.cos((iy + time) * 0.32);
}
var glowAttr = geometry.attributes.aGlow.array;
var i = 0;
var j = 0;
for (ix = 0; ix < AMOUNT_X; ix++) {
var colWave = Math.sin((ix + time) * 0.28);
var diagA = ix * 0.11 + time * 0.55;
var diagB = ix * 0.07 - time * 0.38;
for (iy = 0; iy < AMOUNT_Y; iy++) {
var rowW = rowWave[iy];
// Secondary pair travels diagonally at a different rate, so the
// two systems beat against each other instead of repeating.
var s2 = Math.sin(diagA + iy * 0.09);
var c2 = Math.cos(diagB - iy * 0.13);
positions[i + 1] = colWave * 26 + rowW * 26 + s2 * 18 + c2 * 13;
// Normalised crest height (0 trough → 1 peak) drives size and glow.
var amp = (colWave + rowW + s2 + c2) * 0.25;
var lift = amp * 0.5 + 0.5;
scales[j] = 2.2 + lift * 9.5;
glowAttr[j] = lift * lift;
i += 3;
j++;
}
}
geometry.attributes.position.needsUpdate = true;
geometry.attributes.aScale.needsUpdate = true;
geometry.attributes.aGlow.needsUpdate = true;
}
/** Embers rise, sway, and wrap back to the floor when they clear the ceiling. */
function updateDust(dt) {
for (var k = 0; k < DUST_COUNT; k++) {
var p = k * 3;
dustPos[p + 1] += dustVel[k] * dt;
dustPos[p] += Math.sin(time * dustDrift[k] + k) * 0.55;
dustPos[p + 2] += Math.cos(time * dustDrift[k] * 0.7 + k) * 0.35;
if (dustPos[p + 1] > DUST_CEIL) {
dustPos[p] = (Math.random() - 0.5) * DUST_SPAN_X;
dustPos[p + 1] = DUST_FLOOR;
dustPos[p + 2] = (Math.random() - 0.5) * DUST_SPAN_Z;
}
}
dustGeo.attributes.position.needsUpdate = true;
dustMat.uniforms.uTime.value = time;
}
/** Damped camera parallax on both axes. */
function updateCamera() {
camTarget.x += (pointer.nx * 360 - camTarget.x) * 0.045;
camTarget.y += (-pointer.ny * 210 - camTarget.y) * 0.045;
camera.position.x = camTarget.x;
camera.position.y = 380 + camTarget.y;
camera.lookAt(0, 0, 0);
particles.rotation.y = Math.sin(time * 0.012) * 0.06;
if (dustPoints) dustPoints.rotation.y = particles.rotation.y * 0.5;
}
/* ---------------------------------------------------------
Spotlight (DOM layer)
--------------------------------------------------------- */
function updateSpotlight() {
if (!finePointer) return;
spot.x += (pointer.px - spot.x) * 0.12;
spot.y += (pointer.py - spot.y) * 0.12;
// Sub-pixel churn would re-run style resolution for every .glass::after
// ring on the page for no visible gain, so settle instead.
if (Math.abs(spot.x - lastSpot.x) < 0.4 && Math.abs(spot.y - lastSpot.y) < 0.4) return;
lastSpot.x = spot.x;
lastSpot.y = spot.y;
if (spotlight) {
spotlight.style.transform = "translate3d(" + spot.x.toFixed(1) + "px," + spot.y.toFixed(1) + "px,0)";
}
root.style.setProperty("--spot-x", spot.x.toFixed(1) + "px");
root.style.setProperty("--spot-y", spot.y.toFixed(1) + "px");
}
/* ---------------------------------------------------------
Loop + lifecycle
--------------------------------------------------------- */
function renderFrame() {
if (!webglReady) return;
updateCamera();
renderer.render(scene, camera);
}
var lastTs = 0;
function animate(ts) {
frameId = window.requestAnimationFrame(animate);
// Delta-timed so ember speed is frame-rate independent, clamped so a
// background tab returning after a long pause doesn't teleport them.
var dt = lastTs ? Math.min((ts - lastTs) / 1000, 0.05) : 0.016;
lastTs = ts;
time += dt * 3.3;
if (webglReady) {
updateWave();
updateDust(dt);
}
updateSpotlight();
renderFrame();
}
function start() {
if (running) return;
running = true;
if (reduceMotion) {
// One static frame, no loop.
if (webglReady) {
updateWave();
updateDust(0);
renderFrame();
}
return;
}
lastTs = 0;
frameId = window.requestAnimationFrame(animate);
}
function stop() {
running = false;
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
frameId = null;
}
}
function onResize() {
if (!webglReady) return;
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight, false);
if (!running || reduceMotion) renderFrame();
}
function onPointerMove(event) {
pointer.px = event.clientX;
pointer.py = event.clientY;
pointer.nx = (event.clientX / window.innerWidth) * 2 - 1;
pointer.ny = (event.clientY / window.innerHeight) * 2 - 1;
if (!pointerSeen && finePointer) {
pointerSeen = true;
// Jump the halo to the cursor so it fades in where the pointer already
// is rather than sliding in from the middle of the screen.
spot.x = pointer.px;
spot.y = pointer.py;
root.classList.add("has-pointer");
}
}
function onVisibility() {
if (document.hidden) stop();
else start();
}
function onContextLost(event) {
event.preventDefault();
webglReady = false;
stop();
console.warn("[bg3d] WebGL context lost.");
}
function onContextRestored() {
console.info("[bg3d] WebGL context restored.");
webglReady = initWebGL();
start();
}
/* ---------------------------------------------------------
Boot
--------------------------------------------------------- */
webglReady = initWebGL();
window.addEventListener("resize", onResize, { passive: true });
document.addEventListener("pointermove", onPointerMove, { passive: true });
document.addEventListener("visibilitychange", onVisibility);
if (canvas) {
canvas.addEventListener("webglcontextlost", onContextLost, false);
canvas.addEventListener("webglcontextrestored", onContextRestored, false);
}
// Runs even with no WebGL — the spotlight still needs a loop.
start();
})();