Merge floating particles into WebGL shader and fix color picker clipping

Remove overflow:hidden from .card and .template-card that was clipping
the color picker popover. Combine noise field + particle glow into a
single GPU shader pass (40 drifting particles as uniforms).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 11:35:49 +03:00
parent b4ab5ffe3c
commit 4245e81a35
3 changed files with 72 additions and 16 deletions

View File

@@ -1,10 +1,12 @@
/**
* Ambient background animation — WebGL shader.
* Ambient background animation — WebGL shader + floating particles.
*
* Renders a smooth flowing noise field with accent-colored highlights.
* GPU-accelerated, no banding, very lightweight.
* Renders a smooth flowing noise field with accent-colored highlights
* plus drifting glowing particles, all in a single GPU shader pass.
*/
const PARTICLE_COUNT = 40;
const VERT_SRC = `
attribute vec2 a_pos;
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
@@ -16,8 +18,9 @@ uniform float u_time;
uniform vec2 u_res;
uniform vec3 u_accent;
uniform vec3 u_bg;
uniform vec3 u_particles[${PARTICLE_COUNT}]; // xy = position (0-1), z = radius
// Simplex-style noise (compact hash-based)
// Simplex-style noise
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); }
@@ -59,31 +62,76 @@ void main() {
float n = n1 * 0.5 + n2 * 0.3 + n3 * 0.2;
// Map noise to a soft glow: only show the bright peaks
// Map noise to a soft glow
float glow = smoothstep(0.05, 0.6, n);
// Create two accent-derived colors with hue shift
// Two accent-derived colors
vec3 col1 = u_accent;
vec3 col2 = u_accent.gbr; // channel rotation for variety
vec3 col2 = u_accent.gbr;
// 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
// Noise background
vec3 result = mix(u_bg, u_bg + color * 0.6, glow * 0.14);
// Floating particles — accumulate glow from each
float particleGlow = 0.0;
float particleColor = 0.0;
for (int i = 0; i < ${PARTICLE_COUNT}; i++) {
vec2 ppos = u_particles[i].xy;
float pradius = u_particles[i].z;
// Particle position in aspect-corrected space
vec2 pp = vec2(ppos.x * aspect, ppos.y);
float d = length(p - pp);
float g = pradius / (d * d * 800.0 + pradius);
particleGlow += g;
// Alternate color based on index parity (use float comparison)
particleColor += g * step(0.5, fract(float(i) * 0.5));
}
// Mix particle colors: accent and white-ish accent
vec3 pCol = mix(u_accent, mix(u_accent, vec3(1.0), 0.5), particleColor / max(particleGlow, 0.001));
result += pCol * particleGlow * 0.5;
gl_FragColor = vec4(result, 1.0);
}
`;
let _canvas, _gl, _prog;
let _uTime, _uRes, _uAccent, _uBg;
let _uTime, _uRes, _uAccent, _uBg, _uParticles;
let _raf = null;
let _startTime = 0;
let _accent = [76 / 255, 175 / 255, 80 / 255];
let _bgColor = [26 / 255, 26 / 255, 26 / 255];
// Particle state (CPU-side, positions in 0..1 UV space)
const _particles = [];
function _initParticles() {
_particles.length = 0;
for (let i = 0; i < PARTICLE_COUNT; i++) {
_particles.push({
x: Math.random(),
y: Math.random(),
vx: (Math.random() - 0.5) * 0.0002,
vy: 0.0001 + Math.random() * 0.0003,
r: 0.003 + Math.random() * 0.006,
});
}
}
function _updateParticles() {
for (const p of _particles) {
p.x += p.vx;
p.y += p.vy;
// Wrap around
if (p.y > 1.05) { p.y = -0.05; p.x = Math.random(); }
if (p.x < -0.05) p.x = 1.05;
if (p.x > 1.05) p.x = -0.05;
}
}
function _compile(gl, type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
@@ -126,15 +174,17 @@ function _initGL() {
_uRes = gl.getUniformLocation(_prog, 'u_res');
_uAccent = gl.getUniformLocation(_prog, 'u_accent');
_uBg = gl.getUniformLocation(_prog, 'u_bg');
_uParticles = [];
for (let i = 0; i < PARTICLE_COUNT; i++) {
_uParticles.push(gl.getUniformLocation(_prog, `u_particles[${i}]`));
}
return true;
}
function _resize() {
// 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);
const w = Math.round(window.innerWidth * 0.5);
const h = Math.round(window.innerHeight * 0.5);
_canvas.width = w;
_canvas.height = h;
if (_gl) _gl.viewport(0, 0, w, h);
@@ -145,12 +195,19 @@ function _draw(time) {
const gl = _gl;
if (!gl) return;
_updateParticles();
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]);
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = _particles[i];
gl.uniform3f(_uParticles[i], p.x, p.y, p.r);
}
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
@@ -158,6 +215,7 @@ function _start() {
if (_raf) return;
if (!_gl && !_initGL()) return;
_resize();
_initParticles();
_startTime = performance.now();
_raf = requestAnimationFrame(_draw);
}