Stable-fluids solver (velocity + dye) stirred by mouse/touch drives a GPU spring system over all ~65k particles: they swirl with the flow, heat up and flare while disturbed, then spring home. The sky shader leans and washes with the flow. WebGL2; falls back to the static orb without float render targets, and to a CSS orb without WebGL2.
820 lines
28 KiB
JavaScript
820 lines
28 KiB
JavaScript
// 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;
|
|
}
|