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,
* 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';