From f2f6bf77084f66a6a6d054e5c9cbf4d1fd2a050f Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 10:06:33 -0400 Subject: [PATCH] =?UTF-8?q?radiance:=20perf=20=E2=80=94=20spawn=20point=20?= =?UTF-8?q?and=20corona=20birth=20cached=20in=20particle=20state,=20adapti?= =?UTF-8?q?ve=20quality=20governor,=20debounced=20resize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scripts/orb.js | 165 ++++++++++++++++++++++++++++++++------------- 1 file changed, 118 insertions(+), 47 deletions(-) diff --git a/src/scripts/orb.js b/src/scripts/orb.js index 2e3aad8..20933ae 100644 --- a/src/scripts/orb.js +++ b/src/scripts/orb.js @@ -125,7 +125,8 @@ vec3 spawnDir(uint idx, float epoch, float t) { } 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) { float tSpawn = (epoch - h01(idx * 8u + 2u)) * lifeLen(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 coronaLT(uint idx, float t) { return t / coronaLen(idx) + h01(idx * 8u + 2u); } -P corona(uint idx) { - P o; - o.r = vec4(h01(idx * 8u), h01(idx * 8u + 1u), h01(idx * 8u + 2u), h01(idx * 8u + 3u)); - float lt = coronaLT(idx, uTime); - 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 +// birthplace: best of three rim angles, scored by tuft strength and how far over the top it is. +// Costly, so it is done once per life and kept in the particle state (cd = rim direction, n = tuft strength). +void coronaBirth(uint idx, float epoch, out vec2 cd, out float n) { + float tSpawn = (epoch - h01(idx * 8u + 2u)) * coronaLen(idx); uint base = pcg(idx * 2654435761u + uint(epoch) * 40503u + 91u); - vec2 cd = vec2(0.0, 1.0); - float n = 0.0, up = 0.0, best = -1.0; + cd = vec2(0.0, 1.0); + n = 0.0; + float best = -1.0; for (int i = 0; i < 3; i++) { float a = h01(base + uint(i) * 374761393u) * 6.2831853; vec2 c = vec2(cos(a), sin(a)); float nn = fbm(vec3(c * 4.2, tSpawn * 0.10)); float u = smoothstep(-0.75, 0.9, c.y); 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 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 @@ -320,8 +324,9 @@ void main() { o = uValue * texture(uTex, vUv); } const FRAG_STATE = `${HEAD} ${COMMON} -uniform sampler2D uPos; // inner: body-space xyz (orb radii), w = stir. corona: xy displacement -uniform sampler2D uVel; // velocity, same layout +uniform sampler2D uPos; // inner: body-space xyz (orb radii), w = stir. corona: xy displacement, zw rim direction +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 int uT; uniform uint uTotal; @@ -335,6 +340,7 @@ uniform float uAmb; // 0 freezes the ambient currents (reduced motion) uniform int uInit; layout(location = 0) out vec4 oPos; 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 vec2 flowAt(vec2 sp) { @@ -347,18 +353,27 @@ vec2 flowAt(vec2 sp) { void main() { ivec2 ip = ivec2(gl_FragCoord.xy); 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 s1 = texelFetch(uVel, ip, 0); + vec4 s2 = texelFetch(uHome, ip, 0); float t = uTime; + oHome = s2; if (idx >= uInner) { // 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) - P p = corona(idx); vec2 disp = s0.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); vec3 np = vec3(p.xy * 2.4 + vec2(0.0, t * 0.05), t * 0.16); float e = 0.03; @@ -369,8 +384,8 @@ void main() { disp += vel * uDt; float m = length(disp); if (m > 1.2) disp *= 1.2 / m; - oPos = vec4(disp, 0.0, 0.0); - oVel = vec4(vel, 0.0, 0.0); + oPos = vec4(disp, cd); + oVel = vec4(vel, tuft, ep); return; } @@ -380,8 +395,10 @@ void main() { vec3 pos = s0.xyz; vec3 vel = s1.xyz; float stir = s0.w; - vec3 home = homeOf(idx, floor(lt)); - if (uInit == 1 || floor(lt) != floor(lifeT(idx, t - uDt))) { + float ep = floor(lt); + vec3 home = s2.xyz; + if (uInit == 1 || ep != s1.w) { + home = homeOf(idx, ep); pos = home; vel = vec3(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)); 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} uniform sampler2D uPos; uniform sampler2D uVel; +uniform sampler2D uHome; uniform int uT; uniform int uStatic; // no float render targets: particles sit at their spawn points uniform vec2 uView; // css px @@ -448,6 +467,7 @@ void main() { ivec2 tc = ivec2(int(idx) % uT, int(idx) / uT); vec4 s0 = texelFetch(uPos, tc, 0); vec4 s1 = texelFetch(uVel, tc, 0); + vec4 s2 = texelFetch(uHome, tc, 0); float t = uTime; float sc = clamp(uR / 315.0, 0.8, 1.7); vec2 vp; @@ -468,7 +488,7 @@ void main() { persp = 1.0 / (1.0 - bp.z * 0.12); 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); 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); @@ -487,7 +507,10 @@ void main() { size *= 1.0 + stir * 0.5; vGlow = max(vGlow, clamp(stir * 0.2, 0.0, 0.25)); } 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; float stir = clamp(length(s1.xy) * 0.35, 0.0, 1.0); vp = p.xy + disp; @@ -665,31 +688,26 @@ function texture(gl, w, h, internal, type, filter) { return tex; } -// dual: a second colour attachment (tex2) is rendered alongside the first -function target(gl, w, h, internal, type, filter, dual = false) { - const tex = texture(gl, w, h, internal, type, filter); - const tex2 = dual ? texture(gl, w, h, internal, type, filter) : null; +// count: how many colour attachments are rendered together (tex, tex2, tex3) +function target(gl, w, h, internal, type, filter, count = 1) { + const texs = Array.from({ length: count }, () => texture(gl, w, h, internal, type, filter)); const fbo = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); - if (dual) { - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, tex2, 0); - gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]); - } + texs.forEach((tx, i) => gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tx, 0)); + if (count > 1) gl.drawBuffers(texs.map((_, i) => gl.COLOR_ATTACHMENT0 + i)); gl.viewport(0, 0, w, h); gl.clearColor(0, 0, 0, 0); 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) { - gl.deleteTexture(t.tex); - if (t.tex2) gl.deleteTexture(t.tex2); + [t.tex, t.tex2, t.tex3].forEach((tx) => tx && gl.deleteTexture(tx)); gl.deleteFramebuffer(t.fbo); } -function pingPong(gl, w, h, internal, type, filter, dual = false) { - const pair = [target(gl, w, h, internal, type, filter, dual), target(gl, w, h, internal, type, filter, dual)]; +function pingPong(gl, w, h, internal, type, filter, count = 1) { + const pair = [target(gl, w, h, internal, type, filter, count), target(gl, w, h, internal, type, filter, count)]; let i = 0; return { 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_MAG_FILTER, gl.NEAREST); - // particle state, two textures: position (xyz + stir) and velocity (xyz) per particle - const state = interactive ? pingPong(gl, T, T, stateFmt.internal, stateFmt.type, gl.NEAREST, true) : null; + // particle state, three textures per particle: position (+ stir), velocity (+ life number), + // 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; // 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); } + // 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 let W = 0, H = 0, dpr = 1, cx = 0, cy = 0, R = 0; function resize() { - dpr = Math.min(window.devicePixelRatio || 1, 2); + dpr = Math.min(window.devicePixelRatio || 1, dprCap); W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); const portrait = W < H * 0.85; @@ -779,7 +806,9 @@ export function startOrb(canvas, { onReady } = {}) { initFluid(); } 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 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); } + // 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) { + watchFrameTime(now - last); // 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; last = now; @@ -922,7 +990,8 @@ export function startOrb(canvas, { onReady } = {}) { commonUniforms(s, t); gl.uniform1i(s.u.uPos, bind(0, state.read.tex)); 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.uniform1i(s.u.uInit, needInit ? 1 : 0); needInit = false; @@ -934,7 +1003,7 @@ export function startOrb(canvas, { onReady } = {}) { gl.uniform2f(s.u.uSim, simW, simH); gl.uniform1f(s.u.uDt, dt); gl.uniform1f(s.u.uGain, 0.5); - into(state.write); tri(); state.swap(); + stepState(); } gl.bindFramebuffer(gl.FRAMEBUFFER, null); @@ -955,12 +1024,14 @@ export function startOrb(canvas, { onReady } = {}) { const posTex = interactive ? state.read.tex : zeroTex; const velTex = interactive ? state.read.tex2 : zeroTex; + const homeTex = interactive ? state.read.tex3 : zeroTex; const useParticles = () => { const p = progs.particles; gl.useProgram(p.p); commonUniforms(p, t); gl.uniform1i(p.u.uPos, bind(0, posTex)); 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.uT, T); gl.uniform2f(p.u.uView, W, H); @@ -971,7 +1042,7 @@ export function startOrb(canvas, { onReady } = {}) { // 2. corona (behind the shell) useParticles(); - gl.drawArrays(gl.POINTS, INNER, CORONA); + gl.drawArrays(gl.POINTS, INNER, drawCorona); // 3. glass shell + glow g = progs.shell; gl.useProgram(g.p); @@ -982,7 +1053,7 @@ export function startOrb(canvas, { onReady } = {}) { // 4. particles on the shell useParticles(); - gl.drawArrays(gl.POINTS, 0, INNER); + gl.drawArrays(gl.POINTS, 0, drawInner); if (!ready) { ready = true; onReady && onReady(); } requestAnimationFrame(frame);