hmusman2804045-max
Complete Phase 7: Add Glassmorphism Frontend Dashboard, 3D WebGL Three.js motion, background glow effects, and update README
ec036fb | /* ============================================================= | |
| 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(); | |
| })(); | |