motion-library 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +37 -2
  3. package/anims/basics/anticipation.js +56 -0
  4. package/anims/basics/card-flip.js +39 -0
  5. package/anims/basics/easing-race.js +57 -0
  6. package/anims/basics/fade-scale.js +44 -0
  7. package/anims/basics/stagger-grid.js +43 -0
  8. package/anims/chrome/black-hole.js +81 -0
  9. package/anims/chrome/cloth-reveal.js +156 -0
  10. package/anims/chrome/dna-helix.js +52 -0
  11. package/anims/chrome/dust-bust.js +109 -0
  12. package/anims/chrome/extruded-type.js +64 -0
  13. package/anims/chrome/fractal-tree.js +75 -0
  14. package/anims/chrome/gem.js +111 -0
  15. package/anims/chrome/geode.js +102 -0
  16. package/anims/chrome/glass-lens.js +73 -0
  17. package/anims/chrome/glass-prism.js +95 -0
  18. package/anims/chrome/gyroid.js +69 -0
  19. package/anims/chrome/holo-card.js +52 -0
  20. package/anims/chrome/infinite-tunnel.js +81 -0
  21. package/anims/chrome/inflated-type.js +141 -0
  22. package/anims/chrome/isometric-city.js +97 -0
  23. package/anims/chrome/jelly-cube.js +329 -0
  24. package/anims/chrome/klein-bottle.js +80 -0
  25. package/anims/chrome/liquid-metal.js +94 -0
  26. package/anims/chrome/low-poly-planet.js +163 -0
  27. package/anims/chrome/mandelbulb.js +79 -0
  28. package/anims/chrome/melting-logo.js +126 -0
  29. package/anims/chrome/menger-sponge.js +79 -0
  30. package/anims/chrome/neon-geodesic.js +149 -0
  31. package/anims/chrome/paper-fold.js +135 -0
  32. package/anims/chrome/path-spheres.js +112 -0
  33. package/anims/chrome/point-globe.js +81 -0
  34. package/anims/chrome/portal.js +105 -0
  35. package/anims/chrome/rubiks-cube.js +74 -0
  36. package/anims/chrome/sdf-morph.js +129 -0
  37. package/anims/chrome/shatter.js +120 -0
  38. package/anims/chrome/soap-bubble.js +86 -0
  39. package/anims/chrome/terrain-flyover.js +78 -0
  40. package/anims/chrome/tesseract.js +148 -0
  41. package/anims/chrome/torus-knot.js +89 -0
  42. package/anims/chrome/volumetric-clouds.js +70 -0
  43. package/anims/flow/bio-shore.js +140 -0
  44. package/anims/flow/calligraphy.js +116 -0
  45. package/anims/flow/chladni.js +152 -0
  46. package/anims/flow/cloud-chamber.js +101 -0
  47. package/anims/flow/confetti.js +46 -0
  48. package/anims/flow/curl-flow.js +52 -0
  49. package/anims/flow/dandelion.js +80 -0
  50. package/anims/flow/dla-crystal.js +57 -0
  51. package/anims/flow/drone-show.js +357 -0
  52. package/anims/flow/embers.js +63 -0
  53. package/anims/flow/firefly-sync.js +107 -0
  54. package/anims/flow/galaxy.js +69 -0
  55. package/anims/flow/galton-board.js +155 -0
  56. package/anims/flow/gpu-particles.js +73 -0
  57. package/anims/flow/harmonograph.js +61 -0
  58. package/anims/flow/holi.js +153 -0
  59. package/anims/flow/iron-filings.js +112 -0
  60. package/anims/flow/lightning.js +72 -0
  61. package/anims/flow/murmuration.js +105 -0
  62. package/anims/flow/orbitals.js +195 -0
  63. package/anims/flow/particle-life.js +87 -0
  64. package/anims/flow/physarum.js +171 -0
  65. package/anims/flow/pin-art.js +181 -0
  66. package/anims/flow/rainbow.js +127 -0
  67. package/anims/flow/roche-limit.js +161 -0
  68. package/anims/flow/sand-mandala.js +121 -0
  69. package/anims/flow/scramble-crossing.js +152 -0
  70. package/anims/flow/snowfall.js +84 -0
  71. package/anims/flow/sparks.js +71 -0
  72. package/anims/flow/spider-web.js +123 -0
  73. package/anims/flow/starry-night.js +121 -0
  74. package/anims/flow/states-of-matter.js +113 -0
  75. package/anims/flow/strange-attractor.js +54 -0
  76. package/anims/flow/wheat-field.js +186 -0
  77. package/anims/flow/wind-tunnel.js +227 -0
  78. package/anims/fluid/bubbles.js +152 -0
  79. package/anims/fluid/caustics.js +45 -0
  80. package/anims/fluid/dripping-text.js +53 -0
  81. package/anims/fluid/ferrofluid.js +56 -0
  82. package/anims/fluid/fire-smoke.js +83 -0
  83. package/anims/fluid/honey-drip.js +167 -0
  84. package/anims/fluid/ink-drop.js +50 -0
  85. package/anims/fluid/ink-fluid.js +125 -0
  86. package/anims/fluid/lava-lamp.js +56 -0
  87. package/anims/fluid/liquid-distort.js +64 -0
  88. package/anims/fluid/liquid-glass-fill.js +53 -0
  89. package/anims/fluid/marbling.js +53 -0
  90. package/anims/fluid/mercury.js +177 -0
  91. package/anims/fluid/ocean-waves.js +50 -0
  92. package/anims/fluid/paint-swirl.js +153 -0
  93. package/anims/fluid/raindrops-glass.js +60 -0
  94. package/anims/fluid/ripples.js +57 -0
  95. package/anims/fluid/sph-water.js +75 -0
  96. package/anims/fluid/underwater.js +55 -0
  97. package/anims/fluid/water-surface-3d.js +98 -0
  98. package/anims/hover/cursor-trail.js +37 -0
  99. package/anims/hover/magnetic.js +41 -0
  100. package/anims/hover/ripple.js +31 -0
  101. package/anims/hover/spotlight.js +31 -0
  102. package/anims/hover/tilt.js +36 -0
  103. package/anims/micro/arc-spinner.js +22 -0
  104. package/anims/micro/like-burst.js +42 -0
  105. package/anims/micro/skeleton.js +36 -0
  106. package/anims/micro/submit-success.js +45 -0
  107. package/anims/micro/toggle.js +35 -0
  108. package/anims/morph/gooey.js +36 -0
  109. package/anims/morph/icon-morph.js +45 -0
  110. package/anims/morph/line-draw.js +34 -0
  111. package/anims/morph/liquid-button.js +53 -0
  112. package/anims/morph/shape-morph.js +61 -0
  113. package/anims/registry-more.js +327 -0
  114. package/anims/registry.js +294 -0
  115. package/anims/scroll/parallax.js +50 -0
  116. package/anims/scroll/pinned-track.js +40 -0
  117. package/anims/scroll/scrub-reveal.js +34 -0
  118. package/anims/scroll/velocity-skew.js +38 -0
  119. package/anims/scroll/zoom-through.js +41 -0
  120. package/anims/shaders/aurora.js +47 -0
  121. package/anims/shaders/dither.js +69 -0
  122. package/anims/shaders/domain-warp.js +52 -0
  123. package/anims/shaders/guilloche.js +42 -0
  124. package/anims/shaders/kaleidoscope.js +64 -0
  125. package/anims/shaders/reaction-diffusion.js +85 -0
  126. package/anims/shaders/rgb-glitch.js +56 -0
  127. package/anims/shaders/truchet.js +57 -0
  128. package/anims/shaders/voronoi.js +50 -0
  129. package/anims/springs/ball-collisions.js +47 -0
  130. package/anims/springs/pendulum-wave.js +35 -0
  131. package/anims/springs/rubber-band.js +50 -0
  132. package/anims/springs/squash-bounce.js +50 -0
  133. package/anims/springs/verlet-rope.js +46 -0
  134. package/anims/text/letter-rise.js +40 -0
  135. package/anims/text/line-mask.js +26 -0
  136. package/anims/text/scramble.js +32 -0
  137. package/anims/text/typewriter.js +35 -0
  138. package/anims/text/vf-wave.js +26 -0
  139. package/anims/transitions/circle-reveal.js +42 -0
  140. package/anims/transitions/push-slide.js +40 -0
  141. package/anims/transitions/shared-expand.js +43 -0
  142. package/anims/transitions/staircase-wipe.js +41 -0
  143. package/anims/transitions/view-transition.js +43 -0
  144. package/bin.mjs +155 -0
  145. package/js/code.js +122 -0
  146. package/package.json +10 -4
@@ -0,0 +1,181 @@
1
+ // Pin art: the pin-screen toy, in 3D. Three thousand steel pins slide through a frame. Something pushes up from
2
+ // behind (a hand, then a face, then a ripple) and every pin it touches slides forward, so the shape appears in relief.
3
+ // The pins only move one way: when the shape pulls back, its imprint stays. Then a sweep flattens the board for the
4
+ // next one. Each pin is an instanced cylinder with a domed head, shaded like polished steel, and darkened where taller
5
+ // neighbours shade it. Press with the pointer to push pins back in and draw into the relief.
6
+ const PARAMS = { pins: 72, depth: 1.2, speed: 1 };
7
+
8
+ export default function (stage) {
9
+ const canvas = document.createElement('canvas');
10
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none';
11
+ stage.append(canvas);
12
+ const gl = canvas.getContext('webgl2', { antialias: true });
13
+ if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
14
+ const NX = Math.max(16, Math.round(PARAMS.pins)), NY = Math.round(NX * 0.68), N = NX * NY, HMAX = 8 * PARAMS.depth;
15
+ const h = new Float32Array(N), occ = new Float32Array(N);
16
+
17
+ // the shapes that push from behind, as height fields over the board (x across −1..1, y up −0.68..0.68)
18
+ const cap = (px, py, ax, ay, bx, by, r) => { const dx = bx - ax, dy = by - ay, t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy))), qx = px - ax - dx * t, qy = py - ay - dy * t, d2 = qx * qx + qy * qy; return d2 < r * r ? Math.sqrt(r * r - d2) / r : 0; };
19
+ const SHAPES = [
20
+ (x, y) => { // a hand, fingers spread
21
+ let v = cap(x, y, 0.02, -0.5, 0.02, -0.12, 0.27) * 0.9;
22
+ const F = [[-0.27, -0.12, -0.52, 0.12, 0.075], [-0.14, 0.04, -0.2, 0.5, 0.075], [0.02, 0.06, 0.03, 0.56, 0.08], [0.16, 0.04, 0.24, 0.48, 0.075], [0.27, -0.02, 0.42, 0.33, 0.065]];
23
+ for (const [ax, ay, bx, by, r] of F) v = Math.max(v, cap(x, y, ax, ay, bx, by, r) * 0.75);
24
+ return v;
25
+ },
26
+ (x, y) => { // a face, like a mask
27
+ const e = (x * x) / 0.16 + ((y + 0.02) ** 2) / 0.3; if (e > 1) return 0;
28
+ let v = Math.sqrt(1 - e) * 0.7;
29
+ v += 0.28 * Math.exp(-((x * x) / 0.004 + ((y + 0.05) ** 2) / 0.03)); // nose
30
+ v -= 0.16 * (Math.exp(-(((x - 0.15) ** 2) + ((y - 0.12) ** 2)) / 0.006) + Math.exp(-(((x + 0.15) ** 2) + ((y - 0.12) ** 2)) / 0.006)); // eye sockets
31
+ v += 0.08 * (Math.exp(-(((x - 0.15) ** 2) / 0.012 + ((y - 0.22) ** 2) / 0.0015)) + Math.exp(-(((x + 0.15) ** 2) / 0.012 + ((y - 0.22) ** 2) / 0.0015))); // brows
32
+ v += 0.1 * Math.exp(-((x * x) / 0.014 + ((y + 0.3) ** 2) / 0.002)) - 0.05 * Math.exp(-((x * x) / 0.02 + ((y + 0.33) ** 2) / 0.0004)); // lips
33
+ return Math.max(0, v);
34
+ },
35
+ (x, y) => { const r = Math.hypot(x, y); return r > 0.62 ? 0 : (0.5 + 0.5 * Math.cos(r * 26)) * (1 - r / 0.62) * 0.9; }, // a ripple
36
+ ];
37
+ const ptr = { x: 0, y: 0, down: false };
38
+ let shape = 0, phaseT = 0;
39
+
40
+ // GL
41
+ const sh = (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; };
42
+ const prog = (vs, fs) => {
43
+ const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(p);
44
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
45
+ const u = {}; for (let i = 0, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); i < n; i++) { const nm = gl.getActiveUniform(p, i).name; u[nm] = gl.getUniformLocation(p, nm); }
46
+ return { p, u };
47
+ };
48
+ const ENV = `
49
+ // a studio: dark floor, soft grey walls, one big window up and to the left
50
+ vec3 env(vec3 r) {
51
+ vec3 c = mix(vec3(.012, .012, .015), vec3(.11, .115, .13), smoothstep(-.3, .6, r.y));
52
+ c += vec3(2.6, 2.45, 2.2) * smoothstep(.82, .93, dot(r, normalize(vec3(-.55, .65, .5)))); // the key: a big softbox up-left
53
+ c += vec3(.55, .42, .3) * smoothstep(.72, .95, dot(r, normalize(vec3(.85, .05, .5)))); // a warm kicker from the right
54
+ return c;
55
+ }`;
56
+ const pinVS = `#version 300 es
57
+ layout(location = 0) in vec3 P; layout(location = 1) in vec3 Nrm; // unit pin: shaft along z 0..1, head beyond
58
+ layout(location = 2) in vec2 G; layout(location = 3) in float Hh; layout(location = 4) in float O;
59
+ uniform mat4 M; uniform float SP; out vec3 n; out vec3 w; out float oc; out float head;
60
+ void main() {
61
+ vec3 p = vec3(G * SP, 0) + vec3(P.xy, P.z <= 1. ? P.z * (Hh + 1.) : Hh + 1. + (P.z - 1.));
62
+ n = Nrm; w = p; oc = O; head = step(1.001, P.z);
63
+ gl_Position = M * vec4(p, 1);
64
+ }`;
65
+ const pinFS = `#version 300 es
66
+ precision highp float; in vec3 n; in vec3 w; in float oc; in float head; out vec4 o; uniform vec3 E;
67
+ ${ENV}
68
+ void main() {
69
+ vec3 N = normalize(n), V = normalize(E - w), R = reflect(-V, N);
70
+ float fr = .55 + .45 * pow(1. - max(dot(N, V), 0.), 5.);
71
+ vec3 c = env(R) * vec3(.82, .84, .88) * fr;
72
+ c *= mix(1., .12, oc) * mix(.7, 1., head) * (.7 + .5 * clamp(w.z / 8., 0., 1.)); // shadowed by taller neighbours; the furthest-out pins catch the most light
73
+ o = vec4(pow(c / (1. + c * .3), vec3(1. / 2.2)), 1);
74
+ }`;
75
+ const boardFS = `#version 300 es
76
+ precision highp float; in vec2 v; out vec4 o; uniform vec2 R;
77
+ void main() { vec2 q = v; vec3 c = mix(vec3(.012, .012, .014), vec3(.003, .003, .004), length(q * vec2(.7, 1.))); o = vec4(pow(c, vec3(1. / 2.2)), 1); }`;
78
+ const plateP = prog(`#version 300 es
79
+ layout(location = 0) in vec3 P; uniform mat4 M; out vec2 g; void main() { g = P.xy; gl_Position = M * vec4(P, 1); }`, `#version 300 es
80
+ precision highp float; in vec2 g; out vec4 o; uniform vec2 S;
81
+ void main() { vec2 k = g / S; float edge = max(abs(k.x), abs(k.y)); vec3 c = vec3(.04, .04, .045) * (1. - .3 * length(k)) * (edge > .97 ? 1.8 : 1.); o = vec4(pow(c, vec3(1. / 2.2)), 1); }`);
82
+ const pinP = prog(pinVS, pinFS), boardP = prog(`#version 300 es
83
+ in vec2 p; out vec2 v; void main() { v = p; gl_Position = vec4(p, .9999, 1); }`, boardFS);
84
+
85
+ // one pin: an 10-sided shaft and a rounded head, a little wider
86
+ const SEG = 10, R0 = 0.26, RH = 0.47, verts = [];
87
+ const ring = (z, r, nz) => Array.from({ length: SEG + 1 }, (_, i) => { const a = (i / SEG) * Math.PI * 2; return [Math.cos(a) * r, Math.sin(a) * r, z, Math.cos(a) * (1 - Math.abs(nz)), Math.sin(a) * (1 - Math.abs(nz)), nz]; });
88
+ const band = (A, B) => { for (let i = 0; i < SEG; i++) verts.push(...A[i], ...B[i], ...A[i + 1], ...A[i + 1], ...B[i], ...B[i + 1]); };
89
+ band(ring(0, R0, 0), ring(1, R0, 0)); // shaft
90
+ band(ring(1.0001, R0, -0.6), ring(1.05, RH, -0.2)); band(ring(1.05, RH, 0), ring(1.3, RH, 0)); // head: flare, side
91
+ const dome = [0.3, 0.6, 0.85, 1].map((k, j) => ring(1.3 + Math.sin(k * Math.PI / 2) * 0.22, RH * Math.cos(k * Math.PI / 2) + 0.001, Math.sin(k * Math.PI / 2)));
92
+ band(ring(1.3, RH, 0.05), dome[0]); band(dome[0], dome[1]); band(dome[1], dome[2]); band(dome[2], dome[3]);
93
+ const mesh = new Float32Array(verts), VCOUNT = mesh.length / 6;
94
+ const grid = new Float32Array(N * 2); for (let j = 0; j < NY; j++) for (let i = 0; i < NX; i++) grid.set([i - (NX - 1) / 2, j - (NY - 1) / 2], (j * NX + i) * 2);
95
+ const vao = gl.createVertexArray(); gl.bindVertexArray(vao);
96
+ const buf = (data, loc, size, stride, off, div, usage = gl.STATIC_DRAW) => { const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, data, usage); return b; };
97
+ const mb = buf(mesh); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12);
98
+ buf(grid); gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(2, 1);
99
+ const hb = buf(h.byteLength, 0, 0, 0, 0, 0, gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3, 1, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(3, 1);
100
+ const ob = buf(occ.byteLength, 0, 0, 0, 0, 0, gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4, 1, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(4, 1);
101
+ gl.bindVertexArray(null);
102
+ const bx = NX / 2 + 1.2, by = NY / 2 + 1.2;
103
+ const pv = gl.createVertexArray(); gl.bindVertexArray(pv); buf(new Float32Array([-bx, -by, 0, bx, -by, 0, -bx, by, 0, bx, -by, 0, bx, by, 0, -bx, by, 0])); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); gl.bindVertexArray(null);
104
+ const qv = gl.createVertexArray(); gl.bindVertexArray(qv); buf(new Float32Array([-1, -1, 3, -1, -1, 3])); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.bindVertexArray(null);
105
+
106
+ let W = 1, H = 1;
107
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = canvas.width = Math.max(1, (stage.clientWidth * d) | 0); H = canvas.height = Math.max(1, (stage.clientHeight * d) | 0); };
108
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
109
+ // camera: in front of the board, a little left and above, so the relief reads
110
+ const SP = 1;
111
+ let EYE = [0, 0, 0];
112
+ const matrix = t => {
113
+ const az = -0.5 + 0.12 * Math.sin(t * 0.21), el = 0.32, dist = NX * 1.02;
114
+ EYE = [Math.sin(az) * Math.cos(el) * dist, Math.sin(el) * dist, Math.cos(az) * Math.cos(el) * dist];
115
+ const T = [0, 0, HMAX * 0.25], f = EYE.map((v, i) => T[i] - v), fl = Math.hypot(...f); f.forEach((v, i) => f[i] = v / fl);
116
+ const rl0 = Math.hypot(f[2], f[0]), r = [-f[2] / rl0, 0, f[0] / rl0]; // right = forward × up
117
+ const u = [r[1] * f[2] - r[2] * f[1], r[2] * f[0] - r[0] * f[2], r[0] * f[1] - r[1] * f[0]];
118
+ const V = [r[0], u[0], -f[0], 0, r[1], u[1], -f[1], 0, r[2], u[2], -f[2], 0, -(r[0] * EYE[0] + r[1] * EYE[1] + r[2] * EYE[2]), -(u[0] * EYE[0] + u[1] * EYE[1] + u[2] * EYE[2]), f[0] * EYE[0] + f[1] * EYE[1] + f[2] * EYE[2], 1];
119
+ const F = 1 / Math.tan(0.36), A = W / H, n = 1, fa = 600, nf = 1 / (n - fa);
120
+ const P = [F / A, 0, 0, 0, 0, F, 0, 0, 0, 0, (fa + n) * nf, -1, 0, 0, 2 * fa * n * nf, 0], out = new Float32Array(16);
121
+ for (let c = 0; c < 4; c++) for (let rr = 0; rr < 4; rr++) { let s = 0; for (let k = 0; k < 4; k++) s += P[k * 4 + rr] * V[c * 4 + k]; out[c * 4 + rr] = s; }
122
+ return out;
123
+ };
124
+ // pointer: find the pin under it (on the board plane, at mid relief) and press nearby pins back
125
+ const move = e => {
126
+ const r = canvas.getBoundingClientRect(); ptr.sx = ((e.clientX - r.left) / r.width) * 2 - 1; ptr.sy = 1 - ((e.clientY - r.top) / r.height) * 2;
127
+ };
128
+ const down = e => { ptr.down = true; move(e); canvas.setPointerCapture(e.pointerId); };
129
+ const up = () => { ptr.down = false; };
130
+ canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', up);
131
+
132
+ let raf, last = performance.now(), t = 0, M = null;
133
+ function frame(now) {
134
+ const dt = Math.min(0.05, (now - last) / 1000) * PARAMS.speed; last = now; t += dt; phaseT += dt;
135
+ // a cycle: push in (3 s), pull back leaving the imprint (hold 3 s), then sweep the board flat (1.5 s)
136
+ const f = SHAPES[shape], push = phaseT < 3 ? phaseT / 3 : phaseT < 4 ? 1 - (phaseT - 3) : 0;
137
+ const sweep = phaseT > 6.2 ? (phaseT - 6.2) / 1.5 : -1;
138
+ if (phaseT > 7.9) { phaseT = 0; shape = (shape + 1) % SHAPES.length; }
139
+ const pushE = push * push * (3 - 2 * push);
140
+ for (let j = 0; j < NY; j++) for (let i = 0; i < NX; i++) {
141
+ const k = j * NX + i, x = (i / (NX - 1)) * 2 - 1, y = ((j / (NY - 1)) * 2 - 1) * (NY / NX);
142
+ if (sweep >= 0) { if ((x + 1) / 2 < sweep * 1.15 - 0.08) h[k] += (0 - h[k]) * Math.min(1, dt * 14); continue; } // a palm sweeps them flat, left to right
143
+ const target = f(x, y) * HMAX * (0.25 + 0.75 * pushE) * (push > 0 ? 1 : 0);
144
+ if (target > h[k]) h[k] += Math.min(target - h[k], dt * 26); // pins only ever get pushed forward
145
+ }
146
+ if (ptr.down && M) { // press: pins near the pointer go back in
147
+ for (let k = 0; k < N; k++) {
148
+ const gx = grid[k * 2], gy = grid[k * 2 + 1], z = h[k] + 1.5;
149
+ const cx = M[0] * gx + M[4] * gy + M[8] * z + M[12], cy = M[1] * gx + M[5] * gy + M[9] * z + M[13], cw = M[3] * gx + M[7] * gy + M[11] * z + M[15];
150
+ const d = Math.hypot(cx / cw - ptr.sx, (cy / cw - ptr.sy) * H / W);
151
+ if (d < 0.05) h[k] = Math.max(0, h[k] - dt * 30 * (1 - d / 0.05));
152
+ }
153
+ }
154
+ // occlusion: a pin standing lower than its neighbours sits in their shade
155
+ for (let j = 0; j < NY; j++) for (let i = 0; i < NX; i++) {
156
+ const k = j * NX + i; let m = 0, c = 0;
157
+ for (let dj = -1; dj <= 1; dj++) for (let di = -1; di <= 1; di++) { const ii = i + di, jj = j + dj; if (ii < 0 || jj < 0 || ii >= NX || jj >= NY || (!di && !dj)) continue; m += h[jj * NX + ii]; c++; }
158
+ // and a raking light from the upper left: a taller pin a step or three that way casts this one into shadow
159
+ let sh = 0; for (let st = 1; st <= 4; st++) { const ii = i - st, jj = j + st; if (ii < 0 || jj >= NY) break; sh = Math.max(sh, (h[jj * NX + ii] - h[k] - st * 0.9) / 1.5); }
160
+ occ[k] = Math.min(1, Math.max(0, (m / c - h[k]) / 2.2, sh));
161
+ }
162
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H);
163
+ gl.clearColor(0.03, 0.03, 0.035, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.enable(gl.DEPTH_TEST);
164
+ gl.useProgram(boardP.p); gl.bindVertexArray(qv); gl.drawArrays(gl.TRIANGLES, 0, 3);
165
+ M = matrix(t);
166
+ gl.useProgram(plateP.p); gl.uniformMatrix4fv(plateP.u.M, false, M); gl.uniform2f(plateP.u.S, bx, by); gl.bindVertexArray(pv); gl.drawArrays(gl.TRIANGLES, 0, 6);
167
+ gl.useProgram(pinP.p); gl.uniformMatrix4fv(pinP.u.M, false, M); gl.uniform1f(pinP.u.SP, SP); gl.uniform3f(pinP.u.E, ...EYE);
168
+ gl.bindVertexArray(vao);
169
+ gl.bindBuffer(gl.ARRAY_BUFFER, hb); gl.bufferSubData(gl.ARRAY_BUFFER, 0, h);
170
+ gl.bindBuffer(gl.ARRAY_BUFFER, ob); gl.bufferSubData(gl.ARRAY_BUFFER, 0, occ);
171
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, VCOUNT, N);
172
+ gl.bindVertexArray(null); gl.disable(gl.DEPTH_TEST);
173
+ raf = requestAnimationFrame(frame);
174
+ }
175
+ raf = requestAnimationFrame(frame);
176
+ return () => {
177
+ cancelAnimationFrame(raf); ro.disconnect();
178
+ canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', up);
179
+ gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove();
180
+ };
181
+ }
@@ -0,0 +1,127 @@
1
+ // Rainbow from raindrops: the sun is low behind you and rain falls in front. Light that enters a drop, bounces once
2
+ // inside and comes back out leaves at about 42° from the direction opposite the sun, red a little wider than violet.
3
+ // So each falling drop sends you one colour only at the moment it crosses that angle, and the bow is simply the place
4
+ // where thousands of drops agree. A second bounce makes the fainter outer bow at about 51°, colours reversed, and the
5
+ // sky between the two (Alexander's band) is darker, because no drop sends light from there. The drops are drawn as
6
+ // streaks, each coloured by its angle from the anti-solar point.
7
+ const PARAMS = { drops: 24000, rain: 1, bow: 1 };
8
+
9
+ export default function (stage) {
10
+ const canvas = document.createElement('canvas');
11
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
12
+ stage.append(canvas);
13
+ const gl = canvas.getContext('webgl2', { antialias: true });
14
+ if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
15
+ const N = Math.max(2000, Math.round(PARAMS.drops));
16
+ // the sun: low (11°), behind and a little to the right; the bow is centred opposite it, below the horizon
17
+ const SUN = (() => { const e = 0.19, a = 0.06; return [Math.sin(a) * Math.cos(e), Math.sin(e), -Math.cos(a) * Math.cos(e)]; })();
18
+ const PITCH = 0.13, FOC = 1 / Math.tan(0.62), EYE = 1.7;
19
+ const COMMON = `
20
+ uniform vec3 SUN; uniform float PITCH, FOC, A, T, BOW;
21
+ vec3 hsv(float h, float s, float v) { vec3 k = clamp(abs(fract(h + vec3(0., 2. / 3., 1. / 3.)) * 6. - 3.) - 1., 0., 1.); return v * mix(vec3(1), k, s); }
22
+ // what a drop at angle th (degrees) from the anti-solar point sends back
23
+ vec3 bow(float th) {
24
+ vec3 c = vec3(0);
25
+ float x = (th - 40.4) / 2.1; // primary: violet inside, red outside
26
+ c += hsv(.78 * (1. - clamp(x, 0., 1.)), .9, 1.) * smoothstep(-.35, .25, x) * (1. - smoothstep(.92, 1.12, x));
27
+ float y = (53.6 - th) / 2.9; // secondary: reversed, and fainter
28
+ c += hsv(.78 * (1. - clamp(y, 0., 1.)), .85, .42) * smoothstep(-.35, .25, y) * (1. - smoothstep(.92, 1.12, y));
29
+ c += vec3(.16) * smoothstep(10., 40.5, th) * step(th, 40.6); // inside the bow the sky is brighter
30
+ return c;
31
+ }
32
+ vec3 ray(vec2 q) { float cp = cos(PITCH), sp = sin(PITCH); return normalize(vec3(q.x, q.y * cp + FOC * sp, -q.y * sp + FOC * cp)); }`;
33
+ // the sky (a storm, lit from behind the viewer), the bow's faint continuous glow, sunlit hills
34
+ const sceneFS = `#version 300 es
35
+ precision highp float; in vec2 uv; out vec4 o; uniform vec2 R;
36
+ ${COMMON}
37
+ float h(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
38
+ float n2(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3. - 2. * f); return mix(mix(h(i), h(i + vec2(1, 0)), f.x), mix(h(i + vec2(0, 1)), h(i + 1.), f.x), f.y); }
39
+ float fbm(vec2 p) { float s = 0., a = .5; for (int i = 0; i < 5; i++) { s += a * n2(p); p = p * 2.03 + 7.1; a *= .5; } return s; }
40
+ void main() {
41
+ vec2 q = (uv * 2. - 1.) * vec2(A, 1.);
42
+ vec3 d = ray(q);
43
+ float th = degrees(acos(clamp(dot(d, -SUN), -1., 1.)));
44
+ vec3 col;
45
+ float hill1 = .02 + .07 * fbm(vec2(d.x / d.z * 1.4, 3.)), hill2 = .004 + .035 * fbm(vec2(d.x / d.z * 3.1, 9.));
46
+ if (d.y > hill1) {
47
+ float cl = fbm(vec2(d.x / max(d.y + .25, .05) * .6 + T * .006, d.y * 4.));
48
+ col = mix(vec3(.2, .22, .26), vec3(.36, .38, .42), cl); // the storm: slate, lit from behind you
49
+ col = mix(col, vec3(.44, .46, .5), smoothstep(.55, .0, d.y) * .35);
50
+ col *= 1. - .28 * smoothstep(42.6, 44., th) * (1. - smoothstep(49.5, 51., th)); // Alexander's dark band
51
+ col += bow(th) * .16 * BOW; // the glow of countless drops too far to see
52
+ } else {
53
+ // the land: two lines of hills in low gold light, then a meadow
54
+ float near = step(d.y, hill2);
55
+ vec3 far = mix(vec3(.38, .42, .3), vec3(.5, .5, .32), fbm(vec2(d.x / d.z * 6., 1.)));
56
+ vec3 hill = mix(vec3(.3, .38, .14), vec3(.56, .52, .2), fbm(vec2(d.x / d.z * 9., d.y * 60.)));
57
+ col = mix(far, hill, near);
58
+ float shade = fbm(vec2(d.x / d.z * 2. + T * .01, 5.)); col *= .7 + .45 * smoothstep(.35, .7, shade); // cloud shadows drifting over
59
+ if (d.y < -.01) { // the meadow up close, fading into the hills
60
+ float z = EYE_ / -d.y; vec2 g = vec2(d.x * z, z);
61
+ vec3 m = mix(vec3(.26, .32, .11), vec3(.44, .46, .17), fbm(g * .06)) * (.88 + .2 * n2(g * .5));
62
+ col = mix(m, col, smoothstep(25., 120., z));
63
+ }
64
+ col = mix(col, vec3(.48, .5, .52), smoothstep(.0, -.12, -abs(d.y)) * .35);
65
+ }
66
+ o = vec4(pow(col, vec3(.95)), 1);
67
+ }`.replace('EYE_', EYE.toFixed(2));
68
+ // rain: each drop a short streak (two vertices per drop, instanced), coloured by its angle to the anti-solar point
69
+ const rainVS = `#version 300 es
70
+ layout(location = 0) in float end; // 0 = head, 1 = tail
71
+ layout(location = 1) in vec4 D; // x, starting height, z, phase
72
+ ${COMMON}
73
+ uniform float SPD; out vec3 col; out float a;
74
+ void main() {
75
+ float fall = mod(D.y - (T * SPD * 9. + D.w * 40.), 40.);
76
+ vec3 p = vec3(D.x + fall * .12, fall - ${EYE.toFixed(2)}, D.z);
77
+ p -= vec3(.12, -1., 0.) * end * .33 * SPD; // the streak a drop draws in one exposure
78
+ float cp = cos(PITCH), sp = sin(PITCH);
79
+ vec3 c = vec3(p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp); // into the camera's frame
80
+ gl_Position = vec4(c.x * FOC / (c.z * A), c.y * FOC / c.z, 0, 1);
81
+ vec3 d = normalize(p);
82
+ float th = degrees(acos(clamp(dot(d, -SUN), -1., 1.)));
83
+ col = bow(th) * BOW * 1.6 + vec3(.1, .11, .12);
84
+ a = smoothstep(150., 20., D.z) * (1. - end * .7);
85
+ }`;
86
+ const rainFS = `#version 300 es
87
+ precision mediump float; in vec3 col; in float a; out vec4 o; void main() { o = vec4(col * a, 1); }`;
88
+ const QUAD = `#version 300 es
89
+ in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
90
+
91
+ const sh = (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; };
92
+ const prog = (vs, fs) => {
93
+ const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs)); gl.bindAttribLocation(p, 0, 'p'); gl.linkProgram(p);
94
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
95
+ const u = {}; for (let i = 0, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); i < n; i++) { const nm = gl.getActiveUniform(p, i).name; u[nm] = gl.getUniformLocation(p, nm); }
96
+ return { p, u };
97
+ };
98
+ const sceneP = prog(QUAD, sceneFS), rainP = prog(rainVS, rainFS);
99
+ const quad = gl.createVertexArray(); gl.bindVertexArray(quad);
100
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
101
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
102
+ // drops fill a slab in front of the viewer; more of them near, where each is bigger
103
+ const inst = new Float32Array(N * 4);
104
+ for (let i = 0; i < N; i++) { const z = 8 + 140 * Math.pow(Math.random(), 1.6); inst.set([(Math.random() * 2 - 1) * z * 1.15, Math.random() * 40, z, Math.random()], i * 4); }
105
+ const rv = gl.createVertexArray(); gl.bindVertexArray(rv);
106
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 1]), gl.STATIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 1, gl.FLOAT, false, 0, 0);
107
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, inst, gl.STATIC_DRAW); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(1, 1);
108
+ gl.bindVertexArray(null);
109
+
110
+ let W = 1, H = 1;
111
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = canvas.width = Math.max(1, (stage.clientWidth * d) | 0); H = canvas.height = Math.max(1, (stage.clientHeight * d) | 0); };
112
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
113
+ const common = (P, t) => { gl.uniform3f(P.u.SUN, ...SUN); gl.uniform1f(P.u.PITCH, PITCH); gl.uniform1f(P.u.FOC, FOC); gl.uniform1f(P.u.A, W / H); gl.uniform1f(P.u.T, t); gl.uniform1f(P.u.BOW, PARAMS.bow); };
114
+ let raf, last = performance.now(), t = 0;
115
+ function frame(now) {
116
+ const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt;
117
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H); gl.disable(gl.BLEND);
118
+ gl.useProgram(sceneP.p); common(sceneP, t); gl.uniform2f(sceneP.u.R, W, H); gl.bindVertexArray(quad); gl.drawArrays(gl.TRIANGLES, 0, 3);
119
+ gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
120
+ gl.useProgram(rainP.p); common(rainP, t); gl.uniform1f(rainP.u.SPD, PARAMS.rain); gl.bindVertexArray(rv);
121
+ gl.drawArraysInstanced(gl.LINES, 0, 2, Math.round(N * Math.min(1, PARAMS.rain + 0.2)));
122
+ gl.bindVertexArray(null); gl.disable(gl.BLEND);
123
+ raf = requestAnimationFrame(frame);
124
+ }
125
+ raf = requestAnimationFrame(frame);
126
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
127
+ }
@@ -0,0 +1,161 @@
1
+ // Roche limit: a rubble-pile moon spirals in toward an ice giant. Its own gravity holds it together, but the planet
2
+ // pulls harder on the near side than the far side, and that difference (the tide) grows as 1/d³. Inside about 2.4
3
+ // planet radii the tide wins: the moon stretches, then sheds two streams, one inward and one outward, from the points
4
+ // where the pull balances. Inner rubble orbits faster than outer rubble, so the streams shear and wind round the
5
+ // planet until they close into a ring. Twenty thousand particles, each lit by the sun and shadowed by the planet.
6
+ const PARAMS = { particles: 20000, speed: 1, glow: 1 };
7
+
8
+ export default function (stage) {
9
+ const canvas = document.createElement('canvas');
10
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
11
+ stage.append(canvas);
12
+ const gl = canvas.getContext('webgl2', { antialias: false, premultipliedAlpha: false });
13
+ if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
14
+ const N = Math.max(2000, Math.round(PARAMS.particles));
15
+ const SUN = (() => { const v = [0.8, 0.32, 0.35], l = Math.hypot(...v); return v.map(x => x / l); })();
16
+
17
+ // ── the simulation (planet radius 1, GM = 1)
18
+ const px = new Float32Array(N), py = new Float32Array(N), pz = new Float32Array(N), vx = new Float32Array(N), vy = new Float32Array(N), vz = new Float32Array(N);
19
+ const bound = new Uint8Array(N), lit = new Float32Array(N);
20
+ const RM = 0.24, KB = 2 / 2.4 ** 3; // moon radius; self-gravity strength that fails at d ≈ 2.4
21
+ let com = [0, 0, 0], comV = [0, 0, 0], simT = 0, cycleT = 0;
22
+ function reset() {
23
+ const d0 = 3.5, v0 = Math.sqrt(1 / d0);
24
+ for (let i = 0; i < N; i++) {
25
+ let x, y, z; do { x = Math.random() * 2 - 1; y = Math.random() * 2 - 1; z = Math.random() * 2 - 1; } while (x * x + y * y + z * z > 1);
26
+ px[i] = d0 + x * RM; py[i] = y * RM; pz[i] = z * RM; vx[i] = 0; vy[i] = 0; vz[i] = v0; bound[i] = 1;
27
+ }
28
+ simT = 0; cycleT = 0;
29
+ }
30
+ reset();
31
+ function step(h) {
32
+ // the clump that is still a moon: its centre and velocity
33
+ let n = 0, cx = 0, cy = 0, cz = 0, cvx = 0, cvy = 0, cvz = 0;
34
+ for (let i = 0; i < N; i++) if (bound[i]) { n++; cx += px[i]; cy += py[i]; cz += pz[i]; cvx += vx[i]; cvy += vy[i]; cvz += vz[i]; }
35
+ if (n) { com = [cx / n, cy / n, cz / n]; comV = [cvx / n, cvy / n, cvz / n]; }
36
+ const decay = 0.0032; // the moon's orbit decays (tides raised on the planet)
37
+ for (let i = 0; i < N; i++) {
38
+ const r2 = px[i] * px[i] + py[i] * py[i] + pz[i] * pz[i], r = Math.sqrt(r2), g = -1 / (r2 * r);
39
+ let ax = px[i] * g, ay = py[i] * g, az = pz[i] * g;
40
+ if (bound[i]) {
41
+ const dx = px[i] - com[0], dy = py[i] - com[1], dz = pz[i] - com[2];
42
+ if (dx * dx + dy * dy + dz * dz > (RM * 1.5) ** 2) bound[i] = 0; // pulled too far from the clump: free rubble now
43
+ else {
44
+ ax -= KB * dx + 0.3 * (vx[i] - comV[0]); ay -= KB * dy + 0.3 * (vy[i] - comV[1]); az -= KB * dz + 0.3 * (vz[i] - comV[2]); // self-gravity, and friction inside the pile
45
+ ax -= decay * vx[i]; ay -= decay * vy[i]; az -= decay * vz[i];
46
+ }
47
+ } else {
48
+ // free rubble collides and settles: radial and vertical motion are slowly damped, so the ring turns round and flat
49
+ const vr = (vx[i] * px[i] + vy[i] * py[i] + vz[i] * pz[i]) / r;
50
+ const kick = (Math.random() - 0.5) * 0.05; // collisions also jostle them, spreading the streams into a band
51
+ ax += (kick - 0.03 * vr) * px[i] / r; ay -= 0.03 * vr * py[i] / r + 0.04 * vy[i]; az += (kick - 0.03 * vr) * pz[i] / r;
52
+ }
53
+ vx[i] += ax * h; vy[i] += ay * h; vz[i] += az * h;
54
+ px[i] += vx[i] * h; py[i] += vy[i] * h; pz[i] += vz[i] * h;
55
+ if (r < 1.02) { const k = 1.02 / r; px[i] *= k; py[i] *= k; pz[i] *= k; vx[i] *= 0.5; vy[i] *= 0.5; vz[i] *= 0.5; } // grazes the cloud tops
56
+ }
57
+ }
58
+
59
+ // ── GL
60
+ const sh = (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; };
61
+ const prog = (vs, fs) => {
62
+ const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs)); gl.bindAttribLocation(p, 0, 'p'); gl.linkProgram(p);
63
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
64
+ const u = {}; for (let i = 0, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); i < n; i++) { const nm = gl.getActiveUniform(p, i).name; u[nm] = gl.getUniformLocation(p, nm); }
65
+ return { p, u };
66
+ };
67
+ const CAMERA = `uniform vec3 CP, CR, CU, CF, SUN; uniform float FOC, A;`;
68
+ // the planet and the stars, per pixel: a ray against a sphere, banded clouds, a soft terminator, a blue rim
69
+ const planetP = prog(`#version 300 es
70
+ in vec2 p; out vec2 v; void main() { v = p; gl_Position = vec4(p, 0, 1); }`, `#version 300 es
71
+ precision highp float; in vec2 v; out vec4 o; ${CAMERA} uniform float T, RING;
72
+ float h(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
73
+ float n2(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3. - 2. * f); return mix(mix(h(i), h(i + vec2(1, 0)), f.x), mix(h(i + vec2(0, 1)), h(i + 1.), f.x), f.y); }
74
+ void main() {
75
+ vec3 d = normalize(CF * FOC + CR * v.x * A + CU * v.y);
76
+ float b = dot(CP, d), c = dot(CP, CP) - 1., disc = b * b - c;
77
+ vec3 col = vec3(0);
78
+ vec2 sp = vec2(atan(d.z, d.x), asin(d.y)) * 230., ci = floor(sp);
79
+ vec3 p3 = fract(vec3(ci.xyx) * .1031); p3 += dot(p3, p3.yzx + 33.33); float s = fract((p3.x + p3.y) * p3.z);
80
+ col += vec3(.8, .85, 1.) * step(.992, s) * (s - .992) * 70. * smoothstep(.45, .1, length(fract(sp) - .5));
81
+ if (disc > 0.) {
82
+ float t = -b - sqrt(disc); vec3 pos = CP + d * t, n = normalize(pos);
83
+ float lat = n.y, lon = atan(n.z, n.x);
84
+ float turb = n2(vec2(lon * 3. + T * .02, lat * 14.)) * .6 + n2(vec2(lon * 9. - T * .03, lat * 40.)) * .25;
85
+ float band = sin(lat * 15. + turb * 3.);
86
+ vec3 base = mix(vec3(.28, .52, .66), vec3(.55, .78, .84), .5 + .5 * band);
87
+ base = mix(base, vec3(.85, .93, .95), smoothstep(.6, 1., band) * .35);
88
+ float dl = dot(n, SUN), lambert = smoothstep(-.12, .55, dl);
89
+ // the ring's shadow on the cloud tops, once there is a ring
90
+ vec3 toS = SUN; float tr = -pos.y / toS.y; vec3 hit = pos + toS * tr; float rr = length(hit.xz);
91
+ float shade = 1. - RING * .55 * step(0., tr) * smoothstep(1.35, 1.5, rr) * smoothstep(3.2, 2.7, rr);
92
+ col = base * (.035 + .965 * lambert) * shade + base * .03 * RING; // a little ring-shine on the night side
93
+ col += vec3(.35, .6, 1.) * pow(1. - max(dot(n, -d), 0.), 3.) * (.25 + .75 * smoothstep(-.2, .4, dl)) * .8; // atmosphere at the limb
94
+ }
95
+ o = vec4(col, 1);
96
+ }`);
97
+ // the rubble: points, hidden when the planet is in the way, dark in its shadow, lit by the sun
98
+ const rockP = prog(`#version 300 es
99
+ layout(location = 0) in vec3 P; layout(location = 1) in float L; ${CAMERA} uniform float PS; out float b;
100
+ void main() {
101
+ vec3 rel = P - CP; float dist = length(rel); vec3 d = rel / dist;
102
+ float bb = dot(CP, d), cc = dot(CP, CP) - 1., disc = bb * bb - cc, t = -bb - sqrt(max(disc, 0.));
103
+ float hidden = disc > 0. && t < dist ? 0. : 1.;
104
+ float sd = dot(P, SUN); vec3 perp = P - SUN * sd; float shadow = sd < 0. && dot(perp, perp) < 1. ? .06 : 1.;
105
+ vec3 c = vec3(dot(rel, CR), dot(rel, CU), dot(rel, CF));
106
+ gl_Position = vec4(c.x * FOC / (c.z * A), c.y * FOC / c.z, 0, 1);
107
+ gl_PointSize = PS * 6. / max(c.z, .5);
108
+ b = hidden * shadow * L;
109
+ }`, `#version 300 es
110
+ precision mediump float; in float b; out vec4 o; uniform float K;
111
+ void main() { vec2 q = gl_PointCoord * 2. - 1.; float f = max(0., 1. - dot(q, q)); o = vec4(vec3(1., .96, .9) * b * f * K, 1); }`);
112
+
113
+ const quad = gl.createVertexArray(); gl.bindVertexArray(quad);
114
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
115
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
116
+ const rv = gl.createVertexArray(); gl.bindVertexArray(rv);
117
+ const pos = new Float32Array(N * 3), pb = gl.createBuffer(), lb = gl.createBuffer();
118
+ gl.bindBuffer(gl.ARRAY_BUFFER, pb); gl.bufferData(gl.ARRAY_BUFFER, pos.byteLength, gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
119
+ gl.bindBuffer(gl.ARRAY_BUFFER, lb); gl.bufferData(gl.ARRAY_BUFFER, lit.byteLength, gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 1, gl.FLOAT, false, 0, 0);
120
+ gl.bindVertexArray(null);
121
+
122
+ let W = 1, H = 1;
123
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = canvas.width = Math.max(1, (stage.clientWidth * d) | 0); H = canvas.height = Math.max(1, (stage.clientHeight * d) | 0); };
124
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
125
+
126
+ let raf, last = performance.now(), t = 0;
127
+ function frame(now) {
128
+ const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt;
129
+ const simDt = dt * 6.5 * PARAMS.speed;
130
+ for (let s = 0; s < 3; s++) step(simDt / 3);
131
+ simT += simDt; cycleT += dt * PARAMS.speed;
132
+ // the story repeats: once the ring has closed and settled for a while, a fresh moon arrives
133
+ const fade = Math.min(1, cycleT / 1.2) * Math.min(1, Math.max(0, (52 - cycleT) / 2));
134
+ if (cycleT > 52) reset();
135
+ // light each particle: the moon is a lit ball (day side and night side); loose rubble just reflects
136
+ let free = 0;
137
+ for (let i = 0; i < N; i++) {
138
+ pos[i * 3] = px[i]; pos[i * 3 + 1] = py[i]; pos[i * 3 + 2] = pz[i];
139
+ if (bound[i]) { const dx = px[i] - com[0], dy = py[i] - com[1], dz = pz[i] - com[2], l = Math.hypot(dx, dy, dz) + 1e-6; lit[i] = (0.12 + 0.88 * Math.max(0, (dx * SUN[0] + dy * SUN[1] + dz * SUN[2]) / l)) * fade * 0.4; }
140
+ else { lit[i] = fade; free++; }
141
+ }
142
+ // camera: circling slowly, a little above the ring plane
143
+ const az = 0.6 + t * 0.05, el = 0.26, dist = 5.4;
144
+ const CP = [Math.cos(az) * Math.cos(el) * dist, Math.sin(el) * dist, Math.sin(az) * Math.cos(el) * dist];
145
+ let CF = CP.map(v => -v / dist), CR = [CF[2], 0, -CF[0]]; const rl = Math.hypot(CR[0], CR[2]); CR = CR.map(v => v / rl);
146
+ const CU = [CF[1] * CR[2] - CF[2] * CR[1], CF[2] * CR[0] - CF[0] * CR[2], CF[0] * CR[1] - CF[1] * CR[0]];
147
+ const set = P => { gl.uniform3f(P.u.CP, ...CP); gl.uniform3f(P.u.CR, ...CR); gl.uniform3f(P.u.CU, ...CU); gl.uniform3f(P.u.CF, ...CF); gl.uniform3f(P.u.SUN, ...SUN); gl.uniform1f(P.u.FOC, 2.1); gl.uniform1f(P.u.A, W / H); };
148
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H); gl.disable(gl.BLEND);
149
+ gl.useProgram(planetP.p); set(planetP); gl.uniform1f(planetP.u.T, t); gl.uniform1f(planetP.u.RING, Math.min(1, free / N) * fade);
150
+ gl.bindVertexArray(quad); gl.drawArrays(gl.TRIANGLES, 0, 3);
151
+ gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
152
+ gl.useProgram(rockP.p); set(rockP); gl.uniform1f(rockP.u.PS, Math.max(1.4, H / 380)); gl.uniform1f(rockP.u.K, 0.5 * PARAMS.glow * Math.sqrt(20000 / N));
153
+ gl.bindVertexArray(rv);
154
+ gl.bindBuffer(gl.ARRAY_BUFFER, pb); gl.bufferSubData(gl.ARRAY_BUFFER, 0, pos);
155
+ gl.bindBuffer(gl.ARRAY_BUFFER, lb); gl.bufferSubData(gl.ARRAY_BUFFER, 0, lit);
156
+ gl.drawArrays(gl.POINTS, 0, N); gl.bindVertexArray(null); gl.disable(gl.BLEND);
157
+ raf = requestAnimationFrame(frame);
158
+ }
159
+ raf = requestAnimationFrame(frame);
160
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
161
+ }
@@ -0,0 +1,121 @@
1
+ // Sand mandala: chalk guide lines first, then coloured sand poured grain by grain from eight funnels moving in step,
2
+ // from the centre outward, ring by ring: a lotus, a palace with four gates, flames, petals, a ring of fire. Every
3
+ // grain takes the colour of the design under it with a little variation, so the colours read as sand. When it is
4
+ // finished it is swept away (impermanence is the point): the whole picture is turned and drawn in on itself a little
5
+ // every frame, so the colours spiral into a mixed pile at the centre. Then a fresh cloth.
6
+ const PARAMS = { grains: 2200, symmetry: 8, speed: 1 };
7
+
8
+ export default function (stage) {
9
+ const canvas = document.createElement('canvas');
10
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;background:#3a1416';
11
+ stage.append(canvas);
12
+ const ctx = canvas.getContext('2d');
13
+ let W = 1, H = 1, D = 1, R = 1, cx = 0, cy = 0, cloth = null, sand = null, sg = null, tmp = null, design = null, guide = null;
14
+ const PAL = { saffron: [232, 163, 23], crimson: [178, 30, 40], white: [240, 236, 226], blue: [30, 62, 150], green: [24, 130, 82], gold: [240, 200, 60], black: [24, 20, 22], pink: [232, 120, 140], sky: [90, 170, 220], orange: [236, 104, 30] };
15
+ // the design, as a colour at polar position (r 0..1 of the mandala's radius, angle a)
16
+ function paint(r, a, x, y) {
17
+ const S = Math.round(PARAMS.symmetry);
18
+ if (r < 0.06) return r < 0.035 ? PAL.gold : PAL.crimson;
19
+ if (r < 0.2) { const petal = 0.07 + 0.13 * Math.pow(Math.abs(Math.cos(a * S / 2)), 0.7); return r < petal ? (r > petal - 0.015 ? PAL.white : PAL.crimson) : PAL.saffron; }
20
+ if (r < 0.235) { const k = (a / (Math.PI * 2)) * 32; return Math.hypot((k - Math.round(k)) * 0.04, r - 0.218) < 0.011 ? PAL.white : PAL.blue; }
21
+ // the palace: a square with a T-shaped gate in each side; each quarter its direction's colour
22
+ const ax = Math.abs(x), ay = Math.abs(y), m = Math.max(ax, ay);
23
+ if (r < 0.54) {
24
+ const gate = Math.min(ax, ay) < 0.07 && m > 0.33 && m < 0.44;
25
+ if (m < 0.38 || gate) {
26
+ if (m > 0.355 || (gate && (m > 0.42 || Math.min(ax, ay) > 0.055))) return PAL.gold;
27
+ const q = ax > ay ? (x > 0 ? 0 : 2) : (y > 0 ? 1 : 3);
28
+ return [PAL.white, PAL.saffron, PAL.crimson, PAL.green][q];
29
+ }
30
+ return m < 0.39 ? PAL.black : PAL.blue;
31
+ }
32
+ if (r < 0.6) { const k = ((a / (Math.PI * 2)) * 48) % 1, tri = (r - 0.54) / 0.06 < 1 - Math.abs(k - 0.5) * 2; return tri ? (Math.floor((a / (Math.PI * 2)) * 48) % 2 ? PAL.orange : PAL.gold) : PAL.crimson; }
33
+ if (r < 0.68) { const k = ((a / (Math.PI * 2)) * 36) % 1, petal = 0.6 + 0.08 * Math.sin(k * Math.PI); return r < petal ? (Math.abs(k - 0.5) < 0.06 ? PAL.white : PAL.pink) : PAL.green; }
34
+ if (r < 0.76) { const k = ((a / (Math.PI * 2)) * 24) % 1, mid = Math.abs(r - 0.72); return (Math.abs(k - 0.5) < 0.05 && mid < 0.03) || (mid < 0.006 && Math.abs(k - 0.5) < 0.2) ? PAL.gold : PAL.blue; }
35
+ if (r < 0.84) { const flame = Math.sin(a * 60 + Math.sin(a * 15) * 2) * 0.018, k = (r - 0.76 + flame) / 0.08; return k < 0.33 ? PAL.gold : k < 0.66 ? PAL.orange : PAL.crimson; }
36
+ if (r < 0.86) return PAL.black;
37
+ return null;
38
+ }
39
+ const resize = () => {
40
+ D = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1;
41
+ canvas.width = Math.max(1, (W * D) | 0); canvas.height = Math.max(1, (H * D) | 0); ctx.setTransform(D, 0, 0, D, 0, 0);
42
+ R = Math.min(W, H) * 0.47 / 0.86; cx = W / 2; cy = H / 2;
43
+ // cloth: deep red, a woven texture
44
+ cloth = document.createElement('canvas'); cloth.width = canvas.width; cloth.height = canvas.height; const c = cloth.getContext('2d'); c.setTransform(D, 0, 0, D, 0, 0);
45
+ c.fillStyle = '#3a1416'; c.fillRect(0, 0, W, H);
46
+ for (let y = 0; y < H; y += 2) { c.fillStyle = `rgba(0,0,0,${0.05 + Math.random() * 0.05})`; c.fillRect(0, y, W, 1); }
47
+ for (let x = 0; x < W; x += 3) { c.fillStyle = `rgba(255,200,180,${Math.random() * 0.03})`; c.fillRect(x, 0, 1, H); }
48
+ const v = c.createRadialGradient(cx, cy, R * 0.3, cx, cy, Math.max(W, H) * 0.7); v.addColorStop(0, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,.45)'); c.fillStyle = v; c.fillRect(0, 0, W, H);
49
+ // chalk guides: the rings and the palace square, thin and pale
50
+ guide = document.createElement('canvas'); guide.width = canvas.width; guide.height = canvas.height; const g = guide.getContext('2d'); g.setTransform(D, 0, 0, D, 0, 0);
51
+ g.strokeStyle = 'rgba(240,230,215,.35)'; g.lineWidth = 0.8;
52
+ for (const k of [0.06, 0.2, 0.235, 0.54, 0.6, 0.68, 0.76, 0.84, 0.86]) { g.beginPath(); g.arc(cx, cy, k * R, 0, 7); g.stroke(); }
53
+ g.strokeRect(cx - 0.38 * R, cy - 0.38 * R, 0.76 * R, 0.76 * R);
54
+ for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2; g.beginPath(); g.moveTo(cx, cy); g.lineTo(cx + Math.cos(a) * 0.86 * R, cy + Math.sin(a) * 0.86 * R); g.stroke(); }
55
+ sand = document.createElement('canvas'); sand.width = canvas.width; sand.height = canvas.height; sg = sand.getContext('2d'); sg.setTransform(D, 0, 0, D, 0, 0);
56
+ tmp = document.createElement('canvas'); tmp.width = canvas.width; tmp.height = canvas.height;
57
+ restart();
58
+ };
59
+ let progress = 0, phase = 'build', phaseT = 0, theta = 0;
60
+ function restart() { sg.setTransform(1, 0, 0, 1, 0, 0); sg.clearRect(0, 0, sand.width, sand.height); sg.setTransform(D, 0, 0, D, 0, 0); progress = 0; phase = 'build'; phaseT = 0; }
61
+ let swept = [];
62
+ function gather() {
63
+ const img = sg.getImageData(0, 0, sand.width, sand.height).data, groups = new Map();
64
+ for (let n = 0; n < 70000; n++) {
65
+ const px = (Math.random() * sand.width) | 0, py = (Math.random() * sand.height) | 0, o = (py * sand.width + px) * 4;
66
+ if (img[o + 3] < 200) continue;
67
+ const key = `rgb(${img[o] & 0xf8},${img[o + 1] & 0xf8},${img[o + 2] & 0xf8})`;
68
+ let gp = groups.get(key); if (!gp) groups.set(key, gp = { style: key, g: [] });
69
+ const x = px / D - cx, y = py / D - cy;
70
+ gp.g.push(Math.hypot(x, y) / R, Math.atan2(y, x), 0.8 + Math.random() * 1.6, Math.random() ** 2 * 0.12); // radius, angle, speed, where it ends up in the pile
71
+ }
72
+ swept = [...groups.values()];
73
+ }
74
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
75
+
76
+ let raf, last = performance.now();
77
+ function frame(now) {
78
+ const dt = Math.min(0.05, (now - last) / 1000) * PARAMS.speed; last = now; phaseT += dt;
79
+ if (phase === 'build') {
80
+ // the funnels work outward; each grain lands near one of them, mirrored round the circle
81
+ progress = Math.min(1, progress + dt / 21); theta += dt * 2.4;
82
+ const S = Math.max(3, Math.round(PARAMS.symmetry)), band = 0.07, rc = 0.03 + progress * 0.85;
83
+ const n = Math.round(PARAMS.grains), buckets = new Map();
84
+ for (let i = 0; i < n; i++) {
85
+ const k = i % S, a = theta * (0.8 + 0.4 * ((i >> 3) % 2)) + (k / S) * Math.PI * 2 + (Math.random() - 0.5) * 0.9, r = Math.max(0, rc - Math.random() * band);
86
+ const x = Math.cos(a) * r, y = Math.sin(a) * r, col = paint(r, ((a % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2), x, y);
87
+ if (!col) continue;
88
+ const j = (Math.random() * 3) | 0, key = col.join() + j; let b = buckets.get(key); if (!b) buckets.set(key, b = { col, j, pts: [] });
89
+ b.pts.push(cx + x * R, cy + y * R);
90
+ }
91
+ for (const { col, j, pts } of buckets.values()) {
92
+ const f = [0.82, 1, 1.12][j]; sg.fillStyle = `rgb(${Math.min(255, col[0] * f) | 0},${Math.min(255, col[1] * f) | 0},${Math.min(255, col[2] * f) | 0})`;
93
+ const s = Math.max(1, R / 300);
94
+ for (let i = 0; i < pts.length; i += 2) sg.fillRect(pts[i], pts[i + 1], s, s);
95
+ }
96
+ if (progress >= 1) { phase = 'admire'; phaseT = 0; }
97
+ } else if (phase === 'admire' && phaseT > 5) { phase = 'sweep'; phaseT = 0; gather(); }
98
+ else if (phase === 'sweep') {
99
+ // a brush comes in from the rim; every grain it passes is swept round and inward to the pile at the centre
100
+ const brush = 1 - phaseT / 4.2;
101
+ sg.setTransform(1, 0, 0, 1, 0, 0); sg.clearRect(0, 0, sand.width, sand.height); sg.setTransform(D, 0, 0, D, 0, 0);
102
+ const sz = Math.max(1, R / 300);
103
+ for (const b of swept) {
104
+ sg.fillStyle = b.style;
105
+ for (let i = 0; i < b.g.length; i += 4) {
106
+ let r = b.g[i], a = b.g[i + 1]; const end = b.g[i + 3];
107
+ if (r > brush * 0.86 && r > end) { const k = Math.min(1, dt * b.g[i + 2] * 0.55); r += (end - r) * k; a += dt * 3.2 * (1.1 - r) * b.g[i + 2] * 0.5; b.g[i] = r; b.g[i + 1] = a; }
108
+ sg.fillRect(cx + Math.cos(a) * r * R, cy + Math.sin(a) * r * R, sz, sz);
109
+ }
110
+ }
111
+ if (phaseT > 6.5) restart();
112
+ }
113
+ ctx.drawImage(cloth, 0, 0, W, H);
114
+ if (phase === 'build') { ctx.globalAlpha = 1 - progress * 0.6; ctx.drawImage(guide, 0, 0, W, H); ctx.globalAlpha = 1; }
115
+ ctx.drawImage(sand, 0, 0, W, H);
116
+ if (phase === 'sweep' && phaseT > 5.5) { ctx.fillStyle = `rgba(58,20,22,${(phaseT - 5.5)})`; ctx.fillRect(0, 0, W, H); }
117
+ raf = requestAnimationFrame(frame);
118
+ }
119
+ raf = requestAnimationFrame(frame);
120
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
121
+ }