radiance: perf — spawn point and corona birth cached in particle state, adaptive quality governor, debounced resize

This commit is contained in:
dangrubbb
2026-10-24 10:06:33 -04:00
parent 1645ed76ca
commit f2f6bf7708

View File

@ -125,7 +125,8 @@ vec3 spawnDir(uint idx, float epoch, float t) {
} }
return best; return best;
} }
// the spawn point of a particle's current life; the flow carries it away, a soft pull brings it back // the spawn point of a particle's current life; the flow carries it away, a soft pull brings it back.
// Costly (four density evaluations), so it is computed once per life and kept in the particle state.
vec3 homeOf(uint idx, float epoch) { vec3 homeOf(uint idx, float epoch) {
float tSpawn = (epoch - h01(idx * 8u + 2u)) * lifeLen(idx); float tSpawn = (epoch - h01(idx * 8u + 2u)) * lifeLen(idx);
return spawnDir(idx, epoch, tSpawn) * layerR(idx); return spawnDir(idx, epoch, tSpawn) * layerR(idx);
@ -146,26 +147,29 @@ struct P {
float coronaLen(uint idx) { return mix(3.5, 8.5, h01(idx * 8u + 1u)); } float coronaLen(uint idx) { return mix(3.5, 8.5, h01(idx * 8u + 1u)); }
float coronaLT(uint idx, float t) { return t / coronaLen(idx) + h01(idx * 8u + 2u); } float coronaLT(uint idx, float t) { return t / coronaLen(idx) + h01(idx * 8u + 2u); }
P corona(uint idx) { // birthplace: best of three rim angles, scored by tuft strength and how far over the top it is.
P o; // Costly, so it is done once per life and kept in the particle state (cd = rim direction, n = tuft strength).
o.r = vec4(h01(idx * 8u), h01(idx * 8u + 1u), h01(idx * 8u + 2u), h01(idx * 8u + 3u)); void coronaBirth(uint idx, float epoch, out vec2 cd, out float n) {
float lt = coronaLT(idx, uTime); float tSpawn = (epoch - h01(idx * 8u + 2u)) * coronaLen(idx);
float epoch = floor(lt);
o.life = fract(lt);
float tSpawn = (epoch - o.r.z) * coronaLen(idx);
// birthplace: best of three rim angles, scored by tuft strength and how far over the top it is
uint base = pcg(idx * 2654435761u + uint(epoch) * 40503u + 91u); uint base = pcg(idx * 2654435761u + uint(epoch) * 40503u + 91u);
vec2 cd = vec2(0.0, 1.0); cd = vec2(0.0, 1.0);
float n = 0.0, up = 0.0, best = -1.0; n = 0.0;
float best = -1.0;
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
float a = h01(base + uint(i) * 374761393u) * 6.2831853; float a = h01(base + uint(i) * 374761393u) * 6.2831853;
vec2 c = vec2(cos(a), sin(a)); vec2 c = vec2(cos(a), sin(a));
float nn = fbm(vec3(c * 4.2, tSpawn * 0.10)); float nn = fbm(vec3(c * 4.2, tSpawn * 0.10));
float u = smoothstep(-0.75, 0.9, c.y); float u = smoothstep(-0.75, 0.9, c.y);
float score = (0.25 + 0.75 * u) * (0.3 + nn); float score = (0.25 + 0.75 * u) * (0.3 + nn);
if (score > best) { best = score; cd = c; n = nn; up = u; } if (score > best) { best = score; cd = c; n = nn; }
} }
}
P corona(uint idx, vec2 cd, float n) {
P o;
o.r = vec4(h01(idx * 8u), h01(idx * 8u + 1u), h01(idx * 8u + 2u), h01(idx * 8u + 3u));
o.life = fract(coronaLT(idx, uTime));
float up = smoothstep(-0.75, 0.9, cd.y);
float spike = pow(n, 1.6) * 1.6; float spike = pow(n, 1.6) * 1.6;
float travel = pow(o.r.x, 1.3) * (0.07 + 0.5 * spike) * (0.3 + 0.7 * up) + 0.02; float travel = pow(o.r.x, 1.3) * (0.07 + 0.5 * spike) * (0.3 + 0.7 * up) + 0.02;
float ease = 1.0 - pow(1.0 - o.life, 1.35); // a steady drift that slows toward the end float ease = 1.0 - pow(1.0 - o.life, 1.35); // a steady drift that slows toward the end
@ -320,8 +324,9 @@ void main() { o = uValue * texture(uTex, vUv); }
const FRAG_STATE = `${HEAD} const FRAG_STATE = `${HEAD}
${COMMON} ${COMMON}
uniform sampler2D uPos; // inner: body-space xyz (orb radii), w = stir. corona: xy displacement uniform sampler2D uPos; // inner: body-space xyz (orb radii), w = stir. corona: xy displacement, zw rim direction
uniform sampler2D uVel; // velocity, same layout uniform sampler2D uVel; // velocity, w = the life (epoch) the particle is in. corona: z = tuft strength
uniform sampler2D uHome; // inner: spawn point xyz of the current life
uniform sampler2D uFlow; uniform sampler2D uFlow;
uniform int uT; uniform int uT;
uniform uint uTotal; uniform uint uTotal;
@ -335,6 +340,7 @@ uniform float uAmb; // 0 freezes the ambient currents (reduced motion)
uniform int uInit; uniform int uInit;
layout(location = 0) out vec4 oPos; layout(location = 0) out vec4 oPos;
layout(location = 1) out vec4 oVel; layout(location = 1) out vec4 oVel;
layout(location = 2) out vec4 oHome;
// fluid velocity (texels/s) under a view-space point (orb radii, y up) -> orb radii per second // fluid velocity (texels/s) under a view-space point (orb radii, y up) -> orb radii per second
vec2 flowAt(vec2 sp) { vec2 flowAt(vec2 sp) {
@ -347,18 +353,27 @@ vec2 flowAt(vec2 sp) {
void main() { void main() {
ivec2 ip = ivec2(gl_FragCoord.xy); ivec2 ip = ivec2(gl_FragCoord.xy);
uint idx = uint(ip.y * uT + ip.x); uint idx = uint(ip.y * uT + ip.x);
if (idx >= uTotal) { oPos = vec4(0.0); oVel = vec4(0.0); return; } if (idx >= uTotal) { oPos = vec4(0.0); oVel = vec4(0.0); oHome = vec4(0.0); return; }
vec4 s0 = texelFetch(uPos, ip, 0); vec4 s0 = texelFetch(uPos, ip, 0);
vec4 s1 = texelFetch(uVel, ip, 0); vec4 s1 = texelFetch(uVel, ip, 0);
vec4 s2 = texelFetch(uHome, ip, 0);
float t = uTime; float t = uTime;
oHome = s2;
if (idx >= uInner) { if (idx >= uInner) {
// corona: the cursor flow and a light wind carry a spark along; nothing pulls it back, // corona: the cursor flow and a light wind carry a spark along; nothing pulls it back,
// it just lives out its life and dies (a rebirth clears the push) // it just lives out its life and dies (a rebirth clears the push)
P p = corona(idx);
vec2 disp = s0.xy; vec2 disp = s0.xy;
vec2 vel = s1.xy; vec2 vel = s1.xy;
if (uInit == 1 || floor(coronaLT(idx, t)) != floor(coronaLT(idx, t - uDt))) { disp = vec2(0.0); vel = vec2(0.0); } vec2 cd = s0.zw;
float tuft = s1.z;
float ep = floor(coronaLT(idx, t));
if (uInit == 1 || ep != s1.w) {
coronaBirth(idx, ep, cd, tuft);
disp = vec2(0.0);
vel = vec2(0.0);
}
P p = corona(idx, cd, tuft);
vec2 flow = flowAt(p.xy + disp); vec2 flow = flowAt(p.xy + disp);
vec3 np = vec3(p.xy * 2.4 + vec2(0.0, t * 0.05), t * 0.16); vec3 np = vec3(p.xy * 2.4 + vec2(0.0, t * 0.05), t * 0.16);
float e = 0.03; float e = 0.03;
@ -369,8 +384,8 @@ void main() {
disp += vel * uDt; disp += vel * uDt;
float m = length(disp); float m = length(disp);
if (m > 1.2) disp *= 1.2 / m; if (m > 1.2) disp *= 1.2 / m;
oPos = vec4(disp, 0.0, 0.0); oPos = vec4(disp, cd);
oVel = vec4(vel, 0.0, 0.0); oVel = vec4(vel, tuft, ep);
return; return;
} }
@ -380,8 +395,10 @@ void main() {
vec3 pos = s0.xyz; vec3 pos = s0.xyz;
vec3 vel = s1.xyz; vec3 vel = s1.xyz;
float stir = s0.w; float stir = s0.w;
vec3 home = homeOf(idx, floor(lt)); float ep = floor(lt);
if (uInit == 1 || floor(lt) != floor(lifeT(idx, t - uDt))) { vec3 home = s2.xyz;
if (uInit == 1 || ep != s1.w) {
home = homeOf(idx, ep);
pos = home; pos = home;
vel = vec3(0.0); vel = vec3(0.0);
stir = 0.0; stir = 0.0;
@ -423,7 +440,8 @@ void main() {
stir = max(stir * exp(-uDt * 1.2), clamp(fm * front * 1.5, 0.0, 1.5)); stir = max(stir * exp(-uDt * 1.2), clamp(fm * front * 1.5, 0.0, 1.5));
oPos = vec4(pos, stir); oPos = vec4(pos, stir);
oVel = vec4(vel, 0.0); oVel = vec4(vel, ep);
oHome = vec4(home, 0.0);
} }
`; `;
@ -433,6 +451,7 @@ const VERT_PARTICLES = `${HEAD}
${COMMON} ${COMMON}
uniform sampler2D uPos; uniform sampler2D uPos;
uniform sampler2D uVel; uniform sampler2D uVel;
uniform sampler2D uHome;
uniform int uT; uniform int uT;
uniform int uStatic; // no float render targets: particles sit at their spawn points uniform int uStatic; // no float render targets: particles sit at their spawn points
uniform vec2 uView; // css px uniform vec2 uView; // css px
@ -448,6 +467,7 @@ void main() {
ivec2 tc = ivec2(int(idx) % uT, int(idx) / uT); ivec2 tc = ivec2(int(idx) % uT, int(idx) / uT);
vec4 s0 = texelFetch(uPos, tc, 0); vec4 s0 = texelFetch(uPos, tc, 0);
vec4 s1 = texelFetch(uVel, tc, 0); vec4 s1 = texelFetch(uVel, tc, 0);
vec4 s2 = texelFetch(uHome, tc, 0);
float t = uTime; float t = uTime;
float sc = clamp(uR / 315.0, 0.8, 1.7); float sc = clamp(uR / 315.0, 0.8, 1.7);
vec2 vp; vec2 vp;
@ -468,7 +488,7 @@ void main() {
persp = 1.0 / (1.0 - bp.z * 0.12); persp = 1.0 / (1.0 - bp.z * 0.12);
vp = bp.xy * persp; vp = bp.xy * persp;
float n = density(normalize(pos + vec3(1e-5)), t); float n = density(normalize(pos + vec3(1e-5)), t); // live: the field drifts, which gives the colour contrast
vec3 warm = mix(vec3(0.88, 0.17, 0.15), vec3(1.0, 0.45, 0.28), r.w * r.w); vec3 warm = mix(vec3(0.88, 0.17, 0.15), vec3(1.0, 0.45, 0.28), r.w * r.w);
warm = mix(warm, vec3(1.0, 0.70, 0.40), smoothstep(0.25, 0.7, 1.0 - n) * 0.85); warm = mix(warm, vec3(1.0, 0.70, 0.40), smoothstep(0.25, 0.7, 1.0 - n) * 0.85);
col = vec4(mix(warm, vec3(1.0, 0.99, 0.92), spark), 0.95); col = vec4(mix(warm, vec3(1.0, 0.99, 0.92), spark), 0.95);
@ -487,7 +507,10 @@ void main() {
size *= 1.0 + stir * 0.5; size *= 1.0 + stir * 0.5;
vGlow = max(vGlow, clamp(stir * 0.2, 0.0, 0.25)); vGlow = max(vGlow, clamp(stir * 0.2, 0.0, 0.25));
} else { } else {
P p = corona(idx); vec2 cd = s0.zw;
float tuft = s1.z;
if (uStatic == 1) coronaBirth(idx, floor(coronaLT(idx, t)), cd, tuft);
P p = corona(idx, cd, tuft);
vec2 disp = s0.xy; vec2 disp = s0.xy;
float stir = clamp(length(s1.xy) * 0.35, 0.0, 1.0); float stir = clamp(length(s1.xy) * 0.35, 0.0, 1.0);
vp = p.xy + disp; vp = p.xy + disp;
@ -665,31 +688,26 @@ function texture(gl, w, h, internal, type, filter) {
return tex; return tex;
} }
// dual: a second colour attachment (tex2) is rendered alongside the first // count: how many colour attachments are rendered together (tex, tex2, tex3)
function target(gl, w, h, internal, type, filter, dual = false) { function target(gl, w, h, internal, type, filter, count = 1) {
const tex = texture(gl, w, h, internal, type, filter); const texs = Array.from({ length: count }, () => texture(gl, w, h, internal, type, filter));
const tex2 = dual ? texture(gl, w, h, internal, type, filter) : null;
const fbo = gl.createFramebuffer(); const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); texs.forEach((tx, i) => gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tx, 0));
if (dual) { if (count > 1) gl.drawBuffers(texs.map((_, i) => gl.COLOR_ATTACHMENT0 + i));
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, tex2, 0);
gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]);
}
gl.viewport(0, 0, w, h); gl.viewport(0, 0, w, h);
gl.clearColor(0, 0, 0, 0); gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT); gl.clear(gl.COLOR_BUFFER_BIT);
return { tex, tex2, fbo, w, h }; return { tex: texs[0], tex2: texs[1] || null, tex3: texs[2] || null, fbo, w, h };
} }
function freeTarget(gl, t) { function freeTarget(gl, t) {
gl.deleteTexture(t.tex); [t.tex, t.tex2, t.tex3].forEach((tx) => tx && gl.deleteTexture(tx));
if (t.tex2) gl.deleteTexture(t.tex2);
gl.deleteFramebuffer(t.fbo); gl.deleteFramebuffer(t.fbo);
} }
function pingPong(gl, w, h, internal, type, filter, dual = false) { function pingPong(gl, w, h, internal, type, filter, count = 1) {
const pair = [target(gl, w, h, internal, type, filter, dual), target(gl, w, h, internal, type, filter, dual)]; const pair = [target(gl, w, h, internal, type, filter, count), target(gl, w, h, internal, type, filter, count)];
let i = 0; let i = 0;
return { return {
get read() { return pair[i]; }, get read() { return pair[i]; },
@ -748,8 +766,9 @@ export function startOrb(canvas, { onReady } = {}) {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
// particle state, two textures: position (xyz + stir) and velocity (xyz) per particle // particle state, three textures per particle: position (+ stir), velocity (+ life number),
const state = interactive ? pingPong(gl, T, T, stateFmt.internal, stateFmt.type, gl.NEAREST, true) : null; // and the spawn point of the current life, so the costly spawn maths runs once per life
const state = interactive ? pingPong(gl, T, T, stateFmt.internal, stateFmt.type, gl.NEAREST, 3) : null;
let needInit = true; let needInit = true;
// fluid fields; rebuilt when the viewport shape changes so the grid stays square-celled // fluid fields; rebuilt when the viewport shape changes so the grid stays square-celled
@ -766,10 +785,18 @@ export function startOrb(canvas, { onReady } = {}) {
curl = target(gl, simW, simH, fluidFmt.internal, fluidFmt.type, gl.NEAREST); curl = target(gl, simW, simH, fluidFmt.internal, fluidFmt.type, gl.NEAREST);
} }
// adaptive quality: if frames stay slow, step down resolution and particle count (never back up)
const QUALITY = [
{ dprCap: 2, frac: 1 },
{ dprCap: 1.5, frac: 0.7 },
{ dprCap: 1, frac: 0.45 },
];
let quality = 0, dprCap = 2, drawInner = INNER, drawCorona = CORONA;
// layout // layout
let W = 0, H = 0, dpr = 1, cx = 0, cy = 0, R = 0; let W = 0, H = 0, dpr = 1, cx = 0, cy = 0, R = 0;
function resize() { function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2); dpr = Math.min(window.devicePixelRatio || 1, dprCap);
W = canvas.clientWidth; H = canvas.clientHeight; W = canvas.clientWidth; H = canvas.clientHeight;
canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
const portrait = W < H * 0.85; const portrait = W < H * 0.85;
@ -779,7 +806,9 @@ export function startOrb(canvas, { onReady } = {}) {
initFluid(); initFluid();
} }
resize(); resize();
addEventListener('resize', resize); // coalesce resize bursts (window drags, mobile URL bars): each resize reallocates the fluid textures
let resizeTimer = 0;
addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(resize, 120); });
// pointer: mouse, pen and touch all arrive as pointer events // pointer: mouse, pen and touch all arrive as pointer events
const ptr = { x: 0, y: 0, lx: 0, ly: 0, has: false, moved: false }; const ptr = { x: 0, y: 0, lx: 0, ly: 0, has: false, moved: false };
@ -902,7 +931,46 @@ export function startOrb(canvas, { onReady } = {}) {
gl.uniform1ui(prog.u.uInner, INNER); gl.uniform1ui(prog.u.uInner, INNER);
} }
// frame-time watch: after a warm-up, the median of 60 frames over ~24 ms (slower than ~42 fps) drops a level
let warmup = 90;
const frameTimes = [];
function setQuality(level) {
quality = level;
dprCap = QUALITY[level].dprCap;
drawInner = Math.floor(INNER * QUALITY[level].frac);
drawCorona = Math.floor(CORONA * QUALITY[level].frac);
canvas.dataset.quality = String(level);
resize();
}
function watchFrameTime(raw) {
if (document.hidden || warmup-- > 0) return;
frameTimes.push(raw);
if (frameTimes.length < 60) return;
frameTimes.sort((a, b) => a - b);
const median = frameTimes[30];
frameTimes.length = 0;
if (median > 24 && quality < QUALITY.length - 1) { setQuality(quality + 1); warmup = 45; }
}
// the state pass covers only the particles that are still drawn
function stepState() {
into(state.write);
if (quality === 0) {
tri();
} else {
gl.enable(gl.SCISSOR_TEST);
gl.scissor(0, 0, T, Math.ceil(drawInner / T));
tri();
const coronaRow = Math.floor(INNER / T);
gl.scissor(0, coronaRow, T, Math.ceil((INNER + drawCorona) / T) - coronaRow);
tri();
gl.disable(gl.SCISSOR_TEST);
}
state.swap();
}
function frame(now) { function frame(now) {
watchFrameTime(now - last);
// everything (currents, spin, lifecycles, the cursor fluid) runs on one sped-up simulation clock // everything (currents, spin, lifecycles, the cursor fluid) runs on one sped-up simulation clock
const dt = (Math.min((now - last) / 1000, 1 / 30) || 1 / 60) * SPEED; const dt = (Math.min((now - last) / 1000, 1 / 30) || 1 / 60) * SPEED;
last = now; last = now;
@ -922,7 +990,8 @@ export function startOrb(canvas, { onReady } = {}) {
commonUniforms(s, t); commonUniforms(s, t);
gl.uniform1i(s.u.uPos, bind(0, state.read.tex)); gl.uniform1i(s.u.uPos, bind(0, state.read.tex));
gl.uniform1i(s.u.uVel, bind(1, state.read.tex2)); gl.uniform1i(s.u.uVel, bind(1, state.read.tex2));
gl.uniform1i(s.u.uFlow, bind(2, velocity.read.tex)); gl.uniform1i(s.u.uHome, bind(2, state.read.tex3));
gl.uniform1i(s.u.uFlow, bind(3, velocity.read.tex));
gl.uniform1f(s.u.uAmb, reduced ? 0 : 1); gl.uniform1f(s.u.uAmb, reduced ? 0 : 1);
gl.uniform1i(s.u.uInit, needInit ? 1 : 0); gl.uniform1i(s.u.uInit, needInit ? 1 : 0);
needInit = false; needInit = false;
@ -934,7 +1003,7 @@ export function startOrb(canvas, { onReady } = {}) {
gl.uniform2f(s.u.uSim, simW, simH); gl.uniform2f(s.u.uSim, simW, simH);
gl.uniform1f(s.u.uDt, dt); gl.uniform1f(s.u.uDt, dt);
gl.uniform1f(s.u.uGain, 0.5); gl.uniform1f(s.u.uGain, 0.5);
into(state.write); tri(); state.swap(); stepState();
} }
gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.bindFramebuffer(gl.FRAMEBUFFER, null);
@ -955,12 +1024,14 @@ export function startOrb(canvas, { onReady } = {}) {
const posTex = interactive ? state.read.tex : zeroTex; const posTex = interactive ? state.read.tex : zeroTex;
const velTex = interactive ? state.read.tex2 : zeroTex; const velTex = interactive ? state.read.tex2 : zeroTex;
const homeTex = interactive ? state.read.tex3 : zeroTex;
const useParticles = () => { const useParticles = () => {
const p = progs.particles; const p = progs.particles;
gl.useProgram(p.p); gl.useProgram(p.p);
commonUniforms(p, t); commonUniforms(p, t);
gl.uniform1i(p.u.uPos, bind(0, posTex)); gl.uniform1i(p.u.uPos, bind(0, posTex));
gl.uniform1i(p.u.uVel, bind(1, velTex)); gl.uniform1i(p.u.uVel, bind(1, velTex));
gl.uniform1i(p.u.uHome, bind(2, homeTex));
gl.uniform1i(p.u.uStatic, interactive ? 0 : 1); gl.uniform1i(p.u.uStatic, interactive ? 0 : 1);
gl.uniform1i(p.u.uT, T); gl.uniform1i(p.u.uT, T);
gl.uniform2f(p.u.uView, W, H); gl.uniform2f(p.u.uView, W, H);
@ -971,7 +1042,7 @@ export function startOrb(canvas, { onReady } = {}) {
// 2. corona (behind the shell) // 2. corona (behind the shell)
useParticles(); useParticles();
gl.drawArrays(gl.POINTS, INNER, CORONA); gl.drawArrays(gl.POINTS, INNER, drawCorona);
// 3. glass shell + glow // 3. glass shell + glow
g = progs.shell; gl.useProgram(g.p); g = progs.shell; gl.useProgram(g.p);
@ -982,7 +1053,7 @@ export function startOrb(canvas, { onReady } = {}) {
// 4. particles on the shell // 4. particles on the shell
useParticles(); useParticles();
gl.drawArrays(gl.POINTS, 0, INNER); gl.drawArrays(gl.POINTS, 0, drawInner);
if (!ready) { ready = true; onReady && onReady(); } if (!ready) { ready = true; onReady && onReady(); }
requestAnimationFrame(frame); requestAnimationFrame(frame);