Replace particle background with WebGL simplex noise shader

GPU-accelerated flowing noise field with accent-colored highlights.
Three layered noise octaves at different scales produce organic motion.
Renders at half resolution for minimal GPU load, zero color banding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 11:29:27 +03:00
parent 4db7cd2d27
commit b4ab5ffe3c

View File

@@ -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, * Renders a smooth flowing noise field with accent-colored highlights.
* tinted with the current accent color. Lightweight and smooth. * GPU-accelerated, no banding, very lightweight.
*/ */
const PARTICLE_COUNT = 60; const VERT_SRC = `
const FPS = 30; 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 _raf = null;
let _particles = []; let _startTime = 0;
let _accentRgb = [76, 175, 80]; let _accent = [76 / 255, 175 / 255, 80 / 255];
let _bgColor = [26, 26, 26]; let _bgColor = [26 / 255, 26 / 255, 26 / 255];
let _w = 0, _h = 0;
function _createParticle(randomY) { function _compile(gl, type, src) {
return { const s = gl.createShader(type);
x: Math.random() * _w, gl.shaderSource(s, src);
y: randomY ? Math.random() * _h : _h + Math.random() * 40, gl.compileShader(s);
r: 1 + Math.random() * 2, if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
alpha: 0.15 + Math.random() * 0.35, console.error('Shader compile:', gl.getShaderInfoLog(s));
vx: (Math.random() - 0.5) * 0.3, return null;
vy: -(0.15 + Math.random() * 0.4), }
// slight color variation: mix accent with white return s;
mix: 0.3 + Math.random() * 0.7, }
};
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() { function _resize() {
_w = window.innerWidth; // Render at half resolution for performance
_h = window.innerHeight; const dpr = Math.min(window.devicePixelRatio || 1, 1);
_canvas.width = _w; const w = Math.round(window.innerWidth * dpr * 0.5);
_canvas.height = _h; 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) { function _draw(time) {
_raf = requestAnimationFrame(_draw); _raf = requestAnimationFrame(_draw);
if (time - _lastFrame < 1000 / FPS) return; const gl = _gl;
_lastFrame = time; if (!gl) return;
const [br, bg, bb] = _bgColor; const t = (time - _startTime) * 0.001;
const [ar, ag, ab] = _accentRgb; 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})`; gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
_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);
}
} }
function _start() { function _start() {
if (_raf) return; if (_raf) return;
if (!_gl && !_initGL()) return;
_resize(); _resize();
_initParticles(); _startTime = performance.now();
_raf = requestAnimationFrame(_draw); _raf = requestAnimationFrame(_draw);
} }
@@ -98,26 +169,25 @@ function _stop() {
} }
} }
function hexToRgb(hex) { function hexToNorm(hex) {
return [ return [
parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16), parseInt(hex.slice(5, 7), 16) / 255,
]; ];
} }
export function updateBgAnimAccent(hex) { export function updateBgAnimAccent(hex) {
_accentRgb = hexToRgb(hex); _accent = hexToNorm(hex);
} }
export function updateBgAnimTheme(isDark) { 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() { export function initBgAnim() {
_canvas = document.getElementById('bg-anim-canvas'); _canvas = document.getElementById('bg-anim-canvas');
if (!_canvas) return; if (!_canvas) return;
_ctx = _canvas.getContext('2d');
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
const on = document.documentElement.getAttribute('data-bg-anim') === 'on'; const on = document.documentElement.getAttribute('data-bg-anim') === 'on';