// Radiance orb: a glassy sphere of particles with a sparkling corona that reacts to the pointer. // // WebGL2, no libraries. Per frame: // 1. the pointer stirs a small stable-fluids solver (velocity + dye fields), // 2. a GPU spring system moves every particle with that flow and pulls it home, // 3. the sky, corona, glass shell and inner particles are drawn. // Particle attributes come from a hash of the vertex index, so there are no vertex buffers. // ---------------------------------------------------------------- shared GLSL const HEAD = `#version 300 es precision highp float; precision highp int; precision highp sampler2D; `; const COMMON = ` uniform float uTime; uniform vec2 uTilt; uniform uint uInner; uint pcg(uint v) { uint s = v * 747796405u + 2891336453u; uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; return (w >> 22u) ^ w; } float h01(uint v) { return float(pcg(v)) * (1.0 / 4294967296.0); } float hash(vec3 p) { p = fract(p * 0.3183099 + 0.1); p *= 17.0; return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); } float vnoise(vec3 x) { vec3 i = floor(x); vec3 f = fract(x); f = f * f * (3.0 - 2.0 * f); return mix( mix(mix(hash(i), hash(i + vec3(1, 0, 0)), f.x), mix(hash(i + vec3(0, 1, 0)), hash(i + vec3(1, 1, 0)), f.x), f.y), mix(mix(hash(i + vec3(0, 0, 1)), hash(i + vec3(1, 0, 1)), f.x), mix(hash(i + vec3(0, 1, 1)), hash(i + vec3(1, 1, 1)), f.x), f.y), f.z); } float fbm(vec3 p) { float v = 0.0; float a = 0.55; for (int i = 0; i < 3; i++) { v += a * vnoise(p); p = p * 2.03 + 11.7; a *= 0.5; } return v; } vec3 rotY(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z); } vec3 rotX(vec3 p, float a) { float c = cos(a), s = sin(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). 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 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.94, 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); } return o; } `; const VERT_FULL = `${HEAD} out vec2 vUv; void main() { vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); vUv = p; gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); } `; // ---------------------------------------------------------------- fluid solver const FRAG_ADVECT = `${HEAD} in vec2 vUv; uniform sampler2D uVelocity; uniform sampler2D uSource; uniform vec2 uTexel; uniform float uDt; uniform float uDissipation; out vec4 o; void main() { vec2 coord = vUv - uDt * texture(uVelocity, vUv).xy * uTexel; o = texture(uSource, coord) / (1.0 + uDissipation * uDt); } `; const FRAG_SPLAT = `${HEAD} in vec2 vUv; uniform sampler2D uTarget; uniform vec2 uPoint; uniform vec3 uColor; uniform float uRadius; uniform float uAspect; out vec4 o; void main() { vec2 p = vUv - uPoint; p.x *= uAspect; vec3 splat = exp(-dot(p, p) / uRadius) * uColor; o = vec4(texture(uTarget, vUv).xyz + splat, 1.0); } `; const FRAG_CURL = `${HEAD} in vec2 vUv; uniform sampler2D uVelocity; uniform vec2 uTexel; out vec4 o; void main() { float L = texture(uVelocity, vUv - vec2(uTexel.x, 0.0)).y; float R = texture(uVelocity, vUv + vec2(uTexel.x, 0.0)).y; float T = texture(uVelocity, vUv + vec2(0.0, uTexel.y)).x; float B = texture(uVelocity, vUv - vec2(0.0, uTexel.y)).x; o = vec4(0.5 * (R - L - T + B), 0.0, 0.0, 1.0); } `; const FRAG_VORTICITY = `${HEAD} in vec2 vUv; uniform sampler2D uVelocity; uniform sampler2D uCurl; uniform vec2 uTexel; uniform float uStrength; uniform float uDt; out vec4 o; void main() { float L = texture(uCurl, vUv - vec2(uTexel.x, 0.0)).x; float R = texture(uCurl, vUv + vec2(uTexel.x, 0.0)).x; float T = texture(uCurl, vUv + vec2(0.0, uTexel.y)).x; float B = texture(uCurl, vUv - vec2(0.0, uTexel.y)).x; float C = texture(uCurl, vUv).x; vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L)); force /= length(force) + 0.0001; force *= uStrength * C; force.y *= -1.0; vec2 v = texture(uVelocity, vUv).xy + force * uDt; o = vec4(clamp(v, -1000.0, 1000.0), 0.0, 1.0); } `; const FRAG_DIVERGENCE = `${HEAD} in vec2 vUv; uniform sampler2D uVelocity; uniform vec2 uTexel; out vec4 o; void main() { vec2 C = texture(uVelocity, vUv).xy; float L = texture(uVelocity, vUv - vec2(uTexel.x, 0.0)).x; float R = texture(uVelocity, vUv + vec2(uTexel.x, 0.0)).x; float T = texture(uVelocity, vUv + vec2(0.0, uTexel.y)).y; float B = texture(uVelocity, vUv - vec2(0.0, uTexel.y)).y; if (vUv.x - uTexel.x < 0.0) L = -C.x; if (vUv.x + uTexel.x > 1.0) R = -C.x; if (vUv.y + uTexel.y > 1.0) T = -C.y; if (vUv.y - uTexel.y < 0.0) B = -C.y; o = vec4(0.5 * (R - L + T - B), 0.0, 0.0, 1.0); } `; const FRAG_PRESSURE = `${HEAD} in vec2 vUv; uniform sampler2D uPressure; uniform sampler2D uDivergence; uniform vec2 uTexel; out vec4 o; void main() { float L = texture(uPressure, vUv - vec2(uTexel.x, 0.0)).x; float R = texture(uPressure, vUv + vec2(uTexel.x, 0.0)).x; float T = texture(uPressure, vUv + vec2(0.0, uTexel.y)).x; float B = texture(uPressure, vUv - vec2(0.0, uTexel.y)).x; float div = texture(uDivergence, vUv).x; o = vec4((L + R + B + T - div) * 0.25, 0.0, 0.0, 1.0); } `; const FRAG_GRADIENT = `${HEAD} in vec2 vUv; uniform sampler2D uPressure; uniform sampler2D uVelocity; uniform vec2 uTexel; out vec4 o; void main() { float L = texture(uPressure, vUv - vec2(uTexel.x, 0.0)).x; float R = texture(uPressure, vUv + vec2(uTexel.x, 0.0)).x; float T = texture(uPressure, vUv + vec2(0.0, uTexel.y)).x; float B = texture(uPressure, vUv - vec2(0.0, uTexel.y)).x; vec2 v = texture(uVelocity, vUv).xy - vec2(R - L, T - B); o = vec4(v, 0.0, 1.0); } `; const FRAG_SCALE = `${HEAD} in vec2 vUv; uniform sampler2D uTex; uniform float uValue; out vec4 o; void main() { o = uValue * texture(uTex, vUv); } `; // ---------------------------------------------------------------- particle spring system const FRAG_STATE = `${HEAD} ${COMMON} uniform sampler2D uState; uniform sampler2D uFlow; uniform int uT; uniform uint uTotal; uniform vec2 uCenter; // css px uniform vec2 uView; // css px uniform float uR; // css px uniform vec2 uSim; // flow texture size in texels uniform float uDt; uniform float uGain; out vec4 o; 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; } vec4 st = texelFetch(uState, ip, 0); vec2 disp = st.xy; vec2 vel = st.zw; // 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 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); vel += acc * uDt; disp += vel * uDt; float m = length(disp); if (m > 0.9) disp *= 0.9 / m; o = vec4(disp, vel); } `; // ---------------------------------------------------------------- drawing const VERT_PARTICLES = `${HEAD} ${COMMON} uniform sampler2D uState; uniform int uT; uniform vec2 uView; // css px uniform vec2 uCenter; // css px uniform float uR; // css px uniform float uDpr; out vec4 vColor; 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; float sc = clamp(uR / 315.0, 0.8, 1.7); vec4 col; float size; float persp = 1.0; if (p.kind < 0.5) { vec3 warm = mix(vec3(0.90, 0.22, 0.17), vec3(0.99, 0.50, 0.30), 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.6 + p.r.y * 1.6 + p.n * 1.0, 3.2 + p.r.w * 2.6, 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); // 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); 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); vGlow = 0.5; // sparks that get knocked off the limb flare, then fade out while they are away from home float away = smoothstep(0.03, 0.5, length(disp)); col.a *= 1.0 - 0.8 * away; col.a = min(1.0, col.a * (1.0 + stir * 0.8)); size *= 1.0 + stir * 0.9; } 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; } `; const FRAG_PARTICLES = `${HEAD} in vec4 vColor; in float vGlow; out vec4 o; void main() { vec2 c = gl_PointCoord - 0.5; float r = length(c); float core = smoothstep(0.5, 0.18, r); float halo = vGlow * smoothstep(0.5, 0.0, r) * 0.35; float a = vColor.a * clamp(core + halo, 0.0, 1.0); if (a < 0.01) discard; o = vec4(vColor.rgb * a, a); } `; const FRAG_SKY = `${HEAD} in vec2 vUv; uniform vec2 uDevice; // device px uniform sampler2D uFlow; uniform sampler2D uDye; uniform vec2 uSim; uniform float uTime; out vec4 o; vec3 ramp(float y) { const vec3 c0 = vec3(0.984, 0.867, 0.718); const vec3 c1 = vec3(0.976, 0.902, 0.737); const vec3 c2 = vec3(0.976, 0.922, 0.737); const vec3 c3 = vec3(0.976, 0.851, 0.702); const vec3 c4 = vec3(0.969, 0.812, 0.675); const vec3 c5 = vec3(0.953, 0.757, 0.620); const vec3 c6 = vec3(0.933, 0.682, 0.541); const vec3 c7 = vec3(0.933, 0.600, 0.447); const vec3 c8 = vec3(0.922, 0.545, 0.388); const vec3 c9 = vec3(0.937, 0.506, 0.361); vec3 c = mix(c0, c1, smoothstep(0.00, 0.14, y)); c = mix(c, c2, smoothstep(0.14, 0.30, y)); c = mix(c, c3, smoothstep(0.30, 0.52, y)); c = mix(c, c4, smoothstep(0.52, 0.62, y)); c = mix(c, c5, smoothstep(0.62, 0.70, y)); c = mix(c, c6, smoothstep(0.70, 0.79, y)); c = mix(c, c7, smoothstep(0.79, 0.87, y)); c = mix(c, c8, smoothstep(0.87, 0.94, y)); c = mix(c, c9, smoothstep(0.94, 1.00, y)); return c; } void main() { vec2 uv = gl_FragCoord.xy / uDevice; // y up vec2 flow = texture(uFlow, uv).xy; float dye = texture(uDye, uv).x; // the sky leans with the flow and washes pale where the pointer has been float y = 1.0 - uv.y; y += clamp(flow.y / uSim.y, -0.2, 0.2) * 0.12 - clamp(flow.x / uSim.x, -0.2, 0.2) * 0.04; vec3 col = ramp(clamp(y, 0.0, 1.0)); vec2 d = (uv - vec2(0.5, 0.6)) / vec2(1.15, 0.8); float v = clamp((length(d) - 0.45) / 0.55, 0.0, 1.0) * 0.65; col = mix(col, vec3(0.984, 0.737, 0.651), v); float wash = 0.4 * (1.0 - exp(-dye * 1.4)); col = mix(col, vec3(0.98, 0.93, 0.89), wash); float grain = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233)) + uTime) * 43758.5453); col += (grain - 0.5) * 0.012; o = vec4(col, 1.0); } `; const FRAG_SHELL = `${HEAD} in vec2 vUv; uniform vec2 uDevice; uniform vec2 uCenter; // device px uniform float uR; // device px out vec4 o; void main() { vec2 frag = vec2(gl_FragCoord.x, uDevice.y - gl_FragCoord.y); vec2 d = (frag - uCenter) / uR; float r = length(d); if (r > 1.6) { o = vec4(0.0); return; } // soft light spilling around the silhouette float glow = 0.28 * exp(-(r - 1.0) * 7.5) * step(1.0, r); float edge = 1.0 - smoothstep(0.994, 1.004, r); float z = sqrt(max(0.0, 1.0 - r * r)); vec3 n = vec3(d.x, -d.y, z); // glassy body: lighter top-left, deeper orange bottom-right vec3 top = vec3(0.992, 0.76, 0.52); vec3 bot = vec3(0.975, 0.55, 0.38); float lit = clamp(0.5 + 0.5 * dot(normalize(n), normalize(vec3(-0.45, 0.6, 0.65))), 0.0, 1.0); vec3 body = mix(bot, top, lit); // fresnel rim, pale pink-white float fres = pow(1.0 - z, 2.0); vec3 rim = vec3(1.0, 0.89, 0.86); vec3 col = mix(body, rim, clamp(fres * 1.15, 0.0, 1.0)); float a = mix(0.7, 0.97, fres); // crisp bright edge line float ring = smoothstep(0.955, 0.985, r) * (1.0 - smoothstep(0.985, 1.004, r)); col = mix(col, vec3(1.0, 0.95, 0.93), ring * 0.6); vec4 shell = vec4(col * a, a) * edge; vec4 halo = vec4(vec3(1.0, 0.96, 0.86) * glow, glow); o = shell + halo * (1.0 - shell.a); } `; // ---------------------------------------------------------------- GL helpers function compile(gl, type, src) { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); return s; } function program(gl, vs, fs) { const p = gl.createProgram(); gl.attachShader(p, compile(gl, gl.VERTEX_SHADER, vs)); gl.attachShader(p, compile(gl, gl.FRAGMENT_SHADER, fs)); gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p)); const uniforms = {}; const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); for (let i = 0; i < n; i++) { const info = gl.getActiveUniform(p, i); uniforms[info.name] = gl.getUniformLocation(p, info.name); } return { p, u: uniforms }; } function target(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); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_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_T, gl.CLAMP_TO_EDGE); const fbo = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); gl.viewport(0, 0, w, h); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); return { tex, fbo, w, h }; } function freeTarget(gl, t) { gl.deleteTexture(t.tex); 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)]; let i = 0; return { get read() { return pair[i]; }, get write() { return pair[1 - i]; }, swap() { i = 1 - i; }, free() { pair.forEach((t) => freeTarget(gl, t)); }, }; } // ---------------------------------------------------------------- public entry export function startOrb(canvas, { onReady } = {}) { const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, powerPreference: 'high-performance' }); if (!gl) return false; const hasF32 = !!gl.getExtension('EXT_color_buffer_float'); const interactive = hasF32 || !!gl.getExtension('EXT_color_buffer_half_float'); const stateFmt = hasF32 ? { internal: gl.RGBA32F, type: gl.FLOAT } : { internal: gl.RGBA16F, type: gl.HALF_FLOAT }; const fluidFmt = { internal: gl.RGBA16F, type: gl.HALF_FLOAT }; const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; 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 TOTAL = INNER + CORONA; let progs; try { const mk = (fs) => program(gl, VERT_FULL, fs); progs = { advect: mk(FRAG_ADVECT), splat: mk(FRAG_SPLAT), curl: mk(FRAG_CURL), vorticity: mk(FRAG_VORTICITY), divergence: mk(FRAG_DIVERGENCE), pressure: mk(FRAG_PRESSURE), gradient: mk(FRAG_GRADIENT), scale: mk(FRAG_SCALE), state: mk(FRAG_STATE), sky: mk(FRAG_SKY), shell: mk(FRAG_SHELL), particles: program(gl, VERT_PARTICLES, FRAG_PARTICLES), }; } catch (e) { console.warn('orb: shader setup failed', e); return false; } gl.bindVertexArray(gl.createVertexArray()); const tri = () => gl.drawArrays(gl.TRIANGLES, 0, 3); const bind = (unit, tex) => { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, tex); return unit; }; const into = (t) => { gl.bindFramebuffer(gl.FRAMEBUFFER, t.fbo); gl.viewport(0, 0, t.w, t.h); }; // 1x1 zero texture used when float rendering is unavailable (the orb just stays calm) const zeroTex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, zeroTex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4)); 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; // fluid fields; rebuilt when the viewport shape changes so the grid stays square-celled let velocity, dye, pressure, divergence, curl, simW = 1, simH = 1; function initFluid() { if (!interactive) return; if (velocity) { velocity.free(); dye.free(); pressure.free(); freeTarget(gl, divergence); freeTarget(gl, curl); } simH = coarse ? 96 : 128; simW = Math.max(32, Math.round(simH * (W / H))); velocity = pingPong(gl, simW, simH, fluidFmt.internal, fluidFmt.type, gl.LINEAR); dye = pingPong(gl, simW, simH, fluidFmt.internal, fluidFmt.type, gl.LINEAR); pressure = pingPong(gl, simW, simH, fluidFmt.internal, fluidFmt.type, gl.NEAREST); divergence = target(gl, simW, simH, fluidFmt.internal, fluidFmt.type, gl.NEAREST); curl = target(gl, simW, simH, fluidFmt.internal, fluidFmt.type, gl.NEAREST); } // layout let W = 0, H = 0, dpr = 1, cx = 0, cy = 0, R = 0; function resize() { dpr = Math.min(window.devicePixelRatio || 1, 2); W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); const portrait = W < H * 0.85; R = portrait ? Math.min(W * 0.46, H * 0.3) : W * 0.2625; cx = W / 2; cy = H * (portrait ? 0.8 : 0.52) + R * (portrait ? 0.15 : 1); initFluid(); } resize(); addEventListener('resize', resize); // 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 par = { x: 0, y: 0, cx: 0, cy: 0 }; function onMove(e) { const x = e.clientX, y = e.clientY; if (!ptr.has) { ptr.lx = x; ptr.ly = y; ptr.has = true; } ptr.x = x; ptr.y = y; ptr.moved = true; par.x = (x / innerWidth - 0.5) * 2; par.y = (y / innerHeight - 0.5) * 2; } function onLeave() { ptr.has = false; ptr.moved = false; } addEventListener('pointermove', onMove, { passive: true }); addEventListener('pointerdown', (e) => { ptr.has = false; onMove(e); }, { passive: true }); addEventListener('pointerup', (e) => { if (e.pointerType !== 'mouse') onLeave(); }, { passive: true }); addEventListener('pointercancel', onLeave, { passive: true }); document.documentElement.addEventListener('mouseleave', onLeave); // fluid tuning const FORCE = 1500; const RADIUS = 0.008; const VEL_DISSIPATION = 1.5; const DYE_DISSIPATION = 1.1; const CURL = 24; function splat(u, v, dx, dy, dyeAmt) { const s = progs.splat; gl.useProgram(s.p); gl.uniform1f(s.u.uAspect, W / H); gl.uniform1f(s.u.uRadius, RADIUS * Math.max(1, W / H)); gl.uniform2f(s.u.uPoint, u, v); gl.uniform1i(s.u.uTarget, bind(0, velocity.read.tex)); gl.uniform3f(s.u.uColor, dx, dy, 0); into(velocity.write); tri(); velocity.swap(); gl.uniform1i(s.u.uTarget, bind(0, dye.read.tex)); gl.uniform3f(s.u.uColor, dyeAmt, 0, 0); into(dye.write); tri(); dye.swap(); } function stepFluid(dt) { // pointer path since the last frame, as a run of splats so fast moves leave no gaps if (ptr.moved && ptr.has) { const dxp = ptr.x - ptr.lx, dyp = ptr.y - ptr.ly; const dist = Math.hypot(dxp, dyp); if (dist > 0.01) { const steps = Math.min(16, Math.max(1, Math.ceil(dist / (Math.min(W, H) * 0.02)))); const du = dxp / W, dv = -dyp / H; const speed = Math.hypot(du, dv); const norm = Math.sqrt(steps); for (let i = 1; i <= steps; i++) { const f = i / steps; splat((ptr.lx + dxp * f) / W, 1 - (ptr.ly + dyp * f) / H, (du * FORCE) / norm, (dv * FORCE) / norm, Math.min(1, speed * 14) / norm); } } ptr.lx = ptr.x; ptr.ly = ptr.y; ptr.moved = false; } const tx = 1 / simW, ty = 1 / simH; let g = progs.curl; gl.useProgram(g.p); gl.uniform2f(g.u.uTexel, tx, ty); gl.uniform1i(g.u.uVelocity, bind(0, velocity.read.tex)); into(curl); tri(); g = progs.vorticity; gl.useProgram(g.p); gl.uniform2f(g.u.uTexel, tx, ty); gl.uniform1i(g.u.uVelocity, bind(0, velocity.read.tex)); gl.uniform1i(g.u.uCurl, bind(1, curl.tex)); gl.uniform1f(g.u.uStrength, CURL); gl.uniform1f(g.u.uDt, dt); into(velocity.write); tri(); velocity.swap(); g = progs.divergence; gl.useProgram(g.p); gl.uniform2f(g.u.uTexel, tx, ty); gl.uniform1i(g.u.uVelocity, bind(0, velocity.read.tex)); into(divergence); tri(); g = progs.scale; gl.useProgram(g.p); gl.uniform1i(g.u.uTex, bind(0, pressure.read.tex)); gl.uniform1f(g.u.uValue, 0.8); into(pressure.write); tri(); pressure.swap(); g = progs.pressure; gl.useProgram(g.p); gl.uniform2f(g.u.uTexel, tx, ty); gl.uniform1i(g.u.uDivergence, bind(1, divergence.tex)); for (let i = 0; i < 18; i++) { gl.uniform1i(g.u.uPressure, bind(0, pressure.read.tex)); into(pressure.write); tri(); pressure.swap(); } g = progs.gradient; gl.useProgram(g.p); gl.uniform2f(g.u.uTexel, tx, ty); gl.uniform1i(g.u.uPressure, bind(0, pressure.read.tex)); gl.uniform1i(g.u.uVelocity, bind(1, velocity.read.tex)); into(velocity.write); tri(); velocity.swap(); g = progs.advect; gl.useProgram(g.p); gl.uniform2f(g.u.uTexel, tx, ty); gl.uniform1f(g.u.uDt, dt); gl.uniform1i(g.u.uVelocity, bind(0, velocity.read.tex)); gl.uniform1i(g.u.uSource, bind(1, velocity.read.tex)); gl.uniform1f(g.u.uDissipation, VEL_DISSIPATION); into(velocity.write); tri(); velocity.swap(); gl.uniform1i(g.u.uVelocity, bind(0, velocity.read.tex)); gl.uniform1i(g.u.uSource, bind(1, dye.read.tex)); gl.uniform1f(g.u.uDissipation, DYE_DISSIPATION); into(dye.write); tri(); dye.swap(); } const t0 = performance.now(); let last = t0; let ready = false; function commonUniforms(prog, t) { gl.uniform1f(prog.u.uTime, t); gl.uniform2f(prog.u.uTilt, par.cx * 0.25, par.cy * 0.12); gl.uniform1ui(prog.u.uInner, INNER); } function frame(now) { const dt = Math.min((now - last) / 1000, 1 / 30) || 1 / 60; last = now; const t = reduced ? 8 : (now - t0) / 1000; 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; if (interactive) { gl.disable(gl.BLEND); stepFluid(dt); 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.uT, T); gl.uniform1ui(s.u.uTotal, TOTAL); gl.uniform2f(s.u.uCenter, cx + ox, cy + oy); gl.uniform2f(s.u.uView, W, H); gl.uniform1f(s.u.uR, R); 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(); } gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, canvas.width, canvas.height); gl.disable(gl.BLEND); // 1. sky let g = progs.sky; gl.useProgram(g.p); gl.uniform2f(g.u.uDevice, canvas.width, canvas.height); gl.uniform1i(g.u.uFlow, bind(0, interactive ? velocity.read.tex : zeroTex)); gl.uniform1i(g.u.uDye, bind(1, interactive ? dye.read.tex : zeroTex)); gl.uniform2f(g.u.uSim, simW, simH); gl.uniform1f(g.u.uTime, t); tri(); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); const stateTex = interactive ? state.read.tex : 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.uT, T); gl.uniform2f(p.u.uView, W, H); gl.uniform2f(p.u.uCenter, cx + ox, cy + oy); gl.uniform1f(p.u.uR, R); gl.uniform1f(p.u.uDpr, dpr); }; // 2. corona (behind the shell) useParticles(); gl.drawArrays(gl.POINTS, INNER, CORONA); // 3. glass shell + glow g = progs.shell; gl.useProgram(g.p); gl.uniform2f(g.u.uDevice, canvas.width, canvas.height); gl.uniform2f(g.u.uCenter, (cx + ox) * dpr, (cy + oy) * dpr); gl.uniform1f(g.u.uR, R * dpr); tri(); // 4. particles on the shell useParticles(); gl.drawArrays(gl.POINTS, 0, INNER); if (!ready) { ready = true; onReady && onReady(); } requestAnimationFrame(frame); } requestAnimationFrame(frame); return true; }