From ecaa3bbf6afa3d382c0cbe4c120bcf4e89f98f94 Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Thu, 22 Oct 2026 20:00:53 -0400 Subject: [PATCH 01/14] =?UTF-8?q?radiance:=20new=20home=20page=20=E2=80=94?= =?UTF-8?q?=20WebGL=20orb=20hero,=20DG=20cube=20loader,=20white=20dropdown?= =?UTF-8?q?=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Crush landing page with a single-screen hero (no scroll): gradient sky, particle orb with corona, 'Welcome to dangrubb.net' title, top-left DG logo and a numbered menu. Raw WebGL (no three.js), self-hosted Instrument Sans + Geist Mono. ~10 KB of JS. --- package-lock.json | 20 ++ package.json | 4 +- public/src/img/DanGrubbLogoDark.png | Bin 0 -> 5041 bytes src/pages/index.astro | 261 ++++++++-------------- src/scripts/orb.js | 328 ++++++++++++++++++++++++++++ src/scripts/ui.js | 80 +++++++ src/styles/radiance.css | 195 +++++++++++++++++ 7 files changed, 714 insertions(+), 174 deletions(-) create mode 100644 public/src/img/DanGrubbLogoDark.png create mode 100644 src/scripts/orb.js create mode 100644 src/scripts/ui.js create mode 100644 src/styles/radiance.css diff --git a/package-lock.json b/package-lock.json index d68c014..0d9e2f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "dangrubb-astro", "version": "0.0.1", "dependencies": { + "@fontsource/geist-mono": "^5.3.0", + "@fontsource/instrument-sans": "^5.3.0", "astro": "^6.1.9" }, "engines": { @@ -593,6 +595,24 @@ "node": ">=18" } }, + "node_modules/@fontsource/geist-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/geist-mono/-/geist-mono-5.3.0.tgz", + "integrity": "sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/instrument-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/instrument-sans/-/instrument-sans-5.3.0.tgz", + "integrity": "sha512-QwXc4hb/px3XvSPS2CPAOgey8nyrQFxgxrmXQQ+pN+P51hKdAcchxpg8rSbjANFfPu6VKXhDqkVDucXMZ9CM5g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", diff --git a/package.json b/package.json index 91a7941..a429400 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "astro": "astro" }, "dependencies": { + "@fontsource/geist-mono": "^5.3.0", + "@fontsource/instrument-sans": "^5.3.0", "astro": "^6.1.9" } -} \ No newline at end of file +} diff --git a/public/src/img/DanGrubbLogoDark.png b/public/src/img/DanGrubbLogoDark.png new file mode 100644 index 0000000000000000000000000000000000000000..a540524b259e99dd83baf9f2d6e141db0941d8c0 GIT binary patch literal 5041 zcmZ`+2_RJa_rGJ`2_dAhlWk@!jY)PfjeQ%03}(iJF=p&b7*g4iJ(Q)0EG4u^BFR#+ zgd`$FDJn~n@Vk2N_1^pafB%_#?m73I&spyG+&kYh=d3MFxj95R007`dn;F{z0Hk@B zAdH}<=oGyWyoe+j8CjFOyZ}JqLiB|rX6^do9VhI{O82rJHnZunKK#nWMBI08lC|Uz zM-3{^^#`+sXojG$XGrv69^2U*altT-^mqnc^pO=KOed-5&9V@ zvBBjJ3$SWTXuar8$m^|1ioIclqs9iZdrOe^U*DZ7+$nVqa|{3D;*- z?y(XuwYRtyCHdMjdqs#?W9zjTxh%}G@}mdwl9VQO&P3UO&c>{nWfPYwQiD7ZmeJX~f zb~Wwc7(mcN-w18!+5>Cgc6ANa!1LZ6haA1k^(xD)+saNsFm_%2N4YpGigWcwV}$SL znN>)aHMUZ0YKrfykYeP-%tZ9WgRRK&iAmm)66(E>olQfq8&EGZTMGb)kOP3&1OWH} zs$$;*Krjpd7CZm|l?MRAl)@$(9k68xyc3#$Mjr&&Kol3i2`~UWpmZ4|0Kx=7K-LVN zApejVz?Sbey&5EbP~OGtk|>1v7rzC{Vt>j_psesGh5?k}5C(u70N?^mFb0mO4cB> zg$e5dhmi0gxdYev^Fg0S5%x|&wzyCzg&II0`Qf2K;S@ad*NcQqEwekGCN9bKb6!xJ z{=BUc0^GmENMn*g3)neX;`E zxVm{+5{2TsoA|F1bR|c_v>Ez7MerWzeAK7OQQh6C-6?YJn?hLIjfl=g_DQ<<0pm}H z1Z?3e3xNBVeLI90QCsfDj{FdMv=BKqmhidR@)h-pE8*CSgbmr&$b^5*o1I4Xd6)sAo43Ajl8u zN+iT=lmHS|(~61lZ+lyp6lJBNw7e60Y!nw4q}lMbHRh)u?9KbW_-tj;QT}8wzXSFw zI_?{C-~JJ?ReVe||HfBt*EjaxM2dfPxE0+~k8S#ZyPHvESHy6in5AUfx+2k$7N|)PAp#{|}s61eIt}8lw48@j*wTiEunXX z{fzxG1!s*mQGw1&W{&D2hqcSKxV4R^FS1Fq3<#R;a0Z&~dwSn9X~LMF@uo2nzDP4y ze8ruHGF7O_N+9-2v|O7$j})p-y0UpNaPa71-LI;x9as0fED>$15~4U~IN6OM`x)nz z(p30cfjb_m3`#~H^&hc$uju%kqg;$T+pnJ>Hnoy(jL2m1nT7a$y}uIp7OOwR_Euhu z&xJE8t_?{PnR#y{@51lY$YF9hJbW&p(wmCM?>N7Ft-!@DP_#Uh2BIal~u#olZR(K zfAF*pv6pj}qg~pOeEL;fiV-cYlyK7Od-2B_`fj1-2C>z#(j)91Il)h5Uv3V~rlZLE zAK;Q9BeStlzN4dr1Fn~!r|choxkN@U6?HN-=4WPA#+j8uLlUMVnD9y|&rQcPPrtGD z92k>TGpjqCRw_ew@Tt6Qav+!9f2g3~xJjMM=`-il9zQXM%O8%+Q%@67b^m;)?~G7_ zPeOq0+C?R&%X`yrch0e3WeAq1GFBx*&bJ;mOB;H;c@vo~#+yk@&t7tyq54K^r~fn? zo6|UNStFQ5c54)Oo4%%yRIBm2d$BuF=fiFFvmQtE!fFSijAxX!T=0nw%_G;DdX*yX znYL&0G?me7yOc5RS?163Ppn+LHHSUxk9u0x)I=O_^}%=*cw2Yw+u{f??9cW4vUJ}f z5&7VH*{9@fjeG{LFK$F{g_w^{jOcdD9o zu$$nnBpYPn6y%Ky!lS&Xc#!Sxp;{_%m&S`ZHWLHw^InkaSTA4x>KYxqGQ(On;W7>R%~Bn8o|@l-p| z%MTAAm7%)_|Dvuuh}!cTWfb5SK((Xbyzl^=w}1f+8lnF%`cnePcybWUD1b~L5re6? zAW{GsP*qhuxm@B6VkLiLjlm_b2h03lqr~E!x*H%^;5XO=EYONTpy7i61Oj^BG;MEHjkcIPX>-ypOooTzmTU>Ohq6?`+OSo<>yb02*m`fk|W{0I%ka1^*TRV!IG< zut9kRP{OGsqE8TD9qj2x@`4%%_~S_A+ZW)+L73EEFmF7~i%O#W>eH``p1~x)AQciB zO0zXJG$$GefaqUKy&KdYM-C?7yn=$McGq!hw$}-VfMyJ??=v`pY$# z>`M*^CI5TS#LCF;K3k9gs--=ouQU{#(^&G*#s>CbV~Yg_gAEE?iH)vzSJ}jIX|3bt zX>&F@bd0bYVue#GT)?lj^2S1eR8`Jg+^oe-|Djsnk!t69LT|q2-@>=5>N{=U5X&r! zwi>2P5`D!jk>nkT=lAT=a_9rdZ<*R9Zf{3*De=rNvrb>&*OD|7m554hyvgEu>PxW4 zgb&6RJ3ZRgrebO*Y!l#|>20Vsn8lItqmg#c)*yo~TED*kn|P_!A%6@LiP?JKNeW-I zPUg^ek!fe76N;Mdvdp0mHyeA$x0Op5H@?Yg%|^|*#9+`|+ zIIEO$L=Pj%z1=NhF_hhdidxqQ$}nEMY|+)3wP#v(tEb?$+EiC$Z{NG4bQumgA;qy2 z$eOd{kF(R{_~xS*E#8l&@0}h#-@TT-B55LE9xfRXEX0PIe`Fgkgt8LTYtQ)xB}w1S zQM{hJ#5mjKYL@i%BI($JH>M2jF<>Oteep1Je9pt2NkpA+X+0# zFn_qV5-P@I&-{vV&h=z##T`|%E1Z6Kcj9Q65`uW+iJsQD2YP$|T~?T0DiI$uWq0}X zl2IGtTZXuZf6fO^@#N*tgA%P}fW0tsFa3psnw`yCBro^HCcst4$r`E3nT)LzaXsQ> zizJp^nsQ|e!L~gsizL>waw?_8`tGTCklg5T!YPnAtryT*a?`$Vx|Y!td(sG(%1TjI z9)>w+BC~TAF0!sL*mrEUPl!>p+iLg7XZPjN~Niv2NcN{Mc6c({-&% ze~UrGZsXNJ+(Hb`_1xkO8%iZhFyoFFC4yau#_i|9DCWettKVN-$x?O7X$?am6?26+ z6hCaJWqEl57|WeUyY|HF>iU5stmqc^9+op!l^mMZ}{FJP5Y5_rAR0Xl8*8 z7OQF~+j7U$G5z?0KF4wLu+ZU)m{K1Yr0~O{*nGAp@3^v581Kt7uZ8whbPU$9N${j( zIr6h*aK9bliY;K0;1?-#t=3lHv`QJx*qE8SN*IkYKD^P-dBg{W{_oM*({O!Bf!3GMRI2?{tT- zj~SH|Dztl(sJu1<5Xfv<6EnpkZL)4YbZO}urf#s}N;tuh#w2)<9OqM3jh?^DIn<|l z=u471Bns8nX7*x#ou{U$zI$NK`2qG0R9(>ifkSd-x>a{KLVUa@`S=b#8Z_OkP`f(! zKJWmV`TP-FuMO|?^9T5cHKg=H9_3B{RGl-~7A?`=lip>2Uzy^nLQ3r&=8kwI={#)D z^CcCf9~1UrY_X`JG&h$L*xJ}MQIgwCftWXz1}Y5hRSnRDR8sSitbHZRwL@a*Nlo?X zwGHzsdv$yJ^{iot!j^6SA-bmWN@rKo+FuCUDUEqcVpiL}|YYjc< F{{g4CM?(Mr literal 0 HcmV?d00001 diff --git a/src/pages/index.astro b/src/pages/index.astro index 3bf406a..03bfa68 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,182 +1,97 @@ --- -import CrushBase from '../layouts/CrushBase.astro'; +import '@fontsource/instrument-sans/latin-400.css'; +import '@fontsource/instrument-sans/latin-500.css'; +import '@fontsource/geist-mono/latin-500.css'; +import '../styles/radiance.css'; + +const nav = [ + { label: 'Intelligence Platform', href: 'https://ai.dangrubb.net' }, + { label: 'Exclusive Music', href: '/music' }, + { label: 'Multimedia Entertainment', href: 'https://pi.dangrubb.net/jellyfin' }, + { label: 'Crypto', href: '#' }, + { label: 'Storage Solutions', href: 'https://pi.dangrubb.net/nextcloud' }, +]; +const links = [ + { label: 'Blog', href: '/blog' }, + { label: 'CV', href: '/cv' }, + { label: 'Git', href: 'https://pi.dangrubb.net/dangit' }, +]; --- - -
-
-
- dangrubb.net + + + + + + dangrubb.net + + + + + + + + +
+ + +
-
- -
- DanGrubb -
+
+ +

Welcome to dangrubb.net

- -
- + + + diff --git a/src/scripts/orb.js b/src/scripts/orb.js new file mode 100644 index 0000000..a3ca514 --- /dev/null +++ b/src/scripts/orb.js @@ -0,0 +1,328 @@ +// Radiance orb: a glassy sphere of particles with a sparkling corona. +// Raw WebGL (no libraries) so the home page stays tiny and loads fast. + +const VERT_PARTICLES = ` +precision highp float; +attribute vec3 aDir; +attribute vec4 aRand; +uniform vec2 uRes; +uniform vec2 uCenter; +uniform float uR; +uniform float uTime; +uniform float uDpr; +uniform vec2 uTilt; +uniform float uMode; // 0 = inner particles, 1 = corona +varying vec4 vColor; +varying float vGlow; + +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); +} + +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); + + 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)); + 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; + 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; + } else { + // ---- corona: sparks thrown off the limb, laid out in screen space; strongest over the top + vec2 cd = d.xy; + 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; + } +} +`; + +const FRAG_PARTICLES = ` +precision mediump float; +varying vec4 vColor; +varying float vGlow; +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; + gl_FragColor = 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; +uniform float uTime; + +void main() { + vec2 frag = vec2(gl_FragCoord.x, uRes.y - gl_FragCoord.y); + vec2 d = (frag - uCenter) / uR; + float r = length(d); + + // 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); + gl_FragColor = shell + halo * (1.0 - shell.a); +} +`; + +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)); + return p; +} +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 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 }; +} + +export function startOrb(canvas, { onReady } = {}) { + const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false, powerPreference: 'low-power' }); + if (!gl) return false; + + const reduced = matchMedia('(prefers-reduced-motion: reduce)').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 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])), + }; + + 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.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.clearColor(0, 0, 0, 0); + + 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); + } + 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 }); + + 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); + } + + const t0 = performance.now(); + let ready = false; + function frame(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; + + gl.clear(gl.COLOR_BUFFER_BIT); + + // 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); + + // 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); + + // 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); + + if (!ready) { ready = true; onReady && onReady(); } + if (!reduced) requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + return true; +} diff --git a/src/scripts/ui.js b/src/scripts/ui.js new file mode 100644 index 0000000..5f9c7c2 --- /dev/null +++ b/src/scripts/ui.js @@ -0,0 +1,80 @@ +// Loader, title reveal and menu behaviour for the home page. + +export function initUI(startOrb) { + const body = document.body; + const loader = document.getElementById('loader'); + const count = document.getElementById('loader-count'); + const title = document.getElementById('title'); + const canvas = document.getElementById('orb'); + const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; + + // --- title: split into letters (each wrapped per word so lines break cleanly) + const text = title.textContent.trim(); + title.setAttribute('aria-label', text); + title.textContent = ''; + let n = 0; + text.split(' ').forEach((word, wi, words) => { + const w = document.createElement('span'); + w.className = 'word'; + w.setAttribute('aria-hidden', 'true'); + for (const ch of word) { + const mask = document.createElement('span'); + mask.className = 'ch'; + const inner = document.createElement('span'); + inner.textContent = ch; + inner.style.transitionDelay = `${0.04 * n++}s`; + mask.appendChild(inner); + w.appendChild(mask); + } + title.appendChild(w); + if (wi < words.length - 1) title.appendChild(document.createTextNode(' ')); + }); + + // --- loader: counts up while fonts and the first orb frame get ready + let orbReady = false; + let fontsReady = false; + let shown = 0; + const started = performance.now(); + const minimum = reduced ? 0 : 1100; + + const webgl = startOrb(canvas, { onReady: () => { orbReady = true; } }); + if (!webgl) { document.documentElement.classList.add('no-webgl'); orbReady = true; } + (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => { fontsReady = true; }); + + function tick(now) { + const elapsed = now - started; + const real = (orbReady ? 0.5 : 0.1) + (fontsReady ? 0.5 : 0.15); + const timed = Math.min(1, elapsed / (minimum || 1)); + const target = Math.min(real, timed) * 100; + shown += (target - shown) * 0.2; + if (target - shown < 0.5) shown = target; + count.textContent = String(Math.round(shown)).padStart(3, '0') + '%'; + if (shown >= 99.5 && orbReady && fontsReady && elapsed >= minimum) return done(); + requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + + function done() { + count.textContent = '100%'; + body.classList.remove('is-loading'); + body.classList.add('is-ready'); + setTimeout(() => loader.remove(), 900); + } + + // --- menu + const menu = document.getElementById('menu'); + const btn = document.getElementById('menu-btn'); + const panel = document.getElementById('menu-panel'); + + function setOpen(open) { + menu.classList.toggle('is-open', open); + btn.setAttribute('aria-expanded', String(open)); + panel.toggleAttribute('inert', !open); + } + setOpen(false); + btn.addEventListener('click', () => setOpen(!menu.classList.contains('is-open'))); + addEventListener('keydown', (e) => { if (e.key === 'Escape') { setOpen(false); btn.focus(); } }); + document.addEventListener('pointerdown', (e) => { + if (menu.classList.contains('is-open') && !menu.contains(e.target)) setOpen(false); + }); +} diff --git a/src/styles/radiance.css b/src/styles/radiance.css new file mode 100644 index 0000000..3b56ae1 --- /dev/null +++ b/src/styles/radiance.css @@ -0,0 +1,195 @@ +/* Radiance home page. Sizes are written in --u units (1u = 1px on a 1600px-wide design). */ + +:root { + --ink: 9 11 17; + --u: clamp(0.5px, 0.0625vw, 1.25px); + --ease: cubic-bezier(0.33, 0, 0.2, 1); +} + +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + overflow: hidden; + background: #fff6f4; + color: rgb(var(--ink) / 0.8); + font-family: 'Instrument Sans', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} +a { color: inherit; text-decoration: none; } +ul { list-style: none; margin: 0; padding: 0; } +button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; } +img { display: block; } +.sr-only { + position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; + overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; +} +:focus-visible { outline: 2px solid rgb(var(--ink) / 0.7); outline-offset: 2px; } + +/* ---------- hero ---------- */ +.hero { + position: fixed; inset: 0; + background: + radial-gradient(115% 80% at 50% 40%, transparent 45%, rgb(251 188 166 / 0.65) 100%), + linear-gradient(180deg, + #fbddb7 0%, #f9e6bc 14%, #f9ebbc 30%, #f9d9b3 52%, + #f7cfac 62%, #f3c19e 70%, #eeae8a 79%, #ee9972 87%, #eb8b63 94%, #ef815c 100%); +} +.hero__orb { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } + +.hero__title { + position: absolute; left: 0; right: 0; top: 24%; + margin: 0; padding: 0 24px; + text-align: center; + font-weight: 400; + font-size: clamp(30px, 3.34vw, 64px); + line-height: 1; + letter-spacing: -0.03em; + color: rgb(var(--ink) / 0.8); + pointer-events: none; +} +.word { display: inline-block; white-space: nowrap; } +.ch { display: inline-block; overflow: hidden; vertical-align: top; padding-bottom: 0.12em; margin-bottom: -0.12em; } +.ch > span { + display: inline-block; + transform: translateY(110%); + transition: transform 0.9s var(--ease); +} +.is-ready .ch > span { transform: none; } + +/* fallback when WebGL is unavailable: a flat glowing sphere */ +.no-webgl .hero::after { + content: ''; position: absolute; left: 50%; top: 52%; + width: 52.5vw; max-width: 1100px; aspect-ratio: 1; transform: translateX(-50%); + border-radius: 50%; + background: radial-gradient(circle at 35% 30%, #fdd29a, #fb9a63 60%, #f06a4a); + box-shadow: 0 0 0 2px rgb(255 235 230 / 0.8), 0 0 80px rgb(255 245 220 / 0.7); +} + +/* ---------- top bar ---------- */ +.bar { position: fixed; z-index: 10; inset: 0 0 auto 0; pointer-events: none; } +.bar > * { pointer-events: auto; } +.bar__logo { + position: absolute; left: calc(14 * var(--u)); top: calc(14 * var(--u)); + opacity: 0; transition: opacity 0.8s var(--ease) 0.2s; +} +.bar__logo img { width: calc(80 * var(--u)); height: calc(80 * var(--u)); min-width: 48px; min-height: 48px; } +.is-ready .bar__logo { opacity: 1; } + +/* ---------- menu ---------- */ +.menu { + position: absolute; right: calc(20 * var(--u)); top: calc(30 * var(--u)); + opacity: 0; transition: opacity 0.8s var(--ease) 0.3s; +} +.is-ready .menu { opacity: 1; } +.menu__btn { + position: relative; z-index: 2; + display: flex; align-items: center; justify-content: center; + width: 40px; height: 40px; + background: #fff; +} +@media (min-width: 768px) { .menu__btn { width: calc(64 * var(--u)); height: calc(64 * var(--u)); min-width: 40px; min-height: 40px; } } +.menu__icon { position: absolute; transition: opacity 0.25s, transform 0.4s var(--ease); } +.menu__icon--close { opacity: 0; transform: rotate(-45deg); } +.is-open .menu__icon--open { opacity: 0; transform: rotate(45deg); } +.is-open .menu__icon--close { opacity: 1; transform: none; } + +.menu__panel { + position: absolute; top: 0; right: 0; z-index: 1; + background: #fff; + width: max-content; max-width: calc(100vw - 24px); + padding: calc(50 * var(--u)) calc(20 * var(--u)) calc(20 * var(--u)); + clip-path: inset(0 0 100% 0); + visibility: hidden; + transition: clip-path 0.5s var(--ease), visibility 0s 0.5s; +} +.is-open .menu__panel { clip-path: inset(0); visibility: visible; transition: clip-path 0.5s var(--ease); } +@media (max-width: 767px) { .menu__panel { width: calc(100vw - 24px); padding: 64px 16px 16px; } } + +.menu__list li { border-bottom: 1px solid rgb(var(--ink) / 0.15); } +.menu__list a { + display: flex; align-items: baseline; + padding: calc(6.5 * var(--u)) calc(60 * var(--u)) calc(6.5 * var(--u)) 0; + font-weight: 500; font-size: calc(18 * var(--u)); line-height: 1.2; white-space: nowrap; + transition: opacity 0.3s; +} +.menu__num { + margin-right: calc(16 * var(--u)); + font-family: 'Geist Mono', ui-monospace, monospace; font-weight: 500; + font-size: calc(14 * var(--u)); text-transform: uppercase; letter-spacing: 0.02em; +} +.menu__list:hover a:not(:hover), .menu__list:focus-within a:not(:focus-visible):not(:hover) { opacity: 0.45; } +.menu__list li, .menu__links { transform: translateY(8px); opacity: 0; transition: transform 0.5s var(--ease), opacity 0.4s; } +.is-open .menu__list li, .is-open .menu__links { transform: none; opacity: 1; } +.is-open .menu__list li:nth-child(1) { transition-delay: 0.10s; } +.is-open .menu__list li:nth-child(2) { transition-delay: 0.14s; } +.is-open .menu__list li:nth-child(3) { transition-delay: 0.18s; } +.is-open .menu__list li:nth-child(4) { transition-delay: 0.22s; } +.is-open .menu__list li:nth-child(5) { transition-delay: 0.26s; } +.is-open .menu__links { transition-delay: 0.32s; } + +.menu__links { display: flex; gap: calc(24 * var(--u)); padding-top: calc(39 * var(--u)); } +.menu__link { + position: relative; display: inline-block; overflow: hidden; + font-family: 'Geist Mono', ui-monospace, monospace; font-weight: 500; + font-size: calc(14 * var(--u)); line-height: 1; text-transform: uppercase; letter-spacing: 0.02em; +} +.menu__link::after { + content: ''; position: absolute; left: 0; bottom: 0; width: 100%; height: 1px; + background: rgb(var(--ink) / 0.3); transform: translateX(-101%); transition: transform 0.4s; +} +.menu__link:hover::after, .menu__link:focus-visible::after { transform: none; } +@media (max-width: 767px) { + .menu__list a { padding: 10px 40px 10px 0; font-size: 18px; } + .menu__num { font-size: 12px; margin-right: 14px; } + .menu__links { gap: 20px; padding-top: 28px; } + .menu__link { font-size: 12px; } + .bar__logo { left: 12px; top: 12px; } + .bar__logo img { width: 44px; height: 44px; } + .menu { right: 12px; top: 12px; } +} + +/* ---------- loader ---------- */ +.loader { + position: fixed; inset: 0; z-index: 1000; + display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 32px; + background: #fff6f4; + transition: opacity 0.7s var(--ease) 0.1s, visibility 0s 0.8s; +} +.is-ready .loader { opacity: 0; visibility: hidden; } +.loader__stage { flex-grow: 1; display: flex; align-items: center; } +.scene { margin: 80px; width: 140px; height: 140px; perspective: 400px; transform: translateZ(0); } +.cube { + position: relative; width: 140px; height: 140px; + transform-style: preserve-3d; transform: translateZ(-100px); + animation: spin 3s var(--ease) infinite; +} +.cube__face { + position: absolute; width: 140px; height: 140px; + background: rgb(var(--ink)); border: 2px solid rgb(var(--ink)); + display: flex; align-items: center; justify-content: center; +} +.cube__face img { width: 100%; height: 100%; padding: 14px; } +.cube__face--front { transform: rotateY(0) translateZ(70px); } +.cube__face--right { transform: rotateY(90deg) translateZ(70px); } +.cube__face--back { transform: rotateY(180deg) translateZ(70px); } +.cube__face--left { transform: rotateY(-90deg) translateZ(70px); } +.cube__face--top { transform: rotateX(90deg) translateZ(70px); } +.cube__face--bottom { transform: rotateX(-90deg) translateZ(70px); } +.is-ready .cube { animation: none; } +@keyframes spin { + 0% { transform: translateZ(-100px) rotateY(0); } + 40%, 50% { transform: translateZ(-100px) rotateY(-180deg); } + 90%, 100% { transform: translateZ(-100px) rotateY(-360deg); } +} +.loader__count { + margin: 0; flex-shrink: 0; + font-family: 'Geist Mono', ui-monospace, monospace; font-weight: 500; + font-size: 16px; line-height: 1; text-transform: uppercase; color: rgb(var(--ink) / 0.8); +} + +@media (prefers-reduced-motion: reduce) { + .cube { animation: none; } + .ch > span { transform: none; transition: none; } + * { transition-duration: 0.01s !important; } +} From 20d848623f0327dcb1862b7eefb9b73e27cc7304 Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Thu, 22 Oct 2026 20:10:52 -0400 Subject: [PATCH 02/14] radiance: Private Search menu item (search.dangrubb.net); restyle blog with radiance colors and fonts --- src/layouts/RadianceBase.astro | 42 +++++++++ src/pages/blog/index.astro | 164 ++++++++++++--------------------- src/pages/index.astro | 2 +- 3 files changed, 104 insertions(+), 104 deletions(-) create mode 100644 src/layouts/RadianceBase.astro diff --git a/src/layouts/RadianceBase.astro b/src/layouts/RadianceBase.astro new file mode 100644 index 0000000..aa96a61 --- /dev/null +++ b/src/layouts/RadianceBase.astro @@ -0,0 +1,42 @@ +--- +import '@fontsource/instrument-sans/latin-400.css'; +import '@fontsource/instrument-sans/latin-500.css'; +import '@fontsource/geist-mono/latin-500.css'; + +const { title } = Astro.props; +--- + + + + + + + {title} + + + + + + + + + diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 22f3fee..f4ddee0 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -1,18 +1,17 @@ --- -import CrushBase from '../../layouts/CrushBase.astro'; +import RadianceBase from '../../layouts/RadianceBase.astro'; import posts from '../../data/posts.json'; --- - +
- -

- dangrubb.net/blog -

- + +

Blog

+
@@ -20,7 +19,7 @@ import posts from '../../data/posts.json';

{post.title}

@@ -31,65 +30,49 @@ import posts from '../../data/posts.json';
- + diff --git a/src/pages/index.astro b/src/pages/index.astro index 03bfa68..a20b8fd 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -8,7 +8,7 @@ const nav = [ { label: 'Intelligence Platform', href: 'https://ai.dangrubb.net' }, { label: 'Exclusive Music', href: '/music' }, { label: 'Multimedia Entertainment', href: 'https://pi.dangrubb.net/jellyfin' }, - { label: 'Crypto', href: '#' }, + { label: 'Private Search', href: 'https://search.dangrubb.net' }, { label: 'Storage Solutions', href: 'https://pi.dangrubb.net/nextcloud' }, ]; const links = [ From 0cd5d4c27cec85fd336d8d4eef0c46fb04ba2018 Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Thu, 22 Oct 2026 20:23:09 -0400 Subject: [PATCH 03/14] radiance: pointer/touch-reactive particles 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. --- src/scripts/orb.js | 841 +++++++++++++++++++++++++++++++--------- src/styles/radiance.css | 1 + 2 files changed, 667 insertions(+), 175 deletions(-) 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%), From d4bfc88be78ba3952b5fabb3ba21a3b5a88d1f3f Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Thu, 22 Oct 2026 20:29:47 -0400 Subject: [PATCH 04/14] radiance: match reference colors (asymmetric pink sky, saturated orange base, translucent pink-rimmed shell); ambient curl-noise shimmer --- src/scripts/orb.js | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/scripts/orb.js b/src/scripts/orb.js index a8520c0..489ddc0 100644 --- a/src/scripts/orb.js +++ b/src/scripts/orb.js @@ -93,7 +93,7 @@ P rest(uint idx) { 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.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; @@ -291,11 +291,18 @@ void main() { vec2 f = texture(uFlow, uv).xy; // texels per second vec2 flow = vec2(f.x / uSim.x * uView.x, f.y / uSim.y * uView.y) / uR; // radii per second + // ambient shimmer: a slow curl-noise wind keeps the particles streaming + vec3 np = vec3(p.xy * 2.4 + vec2(0.0, uTime * 0.05), uTime * 0.16); + float e = 0.03; + float 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 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); + 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); @@ -334,10 +341,10 @@ void main() { 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); + 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.6 + p.r.y * 1.6 + p.n * 1.0, 3.2 + p.r.w * 2.6, 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; @@ -402,16 +409,16 @@ vec3 ramp(float y) { 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); + const vec3 c9 = vec3(0.937, 0.53, 0.385); 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)); + c = mix(c, c6, smoothstep(0.70, 0.82, y)); + c = mix(c, c7, smoothstep(0.82, 0.90, y)); + c = mix(c, c8, smoothstep(0.90, 0.96, y)); + c = mix(c, c9, smoothstep(0.96, 1.00, y)); return c; } @@ -425,9 +432,11 @@ void main() { 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 top = 1.0 - smoothstep(0.45, 0.9, 1.0 - uv.y); // fades out toward the orange bottom + float right = smoothstep(0.55, 1.0, uv.x) * (0.55 + 0.45 * smoothstep(0.0, 0.5, uv.y)); + col = mix(col, vec3(0.984, 0.78, 0.67), clamp(right * 0.95, 0.0, 1.0) * top); + float left = (1.0 - smoothstep(0.0, 0.3, uv.x)) * smoothstep(0.3, 0.95, uv.y); + col = mix(col, vec3(0.97, 0.885, 0.80), left * 0.35 * top); float wash = 0.4 * (1.0 - exp(-dye * 1.4)); col = mix(col, vec3(0.98, 0.93, 0.89), wash); @@ -459,16 +468,16 @@ void main() { 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); + vec3 top = vec3(1.0, 0.77, 0.56); + vec3 bot = vec3(1.0, 0.56, 0.42); 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); + float fres = pow(1.0 - z, 1.8); + vec3 rim = vec3(1.0, 0.85, 0.83); vec3 col = mix(body, rim, clamp(fres * 1.15, 0.0, 1.0)); - float a = mix(0.7, 0.97, fres); + float a = mix(0.58, 0.97, fres); // crisp bright edge line float ring = smoothstep(0.955, 0.985, r) * (1.0 - smoothstep(0.985, 1.004, r)); From 384cb7f7b8debadb2b6d2d55927f2a4d2646bb91 Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 09:22:12 -0400 Subject: [PATCH 05/14] =?UTF-8?q?radiance:=203D=20particle=20sim=20?= =?UTF-8?q?=E2=80=94=20curl-noise=20currents,=20ribbon=20density,=20depth?= =?UTF-8?q?=20scatter=20on=20stir,=20rolling=20respawn;=20~100k=20particle?= =?UTF-8?q?s?= 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); From a900c5f750b5a876266a16c818f7bf62995ee062 Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 09:29:07 -0400 Subject: [PATCH 06/14] radiance: shaded coral body + deeper right rim, red mass weighted to lower latitudes, bigger flakes, crisper haloed glints and corona --- src/scripts/orb.js | 50 +++++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/src/scripts/orb.js b/src/scripts/orb.js index 0c80ce5..7725771 100644 --- a/src/scripts/orb.js +++ b/src/scripts/orb.js @@ -104,7 +104,12 @@ float layerR(uint idx) { 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); + float ridge = 1.0 - abs(n - 0.48) * 4.2; + // plus broad swells, so red gathers in big masses and not only thin ribbons + float swell = fbm(dir * 0.9 + vec3(0.0, t * 0.03, 7.3)); + float d = clamp(max(ridge, (swell - 0.4) * 3.2), 0.0, 1.0); + // the red gathers toward the middle latitudes (the lower part of what the camera sees), thinning over the crown + return d * mix(0.6, 1.0, 1.0 - smoothstep(0.15, 0.85, dir.y)); } // where a particle is reborn: best of four candidates against the density field vec3 spawnDir(uint idx, float epoch, float t) { @@ -146,7 +151,7 @@ P corona(uint idx) { 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); + float reach = pow(o.r.x, 1.3) * (0.07 + 0.5 * 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; @@ -430,7 +435,7 @@ void main() { 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 spark = step(0.86, h01(idx * 8u + 6u)); float rt = layerR(idx); float lt = lifeT(idx, t); vec3 pos = s0.xyz; @@ -442,12 +447,12 @@ void main() { 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); + vec3 warm = mix(vec3(0.88, 0.17, 0.15), vec3(1.0, 0.45, 0.28), r.w * r.w); + warm = mix(warm, vec3(1.0, 0.70, 0.40), smoothstep(0.25, 0.7, 1.0 - n) * 0.85); + col = vec4(mix(warm, vec3(1.0, 0.99, 0.92), spark), 0.95); + size = mix(1.5 + r.y * 1.5 + n * 1.3, 2.8 + r.w * 2.4, 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); + col.a *= mix(1.0, mix(0.4, 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) @@ -465,10 +470,13 @@ void main() { 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); - vGlow = 0.5; + col = vec4(1.0, 0.98, 0.88, (0.45 + 0.55 * p.fade) * (0.5 + 0.5 * smoothstep(0.2, 0.75, p.n))); + col.a *= mix(0.55, 1.0, tw); + // a minority of sparks are bright, haloed points; the rest stay faint dust + float lead = step(0.7, p.r.x); + col.a *= mix(0.7, 1.0, lead); + size = (1.0 + p.r.y * p.r.y * 2.8 * (0.3 + p.fade)) * mix(0.9, 1.5, lead); + vGlow = mix(0.4, 1.0, lead); // 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; @@ -491,7 +499,7 @@ 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 halo = vGlow * smoothstep(0.5, 0.0, r) * 0.55; float a = vColor.a * clamp(core + halo, 0.0, 1.0); if (a < 0.01) discard; o = vec4(vColor.rgb * a, a); @@ -575,17 +583,19 @@ void main() { 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(1.0, 0.77, 0.56); - vec3 bot = vec3(1.0, 0.56, 0.42); - float lit = clamp(0.5 + 0.5 * dot(normalize(n), normalize(vec3(-0.45, 0.6, 0.65))), 0.0, 1.0); + // shaded body: peach at the top-left deepening to saturated coral toward the bottom and right + vec3 top = vec3(1.0, 0.80, 0.58); + vec3 bot = vec3(1.0, 0.45, 0.33); + float lit = clamp(0.5 + 0.5 * dot(normalize(n), normalize(vec3(-0.5, 0.55, 0.65))), 0.0, 1.0); + lit = pow(lit, 1.6); vec3 body = mix(bot, top, lit); + body = mix(body, vec3(1.0, 0.33, 0.28), smoothstep(-0.3, 0.25, d.y) * 0.45); // the lower visible band runs red - // fresnel rim, pale pink-white + // fresnel rim: pale on the left, deeper pink on the right float fres = pow(1.0 - z, 1.8); - vec3 rim = vec3(1.0, 0.85, 0.83); + vec3 rim = mix(vec3(1.0, 0.86, 0.84), vec3(1.0, 0.52, 0.55), smoothstep(-0.2, 0.8, d.x)); vec3 col = mix(body, rim, clamp(fres * 1.15, 0.0, 1.0)); - float a = mix(0.58, 0.97, fres); + float a = mix(0.72, 0.97, fres); // crisp bright edge line float ring = smoothstep(0.955, 0.985, r) * (1.0 - smoothstep(0.985, 1.004, r)); From aac2bdf2e6bb123f5a4b9653d0888be4570485ad Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 09:32:17 -0400 Subject: [PATCH 07/14] =?UTF-8?q?radiance:=20snow-globe=20containment=20?= =?UTF-8?q?=E2=80=94=20tangential=20cursor=20push,=20glass=20wall,=20radiu?= =?UTF-8?q?s=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scripts/orb.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/scripts/orb.js b/src/scripts/orb.js index 7725771..cc4d029 100644 --- a/src/scripts/orb.js +++ b/src/scripts/orb.js @@ -382,7 +382,11 @@ void main() { 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; + vec3 depth = n * (h01(idx * 8u + 7u) - 0.5) * fm * 0.9 * front * (1.0 - surf); + // snow globe: a push is felt along the glass, not through it. Near the limb that slides the particle + // around to the far side instead of out of the ball. + push -= n * dot(push, n); + tumble -= n * dot(tumble, n) * 0.7; // 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)) @@ -394,11 +398,12 @@ void main() { vel += (target - vel) * (1.0 - exp(-uDt * 3.0)); pos += vel * uDt; - // soft restoring pull to its layer + // the glass wall: no outward velocity at the shell, a hard radius cap, and a soft pull back to its layer r = length(pos); n = pos / max(r, 1e-4); + vel -= n * max(dot(vel, n), 0.0) * smoothstep(rt - 0.05, rt + 0.01, r); r = mix(r, rt, 1.0 - exp(-uDt * 1.6)); - pos = n * r; + pos = n * min(r, 0.985); stir = max(stir * exp(-uDt * 1.2), clamp(fm * front * 1.5, 0.0, 1.5)); oPos = vec4(pos, stir); From fcc1326e8635f786b41396baf3ca6d523cbb8e2b Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 09:36:45 -0400 Subject: [PATCH 08/14] radiance: margin inside the glass rim; haloed corona sparks and glints (oversized soft-falloff points) --- src/scripts/orb.js | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/scripts/orb.js b/src/scripts/orb.js index cc4d029..ba88366 100644 --- a/src/scripts/orb.js +++ b/src/scripts/orb.js @@ -98,7 +98,8 @@ float lifeAlpha(float x) { 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); + // both stay clear of the glass at r = 1, leaving a margin around the rim + return k < 0.5 ? mix(0.86, 0.925, u) : mix(0.4, 0.85, u); } // A drifting ridge field: density peaks along thin curved sheets, which cut the sphere as ribbons. float density(vec3 dir, float t) { @@ -372,7 +373,7 @@ void main() { } float r = length(pos); vec3 n = pos / max(r, 1e-4); - float surf = step(0.9, rt); + float surf = step(0.855, rt); // the cursor: the 2D fluid under the particle's screen position, pushed into 3D vec3 bp = bodyToView(pos); @@ -403,7 +404,7 @@ void main() { n = pos / max(r, 1e-4); vel -= n * max(dot(vel, n), 0.0) * smoothstep(rt - 0.05, rt + 0.01, r); r = mix(r, rt, 1.0 - exp(-uDt * 1.6)); - pos = n * min(r, 0.985); + pos = n * min(r, 0.93); stir = max(stir * exp(-uDt * 1.2), clamp(fm * front * 1.5, 0.0, 1.5)); oPos = vec4(pos, stir); @@ -425,6 +426,7 @@ uniform float uR; // css px uniform float uDpr; out vec4 vColor; out float vGlow; +out float vCore; void main() { uint idx = uint(gl_VertexID); @@ -458,17 +460,17 @@ void main() { size = mix(1.5 + r.y * 1.5 + n * 1.3, 2.8 + r.w * 2.4, spark); float tw = 0.5 + 0.5 * sin(t * (1.2 + r.x * 2.5) + r.w * 40.0); col.a *= mix(1.0, mix(0.4, 1.0, tw), spark); - vGlow = spark; + vGlow = spark * 0.5; // 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 *= mix(0.65, 1.0, step(0.855, 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)); + size *= 1.0 + stir * 0.5; + vGlow = max(vGlow, clamp(stir * 0.2, 0.0, 0.25)); } else { P p = corona(idx); vec2 disp = s0.xy; @@ -491,7 +493,10 @@ void main() { 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; + // glowing points are drawn larger so the halo has room around the core + float glowScale = 1.0 + vGlow * 2.4; + gl_PointSize = size * sc * uDpr * persp * glowScale; + vCore = 1.0 / glowScale; vColor = col; } `; @@ -499,12 +504,12 @@ void main() { const FRAG_PARTICLES = `${HEAD} in vec4 vColor; in float vGlow; +in float vCore; // the core's share of the point's width; the rest is room for the halo 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.55; + float rr = length(gl_PointCoord - 0.5) * 2.0; // 0 at the centre, 1 at the edge of the point + float core = 1.0 - smoothstep(0.36 * vCore, vCore, rr); + float halo = vGlow * pow(max(1.0 - rr, 0.0), 2.2) * 0.55; float a = vColor.a * clamp(core + halo, 0.0, 1.0); if (a < 0.01) discard; o = vec4(vColor.rgb * a, a); From cfd081abcd8c501952f42217c0a2e0cbefb48f68 Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 09:41:32 -0400 Subject: [PATCH 09/14] =?UTF-8?q?radiance:=20corona=20sparks=20now=20have?= =?UTF-8?q?=20a=20lifecycle=20=E2=80=94=20born=20on=20the=20rim,=20drift?= =?UTF-8?q?=20outward,=20shrink=20and=20fizzle=20out;=20cursor=20pushes=20?= =?UTF-8?q?carry=20them,=20nothing=20pulls=20them=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scripts/orb.js | 72 +++++++++++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/src/scripts/orb.js b/src/scripts/orb.js index ba88366..652c593 100644 --- a/src/scripts/orb.js +++ b/src/scripts/orb.js @@ -133,32 +133,48 @@ vec3 homeOf(uint idx, float epoch) { 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 +// ---- corona: sparks born on the rim, laid out in screen space, that drift outward and fizzle out. +// Each is reborn at a fresh spot on the rim (favouring tufts over the top) when its life ends. struct P { - vec2 xy; - float n; - float reach; - float fade; + vec2 xy; // where the spark is, before any cursor push + float n; // strength of the tuft it was born in + float life; // 0 at birth .. 1 at death + float fade; // fades in at birth, out toward death vec4 r; }; +float coronaLen(uint idx) { return mix(3.5, 8.5, h01(idx * 8u + 1u)); } +float coronaLT(uint idx, float t) { return t / coronaLen(idx) + h01(idx * 8u + 2u); } + 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)); - float t = uTime; - 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 lt = coronaLT(idx, uTime); + float epoch = floor(lt); + o.life = fract(lt); + float tSpawn = (epoch - o.r.z) * coronaLen(idx); + + // birthplace: best of three rim angles, scored by tuft strength and how far over the top it is + uint base = pcg(idx * 2654435761u + uint(epoch) * 40503u + 91u); + vec2 cd = vec2(0.0, 1.0); + float n = 0.0, up = 0.0, best = -1.0; + for (int i = 0; i < 3; i++) { + float a = h01(base + uint(i) * 374761393u) * 6.2831853; + vec2 c = vec2(cos(a), sin(a)); + float nn = fbm(vec3(c * 4.2, tSpawn * 0.10)); + float u = smoothstep(-0.75, 0.9, c.y); + float score = (0.25 + 0.75 * u) * (0.3 + nn); + if (score > best) { best = score; cd = c; n = nn; up = u; } + } float spike = pow(n, 1.6) * 1.6; - float reach = pow(o.r.x, 1.3) * (0.07 + 0.5 * 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); + float travel = pow(o.r.x, 1.3) * (0.07 + 0.5 * spike) * (0.3 + 0.7 * up) + 0.02; + float ease = 1.0 - pow(1.0 - o.life, 1.35); // a steady drift that slows toward the end + float ang = (o.r.w - 0.5) * 0.08 * o.life; // a little sideways drift + float ca = cos(ang), sa = sin(ang); + vec2 dir = vec2(ca * cd.x - sa * cd.y, sa * cd.x + ca * cd.y); o.n = n; - o.reach = reach; - o.fade = 1.0 - smoothstep(0.0, 0.42, reach); - o.xy = cd * (1.0 + 0.004 + reach); + o.xy = dir * (1.004 + travel * ease); + o.fade = smoothstep(0.0, 0.04, o.life) * (1.0 - smoothstep(0.12, 1.0, o.life)); // brightest at the rim, dissolving with distance return o; } `; @@ -337,23 +353,22 @@ void main() { float t = uTime; if (idx >= uInner) { - // corona: a spring pulls each spark home, the flow and a light wind shake it + // corona: the cursor flow and a light wind carry a spark along; nothing pulls it back, + // it just lives out its life and dies (a rebirth clears the push) P p = corona(idx); vec2 disp = s0.xy; vec2 vel = s1.xy; + if (uInit == 1 || floor(coronaLT(idx, t)) != floor(coronaLT(idx, t - uDt))) { disp = vec2(0.0); vel = vec2(0.0); } 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; + vel += (flow * uGain * 1.8 + wind * 0.05 * uAmb - vel) * (1.0 - exp(-uDt * 3.0)); disp += vel * uDt; float m = length(disp); - if (m > 0.9) disp *= 0.9 / m; + if (m > 1.2) disp *= 1.2 / m; oPos = vec4(disp, 0.0, 0.0); oVel = vec4(vel, 0.0, 0.0); return; @@ -474,21 +489,18 @@ void main() { } else { P p = corona(idx); vec2 disp = s0.xy; - float stir = clamp(length(disp) * 1.6 + length(s1.xy) * 0.35, 0.0, 1.5); + float stir = clamp(length(s1.xy) * 0.35, 0.0, 1.0); 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.98, 0.88, (0.45 + 0.55 * p.fade) * (0.5 + 0.5 * smoothstep(0.2, 0.75, p.n))); + col = vec4(1.0, 0.98, 0.88, 0.95 * p.fade * (0.55 + 0.45 * smoothstep(0.2, 0.75, p.n))); col.a *= mix(0.55, 1.0, tw); // a minority of sparks are bright, haloed points; the rest stay faint dust float lead = step(0.7, p.r.x); col.a *= mix(0.7, 1.0, lead); - size = (1.0 + p.r.y * p.r.y * 2.8 * (0.3 + p.fade)) * mix(0.9, 1.5, lead); + size = (1.0 + p.r.y * p.r.y * 2.8) * mix(0.9, 1.5, lead) * (1.0 - 0.5 * p.life); // shrinking as it dies vGlow = mix(0.4, 1.0, lead); - // 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; + size *= 1.0 + stir * 0.5; } vec2 px = uCenter + vec2(vp.x, -vp.y) * uR; From ea3f0612113d7ebeab0f0ede6fae775ff9bc49d3 Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 09:43:29 -0400 Subject: [PATCH 10/14] radiance: 20% fewer particles drawn 20% bigger; stronger cursor reaction inside the orb; idle spin and currents +50% --- src/scripts/orb.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/scripts/orb.js b/src/scripts/orb.js index 652c593..b006f44 100644 --- a/src/scripts/orb.js +++ b/src/scripts/orb.js @@ -130,8 +130,8 @@ 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)); } +vec3 bodyToView(vec3 p) { return rotX(rotY(p, uTime * 0.0675 + 2.4 + uTilt.x), uTilt.y); } +vec3 viewToBody(vec3 v) { return rotY(rotX(v, -uTilt.y), -(uTime * 0.0675 + 2.4 + uTilt.x)); } // ---- corona: sparks born on the rim, laid out in screen space, that drift outward and fizzle out. // Each is reborn at a fresh spot on the rim (favouring tufts over the top) when its life ends. @@ -395,23 +395,23 @@ void main() { 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; + vec3 push = viewToBody(vec3(flow, 0.0)) * uGain * 1.7 * 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 * (1.0 - surf); + vec3 tumble = curlNoise(pos * 4.0 + vec3(0.0, 0.0, t * 0.9)) * fm * 1.1 * front; + vec3 depth = n * (h01(idx * 8u + 7u) - 0.5) * fm * 1.4 * front * (1.0 - surf); // snow globe: a push is felt along the glass, not through it. Near the limb that slides the particle // around to the far side instead of out of the ball. push -= n * dot(push, n); tumble -= n * dot(tumble, n) * 0.7; // 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)); + vec3 amb = curlNoise(pos * 1.35 + vec3(0.0, 0.0, t * 0.525)) + + 0.6 * curlNoise(pos * 3.2 + vec3(t * 0.225, 0.0, t * 1.05)); amb -= n * dot(amb, n) * mix(0.35, 0.8, surf); - amb *= 0.3 * uAmb; + amb *= 0.45 * uAmb; vec3 target = amb + push + tumble + depth + (home - pos) * 0.3; - vel += (target - vel) * (1.0 - exp(-uDt * 3.0)); + vel += (target - vel) * (1.0 - exp(-uDt * 4.5)); pos += vel * uDt; // the glass wall: no outward velocity at the shell, a hard radius cap, and a soft pull back to its layer @@ -507,7 +507,7 @@ void main() { gl_Position = vec4(px.x / uView.x * 2.0 - 1.0, 1.0 - px.y / uView.y * 2.0, 0.0, 1.0); // glowing points are drawn larger so the halo has room around the core float glowScale = 1.0 + vGlow * 2.4; - gl_PointSize = size * sc * uDpr * persp * glowScale; + gl_PointSize = size * 1.2 * sc * uDpr * persp * glowScale; vCore = 1.0 / glowScale; vColor = col; } @@ -716,9 +716,9 @@ export function startOrb(canvas, { onReady } = {}) { const coarse = matchMedia('(pointer: coarse)').matches; const small = Math.min(innerWidth, innerHeight) < 600; - const T = small ? 192 : 320; - const INNER = small ? 26000 : 82000; - const CORONA = small ? 8000 : 18000; + const T = small ? 168 : 288; + const INNER = small ? 20800 : 65600; + const CORONA = small ? 6400 : 14400; const TOTAL = INNER + CORONA; let progs; From 1645ed76caf79efac36d31eac1856db541481c4f Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 09:46:18 -0400 Subject: [PATCH 11/14] radiance: simulation clock x1.5 (currents, spin, lifecycles, fluid) and cursor force +30% --- src/scripts/orb.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/scripts/orb.js b/src/scripts/orb.js index b006f44..2e3aad8 100644 --- a/src/scripts/orb.js +++ b/src/scripts/orb.js @@ -799,7 +799,7 @@ export function startOrb(canvas, { onReady } = {}) { document.documentElement.addEventListener('mouseleave', onLeave); // fluid tuning - const FORCE = 1500; + const FORCE = 1950; const RADIUS = 0.008; const VEL_DISSIPATION = 1.5; const DYE_DISSIPATION = 1.1; @@ -891,6 +891,7 @@ export function startOrb(canvas, { onReady } = {}) { into(dye.write); tri(); dye.swap(); } + const SPEED = 1.5; let simT = reduced ? 8 : 0; let last = performance.now(); let ready = false; @@ -902,13 +903,14 @@ export function startOrb(canvas, { onReady } = {}) { } function frame(now) { - const dt = Math.min((now - last) / 1000, 1 / 30) || 1 / 60; + // everything (currents, spin, lifecycles, the cursor fluid) runs on one sped-up simulation clock + const dt = (Math.min((now - last) / 1000, 1 / 30) || 1 / 60) * SPEED; last = now; // 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; + par.cx += (par.x - par.cx) * 0.04 * SPEED; + par.cy += (par.y - par.cy) * 0.04 * SPEED; const ox = par.cx * 10, oy = par.cy * 6; if (interactive) { From f2f6bf77084f66a6a6d054e5c9cbf4d1fd2a050f Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 10:06:33 -0400 Subject: [PATCH 12/14] =?UTF-8?q?radiance:=20perf=20=E2=80=94=20spawn=20po?= =?UTF-8?q?int=20and=20corona=20birth=20cached=20in=20particle=20state,=20?= =?UTF-8?q?adaptive=20quality=20governor,=20debounced=20resize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scripts/orb.js | 165 ++++++++++++++++++++++++++++++++------------- 1 file changed, 118 insertions(+), 47 deletions(-) diff --git a/src/scripts/orb.js b/src/scripts/orb.js index 2e3aad8..20933ae 100644 --- a/src/scripts/orb.js +++ b/src/scripts/orb.js @@ -125,7 +125,8 @@ vec3 spawnDir(uint idx, float epoch, float t) { } return best; } -// the spawn point of a particle's current life; the flow carries it away, a soft pull brings it back +// the spawn point of a particle's current life; the flow carries it away, a soft pull brings it back. +// Costly (four density evaluations), so it is computed once per life and kept in the particle state. vec3 homeOf(uint idx, float epoch) { float tSpawn = (epoch - h01(idx * 8u + 2u)) * lifeLen(idx); return spawnDir(idx, epoch, tSpawn) * layerR(idx); @@ -146,26 +147,29 @@ struct P { float coronaLen(uint idx) { return mix(3.5, 8.5, h01(idx * 8u + 1u)); } float coronaLT(uint idx, float t) { return t / coronaLen(idx) + h01(idx * 8u + 2u); } -P corona(uint idx) { - P o; - o.r = vec4(h01(idx * 8u), h01(idx * 8u + 1u), h01(idx * 8u + 2u), h01(idx * 8u + 3u)); - float lt = coronaLT(idx, uTime); - float epoch = floor(lt); - o.life = fract(lt); - float tSpawn = (epoch - o.r.z) * coronaLen(idx); - - // birthplace: best of three rim angles, scored by tuft strength and how far over the top it is +// birthplace: best of three rim angles, scored by tuft strength and how far over the top it is. +// Costly, so it is done once per life and kept in the particle state (cd = rim direction, n = tuft strength). +void coronaBirth(uint idx, float epoch, out vec2 cd, out float n) { + float tSpawn = (epoch - h01(idx * 8u + 2u)) * coronaLen(idx); uint base = pcg(idx * 2654435761u + uint(epoch) * 40503u + 91u); - vec2 cd = vec2(0.0, 1.0); - float n = 0.0, up = 0.0, best = -1.0; + cd = vec2(0.0, 1.0); + n = 0.0; + float best = -1.0; for (int i = 0; i < 3; i++) { float a = h01(base + uint(i) * 374761393u) * 6.2831853; vec2 c = vec2(cos(a), sin(a)); float nn = fbm(vec3(c * 4.2, tSpawn * 0.10)); float u = smoothstep(-0.75, 0.9, c.y); float score = (0.25 + 0.75 * u) * (0.3 + nn); - if (score > best) { best = score; cd = c; n = nn; up = u; } + if (score > best) { best = score; cd = c; n = nn; } } +} + +P corona(uint idx, vec2 cd, float n) { + P o; + o.r = vec4(h01(idx * 8u), h01(idx * 8u + 1u), h01(idx * 8u + 2u), h01(idx * 8u + 3u)); + o.life = fract(coronaLT(idx, uTime)); + float up = smoothstep(-0.75, 0.9, cd.y); float spike = pow(n, 1.6) * 1.6; float travel = pow(o.r.x, 1.3) * (0.07 + 0.5 * spike) * (0.3 + 0.7 * up) + 0.02; float ease = 1.0 - pow(1.0 - o.life, 1.35); // a steady drift that slows toward the end @@ -320,8 +324,9 @@ void main() { o = uValue * texture(uTex, vUv); } const FRAG_STATE = `${HEAD} ${COMMON} -uniform sampler2D uPos; // inner: body-space xyz (orb radii), w = stir. corona: xy displacement -uniform sampler2D uVel; // velocity, same layout +uniform sampler2D uPos; // inner: body-space xyz (orb radii), w = stir. corona: xy displacement, zw rim direction +uniform sampler2D uVel; // velocity, w = the life (epoch) the particle is in. corona: z = tuft strength +uniform sampler2D uHome; // inner: spawn point xyz of the current life uniform sampler2D uFlow; uniform int uT; uniform uint uTotal; @@ -335,6 +340,7 @@ 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; +layout(location = 2) out vec4 oHome; // fluid velocity (texels/s) under a view-space point (orb radii, y up) -> orb radii per second vec2 flowAt(vec2 sp) { @@ -347,18 +353,27 @@ vec2 flowAt(vec2 sp) { void main() { ivec2 ip = ivec2(gl_FragCoord.xy); uint idx = uint(ip.y * uT + ip.x); - if (idx >= uTotal) { oPos = vec4(0.0); oVel = vec4(0.0); return; } + if (idx >= uTotal) { oPos = vec4(0.0); oVel = vec4(0.0); oHome = vec4(0.0); return; } vec4 s0 = texelFetch(uPos, ip, 0); vec4 s1 = texelFetch(uVel, ip, 0); + vec4 s2 = texelFetch(uHome, ip, 0); float t = uTime; + oHome = s2; if (idx >= uInner) { // corona: the cursor flow and a light wind carry a spark along; nothing pulls it back, // it just lives out its life and dies (a rebirth clears the push) - P p = corona(idx); vec2 disp = s0.xy; vec2 vel = s1.xy; - if (uInit == 1 || floor(coronaLT(idx, t)) != floor(coronaLT(idx, t - uDt))) { disp = vec2(0.0); vel = vec2(0.0); } + vec2 cd = s0.zw; + float tuft = s1.z; + float ep = floor(coronaLT(idx, t)); + if (uInit == 1 || ep != s1.w) { + coronaBirth(idx, ep, cd, tuft); + disp = vec2(0.0); + vel = vec2(0.0); + } + P p = corona(idx, cd, tuft); 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; @@ -369,8 +384,8 @@ void main() { disp += vel * uDt; float m = length(disp); if (m > 1.2) disp *= 1.2 / m; - oPos = vec4(disp, 0.0, 0.0); - oVel = vec4(vel, 0.0, 0.0); + oPos = vec4(disp, cd); + oVel = vec4(vel, tuft, ep); return; } @@ -380,8 +395,10 @@ void main() { 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))) { + float ep = floor(lt); + vec3 home = s2.xyz; + if (uInit == 1 || ep != s1.w) { + home = homeOf(idx, ep); pos = home; vel = vec3(0.0); stir = 0.0; @@ -423,7 +440,8 @@ void main() { 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); + oVel = vec4(vel, ep); + oHome = vec4(home, 0.0); } `; @@ -433,6 +451,7 @@ const VERT_PARTICLES = `${HEAD} ${COMMON} uniform sampler2D uPos; uniform sampler2D uVel; +uniform sampler2D uHome; uniform int uT; uniform int uStatic; // no float render targets: particles sit at their spawn points uniform vec2 uView; // css px @@ -448,6 +467,7 @@ void main() { ivec2 tc = ivec2(int(idx) % uT, int(idx) / uT); vec4 s0 = texelFetch(uPos, tc, 0); vec4 s1 = texelFetch(uVel, tc, 0); + vec4 s2 = texelFetch(uHome, tc, 0); float t = uTime; float sc = clamp(uR / 315.0, 0.8, 1.7); vec2 vp; @@ -468,7 +488,7 @@ void main() { persp = 1.0 / (1.0 - bp.z * 0.12); vp = bp.xy * persp; - float n = density(normalize(pos + vec3(1e-5)), t); + float n = density(normalize(pos + vec3(1e-5)), t); // live: the field drifts, which gives the colour contrast vec3 warm = mix(vec3(0.88, 0.17, 0.15), vec3(1.0, 0.45, 0.28), r.w * r.w); warm = mix(warm, vec3(1.0, 0.70, 0.40), smoothstep(0.25, 0.7, 1.0 - n) * 0.85); col = vec4(mix(warm, vec3(1.0, 0.99, 0.92), spark), 0.95); @@ -487,7 +507,10 @@ void main() { size *= 1.0 + stir * 0.5; vGlow = max(vGlow, clamp(stir * 0.2, 0.0, 0.25)); } else { - P p = corona(idx); + vec2 cd = s0.zw; + float tuft = s1.z; + if (uStatic == 1) coronaBirth(idx, floor(coronaLT(idx, t)), cd, tuft); + P p = corona(idx, cd, tuft); vec2 disp = s0.xy; float stir = clamp(length(s1.xy) * 0.35, 0.0, 1.0); vp = p.xy + disp; @@ -665,31 +688,26 @@ function texture(gl, w, h, internal, type, filter) { 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; +// count: how many colour attachments are rendered together (tex, tex2, tex3) +function target(gl, w, h, internal, type, filter, count = 1) { + const texs = Array.from({ length: count }, () => texture(gl, w, h, internal, type, filter)); 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]); - } + texs.forEach((tx, i) => gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tx, 0)); + if (count > 1) gl.drawBuffers(texs.map((_, i) => gl.COLOR_ATTACHMENT0 + i)); gl.viewport(0, 0, w, h); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); - return { tex, tex2, fbo, w, h }; + return { tex: texs[0], tex2: texs[1] || null, tex3: texs[2] || null, fbo, w, h }; } function freeTarget(gl, t) { - gl.deleteTexture(t.tex); - if (t.tex2) gl.deleteTexture(t.tex2); + [t.tex, t.tex2, t.tex3].forEach((tx) => tx && gl.deleteTexture(tx)); gl.deleteFramebuffer(t.fbo); } -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)]; +function pingPong(gl, w, h, internal, type, filter, count = 1) { + const pair = [target(gl, w, h, internal, type, filter, count), target(gl, w, h, internal, type, filter, count)]; let i = 0; return { get read() { return pair[i]; }, @@ -748,8 +766,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, 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; + // particle state, three textures per particle: position (+ stir), velocity (+ life number), + // and the spawn point of the current life, so the costly spawn maths runs once per life + const state = interactive ? pingPong(gl, T, T, stateFmt.internal, stateFmt.type, gl.NEAREST, 3) : null; let needInit = true; // fluid fields; rebuilt when the viewport shape changes so the grid stays square-celled @@ -766,10 +785,18 @@ export function startOrb(canvas, { onReady } = {}) { curl = target(gl, simW, simH, fluidFmt.internal, fluidFmt.type, gl.NEAREST); } + // adaptive quality: if frames stay slow, step down resolution and particle count (never back up) + const QUALITY = [ + { dprCap: 2, frac: 1 }, + { dprCap: 1.5, frac: 0.7 }, + { dprCap: 1, frac: 0.45 }, + ]; + let quality = 0, dprCap = 2, drawInner = INNER, drawCorona = CORONA; + // layout let W = 0, H = 0, dpr = 1, cx = 0, cy = 0, R = 0; function resize() { - dpr = Math.min(window.devicePixelRatio || 1, 2); + dpr = Math.min(window.devicePixelRatio || 1, dprCap); W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); const portrait = W < H * 0.85; @@ -779,7 +806,9 @@ export function startOrb(canvas, { onReady } = {}) { initFluid(); } resize(); - addEventListener('resize', resize); + // coalesce resize bursts (window drags, mobile URL bars): each resize reallocates the fluid textures + let resizeTimer = 0; + addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(resize, 120); }); // pointer: mouse, pen and touch all arrive as pointer events const ptr = { x: 0, y: 0, lx: 0, ly: 0, has: false, moved: false }; @@ -902,7 +931,46 @@ export function startOrb(canvas, { onReady } = {}) { gl.uniform1ui(prog.u.uInner, INNER); } + // frame-time watch: after a warm-up, the median of 60 frames over ~24 ms (slower than ~42 fps) drops a level + let warmup = 90; + const frameTimes = []; + function setQuality(level) { + quality = level; + dprCap = QUALITY[level].dprCap; + drawInner = Math.floor(INNER * QUALITY[level].frac); + drawCorona = Math.floor(CORONA * QUALITY[level].frac); + canvas.dataset.quality = String(level); + resize(); + } + function watchFrameTime(raw) { + if (document.hidden || warmup-- > 0) return; + frameTimes.push(raw); + if (frameTimes.length < 60) return; + frameTimes.sort((a, b) => a - b); + const median = frameTimes[30]; + frameTimes.length = 0; + if (median > 24 && quality < QUALITY.length - 1) { setQuality(quality + 1); warmup = 45; } + } + + // the state pass covers only the particles that are still drawn + function stepState() { + into(state.write); + if (quality === 0) { + tri(); + } else { + gl.enable(gl.SCISSOR_TEST); + gl.scissor(0, 0, T, Math.ceil(drawInner / T)); + tri(); + const coronaRow = Math.floor(INNER / T); + gl.scissor(0, coronaRow, T, Math.ceil((INNER + drawCorona) / T) - coronaRow); + tri(); + gl.disable(gl.SCISSOR_TEST); + } + state.swap(); + } + function frame(now) { + watchFrameTime(now - last); // everything (currents, spin, lifecycles, the cursor fluid) runs on one sped-up simulation clock const dt = (Math.min((now - last) / 1000, 1 / 30) || 1 / 60) * SPEED; last = now; @@ -922,7 +990,8 @@ export function startOrb(canvas, { onReady } = {}) { commonUniforms(s, t); 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.uniform1i(s.u.uHome, bind(2, state.read.tex3)); + gl.uniform1i(s.u.uFlow, bind(3, velocity.read.tex)); gl.uniform1f(s.u.uAmb, reduced ? 0 : 1); gl.uniform1i(s.u.uInit, needInit ? 1 : 0); needInit = false; @@ -934,7 +1003,7 @@ export function startOrb(canvas, { onReady } = {}) { 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(); + stepState(); } gl.bindFramebuffer(gl.FRAMEBUFFER, null); @@ -955,12 +1024,14 @@ export function startOrb(canvas, { onReady } = {}) { const posTex = interactive ? state.read.tex : zeroTex; const velTex = interactive ? state.read.tex2 : zeroTex; + const homeTex = interactive ? state.read.tex3 : zeroTex; const useParticles = () => { const p = progs.particles; gl.useProgram(p.p); commonUniforms(p, t); gl.uniform1i(p.u.uPos, bind(0, posTex)); gl.uniform1i(p.u.uVel, bind(1, velTex)); + gl.uniform1i(p.u.uHome, bind(2, homeTex)); gl.uniform1i(p.u.uStatic, interactive ? 0 : 1); gl.uniform1i(p.u.uT, T); gl.uniform2f(p.u.uView, W, H); @@ -971,7 +1042,7 @@ export function startOrb(canvas, { onReady } = {}) { // 2. corona (behind the shell) useParticles(); - gl.drawArrays(gl.POINTS, INNER, CORONA); + gl.drawArrays(gl.POINTS, INNER, drawCorona); // 3. glass shell + glow g = progs.shell; gl.useProgram(g.p); @@ -982,7 +1053,7 @@ export function startOrb(canvas, { onReady } = {}) { // 4. particles on the shell useParticles(); - gl.drawArrays(gl.POINTS, 0, INNER); + gl.drawArrays(gl.POINTS, 0, drawInner); if (!ready) { ready = true; onReady && onReady(); } requestAnimationFrame(frame); From c98bab9868352e8136baafc6a9d74b6896f9e8ad Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 10:14:17 -0400 Subject: [PATCH 13/14] radiance: link CV as /cv/ so its relative stylesheet resolves without relying on a redirect --- src/pages/index.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/index.astro b/src/pages/index.astro index a20b8fd..b9ae951 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -13,7 +13,7 @@ const nav = [ ]; const links = [ { label: 'Blog', href: '/blog' }, - { label: 'CV', href: '/cv' }, + { label: 'CV', href: '/cv/' }, { label: 'Git', href: 'https://pi.dangrubb.net/dangit' }, ]; --- From 25a97bf9825ac8eff1515f4f1045a2fc6c3bc8b9 Mon Sep 17 00:00:00 2001 From: dangrubbb Date: Sat, 24 Oct 2026 10:19:49 -0400 Subject: [PATCH 14/14] radiance: slash-terminated music/CV links so relative CSS resolves everywhere; Git menu links straight to the repo --- public/music/Darnea/DGDN/index.html | 4 ++-- public/music/Darnea/index.html | 6 +++--- public/music/index.html | 2 +- src/pages/index.astro | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/public/music/Darnea/DGDN/index.html b/public/music/Darnea/DGDN/index.html index d2ad1f0..f030843 100644 --- a/public/music/Darnea/DGDN/index.html +++ b/public/music/Darnea/DGDN/index.html @@ -11,7 +11,7 @@
-

Music / Darnea / DGDN

+

Music / Darnea / DGDN

@@ -39,7 +39,7 @@
diff --git a/public/music/Darnea/index.html b/public/music/Darnea/index.html index 98a5419..abbd9a6 100644 --- a/public/music/Darnea/index.html +++ b/public/music/Darnea/index.html @@ -10,7 +10,7 @@
-

Music / Darnea

+

Music / Darnea

@@ -21,13 +21,13 @@
🔥
-

DGDN

+

DGDN

8 tracks

diff --git a/public/music/index.html b/public/music/index.html index 63d50c9..03cd4a8 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -16,7 +16,7 @@
🎤
-

Darnea

+

Darnea

1 album

diff --git a/src/pages/index.astro b/src/pages/index.astro index b9ae951..704be36 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -6,7 +6,7 @@ import '../styles/radiance.css'; const nav = [ { label: 'Intelligence Platform', href: 'https://ai.dangrubb.net' }, - { label: 'Exclusive Music', href: '/music' }, + { label: 'Exclusive Music', href: '/music/' }, { label: 'Multimedia Entertainment', href: 'https://pi.dangrubb.net/jellyfin' }, { label: 'Private Search', href: 'https://search.dangrubb.net' }, { label: 'Storage Solutions', href: 'https://pi.dangrubb.net/nextcloud' }, @@ -14,7 +14,7 @@ const nav = [ const links = [ { label: 'Blog', href: '/blog' }, { label: 'CV', href: '/cv/' }, - { label: 'Git', href: 'https://pi.dangrubb.net/dangit' }, + { label: 'Git', href: 'https://pi.dangrubb.net/dangit/dangrubbb/dangrubb.net' }, ]; ---