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,155 @@
1
+ // Galton board: glass beads pour through a triangle of brass pegs. At every peg a bead goes left or right, more or
2
+ // less a coin toss, so where it lands is the sum of a dozen coin tosses, and sums of coin tosses pile up as a bell
3
+ // curve: the binomial distribution, which tends to the normal one. The beads are real little bodies: they bounce
4
+ // off pegs, knock into each other and stack in the bins. Their colour shifts as the run goes on, so each bin shows its
5
+ // history in layers. The chalk line is what theory expects for this many beads. Then the floor opens and it starts again.
6
+ const PARAMS = { beads: 400, rows: 12, rate: 26 };
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 ctx = canvas.getContext('2d');
13
+ let W = 1, H = 1, u = 1, ox = 0, oy = 0;
14
+ const R = Math.max(4, Math.min(16, Math.round(PARAMS.rows))), NB = Math.max(50, Math.round(PARAMS.beads));
15
+ const resize = () => {
16
+ const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1;
17
+ canvas.width = Math.max(1, (W * d) | 0); canvas.height = Math.max(1, (H * d) | 0); ctx.setTransform(d, 0, 0, d, 0, 0);
18
+ u = Math.min(W * 0.92, (H * 0.96) / 1.04); ox = (W - u) / 2; oy = (H - u * 1.04) / 2;
19
+ sprites = makeSprites(); restart();
20
+ };
21
+ // geometry in board units (the board is 1 wide)
22
+ const dx = 0.78 / (R + 1), dy = dx * 0.86, rb = Math.min(0.0058, dx * 0.085), rp = rb * 1.3;
23
+ const top = 0.15, rowY = i => top + i * dy, binTop = rowY(R - 1) + dy * 0.7, floor = 1.0;
24
+ // a full lattice of pegs between the side walls: rows alternate by half a step, and the top row has one dead centre
25
+ const pegs = []; for (let i = 0; i < R; i++) for (let k = -R; k <= R; k++) { const x = 0.5 + (k + (i % 2) * 0.5) * dx; if (Math.abs(x - 0.5) < (R + 1) / 2 * dx - dx * 0.35) pegs.push([x, rowY(i)]); }
26
+ const dividers = []; for (let k = 0; k <= R + 1; k++) dividers.push(0.5 + (k - (R + 1) / 2) * dx);
27
+ const funnel = [[0.28, 0.0, 0.475, 0.085], [0.72, 0.0, 0.525, 0.085]];
28
+ const walls = funnel;
29
+
30
+ // a shaded glass bead per hue, drawn once
31
+ let sprites = [];
32
+ const makeSprites = () => Array.from({ length: 16 }, (_, k) => {
33
+ const c = document.createElement('canvas'), s = 24; c.width = c.height = s; const g = c.getContext('2d');
34
+ const hue = 195 - k * 11, gr = g.createRadialGradient(s * 0.38, s * 0.34, 0, s / 2, s / 2, s / 2);
35
+ gr.addColorStop(0, `hsl(${hue} 90% 88%)`); gr.addColorStop(0.35, `hsl(${hue} 75% 58%)`); gr.addColorStop(0.85, `hsl(${hue} 70% 28%)`); gr.addColorStop(1, `hsla(${hue} 70% 18% / 0)`);
36
+ g.fillStyle = gr; g.beginPath(); g.arc(s / 2, s / 2, s / 2, 0, 7); g.fill();
37
+ g.fillStyle = 'rgba(255,255,255,.85)'; g.beginPath(); g.arc(s * 0.36, s * 0.32, s * 0.09, 0, 7); g.fill();
38
+ return c;
39
+ });
40
+
41
+ let bx, by, vx, vy, hue, bin, live, made, phase, phaseT;
42
+ function restart() { bx = new Float32Array(NB); by = new Float32Array(NB); vx = new Float32Array(NB); vy = new Float32Array(NB); hue = new Uint8Array(NB); bin = new Int8Array(NB).fill(-1); live = 0; made = 0; phase = 'pour'; phaseT = 0; }
43
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
44
+
45
+ const G = 2.4, E = 0.2, WALL = 0.0015;
46
+ const hashCell = rb * 2.2, grid = new Map();
47
+ function step(h, open) {
48
+ for (let i = 0; i < live; i++) { vy[i] = Math.min(0.95, vy[i] + G * h); vx[i] *= 1 - 1.5 * h; bx[i] += vx[i] * h; by[i] += vy[i] * h; } // a speed cap, so nothing tunnels through a peg
49
+ for (let i = 0; i < live; i++) {
50
+ // pegs: push out, bounce, and a hair of noise (real boards are chaotic: tiny differences decide left or right)
51
+ if (by[i] > top - 0.03 && by[i] < binTop) for (const [px, py] of pegs) {
52
+ const ex = bx[i] - px, ey = by[i] - py, d2 = ex * ex + ey * ey, m = rb + rp;
53
+ if (d2 < m * m && d2 > 1e-12) {
54
+ const d = Math.sqrt(d2), nx = ex / d, ny = ey / d; bx[i] = px + nx * m; by[i] = py + ny * m;
55
+ const vn = vx[i] * nx + vy[i] * ny;
56
+ if (vn < 0) {
57
+ vx[i] -= (1 + E) * vn * nx; vy[i] -= (1 + E) * vn * ny;
58
+ vx[i] = Math.max(-0.21, Math.min(0.21, vx[i] * 0.6 + (Math.random() - 0.5) * 0.05)); vy[i] *= 0.5; // pegs soak up energy: about half a step sideways per row
59
+ }
60
+ }
61
+ }
62
+ // the funnel's two slopes
63
+ for (const [x1, y1, x2, y2] of walls) {
64
+ const sx = x2 - x1, sy = y2 - y1, t = Math.max(0, Math.min(1, ((bx[i] - x1) * sx + (by[i] - y1) * sy) / (sx * sx + sy * sy)));
65
+ const qx = x1 + sx * t, qy = y1 + sy * t, ex = bx[i] - qx, ey = by[i] - qy, d = Math.hypot(ex, ey);
66
+ if (d < rb && d > 1e-9) { const nx = ex / d, ny = ey / d; bx[i] = qx + nx * rb; by[i] = qy + ny * rb; const vn = vx[i] * nx + vy[i] * ny; if (vn < 0) { vx[i] -= 1.3 * vn * nx; vy[i] -= 1.3 * vn * ny; } }
67
+ }
68
+ // bin walls and the floor
69
+ if (bin[i] < 0 && by[i] > binTop) bin[i] = Math.max(0, Math.min(R, Math.floor((bx[i] - dividers[0]) / dx))); // once in a bin, it stays there
70
+ if (bx[i] < dividers[0] + rb) { bx[i] = dividers[0] + rb; vx[i] = Math.abs(vx[i]) * 0.3; }
71
+ if (bx[i] > dividers[R + 1] - rb) { bx[i] = dividers[R + 1] - rb; vx[i] = -Math.abs(vx[i]) * 0.3; }
72
+ if (!open && by[i] > floor - rb) { by[i] = floor - rb; if (vy[i] > 0) vy[i] *= -0.15; vx[i] *= 0.6; }
73
+ }
74
+ // beads against beads: a spatial hash, then push overlapping pairs apart and kill their closing speed
75
+ for (let pass = 0; pass < 2; pass++) { // two relaxation passes keep the stacks from sagging
76
+ grid.clear();
77
+ for (let i = 0; i < live; i++) { const k = ((bx[i] / hashCell) | 0) * 4096 + ((by[i] / hashCell) | 0); let a = grid.get(k); if (!a) grid.set(k, a = []); a.push(i); }
78
+ for (let i = 0; i < live; i++) {
79
+ const cx = (bx[i] / hashCell) | 0, cy = (by[i] / hashCell) | 0;
80
+ for (let gx = cx - 1; gx <= cx + 1; gx++) for (let gy = cy - 1; gy <= cy + 1; gy++) {
81
+ const a = grid.get(gx * 4096 + gy); if (!a) continue;
82
+ for (const j of a) {
83
+ if (j <= i) continue;
84
+ const ex = bx[j] - bx[i], ey = by[j] - by[i], d2 = ex * ex + ey * ey, m = rb * 2;
85
+ if (d2 < m * m && d2 > 1e-12) {
86
+ const d = Math.sqrt(d2), nx = ex / d, ny = ey / d, push = (m - d) * 0.5;
87
+ bx[i] -= nx * push; by[i] -= ny * push; bx[j] += nx * push; by[j] += ny * push;
88
+ const rel = (vx[j] - vx[i]) * nx + (vy[j] - vy[i]) * ny;
89
+ if (rel < 0) { const imp = rel * 0.55; vx[i] += imp * nx; vy[i] += imp * ny; vx[j] -= imp * nx; vy[j] -= imp * ny; }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ for (let i = 0; i < live; i++) {
96
+ const b = bin[i]; if (b < 0) continue;
97
+ const lo = dividers[b] + rb + WALL, hi = dividers[b + 1] - rb - WALL;
98
+ if (bx[i] < lo) { bx[i] = lo; vx[i] = Math.abs(vx[i]) * 0.3; } else if (bx[i] > hi) { bx[i] = hi; vx[i] = -Math.abs(vx[i]) * 0.3; }
99
+ vx[i] *= 0.97; if (Math.abs(vy[i]) < 0.03) vy[i] *= 0.85; // settle the stacks
100
+ if (!open && by[i] > floor - rb) by[i] = floor - rb;
101
+ }
102
+ }
103
+
104
+ // the bell curve: expected beads per bin for this many beads, as a stack height
105
+ const choose = (n, k) => { let r = 1; for (let i = 1; i <= k; i++) r = (r * (n - k + i)) / i; return r; };
106
+ const perRow = dx / (rb * 2), rowH = rb * 1.62; // how tightly the beads actually pack
107
+
108
+ let raf, last = performance.now(), spawnAcc = 0;
109
+ function frame(now) {
110
+ const dt = Math.min(0.033, (now - last) / 1000); last = now; phaseT += dt;
111
+ if (phase === 'pour') {
112
+ spawnAcc += dt * PARAMS.rate;
113
+ while (spawnAcc > 1 && made < NB) { spawnAcc--; const i = live++; bx[i] = 0.47 + Math.random() * 0.06; by[i] = -0.02 - Math.random() * 0.03; vx[i] = (Math.random() - 0.5) * 0.05; vy[i] = 0.2; hue[i] = Math.min(15, Math.floor((made / NB) * 16)); made++; }
114
+ if (made >= NB && phaseT > NB / PARAMS.rate + 4) { phase = 'drain'; phaseT = 0; }
115
+ } else if (phase === 'drain' && phaseT > 3.2) restart();
116
+ const sub = 6, h = dt / sub;
117
+ for (let s = 0; s < sub; s++) step(h, phase === 'drain' && phaseT > 0.9);
118
+
119
+ // draw the board
120
+ const g = ctx.createLinearGradient(0, 0, 0, H); g.addColorStop(0, '#1a1d24'); g.addColorStop(1, '#101217');
121
+ ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
122
+ const X = x => ox + x * u, Y = y => oy + y * u;
123
+ ctx.fillStyle = 'rgba(255,255,255,.035)'; ctx.fillRect(X(dividers[0]) - 6, Y(0.11), (dividers[R + 1] - dividers[0]) * u + 12, (floor - 0.11) * u + 6); // the glass panel
124
+ ctx.strokeStyle = '#8b6a3a'; ctx.lineWidth = Math.max(1.5, u * 0.006); ctx.lineCap = 'round';
125
+ ctx.beginPath(); for (const [x1, y1, x2, y2] of funnel) { ctx.moveTo(X(x1), Y(y1)); ctx.lineTo(X(x2), Y(y2)); } ctx.stroke();
126
+ ctx.strokeStyle = 'rgba(210,220,235,.32)'; ctx.lineWidth = Math.max(1, u * 0.0028);
127
+ ctx.beginPath(); for (const x of dividers) { ctx.moveTo(X(x), Y(binTop)); ctx.lineTo(X(x), Y(floor)); } ctx.stroke();
128
+ const open = phase === 'drain' && phaseT > 0.9;
129
+ ctx.strokeStyle = open ? 'rgba(210,220,235,.12)' : 'rgba(210,220,235,.5)'; ctx.lineWidth = Math.max(1.5, u * 0.004);
130
+ ctx.beginPath(); ctx.moveTo(X(dividers[0]), Y(floor)); ctx.lineTo(X(dividers[R + 1]), Y(floor)); ctx.stroke();
131
+ for (const [px, py] of pegs) { // brass pegs
132
+ const r = rp * u, pg = ctx.createRadialGradient(X(px) - r * 0.35, Y(py) - r * 0.35, 0, X(px), Y(py), r);
133
+ pg.addColorStop(0, '#fff1c4'); pg.addColorStop(0.4, '#d4a64a'); pg.addColorStop(1, '#6b4c18');
134
+ ctx.fillStyle = pg; ctx.beginPath(); ctx.arc(X(px), Y(py), r, 0, 7); ctx.fill();
135
+ }
136
+ // the curve theory predicts for the beads poured so far
137
+ if (made > 20) {
138
+ ctx.strokeStyle = 'rgba(255,248,230,.55)'; ctx.lineWidth = 1.4; ctx.setLineDash([4, 4]); ctx.beginPath();
139
+ const mean = R / 2, sd = Math.sqrt(R) / 2;
140
+ for (let k = 0; k <= 80; k++) {
141
+ const b = (k / 80) * (R + 1) - 0.5, x = 0.5 + (b - mean) * dx, expect = (made / (sd * Math.sqrt(2 * Math.PI))) * Math.exp(-((b - mean) ** 2) / (2 * sd * sd));
142
+ const y = floor - (expect / perRow) * rowH; k ? ctx.lineTo(X(x), Y(Math.max(binTop - 0.02, y))) : ctx.moveTo(X(x), Y(Math.max(binTop - 0.02, y)));
143
+ }
144
+ ctx.stroke(); ctx.setLineDash([]);
145
+ }
146
+ // beads
147
+ const br = rb * u * 1.12;
148
+ for (let i = 0; i < live; i++) { if (by[i] > 1.2) continue; ctx.drawImage(sprites[hue[i]], X(bx[i]) - br, Y(by[i]) - br, br * 2, br * 2); }
149
+ ctx.fillStyle = 'rgba(230,236,245,.5)'; ctx.font = `500 ${Math.max(10, Math.round(u * 0.022))}px ui-monospace, Menlo, monospace`; ctx.textAlign = 'left';
150
+ ctx.fillText(`${made} beads · ${R} rows · p = ½`, X(dividers[0]), Y(0.035));
151
+ raf = requestAnimationFrame(frame);
152
+ }
153
+ raf = requestAnimationFrame(frame);
154
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
155
+ }
@@ -0,0 +1,73 @@
1
+ // GPU particles: 262,144 particles whose positions live in a float texture. A fragment shader moves every one
2
+ // through 3D curl noise each frame (ping-pong between two textures); the vertex shader reads them back as points.
3
+ const PARAMS = { size: 512, speed: 1 };
4
+
5
+ export default function (stage) {
6
+ const canvas = document.createElement('canvas');
7
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
8
+ stage.append(canvas);
9
+ const gl = canvas.getContext('webgl2', { antialias: false });
10
+ if (!gl || !gl.getExtension('EXT_color_buffer_float')) { stage.textContent = 'This needs WebGL2 with float render targets.'; return () => canvas.remove(); }
11
+ const N = Math.max(64, Math.round(PARAMS.size)), COUNT = N * N;
12
+ const quadVS = `#version 300 es
13
+ in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
14
+ const simFS = `#version 300 es
15
+ precision highp float; in vec2 uv; out vec4 o; uniform sampler2D S; uniform float T, dt;
16
+ vec3 h3(vec3 p) { p = fract(p * vec3(.1031, .103, .0973)); p += dot(p, p.yxz + 33.33); return fract((p.xxy + p.yxx) * p.zyx) * 2. - 1.; }
17
+ float n3(vec3 p) { vec3 i = floor(p), f = fract(p), u = f * f * (3. - 2. * f);
18
+ return mix(mix(mix(dot(h3(i), f), dot(h3(i + vec3(1,0,0)), f - vec3(1,0,0)), u.x), mix(dot(h3(i + vec3(0,1,0)), f - vec3(0,1,0)), dot(h3(i + vec3(1,1,0)), f - vec3(1,1,0)), u.x), u.y),
19
+ mix(mix(dot(h3(i + vec3(0,0,1)), f - vec3(0,0,1)), dot(h3(i + vec3(1,0,1)), f - vec3(1,0,1)), u.x), mix(dot(h3(i + vec3(0,1,1)), f - vec3(0,1,1)), dot(h3(i + vec3(1,1,1)), f - vec3(1,1,1)), u.x), u.y), u.z); }
20
+ vec3 pot(vec3 p) { return vec3(n3(p), n3(p + 31.4), n3(p - 17.9)); }
21
+ vec3 curl(vec3 p) { const float e = .05; vec3 dx = vec3(e, 0, 0), dy = vec3(0, e, 0), dz = vec3(0, 0, e);
22
+ vec3 a = (pot(p + dy) - pot(p - dy)), b = (pot(p + dz) - pot(p - dz)), c = (pot(p + dx) - pot(p - dx));
23
+ return vec3(a.z - b.y, b.x - c.z, c.y - a.x) / (2. * e); }
24
+ void main() {
25
+ vec4 s = texture(S, uv); vec3 p = s.xyz; float life = s.w - dt * .12;
26
+ p += curl(p * 1.1 + T * .05) * dt * .18;
27
+ p -= p * dt * .05 * dot(p, p); // a soft pull keeps the cloud together
28
+ if (life < 0.) { vec3 r = h3(vec3(uv * 91.7, T)); p = normalize(r) * .35 * (.5 + .5 * fract(r.x * 7.)); life = 1.; }
29
+ o = vec4(p, life);
30
+ }`;
31
+ const drawVS = `#version 300 es
32
+ uniform sampler2D S; uniform int N; uniform float T; uniform vec2 R; out float k; out float l;
33
+ void main() {
34
+ ivec2 c = ivec2(gl_VertexID % N, gl_VertexID / N); vec4 s = texelFetch(S, c, 0); vec3 p = s.xyz;
35
+ float a = T * .15, ca = cos(a), sa = sin(a); p = vec3(p.x * ca - p.z * sa, p.y, p.x * sa + p.z * ca);
36
+ float z = p.z + 2.2;
37
+ gl_Position = vec4(p.x / z * 2.4 * R.y / R.x, p.y / z * 2.4, 0, 1); gl_PointSize = R.y / 700. + 1.;
38
+ k = clamp(length(s.xyz) * 1.4, 0., 1.); l = s.w;
39
+ }`;
40
+ const drawFS = `#version 300 es
41
+ precision mediump float; in float k; in float l; out vec4 o;
42
+ void main() { vec3 c = mix(vec3(1., .55, .18), vec3(.25, .65, 1.), k); o = vec4(c * .09 * smoothstep(0., .2, l) * smoothstep(1., .85, l), 1); }`;
43
+ const sh = (t, s) => { const x = gl.createShader(t); gl.shaderSource(x, s); gl.compileShader(x); if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)); return x; };
44
+ const mk = (v, f) => { const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, v)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, f)); gl.bindAttribLocation(p, 0, 'p'); gl.linkProgram(p); return p; };
45
+ const sim = mk(quadVS, simFS), draw = mk(drawVS, drawFS);
46
+ const quad = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, quad); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
47
+ const init = new Float32Array(COUNT * 4);
48
+ for (let i = 0; i < COUNT; i++) { const r = Math.cbrt(Math.random()) * 0.6, t = Math.random() * 6.28, u = Math.random() * 2 - 1, s = Math.sqrt(1 - u * u); init.set([Math.cos(t) * s * r, u * r, Math.sin(t) * s * r, Math.random()], i * 4); }
49
+ const tgt = () => { const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, N, N, 0, gl.RGBA, gl.FLOAT, init);
50
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
51
+ const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); return { tex, fb }; };
52
+ let A = tgt(), B = tgt();
53
+ const vao = gl.createVertexArray();
54
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); canvas.width = stage.clientWidth * d | 0; canvas.height = stage.clientHeight * d | 0; };
55
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
56
+ let last = performance.now(), raf;
57
+ function frame(now) {
58
+ const dt = Math.min(0.033, (now - last) / 1000) * PARAMS.speed; last = now;
59
+ gl.disable(gl.BLEND); gl.useProgram(sim); gl.bindBuffer(gl.ARRAY_BUFFER, quad); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
60
+ gl.bindFramebuffer(gl.FRAMEBUFFER, B.fb); gl.viewport(0, 0, N, N); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, A.tex);
61
+ gl.uniform1i(gl.getUniformLocation(sim, 'S'), 0); gl.uniform1f(gl.getUniformLocation(sim, 'T'), now / 1000); gl.uniform1f(gl.getUniformLocation(sim, 'dt'), dt);
62
+ gl.drawArrays(gl.TRIANGLES, 0, 3); [A, B] = [B, A];
63
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, canvas.width, canvas.height);
64
+ gl.clearColor(0.015, 0.018, 0.03, 1); gl.clear(gl.COLOR_BUFFER_BIT);
65
+ gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); gl.useProgram(draw); gl.bindVertexArray(vao);
66
+ gl.bindTexture(gl.TEXTURE_2D, A.tex); gl.uniform1i(gl.getUniformLocation(draw, 'S'), 0); gl.uniform1i(gl.getUniformLocation(draw, 'N'), N);
67
+ gl.uniform1f(gl.getUniformLocation(draw, 'T'), now / 1000); gl.uniform2f(gl.getUniformLocation(draw, 'R'), canvas.width, canvas.height);
68
+ gl.drawArrays(gl.POINTS, 0, COUNT); gl.bindVertexArray(null);
69
+ raf = requestAnimationFrame(frame);
70
+ }
71
+ raf = requestAnimationFrame(frame);
72
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
73
+ }
@@ -0,0 +1,61 @@
1
+ // Harmonograph: the Victorian drawing machine. Two pendulums move the pen left-right and up-down, and a third swings
2
+ // the paper; each is a decaying sine. When their frequencies sit near a simple ratio (2:3, 3:4, 1:2) the pen traces
3
+ // a slowly precessing rosette, and the tiny detuning is what makes the loops creep round. As the swing dies down the
4
+ // figure spirals into the centre; then the paper is changed and a new ratio is drawn.
5
+ const PARAMS = { speed: 1, decay: 0.6 };
6
+
7
+ export default function (stage) {
8
+ const canvas = document.createElement('canvas');
9
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
10
+ stage.append(canvas);
11
+ const ctx = canvas.getContext('2d');
12
+ const paper = document.createElement('canvas'), pctx = paper.getContext('2d');
13
+ let W = 0, H = 0, d = 1;
14
+ const resize = () => { d = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1; canvas.width = paper.width = W * d; canvas.height = paper.height = H * d; ctx.setTransform(d, 0, 0, d, 0, 0); pctx.setTransform(d, 0, 0, d, 0, 0); fresh(); };
15
+ const RATIOS = [[2, 3], [3, 4], [1, 2], [3, 5], [4, 5], [1, 3], [2, 5]];
16
+ const INKS = ['#1d3c6e', '#7a1e3a', '#1f5b4a', '#5a3d8a', '#8a4a12'];
17
+ let pend, time = 0, prev = null, ink, nth = 0;
18
+ function fresh() {
19
+ const [p, q] = RATIOS[nth % RATIOS.length], base = 1.6, det = () => (Math.random() - 0.5) * 0.012;
20
+ pend = [
21
+ { f: base * p + det(), ph: Math.random() * 6.28, a: 1 }, // pen x
22
+ { f: base * q + det(), ph: Math.random() * 6.28, a: 1 }, // pen y
23
+ { f: base * p + det() * 2, ph: Math.random() * 6.28, a: 0.35 + Math.random() * 0.2 }, // paper x
24
+ { f: base * q + det() * 2, ph: Math.random() * 6.28, a: 0.35 + Math.random() * 0.2 }, // paper y
25
+ ];
26
+ ink = INKS[nth % INKS.length]; nth++; time = 0; prev = null;
27
+ pctx.fillStyle = '#f3ede0'; pctx.fillRect(0, 0, W, H);
28
+ for (let i = 0; i < 600; i++) { pctx.fillStyle = `rgba(120,100,70,${Math.random() * 0.04})`; pctx.fillRect(Math.random() * W, Math.random() * H, 1, 1); } // paper grain
29
+ }
30
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
31
+ const pos = t => {
32
+ const k = 0.01 + 0.05 * PARAMS.decay, e = Math.exp(-k * t), S = Math.min(W, H) * 0.42;
33
+ const [a, b, c, dd] = pend;
34
+ return [W / 2 + S * e * (a.a * Math.sin(a.f * t + a.ph) + c.a * Math.sin(c.f * t + c.ph)) / 1.5, H / 2 + S * e * (b.a * Math.sin(b.f * t + b.ph) + dd.a * Math.sin(dd.f * t + dd.ph)) / 1.5, e];
35
+ };
36
+ let raf, last = performance.now(), hold = 0;
37
+ function frame(now) {
38
+ const dt = Math.min(0.05, (now - last) / 1000); last = now;
39
+ if (hold > 0) { hold -= dt; if (hold <= 0) fresh(); }
40
+ else {
41
+ // many small sub-steps per frame keep the curve smooth
42
+ const steps = 80, h = dt * PARAMS.speed * 5 / steps;
43
+ pctx.strokeStyle = ink; pctx.lineCap = 'round'; pctx.lineJoin = 'round';
44
+ for (let i = 0; i < steps; i++) {
45
+ time += h; const [x, y, e] = pos(time);
46
+ if (prev) { pctx.globalAlpha = 0.55 + 0.4 * e; pctx.lineWidth = 0.55 + 0.5 * e; pctx.beginPath(); pctx.moveTo(prev[0], prev[1]); pctx.lineTo(x, y); pctx.stroke(); }
47
+ prev = [x, y];
48
+ if (e < 0.04) { hold = 1.6; break; } // the swing has died: admire it, then change the paper
49
+ }
50
+ pctx.globalAlpha = 1;
51
+ }
52
+ ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(paper, 0, 0); ctx.setTransform(d, 0, 0, d, 0, 0);
53
+ if (prev && hold <= 0) { // the pen nib
54
+ ctx.fillStyle = ink; ctx.beginPath(); ctx.arc(prev[0], prev[1], 3, 0, 7); ctx.fill();
55
+ ctx.strokeStyle = 'rgba(0,0,0,.18)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(prev[0], prev[1]); ctx.lineTo(prev[0] + 40, prev[1] - 60); ctx.stroke();
56
+ }
57
+ raf = requestAnimationFrame(frame);
58
+ }
59
+ raf = requestAnimationFrame(frame);
60
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
61
+ }
@@ -0,0 +1,153 @@
1
+ // Holi powder: handfuls of coloured powder thrown up into a bright sky, billowing and drifting as they slow. Every
2
+ // burst is a few thousand soft puffs that drag to a stop, swell and swirl in turbulence. To make them read as clouds
3
+ // rather than flat blobs, the puffs shade each other: each frame their density is splatted into a coarse 3D grid,
4
+ // light is marched through the grid from the sun, and each puff is lit by what gets through. The sun is behind the
5
+ // powder, so thin edges glow and thick cores go dusky. Puffs are sorted far to near and drawn over one another.
6
+ const PARAMS = { bursts: 1, puffs: 1100, wind: 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: true });
13
+ if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
14
+ const PAL = [[0.95, 0.12, 0.55], [1, 0.82, 0.08], [0.1, 0.55, 0.95], [0.15, 0.78, 0.35], [1, 0.45, 0.08], [0.55, 0.22, 0.9], [0.98, 0.3, 0.3]];
15
+ const SUN = (() => { const v = [0.35, 0.55, 0.75]; const l = Math.hypot(...v); return v.map(x => x / l); })(); // high, a little right, behind the powder
16
+ const MAXP = 14000;
17
+ const P = { x: new Float32Array(MAXP), y: new Float32Array(MAXP), z: new Float32Array(MAXP), vx: new Float32Array(MAXP), vy: new Float32Array(MAXP), vz: new Float32Array(MAXP), age: new Float32Array(MAXP), life: new Float32Array(MAXP), s: new Float32Array(MAXP), r: new Float32Array(MAXP), g: new Float32Array(MAXP), b: new Float32Array(MAXP), rot: new Float32Array(MAXP), light: new Float32Array(MAXP) };
18
+ let count = 0;
19
+ const hands = [-3.6, -1.6, 0.4, 2.3, 4.1];
20
+ function burst() {
21
+ const n = Math.round(PARAMS.puffs), hx = hands[(Math.random() * hands.length) | 0] + (Math.random() - 0.5) * 0.6, hz = 7 + Math.random() * 2, col = PAL[(Math.random() * PAL.length) | 0];
22
+ const ax = (Math.random() - 0.5) * 1.1, power = 10 + Math.random() * 5;
23
+ for (let k = 0; k < n; k++) {
24
+ let i = count < MAXP ? count++ : (Math.random() * MAXP) | 0; // when full, overwrite an old puff
25
+ const u = Math.random(), a = Math.random() * 6.283, spread = 0.62 * Math.sqrt(u);
26
+ P.x[i] = hx; P.y[i] = -1.2; P.z[i] = hz;
27
+ const sp = power * (0.35 + 0.65 * Math.random());
28
+ P.vx[i] = (ax + Math.cos(a) * spread) * sp; P.vy[i] = sp; P.vz[i] = Math.sin(a) * spread * sp;
29
+ P.age[i] = 0; P.life[i] = 4.5 + Math.random() * 3; P.s[i] = 0.08 + Math.random() * 0.08; P.rot[i] = Math.random() * 6.283;
30
+ const j = (Math.random() - 0.5) * 0.12; P.r[i] = Math.min(1, col[0] + j); P.g[i] = Math.min(1, col[1] + j); P.b[i] = Math.min(1, col[2] + j);
31
+ }
32
+ }
33
+ // the shadow grid over the space where powder flies
34
+ const GX = 30, GY = 20, GZ = 16, X0 = -8, X1 = 8, Y0 = 0, Y1 = 9, Z0 = 3, Z1 = 13;
35
+ const dens = new Float32Array(GX * GY * GZ), trans = new Float32Array(GX * GY * GZ);
36
+ const cell = (x, y, z) => { const i = Math.floor((x - X0) / (X1 - X0) * GX), j = Math.floor((y - Y0) / (Y1 - Y0) * GY), k = Math.floor((z - Z0) / (Z1 - Z0) * GZ); return i < 0 || j < 0 || k < 0 || i >= GX || j >= GY || k >= GZ ? -1 : (k * GY + j) * GX + i; };
37
+ function shade() {
38
+ dens.fill(0);
39
+ for (let i = 0; i < count; i++) { const c = cell(P.x[i], P.y[i], P.z[i]); if (c >= 0) dens[c] += alphaOf(i) * P.s[i] * P.s[i] * 3; }
40
+ // march toward the sun from every cell, summing density: how much light reaches it
41
+ const sx = SUN[0] / (X1 - X0) * GX, sy = SUN[1] / (Y1 - Y0) * GY, sz = SUN[2] / (Z1 - Z0) * GZ, sl = Math.max(Math.abs(sx), Math.abs(sy), Math.abs(sz)), dx = sx / sl, dy = sy / sl, dz = sz / sl;
42
+ for (let k = 0; k < GZ; k++) for (let j = 0; j < GY; j++) for (let i = 0; i < GX; i++) {
43
+ let x = i + 0.5, y = j + 0.5, z = k + 0.5, tau = 0;
44
+ for (let s = 0; s < 20; s++) { x += dx; y += dy; z += dz; if (x < 0 || y < 0 || z < 0 || x >= GX || y >= GY || z >= GZ) break; tau += dens[((z | 0) * GY + (y | 0)) * GX + (x | 0)]; }
45
+ trans[(k * GY + j) * GX + i] = Math.exp(-tau * 0.6);
46
+ }
47
+ for (let i = 0; i < count; i++) { const c = cell(P.x[i], P.y[i], P.z[i]); P.light[i] = c >= 0 ? trans[c] : 1; }
48
+ }
49
+ const alphaOf = i => { const a = P.age[i], l = P.life[i]; return Math.min(1, a / 0.15) * Math.max(0, 1 - a / l) * 0.55 / (1 + a * 0.6); };
50
+
51
+ // GL: the sky and a crowd's silhouette; then the puffs as textured, rotated sprites
52
+ 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; };
53
+ const prog = (vs, fs) => {
54
+ const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(p);
55
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
56
+ 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); }
57
+ return { p, u };
58
+ };
59
+ const QUAD = `#version 300 es
60
+ layout(location = 0) in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
61
+ const skyP = prog(QUAD, `#version 300 es
62
+ precision highp float; in vec2 uv; out vec4 o; uniform vec2 R; uniform vec3 SUNS;
63
+ void main() {
64
+ vec3 c = mix(vec3(1., .9, .78), vec3(.36, .6, .93), smoothstep(.0, .95, uv.y));
65
+ vec2 q = (uv - SUNS.xy) * vec2(R.x / R.y, 1.); float d = length(q);
66
+ c += vec3(1., .95, .8) * (exp(-d * 9.) * .6 + smoothstep(.045, .035, d) * .9);
67
+ o = vec4(c, 1);
68
+ }`);
69
+ const puffP = prog(`#version 300 es
70
+ layout(location = 0) in vec2 c; layout(location = 1) in vec4 PS; layout(location = 2) in vec4 CL; // PS: x y z size; CL: r g b alpha
71
+ layout(location = 3) in vec2 RL; // rotation, light
72
+ uniform float A, FOC; uniform vec3 SUN; out vec2 q; out vec4 col; out float lit, rot, back;
73
+ void main() {
74
+ vec3 p = PS.xyz; vec2 v = vec2(p.x, p.y - 2.2) / p.z * FOC;
75
+ gl_Position = vec4((v.x + c.x * PS.w * FOC / p.z) / A, v.y + c.y * PS.w * FOC / p.z, 0, 1);
76
+ q = c; col = CL; lit = RL.y; rot = RL.x;
77
+ back = pow(max(0., dot(normalize(p), SUN)), 4.); // looking toward the sun through thin powder
78
+ }`, `#version 300 es
79
+ precision highp float; in vec2 q; in vec4 col; in float lit, rot, back; out vec4 o; uniform sampler2D TX;
80
+ void main() {
81
+ float c = cos(rot), s = sin(rot); vec2 r = mat2(c, -s, s, c) * q;
82
+ float tex = texture(TX, r * .5 + .5).r, a = col.a * tex;
83
+ if (a < .004) discard;
84
+ vec3 sky = vec3(.55, .7, .95), sunc = vec3(1., .93, .78);
85
+ vec3 cc = col.rgb * (sky * .55 + sunc * (.25 + lit * .8)) + col.rgb * sunc * lit * back * (1. - tex) * 1.6; // thin edges glow when backlit
86
+ o = vec4(cc * a, a);
87
+ }`);
88
+ // a soft, lumpy puff texture: noise inside a falling-off disc
89
+ const TS = 64, tdata = new Uint8Array(TS * TS * 4);
90
+ const hh = (x, y) => { const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; return s - Math.floor(s); };
91
+ const vn = (x, y) => { const xi = Math.floor(x), yi = Math.floor(y), u = x - xi, v = y - yi, su = u * u * (3 - 2 * u), sv = v * v * (3 - 2 * v); return hh(xi, yi) * (1 - su) * (1 - sv) + hh(xi + 1, yi) * su * (1 - sv) + hh(xi, yi + 1) * (1 - su) * sv + hh(xi + 1, yi + 1) * su * sv; };
92
+ for (let y = 0; y < TS; y++) for (let x = 0; x < TS; x++) {
93
+ const u = (x / (TS - 1)) * 2 - 1, v = (y / (TS - 1)) * 2 - 1, r = Math.hypot(u, v), n = vn(u * 3 + 9, v * 3) * 0.6 + vn(u * 7, v * 7 + 4) * 0.4;
94
+ const val = Math.max(0, Math.min(1, (1 - r) * 1.9 - 0.45 + (n - 0.5) * 1.3)) ** 1.1;
95
+ tdata.set([val * 255, 0, 0, 255], (y * TS + x) * 4);
96
+ }
97
+ const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, TS, TS, 0, gl.RGBA, gl.UNSIGNED_BYTE, tdata);
98
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 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);
99
+ const quadV = gl.createVertexArray(); gl.bindVertexArray(quadV);
100
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
101
+ const puffV = gl.createVertexArray(); gl.bindVertexArray(puffV);
102
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
103
+ const inst = new Float32Array(MAXP * 10), ib = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, ib); gl.bufferData(gl.ARRAY_BUFFER, inst.byteLength, gl.DYNAMIC_DRAW);
104
+ gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 40, 0); gl.vertexAttribDivisor(1, 1);
105
+ gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 4, gl.FLOAT, false, 40, 16); gl.vertexAttribDivisor(2, 1);
106
+ gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3, 2, gl.FLOAT, false, 40, 32); gl.vertexAttribDivisor(3, 1);
107
+ gl.bindVertexArray(null);
108
+
109
+ let W = 1, H = 1;
110
+ 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); };
111
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
112
+ const FOC = 1.5;
113
+ let raf, last = performance.now(), t = 0, next = 0, order = new Uint32Array(MAXP), depth = new Float32Array(MAXP);
114
+ for (let i = 0; i < 4; i++) { burst(); for (let s = 0; s < 40 + i * 30; s++) step(1 / 60); } // a sky already full of colour
115
+ function step(dt) {
116
+ t += dt;
117
+ for (let i = 0; i < count; i++) {
118
+ if (P.age[i] > P.life[i]) continue;
119
+ const drag = Math.exp(-dt * (2.4 + P.age[i]));
120
+ // turbulence: a smooth swirling field, plus a breeze to the right
121
+ const tx = Math.sin(P.y[i] * 1.3 + t * 0.7) + Math.sin(P.z[i] * 0.9 - t * 0.4), ty = Math.sin(P.z[i] * 1.1 + t * 0.6) * 0.6, tz = Math.cos(P.x[i] * 1.2 - t * 0.5);
122
+ P.vx[i] = P.vx[i] * drag + (tx * 0.9 + 0.6 * PARAMS.wind) * dt; P.vy[i] = P.vy[i] * drag + (ty * 0.9 - 0.25) * dt; P.vz[i] = P.vz[i] * drag + tz * 0.9 * dt;
123
+ P.x[i] += P.vx[i] * dt; P.y[i] += P.vy[i] * dt; P.z[i] += P.vz[i] * dt;
124
+ P.age[i] += dt; P.s[i] += dt * (0.14 + 0.3 / (1 + P.age[i] * 2)); P.rot[i] += dt * 0.3 * ((i & 1) * 2 - 1);
125
+ }
126
+ }
127
+ function frame(now) {
128
+ const dt = Math.min(0.05, (now - last) / 1000); last = now;
129
+ step(dt);
130
+ if (t > next) { burst(); next = t + (0.7 + Math.random() * 0.9) / Math.max(0.2, PARAMS.bursts); }
131
+ // compact out the dead now and then
132
+ if (Math.random() < 0.05) { let j = 0; for (let i = 0; i < count; i++) if (P.age[i] <= P.life[i]) { if (i !== j) for (const k in P) P[k][j] = P[k][i]; j++; } count = j; }
133
+ shade();
134
+ // sort far to near, then pack the instance buffer
135
+ let n = 0; for (let i = 0; i < count; i++) if (P.age[i] <= P.life[i]) { order[n] = i; depth[i] = P.z[i]; n++; }
136
+ const ord = order.subarray(0, n).sort((a, b) => depth[b] - depth[a]);
137
+ for (let k = 0; k < n; k++) { const i = ord[k], o = k * 10; inst[o] = P.x[i]; inst[o + 1] = P.y[i]; inst[o + 2] = P.z[i]; inst[o + 3] = P.s[i]; inst[o + 4] = P.r[i]; inst[o + 5] = P.g[i]; inst[o + 6] = P.b[i]; inst[o + 7] = alphaOf(i); inst[o + 8] = P.rot[i]; inst[o + 9] = P.light[i]; }
138
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H); gl.disable(gl.BLEND);
139
+ gl.useProgram(skyP.p); gl.uniform2f(skyP.u.R, W, H);
140
+ // where the sun sits on screen
141
+ const sz = SUN[2], sv = [SUN[0] / sz * FOC / (W / H), (SUN[1] / sz) * FOC]; gl.uniform3f(skyP.u.SUNS, sv[0] * 0.5 + 0.5, Math.min(0.92, sv[1] * 0.5 + 0.5), 0);
142
+ gl.bindVertexArray(quadV); gl.drawArrays(gl.TRIANGLES, 0, 3);
143
+ gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
144
+ gl.useProgram(puffP.p); gl.uniform1f(puffP.u.A, W / H); gl.uniform1f(puffP.u.FOC, FOC); gl.uniform3f(puffP.u.SUN, ...SUN);
145
+ gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex); gl.uniform1i(puffP.u.TX, 0);
146
+ gl.bindVertexArray(puffV); gl.bindBuffer(gl.ARRAY_BUFFER, ib); gl.bufferSubData(gl.ARRAY_BUFFER, 0, inst, 0, n * 10);
147
+ gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, n);
148
+ gl.disable(gl.BLEND); gl.bindVertexArray(null);
149
+ raf = requestAnimationFrame(frame);
150
+ }
151
+ raf = requestAnimationFrame(frame);
152
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
153
+ }
@@ -0,0 +1,112 @@
1
+ // Iron filings: thousands of steel slivers scattered on paper over hidden bar magnets. Each sliver turns to lie along
2
+ // the field, as a tiny compass would. When the paper is tapped the filings hop, and they settle onto field lines:
3
+ // they are pulled across the field toward the nearest contour of the flux function (in 2D, the sum of each pole's
4
+ // angle, weighted by its charge), which is where real filings chain up end to end. Drag a magnet and tap again.
5
+ const PARAMS = { filings: 9000, lines: 36, taps: 1 };
6
+
7
+ export default function (stage) {
8
+ const canvas = document.createElement('canvas');
9
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab';
10
+ stage.append(canvas);
11
+ const ctx = canvas.getContext('2d');
12
+ let W = 1, H = 1, D = 1, paper = null;
13
+ // paper: warm cream, a little fibre, a vignette, all drawn once
14
+ const makePaper = () => {
15
+ paper = document.createElement('canvas'); paper.width = canvas.width; paper.height = canvas.height;
16
+ const g = paper.getContext('2d'); g.setTransform(D, 0, 0, D, 0, 0);
17
+ g.fillStyle = '#ece4d2'; g.fillRect(0, 0, W, H);
18
+ for (let i = 0; i < W * H / 60; i++) { g.fillStyle = `rgba(${Math.random() < 0.5 ? '120,100,70' : '255,255,245'},${Math.random() * 0.06})`; g.fillRect(Math.random() * W, Math.random() * H, 1 + Math.random() * 3, 0.6); }
19
+ const v = g.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.3, W / 2, H / 2, Math.max(W, H) * 0.75); v.addColorStop(0, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(60,45,25,.22)');
20
+ g.fillStyle = v; g.fillRect(0, 0, W, H);
21
+ };
22
+ const N = Math.max(500, Math.round(PARAMS.filings));
23
+ const fx = new Float32Array(N), fy = new Float32Array(N), fa = new Float32Array(N), fl = new Float32Array(N), shade = new Uint8Array(N);
24
+ const scatter = () => { for (let i = 0; i < N; i++) { fx[i] = Math.random() * W; fy[i] = Math.random() * H; fa[i] = Math.random() * Math.PI; fl[i] = 2.2 + Math.random() * 3.2; shade[i] = (Math.random() * 3) | 0; } };
25
+ const resize = () => {
26
+ D = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1;
27
+ canvas.width = Math.max(1, (W * D) | 0); canvas.height = Math.max(1, (H * D) | 0); ctx.setTransform(D, 0, 0, D, 0, 0); makePaper(); scatter(); place();
28
+ };
29
+ // two bar magnets, each a north and a south pole a bar-length apart
30
+ const mags = [{ x: 0, y: 0, a: 0, len: 0, tx: 0, ty: 0 }, { x: 0, y: 0, a: 0, len: 0, tx: 0, ty: 0 }];
31
+ function place() {
32
+ const s = Math.min(W, H);
33
+ Object.assign(mags[0], { x: W * 0.36, y: H * 0.5, a: 0.2, len: s * 0.34 }); Object.assign(mags[1], { x: W * 0.68, y: H * 0.52, a: Math.PI / 2 + 0.3, len: s * 0.28 });
34
+ mags.forEach(m => { m.tx = m.x; m.ty = m.y; });
35
+ }
36
+ const poles = () => mags.flatMap(m => { const c = Math.cos(m.a) * m.len / 2, s = Math.sin(m.a) * m.len / 2; return [[m.x + c, m.y + s, 1], [m.x - c, m.y - s, -1]]; });
37
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
38
+
39
+ // drag a magnet
40
+ let drag = null;
41
+ const at = e => { const r = canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
42
+ const down = e => { const [x, y] = at(e); let best = null, bd = Math.min(W, H) * 0.2; for (const m of mags) { const d = Math.hypot(x - m.x, y - m.y); if (d < bd) { bd = d; best = m; } } if (best) { drag = { m: best, dx: best.x - x, dy: best.y - y }; canvas.setPointerCapture(e.pointerId); canvas.style.cursor = 'grabbing'; } };
43
+ const move = e => { if (!drag) return; const [x, y] = at(e); drag.m.tx = x + drag.dx; drag.m.ty = y + drag.dy; };
44
+ const up = () => { if (drag) tapAt = time; drag = null; canvas.style.cursor = 'grab'; };
45
+ canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', up);
46
+
47
+ let raf, last = performance.now(), time = 0, tapAt = 0.6, nextTap = 0.6;
48
+ function frame(now) {
49
+ const dt = Math.min(0.05, (now - last) / 1000); last = now; time += dt;
50
+ // left alone, the second magnet drifts slowly round, and the paper is tapped every few seconds
51
+ if (!drag) { const m = mags[1]; m.tx = W * (0.66 + 0.1 * Math.sin(time * 0.17)); m.ty = H * (0.5 + 0.18 * Math.sin(time * 0.23)); m.a += dt * 0.12; }
52
+ for (const m of mags) { m.x += (m.tx - m.x) * Math.min(1, dt * 6); m.y += (m.ty - m.y) * Math.min(1, dt * 6); }
53
+ if (time > nextTap) { tapAt = time; nextTap = time + 2.4 / Math.max(0.2, PARAMS.taps); }
54
+ const since = time - tapAt, hop = since < 0.55 ? Math.exp(-since * 5) : 0; // how loose the filings are right now
55
+ const P = poles(), K = 2 * Math.max(2, Math.round(PARAMS.lines / 2)), s = Math.min(W, H); // even, so the lines meet seamlessly round each pole
56
+ for (let i = 0; i < N; i++) {
57
+ let bx = 0, by = 0, gpx = 0, gpy = 0, psi = 0;
58
+ for (const [px, py, q] of P) {
59
+ const dx = fx[i] - px, dy = fy[i] - py, r2 = dx * dx + dy * dy + 30;
60
+ bx += (q * dx) / r2; by += (q * dy) / r2; // a 2D pole's field falls off as 1/r
61
+ psi += q * Math.atan2(dy, dx); gpx += (-q * dy) / r2; gpy += (q * dx) / r2; // the flux function and its gradient
62
+ }
63
+ const b = Math.hypot(bx, by) + 1e-9;
64
+ // turn toward the field (a filing has no head or tail, so the nearest of the two directions)
65
+ let da = Math.atan2(by, bx) - fa[i]; da -= Math.PI * Math.round(da / Math.PI);
66
+ fa[i] += da * Math.min(1, dt * (2 + 30 * hop) * Math.min(1, b * s * 0.6));
67
+ if (hop > 0.01) {
68
+ // a gentle pull across the field toward the nearest field line (filings chain up there), lost in the shake far
69
+ // from the magnets where the field is weak; and a slide along the field toward the stronger end
70
+ const g = Math.hypot(gpx, gpy) + 1e-9, pull = -Math.sin(K * psi / 2), grip = Math.min(1, b * s * 0.35);
71
+ const across = Math.max(-1, Math.min(1, pull * 1.5)) * 1.1 * grip * hop;
72
+ fx[i] += (gpx / g) * across + (Math.random() - 0.5) * hop * 2.6;
73
+ fy[i] += (gpy / g) * across + (Math.random() - 0.5) * hop * 2.6;
74
+ const along = (Math.random() - 0.5) * 3.2 * hop;
75
+ fx[i] += (bx / b) * along; fy[i] += (by / b) * along;
76
+ if (fx[i] < 0) fx[i] += W; else if (fx[i] > W) fx[i] -= W; if (fy[i] < 0) fy[i] += H; else if (fy[i] > H) fy[i] -= H;
77
+ }
78
+ }
79
+ // draw: the paper (it jumps a pixel when tapped), the magnets' faint shadows through it, then the filings
80
+ const j = hop * 1.2;
81
+ ctx.save(); ctx.translate((Math.random() - 0.5) * j, (Math.random() - 0.5) * j);
82
+ ctx.drawImage(paper, 0, 0, W, H);
83
+ for (const m of mags) {
84
+ ctx.save(); ctx.translate(m.x, m.y); ctx.rotate(m.a);
85
+ const w = m.len * 1.08, h = m.len * 0.2, g = ctx.createLinearGradient(-w / 2, 0, w / 2, 0);
86
+ g.addColorStop(0, 'rgba(150,40,30,.09)'); g.addColorStop(0.5, 'rgba(60,50,40,.05)'); g.addColorStop(1, 'rgba(30,60,120,.09)');
87
+ ctx.fillStyle = g; ctx.filter = 'blur(6px)'; ctx.fillRect(-w / 2, -h / 2, w, h); ctx.filter = 'none';
88
+ ctx.fillStyle = 'rgba(80,60,40,.18)'; ctx.font = `600 ${Math.round(h * 0.55)}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
89
+ ctx.fillText('N', w * 0.38, 0); ctx.fillText('S', -w * 0.38, 0);
90
+ ctx.restore();
91
+ }
92
+ ctx.lineCap = 'round';
93
+ ctx.lineWidth = 1.5; ctx.strokeStyle = 'rgba(70,55,35,.16)'; ctx.beginPath(); // tiny cast shadows
94
+ for (let i = 0; i < N; i++) { const c = Math.cos(fa[i]) * fl[i], s2 = Math.sin(fa[i]) * fl[i]; ctx.moveTo(fx[i] - c + 0.8, fy[i] - s2 + 1); ctx.lineTo(fx[i] + c + 0.8, fy[i] + s2 + 1); }
95
+ ctx.stroke();
96
+ const tones = ['#2e2f33', '#46484d', '#5d6066'];
97
+ ctx.lineWidth = 1.1;
98
+ for (let k = 0; k < 3; k++) {
99
+ ctx.strokeStyle = tones[k]; ctx.beginPath();
100
+ for (let i = 0; i < N; i++) { if (shade[i] !== k) continue; const c = Math.cos(fa[i]) * fl[i], s2 = Math.sin(fa[i]) * fl[i]; ctx.moveTo(fx[i] - c, fy[i] - s2); ctx.lineTo(fx[i] + c, fy[i] + s2); }
101
+ ctx.stroke();
102
+ }
103
+ ctx.restore();
104
+ raf = requestAnimationFrame(frame);
105
+ }
106
+ raf = requestAnimationFrame(frame);
107
+ return () => {
108
+ cancelAnimationFrame(raf); ro.disconnect();
109
+ canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', up);
110
+ canvas.remove();
111
+ };
112
+ }
@@ -0,0 +1,72 @@
1
+ // Lightning arcs: midpoint displacement makes a fractal path (split each segment, push the middle sideways,
2
+ // repeat), some segments fork into fading branches, and a blurred under-stroke plus a white core makes the glow.
3
+ const PARAMS = { branches: 0.5, rate: 14 };
4
+
5
+ export default function (stage) {
6
+ const canvas = document.createElement('canvas');
7
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
8
+ stage.append(canvas);
9
+ const ctx = canvas.getContext('2d');
10
+ let W = 0, H = 0;
11
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth; H = stage.clientHeight; canvas.width = W * d; canvas.height = H * d; ctx.setTransform(d, 0, 0, d, 0, 0); };
12
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
13
+
14
+ let px = 0, py = 0, lastMove = -1e9;
15
+ const onMove = e => { const b = canvas.getBoundingClientRect(); px = e.clientX - b.left; py = e.clientY - b.top; lastMove = performance.now(); };
16
+ canvas.addEventListener('pointermove', onMove);
17
+
18
+ function bolt(x1, y1, x2, y2, rough, depth, out, width) {
19
+ const pts = [[x1, y1], [x2, y2]];
20
+ let disp = Math.hypot(x2 - x1, y2 - y1) * rough;
21
+ for (let k = 0; k < depth; k++) {
22
+ for (let i = pts.length - 1; i > 0; i--) {
23
+ const [ax, ay] = pts[i - 1], [bx, by] = pts[i], mx = (ax + bx) / 2, my = (ay + by) / 2, nx = -(by - ay), ny = bx - ax, l = Math.hypot(nx, ny) || 1, o = (Math.random() - 0.5) * disp;
24
+ pts.splice(i, 0, [mx + nx / l * o, my + ny / l * o]);
25
+ }
26
+ disp *= 0.55;
27
+ }
28
+ out.push({ pts, width });
29
+ if (width > 0.6) for (let i = 2; i < pts.length - 2; i++) {
30
+ if (Math.random() < PARAMS.branches * 0.06) {
31
+ const [sx, sy] = pts[i], ang = Math.atan2(y2 - y1, x2 - x1) + (Math.random() - 0.5) * 1.8, len = Math.hypot(x2 - x1, y2 - y1) * (0.15 + Math.random() * 0.3);
32
+ bolt(sx, sy, sx + Math.cos(ang) * len, sy + Math.sin(ang) * len, rough, Math.max(2, depth - 2), out, width * 0.45);
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+ const nodes = () => [[W * 0.18, H * 0.22], [W * 0.82, H * 0.2], [W * 0.5, H * 0.86]];
38
+ let strikes = [], acc = 0, last = performance.now(), raf;
39
+ function frame(now) {
40
+ const dt = Math.min(0.05, (now - last) / 1000); last = now;
41
+ let tx = px, ty = py;
42
+ if (now - lastMove > 1500) { const t = now / 1000; tx = W / 2 + Math.cos(t * 0.8) * W * 0.18; ty = H * 0.5 + Math.sin(t * 1.3) * H * 0.14; }
43
+ acc += dt * PARAMS.rate;
44
+ while (acc >= 1) {
45
+ acc -= 1;
46
+ const [nx, ny] = nodes()[Math.floor(Math.random() * 3)];
47
+ strikes.push({ segs: bolt(tx, ty, nx, ny, 0.22, 7, [], 2.2), life: 1 });
48
+ }
49
+ ctx.globalCompositeOperation = 'source-over';
50
+ ctx.fillStyle = 'rgba(6, 8, 18, 0.32)'; ctx.fillRect(0, 0, W, H);
51
+ ctx.globalCompositeOperation = 'lighter';
52
+ ctx.lineJoin = 'round'; ctx.lineCap = 'round';
53
+ for (const s of strikes) {
54
+ const flick = 0.6 + Math.random() * 0.4;
55
+ for (const { pts, width } of s.segs) {
56
+ ctx.beginPath(); pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
57
+ ctx.strokeStyle = `rgba(110, 150, 255, ${0.35 * s.life * flick})`; ctx.lineWidth = width * 7; ctx.shadowColor = 'rgba(120,160,255,.9)'; ctx.shadowBlur = 18; ctx.stroke();
58
+ ctx.shadowBlur = 0; ctx.strokeStyle = `rgba(235, 242, 255, ${s.life * flick})`; ctx.lineWidth = width; ctx.stroke();
59
+ }
60
+ s.life -= dt * 7;
61
+ }
62
+ strikes = strikes.filter(s => s.life > 0);
63
+ // electrodes and the pointer's charge
64
+ ctx.globalCompositeOperation = 'source-over';
65
+ for (const [x, y] of nodes()) { ctx.fillStyle = '#c9d6ff'; ctx.beginPath(); ctx.arc(x, y, 5, 0, 7); ctx.fill(); }
66
+ const g = ctx.createRadialGradient(tx, ty, 0, tx, ty, 26); g.addColorStop(0, 'rgba(220,230,255,.9)'); g.addColorStop(1, 'rgba(120,150,255,0)');
67
+ ctx.fillStyle = g; ctx.beginPath(); ctx.arc(tx, ty, 26, 0, 7); ctx.fill();
68
+ raf = requestAnimationFrame(frame);
69
+ }
70
+ raf = requestAnimationFrame(frame);
71
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener('pointermove', onMove); canvas.remove(); };
72
+ }