diff --git a/server/src/wled_controller/static/js/core/bg-anim.js b/server/src/wled_controller/static/js/core/bg-anim.js index 6be69d2..9dd38e5 100644 --- a/server/src/wled_controller/static/js/core/bg-anim.js +++ b/server/src/wled_controller/static/js/core/bg-anim.js @@ -1,93 +1,164 @@ /** - * Ambient background animation — floating particle field. + * Ambient background animation — WebGL shader. * - * Renders small glowing dots that drift slowly upward on a canvas, - * tinted with the current accent color. Lightweight and smooth. + * Renders a smooth flowing noise field with accent-colored highlights. + * GPU-accelerated, no banding, very lightweight. */ -const PARTICLE_COUNT = 60; -const FPS = 30; +const VERT_SRC = ` +attribute vec2 a_pos; +void main() { gl_Position = vec4(a_pos, 0.0, 1.0); } +`; -let _canvas, _ctx; +const FRAG_SRC = ` +precision mediump float; +uniform float u_time; +uniform vec2 u_res; +uniform vec3 u_accent; +uniform vec3 u_bg; + +// Simplex-style noise (compact hash-based) +vec3 mod289(vec3 x) { return x - floor(x * (1.0/289.0)) * 289.0; } +vec2 mod289(vec2 x) { return x - floor(x * (1.0/289.0)) * 289.0; } +vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); } + +float snoise(vec2 v) { + const vec4 C = vec4(0.211324865405187, 0.366025403784439, + -0.577350269189626, 0.024390243902439); + vec2 i = floor(v + dot(v, C.yy)); + vec2 x0 = v - i + dot(i, C.xx); + vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + vec4 x12 = x0.xyxy + C.xxzz; + x12.xy -= i1; + i = mod289(i); + vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); + vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0); + m = m*m; m = m*m; + vec3 x = 2.0 * fract(p * C.www) - 1.0; + vec3 h = abs(x) - 0.5; + vec3 ox = floor(x + 0.5); + vec3 a0 = x - ox; + m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h); + vec3 g; + g.x = a0.x * x0.x + h.x * x0.y; + g.yz = a0.yz * x12.xz + h.yz * x12.yw; + return 130.0 * dot(m, g); +} + +void main() { + vec2 uv = gl_FragCoord.xy / u_res; + float aspect = u_res.x / u_res.y; + vec2 p = vec2(uv.x * aspect, uv.y); + + float t = u_time * 0.08; + + // Layered noise at different scales and speeds + float n1 = snoise(p * 1.5 + vec2(t * 0.3, t * 0.2)); + float n2 = snoise(p * 2.5 + vec2(-t * 0.2, t * 0.15)); + float n3 = snoise(p * 0.8 + vec2(t * 0.1, -t * 0.25)); + + float n = n1 * 0.5 + n2 * 0.3 + n3 * 0.2; + + // Map noise to a soft glow: only show the bright peaks + float glow = smoothstep(0.05, 0.6, n); + + // Create two accent-derived colors with hue shift + vec3 col1 = u_accent; + vec3 col2 = u_accent.gbr; // channel rotation for variety + + // Mix between the two colors based on second noise layer + float colorMix = n2 * 0.5 + 0.5; + vec3 color = mix(col1, col2, colorMix); + + // Final: blend colored glow onto background + vec3 result = mix(u_bg, u_bg + color * 0.6, glow * 0.14); + + gl_FragColor = vec4(result, 1.0); +} +`; + +let _canvas, _gl, _prog; +let _uTime, _uRes, _uAccent, _uBg; let _raf = null; -let _particles = []; -let _accentRgb = [76, 175, 80]; -let _bgColor = [26, 26, 26]; -let _w = 0, _h = 0; +let _startTime = 0; +let _accent = [76 / 255, 175 / 255, 80 / 255]; +let _bgColor = [26 / 255, 26 / 255, 26 / 255]; -function _createParticle(randomY) { - return { - x: Math.random() * _w, - y: randomY ? Math.random() * _h : _h + Math.random() * 40, - r: 1 + Math.random() * 2, - alpha: 0.15 + Math.random() * 0.35, - vx: (Math.random() - 0.5) * 0.3, - vy: -(0.15 + Math.random() * 0.4), - // slight color variation: mix accent with white - mix: 0.3 + Math.random() * 0.7, - }; +function _compile(gl, type, src) { + const s = gl.createShader(type); + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + console.error('Shader compile:', gl.getShaderInfoLog(s)); + return null; + } + return s; +} + +function _initGL() { + _gl = _canvas.getContext('webgl', { alpha: false, antialias: false, depth: false }); + if (!_gl) return false; + const gl = _gl; + + const vs = _compile(gl, gl.VERTEX_SHADER, VERT_SRC); + const fs = _compile(gl, gl.FRAGMENT_SHADER, FRAG_SRC); + if (!vs || !fs) return false; + + _prog = gl.createProgram(); + gl.attachShader(_prog, vs); + gl.attachShader(_prog, fs); + gl.linkProgram(_prog); + if (!gl.getProgramParameter(_prog, gl.LINK_STATUS)) { + console.error('Program link:', gl.getProgramInfoLog(_prog)); + return false; + } + gl.useProgram(_prog); + + // Full-screen quad + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW); + const aPos = gl.getAttribLocation(_prog, 'a_pos'); + gl.enableVertexAttribArray(aPos); + gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); + + _uTime = gl.getUniformLocation(_prog, 'u_time'); + _uRes = gl.getUniformLocation(_prog, 'u_res'); + _uAccent = gl.getUniformLocation(_prog, 'u_accent'); + _uBg = gl.getUniformLocation(_prog, 'u_bg'); + + return true; } function _resize() { - _w = window.innerWidth; - _h = window.innerHeight; - _canvas.width = _w; - _canvas.height = _h; + // Render at half resolution for performance + const dpr = Math.min(window.devicePixelRatio || 1, 1); + const w = Math.round(window.innerWidth * dpr * 0.5); + const h = Math.round(window.innerHeight * dpr * 0.5); + _canvas.width = w; + _canvas.height = h; + if (_gl) _gl.viewport(0, 0, w, h); } -function _initParticles() { - _particles = []; - for (let i = 0; i < PARTICLE_COUNT; i++) { - _particles.push(_createParticle(true)); - } -} - -let _lastFrame = 0; function _draw(time) { _raf = requestAnimationFrame(_draw); - if (time - _lastFrame < 1000 / FPS) return; - _lastFrame = time; + const gl = _gl; + if (!gl) return; - const [br, bg, bb] = _bgColor; - const [ar, ag, ab] = _accentRgb; + const t = (time - _startTime) * 0.001; + gl.uniform1f(_uTime, t); + gl.uniform2f(_uRes, _canvas.width, _canvas.height); + gl.uniform3f(_uAccent, _accent[0], _accent[1], _accent[2]); + gl.uniform3f(_uBg, _bgColor[0], _bgColor[1], _bgColor[2]); - _ctx.fillStyle = `rgb(${br},${bg},${bb})`; - _ctx.fillRect(0, 0, _w, _h); - - for (let i = 0; i < _particles.length; i++) { - const p = _particles[i]; - - // Update position - p.x += p.vx; - p.y += p.vy; - - // Recycle particles that leave the screen - if (p.y < -10 || p.x < -10 || p.x > _w + 10) { - _particles[i] = _createParticle(false); - continue; - } - - // Color: mix between accent and white based on particle's mix factor - const m = p.mix; - const r = Math.round(ar * m + 255 * (1 - m)); - const g = Math.round(ag * m + 255 * (1 - m)); - const b = Math.round(ab * m + 255 * (1 - m)); - - // Draw soft glowing dot - const grad = _ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 3); - grad.addColorStop(0, `rgba(${r},${g},${b},${p.alpha})`); - grad.addColorStop(0.4, `rgba(${r},${g},${b},${p.alpha * 0.4})`); - grad.addColorStop(1, `rgba(${r},${g},${b},0)`); - - _ctx.fillStyle = grad; - _ctx.fillRect(p.x - p.r * 3, p.y - p.r * 3, p.r * 6, p.r * 6); - } + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } function _start() { if (_raf) return; + if (!_gl && !_initGL()) return; _resize(); - _initParticles(); + _startTime = performance.now(); _raf = requestAnimationFrame(_draw); } @@ -98,26 +169,25 @@ function _stop() { } } -function hexToRgb(hex) { +function hexToNorm(hex) { return [ - parseInt(hex.slice(1, 3), 16), - parseInt(hex.slice(3, 5), 16), - parseInt(hex.slice(5, 7), 16), + parseInt(hex.slice(1, 3), 16) / 255, + parseInt(hex.slice(3, 5), 16) / 255, + parseInt(hex.slice(5, 7), 16) / 255, ]; } export function updateBgAnimAccent(hex) { - _accentRgb = hexToRgb(hex); + _accent = hexToNorm(hex); } export function updateBgAnimTheme(isDark) { - _bgColor = isDark ? [26, 26, 26] : [245, 245, 245]; + _bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255]; } export function initBgAnim() { _canvas = document.getElementById('bg-anim-canvas'); if (!_canvas) return; - _ctx = _canvas.getContext('2d'); const observer = new MutationObserver(() => { const on = document.documentElement.getAttribute('data-bg-anim') === 'on';