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,357 @@
1
+ // Drone light show: a fleet of drones lifts off a barge and flies formations over a night city: a turning globe,
2
+ // Saturn and its ring, a beating heart, a word. Every change of formation is an assignment problem: each drone needs
3
+ // its own spot, and the show only looks clean if no drone crosses the sky to reach it. Targets are matched by
4
+ // sorting both sets into bands, then thousands of swap tests each keep a swap only if it shortens the total flight.
5
+ // The bay reflects everything: the image is mirrored at the waterline, broken up by the swell and stretched toward you.
6
+ const PARAMS = { drones: 1500, speed: 1, glow: 1 };
7
+ const WORD = 'HELLO';
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: false });
14
+ if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
15
+ const hdr = !!gl.getExtension('EXT_color_buffer_float');
16
+ const N = Math.max(200, Math.round(PARAMS.drones));
17
+
18
+ // the world is in metres: the camera floats 4 m above the bay; the drones fly 500 m away, the city stands at 760 m
19
+ const CAMH = 4, FOC = 4, HOR = -0.44, CZ = 500, CY = 98, RAD = 46;
20
+ const BASE = ((FOC * -CAMH) / 760 + HOR) * 0.5 + 0.5; // the city's waterline, as a fraction of the height
21
+
22
+ // ── small JS noise for the continents and Saturn's bands
23
+ const hash3 = (x, y, z) => { const h = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453; return h - Math.floor(h); };
24
+ const vnoise = (x, y, z) => {
25
+ const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z), s = v => v * v * (3 - 2 * v);
26
+ const u = s(x - xi), v = s(y - yi), w = s(z - zi), L = (a, b, t) => a + (b - a) * t;
27
+ const c = (i, j, k) => hash3(xi + i, yi + j, zi + k);
28
+ return L(L(L(c(0, 0, 0), c(1, 0, 0), u), L(c(0, 1, 0), c(1, 1, 0), u), v), L(L(c(0, 0, 1), c(1, 0, 1), u), L(c(0, 1, 1), c(1, 1, 1), u), v), w);
29
+ };
30
+ const fbm = (x, y, z) => vnoise(x, y, z) * 0.55 + vnoise(x * 2.1, y * 2.1, z * 2.1) * 0.3 + vnoise(x * 4.3, y * 4.3, z * 4.3) * 0.15;
31
+ const smooth = (a, b, v) => { const t = Math.min(1, Math.max(0, (v - a) / (b - a))); return t * t * (3 - 2 * t); };
32
+ const lin = c => c.map(v => v * v); // colours are picked by eye, mixed as light
33
+
34
+ // ── formations: each knows a colour per slot and where slot k is at time t (it returns that slot's brightness)
35
+ function makePad() {
36
+ const rows = Math.max(4, Math.round(Math.sqrt(N / 7))), cols = Math.ceil(N / rows), col = new Float32Array(N * 3);
37
+ for (let k = 0; k < N; k++) col.set(lin([0.55, 0.68, 0.85]), k * 3);
38
+ return { col, pos(k, t, o) { o[0] = ((k % cols) - (cols - 1) / 2) * 1.3; o[1] = 0.7; o[2] = CZ + (((k / cols) | 0) - (rows - 1) / 2) * 1.3; return 0.07; } };
39
+ }
40
+ function makeGlobe() {
41
+ const P = new Float32Array(N * 3), col = new Float32Array(N * 3);
42
+ for (let k = 0; k < N; k++) {
43
+ const y = 1 - 2 * (k + 0.5) / N, r = Math.sqrt(1 - y * y), a = k * 2.399963, x = Math.cos(a) * r, z = Math.sin(a) * r;
44
+ P[k * 3] = x; P[k * 3 + 1] = y; P[k * 3 + 2] = z;
45
+ const n = fbm(x * 1.05 + 5.2, y * 1.05 + 1.3, z * 1.05 - 2.7);
46
+ let c;
47
+ if (Math.abs(y) > 0.9) c = [0.85, 0.93, 1];
48
+ else if (n > 0.5) { const m = Math.min(1, (n - 0.5) * 8); c = [0.12 + 0.85 * m * m, 0.9 - 0.18 * m, 0.22]; } // forest, then desert inland
49
+ else { const m = Math.min(1, (0.5 - n) * 9); c = [0.0, 0.8 - 0.55 * m, 1]; } // shallows, then deep ocean
50
+ col.set(lin(c), k * 3);
51
+ }
52
+ return { col, pos(k, t, o) {
53
+ let x = P[k * 3], y = P[k * 3 + 1], z = P[k * 3 + 2], u;
54
+ const a = t * 0.28, ca = Math.cos(a), sa = Math.sin(a); u = x * ca + z * sa; z = -x * sa + z * ca; x = u; // spin
55
+ u = x * 0.917 - y * 0.399; y = x * 0.399 + y * 0.917; x = u; // axial tilt
56
+ u = y * 0.955 + z * 0.296; z = -y * 0.296 + z * 0.955; y = u; // north leans toward us
57
+ o[0] = x * RAD; o[1] = CY + y * RAD; o[2] = CZ + z * RAD;
58
+ return 0.08 + 0.92 * smooth(-0.2, 0.45, -z); // the far side is dimmed, as a show designer would
59
+ } };
60
+ }
61
+ function makeSaturn() {
62
+ const P = new Float32Array(N * 3), col = new Float32Array(N * 3), NP = Math.round(N * 0.42);
63
+ for (let k = 0; k < N; k++) {
64
+ let x, y, z, c;
65
+ if (k < NP) {
66
+ y = 1 - 2 * (k + 0.5) / NP; const r = Math.sqrt(1 - y * y), a = k * 2.399963; x = Math.cos(a) * r * 0.58; z = Math.sin(a) * r * 0.58; y *= 0.53;
67
+ const band = 0.5 + 0.5 * Math.sin(y * 19 + fbm(x * 3, y * 3, z * 3) * 4);
68
+ c = [1 - 0.12 * band, 0.86 - 0.38 * band, 0.6 - 0.42 * band];
69
+ } else {
70
+ let r; do { r = Math.sqrt(0.72 + Math.random() * 1.38); } while (r > 1.2 && r < 1.26); // Cassini division
71
+ const a = Math.random() * Math.PI * 2; x = Math.cos(a) * r; z = Math.sin(a) * r; y = (Math.random() - 0.5) * 0.02;
72
+ const s = r < 0.98 ? 0.5 : r < 1.2 ? 1 : 0.72; c = [0.98 * s, 0.86 * s, 0.62 * s];
73
+ }
74
+ P[k * 3] = x; P[k * 3 + 1] = y; P[k * 3 + 2] = z; col.set(lin(c), k * 3);
75
+ }
76
+ return { col, pos(k, t, o) {
77
+ let x = P[k * 3], y = P[k * 3 + 1], z = P[k * 3 + 2], u;
78
+ u = y * 0.927 - z * 0.375; z = y * 0.375 + z * 0.927; y = u; // tip the ring open
79
+ u = x * 0.98 - y * 0.199; y = x * 0.199 + y * 0.98; x = u;
80
+ const a = 0.45 * Math.sin(t * 0.32), ca = Math.cos(a), sa = Math.sin(a); u = x * ca + z * sa; z = -x * sa + z * ca; x = u;
81
+ o[0] = x * RAD; o[1] = CY + y * RAD; o[2] = CZ + z * RAD;
82
+ if (k < NP) return 0.1 + 0.9 * smooth(-0.15, 0.3, -z);
83
+ return z > 0 && x * x + (y * 1.08) ** 2 < 0.56 * 0.56 ? 0.08 : 1; // ring behind the planet goes dark
84
+ } };
85
+ }
86
+ function makeHeart() {
87
+ const M = 240, poly = [];
88
+ for (let i = 0; i < M; i++) { const s = (i / M) * Math.PI * 2; poly.push([(16 * Math.sin(s) ** 3) / 17, (13 * Math.cos(s) - 5 * Math.cos(2 * s) - 2 * Math.cos(3 * s) - Math.cos(4 * s)) / 17 + 0.12]); }
89
+ const inside = (x, y) => { let c = false; for (let i = 0, j = M - 1; i < M; j = i++) { const [xi, yi] = poly[i], [xj, yj] = poly[j]; if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c; } return c; };
90
+ const edge = (x, y) => { let m = 9; for (const [px, py] of poly) m = Math.min(m, (px - x) ** 2 + (py - y) ** 2); return Math.sqrt(m); };
91
+ const P = new Float32Array(N * 3), col = new Float32Array(N * 3);
92
+ for (let k = 0; k < N;) {
93
+ const x = Math.random() * 2 - 1, y = Math.random() * 2 - 1.05;
94
+ if (!inside(x, y)) continue;
95
+ const d = Math.min(1, edge(x, y) / 0.42), z = (k % 2 ? 1 : -1) * 0.36 * Math.sqrt(d);
96
+ P[k * 3] = x; P[k * 3 + 1] = y; P[k * 3 + 2] = z;
97
+ const hl = Math.max(0, 1 - Math.hypot(x + 0.42, y - 0.36) / 0.55) * (z < 0 ? 1 : 0.3);
98
+ col.set(lin([1, 0.12 + 0.55 * hl, 0.3 + 0.45 * hl]), k * 3); k++;
99
+ }
100
+ return { col, pos(k, t, o) {
101
+ const tau = (t * 1.15) % 1, beat = 1 + 0.07 * (Math.exp(-(((tau - 0.1) / 0.05) ** 2)) + 0.6 * Math.exp(-(((tau - 0.3) / 0.06) ** 2)));
102
+ let x = P[k * 3] * beat, y = P[k * 3 + 1] * beat, z = P[k * 3 + 2] * beat, u;
103
+ const a = 0.5 * Math.sin(t * 0.55), ca = Math.cos(a), sa = Math.sin(a); u = x * ca + z * sa; z = -x * sa + z * ca; x = u;
104
+ o[0] = x * RAD; o[1] = CY + y * RAD; o[2] = CZ + z * RAD;
105
+ return z < 0.05 ? 1 : 0.4;
106
+ } };
107
+ }
108
+ function makeWord() {
109
+ const c = document.createElement('canvas'); c.width = 1200; c.height = 300;
110
+ const g = c.getContext('2d'); g.font = '900 220px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
111
+ g.fillStyle = '#fff'; g.fillText(WORD, 600, 150, 1160);
112
+ const px = g.getImageData(0, 0, 1200, 300).data, spots = [];
113
+ for (let y = 0; y < 300; y += 2) for (let x = 0; x < 1200; x += 2) if (px[(y * 1200 + x) * 4 + 3] > 140) spots.push(x, y);
114
+ let x0 = 1e9, x1 = -1e9; for (let i = 0; i < spots.length; i += 2) { x0 = Math.min(x0, spots[i]); x1 = Math.max(x1, spots[i]); }
115
+ const sc = 2.5 / Math.max(1, x1 - x0), cx = (x0 + x1) / 2, P = new Float32Array(N * 3), col = new Float32Array(N * 3), n = spots.length / 2;
116
+ for (let k = 0; k < N; k++) {
117
+ const s = n ? (Math.random() * n) | 0 : 0, x = ((spots[s * 2] ?? 600) - cx + Math.random() * 2) * sc, y = -((spots[s * 2 + 1] ?? 150) - 150 + Math.random() * 2) * sc;
118
+ P[k * 3] = x; P[k * 3 + 1] = y; P[k * 3 + 2] = 0;
119
+ col.set(lin([1, 0.78 + 0.18 * (x / 2.5 + 0.5), 0.45 + 0.5 * (x / 2.5 + 0.5)]), k * 3);
120
+ }
121
+ return { col, pos(k, t, o) {
122
+ const x = P[k * 3], y = P[k * 3 + 1];
123
+ o[0] = x * RAD; o[1] = CY + y * RAD; o[2] = CZ + Math.sin(x * 2.2 - t * 1.6) * 6;
124
+ return 0.5 + 0.5 * Math.max(0, Math.sin(x * 2.6 - t * 3.4)) ** 3 + 0.12 * Math.sin(t * 9 + k); // a shimmer runs along the letters
125
+ } };
126
+ }
127
+
128
+ const pad = makePad(), forms = { globe: makeGlobe(), saturn: makeSaturn(), heart: makeHeart(), word: makeWord() };
129
+ const SHOW = [
130
+ { form: pad, tr: 0, hold: 1.2 },
131
+ { form: forms.globe, tr: 5.6, hold: 7, kind: 'launch' },
132
+ { form: forms.saturn, tr: 4.6, hold: 7.5 },
133
+ { form: forms.heart, tr: 4.6, hold: 6.5 },
134
+ { form: forms.word, tr: 4.6, hold: 6.5 },
135
+ { form: pad, tr: 6.2, hold: 2.4, kind: 'land' },
136
+ ];
137
+
138
+ // ── assignment: band-sort both sets, then keep every swap that shortens the total (squared) flight
139
+ const cur = new Float32Array(N * 3), curCol = new Float32Array(N * 3), from = new Float32Array(N * 3), fromCol = new Float32Array(N * 3);
140
+ const delay = new Float32Array(N), Q = new Float32Array(N * 3), tmp = [0, 0, 0];
141
+ let perm = new Int32Array(N);
142
+ function assign(form, tEnd) {
143
+ for (let k = 0; k < N; k++) { form.pos(k, tEnd, tmp); Q[k * 3] = tmp[0]; Q[k * 3 + 1] = tmp[1]; Q[k * 3 + 2] = tmp[2]; }
144
+ let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9;
145
+ for (let i = 0; i < N; i++) { x0 = Math.min(x0, from[i * 3]); x1 = Math.max(x1, from[i * 3]); y0 = Math.min(y0, from[i * 3 + 1]); y1 = Math.max(y1, from[i * 3 + 1]); }
146
+ const flat = y1 - y0 < (x1 - x0) * 0.2; // a flat pad sorts by x first: drones rise straight up
147
+ const a = flat ? 0 : 1, b = flat ? 1 : 0, ap = flat ? 0 : 1, bp = flat ? 2 : 0, sgn = flat ? -1 : 1;
148
+ const ip = Array.from({ length: N }, (_, i) => i).sort((i, j) => from[i * 3 + ap] - from[j * 3 + ap]);
149
+ const iq = Array.from({ length: N }, (_, i) => i).sort((i, j) => Q[i * 3 + a] - Q[j * 3 + a]);
150
+ const band = Math.max(1, Math.round(Math.sqrt(N) * 0.9)), out = new Int32Array(N);
151
+ for (let s = 0; s < N; s += band) {
152
+ const e = Math.min(N, s + band);
153
+ const bp2 = ip.slice(s, e).sort((i, j) => sgn * (from[i * 3 + bp] - from[j * 3 + bp])), bq = iq.slice(s, e).sort((i, j) => Q[i * 3 + b] - Q[j * 3 + b]);
154
+ for (let m = 0; m < bp2.length; m++) { out[bp2[m]] = bq[m]; ip[s + m] = bp2[m]; }
155
+ }
156
+ const d2 = (i, s) => (from[i * 3] - Q[s * 3]) ** 2 + (from[i * 3 + 1] - Q[s * 3 + 1]) ** 2 + (from[i * 3 + 2] - Q[s * 3 + 2]) ** 2;
157
+ for (let it = 0; it < N * 50; it++) {
158
+ const p = (Math.random() * N) | 0, q = Math.random() < 0.8 ? Math.min(N - 1, p + 1 + ((Math.random() * 40) | 0)) : (Math.random() * N) | 0;
159
+ const i = ip[p], j = ip[q]; if (i === j) continue;
160
+ const si = out[i], sj = out[j];
161
+ if (d2(i, sj) + d2(j, si) < d2(i, si) + d2(j, sj)) { out[i] = sj; out[j] = si; }
162
+ }
163
+ return out;
164
+ }
165
+ let si = 0, segT0 = 0;
166
+ function begin(seg, t0) {
167
+ from.set(cur); fromCol.set(curCol);
168
+ perm = seg.tr ? assign(seg.form, t0 + seg.tr) : perm;
169
+ let xm = 1; for (let i = 0; i < N; i++) xm = Math.max(xm, Math.abs(from[i * 3]));
170
+ for (let i = 0; i < N; i++) delay[i] = seg.kind === 'launch' ? (Math.abs(from[i * 3]) / xm) * 1.1 + Math.random() * 0.25 : Math.random() * 0.5;
171
+ }
172
+ for (let k = 0; k < N; k++) { pad.pos(k, 0, tmp); cur.set(tmp, k * 3); perm[k] = k; const b = 0.07; curCol[k * 3] = pad.col[k * 3] * b; curCol[k * 3 + 1] = pad.col[k * 3 + 1] * b; curCol[k * 3 + 2] = pad.col[k * 3 + 2] * b; }
173
+ const phase = Float32Array.from({ length: N * 3 }, () => Math.random() * 6.283);
174
+
175
+ function update(t) {
176
+ let seg = SHOW[si];
177
+ while (t - segT0 > seg.tr + seg.hold) { segT0 += seg.tr + seg.hold; si = (si + 1) % SHOW.length; seg = SHOW[si]; begin(seg, segT0); }
178
+ const maxD = seg.kind === 'launch' ? 1.35 : 0.5, span = Math.max(0.5, seg.tr - maxD), f = seg.form;
179
+ for (let i = 0; i < N; i++) {
180
+ const s = perm[i], bright = f.pos(s, t, tmp);
181
+ let e = seg.tr ? Math.min(1, Math.max(0, (t - segT0 - delay[i]) / span)) : 1;
182
+ e = e * e * e * (e * (e * 6 - 15) + 10); // smootherstep: drones accelerate and brake
183
+ const ax = from[i * 3], ay = from[i * 3 + 1], az = from[i * 3 + 2];
184
+ let x, y, z;
185
+ if (seg.kind === 'launch' || seg.kind === 'land') {
186
+ // a curved path: climb first then swing over (launch), or swing over the pad then descend (landing)
187
+ const up = seg.kind === 'launch', cx = up ? ax : tmp[0], cy = up ? tmp[1] : ay, cz = up ? az : tmp[2], u = 1 - e;
188
+ x = u * u * ax + 2 * u * e * cx + e * e * tmp[0]; y = u * u * ay + 2 * u * e * cy + e * e * tmp[1]; z = u * u * az + 2 * u * e * cz + e * e * tmp[2];
189
+ } else { x = ax + (tmp[0] - ax) * e; y = ay + (tmp[1] - ay) * e; z = az + (tmp[2] - az) * e; }
190
+ cur[i * 3] = x; cur[i * 3 + 1] = y; cur[i * 3 + 2] = z;
191
+ const dim = 1 - 0.3 * Math.sin(Math.PI * e);
192
+ for (let c = 0; c < 3; c++) curCol[i * 3 + c] = (fromCol[i * 3 + c] * (1 - e) + f.col[s * 3 + c] * bright * e) * dim;
193
+ }
194
+ }
195
+
196
+ // ── GL plumbing
197
+ 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; };
198
+ const prog = (vs, fs) => {
199
+ 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);
200
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
201
+ 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); }
202
+ return { p, u };
203
+ };
204
+ const QUAD = `#version 300 es
205
+ in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
206
+ const HEAD = `#version 300 es
207
+ precision highp float; in vec2 uv; out vec4 o;
208
+ float h1(float n) { return fract(sin(n * 127.1) * 43758.5453); }
209
+ float h2(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
210
+ vec3 L(vec3 c) { return c * c; }
211
+ const float FOC = ${FOC.toFixed(1)}, HOR = ${HOR.toFixed(3)}, CAMH = ${CAMH.toFixed(1)};
212
+ `;
213
+ // the sky and the city, drawn per pixel: two rows of blocks with lit windows, towers with blinking beacons
214
+ const skyP = prog(QUAD, HEAD + `
215
+ uniform vec2 R; uniform float T, A, BASE;
216
+ vec3 blocks(float x, float w, float seed, float hmax) {
217
+ float i = floor(x / w), f = fract(x / w), r = h1(i + seed), r2 = h1(i * 1.7 + seed + 3.1);
218
+ float h = hmax * (.14 + .86 * pow(r, 2.3)), tower = step(.9, r2);
219
+ h = mix(h, hmax * (1.3 + .7 * r), tower);
220
+ h *= mix(1., .45, step(r2, .12));
221
+ h -= step(min(f, 1. - f), .14) * step(hmax * .55, h) * h * .1; // stepped crowns
222
+ h += tower * step(abs(f - .5), .03) * hmax * .4; // and a spire
223
+ return vec3(h, i + seed * 7.3, tower);
224
+ }
225
+ void main() {
226
+ vec2 c = uv * 2. - 1.; float hh = uv.y - BASE; vec3 col = vec3(0);
227
+ if (hh > 0.) {
228
+ vec3 sky = mix(vec3(.38, .2, .21), vec3(.08, .075, .16), smoothstep(0., .2, hh));
229
+ col = L(mix(sky, vec3(.02, .028, .065), smoothstep(.14, .72, hh)));
230
+ vec2 sp = vec2(c.x * A, c.y) * 110., si = floor(sp); float s = h2(si);
231
+ if (s > .99) { vec2 sf = fract(sp) - .5 - (vec2(h2(si + 1.7), h2(si + 4.2)) - .5) * .5; col += vec3(.7, .76, 1.) * exp(-dot(sf, sf) * 26.) * (s - .99) * 40. * smoothstep(.1, .45, hh) * (.7 + .3 * sin(T * 2. + s * 300.)); }
232
+ for (int k = 0; k < 2; k++) {
233
+ float Z = k == 0 ? 1100. : 760., W = k == 0 ? 36. : 27., HM = k == 0 ? 100. : 66., SEED = k == 0 ? 11.3 : 3.7;
234
+ float x = c.x * A * Z / FOC, y = (c.y - HOR) * Z / FOC + CAMH, mpp = Z / FOC * 2. / R.y;
235
+ vec3 b = blocks(x, W, SEED, HM);
236
+ if (y < b.x) {
237
+ col = k == 0 ? L(vec3(.085, .066, .11)) : L(vec3(.024, .026, .046));
238
+ vec2 g = vec2(x / 2.8, y / 3.3), gi = floor(g), gf = fract(g), aa = fwidth(g);
239
+ float win = smoothstep(.2 - aa.x, .2 + aa.x, gf.x) * smoothstep(.8 + aa.x, .8 - aa.x, gf.x) * smoothstep(.25 - aa.y, .25 + aa.y, gf.y) * smoothstep(.72 + aa.y, .72 - aa.y, gf.y);
240
+ win = mix(win, .3, clamp(max(aa.x, aa.y) * 2. - .6, 0., 1.));
241
+ float on = step(.6, h2(gi + b.y * 1.31)) * step(.45, h2(vec2(floor(x / 11.), floor(y / 8.)) + b.y));
242
+ vec3 wc = mix(vec3(1., .64, .34), vec3(.62, .8, 1.), step(.78, h2(gi * .37 + b.y)));
243
+ col += L(wc) * win * on * (k == 0 ? .3 : .85);
244
+ }
245
+ if (b.z > .5) { // aviation light on the spire
246
+ float xc = (floor(x / W) + .5) * W, top = blocks(xc, W, SEED, HM).x + 1.;
247
+ float d = length(vec2(x - xc, y - top)) / mpp;
248
+ col += vec3(1., .08, .04) * exp(-d * d * .35) * 3. * step(.55, fract(T * .6 + h1(b.y)));
249
+ }
250
+ }
251
+ }
252
+ o = vec4(col, 1);
253
+ }`);
254
+ // the drones: a sharp core and a faint halo per light
255
+ const droneP = prog(`#version 300 es
256
+ layout(location = 0) in vec3 P; layout(location = 1) in vec3 C;
257
+ uniform float A, S; out vec3 col;
258
+ void main() { gl_Position = vec4(${FOC.toFixed(1)} * P.x / P.z / A, ${FOC.toFixed(1)} * (P.y - ${CAMH.toFixed(1)}) / P.z + ${HOR.toFixed(3)}, 0, 1); gl_PointSize = S * ${CZ.toFixed(1)} / P.z; col = C; }`,
259
+ `#version 300 es
260
+ precision mediump float; in vec3 col; out vec4 o;
261
+ void main() { vec2 q = gl_PointCoord * 2. - 1.; float r2 = dot(q, q); if (r2 > 1.) discard; o = vec4(col * (exp(-r2 * 11.) + .1 * exp(-r2 * 3.)), 1); }`);
262
+ // the bay: the scene mirrored at the waterline, rippled by three swells and stretched toward the viewer
263
+ const waterP = prog(QUAD, HEAD + `
264
+ uniform sampler2D S; uniform vec2 R; uniform float T, A, BASE;
265
+ void main() {
266
+ vec3 c = texture(S, uv).rgb; float d = BASE - uv.y;
267
+ if (d > 0.) {
268
+ float z = 1. / (d + .006);
269
+ float w = sin(z * .9 - T * 1.3 + sin(uv.x * A * 7. + z * .13) * 1.5) * .6 + sin(z * 2.3 + T * 1.9 + uv.x * A * 19.) * .3 + sin(z * 5.1 - T * 2.7 + uv.x * A * 41.) * .15;
270
+ vec2 m = vec2(uv.x + w * d * .05, 2. * BASE - uv.y);
271
+ vec3 r = vec3(0);
272
+ for (int k = 0; k < 8; k++) r += texture(S, m + vec2((float(k) - 3.5) * .0012 * w, float(k) * d * .018)).rgb;
273
+ float fres = mix(.7, .28, smoothstep(0., .22, d));
274
+ c += r / 8. * fres * vec3(.82, .9, 1.) + L(vec3(.012, .018, .036)) * (1. - fres);
275
+ }
276
+ o = vec4(c, 1);
277
+ }`);
278
+ const downP = prog(QUAD, `#version 300 es
279
+ precision highp float; in vec2 uv; out vec4 o; uniform sampler2D S; uniform vec2 H;
280
+ void main() { o = (texture(S, uv) * 4. + texture(S, uv - H) + texture(S, uv + H) + texture(S, uv + vec2(H.x, -H.y)) + texture(S, uv - vec2(H.x, -H.y))) / 8.; }`);
281
+ const upP = prog(QUAD, `#version 300 es
282
+ precision highp float; in vec2 uv; out vec4 o; uniform sampler2D S; uniform vec2 H;
283
+ void main() { o = (texture(S, uv + vec2(-2. * H.x, 0)) + texture(S, uv + vec2(2. * H.x, 0)) + texture(S, uv + vec2(0, 2. * H.y)) + texture(S, uv + vec2(0, -2. * H.y))
284
+ + 2. * (texture(S, uv + H) + texture(S, uv - H) + texture(S, uv + vec2(H.x, -H.y)) + texture(S, uv - vec2(H.x, -H.y)))) / 12.; }`);
285
+ const finalP = prog(QUAD, `#version 300 es
286
+ precision highp float; in vec2 uv; out vec4 o; uniform sampler2D S, B; uniform float K, T; uniform vec2 R;
287
+ float h2(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
288
+ void main() {
289
+ vec3 c = texture(S, uv).rgb + texture(B, uv).rgb * K;
290
+ c = (c * (2.51 * c + .03)) / (c * (2.43 * c + .59) + .14);
291
+ c = pow(clamp(c, 0., 1.), vec3(1. / 2.2));
292
+ c *= mix(1., smoothstep(1.25, .35, length((uv - .5) * vec2(1.1, 1.))), .3);
293
+ o = vec4(c + (h2(uv * R + fract(T) * 91.) - .5) / 128., 1);
294
+ }`);
295
+
296
+ const quad = gl.createVertexArray(); gl.bindVertexArray(quad);
297
+ const qb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, qb); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
298
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
299
+ const dv = gl.createVertexArray(); gl.bindVertexArray(dv);
300
+ const data = new Float32Array(N * 6), db = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, db); gl.bufferData(gl.ARRAY_BUFFER, data.byteLength, gl.DYNAMIC_DRAW);
301
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12);
302
+ gl.bindVertexArray(null);
303
+
304
+ const fmt = hdr ? [gl.RGBA16F, gl.HALF_FLOAT] : [gl.RGBA8, gl.UNSIGNED_BYTE];
305
+ const rt = (w, h) => {
306
+ const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex); gl.texImage2D(gl.TEXTURE_2D, 0, fmt[0], w, h, 0, gl.RGBA, fmt[1], null);
307
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
308
+ 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);
309
+ const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
310
+ return { tex, fb, w, h };
311
+ };
312
+ let W = 1, H = 1, scene = null, comp = null, mips = [];
313
+ const resize = () => {
314
+ const w = stage.clientWidth || 1, h = stage.clientHeight || 1, d = Math.min(devicePixelRatio, 2, Math.max(1, Math.sqrt(2.4e6 / (w * h))));
315
+ W = canvas.width = Math.max(1, (w * d) | 0); H = canvas.height = Math.max(1, (h * d) | 0);
316
+ for (const r of [scene, comp, ...mips]) if (r) { gl.deleteTexture(r.tex); gl.deleteFramebuffer(r.fb); }
317
+ scene = rt(W, H); comp = rt(W, H); mips = [];
318
+ let mw = W, mh = H; for (let i = 0; i < 6; i++) { mw = Math.max(1, mw >> 1); mh = Math.max(1, mh >> 1); mips.push(rt(mw, mh)); }
319
+ };
320
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
321
+
322
+ const pass = (P, target, tex) => {
323
+ gl.useProgram(P.p); gl.bindFramebuffer(gl.FRAMEBUFFER, target ? target.fb : null); gl.viewport(0, 0, target ? target.w : W, target ? target.h : H);
324
+ if (tex) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex); gl.uniform1i(P.u.S, 0); }
325
+ gl.bindVertexArray(quad);
326
+ };
327
+ let raf, last = performance.now(), t = 0;
328
+ function frame(now) {
329
+ t += Math.min(0.05, (now - last) / 1000) * PARAMS.speed; last = now;
330
+ update(t);
331
+ for (let i = 0; i < N; i++) {
332
+ const o = i * 6, w = 0.22;
333
+ data[o] = cur[i * 3] + Math.sin(t * 1.3 + phase[i * 3]) * w; data[o + 1] = cur[i * 3 + 1] + Math.sin(t * 1.7 + phase[i * 3 + 1]) * w; data[o + 2] = cur[i * 3 + 2] + Math.sin(t * 1.1 + phase[i * 3 + 2]) * w;
334
+ data[o + 3] = curCol[i * 3] * 3; data[o + 4] = curCol[i * 3 + 1] * 3; data[o + 5] = curCol[i * 3 + 2] * 3;
335
+ }
336
+ const A = W / H;
337
+ gl.disable(gl.BLEND);
338
+ pass(skyP, scene); gl.uniform2f(skyP.u.R, W, H); gl.uniform1f(skyP.u.T, t); gl.uniform1f(skyP.u.A, A); gl.uniform1f(skyP.u.BASE, BASE); gl.drawArrays(gl.TRIANGLES, 0, 3);
339
+ gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
340
+ gl.useProgram(droneP.p); gl.uniform1f(droneP.u.A, A); gl.uniform1f(droneP.u.S, Math.max(5, H / 105)); gl.bindVertexArray(dv);
341
+ gl.bindBuffer(gl.ARRAY_BUFFER, db); gl.bufferSubData(gl.ARRAY_BUFFER, 0, data); gl.drawArrays(gl.POINTS, 0, N);
342
+ gl.disable(gl.BLEND);
343
+ pass(waterP, comp, scene.tex); gl.uniform2f(waterP.u.R, W, H); gl.uniform1f(waterP.u.T, t); gl.uniform1f(waterP.u.A, A); gl.uniform1f(waterP.u.BASE, BASE); gl.drawArrays(gl.TRIANGLES, 0, 3);
344
+ // bloom: halve down the chain, then add each level back up into the one above it
345
+ let src = comp;
346
+ for (const m of mips) { pass(downP, m, src.tex); gl.uniform2f(downP.u.H, 0.5 / src.w, 0.5 / src.h); gl.drawArrays(gl.TRIANGLES, 0, 3); src = m; }
347
+ gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
348
+ for (let i = mips.length - 1; i > 0; i--) { pass(upP, mips[i - 1], mips[i].tex); gl.uniform2f(upP.u.H, 0.5 / mips[i].w, 0.5 / mips[i].h); gl.drawArrays(gl.TRIANGLES, 0, 3); }
349
+ gl.disable(gl.BLEND);
350
+ pass(finalP, null, comp.tex); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, mips[0].tex); gl.uniform1i(finalP.u.B, 1);
351
+ gl.uniform1f(finalP.u.K, 0.22 * PARAMS.glow); gl.uniform1f(finalP.u.T, t); gl.uniform2f(finalP.u.R, W, H); gl.drawArrays(gl.TRIANGLES, 0, 3);
352
+ gl.activeTexture(gl.TEXTURE0);
353
+ raf = requestAnimationFrame(frame);
354
+ }
355
+ raf = requestAnimationFrame(frame);
356
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
357
+ }
@@ -0,0 +1,63 @@
1
+ // Bonfire embers: glowing specks lifted by the fire's heat, swirled by turbulence and cooling as they rise. Buoyancy
2
+ // fades as they cool, so the hottest ones climb fastest and the dim ones drift and sink. The turbulence is a curl of
3
+ // layered sine fields, which never converges, so embers curl into eddies instead of clumping. The fire is a stack of
4
+ // flickering soft tongues drawn with additive light.
5
+ const PARAMS = { embers: 260, turbulence: 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';
10
+ stage.append(canvas);
11
+ const ctx = canvas.getContext('2d');
12
+ let W = 0, H = 0;
13
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1; canvas.width = W * d; canvas.height = H * d; ctx.setTransform(d, 0, 0, d, 0, 0); };
14
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
15
+ // a stream function made of sines; its curl is a swirling, divergence-free velocity
16
+ const psi = (x, y, t) => Math.sin(x * 3.1 + t * 0.7) * Math.cos(y * 2.3 - t * 0.5) + 0.5 * Math.sin(x * 6.7 - y * 5.3 + t * 1.3) + 0.25 * Math.cos(x * 11 + y * 13 - t * 2);
17
+ const curl = (x, y, t) => { const e = 0.01; return [(psi(x, y + e, t) - psi(x, y - e, t)) / (2 * e), -(psi(x + e, y, t) - psi(x - e, y, t)) / (2 * e)]; };
18
+ const make = () => ({ x: 0.5 + (Math.random() - 0.5) * 0.14, y: 0.86, vx: 0, vy: 0, k: 0.7 + Math.random() * 0.3, cool: 0.12 + Math.random() * 0.2, s: 0.6 + Math.random() * 1.4, ph: Math.random() * 6.28 });
19
+ const embers = Array.from({ length: Math.round(PARAMS.embers) }, () => { const e = make(); e.y = Math.random() * 0.86; e.k = Math.random(); return e; });
20
+ let raf, last = performance.now();
21
+ function frame(now) {
22
+ const dt = Math.min(0.033, (now - last) / 1000); last = now;
23
+ const t = now / 1000, S = Math.min(W, H), fx = W / 2, fy = H * 0.88;
24
+ ctx.globalCompositeOperation = 'source-over';
25
+ const bg = ctx.createLinearGradient(0, 0, 0, H); bg.addColorStop(0, '#07070c'); bg.addColorStop(1, '#1a0f0b');
26
+ ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
27
+ ctx.globalCompositeOperation = 'lighter';
28
+ // the glow on everything around the fire, breathing with the flames
29
+ const fl = 0.85 + Math.sin(t * 7) * 0.05 + Math.sin(t * 13.3) * 0.04;
30
+ const glow = ctx.createRadialGradient(fx, fy, 0, fx, fy, S * 0.7 * fl); glow.addColorStop(0, 'rgba(255,120,40,.35)'); glow.addColorStop(1, 'rgba(255,60,10,0)');
31
+ ctx.fillStyle = glow; ctx.fillRect(0, 0, W, H);
32
+ // flame tongues
33
+ for (let i = 0; i < 26; i++) {
34
+ const u = (i / 25 - 0.5), hgt = S * (0.16 + 0.12 * (1 - Math.abs(u) * 2)) * (0.7 + 0.3 * Math.sin(t * (5 + i % 5) + i * 1.7));
35
+ const x = fx + u * S * 0.22 + Math.sin(t * 3 + i) * S * 0.01, y = fy - hgt * 0.5;
36
+ const g = ctx.createRadialGradient(x, y + hgt * 0.25, 0, x, y, hgt * 0.6);
37
+ g.addColorStop(0, 'rgba(255,230,160,.30)'); g.addColorStop(0.4, 'rgba(255,130,40,.16)'); g.addColorStop(1, 'rgba(200,40,10,0)');
38
+ ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(x, y, hgt * 0.22, hgt * 0.6, Math.sin(t * 2 + i) * 0.15, 0, 7); ctx.fill();
39
+ }
40
+ for (const e of embers) {
41
+ const [cx, cy] = curl(e.x * 2, e.y * 2, t * 0.6);
42
+ const lift = 0.32 * e.k * e.k; // hot air lifts hot embers; cooled ones barely rise
43
+ e.vx += (cx * 0.12 * PARAMS.turbulence - e.vx) * dt * 2;
44
+ e.vy += ((cy * 0.12 * PARAMS.turbulence - lift + 0.03) - e.vy) * dt * 2;
45
+ e.x += e.vx * dt * (H / W); e.y += e.vy * dt;
46
+ e.k -= e.cool * dt;
47
+ if (e.k <= 0 || e.y < -0.05 || e.y > 0.95 || e.x < -0.05 || e.x > 1.05) Object.assign(e, make());
48
+ const x = e.x * W, y = e.y * H, k = Math.max(0, e.k) * (0.8 + 0.2 * Math.sin(t * 20 + e.ph)); // they twinkle
49
+ const r = e.s * (1 + k), g = ctx.createRadialGradient(x, y, 0, x, y, r * 4);
50
+ g.addColorStop(0, `rgba(255,${180 + k * 70 | 0},${80 + k * 120 | 0},${k})`); g.addColorStop(0.3, `rgba(255,${90 + k * 60 | 0},20,${k * 0.5})`); g.addColorStop(1, 'rgba(255,60,0,0)');
51
+ ctx.fillStyle = g; ctx.fillRect(x - r * 4, y - r * 4, r * 8, r * 8);
52
+ }
53
+ ctx.globalCompositeOperation = 'source-over';
54
+ // logs in silhouette
55
+ ctx.fillStyle = '#0c0807';
56
+ ctx.save(); ctx.translate(fx, fy + S * 0.02);
57
+ for (const [ang, len] of [[0.25, 0.34], [-0.3, 0.3], [0.05, 0.28]]) { ctx.save(); ctx.rotate(ang); ctx.beginPath(); ctx.roundRect(-S * len / 2, -S * 0.022, S * len, S * 0.044, S * 0.02); ctx.fill(); ctx.restore(); }
58
+ ctx.restore();
59
+ raf = requestAnimationFrame(frame);
60
+ }
61
+ raf = requestAnimationFrame(frame);
62
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
63
+ }
@@ -0,0 +1,107 @@
1
+ // Firefly sync: a forest of fireflies flashing at random slowly falls into one rhythm, with nobody in charge. Each
2
+ // one is a clock that flashes when it runs out and resets. Whenever it sees a neighbour flash, it nudges its own
3
+ // clock forward a little, and if that tips it over, it flashes right away (the Mirollo–Strogatz model of
4
+ // pulse-coupled oscillators). Small groups lock together, waves of flashes start to roll through the trees, and in
5
+ // the end the whole forest blinks as one. Fireflies sit between three layers of trees, near ones blurred to bokeh.
6
+ // Wave the pointer to startle the ones nearby and break the spell.
7
+ const PARAMS = { fireflies: 700, coupling: 0.07, spread: 0.05 };
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;background:#04070a';
12
+ stage.append(canvas);
13
+ const ctx = canvas.getContext('2d');
14
+ let W = 1, H = 1, layers = [];
15
+ const rnd = (a, b) => a + Math.random() * (b - a);
16
+ // three layers of trees, painted once: far ones misty blue, near ones black
17
+ function paintForest() {
18
+ const d = Math.min(devicePixelRatio, 2);
19
+ layers = [0, 1, 2].map(L => {
20
+ const c = document.createElement('canvas'); c.width = Math.max(1, (W * d) | 0); c.height = Math.max(1, (H * d) | 0);
21
+ const g = c.getContext('2d'); g.setTransform(d, 0, 0, d, 0, 0);
22
+ const tone = ['#14262f', '#0a161c', '#020405'][L], n = [16, 9, 4][L];
23
+ g.fillStyle = tone;
24
+ for (let i = 0; i < n; i++) {
25
+ const x = (i + rnd(0.1, 0.9)) * (W / n), w = [W * 0.01, W * 0.022, W * 0.05][L] * rnd(0.7, 1.3), top = -H * 0.1;
26
+ g.beginPath(); g.moveTo(x - w * 0.5, H); g.lineTo(x - w * 0.36, top); g.lineTo(x + w * 0.36, top); g.lineTo(x + w * 0.5, H); g.fill();
27
+ for (let b = 0; b < 3; b++) { // a few branches
28
+ const y = H * rnd(0.15, 0.6), s = Math.random() < 0.5 ? -1 : 1, len = w * rnd(2, 5);
29
+ g.beginPath(); g.moveTo(x, y); g.quadraticCurveTo(x + s * len * 0.6, y - len * 0.3, x + s * len, y - len * 0.5); g.lineWidth = w * 0.18; g.strokeStyle = tone; g.stroke();
30
+ }
31
+ }
32
+ if (L === 2) { g.fillStyle = '#020304'; for (let x = 0; x < W; x += 3) { const h = H * rnd(0.02, 0.07); g.fillRect(x, H - h, 1.6, h); } } // grass
33
+ return c;
34
+ });
35
+ }
36
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1; canvas.width = Math.max(1, (W * d) | 0); canvas.height = Math.max(1, (H * d) | 0); ctx.setTransform(d, 0, 0, d, 0, 0); paintForest(); };
37
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
38
+
39
+ // a soft yellow-green glow, stamped for every flash
40
+ const glow = document.createElement('canvas'); glow.width = glow.height = 64;
41
+ { const g = glow.getContext('2d'), r = g.createRadialGradient(32, 32, 0, 32, 32, 32); r.addColorStop(0, 'rgba(250,255,200,1)'); r.addColorStop(0.18, 'rgba(200,255,90,.75)'); r.addColorStop(0.5, 'rgba(120,220,60,.18)'); r.addColorStop(1, 'rgba(80,180,40,0)'); g.fillStyle = r; g.fillRect(0, 0, 64, 64); }
42
+ const disc = document.createElement('canvas'); disc.width = disc.height = 64;
43
+ { const g = disc.getContext('2d'), r = g.createRadialGradient(32, 32, 20, 32, 32, 32); r.addColorStop(0, 'rgba(200,255,110,.5)'); r.addColorStop(0.85, 'rgba(210,255,120,.62)'); r.addColorStop(1, 'rgba(190,255,100,0)'); g.fillStyle = r; g.beginPath(); g.arc(32, 32, 32, 0, 7); g.fill(); }
44
+
45
+ const N = Math.max(50, Math.round(PARAMS.fireflies));
46
+ // each firefly: position (x, y in 0..1 of the stage, depth 0 far .. 1 near), its clock, its own natural period
47
+ const F = Array.from({ length: N }, () => { const z = Math.random() ** 2.2; return { x: Math.random(), y: rnd(0.25, 0.92), z, layer: z < 0.4 ? 0 : z < 0.8 ? 1 : 2, c: Math.random(), T: 1 + rnd(-1, 1) * PARAMS.spread, since: 9, vx: rnd(-1, 1), vy: rnd(-1, 1) }; });
48
+ const ptr = { x: 0, y: 0, on: false };
49
+ const move = e => { const r = canvas.getBoundingClientRect(); ptr.x = (e.clientX - r.left) / r.width; ptr.y = (e.clientY - r.top) / r.height; ptr.on = true; };
50
+ const leave = () => { ptr.on = false; };
51
+ canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerleave', leave);
52
+
53
+ let raf, last = performance.now(), time = 0, syncedFor = 0;
54
+ const SEE = 0.2; // how far a firefly can see a flash (stage widths)
55
+ function frame(now) {
56
+ const dt = Math.min(0.04, (now - last) / 1000); last = now; time += dt;
57
+ // advance every clock; collect who fires, then let each flash nudge the neighbours who see it (which can cascade)
58
+ const queue = [];
59
+ for (const f of F) {
60
+ f.c += dt / f.T; f.since += dt;
61
+ f.x += f.vx * dt * 0.004; f.y += f.vy * dt * 0.004; f.vx += rnd(-1, 1) * dt * 2; f.vy += rnd(-1, 1) * dt * 2; f.vx *= 0.99; f.vy *= 0.99;
62
+ if (f.x < 0 || f.x > 1) f.vx = -f.vx; if (f.y < 0.2 || f.y > 0.95) f.vy = -f.vy;
63
+ if (ptr.on && Math.hypot(f.x - ptr.x, (f.y - ptr.y) * H / W) < 0.08) f.c = Math.random(); // startled
64
+ if (f.c >= 1) { f.c = 0; f.since = 0; queue.push(f); }
65
+ }
66
+ for (let q = 0; q < queue.length; q++) {
67
+ const s = queue[q];
68
+ for (const f of F) {
69
+ if (f === s || f.since < 0.12 || f.c < 0.18) continue; // a refractory moment just after flashing
70
+ const d = Math.hypot(f.x - s.x, (f.y - s.y) * H / W, (f.z - s.z) * 0.6);
71
+ if (d > SEE) continue;
72
+ f.c += PARAMS.coupling * (1 - d / SEE);
73
+ if (f.c >= 1) { f.c = 0; f.since = 0; queue.push(f); }
74
+ }
75
+ }
76
+ // once the forest has blinked as one for a while, a gust scrambles a patch of it and the story starts again
77
+ const lit = F.reduce((a, f) => a + (f.since < 0.15 ? 1 : 0), 0) / N;
78
+ syncedFor = lit > 0.6 ? syncedFor + 1 : syncedFor;
79
+ if (syncedFor > 110) { syncedFor = 0; const cx = Math.random(); for (const f of F) if (Math.abs(f.x - cx) < 0.35) f.c = Math.random(); }
80
+
81
+ // draw: night sky glow, then each tree layer with its fireflies behind the next layer
82
+ const sky = ctx.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, '#1b2f3b'); sky.addColorStop(0.55, '#14252e'); sky.addColorStop(1, '#1a2c28'); // moonlit haze between the trunks
83
+ ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H);
84
+ const mist = ctx.createLinearGradient(0, H * 0.55, 0, H); mist.addColorStop(0, 'rgba(60,90,90,0)'); mist.addColorStop(1, 'rgba(60,90,90,.18)');
85
+ for (let L = 0; L < 3; L++) {
86
+ ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1;
87
+ ctx.drawImage(layers[L], 0, 0, W, H);
88
+ if (L === 0) { ctx.fillStyle = mist; ctx.fillRect(0, 0, W, H); }
89
+ ctx.globalCompositeOperation = 'lighter';
90
+ for (const f of F) {
91
+ if (f.layer !== L) continue;
92
+ const flash = f.since < 0.05 ? f.since / 0.05 : Math.exp(-(f.since - 0.05) / 0.22), ember = 0;
93
+ const b = Math.max(ember, flash); if (b < 0.02) continue;
94
+ const sx = f.x * W, sy = f.y * H, size = (1.3 + f.z * f.z * 6) * Math.min(W, H) / 560;
95
+ const blur = Math.max(0, f.z - 0.72) * 70 * Math.min(W, H) / 560; // depth of field: the nearest go soft
96
+ if (blur > 6 && flash > 0.05) { const r = size + blur; ctx.globalAlpha = Math.min(1, b * 0.9 * (size * 4) / r); ctx.drawImage(disc, sx - r, sy - r, r * 2, r * 2); }
97
+ else { const r = size * 4.5; ctx.globalAlpha = b; ctx.drawImage(glow, sx - r, sy - r, r * 2, r * 2); }
98
+ }
99
+ }
100
+ // when many flash at once, the air itself lights up a little
101
+ ctx.globalAlpha = Math.min(0.2, lit * 0.35); ctx.fillStyle = '#4f7a2c'; ctx.fillRect(0, 0, W, H);
102
+ ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';
103
+ raf = requestAnimationFrame(frame);
104
+ }
105
+ raf = requestAnimationFrame(frame);
106
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerleave', leave); canvas.remove(); };
107
+ }
@@ -0,0 +1,69 @@
1
+ // Spiral galaxy: stars placed on logarithmic spiral arms, scattered into density waves, orbiting with a flat
2
+ // rotation curve (inner stars lap the outer ones). Additive points let the core bloom by itself.
3
+ const PARAMS = { stars: 40000, arms: 3, twist: 2 };
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, premultipliedAlpha: false });
10
+ if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
11
+
12
+ const N = Math.round(PARAMS.stars), arms = Math.round(PARAMS.arms);
13
+ const data = new Float32Array(N * 8); // radius, angle, height, size, r, g, b, speed
14
+ const gauss = () => { let u = 0, v = 0; while (!u) u = Math.random(); while (!v) v = Math.random(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); };
15
+ for (let i = 0; i < N; i++) {
16
+ const core = i < N * 0.18;
17
+ const r = core ? Math.abs(gauss()) * 0.12 : 0.06 + Math.pow(Math.random(), 1.6) * 0.94;
18
+ const arm = Math.floor(Math.random() * arms);
19
+ const spread = (0.35 + r * 0.9) * 0.32;
20
+ const a = arm * (Math.PI * 2 / arms) + Math.log(r + 0.02) * PARAMS.twist + gauss() * spread * (core ? 6 : 1);
21
+ const z = gauss() * (core ? 0.05 : 0.018) * (1 - r * 0.6);
22
+ const hot = !core && Math.random() < 0.03; // pink star-forming knots along the arms
23
+ let col;
24
+ if (core) col = [1, 0.86 + Math.random() * 0.1, 0.62 + Math.random() * 0.15];
25
+ else if (hot) col = [1, 0.45, 0.7];
26
+ else { const t = Math.random(); col = [0.62 + 0.35 * t, 0.75 + 0.2 * t, 1]; }
27
+ const o = i * 8;
28
+ data[o] = r; data[o + 1] = a; data[o + 2] = z; data[o + 3] = (core ? 1.6 : 1) * (0.6 + Math.random() * 1.6) * (hot ? 2.4 : 1);
29
+ data[o + 4] = col[0]; data[o + 5] = col[1]; data[o + 6] = col[2];
30
+ data[o + 7] = 0.22 / (r + 0.08); // flat rotation curve: angular speed ∝ 1/r
31
+ }
32
+ const vs = `#version 300 es
33
+ in vec4 a; in vec4 b; uniform float T, D; uniform vec2 R; out vec3 c; out float fade;
34
+ void main() {
35
+ float ang = a.y + T * b.w * .25;
36
+ vec3 p = vec3(cos(ang) * a.x, a.z, sin(ang) * a.x);
37
+ float tilt = .95, ct = cos(tilt), st = sin(tilt);
38
+ p = vec3(p.x, p.y * ct - p.z * st, p.y * st + p.z * ct);
39
+ float yaw = T * .03, cy = cos(yaw), sy = sin(yaw);
40
+ p = vec3(p.x * cy - p.z * sy, p.y, p.x * sy + p.z * cy);
41
+ float z = p.z + 2.2;
42
+ gl_Position = vec4(p.x / z * 2.6 * R.y / R.x, p.y / z * 2.6, 0, 1);
43
+ gl_PointSize = a.w * D * 2.2 / z;
44
+ c = b.xyz; fade = clamp(1.6 - z * .45, .25, 1.);
45
+ }`;
46
+ const fs = `#version 300 es
47
+ precision mediump float; in vec3 c; in float fade; out vec4 o;
48
+ void main() { vec2 q = gl_PointCoord - .5; float d = dot(q, q); float k = exp(-d * 18.); o = vec4(c * k * .55 * fade, 1); }`;
49
+ 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; };
50
+ const prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, fs));
51
+ gl.bindAttribLocation(prog, 0, 'a'); gl.bindAttribLocation(prog, 1, 'b'); gl.linkProgram(prog); gl.useProgram(prog);
52
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
53
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 32, 0);
54
+ gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 32, 16);
55
+ gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
56
+ const uT = gl.getUniformLocation(prog, 'T'), uR = gl.getUniformLocation(prog, 'R'), uD = gl.getUniformLocation(prog, 'D');
57
+ let dpr = 1;
58
+ const resize = () => { dpr = Math.min(devicePixelRatio, 2); canvas.width = stage.clientWidth * dpr | 0; canvas.height = stage.clientHeight * dpr | 0; gl.viewport(0, 0, canvas.width, canvas.height); };
59
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
60
+ let raf;
61
+ function frame(now) {
62
+ gl.clearColor(0.012, 0.016, 0.035, 1); gl.clear(gl.COLOR_BUFFER_BIT);
63
+ gl.uniform1f(uT, now / 1000); gl.uniform2f(uR, canvas.width, canvas.height); gl.uniform1f(uD, dpr * Math.min(canvas.width, canvas.height) / 500);
64
+ gl.drawArrays(gl.POINTS, 0, N);
65
+ raf = requestAnimationFrame(frame);
66
+ }
67
+ raf = requestAnimationFrame(frame);
68
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
69
+ }