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

@@ -56,7 +56,6 @@ section {
border-radius: 8px; border-radius: 8px;
padding: 12px 20px 20px; padding: 12px 20px 20px;
position: relative; position: relative;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -13,7 +13,6 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
overflow: hidden;
transition: box-shadow 0.2s ease, transform 0.2s ease; transition: box-shadow 0.2s ease, transform 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

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. * Renders a smooth flowing noise field with accent-colored highlights
* GPU-accelerated, no banding, very lightweight. * plus drifting glowing particles, all in a single GPU shader pass.
*/ */
const PARTICLE_COUNT = 40;
const VERT_SRC = ` const VERT_SRC = `
attribute vec2 a_pos; attribute vec2 a_pos;
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); } void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
@@ -16,8 +18,9 @@ uniform float u_time;
uniform vec2 u_res; uniform vec2 u_res;
uniform vec3 u_accent; uniform vec3 u_accent;
uniform vec3 u_bg; 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; } 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; } 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); } 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; 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); 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 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; float colorMix = n2 * 0.5 + 0.5;
vec3 color = mix(col1, col2, colorMix); 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); 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); gl_FragColor = vec4(result, 1.0);
} }
`; `;
let _canvas, _gl, _prog; let _canvas, _gl, _prog;
let _uTime, _uRes, _uAccent, _uBg; let _uTime, _uRes, _uAccent, _uBg, _uParticles;
let _raf = null; let _raf = null;
let _startTime = 0; let _startTime = 0;
let _accent = [76 / 255, 175 / 255, 80 / 255]; let _accent = [76 / 255, 175 / 255, 80 / 255];
let _bgColor = [26 / 255, 26 / 255, 26 / 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) { function _compile(gl, type, src) {
const s = gl.createShader(type); const s = gl.createShader(type);
gl.shaderSource(s, src); gl.shaderSource(s, src);
@@ -126,15 +174,17 @@ function _initGL() {
_uRes = gl.getUniformLocation(_prog, 'u_res'); _uRes = gl.getUniformLocation(_prog, 'u_res');
_uAccent = gl.getUniformLocation(_prog, 'u_accent'); _uAccent = gl.getUniformLocation(_prog, 'u_accent');
_uBg = gl.getUniformLocation(_prog, 'u_bg'); _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; return true;
} }
function _resize() { function _resize() {
// Render at half resolution for performance const w = Math.round(window.innerWidth * 0.5);
const dpr = Math.min(window.devicePixelRatio || 1, 1); const h = Math.round(window.innerHeight * 0.5);
const w = Math.round(window.innerWidth * dpr * 0.5);
const h = Math.round(window.innerHeight * dpr * 0.5);
_canvas.width = w; _canvas.width = w;
_canvas.height = h; _canvas.height = h;
if (_gl) _gl.viewport(0, 0, w, h); if (_gl) _gl.viewport(0, 0, w, h);
@@ -145,12 +195,19 @@ function _draw(time) {
const gl = _gl; const gl = _gl;
if (!gl) return; if (!gl) return;
_updateParticles();
const t = (time - _startTime) * 0.001; const t = (time - _startTime) * 0.001;
gl.uniform1f(_uTime, t); gl.uniform1f(_uTime, t);
gl.uniform2f(_uRes, _canvas.width, _canvas.height); gl.uniform2f(_uRes, _canvas.width, _canvas.height);
gl.uniform3f(_uAccent, _accent[0], _accent[1], _accent[2]); gl.uniform3f(_uAccent, _accent[0], _accent[1], _accent[2]);
gl.uniform3f(_uBg, _bgColor[0], _bgColor[1], _bgColor[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); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
} }
@@ -158,6 +215,7 @@ function _start() {
if (_raf) return; if (_raf) return;
if (!_gl && !_initGL()) return; if (!_gl && !_initGL()) return;
_resize(); _resize();
_initParticles();
_startTime = performance.now(); _startTime = performance.now();
_raf = requestAnimationFrame(_draw); _raf = requestAnimationFrame(_draw);
} }