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,152 @@
1
+ // Soap bubbles: a wand blows them, they drift and wobble, and their thin film shows interference colours that
2
+ // depend on its thickness: thinner at the top where it drains, banded lower down, darkening to gold just before
3
+ // it pops. A pop tears the film open from one point and flings it out as droplets. Touch one to pop it.
4
+ const PARAMS = { count: 12, wind: 0.3 };
5
+
6
+ export default function (stage) {
7
+ const canvas = document.createElement('canvas');
8
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none';
9
+ stage.append(canvas);
10
+ const ctx = canvas.getContext('2d');
11
+ let W = 0, H = 0, u = 1, bg;
12
+ function paintBg() {
13
+ bg = document.createElement('canvas'); bg.width = W; bg.height = H;
14
+ const g = bg.getContext('2d'), lg = g.createLinearGradient(0, 0, W * 0.3, H);
15
+ lg.addColorStop(0, '#16263a'); lg.addColorStop(1, '#0a1220'); g.fillStyle = lg; g.fillRect(0, 0, W, H);
16
+ g.filter = `blur(${Math.round(8 * u)}px)`; // out-of-focus garden lights behind
17
+ for (let i = 0; i < 22; i++) {
18
+ const x = Math.random() * W, y = Math.random() * H * 0.8, r = (8 + Math.random() * 26) * u;
19
+ g.fillStyle = ['rgba(120,200,170,.18)', 'rgba(255,190,120,.16)', 'rgba(130,160,255,.16)', 'rgba(255,140,170,.12)'][i % 4];
20
+ g.beginPath(); g.arc(x, y, r, 0, 7); g.fill();
21
+ }
22
+ g.filter = 'none';
23
+ }
24
+ 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); u = Math.min(W, H) / 256; paintBg(); };
25
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
26
+
27
+ // thin-film colour from thickness: three cosine bands out of phase approximate the interference palette
28
+ const film = (th, a) => {
29
+ if (th < 0.12) { const k = th / 0.12; return `rgba(${200 * k + 30 | 0},${160 * k + 20 | 0},${60 * k + 10 | 0},${a})`; } // near-black, then gold
30
+ const r = 0.5 + 0.5 * Math.cos(6.283 * (th * 1.9 + 0.0)), g = 0.5 + 0.5 * Math.cos(6.283 * (th * 1.9 + 0.33)), b = 0.5 + 0.5 * Math.cos(6.283 * (th * 1.9 + 0.67));
31
+ return `rgba(${55 + 200 * r | 0},${55 + 200 * g | 0},${55 + 200 * b | 0},${a})`;
32
+ };
33
+ const wand = () => ({ x: W * 0.13, y: H * 0.74, r: 24 * u });
34
+ const bubbles = [], drops = [], puffs = [];
35
+ const make = (x, y, r, vx, vy, age = 0) => ({ x, y, r, vx, vy, age, life: 7 + Math.random() * 7, th: 0.9 + Math.random() * 0.5, spin: (Math.random() - 0.5) * 2, a2: 0.06, a3: 0.03, p2: Math.random() * 6, p3: Math.random() * 6, seed: Math.random() * 100, wob: 1 });
36
+ for (let i = 0; i < Math.min(6, PARAMS.count); i++) bubbles.push(make(W * (0.35 + Math.random() * 0.6), H * (0.15 + Math.random() * 0.6), (14 + Math.random() * 30) * u, 0, 0, Math.random() * 4));
37
+ let blowing = null, nextBlow = 0.3;
38
+
39
+ const shape = (b, t, k = 1) => {
40
+ const pts = [], amp = b.wob;
41
+ for (let i = 0; i < 44; i++) {
42
+ const a = i / 44 * 6.283;
43
+ const w = 1 + amp * (b.a2 * Math.cos(2 * a - b.p2) * Math.sin(t * 9 + b.seed) + b.a3 * Math.cos(3 * a - b.p3) * Math.sin(t * 13 + b.seed)) + 0.008 * Math.sin(3 * a + t * 2 + b.seed);
44
+ pts.push([b.x + Math.cos(a) * b.r * w * k, b.y + Math.sin(a) * b.r * w * k]);
45
+ }
46
+ return pts;
47
+ };
48
+ const trace = pts => { ctx.beginPath(); pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath(); };
49
+ function drawBubble(b, t, open = 0) {
50
+ const fade = Math.min(1, b.age * 3);
51
+ const thin = b.th * Math.max(0, 1 - b.age / b.life); // the film drains and thins with age
52
+ const cg = ctx.createConicGradient(-Math.PI / 2 + b.spin * t * 0.3, b.x, b.y);
53
+ for (let i = 0; i <= 12; i++) {
54
+ const a = i / 12 * 6.283, top = 0.5 + 0.5 * Math.cos(a); // a = 0 is the top of the bubble: thinnest there
55
+ const th = thin * (0.55 + 0.45 * (1 - top)) + 0.07 * Math.sin(a * 3 + t * 1.5 + b.seed);
56
+ cg.addColorStop(i / 12, film(Math.max(0, th), 1));
57
+ }
58
+ ctx.globalCompositeOperation = 'screen';
59
+ if (open > 0) { // popping: the film retracts away from the tear
60
+ ctx.save(); ctx.beginPath(); ctx.moveTo(b.x, b.y);
61
+ const a0 = b.popA + open * Math.PI, a1 = b.popA + Math.PI * 2 - open * Math.PI;
62
+ ctx.arc(b.x, b.y, b.r * 1.3, a0, a1); ctx.closePath(); ctx.clip();
63
+ }
64
+ const pts = shape(b, t);
65
+ ctx.globalAlpha = 0.06 * fade; trace(pts); ctx.fillStyle = cg; ctx.fill();
66
+ ctx.globalAlpha = 0.28 * fade; trace(shape(b, t, 0.84)); ctx.lineWidth = b.r * 0.3; ctx.strokeStyle = cg; ctx.stroke();
67
+ ctx.globalAlpha = 0.75 * fade; trace(shape(b, t, 0.965)); ctx.lineWidth = Math.max(1, b.r * 0.07); ctx.stroke();
68
+ // reflections: a curved glint hugging the upper-left rim, a soft sky spot, a faint bounce low right
69
+ ctx.globalAlpha = 0.8 * fade; ctx.strokeStyle = '#fff'; ctx.lineCap = 'round';
70
+ ctx.lineWidth = b.r * 0.09; ctx.beginPath(); ctx.arc(b.x, b.y, b.r * 0.78, Math.PI * 1.08, Math.PI * 1.36); ctx.stroke();
71
+ ctx.lineWidth = b.r * 0.05; ctx.beginPath(); ctx.arc(b.x, b.y, b.r * 0.78, Math.PI * 1.42, Math.PI * 1.47); ctx.stroke();
72
+ const hs = ctx.createRadialGradient(b.x - b.r * 0.32, b.y - b.r * 0.36, 0, b.x - b.r * 0.32, b.y - b.r * 0.36, b.r * 0.32);
73
+ hs.addColorStop(0, 'rgba(255,255,255,.35)'); hs.addColorStop(1, 'rgba(255,255,255,0)');
74
+ ctx.globalAlpha = fade; ctx.fillStyle = hs; ctx.fillRect(b.x - b.r, b.y - b.r, b.r, b.r);
75
+ ctx.globalAlpha = 0.35 * fade; ctx.lineWidth = b.r * 0.05; ctx.beginPath(); ctx.arc(b.x, b.y, b.r * 0.8, Math.PI * 0.18, Math.PI * 0.36); ctx.stroke();
76
+ if (open > 0) ctx.restore();
77
+ ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';
78
+ }
79
+ function pop(b) {
80
+ if (b.pop != null) return;
81
+ b.pop = 0; b.popA = Math.random() * 6.283;
82
+ const n = Math.round(10 + b.r / u * 0.6);
83
+ for (let i = 0; i < n; i++) {
84
+ const a = b.popA + Math.PI + (Math.random() - 0.5) * 5.6, sp = (40 + Math.random() * 90) * u;
85
+ drops.push({ x: b.x + Math.cos(a) * b.r, y: b.y + Math.sin(a) * b.r, vx: Math.cos(a) * sp + b.vx, vy: Math.sin(a) * sp + b.vy, life: 0.5 + Math.random() * 0.5, age: 0, hue: Math.random() });
86
+ }
87
+ puffs.push({ x: b.x, y: b.y, r: b.r, age: 0 });
88
+ }
89
+
90
+ let px = -1e4, py = -1e4;
91
+ const onMove = e => { const r = canvas.getBoundingClientRect(); px = e.clientX - r.left; py = e.clientY - r.top; };
92
+ const onLeave = () => { px = py = -1e4; };
93
+ canvas.addEventListener('pointermove', onMove); canvas.addEventListener('pointerdown', onMove); canvas.addEventListener('pointerleave', onLeave);
94
+
95
+ let raf, last = performance.now();
96
+ const t0 = last;
97
+ function frame(now) {
98
+ const dt = Math.min(0.033, (now - last) / 1000); last = now; const t = (now - t0) / 1000;
99
+ ctx.drawImage(bg, 0, 0, W, H);
100
+ const wd = wand();
101
+ // the wand: a membrane bulges out of the ring, grows into a bubble and pinches off
102
+ nextBlow -= dt;
103
+ if (!blowing && nextBlow <= 0 && bubbles.length < PARAMS.count) blowing = { k: 0, r: (16 + Math.random() * 22) * u };
104
+ if (blowing) {
105
+ blowing.k = Math.min(1, blowing.k + dt * 1.1);
106
+ const k = blowing.k, e = 1 - Math.pow(1 - k, 2), r = wd.r * 0.9 + (blowing.r - wd.r * 0.9) * e;
107
+ const b = { x: wd.x + (r + wd.r * 0.1) * e * 0.95, y: wd.y - e * 4 * u, r, age: 1, life: 99, th: 1.1, spin: 0.5, a2: 0, a3: 0, p2: 0, p3: 0, seed: 1, vx: 0, vy: 0, wob: 0 };
108
+ ctx.save(); ctx.translate(b.x, b.y); ctx.scale(1 - 0.18 * (1 - e), 1); ctx.translate(-b.x, -b.y); drawBubble(b, t); ctx.restore();
109
+ if (k >= 1) { const nb = make(b.x, b.y, r, (55 + 25 * Math.random()) * u, -14 * u); nb.age = 0.4; nb.a2 = 0.09; bubbles.push(nb); blowing = null; nextBlow = 0.9 + Math.random() * 0.8; }
110
+ }
111
+ for (const b of bubbles) {
112
+ b.age += dt; b.wob *= Math.exp(-dt * 1.1);
113
+ if (b.pop != null) { b.pop += dt / 0.11; continue; }
114
+ const fx = Math.sin(b.y * 0.012 / u + t * 0.7 + b.seed) * 10 * u, fy = Math.cos(b.x * 0.01 / u + t * 0.5) * 6 * u;
115
+ b.vx += (PARAMS.wind * 30 * u + fx - b.vx * 0.5) * dt;
116
+ b.vy += (fy + (-10 + b.age * 2.5) * u - b.vy * 0.5) * dt; // rises on warm breath, then slowly sinks
117
+ b.x += b.vx * dt; b.y += b.vy * dt;
118
+ if (b.age > b.life || Math.hypot(b.x - px, b.y - py) < b.r) pop(b);
119
+ if (b.y - b.r < 0) { b.y = b.r; b.vy = Math.abs(b.vy) * 0.4; b.wob = Math.max(b.wob, 0.8); }
120
+ }
121
+ for (let i = 0; i < bubbles.length; i++) for (let j = i + 1; j < bubbles.length; j++) { // soft bumps
122
+ const a = bubbles[i], c = bubbles[j]; if (a.pop != null || c.pop != null) continue;
123
+ const dx = c.x - a.x, dy = c.y - a.y, d = Math.hypot(dx, dy) || 1, o = a.r + c.r - d;
124
+ if (o > 0) { const nx = dx / d, ny = dy / d; a.x -= nx * o / 2; a.y -= ny * o / 2; c.x += nx * o / 2; c.y += ny * o / 2; const rv = (c.vx - a.vx) * nx + (c.vy - a.vy) * ny; if (rv < 0) { a.vx += nx * rv * 0.6; a.vy += ny * rv * 0.6; c.vx -= nx * rv * 0.6; c.vy -= ny * rv * 0.6; } a.p2 = c.p2 = Math.atan2(ny, nx) * 2; a.wob = Math.max(a.wob, 0.7); c.wob = Math.max(c.wob, 0.7); }
125
+ }
126
+ for (let i = bubbles.length - 1; i >= 0; i--) {
127
+ const b = bubbles[i];
128
+ if ((b.pop != null && b.pop >= 1) || b.x - b.r > W + 10 || b.y - b.r > H + 10 || b.x + b.r < -10) bubbles.splice(i, 1);
129
+ else drawBubble(b, t, b.pop != null ? b.pop : 0);
130
+ }
131
+ // the ring and handle drawn over the membrane's root
132
+ ctx.lineCap = 'round';
133
+ ctx.strokeStyle = '#d9468a'; ctx.lineWidth = 4 * u; ctx.beginPath(); ctx.moveTo(wd.x - wd.r * 0.4, wd.y + wd.r * 0.95); ctx.lineTo(wd.x - wd.r * 1.4, H + 10); ctx.stroke();
134
+ ctx.lineWidth = 3.2 * u; ctx.beginPath(); ctx.ellipse(wd.x, wd.y, wd.r * 0.32, wd.r, 0, 0, 7); ctx.stroke();
135
+ ctx.strokeStyle = 'rgba(255,200,225,.6)'; ctx.lineWidth = 1 * u; ctx.beginPath(); ctx.ellipse(wd.x - 1 * u, wd.y, wd.r * 0.32, wd.r, 0, 2.2, 4); ctx.stroke();
136
+ // pop debris: droplets and a faint puff ring
137
+ ctx.globalCompositeOperation = 'screen';
138
+ for (let i = drops.length - 1; i >= 0; i--) {
139
+ const d = drops[i]; d.age += dt; if (d.age > d.life) { drops.splice(i, 1); continue; }
140
+ d.vy += 260 * u * dt; d.vx *= 0.97; d.vy *= 0.97; d.x += d.vx * dt; d.y += d.vy * dt;
141
+ ctx.fillStyle = film(0.4 + d.hue, 1 - d.age / d.life); ctx.beginPath(); ctx.arc(d.x, d.y, 1.2 * u, 0, 7); ctx.fill();
142
+ }
143
+ for (let i = puffs.length - 1; i >= 0; i--) {
144
+ const p = puffs[i]; p.age += dt; if (p.age > 0.35) { puffs.splice(i, 1); continue; }
145
+ ctx.strokeStyle = `rgba(220,235,255,${0.35 * (1 - p.age / 0.35)})`; ctx.lineWidth = 1.2 * u; ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 + p.age * 1.4), 0, 7); ctx.stroke();
146
+ }
147
+ ctx.globalCompositeOperation = 'source-over';
148
+ raf = requestAnimationFrame(frame);
149
+ }
150
+ raf = requestAnimationFrame(frame);
151
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener('pointermove', onMove); canvas.removeEventListener('pointerdown', onMove); canvas.removeEventListener('pointerleave', onLeave); canvas.remove(); };
152
+ }
@@ -0,0 +1,45 @@
1
+ // Pool caustics: light focused by a rippling surface. The pattern comes from bending sample points through
2
+ // a few layers of moving waves and measuring how tightly they bunch up; bright threads where light converges.
3
+ const PARAMS = { speed: 0.6, intensity: 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) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
11
+ const vs = `#version 300 es
12
+ in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
13
+ const fs = `#version 300 es
14
+ precision highp float; in vec2 uv; out vec4 o; uniform vec2 R; uniform float T, I;
15
+ float caustic(vec2 q, float t) {
16
+ vec2 p = mod(q * 6.2832, 6.2832) - 250.; vec2 i = p; float c = 1.;
17
+ for (int n = 0; n < 5; n++) { float tt = t * (1. - 3.5 / float(n + 1)); i = p + vec2(cos(tt - i.x) + sin(tt + i.y), sin(tt - i.y) + cos(tt + i.x)); c += 1. / length(vec2(p.x / (sin(i.x + tt) / .005), p.y / (cos(i.y + tt) / .005))); }
18
+ c /= 5.; c = 1.17 - pow(c, 1.4); return pow(abs(c), 8.);
19
+ }
20
+ void main() {
21
+ vec2 a = vec2(R.x / R.y, 1.), q = uv * a;
22
+ float t = T + 23.;
23
+ // pool floor: pale tiles with darker grout
24
+ vec2 tile = fract(q * 7.); float grout = smoothstep(.0, .05, min(min(tile.x, 1. - tile.x), min(tile.y, 1. - tile.y)));
25
+ vec3 floorC = mix(vec3(.55, .78, .86), vec3(.82, .94, .97), grout);
26
+ // chromatic caustics: each channel sampled at a slight offset
27
+ float r = caustic(q * .9 + vec2(.004, 0), t), g = caustic(q * .9, t), b = caustic(q * .9 - vec2(.004, 0), t);
28
+ vec3 light = vec3(r, g, b) * I;
29
+ vec3 water = mix(vec3(.05, .38, .55), vec3(.15, .62, .74), uv.y);
30
+ vec3 c = floorC * water * 1.3 + light * vec3(.85, 1., 1.);
31
+ c *= .8 + .2 * smoothstep(1.2, .2, length(uv - .5));
32
+ o = vec4(c, 1);
33
+ }`;
34
+ 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; };
35
+ const prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, fs)); gl.bindAttribLocation(prog, 0, 'p'); gl.linkProgram(prog); gl.useProgram(prog);
36
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
37
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
38
+ const uR = gl.getUniformLocation(prog, 'R'), uT = gl.getUniformLocation(prog, 'T'), uI = gl.getUniformLocation(prog, 'I');
39
+ const resize = () => { const d = Math.min(devicePixelRatio, 1.5); canvas.width = stage.clientWidth * d | 0; canvas.height = stage.clientHeight * d | 0; gl.viewport(0, 0, canvas.width, canvas.height); };
40
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
41
+ let raf;
42
+ function frame(now) { gl.uniform2f(uR, canvas.width, canvas.height); gl.uniform1f(uT, now / 1000 * PARAMS.speed); gl.uniform1f(uI, PARAMS.intensity); gl.drawArrays(gl.TRIANGLES, 0, 3); raf = requestAnimationFrame(frame); }
43
+ raf = requestAnimationFrame(frame);
44
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
45
+ }
@@ -0,0 +1,53 @@
1
+ // Dripping text: drops swell under the letters, stretch, pinch off and fall. A blur + alpha-threshold filter
2
+ // (the gooey trick) melts drop and letter into one liquid shape, so the necks thin and snap like real paint.
3
+ const PARAMS = { drips: 14, viscosity: 1 };
4
+
5
+ export default function (stage) {
6
+ const id = 'drip-' + Math.random().toString(36).slice(2, 8);
7
+ const wrap = document.createElement('div');
8
+ wrap.style.cssText = 'position:absolute;inset:0;background:#f6efe3';
9
+ wrap.innerHTML = `<svg width="0" height="0" style="position:absolute"><filter id="${id}"><feGaussianBlur stdDeviation="5"/><feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 24 -11"/></filter></svg>`;
10
+ const canvas = document.createElement('canvas');
11
+ canvas.style.cssText = `position:absolute;inset:0;width:100%;height:100%;display:block;filter:url(#${id})`;
12
+ wrap.append(canvas); stage.append(wrap);
13
+ const ctx = canvas.getContext('2d');
14
+ let W = 0, H = 0, font = 0, baseY = 0, spots = [];
15
+ const resize = () => {
16
+ 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);
17
+ font = Math.min(W * 0.24, H * 0.42); baseY = H * 0.46;
18
+ // find where the letters' bottom edges are, so drops start under real ink
19
+ const off = document.createElement('canvas'); off.width = Math.ceil(W); off.height = Math.ceil(H); const o = off.getContext('2d');
20
+ o.font = `900 ${font}px "Mona Sans", system-ui, sans-serif`; o.textAlign = 'center'; o.textBaseline = 'alphabetic'; o.fillText('DRIP', W / 2, baseY);
21
+ const data = o.getImageData(0, 0, off.width, off.height).data; spots = [];
22
+ for (let x = 2; x < off.width - 2; x += 3) { for (let y = Math.min(off.height - 1, Math.ceil(baseY + 4)); y > baseY - font; y--) if (data[(y * off.width + x) * 4 + 3] > 200) { if (y > baseY - font * 0.15) spots.push([x, y]); break; } }
23
+ };
24
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
25
+ let drips = [], raf, last = performance.now();
26
+ const newDrip = () => { const [x, y] = spots[Math.floor(Math.random() * spots.length)] || [W / 2, baseY]; return { x, y0: y, len: 0, r: 2 + Math.random() * font * 0.025, grow: (8 + Math.random() * 18) / PARAMS.viscosity, falling: false, fy: 0, fv: 0 }; };
27
+ for (let i = 0; i < PARAMS.drips; i++) { const d = newDrip(); d.len = Math.random() * font * 0.3; drips.push(d); }
28
+ function frame(now) {
29
+ const dt = Math.min(0.033, (now - last) / 1000); last = now;
30
+ ctx.clearRect(0, 0, W, H);
31
+ ctx.fillStyle = '#d62839';
32
+ ctx.font = `900 ${font}px "Mona Sans", system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText('DRIP', W / 2, baseY);
33
+ for (const d of drips) {
34
+ if (!d.falling) {
35
+ d.len += d.grow * dt * (1 + d.len / (font * 0.2)); // drips accelerate as they get heavier
36
+ d.r = Math.min(font * 0.06, d.r + dt * 2);
37
+ if (d.len > font * (0.35 + Math.random() * 0.002)) { d.falling = true; d.fy = d.y0 + d.len; d.fv = 40; }
38
+ // neck: a column of shrinking circles down to the bulb
39
+ const steps = Math.max(2, d.len / 3);
40
+ for (let i = 0; i <= steps; i++) { const k = i / steps, r = d.r * (0.35 + 0.25 * Math.sin(k * Math.PI)) * (k > 0.85 ? 1.7 : 1); ctx.beginPath(); ctx.arc(d.x, d.y0 + d.len * k, r, 0, 7); ctx.fill(); }
41
+ } else {
42
+ d.fv += 1400 * dt; d.fy += d.fv * dt;
43
+ ctx.beginPath(); ctx.ellipse(d.x, d.fy, d.r * 1.5, d.r * 1.9, 0, 0, 7); ctx.fill();
44
+ }
45
+ }
46
+ // a puddle collects at the bottom
47
+ ctx.fillRect(0, H - H * 0.05, W, H * 0.05);
48
+ drips = drips.map(d => (d.falling && d.fy > H) ? newDrip() : d);
49
+ raf = requestAnimationFrame(frame);
50
+ }
51
+ raf = requestAnimationFrame(frame);
52
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); wrap.remove(); };
53
+ }
@@ -0,0 +1,56 @@
1
+ // Ferrofluid: a glossy black blob whose surface rises into spikes, strongest on the side facing the magnet.
2
+ // The outline is a distance field (circle + sharpened cosine spikes); its gradient gives a normal for the gloss.
3
+ const PARAMS = { spikes: 22, strength: 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) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
11
+ const vs = `#version 300 es
12
+ in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
13
+ const fs = `#version 300 es
14
+ precision highp float; in vec2 uv; out vec4 o; uniform vec2 R, M; uniform float T, N, S;
15
+ float blob(vec2 q) {
16
+ float a = atan(q.y, q.x), r = length(q);
17
+ vec2 toM = M - vec2(0); float face = max(0., dot(normalize(q + 1e-4), normalize(toM + 1e-4))); // spikes grow toward the magnet
18
+ float pull = S * (.35 + .65 * pow(face, 2.)) * clamp(1.2 - length(toM) * .6, .3, 1.);
19
+ float spikes = pow(max(0., cos(a * N + sin(T * .7) * .4)), 6.) * .16 * pull + pow(max(0., cos(a * N * 2. + 1.)), 10.) * .05 * pull;
20
+ float base = .3 + .03 * sin(a * 3. + T) + pull * .04;
21
+ return r - (base + spikes);
22
+ }
23
+ void main() {
24
+ vec2 a = vec2(R.x / R.y, 1.), q = (uv - .5) * a * 1.1;
25
+ float d = blob(q), e = 1.5 / R.y;
26
+ vec2 g = vec2(blob(q + vec2(e, 0)) - blob(q - vec2(e, 0)), blob(q + vec2(0, e)) - blob(q - vec2(0, e)));
27
+ float h = clamp(-d * 8., 0., 1.); // height: rises away from the edge
28
+ vec3 n = normalize(vec3(-g / (2. * e) * (1. - h) * .9, .45 + h));
29
+ vec3 l = normalize(vec3(-.4, .6, .7)), v = vec3(0, 0, 1);
30
+ float spec = pow(max(0., dot(reflect(-l, n), v)), 60.), rim = pow(1. - n.z, 2.);
31
+ vec3 bg = mix(vec3(.93, .92, .9), vec3(.82, .8, .78), length(uv - .5));
32
+ vec3 ink = vec3(.02, .02, .03) + spec * vec3(1.) + rim * vec3(.12, .14, .2) + max(0., n.x) * .05;
33
+ float cover = smoothstep(e * 1.5, -e * 1.5, d);
34
+ float shadow = smoothstep(.12, -.05, blob(q - vec2(.02, -.04))) * .25;
35
+ o = vec4(mix(bg * (1. - shadow), ink, cover), 1);
36
+ }`;
37
+ 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; };
38
+ const prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, fs)); gl.bindAttribLocation(prog, 0, 'p'); gl.linkProgram(prog); gl.useProgram(prog);
39
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
40
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
41
+ const u = n => gl.getUniformLocation(prog, n), uR = u('R'), uM = u('M'), uT = u('T'), uN = u('N'), uS = u('S');
42
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); canvas.width = stage.clientWidth * d | 0; canvas.height = stage.clientHeight * d | 0; gl.viewport(0, 0, canvas.width, canvas.height); };
43
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
44
+ let mx = 0.4, my = 0.2, tx = mx, ty = my, lastMove = -1e9, raf;
45
+ const onMove = e => { const b = canvas.getBoundingClientRect(), a = b.width / b.height; tx = ((e.clientX - b.left) / b.width - 0.5) * a * 1.1; ty = (0.5 - (e.clientY - b.top) / b.height) * 1.1; lastMove = performance.now(); };
46
+ canvas.addEventListener('pointermove', onMove);
47
+ function frame(now) {
48
+ const t = now / 1000;
49
+ if (now - lastMove > 1500) { tx = Math.cos(t * 0.6) * 0.5; ty = Math.sin(t * 0.9) * 0.35; }
50
+ mx += (tx - mx) * 0.08; my += (ty - my) * 0.08;
51
+ gl.uniform2f(uR, canvas.width, canvas.height); gl.uniform2f(uM, mx, my); gl.uniform1f(uT, t); gl.uniform1f(uN, Math.round(PARAMS.spikes)); gl.uniform1f(uS, PARAMS.strength);
52
+ gl.drawArrays(gl.TRIANGLES, 0, 3); raf = requestAnimationFrame(frame);
53
+ }
54
+ raf = requestAnimationFrame(frame);
55
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener('pointermove', onMove); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
56
+ }
@@ -0,0 +1,83 @@
1
+ // Fire & smoke: a GPU fluid solver with buoyancy. Heat rises (it pushes the velocity field up), cools as it
2
+ // travels and leaves smoke behind. Colour comes from temperature through a blackbody ramp: white, yellow, red, smoke.
3
+ const PARAMS = { heat: 1, cooling: 0.95, curl: 22 };
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', { alpha: false, 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 VS = `#version 300 es
12
+ in vec2 p; out vec2 uv, vL, vR, vT, vB; uniform vec2 texel;
13
+ void main() { uv = p * .5 + .5; vL = uv - vec2(texel.x, 0); vR = uv + vec2(texel.x, 0); vT = uv + vec2(0, texel.y); vB = uv - vec2(0, texel.y); gl_Position = vec4(p, 0, 1); }`;
14
+ const head = `#version 300 es
15
+ precision highp float; precision highp sampler2D; in vec2 uv, vL, vR, vT, vB; out vec4 o;`;
16
+ const FS = {
17
+ // the burner: heat (x), smoke (y) and an upward kick near the bottom, flickering
18
+ source: `uniform sampler2D tgt; uniform float T, amt; uniform vec2 aspect; uniform int mode;
19
+ float n(vec2 p) { return fract(sin(dot(p, vec2(12.9, 78.2))) * 43758.5); }
20
+ void main() { vec4 v = texture(tgt, uv); vec2 d = (uv - vec2(.5 + .04 * sin(T * 3.1), .07)) * aspect; float r = exp(-dot(d * vec2(1.7, 4.5), d * vec2(1.7, 4.5)) * 90.);
21
+ float flick = .7 + .3 * n(floor(uv * 40.) + floor(T * 20.));
22
+ if (mode == 0) o = v + vec4(0, r * 2.2 * amt, 0, 0); else o = v + vec4(r * flick * amt * .9, r * amt * .25, 0, 0); }`,
23
+ advect: `uniform sampler2D vel, src; uniform vec2 simTexel; uniform float dt; uniform vec4 keep;
24
+ void main() { vec2 c = uv - dt * texture(vel, uv).xy * simTexel; o = keep * texture(src, c); }`,
25
+ buoy: `uniform sampler2D vel, dye; uniform float dt;
26
+ void main() { vec4 v = texture(vel, uv); vec2 d = texture(dye, uv).xy; o = vec4(v.xy + vec2(0, d.x * 260. - d.y * 18.) * dt, 0, 1); }`,
27
+ divergence: `uniform sampler2D vel;
28
+ void main() { float L = texture(vel, vL).x, R = texture(vel, vR).x, T = texture(vel, vT).y, B = texture(vel, vB).y; vec2 C = texture(vel, uv).xy;
29
+ if (vL.x < 0.) L = -C.x; if (vR.x > 1.) R = -C.x; if (vT.y > 1.) T = -C.y; if (vB.y < 0.) B = -C.y; o = vec4(.5 * (R - L + T - B), 0, 0, 1); }`,
30
+ curl: `uniform sampler2D vel; void main() { o = vec4(.5 * (texture(vel, vR).y - texture(vel, vL).y - texture(vel, vT).x + texture(vel, vB).x), 0, 0, 1); }`,
31
+ vorticity: `uniform sampler2D vel, crl; uniform float strength, dt;
32
+ void main() { float L = texture(crl, vL).x, R = texture(crl, vR).x, T = texture(crl, vT).x, B = texture(crl, vB).x, C = texture(crl, uv).x;
33
+ vec2 f = .5 * vec2(abs(T) - abs(B), abs(R) - abs(L)); f /= length(f) + 1e-4; f *= strength * C; f.y *= -1.; o = vec4(texture(vel, uv).xy + f * dt, 0, 1); }`,
34
+ pressure: `uniform sampler2D prs, div; void main() { o = vec4((texture(prs, vL).x + texture(prs, vR).x + texture(prs, vB).x + texture(prs, vT).x - texture(div, uv).x) * .25, 0, 0, 1); }`,
35
+ gradient: `uniform sampler2D prs, vel; void main() { o = vec4(texture(vel, uv).xy - .5 * vec2(texture(prs, vR).x - texture(prs, vL).x, texture(prs, vT).x - texture(prs, vB).x), 0, 1); }`,
36
+ scale: `uniform sampler2D src; uniform float k; void main() { o = k * texture(src, uv); }`,
37
+ display: `uniform sampler2D dye;
38
+ vec3 black(float t) { return clamp(vec3(t * 3.2, t * t * 2.4, pow(t, 4.) * 3.), 0., 1.); }
39
+ void main() { vec2 d = texture(dye, uv).xy; vec3 bg = mix(vec3(.04, .035, .05), vec3(.08, .06, .07), uv.y);
40
+ vec3 smoke = vec3(.32, .3, .31) * (1. - exp(-d.y * 1.4));
41
+ vec3 c = bg * exp(-d.y * 1.2) + smoke + black(clamp(d.x * .75, 0., 1.)) * 1.3;
42
+ o = vec4(c / (1. + c * .25), 1); }`,
43
+ };
44
+ const compile = (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; };
45
+ const vs = compile(gl.VERTEX_SHADER, VS), progs = {};
46
+ for (const k in FS) {
47
+ const p = gl.createProgram(); gl.attachShader(p, vs); gl.attachShader(p, compile(gl.FRAGMENT_SHADER, head + FS[k])); gl.bindAttribLocation(p, 0, 'p'); gl.linkProgram(p);
48
+ const u = {}; const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); for (let i = 0; i < n; i++) { const name = gl.getActiveUniform(p, i).name; u[name] = gl.getUniformLocation(p, name); }
49
+ progs[k] = { p, u };
50
+ }
51
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
52
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
53
+ const fbo = (w, h) => { const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
54
+ 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); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null);
55
+ const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); return { tex, fb, w, h }; };
56
+ const pair = (w, h) => { let a = fbo(w, h), b = fbo(w, h); return { get read() { return a; }, get write() { return b; }, swap() { [a, b] = [b, a]; } }; };
57
+ let W = 0, H = 0, vel, dye, div, crl, prs;
58
+ const resize = () => { const d = Math.min(devicePixelRatio, 1.5); W = Math.max(2, stage.clientWidth * d | 0); H = Math.max(2, stage.clientHeight * d | 0); canvas.width = W; canvas.height = H;
59
+ const ar = W / H, sw = Math.round(128 * Math.max(1, ar)), sh = Math.round(128 * Math.max(1, 1 / ar)); vel = pair(sw, sh); prs = pair(sw, sh); div = fbo(sw, sh); crl = fbo(sw, sh); dye = pair(sw * 3, sh * 3); };
60
+ resize(); const ro = new ResizeObserver(resize); ro.observe(stage);
61
+ const bind = (u, t) => { gl.activeTexture(gl.TEXTURE0 + u); gl.bindTexture(gl.TEXTURE_2D, t.tex); return u; };
62
+ const run = (prog, target, set) => { gl.useProgram(prog.p); const w = target ? target.w : W, h = target ? target.h : H; gl.uniform2f(prog.u.texel, 1 / w, 1 / h); set(prog.u); gl.viewport(0, 0, w, h); gl.bindFramebuffer(gl.FRAMEBUFFER, target ? target.fb : null); gl.drawArrays(gl.TRIANGLES, 0, 3); };
63
+ let last = performance.now(), raf;
64
+ function frame(now) {
65
+ const dt = Math.min(0.016, (now - last) / 1000); last = now; const t = now / 1000, st = [1 / vel.read.w, 1 / vel.read.h], asp = [W / H, 1];
66
+ run(progs.source, vel.write, u => { gl.uniform1i(u.tgt, bind(0, vel.read)); gl.uniform1f(u.T, t); gl.uniform1f(u.amt, 60 * PARAMS.heat * dt * 60); gl.uniform2f(u.aspect, ...asp); gl.uniform1i(u.mode, 0); }); vel.swap();
67
+ run(progs.source, dye.write, u => { gl.uniform1i(u.tgt, bind(0, dye.read)); gl.uniform1f(u.T, t); gl.uniform1f(u.amt, PARAMS.heat * dt * 60 * 0.4); gl.uniform2f(u.aspect, ...asp); gl.uniform1i(u.mode, 1); }); dye.swap();
68
+ run(progs.buoy, vel.write, u => { gl.uniform1i(u.vel, bind(0, vel.read)); gl.uniform1i(u.dye, bind(1, dye.read)); gl.uniform1f(u.dt, dt); }); vel.swap();
69
+ run(progs.curl, crl, u => gl.uniform1i(u.vel, bind(0, vel.read)));
70
+ run(progs.vorticity, vel.write, u => { gl.uniform1i(u.vel, bind(0, vel.read)); gl.uniform1i(u.crl, bind(1, crl)); gl.uniform1f(u.strength, PARAMS.curl); gl.uniform1f(u.dt, dt); }); vel.swap();
71
+ run(progs.divergence, div, u => gl.uniform1i(u.vel, bind(0, vel.read)));
72
+ run(progs.scale, prs.write, u => { gl.uniform1i(u.src, bind(0, prs.read)); gl.uniform1f(u.k, 0.8); }); prs.swap();
73
+ for (let i = 0; i < 20; i++) { run(progs.pressure, prs.write, u => { gl.uniform1i(u.prs, bind(0, prs.read)); gl.uniform1i(u.div, bind(1, div)); }); prs.swap(); }
74
+ run(progs.gradient, vel.write, u => { gl.uniform1i(u.prs, bind(0, prs.read)); gl.uniform1i(u.vel, bind(1, vel.read)); }); vel.swap();
75
+ run(progs.advect, vel.write, u => { gl.uniform1i(u.vel, bind(0, vel.read)); gl.uniform1i(u.src, bind(0, vel.read)); gl.uniform2f(u.simTexel, ...st); gl.uniform1f(u.dt, dt); gl.uniform4f(u.keep, 0.995, 0.995, 1, 1); }); vel.swap();
76
+ const cool = Math.pow(PARAMS.cooling, dt * 60);
77
+ run(progs.advect, dye.write, u => { gl.uniform1i(u.vel, bind(0, vel.read)); gl.uniform1i(u.src, bind(1, dye.read)); gl.uniform2f(u.simTexel, ...st); gl.uniform1f(u.dt, dt); gl.uniform4f(u.keep, cool, Math.pow(0.992, dt * 60), 1, 1); }); dye.swap();
78
+ run(progs.display, null, u => gl.uniform1i(u.dye, bind(0, dye.read)));
79
+ raf = requestAnimationFrame(frame);
80
+ }
81
+ raf = requestAnimationFrame(frame);
82
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
83
+ }
@@ -0,0 +1,167 @@
1
+ // Honey drip: a strand hangs from a twirling dipper. Honey flows down into a bulb at its end, the bulb's weight
2
+ // stretches the strand (viscous creep that speeds up as it gets longer and heavier), the neck thins until it
3
+ // snaps, the strand springs back, and the drop plops into a slow, thick pool. The strand is a Verlet chain.
4
+ const PARAMS = { viscosity: 1, flow: 1 };
5
+
6
+ export default function (stage) {
7
+ const canvas = document.createElement('canvas');
8
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
9
+ stage.append(canvas);
10
+ const ctx = canvas.getContext('2d');
11
+ let W = 0, H = 0, u = 1;
12
+ const COLS = 96, ph = new Float32Array(COLS), pv = new Float32Array(COLS), mound = new Float32Array(COLS);
13
+ 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); u = H / 215; };
14
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
15
+
16
+ const N = 18;
17
+ const pts = Array.from({ length: N }, () => ({ x: 0, y: 0, px: 0, py: 0 }));
18
+ let L = 18, Lv = 0, M = 0.3, state = 'grow', stateT = 0, snapAt = 0.8;
19
+ const drops = [];
20
+ const tipOf = (t) => { // the dipper sways from a pivot above the frame; its tip carries the strand
21
+ const a = Math.sin(t * 0.9) * 0.06 + Math.sin(t * 2.3) * 0.012, px = W * 0.5, py = -70 * u, r = 70 * u + 72 * u;
22
+ return { a, x: px - Math.sin(a) * r, y: py + Math.cos(a) * r };
23
+ };
24
+ { const tp = tipOf(0); pts.forEach((p, i) => { p.x = p.px = tp.x; p.y = p.py = tp.y + i * L * u / (N - 1); }); }
25
+ const poolY = x => { const i = Math.max(0, Math.min(COLS - 1, Math.round(x / W * (COLS - 1)))); return H - 34 * u - ph[i] - mound[i]; };
26
+
27
+ function physics(dt, t) {
28
+ const tp = tipOf(t), visc = PARAMS.viscosity;
29
+ stateT += dt;
30
+ if (state === 'grow') {
31
+ M = Math.min(1.5, M + dt * 0.36 * PARAMS.flow / Math.sqrt(visc)); // honey runs down into the bulb
32
+ Lv = Math.min(420, M * M * L / visc); // the heavier and longer, the faster it creeps
33
+ L += Lv * dt;
34
+ const neck = Math.max(0.3, 5.5 * Math.pow(18 / L, 1.2));
35
+ if (neck < 0.6 || L * u > (H - 34 * u - tp.y) * snapAt) snap();
36
+ } else if (state === 'recoil') {
37
+ Lv += (-(L - 16) * 140 - Lv * 7) * dt; L += Lv * dt; // elastic snap-back, a little overshoot
38
+ if (stateT > 0.7) { state = 'grow'; stateT = 0; M = 0.22; }
39
+ }
40
+ // Verlet chain: pinned at the dipper, segment rest length = current strand length / segments
41
+ const rest = L * u / (N - 1), g = 700 * u;
42
+ for (let i = 1; i < N; i++) {
43
+ const p = pts[i], vx = (p.x - p.px) * 0.985, vy = (p.y - p.py) * 0.985;
44
+ p.px = p.x; p.py = p.y; p.x += vx; p.y += vy + g * dt * dt;
45
+ }
46
+ pts[0].x = pts[0].px = tp.x; pts[0].y = pts[0].py = tp.y;
47
+ const heavy = state === 'grow' ? 0.12 : 1;
48
+ for (let k = 0; k < 14; k++) for (let i = 0; i < N - 1; i++) {
49
+ const a = pts[i], b = pts[i + 1], dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1e-6;
50
+ const wa = i === 0 ? 0 : 1, wb = i + 1 === N - 1 ? heavy : 1, diff = (d - rest) / d / (wa + wb);
51
+ a.x += dx * diff * wa; a.y += dy * diff * wa; b.x -= dx * diff * wb; b.y -= dy * diff * wb;
52
+ }
53
+ // falling drops and the beaded tail
54
+ for (let i = drops.length - 1; i >= 0; i--) {
55
+ const d = drops[i]; d.vy += g * 1.25 * dt; d.x += d.vx * dt; d.y += d.vy * dt; d.age += dt;
56
+ if (d.y + d.r * 0.6 > poolY(d.x)) { splash(d.x, d.r, d.vy); drops.splice(i, 1); }
57
+ }
58
+ // the pool: a damped row of springs for ripples, plus a mound that spreads like thick honey
59
+ for (let s = 0; s < 3; s++) {
60
+ for (let i = 0; i < COLS; i++) { const l = ph[Math.max(0, i - 1)] + ph[Math.min(COLS - 1, i + 1)] - 2 * ph[i]; pv[i] += (l * 380 - ph[i] * 6 - pv[i] * 4.5 / visc) * dt / 3; }
61
+ for (let i = 0; i < COLS; i++) ph[i] += pv[i] * dt / 3;
62
+ let mean = 0; const m2 = Float32Array.from(mound);
63
+ for (let i = 0; i < COLS; i++) { mound[i] += (m2[Math.max(0, i - 1)] + m2[Math.min(COLS - 1, i + 1)] - 2 * m2[i]) * 0.03 / Math.sqrt(visc); mean += mound[i]; }
64
+ mean /= COLS; for (let i = 0; i < COLS; i++) mound[i] -= mean;
65
+ }
66
+ }
67
+ function snap() {
68
+ const b = pts[N - 1], prev = pts[N - 2], rb = bulbR();
69
+ const vy = Math.max(Lv * u, (b.y - b.py) * 60);
70
+ drops.push({ x: b.x, y: b.y, vx: (b.x - b.px) * 60, vy, r: rb, age: 0, main: true });
71
+ for (let k = 1; k <= 5; k++) { // the neck breaks into a string of tiny beads
72
+ const f = k / 6; drops.push({ x: prev.x + (b.x - prev.x) * f * 0.5, y: b.y - rb - k * 4.5 * u, vx: 0, vy: vy * (0.75 - k * 0.07), r: (0.5 + Math.random() * 0.8) * u, age: 0 });
73
+ }
74
+ state = 'recoil'; stateT = 0; Lv = -Lv * 0.2; L = Math.max(18, L * 0.32); snapAt = 0.7 + Math.random() * 0.18;
75
+ }
76
+ function splash(x, r, vy) {
77
+ const i = Math.round(x / W * (COLS - 1)), big = r / u;
78
+ for (let k = -3; k <= 3; k++) { const j = i + k; if (j >= 0 && j < COLS) { pv[j] -= vy * 0.02 * big * Math.exp(-k * k / 4) / 6; mound[j] += big * 0.7 * Math.exp(-k * k / 2.5) * u; } }
79
+ }
80
+ const bulbR = () => (3 + 9 * Math.cbrt(M)) * u;
81
+
82
+ // ── drawing
83
+ function dipper(t) {
84
+ const tp = tipOf(t);
85
+ ctx.save(); ctx.translate(W * 0.5, -70 * u); ctx.rotate(tp.a);
86
+ // handle
87
+ const hg = ctx.createLinearGradient(-5 * u, 0, 5 * u, 0); hg.addColorStop(0, '#7a4a22'); hg.addColorStop(0.4, '#c48a52'); hg.addColorStop(1, '#6a3d1a');
88
+ ctx.fillStyle = hg; ctx.fillRect(-4.5 * u, 0, 9 * u, 90 * u);
89
+ // grooved head: a barrel with ring grooves
90
+ const y0 = 80 * u, y1 = 138 * u, R = 31 * u, steps = 60, prof = [];
91
+ for (let s = 0; s <= steps; s++) {
92
+ const f = s / steps, env = Math.pow(Math.sin(Math.PI * (0.08 + 0.84 * f)), 0.45), gr = Math.pow(0.5 + 0.5 * Math.cos(f * Math.PI * 2 * 6), 6);
93
+ prof.push([R * env * (1 - 0.16 * gr), y0 + (y1 - y0) * f]);
94
+ }
95
+ ctx.beginPath(); prof.forEach(([r, y], i) => i ? ctx.lineTo(r, y) : ctx.moveTo(r, y)); for (let i = prof.length - 1; i >= 0; i--) ctx.lineTo(-prof[i][0], prof[i][1]); ctx.closePath();
96
+ const wg = ctx.createLinearGradient(-R, 0, R, 0); wg.addColorStop(0, '#5e3415'); wg.addColorStop(0.32, '#d29a5e'); wg.addColorStop(0.55, '#b07640'); wg.addColorStop(1, '#4a280f');
97
+ ctx.fillStyle = wg; ctx.fill();
98
+ ctx.strokeStyle = 'rgba(60,30,10,.45)'; ctx.lineWidth = 1.2 * u;
99
+ for (let k = 1; k < 6; k++) { const f = k / 6, y = y0 + (y1 - y0) * f, r = R * Math.pow(Math.sin(Math.PI * (0.08 + 0.84 * f)), 0.45) * 0.86; ctx.beginPath(); ctx.ellipse(0, y, r, 2.6 * u, 0, 0.1, Math.PI - 0.1); ctx.stroke(); }
100
+ // honey coating the lower half and gathering at the tip, with sagging drips in the grooves
101
+ ctx.beginPath();
102
+ const start = Math.floor(steps * 0.56);
103
+ for (let s = start; s <= steps; s++) { const [r, y] = prof[s]; const sag = Math.sin(s * 0.9 + t * 0.6) * 1.2 * u; ctx.lineTo(r + 2 * u, y + sag); }
104
+ ctx.quadraticCurveTo(10 * u, y1 + 6 * u, 3.5 * u, y1 + 8 * u); ctx.lineTo(-3.5 * u, y1 + 8 * u); ctx.quadraticCurveTo(-10 * u, y1 + 6 * u, -prof[steps][0] - 2 * u, prof[steps][1]);
105
+ for (let s = steps; s >= start; s--) { const [r, y] = prof[s]; const sag = Math.sin(s * 1.1 - t * 0.5) * 1.2 * u; ctx.lineTo(-r - 2 * u, y + sag); }
106
+ ctx.quadraticCurveTo(0, prof[start][1] - 6 * u, prof[start][0] + 2 * u, prof[start][1]);
107
+ const cg = ctx.createLinearGradient(-R, 0, R, 0); cg.addColorStop(0, 'rgba(150,72,6,.85)'); cg.addColorStop(0.35, 'rgba(250,180,50,.66)'); cg.addColorStop(0.7, 'rgba(214,128,18,.74)'); cg.addColorStop(1, 'rgba(120,56,4,.88)');
108
+ ctx.fillStyle = cg; ctx.fill();
109
+ ctx.strokeStyle = 'rgba(255,240,200,.75)'; ctx.lineWidth = 1.4 * u; ctx.beginPath();
110
+ for (let s = start + 3; s < steps - 4; s++) { const [r, y] = prof[s]; ctx.lineTo(-r * 0.55, y); } ctx.stroke();
111
+ ctx.restore();
112
+ }
113
+ function strand() {
114
+ const neck = Math.max(0.3, 5.5 * Math.pow(18 / L, 1.2)) * u, tipW = 8 * u, n = pts.length;
115
+ const left = [], right = [];
116
+ for (let i = 0; i < n; i++) {
117
+ const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)], dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1;
118
+ const s = i / (n - 1), w = (neck + (tipW - neck) * Math.pow(1 - s, 2.4)) / 2;
119
+ left.push([pts[i].x - dy / d * w, pts[i].y + dx / d * w, w]); right.push([pts[i].x + dy / d * w, pts[i].y - dx / d * w]);
120
+ }
121
+ ctx.beginPath(); left.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); for (let i = n - 1; i >= 0; i--) ctx.lineTo(right[i][0], right[i][1]); ctx.closePath();
122
+ ctx.fillStyle = 'rgba(206,124,14,.93)'; ctx.fill();
123
+ // a brighter core (light passing through the thick part) and a hairline highlight
124
+ ctx.strokeStyle = 'rgba(255,205,90,.7)'; ctx.lineCap = 'round';
125
+ for (let i = 0; i < n - 1; i++) { ctx.lineWidth = Math.max(0.3, left[i][2] * 0.7); ctx.beginPath(); ctx.moveTo(pts[i].x - left[i][2] * 0.15, pts[i].y); ctx.lineTo(pts[i + 1].x - left[i + 1][2] * 0.15, pts[i + 1].y); ctx.stroke(); }
126
+ ctx.strokeStyle = 'rgba(255,250,235,.85)'; ctx.lineWidth = Math.max(0.5, 0.9 * u); ctx.beginPath();
127
+ for (let i = 0; i < n * 0.8; i++) { const x = pts[i].x - left[i][2] * 0.55, y = pts[i].y; i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke();
128
+ }
129
+ function drop(x, y, r, stretch) {
130
+ const ry = r * (1 + stretch), rx = r / Math.sqrt(1 + stretch);
131
+ const g = ctx.createRadialGradient(x - rx * 0.1, y + ry * 0.35, r * 0.1, x, y, Math.max(rx, ry));
132
+ g.addColorStop(0, '#ffe08a'); g.addColorStop(0.45, '#eaa022'); g.addColorStop(0.85, '#b25f08'); g.addColorStop(1, '#7c3a03');
133
+ ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2); ctx.fill();
134
+ ctx.fillStyle = 'rgba(255,255,255,.85)'; ctx.beginPath(); ctx.ellipse(x - rx * 0.38, y - ry * 0.42, rx * 0.22, ry * 0.13, -0.5, 0, Math.PI * 2); ctx.fill();
135
+ }
136
+ function pool() {
137
+ const top = H - 34 * u;
138
+ ctx.beginPath(); ctx.moveTo(0, H);
139
+ for (let i = 0; i < COLS; i++) ctx.lineTo(i / (COLS - 1) * W, top - ph[i] - mound[i]);
140
+ ctx.lineTo(W, H); ctx.closePath();
141
+ const g = ctx.createLinearGradient(0, top - 6 * u, 0, H); g.addColorStop(0, '#f2ae35'); g.addColorStop(0.18, '#d98516'); g.addColorStop(1, '#6e3104');
142
+ ctx.fillStyle = g; ctx.fill();
143
+ ctx.strokeStyle = 'rgba(255,236,180,.9)'; ctx.lineWidth = 1.3 * u; ctx.beginPath();
144
+ for (let i = 0; i < COLS; i++) ctx.lineTo(i / (COLS - 1) * W, top - ph[i] - mound[i] + 1.2 * u); ctx.stroke();
145
+ ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.lineWidth = 3 * u; ctx.beginPath();
146
+ for (let i = 0; i < COLS; i++) ctx.lineTo(i / (COLS - 1) * W, top - ph[i] - mound[i] + 5 * u); ctx.stroke();
147
+ }
148
+
149
+ let raf, last = performance.now();
150
+ const t0 = last;
151
+ function frame(now) {
152
+ const dt = Math.min(0.033, (now - last) / 1000); last = now; const t = (now - t0) / 1000;
153
+ physics(dt / 2, t); physics(dt / 2, t);
154
+ const bg = ctx.createRadialGradient(W * 0.3, H * 0.15, 0, W * 0.5, H * 0.5, Math.max(W, H) * 0.8);
155
+ bg.addColorStop(0, '#fbf5ea'); bg.addColorStop(1, '#e6d6bc'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
156
+ strand();
157
+ dipper(t);
158
+ const b = pts[N - 1];
159
+ if (state === 'grow') drop(b.x, b.y, bulbR(), Math.min(0.6, Lv / 220));
160
+ else drop(b.x, b.y, (1.5 + 3 * Math.max(0, 1 - stateT * 2)) * u, 0);
161
+ for (const d of drops) drop(d.x, d.y, d.r, d.main ? Math.min(0.5, d.vy / (900 * u)) : 0);
162
+ pool();
163
+ raf = requestAnimationFrame(frame);
164
+ }
165
+ raf = requestAnimationFrame(frame);
166
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
167
+ }
@@ -0,0 +1,50 @@
1
+ // Ink drop in water: a falling drop rolls up into a vortex ring. Thousands of ink particles circle the ring's
2
+ // core while the ring sinks, widens and slows; it then breaks into curling plumes that hang in the water.
3
+ const PARAMS = { particles: 2600, interval: 3.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 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); ctx.fillStyle = '#f3f1ec'; ctx.fillRect(0, 0, W, H); };
12
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
13
+ const inks = ['20, 40, 120', '170, 20, 60', '10, 90, 80', '30, 30, 40'];
14
+ let drops = [], k = 0, next = 0, raf;
15
+ function spawn(now) {
16
+ const S = Math.min(W, H), n = Math.round(PARAMS.particles), x = W * (0.3 + Math.random() * 0.4);
17
+ const parts = Array.from({ length: n }, () => ({ a: Math.random() * Math.PI * 2, b: Math.random() * Math.PI * 2, rr: Math.sqrt(Math.random()) * 0.35, drift: (Math.random() - 0.5) * 0.6, wisp: Math.random() }));
18
+ drops.push({ x, y: H * 0.08, t0: now, S, parts, col: inks[k++ % inks.length] });
19
+ }
20
+ function frame(now) {
21
+ if (now > next) { spawn(now); next = now + PARAMS.interval * 1000; }
22
+ ctx.fillStyle = 'rgba(243, 241, 236, 0.08)'; ctx.fillRect(0, 0, W, H); // the water slowly clears
23
+ for (const d of drops) {
24
+ const age = (now - d.t0) / 1000;
25
+ // ring: sinks fast then slows, widens as it goes
26
+ const depth = H * 0.08 + H * 0.62 * (1 - Math.exp(-age * 0.9));
27
+ const R = d.S * (0.02 + 0.13 * (1 - Math.exp(-age * 0.7)));
28
+ const core = R * 0.45, spin = age * 4.5;
29
+ for (const p of d.parts) {
30
+ // particle on a torus: angle a around the ring, angle b around the core
31
+ const breakup = Math.max(0, age - 1.6) * p.wisp; // late: plumes curl away
32
+ const a = p.a + Math.sin(p.a * 6 + age) * 0.05 * breakup;
33
+ const b = p.b + spin * (1 - p.rr);
34
+ const rc = core * (p.rr + 0.15) * (1 + breakup * 0.5);
35
+ const ringX = Math.cos(a) * (R + Math.cos(b) * rc), ringZ = Math.sin(a) * (R + Math.cos(b) * rc);
36
+ const y = depth + Math.sin(b) * rc + breakup * breakup * p.drift * d.S * 0.12 + breakup * d.S * 0.03 * Math.sin(a * 3);
37
+ const x = d.x + ringX + ringZ * 0.15 + breakup * p.drift * d.S * 0.08;
38
+ const alpha = Math.max(0, 0.16 - age * 0.012) * (0.5 + 0.5 * (ringZ / R + 1) / 2);
39
+ if (alpha <= 0.002) continue;
40
+ ctx.fillStyle = `rgba(${d.col}, ${alpha})`; ctx.fillRect(x, y, 1.8, 1.8);
41
+ }
42
+ // the short tail that connects ring to surface early on
43
+ if (age < 1.2) { ctx.strokeStyle = `rgba(${d.col}, ${0.25 * (1 - age / 1.2)})`; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(d.x, H * 0.08); ctx.lineTo(d.x, depth); ctx.stroke(); }
44
+ }
45
+ drops = drops.filter(d => now - d.t0 < 14000);
46
+ raf = requestAnimationFrame(frame);
47
+ }
48
+ raf = requestAnimationFrame(frame);
49
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
50
+ }