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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user