radiance: perf — spawn point and corona birth cached in particle state, adaptive quality governor, debounced resize
This commit is contained in:
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user