diff --git a/src/scripts/orb.js b/src/scripts/orb.js index a3ca514..a8520c0 100644 --- a/src/scripts/orb.js +++ b/src/scripts/orb.js @@ -1,19 +1,30 @@ -// Radiance orb: a glassy sphere of particles with a sparkling corona. -// Raw WebGL (no libraries) so the home page stays tiny and loads fast. +// 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. -const VERT_PARTICLES = ` +// ---------------------------------------------------------------- shared GLSL + +const HEAD = `#version 300 es precision highp float; -attribute vec3 aDir; -attribute vec4 aRand; -uniform vec2 uRes; -uniform vec2 uCenter; -uniform float uR; +precision highp int; +precision highp sampler2D; +`; + +const COMMON = ` uniform float uTime; -uniform float uDpr; uniform vec2 uTilt; -uniform float uMode; // 0 = inner particles, 1 = corona -varying vec4 vColor; -varying float vGlow; +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); @@ -50,64 +61,317 @@ vec3 rotX(vec3 p, float a) { return vec3(p.x, c * p.y - s * p.z, s * p.y + c * p.z); } -void main() { - float t = uTime; - vec3 d = aDir; - float size = 1.0; - vec4 col = vec4(0.0); - float sc = clamp(uR / 315.0, 0.8, 1.7); +// 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; +}; - if (uMode < 0.5) { - // ---- inner particles: density follows a slowly drifting, domain-warped noise field - float rad = 0.955 + aRand.x * 0.04; - vec3 q = d * 1.45 + vec3(0.0, t * 0.03, t * 0.02); - float n = fbm(q + 1.6 * fbm(q * 0.8 + 3.1)); +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) - aRand.y * 0.7; - float spark = step(0.94, aRand.z); - if (keep < 0.0 && spark < 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; } - vec3 warm = mix(vec3(0.90, 0.22, 0.17), vec3(0.99, 0.50, 0.30), aRand.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.85, 0.95, spark)); - size = mix(1.6 + aRand.y * 1.6 + n * 1.0, 3.2 + aRand.w * 2.6, spark); - float tw = 0.5 + 0.5 * sin(t * (1.2 + aRand.x * 2.5) + aRand.w * 40.0); - col.a *= mix(1.0, tw, spark); - vGlow = spark; + 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); - // far side of the sphere reads dimmer through the glass - col.a *= mix(0.25, 1.0, smoothstep(-0.4, 0.4, p.z)); - vec2 px = uCenter + vec2(p.x, -p.y) * uR * persp; - gl_Position = vec4(px.x / uRes.x * 2.0 - 1.0, 1.0 - px.y / uRes.y * 2.0, 0.0, 1.0); - gl_PointSize = size * sc * uDpr * persp; - vColor = col; + 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 - vec2 cd = d.xy; + // 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(aRand.x, 1.5) * (0.05 + 0.4 * spike) * (0.3 + 0.7 * up); - reach *= 0.88 + 0.12 * sin(t * 0.7 + aRand.w * 20.0); - reach += 0.012 * sin(t * 0.9 + aRand.z * 30.0); - float fade = 1.0 - smoothstep(0.0, 0.42, reach); - col = vec4(1.0, 0.97, 0.86, (0.3 + 0.7 * fade) * (0.35 + 0.65 * smoothstep(0.2, 0.75, n))); - float tw = 0.5 + 0.5 * sin(t * (1.0 + aRand.y * 3.0) + aRand.z * 60.0); - col.a *= mix(0.5, 1.0, tw); - size = 1.0 + aRand.y * aRand.y * 2.6 * (0.3 + fade); - vGlow = 0.5; - vec2 px = uCenter + vec2(cd.x, -cd.y) * (1.0 + 0.004 + reach) * uR; - gl_Position = vec4(px.x / uRes.x * 2.0 - 1.0, 1.0 - px.y / uRes.y * 2.0, 0.0, 1.0); - gl_PointSize = size * sc * uDpr; - vColor = col; + 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 FRAG_PARTICLES = ` -precision mediump float; -varying vec4 vColor; -varying float vGlow; +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); @@ -115,31 +379,77 @@ void main() { 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; - gl_FragColor = vec4(vColor.rgb * a, a); + o = vec4(vColor.rgb * a, a); } `; -const VERT_QUAD = ` -attribute vec2 aPos; -varying vec2 vUv; -void main() { - vUv = aPos; - gl_Position = vec4(aPos, 0.0, 1.0); -} -`; - -const FRAG_SHELL = ` -precision highp float; -varying vec2 vUv; -uniform vec2 uRes; -uniform vec2 uCenter; -uniform float uR; +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 frag = vec2(gl_FragCoord.x, uRes.y - gl_FragCoord.y); + 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); @@ -166,10 +476,12 @@ void main() { vec4 shell = vec4(col * a, a) * edge; vec4 halo = vec4(vec3(1.0, 0.96, 0.86) * glow, glow); - gl_FragColor = shell + halo * (1.0 - shell.a); + o = shell + halo * (1.0 - shell.a); } `; +// ---------------------------------------------------------------- GL helpers + function compile(gl, type, src) { const s = gl.createShader(type); gl.shaderSource(s, src); @@ -177,151 +489,330 @@ function compile(gl, type, src) { 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)); - return 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 rng(seed) { - return () => { - seed |= 0; seed = (seed + 0x6d2b79f5) | 0; - let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + +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)); }, }; } -function cloud(count, seed, ring) { - const rand = rng(seed); - const dir = new Float32Array(count * 3); - const rnd = new Float32Array(count * 4); - for (let i = 0; i < count; i++) { - if (ring) { - const a = rand() * Math.PI * 2; - dir.set([Math.cos(a), Math.sin(a), 0], i * 3); - } else { - const y = rand() * 2 - 1; - const phi = rand() * Math.PI * 2; - const s = Math.sqrt(1 - y * y); - dir.set([Math.cos(phi) * s, y, Math.sin(phi) * s], i * 3); - } - rnd.set([rand(), rand(), rand(), rand()], i * 4); - } - return { dir, rnd, count }; -} + +// ---------------------------------------------------------------- public entry export function startOrb(canvas, { onReady } = {}) { - const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false, powerPreference: 'low-power' }); + 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 inner = cloud(small ? 28000 : 52000, 7); - const corona = cloud(small ? 6000 : 13000, 21, true); - const pp = program(gl, VERT_PARTICLES, FRAG_PARTICLES); - const ps = program(gl, VERT_QUAD, FRAG_SHELL); + const T = small ? 192 : 256; + const INNER = small ? 26000 : 52000; + const CORONA = small ? 8000 : 13000; + const TOTAL = INNER + CORONA; - const buf = (data) => { - const b = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, b); - gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); - return b; - }; - const bufs = { - iDir: buf(inner.dir), iRnd: buf(inner.rnd), - cDir: buf(corona.dir), cRnd: buf(corona.rnd), - quad: buf(new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1])), - }; + 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; + } - const U = (p, names) => Object.fromEntries(names.map((n) => [n, gl.getUniformLocation(p, n)])); - const up = U(pp, ['uRes', 'uCenter', 'uR', 'uTime', 'uDpr', 'uTilt', 'uMode']); - const us = U(ps, ['uRes', 'uCenter', 'uR', 'uTime']); - const aDir = gl.getAttribLocation(pp, 'aDir'); - const aRand = gl.getAttribLocation(pp, 'aRand'); - const aPos = gl.getAttribLocation(ps, 'aPos'); + gl.bindVertexArray(gl.createVertexArray()); - gl.enable(gl.BLEND); - gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); - gl.clearColor(0, 0, 0, 0); + 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); - gl.viewport(0, 0, canvas.width, canvas.height); 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); - // gentle pointer parallax - const target = { x: 0, y: 0 }, cur = { x: 0, y: 0 }; - addEventListener('pointermove', (e) => { - target.x = (e.clientX / innerWidth - 0.5) * 2; - target.y = (e.clientY / innerHeight - 0.5) * 2; - }, { passive: true }); + // 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); - function bindParticles(dirBuf, rndBuf) { - gl.bindBuffer(gl.ARRAY_BUFFER, dirBuf); - gl.enableVertexAttribArray(aDir); - gl.vertexAttribPointer(aDir, 3, gl.FLOAT, false, 0, 0); - gl.bindBuffer(gl.ARRAY_BUFFER, rndBuf); - gl.enableVertexAttribArray(aRand); - gl.vertexAttribPointer(aRand, 4, gl.FLOAT, false, 0, 0); + // 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; - cur.x += (target.x - cur.x) * 0.04; - cur.y += (target.y - cur.y) * 0.04; - const ox = cur.x * 10, oy = cur.y * 6; + 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; - gl.clear(gl.COLOR_BUFFER_BIT); + if (interactive) { + gl.disable(gl.BLEND); + stepFluid(dt); - // 1. corona (behind the shell) - gl.useProgram(pp); - gl.uniform2f(up.uRes, W, H); - gl.uniform2f(up.uCenter, cx + ox, cy + oy); - gl.uniform1f(up.uR, R); - gl.uniform1f(up.uTime, t); - gl.uniform1f(up.uDpr, dpr); - gl.uniform2f(up.uTilt, cur.x * 0.25, cur.y * 0.12); - gl.disableVertexAttribArray(aPos); - gl.uniform1f(up.uMode, 1); - bindParticles(bufs.cDir, bufs.cRnd); - gl.drawArrays(gl.POINTS, 0, corona.count); + 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(); + } - // 2. glass shell + glow - gl.useProgram(ps); - gl.uniform2f(us.uRes, canvas.width, canvas.height); - gl.uniform2f(us.uCenter, (cx + ox) * dpr, (cy + oy) * dpr); - gl.uniform1f(us.uR, R * dpr); - gl.uniform1f(us.uTime, t); - gl.disableVertexAttribArray(aDir); - gl.disableVertexAttribArray(aRand); - gl.bindBuffer(gl.ARRAY_BUFFER, bufs.quad); - gl.enableVertexAttribArray(aPos); - gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); - gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); - gl.disableVertexAttribArray(aPos); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.disable(gl.BLEND); - // 3. particles inside / on the shell - gl.useProgram(pp); - gl.uniform1f(up.uMode, 0); - bindParticles(bufs.iDir, bufs.iRnd); - gl.drawArrays(gl.POINTS, 0, inner.count); + // 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(); } - if (!reduced) requestAnimationFrame(frame); + requestAnimationFrame(frame); } requestAnimationFrame(frame); return true; diff --git a/src/styles/radiance.css b/src/styles/radiance.css index 3b56ae1..72394a4 100644 --- a/src/styles/radiance.css +++ b/src/styles/radiance.css @@ -27,6 +27,7 @@ img { display: block; } /* ---------- hero ---------- */ .hero { + touch-action: none; position: fixed; inset: 0; background: radial-gradient(115% 80% at 50% 40%, transparent 45%, rgb(251 188 166 / 0.65) 100%),