From 384cb7f7b8debadb2b6d2d55927f2a4d2646bb91 Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 09:22:12 -0400 Subject: [PATCH] =?UTF-8?q?radiance:=203D=20particle=20sim=20=E2=80=94=20c?= =?UTF-8?q?url-noise=20currents,=20ribbon=20density,=20depth=20scatter=20o?= =?UTF-8?q?n=20stir,=20rolling=20respawn;=20~100k=20particles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scripts/orb.js | 352 +++++++++++++++++++++++++++++++-------------- 1 file changed, 241 insertions(+), 111 deletions(-) diff --git a/src/scripts/orb.js b/src/scripts/orb.js index 489ddc0..0c80ce5 100644 --- a/src/scripts/orb.js +++ b/src/scripts/orb.js @@ -61,61 +61,98 @@ vec3 rotX(vec3 p, float a) { 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 { vec2 xy; - float z; - float live; - float kind; // 0 inner, 1 corona float n; - float spark; float reach; float fade; vec4 r; }; -P rest(uint idx) { +P corona(uint idx) { P o; 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)); - 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; - - 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; - vec2 cd = vec2(cos(a), sin(a)); - float n = fbm(vec3(cd * 4.2, t * 0.10)); - float up = smoothstep(-0.75, 0.9, cd.y); - float spike = pow(n, 1.6) * 1.6; - float reach = pow(o.r.x, 1.5) * (0.05 + 0.4 * spike) * (0.3 + 0.7 * up); - reach *= 0.88 + 0.12 * sin(t * 0.7 + o.r.w * 20.0); - reach += 0.012 * sin(t * 0.9 + o.r.z * 30.0); - o.n = n; - o.reach = reach; - o.fade = 1.0 - smoothstep(0.0, 0.42, reach); - o.xy = cd * (1.0 + 0.004 + reach); - } + float a = q.x * 6.2831853; + vec2 cd = vec2(cos(a), sin(a)); + float n = fbm(vec3(cd * 4.2, t * 0.10)); + float up = smoothstep(-0.75, 0.9, cd.y); + float spike = pow(n, 1.6) * 1.6; + float reach = pow(o.r.x, 1.5) * (0.05 + 0.4 * spike) * (0.3 + 0.7 * up); + reach *= 0.88 + 0.12 * sin(t * 0.7 + o.r.w * 20.0); + reach += 0.012 * sin(t * 0.9 + o.r.z * 30.0); + o.n = n; + o.reach = reach; + o.fade = 1.0 - smoothstep(0.0, 0.42, reach); + o.xy = cd * (1.0 + 0.004 + reach); return o; } `; @@ -261,7 +298,8 @@ void main() { o = uValue * texture(uTex, vUv); } const FRAG_STATE = `${HEAD} ${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 int uT; uniform uint uTotal; @@ -271,43 +309,95 @@ uniform float uR; // css px uniform vec2 uSim; // flow texture size in texels uniform float uDt; 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() { ivec2 ip = ivec2(gl_FragCoord.xy); uint idx = uint(ip.y * uT + ip.x); - if (idx >= uTotal) { o = vec4(0.0); return; } - P p = rest(idx); - if (p.live < 0.5) { o = vec4(0.0); return; } + if (idx >= uTotal) { oPos = vec4(0.0); oVel = vec4(0.0); return; } + vec4 s0 = texelFetch(uPos, ip, 0); + vec4 s1 = texelFetch(uVel, ip, 0); + float t = uTime; - vec4 st = texelFetch(uState, ip, 0); - vec2 disp = st.xy; - vec2 vel = st.zw; + if (idx >= uInner) { + // corona: a spring pulls each spark home, the flow and a light wind shake it + P p = corona(idx); + vec2 disp = s0.xy; + vec2 vel = s1.xy; + 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; + 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)); + vec2 wind = vec2(ny, -nx) / (2.0 * e); + float k = 10.5 * (0.65 + 0.7 * p.r.w); + float c = 2.0 * sqrt(k) * 0.45; + vec2 acc = -k * disp - c * vel + 15.0 * (flow * uGain * 1.8 - vel) + wind * 0.84 * uAmb; + vel += acc * uDt; + disp += vel * uDt; + float m = length(disp); + if (m > 0.9) disp *= 0.9 / m; + oPos = vec4(disp, 0.0, 0.0); + oVel = vec4(vel, 0.0, 0.0); + return; + } - // sample the flow where the particle currently is - vec2 pos = p.xy + disp; - vec2 px = uCenter + vec2(pos.x, -pos.y) * uR; - 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 + // 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); - // 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 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)); - vec2 wind = vec2(ny, -nx) / (2.0 * e); + // 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; - float light = p.kind; // corona sparks are lighter - float k = (8.0 + 2.5 * light) * (0.65 + 0.7 * p.r.w); // spring back home - 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; - disp += vel * uDt; - float m = length(disp); - if (m > 0.9) disp *= 0.9 / m; - o = vec4(disp, vel); + // 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} ${COMMON} -uniform sampler2D uState; +uniform sampler2D uPos; +uniform sampler2D uVel; uniform int uT; +uniform int uStatic; // no float render targets: particles sit at their spawn points uniform vec2 uView; // css px uniform vec2 uCenter; // css px uniform float uR; // css px @@ -326,38 +418,53 @@ out float vGlow; void main() { uint idx = uint(gl_VertexID); - P p = rest(idx); - 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 st = texelFetch(uState, ivec2(int(idx) % uT, int(idx) / uT), 0); - 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; + ivec2 tc = ivec2(int(idx) % uT, int(idx) / uT); + vec4 s0 = texelFetch(uPos, tc, 0); + vec4 s1 = texelFetch(uVel, tc, 0); + float t = uTime; float sc = clamp(uR / 315.0, 0.8, 1.7); + vec2 vp; vec4 col; float size; float persp = 1.0; - if (p.kind < 0.5) { - vec3 warm = mix(vec3(0.94, 0.27, 0.20), vec3(1.0, 0.52, 0.32), p.r.w); - warm = mix(warm, vec3(1.0, 0.72, 0.42), smoothstep(0.1, 0.45, 1.0 - p.n) * 0.8); - col = vec4(mix(warm, vec3(1.0, 0.97, 0.84), p.spark), mix(0.85, 0.95, p.spark)); - size = mix(1.25 + p.r.y * 1.3 + p.n * 0.9, 2.6 + p.r.w * 2.4, p.spark); - float tw = 0.5 + 0.5 * sin(uTime * (1.2 + p.r.x * 2.5) + p.r.w * 40.0); - col.a *= mix(1.0, tw, p.spark); - vGlow = p.spark; - // far side of the sphere reads dimmer through the glass - col.a *= mix(0.25, 1.0, smoothstep(-0.4, 0.4, p.z)); - persp = 1.0 / (1.0 - p.z * 0.12); + if (idx < uInner) { + vec4 r = vec4(h01(idx * 8u), h01(idx * 8u + 1u), h01(idx * 8u + 2u), h01(idx * 8u + 3u)); + float spark = step(0.88, h01(idx * 8u + 6u)); + float rt = layerR(idx); + float lt = lifeT(idx, t); + vec3 pos = s0.xyz; + float stir = clamp(s0.w, 0.0, 1.5); + if (uStatic == 1) { pos = homeOf(idx, floor(lt)); stir = 0.0; } + + vec3 bp = bodyToView(pos); + 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 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)); size *= 1.0 + stir * 0.8; vGlow = max(vGlow, clamp(stir * 0.8, 0.0, 1.0)); } 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.a *= mix(0.5, 1.0, tw); 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; } + 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_PointSize = size * sc * uDpr * persp; vColor = col; @@ -514,7 +622,7 @@ function program(gl, vs, fs) { return { p, u: uniforms }; } -function target(gl, w, h, internal, type, filter) { +function texture(gl, w, h, internal, type, filter) { const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex); 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_WRAP_S, 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(); 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]); + } gl.viewport(0, 0, w, h); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); - return { tex, fbo, w, h }; + return { tex, tex2, fbo, w, h }; } function freeTarget(gl, t) { gl.deleteTexture(t.tex); + if (t.tex2) gl.deleteTexture(t.tex2); gl.deleteFramebuffer(t.fbo); } -function pingPong(gl, w, h, internal, type, filter) { - const pair = [target(gl, w, h, internal, type, filter), target(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, dual), target(gl, w, h, internal, type, filter, dual)]; let i = 0; return { get read() { return pair[i]; }, @@ -564,9 +684,9 @@ export function startOrb(canvas, { onReady } = {}) { const coarse = matchMedia('(pointer: coarse)').matches; const small = Math.min(innerWidth, innerHeight) < 600; - const T = small ? 192 : 256; - const INNER = small ? 26000 : 52000; - const CORONA = small ? 8000 : 13000; + const T = small ? 192 : 320; + const INNER = small ? 26000 : 82000; + const CORONA = small ? 8000 : 18000; const TOTAL = INNER + CORONA; 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_MAG_FILTER, gl.NEAREST); - // particle state: displacement.xy + velocity.xy per particle - const state = interactive ? pingPong(gl, T, T, stateFmt.internal, stateFmt.type, gl.NEAREST) : null; + // 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; + let needInit = true; // fluid fields; rebuilt when the viewport shape changes so the grid stays square-celled 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(); } - const t0 = performance.now(); - let last = t0; + let simT = reduced ? 8 : 0; + let last = performance.now(); let ready = false; function commonUniforms(prog, t) { @@ -751,7 +872,9 @@ export function startOrb(canvas, { onReady } = {}) { function frame(now) { const dt = Math.min((now - last) / 1000, 1 / 30) || 1 / 60; 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.cy += (par.y - par.cy) * 0.04; const ox = par.cx * 10, oy = par.cy * 6; @@ -763,8 +886,12 @@ export function startOrb(canvas, { onReady } = {}) { const s = progs.state; gl.useProgram(s.p); commonUniforms(s, t); - gl.uniform1i(s.u.uState, bind(0, state.read.tex)); - gl.uniform1i(s.u.uFlow, bind(1, velocity.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.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.uniform1ui(s.u.uTotal, TOTAL); gl.uniform2f(s.u.uCenter, cx + ox, cy + oy); @@ -792,12 +919,15 @@ export function startOrb(canvas, { onReady } = {}) { gl.enable(gl.BLEND); 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 p = progs.particles; gl.useProgram(p.p); 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.uniform2f(p.u.uView, W, H); gl.uniform2f(p.u.uCenter, cx + ox, cy + oy);