radiance: 3D particle sim — curl-noise currents, ribbon density, depth scatter on stir, rolling respawn; ~100k particles

This commit is contained in:
dangrubbb
2026-10-24 09:22:12 -04:00
parent d4bfc88be7
commit 384cb7f7b8

View File

@ -61,48 +61,86 @@ vec3 rotX(vec3 p, float a) {
return vec3(p.x, c * p.y - s * p.z, s * p.y + c * p.z); return vec3(p.x, c * p.y - s * p.z, s * p.y + c * p.z);
} }
// Where particle idx rests, in units of the orb radius (origin at the orb centre, y up). const float TAU = 6.2831853;
vec3 sphDir(float a, float b) {
float y = a * 2.0 - 1.0;
float s = sqrt(1.0 - y * y);
float ph = b * TAU;
return vec3(cos(ph) * s, y, sin(ph) * s);
}
// A divergence-free current: the curl of three offset value-noise fields.
vec3 curlNoise(vec3 p) {
const float e = 0.1;
const vec3 o1 = vec3(0.0);
const vec3 o2 = vec3(31.4, 17.1, 5.3);
const vec3 o3 = vec3(9.2, 47.7, 23.9);
vec3 dx = vec3(e, 0.0, 0.0), dy = vec3(0.0, e, 0.0), dz = vec3(0.0, 0.0, e);
float x_dy = vnoise(p + o1 + dy) - vnoise(p + o1 - dy);
float x_dz = vnoise(p + o1 + dz) - vnoise(p + o1 - dz);
float y_dx = vnoise(p + o2 + dx) - vnoise(p + o2 - dx);
float y_dz = vnoise(p + o2 + dz) - vnoise(p + o2 - dz);
float z_dx = vnoise(p + o3 + dx) - vnoise(p + o3 - dx);
float z_dy = vnoise(p + o3 + dy) - vnoise(p + o3 - dy);
return vec3(z_dy - y_dz, x_dz - z_dx, y_dx - x_dy) / (2.0 * e);
}
// ---- inner particles: each lives in a 3D volume and is carried by currents.
// Every particle is respawned now and then (fully faded at the seam) so the flow's sinks never swallow the density.
float lifeLen(uint idx) { return mix(9.0, 30.0, h01(idx * 8u + 1u)); }
float lifeT(uint idx, float t) { return t / lifeLen(idx) + h01(idx * 8u + 2u); }
float lifeAlpha(float x) {
float ph = fract(x);
return smoothstep(0.0, 0.08, ph) * (1.0 - smoothstep(0.92, 1.0, ph));
}
// most particles sit in the glassy skin, the rest hang in the volume behind it
float layerR(uint idx) {
float k = h01(idx * 8u + 5u);
float u = h01(idx * 8u + 4u);
return k < 0.5 ? mix(0.94, 1.0, u) : mix(0.4, 0.94, u);
}
// A drifting ridge field: density peaks along thin curved sheets, which cut the sphere as ribbons.
float density(vec3 dir, float t) {
vec3 w = dir * 1.6 + vec3(0.0, t * 0.05, t * 0.035);
float n = fbm(w + 1.6 * fbm(w * 0.8 + 3.1));
return clamp(1.0 - abs(n - 0.48) * 4.2, 0.0, 1.0);
}
// where a particle is reborn: best of four candidates against the density field
vec3 spawnDir(uint idx, float epoch, float t) {
uint base = pcg(idx * 2246822519u + uint(epoch) * 3266489917u + 17u);
vec3 best = vec3(0.0, 1.0, 0.0);
float bn = -1.0;
for (int i = 0; i < 4; i++) {
uint s = base + uint(i) * 374761393u;
vec3 d = sphDir(h01(s), h01(s + 1u));
float n = density(d, t);
if (n > bn) { bn = n; best = d; }
}
return best;
}
// the spawn point of a particle's current life; the flow carries it away, a soft pull brings it back
vec3 homeOf(uint idx, float epoch) {
float tSpawn = (epoch - h01(idx * 8u + 2u)) * lifeLen(idx);
return spawnDir(idx, epoch, tSpawn) * layerR(idx);
}
vec3 bodyToView(vec3 p) { return rotX(rotY(p, uTime * 0.045 + 2.4 + uTilt.x), uTilt.y); }
vec3 viewToBody(vec3 v) { return rotY(rotX(v, -uTilt.y), -(uTime * 0.045 + 2.4 + uTilt.x)); }
// ---- corona: sparks thrown off the limb, laid out in screen space; strongest over the top
struct P { struct P {
vec2 xy; vec2 xy;
float z;
float live;
float kind; // 0 inner, 1 corona
float n; float n;
float spark;
float reach; float reach;
float fade; float fade;
vec4 r; vec4 r;
}; };
P rest(uint idx) { P corona(uint idx) {
P o; P o;
o.r = vec4(h01(idx * 8u), h01(idx * 8u + 1u), h01(idx * 8u + 2u), h01(idx * 8u + 3u)); o.r = vec4(h01(idx * 8u), h01(idx * 8u + 1u), h01(idx * 8u + 2u), h01(idx * 8u + 3u));
vec4 q = vec4(h01(idx * 8u + 4u), h01(idx * 8u + 5u), h01(idx * 8u + 6u), h01(idx * 8u + 7u)); vec4 q = vec4(h01(idx * 8u + 4u), h01(idx * 8u + 5u), h01(idx * 8u + 6u), h01(idx * 8u + 7u));
o.live = 1.0; o.spark = 0.0; o.n = 0.0; o.reach = 0.0; o.fade = 0.0; o.z = 0.0;
float t = uTime; float t = uTime;
if (idx < uInner) {
// inner particles: density follows a slowly drifting, domain-warped noise field
o.kind = 0.0;
float y = q.x * 2.0 - 1.0;
float phi = q.y * 6.2831853;
float s = sqrt(1.0 - y * y);
vec3 d = vec3(cos(phi) * s, y, sin(phi) * s);
float rad = 0.955 + o.r.x * 0.04;
vec3 w = d * 1.45 + vec3(0.0, t * 0.03, t * 0.02);
float n = fbm(w + 1.6 * fbm(w * 0.8 + 3.1));
n = clamp((n - 0.46) * 2.3 + 0.5, 0.0, 1.0);
float keep = smoothstep(0.05, 0.7, n) - o.r.y * 0.7;
o.spark = step(0.92, o.r.z);
o.n = n;
if (keep < 0.0 && o.spark < 0.5) o.live = 0.0;
vec3 p = rotX(rotY(d, t * 0.045 + 2.4 + uTilt.x), uTilt.y) * rad;
float persp = 1.0 / (1.0 - p.z * 0.12);
o.xy = p.xy * persp;
o.z = p.z;
} else {
// corona: sparks thrown off the limb, laid out in screen space; strongest over the top
o.kind = 1.0;
float a = q.x * 6.2831853; float a = q.x * 6.2831853;
vec2 cd = vec2(cos(a), sin(a)); vec2 cd = vec2(cos(a), sin(a));
float n = fbm(vec3(cd * 4.2, t * 0.10)); float n = fbm(vec3(cd * 4.2, t * 0.10));
@ -115,7 +153,6 @@ P rest(uint idx) {
o.reach = reach; o.reach = reach;
o.fade = 1.0 - smoothstep(0.0, 0.42, reach); o.fade = 1.0 - smoothstep(0.0, 0.42, reach);
o.xy = cd * (1.0 + 0.004 + reach); o.xy = cd * (1.0 + 0.004 + reach);
}
return o; return o;
} }
`; `;
@ -261,7 +298,8 @@ void main() { o = uValue * texture(uTex, vUv); }
const FRAG_STATE = `${HEAD} const FRAG_STATE = `${HEAD}
${COMMON} ${COMMON}
uniform sampler2D uState; uniform sampler2D uPos; // inner: body-space xyz (orb radii), w = stir. corona: xy displacement
uniform sampler2D uVel; // velocity, same layout
uniform sampler2D uFlow; uniform sampler2D uFlow;
uniform int uT; uniform int uT;
uniform uint uTotal; uniform uint uTotal;
@ -271,43 +309,95 @@ uniform float uR; // css px
uniform vec2 uSim; // flow texture size in texels uniform vec2 uSim; // flow texture size in texels
uniform float uDt; uniform float uDt;
uniform float uGain; uniform float uGain;
out vec4 o; 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;
// fluid velocity (texels/s) under a view-space point (orb radii, y up) -> orb radii per second
vec2 flowAt(vec2 sp) {
vec2 px = uCenter + vec2(sp.x, -sp.y) * uR;
vec2 uv = vec2(px.x / uView.x, 1.0 - px.y / uView.y);
vec2 f = texture(uFlow, uv).xy;
return vec2(f.x / uSim.x * uView.x, f.y / uSim.y * uView.y) / uR;
}
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) { o = vec4(0.0); return; } if (idx >= uTotal) { oPos = vec4(0.0); oVel = vec4(0.0); return; }
P p = rest(idx); vec4 s0 = texelFetch(uPos, ip, 0);
if (p.live < 0.5) { o = vec4(0.0); return; } vec4 s1 = texelFetch(uVel, ip, 0);
float t = uTime;
vec4 st = texelFetch(uState, ip, 0); if (idx >= uInner) {
vec2 disp = st.xy; // corona: a spring pulls each spark home, the flow and a light wind shake it
vec2 vel = st.zw; P p = corona(idx);
vec2 disp = s0.xy;
// sample the flow where the particle currently is vec2 vel = s1.xy;
vec2 pos = p.xy + disp; vec2 flow = flowAt(p.xy + disp);
vec2 px = uCenter + vec2(pos.x, -pos.y) * uR; vec3 np = vec3(p.xy * 2.4 + vec2(0.0, t * 0.05), t * 0.16);
vec2 uv = vec2(px.x / uView.x, 1.0 - px.y / uView.y);
vec2 f = texture(uFlow, uv).xy; // texels per second
vec2 flow = vec2(f.x / uSim.x * uView.x, f.y / uSim.y * uView.y) / uR; // radii per second
// ambient shimmer: a slow curl-noise wind keeps the particles streaming
vec3 np = vec3(p.xy * 2.4 + vec2(0.0, uTime * 0.05), uTime * 0.16);
float e = 0.03; float e = 0.03;
float nx = vnoise(np + vec3(e, 0.0, 0.0)) - vnoise(np - vec3(e, 0.0, 0.0)); float nx = vnoise(np + vec3(e, 0.0, 0.0)) - vnoise(np - vec3(e, 0.0, 0.0));
float ny = vnoise(np + vec3(0.0, e, 0.0)) - vnoise(np - vec3(0.0, e, 0.0)); float ny = vnoise(np + vec3(0.0, e, 0.0)) - vnoise(np - vec3(0.0, e, 0.0));
vec2 wind = vec2(ny, -nx) / (2.0 * e); vec2 wind = vec2(ny, -nx) / (2.0 * e);
float k = 10.5 * (0.65 + 0.7 * p.r.w);
float light = p.kind; // corona sparks are lighter float c = 2.0 * sqrt(k) * 0.45;
float k = (8.0 + 2.5 * light) * (0.65 + 0.7 * p.r.w); // spring back home vec2 acc = -k * disp - c * vel + 15.0 * (flow * uGain * 1.8 - vel) + wind * 0.84 * uAmb;
float c = 2.0 * sqrt(k) * 0.45; // under-damped: a little bounce on the way home
float follow = 9.0 + 6.0 * light; // how hard the flow drags it
vec2 acc = -k * disp - c * vel + follow * (flow * uGain * (1.0 + 0.8 * light) - vel) + wind * (0.14 + 0.7 * light);
vel += acc * uDt; vel += acc * uDt;
disp += vel * uDt; disp += vel * uDt;
float m = length(disp); float m = length(disp);
if (m > 0.9) disp *= 0.9 / m; if (m > 0.9) disp *= 0.9 / m;
o = vec4(disp, vel); oPos = vec4(disp, 0.0, 0.0);
oVel = vec4(vel, 0.0, 0.0);
return;
}
// inner particle
float rt = layerR(idx);
float lt = lifeT(idx, t);
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))) {
pos = home;
vel = vec3(0.0);
stir = 0.0;
}
float r = length(pos);
vec3 n = pos / max(r, 1e-4);
float surf = step(0.9, rt);
// the cursor: the 2D fluid under the particle's screen position, pushed into 3D
vec3 bp = bodyToView(pos);
vec2 flow = flowAt(bp.xy / (1.0 - bp.z * 0.12));
float fm = length(flow);
float front = mix(0.35, 1.0, smoothstep(-0.5, 0.5, bp.z));
vec3 push = viewToBody(vec3(flow, 0.0)) * uGain * front;
// hard stirring also tumbles particles through depth, so the volume churns instead of sliding sideways
vec3 tumble = curlNoise(pos * 4.0 + vec3(0.0, 0.0, t * 0.9)) * fm * 0.7 * front;
vec3 depth = n * (h01(idx * 8u + 7u) - 0.5) * fm * 0.9 * front;
// idle: slow broad currents plus finer eddies, kept mostly along the skin so streaks pile up and shear
vec3 amb = curlNoise(pos * 1.35 + vec3(0.0, 0.0, t * 0.35))
+ 0.6 * curlNoise(pos * 3.2 + vec3(t * 0.15, 0.0, t * 0.7));
amb -= n * dot(amb, n) * mix(0.35, 0.8, surf);
amb *= 0.3 * uAmb;
vec3 target = amb + push + tumble + depth + (home - pos) * 0.3;
vel += (target - vel) * (1.0 - exp(-uDt * 3.0));
pos += vel * uDt;
// soft restoring pull to its layer
r = length(pos);
n = pos / max(r, 1e-4);
r = mix(r, rt, 1.0 - exp(-uDt * 1.6));
pos = n * r;
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);
} }
`; `;
@ -315,8 +405,10 @@ void main() {
const VERT_PARTICLES = `${HEAD} const VERT_PARTICLES = `${HEAD}
${COMMON} ${COMMON}
uniform sampler2D uState; uniform sampler2D uPos;
uniform sampler2D uVel;
uniform int uT; uniform int uT;
uniform int uStatic; // no float render targets: particles sit at their spawn points
uniform vec2 uView; // css px uniform vec2 uView; // css px
uniform vec2 uCenter; // css px uniform vec2 uCenter; // css px
uniform float uR; // css px uniform float uR; // css px
@ -326,38 +418,53 @@ out float vGlow;
void main() { void main() {
uint idx = uint(gl_VertexID); uint idx = uint(gl_VertexID);
P p = rest(idx); ivec2 tc = ivec2(int(idx) % uT, int(idx) / uT);
if (p.live < 0.5) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; vColor = vec4(0.0); vGlow = 0.0; return; } vec4 s0 = texelFetch(uPos, tc, 0);
vec4 s1 = texelFetch(uVel, tc, 0);
vec4 st = texelFetch(uState, ivec2(int(idx) % uT, int(idx) / uT), 0); float t = uTime;
vec2 disp = st.xy;
float speed = length(st.zw);
float stir = clamp(length(disp) * 1.6 + speed * 0.35, 0.0, 1.5); // how disturbed this particle is
vec2 px = uCenter + vec2(p.xy.x + disp.x, -(p.xy.y + disp.y)) * uR;
float sc = clamp(uR / 315.0, 0.8, 1.7); float sc = clamp(uR / 315.0, 0.8, 1.7);
vec2 vp;
vec4 col; vec4 col;
float size; float size;
float persp = 1.0; float persp = 1.0;
if (p.kind < 0.5) { if (idx < uInner) {
vec3 warm = mix(vec3(0.94, 0.27, 0.20), vec3(1.0, 0.52, 0.32), p.r.w); vec4 r = vec4(h01(idx * 8u), h01(idx * 8u + 1u), h01(idx * 8u + 2u), h01(idx * 8u + 3u));
warm = mix(warm, vec3(1.0, 0.72, 0.42), smoothstep(0.1, 0.45, 1.0 - p.n) * 0.8); float spark = step(0.88, h01(idx * 8u + 6u));
col = vec4(mix(warm, vec3(1.0, 0.97, 0.84), p.spark), mix(0.85, 0.95, p.spark)); float rt = layerR(idx);
size = mix(1.25 + p.r.y * 1.3 + p.n * 0.9, 2.6 + p.r.w * 2.4, p.spark); float lt = lifeT(idx, t);
float tw = 0.5 + 0.5 * sin(uTime * (1.2 + p.r.x * 2.5) + p.r.w * 40.0); vec3 pos = s0.xyz;
col.a *= mix(1.0, tw, p.spark); float stir = clamp(s0.w, 0.0, 1.5);
vGlow = p.spark; if (uStatic == 1) { pos = homeOf(idx, floor(lt)); stir = 0.0; }
// far side of the sphere reads dimmer through the glass
col.a *= mix(0.25, 1.0, smoothstep(-0.4, 0.4, p.z)); vec3 bp = bodyToView(pos);
persp = 1.0 / (1.0 - p.z * 0.12); persp = 1.0 / (1.0 - bp.z * 0.12);
vp = bp.xy * persp;
float n = density(normalize(pos + vec3(1e-5)), t);
vec3 warm = mix(vec3(0.94, 0.27, 0.20), vec3(1.0, 0.52, 0.32), r.w);
warm = mix(warm, vec3(1.0, 0.72, 0.42), smoothstep(0.1, 0.45, 1.0 - n) * 0.8);
col = vec4(mix(warm, vec3(1.0, 0.97, 0.84), spark), mix(0.95, 0.95, spark));
size = mix(1.15 + r.y * 1.2 + n * 0.8, 2.0 + r.w * 2.0, spark);
float tw = 0.5 + 0.5 * sin(t * (1.2 + r.x * 2.5) + r.w * 40.0);
col.a *= mix(1.0, tw, spark);
vGlow = spark;
// the far side reads dimmer through the glass, and so do particles hanging deep in the volume
// (it also thins the crowd that edge-on particles would form along the limb)
col.a *= mix(0.2, 1.0, smoothstep(-0.2, 0.6, bp.z));
col.a *= mix(0.65, 1.0, step(0.9, rt));
col.a *= lifeAlpha(lt);
// stirred particles heat up: brighter, hotter, a little bigger // stirred particles heat up: brighter, hotter, a little bigger
col.rgb = mix(col.rgb, vec3(1.0, 0.8, 0.55), clamp(stir * 0.3, 0.0, 0.4)); col.rgb = mix(col.rgb, vec3(1.0, 0.8, 0.55), clamp(stir * 0.3, 0.0, 0.4));
col.a = min(1.0, col.a * (1.0 + stir * 0.5)); col.a = min(1.0, col.a * (1.0 + stir * 0.5));
size *= 1.0 + stir * 0.8; size *= 1.0 + stir * 0.8;
vGlow = max(vGlow, clamp(stir * 0.8, 0.0, 1.0)); vGlow = max(vGlow, clamp(stir * 0.8, 0.0, 1.0));
} else { } else {
float tw = 0.5 + 0.5 * sin(uTime * (1.0 + p.r.y * 3.0) + p.r.z * 60.0); P p = corona(idx);
vec2 disp = s0.xy;
float stir = clamp(length(disp) * 1.6 + length(s1.xy) * 0.35, 0.0, 1.5);
vp = p.xy + disp;
float tw = 0.5 + 0.5 * sin(t * (1.0 + p.r.y * 3.0) + p.r.z * 60.0);
col = vec4(1.0, 0.97, 0.86, (0.3 + 0.7 * p.fade) * (0.35 + 0.65 * smoothstep(0.2, 0.75, p.n))); col = vec4(1.0, 0.97, 0.86, (0.3 + 0.7 * p.fade) * (0.35 + 0.65 * smoothstep(0.2, 0.75, p.n)));
col.a *= mix(0.5, 1.0, tw); col.a *= mix(0.5, 1.0, tw);
size = 1.0 + p.r.y * p.r.y * 2.6 * (0.3 + p.fade); size = 1.0 + p.r.y * p.r.y * 2.6 * (0.3 + p.fade);
@ -369,6 +476,7 @@ void main() {
size *= 1.0 + stir * 0.9; size *= 1.0 + stir * 0.9;
} }
vec2 px = uCenter + vec2(vp.x, -vp.y) * uR;
gl_Position = vec4(px.x / uView.x * 2.0 - 1.0, 1.0 - px.y / uView.y * 2.0, 0.0, 1.0); gl_Position = vec4(px.x / uView.x * 2.0 - 1.0, 1.0 - px.y / uView.y * 2.0, 0.0, 1.0);
gl_PointSize = size * sc * uDpr * persp; gl_PointSize = size * sc * uDpr * persp;
vColor = col; vColor = col;
@ -514,7 +622,7 @@ function program(gl, vs, fs) {
return { p, u: uniforms }; return { p, u: uniforms };
} }
function target(gl, w, h, internal, type, filter) { function texture(gl, w, h, internal, type, filter) {
const tex = gl.createTexture(); const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex); gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, internal, w, h, 0, gl.RGBA, type, null); gl.texImage2D(gl.TEXTURE_2D, 0, internal, w, h, 0, gl.RGBA, type, null);
@ -522,22 +630,34 @@ function target(gl, w, h, internal, type, filter) {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
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;
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); 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]);
}
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, fbo, w, h }; return { tex, tex2, fbo, w, h };
} }
function freeTarget(gl, t) { function freeTarget(gl, t) {
gl.deleteTexture(t.tex); gl.deleteTexture(t.tex);
if (t.tex2) gl.deleteTexture(t.tex2);
gl.deleteFramebuffer(t.fbo); gl.deleteFramebuffer(t.fbo);
} }
function pingPong(gl, w, h, internal, type, filter) { function pingPong(gl, w, h, internal, type, filter, dual = false) {
const pair = [target(gl, w, h, internal, type, filter), target(gl, w, h, internal, type, filter)]; const pair = [target(gl, w, h, internal, type, filter, dual), target(gl, w, h, internal, type, filter, dual)];
let i = 0; let i = 0;
return { return {
get read() { return pair[i]; }, get read() { return pair[i]; },
@ -564,9 +684,9 @@ export function startOrb(canvas, { onReady } = {}) {
const coarse = matchMedia('(pointer: coarse)').matches; const coarse = matchMedia('(pointer: coarse)').matches;
const small = Math.min(innerWidth, innerHeight) < 600; const small = Math.min(innerWidth, innerHeight) < 600;
const T = small ? 192 : 256; const T = small ? 192 : 320;
const INNER = small ? 26000 : 52000; const INNER = small ? 26000 : 82000;
const CORONA = small ? 8000 : 13000; const CORONA = small ? 8000 : 18000;
const TOTAL = INNER + CORONA; const TOTAL = INNER + CORONA;
let progs; let progs;
@ -596,8 +716,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: displacement.xy + velocity.xy per particle // 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) : null; const state = interactive ? pingPong(gl, T, T, stateFmt.internal, stateFmt.type, gl.NEAREST, true) : null;
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
let velocity, dye, pressure, divergence, curl, simW = 1, simH = 1; let velocity, dye, pressure, divergence, curl, simW = 1, simH = 1;
@ -738,8 +859,8 @@ export function startOrb(canvas, { onReady } = {}) {
into(dye.write); tri(); dye.swap(); into(dye.write); tri(); dye.swap();
} }
const t0 = performance.now(); let simT = reduced ? 8 : 0;
let last = t0; let last = performance.now();
let ready = false; let ready = false;
function commonUniforms(prog, t) { function commonUniforms(prog, t) {
@ -751,7 +872,9 @@ export function startOrb(canvas, { onReady } = {}) {
function frame(now) { function frame(now) {
const dt = Math.min((now - last) / 1000, 1 / 30) || 1 / 60; const dt = Math.min((now - last) / 1000, 1 / 30) || 1 / 60;
last = now; last = now;
const t = reduced ? 8 : (now - t0) / 1000; // simulation clock: advances by clamped steps, so a backgrounded tab resumes without a jump
if (!reduced) simT += dt;
const t = simT;
par.cx += (par.x - par.cx) * 0.04; par.cx += (par.x - par.cx) * 0.04;
par.cy += (par.y - par.cy) * 0.04; par.cy += (par.y - par.cy) * 0.04;
const ox = par.cx * 10, oy = par.cy * 6; const ox = par.cx * 10, oy = par.cy * 6;
@ -763,8 +886,12 @@ export function startOrb(canvas, { onReady } = {}) {
const s = progs.state; const s = progs.state;
gl.useProgram(s.p); gl.useProgram(s.p);
commonUniforms(s, t); commonUniforms(s, t);
gl.uniform1i(s.u.uState, bind(0, state.read.tex)); gl.uniform1i(s.u.uPos, bind(0, state.read.tex));
gl.uniform1i(s.u.uFlow, bind(1, velocity.read.tex)); gl.uniform1i(s.u.uVel, bind(1, state.read.tex2));
gl.uniform1i(s.u.uFlow, bind(2, velocity.read.tex));
gl.uniform1f(s.u.uAmb, reduced ? 0 : 1);
gl.uniform1i(s.u.uInit, needInit ? 1 : 0);
needInit = false;
gl.uniform1i(s.u.uT, T); gl.uniform1i(s.u.uT, T);
gl.uniform1ui(s.u.uTotal, TOTAL); gl.uniform1ui(s.u.uTotal, TOTAL);
gl.uniform2f(s.u.uCenter, cx + ox, cy + oy); gl.uniform2f(s.u.uCenter, cx + ox, cy + oy);
@ -792,12 +919,15 @@ export function startOrb(canvas, { onReady } = {}) {
gl.enable(gl.BLEND); gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
const stateTex = interactive ? state.read.tex : zeroTex; const posTex = interactive ? state.read.tex : zeroTex;
const velTex = interactive ? state.read.tex2 : 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.uState, bind(0, stateTex)); gl.uniform1i(p.u.uPos, bind(0, posTex));
gl.uniform1i(p.u.uVel, bind(1, velTex));
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);
gl.uniform2f(p.u.uCenter, cx + ox, cy + oy); gl.uniform2f(p.u.uCenter, cx + ox, cy + oy);