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,89 @@
1
+ // Torus knot: a tube swept along a (p, q) knot that winds p times around the axis and q times through the hole.
2
+ // The tube's frame is carried along by parallel transport (no sudden flips, unlike Frenet frames). It is shaded
3
+ // as chrome that shifts hue with viewing angle, like a thin film, and stripes flow along it.
4
+ const PARAMS = { p: 2, q: 3, 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 gl = canvas.getContext('webgl2', { antialias: true });
11
+ if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
12
+ const P = Math.max(1, Math.round(PARAMS.p)), Q = Math.max(1, Math.round(PARAMS.q)), SEG = 520, RAD = 28, tube = 0.34;
13
+ const curve = t => { const r = 2 + Math.cos(Q * t); return [r * Math.cos(P * t), r * Math.sin(P * t), Math.sin(Q * t) * 1.1].map(v => v * 0.55); };
14
+ const sub = (a, b) => a.map((v, i) => v - b[i]), nrm = a => { const l = Math.hypot(...a) || 1; return a.map(v => v / l); };
15
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]], dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
16
+ // parallel transport: carry the normal along, removing its component along each new tangent
17
+ const pts = [], T = [], N = [], B = [];
18
+ for (let i = 0; i <= SEG; i++) { const t = i / SEG * Math.PI * 2; pts.push(curve(t)); T.push(nrm(sub(curve(t + 1e-3), curve(t - 1e-3)))); }
19
+ let n = nrm(cross(T[0], [0, 0, 1]));
20
+ for (let i = 0; i <= SEG; i++) { n = nrm(sub(n, T[i].map(v => v * dot(n, T[i])))); N.push(n); B.push(cross(T[i], n)); }
21
+ // close the loop: spread the leftover twist evenly so the seam meets
22
+ const twist = Math.atan2(dot(cross(N[SEG], N[0]), T[0]), dot(N[SEG], N[0]));
23
+ const verts = [], idx = [];
24
+ for (let i = 0; i <= SEG; i++) {
25
+ const tw = twist * i / SEG;
26
+ for (let j = 0; j <= RAD; j++) {
27
+ const a = j / RAD * Math.PI * 2 + tw, c = Math.cos(a), s = Math.sin(a), d = N[i].map((v, k) => v * c + B[i][k] * s);
28
+ verts.push(pts[i][0] + d[0] * tube, pts[i][1] + d[1] * tube, pts[i][2] + d[2] * tube, d[0], d[1], d[2], i / SEG, j / RAD);
29
+ }
30
+ }
31
+ for (let i = 0; i < SEG; i++) for (let j = 0; j < RAD; j++) { const a = i * (RAD + 1) + j, b = a + RAD + 1; idx.push(a, b, a + 1, b, b + 1, a + 1); }
32
+
33
+ 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; };
34
+ const link = (v, f) => { const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, v)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, f)); gl.bindAttribLocation(p, 0, 'p'); gl.linkProgram(p); return p; };
35
+ const prog = link(`#version 300 es
36
+ in vec3 pos, nor; in vec2 st; uniform mat4 PV, M; out vec3 vN, vW; out vec2 vS;
37
+ void main() { vec4 w = M * vec4(pos, 1); vW = w.xyz; vN = mat3(M) * nor; vS = st; gl_Position = PV * w; }`, `#version 300 es
38
+ precision highp float; in vec3 vN, vW; in vec2 vS; out vec4 o; uniform float T, F;
39
+ vec3 env(vec3 d) {
40
+ vec3 c = mix(vec3(.05, .05, .07), vec3(.5, .52, .58), smoothstep(-.3, .9, d.y));
41
+ c += vec3(1.) * 2. * smoothstep(.1, .0, abs(d.x + .3) - .25) * smoothstep(.55, .75, d.y);
42
+ c += vec3(1., .6, .35) * 1.2 * smoothstep(.08, .0, abs(d.z - .2) - .06) * smoothstep(-.2, .1, d.y) * smoothstep(.5, .2, d.y);
43
+ return c;
44
+ }
45
+ void main() {
46
+ vec3 n = normalize(vN), v = normalize(vec3(0, 0, 6.5) - vW), r = reflect(-v, n);
47
+ float fr = pow(1. - max(0., dot(n, v)), 2.);
48
+ vec3 film = .5 + .5 * cos(6.2832 * (fr * 1.4 + vec3(0., .33, .67) + vS.x * 2.)); // hue slides with the viewing angle
49
+ vec3 c = env(r) * mix(vec3(.9), film, .55);
50
+ float stripe = smoothstep(.45, .5, abs(fract(vS.x * 24. - T * .35 * F) - .5)); // stripes flowing along the knot
51
+ c *= .82 + .18 * stripe;
52
+ c += pow(max(0., dot(r, normalize(vec3(-.4, .8, .5)))), 60.) * .8;
53
+ o = vec4(pow(c / (1. + c * .35), vec3(1. / 1.8)), 1);
54
+ }`);
55
+ const bg = link(`#version 300 es
56
+ in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, .999, 1); }`, `#version 300 es
57
+ precision highp float; in vec2 uv; out vec4 o; void main() { vec3 c = mix(vec3(.05, .05, .08), vec3(.16, .14, .2), uv.y); c += vec3(.25, .15, .3) * exp(-length(uv - vec2(.5, .55)) * 3.); o = vec4(c, 1); }`);
58
+ const quadVao = gl.createVertexArray(); gl.bindVertexArray(quadVao);
59
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
60
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
61
+ const vao = gl.createVertexArray(); gl.bindVertexArray(vao);
62
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
63
+ [['pos', 3, 0], ['nor', 3, 12], ['st', 2, 24]].forEach(([nm, s, o]) => { const l = gl.getAttribLocation(prog, nm); gl.enableVertexAttribArray(l); gl.vertexAttribPointer(l, s, gl.FLOAT, false, 32, o); });
64
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(idx), gl.STATIC_DRAW);
65
+ const uPV = gl.getUniformLocation(prog, 'PV'), uM = gl.getUniformLocation(prog, 'M'), uT = gl.getUniformLocation(prog, 'T'), uF = gl.getUniformLocation(prog, 'F');
66
+ // tiny matrix kit (column-major)
67
+ const mul = (a, b) => { const o = new Float32Array(16); for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k]; o[c * 4 + r] = s; } return o; };
68
+ const persp = (fov, asp, nr, f) => { const t = 1 / Math.tan(fov / 2); return new Float32Array([t / asp, 0, 0, 0, 0, t, 0, 0, 0, 0, (f + nr) / (nr - f), -1, 0, 0, 2 * f * nr / (nr - f), 0]); };
69
+ const rotY = a => new Float32Array([Math.cos(a), 0, -Math.sin(a), 0, 0, 1, 0, 0, Math.sin(a), 0, Math.cos(a), 0, 0, 0, 0, 1]);
70
+ const rotX = a => new Float32Array([1, 0, 0, 0, 0, Math.cos(a), Math.sin(a), 0, 0, -Math.sin(a), Math.cos(a), 0, 0, 0, 0, 1]);
71
+ const trans = z => new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, z, 1]);
72
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); canvas.width = stage.clientWidth * d | 0; canvas.height = stage.clientHeight * d | 0; };
73
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
74
+ let raf; const t0 = performance.now();
75
+ function frame(now) {
76
+ const t = (now - t0) / 1000, asp = canvas.width / canvas.height;
77
+ gl.viewport(0, 0, canvas.width, canvas.height); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
78
+ gl.disable(gl.DEPTH_TEST); gl.useProgram(bg); gl.bindVertexArray(quadVao); gl.drawArrays(gl.TRIANGLES, 0, 3);
79
+ gl.enable(gl.DEPTH_TEST); gl.useProgram(prog); gl.bindVertexArray(vao);
80
+ const fit = Math.max(1, 1.25 / asp); // pull back on tall stages
81
+ gl.uniformMatrix4fv(uPV, false, mul(persp(0.7, asp, 0.1, 30), trans(-6.5 * fit)));
82
+ gl.uniformMatrix4fv(uM, false, mul(rotX(0.9 + Math.sin(t * 0.3) * 0.25), rotY(t * 0.4)));
83
+ gl.uniform1f(uT, t); gl.uniform1f(uF, PARAMS.flow);
84
+ gl.drawElements(gl.TRIANGLES, idx.length, gl.UNSIGNED_INT, 0);
85
+ raf = requestAnimationFrame(frame);
86
+ }
87
+ raf = requestAnimationFrame(frame);
88
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
89
+ }
@@ -0,0 +1,70 @@
1
+ // Volumetric clouds: the sky is filled with a density field (layers of 3D noise, shaped into a slab and eroded at the
2
+ // edges). Each pixel's ray walks through it, adding light and losing transparency as it goes (Beer's law). At each
3
+ // step a short second march toward the sun measures how much cloud is in the way, which gives the dark bellies and
4
+ // bright tops; a forward-scattering term makes edges glow when you look toward the sun. The wind slides it all along.
5
+ const PARAMS = { cover: 0.5, wind: 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 gl = canvas.getContext('webgl2', { antialias: false });
12
+ if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
13
+ const vs = `#version 300 es
14
+ in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
15
+ const fs = `#version 300 es
16
+ precision highp float; in vec2 uv; out vec4 o;
17
+ uniform vec2 R; uniform float T, COV, WIND;
18
+ float hash(vec3 p) { p = fract(p * .3183099 + .1); p *= 17.; return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); }
19
+ float noise(vec3 x) { vec3 i = floor(x), f = fract(x); f = f * f * (3. - 2. * f);
20
+ return mix(mix(mix(hash(i), hash(i + vec3(1, 0, 0)), f.x), mix(hash(i + vec3(0, 1, 0)), hash(i + vec3(1, 1, 0)), f.x), f.y),
21
+ mix(mix(hash(i + vec3(0, 0, 1)), hash(i + vec3(1, 0, 1)), f.x), mix(hash(i + vec3(0, 1, 1)), hash(i + 1.), f.x), f.y), f.z); }
22
+ float density(vec3 p) {
23
+ vec3 q = p + vec3(T * .6 * WIND, 0, T * .25 * WIND);
24
+ float f = .5 * noise(q * .3) + .25 * noise(q * .62) + .125 * noise(q * 1.27) + .0625 * noise(q * 2.6) + .03 * noise(q * 5.3);
25
+ float slab = smoothstep(0., 1.2, p.y) * smoothstep(5., 2.8, p.y); // clouds live in a layer
26
+ return clamp((f - (.64 - COV * .2)) * 4.5 * slab, 0., 1.); // above the threshold: cloud; below: clear sky
27
+ }
28
+ vec3 aces(vec3 x) { return clamp((x * (2.51 * x + .03)) / (x * (2.43 * x + .59) + .14), 0., 1.); }
29
+ void main() {
30
+ vec2 q = (uv * 2. - 1.) * vec2(R.x / R.y, 1.);
31
+ vec3 ro = vec3(0, -1.2, 0), rd = normalize(vec3(q.x, q.y * .75 + .78, 1.4)); // looking up into the layer
32
+ vec3 sd = normalize(vec3(.55, .32, .8));
33
+ float sun = max(dot(rd, sd), 0.);
34
+ vec3 sky = mix(vec3(.42, .6, .86), vec3(.06, .2, .55), clamp(rd.y * 1.2, 0., 1.)) + vec3(1., .85, .6) * (pow(sun, 600.) * 6. + pow(sun, 8.) * .25);
35
+ // march from where the ray enters the layer
36
+ float t0 = max(0., (0. - ro.y) / rd.y), t1 = (5. - ro.y) / max(rd.y, .03);
37
+ float t = t0 + .12 * fract(sin(dot(uv * R, vec2(12.9, 78.2))) * 43758.5), trans = 1.; vec3 light = vec3(0);
38
+ float hg = .75 * (1. + .8 * pow(sun, 6.)) + .5 * pow(sun, 30.); // brighter toward the sun: forward scattering
39
+ for (int i = 0; i < 64; i++) {
40
+ if (t > t1 || trans < .02) break;
41
+ vec3 p = ro + rd * t; float d = density(p);
42
+ if (d > .01) {
43
+ float sh = 0.; for (int j = 1; j <= 5; j++) sh += density(p + sd * float(j) * .35); // cloud toward the sun
44
+ vec3 lit = vec3(1., .95, .88) * exp(-sh * 1.6) * hg * 1.5 + vec3(.22, .3, .45) * (.3 + .7 * smoothstep(.0, 4., p.y));
45
+ float a = 1. - exp(-d * .9 * .25 / .25);
46
+ light += trans * a * lit; trans *= 1. - a;
47
+ }
48
+ t += .12 + t * .02;
49
+ }
50
+ vec3 col = sky * trans + light;
51
+ col = mix(col, sky, 1. - exp(-max(t0, 0.) * .02));
52
+ col = aces(col * 1.05);
53
+ o = vec4(pow(col, vec3(1. / 2.2)), 1);
54
+ }`;
55
+ 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; };
56
+ 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);
57
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
58
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
59
+ const U = {}; ['R', 'T', 'COV', 'WIND'].forEach(n => U[n] = gl.getUniformLocation(prog, n));
60
+ const resize = () => { const w = stage.clientWidth || 1, h = stage.clientHeight || 1, d = Math.min(devicePixelRatio, Math.max(0.6, Math.sqrt(190000 / (w * h)))); canvas.width = w * d | 0; canvas.height = h * d | 0; gl.viewport(0, 0, canvas.width, canvas.height); };
61
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
62
+ let raf; const t0 = performance.now();
63
+ function frame(now) {
64
+ gl.uniform2f(U.R, canvas.width, canvas.height); gl.uniform1f(U.T, (now - t0) / 1000); gl.uniform1f(U.COV, PARAMS.cover); gl.uniform1f(U.WIND, PARAMS.wind);
65
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
66
+ raf = requestAnimationFrame(frame);
67
+ }
68
+ raf = requestAnimationFrame(frame);
69
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
70
+ }
@@ -0,0 +1,140 @@
1
+ // Bioluminescent shore: a night beach where plankton (Noctiluca) flash blue whenever the water is disturbed. The glow
2
+ // lives in a texture laid over the beach in world space. Every frame it fades, it is carried up and back down the
3
+ // sand with the wash, and it is lit again wherever a wave breaks and wherever the pointer stirs the water. Each pixel
4
+ // casts a ray onto the beach to find where it looks, then reads the waves, the wet sand's sheen and the glow there.
5
+ const PARAMS = { waves: 1, glow: 1, sparkle: 1 };
6
+
7
+ export default function (stage) {
8
+ const canvas = document.createElement('canvas');
9
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none';
10
+ stage.append(canvas);
11
+ const gl = canvas.getContext('webgl2', { antialias: false });
12
+ if (!gl || !gl.getExtension('EXT_color_buffer_float')) { stage.textContent = 'This needs WebGL2 with float render targets.'; return () => canvas.remove(); }
13
+ const GS = 512; // glow texture: x −24..24 m, z 0..56 m from the camera
14
+ const COMMON = `
15
+ const float X0 = -24., X1 = 24., Z0 = 0., Z1 = 56.;
16
+ uniform float T, WV;
17
+ float h(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
18
+ float n2(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3. - 2. * f); return mix(mix(h(i), h(i + vec2(1, 0)), f.x), mix(h(i + vec2(0, 1)), h(i + 1.), f.x), f.y); }
19
+ float shore(float x) { return 9. + 1.6 * sin(x * .17 + 1.) + .8 * sin(x * .43); } // where the sand meets the sea (z)
20
+ // the wave train: crests run toward the beach; phase 0..1, crest at 0
21
+ float phase(vec2 w) { return fract((w.y - shore(w.x)) / 7. + T * .16 * WV + n2(vec2(w.x * .08, T * .05)) * .35); }
22
+ // how far up the sand the current wash has reached (it surges in after each break, then slides back)
23
+ float wash(float x) { float p = fract(T * .16 * WV + n2(vec2(x * .08, T * .05)) * .35); return shore(x) - 3.2 * sin(3.14159 * clamp(p * 1.6, 0., 1.)) * (.7 + .3 * n2(vec2(x * .2, floor(T * .16 * WV)))); }`;
24
+ const QUAD = `#version 300 es
25
+ in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
26
+ // the glow, in world space: fade, ride the wash, light up at breaking crests and under the pointer
27
+ const glowFS = `#version 300 es
28
+ precision highp float; in vec2 uv; out vec4 o; uniform sampler2D G; uniform vec3 P; uniform float DT;
29
+ ${COMMON}
30
+ void main() {
31
+ vec2 w = vec2(mix(X0, X1, uv.x), mix(Z0, Z1, uv.y));
32
+ float s = shore(w.x), ws = wash(w.x);
33
+ // the wash carries light up the sand and back: sample from slightly seaward (or landward) of here
34
+ float drift = w.y < s + 2. && w.y > ws - 1. ? (fract(T * .16 * WV) < .32 ? .9 : -.5) : 0.;
35
+ vec2 src = uv + vec2(0., drift * DT / (Z1 - Z0));
36
+ float g = texture(G, src).r * exp(-DT * 1.1);
37
+ float ph = phase(w), surf = smoothstep(s + 9., s + 2., w.y) * step(s - .3, w.y);
38
+ float crest = exp(-pow((ph - .02) / .018, 2.)) + exp(-pow((ph - 1.02) / .018, 2.)); // the line where the wave is breaking
39
+ g += crest * surf * (.15 + 1.3 * pow(n2(w * vec2(.9, 2.) + T * .7), 2.)) * DT * 9.; // breaking is patchy, so the line flickers along its length
40
+ if (w.y < s && w.y > ws) g += DT * 1.4 * n2(w * 2. + T * 3.) * smoothstep(ws, ws + .6, w.y); // the foam edge of the wash
41
+ float d = length((w - P.xy) * vec2(1., .7));
42
+ g += P.z * exp(-d * d * 3.) * DT * 6.; // a hand dragged through the water
43
+ o = vec4(min(g, 6.), 0, 0, 1);
44
+ }`;
45
+ // the picture
46
+ const showFS = `#version 300 es
47
+ precision highp float; in vec2 uv; out vec4 o; uniform sampler2D G; uniform vec2 R; uniform float CH, PITCH, FOC, K, SP;
48
+ ${COMMON}
49
+ vec3 sky(vec3 d) {
50
+ vec3 c = mix(vec3(.007, .013, .026), vec3(.0008, .0016, .005), smoothstep(0., .5, d.y));
51
+ vec2 sp = vec2(atan(d.x, d.z), d.y) * 340., ci = floor(sp);
52
+ vec3 p3 = fract(vec3(ci.xyx) * .1031); p3 += dot(p3, p3.yzx + 33.33); float s = fract((p3.x + p3.y) * p3.z);
53
+ c += vec3(.75, .82, 1.) * step(.994, s) * (s - .994) * 120. * smoothstep(.45, .1, length(fract(sp) - .5));
54
+ float band = exp(-pow((d.y - .25 - d.x * .3) / .09, 2.)) * n2(vec2(d.x * 9., d.y * 14.)); // a faint Milky Way
55
+ return c + vec3(.012, .013, .02) * band;
56
+ }
57
+ void main() {
58
+ vec2 q = (uv * 2. - 1.) * vec2(R.x / R.y, 1.);
59
+ float cp = cos(PITCH), sp = sin(PITCH);
60
+ vec3 d = normalize(vec3(q.x, q.y * cp - FOC * sp, q.y * sp + FOC * cp));
61
+ vec3 col;
62
+ if (d.y > -.0005) col = sky(d);
63
+ else {
64
+ float t = CH / -d.y; vec2 w = vec2(d.x, d.z) * t; float dist = t;
65
+ float s = shore(w.x), ws = wash(w.x), ph = phase(w);
66
+ vec2 gt = vec2((w.x - X0) / (X1 - X0), (w.y - Z0) / (Z1 - Z0));
67
+ float g = texture(G, gt).r, gs = (texture(G, gt + vec2(.004, 0)).r + texture(G, gt - vec2(.004, 0)).r + texture(G, gt + vec2(0, .006)).r + texture(G, gt - vec2(0, .006)).r) * .25;
68
+ if (w.y > ws) { // sea (and the thin sheet of the wash)
69
+ float wave = sin(ph * 6.2832), slope = cos(ph * 6.2832);
70
+ float fres = .03 + .5 * pow(1. - abs(d.y), 6.);
71
+ vec3 refl = sky(reflect(d, normalize(vec3(n2(w * 1.3 + T) - .5, 6. + slope * 2., slope * 1.5))));
72
+ col = vec3(.001, .003, .007) + refl * fres * (w.y < s ? .5 : 1.);
73
+ col *= .8 + .2 * wave;
74
+ } else { // sand: wet and glossy near the water, dry further up
75
+ float wet = smoothstep(ws - 4., ws - .3, w.y);
76
+ col = mix(vec3(.0055, .005, .0045), vec3(.0025, .003, .0045), wet) * (.85 + .3 * n2(w * 6.));
77
+ col += sky(reflect(d, vec3(0, 1, 0))) * wet * .35;
78
+ }
79
+ // the plankton light: a broad glow plus sparks where it is strongest
80
+ float spark = step(1. - .04 * SP, h(floor(w * 7.) + floor(T * 12.))) * smoothstep(.3, .1, length(fract(w * 7.) - .5)) * g;
81
+ col += vec3(.05, .55, 1.) * (g * .5 + gs * .5) * K + vec3(.6, .95, 1.) * spark * .8 * K;
82
+ col = mix(col, vec3(.004, .008, .016), smoothstep(30., 56., dist)); // haze toward the horizon
83
+ }
84
+ col = col / (1. + col);
85
+ o = vec4(pow(col, vec3(1. / 2.2)), 1);
86
+ }`;
87
+ 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; };
88
+ const prog = fs => {
89
+ const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, QUAD)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs)); gl.bindAttribLocation(p, 0, 'p'); gl.linkProgram(p);
90
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
91
+ 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); }
92
+ return { p, u };
93
+ };
94
+ const glowP = prog(glowFS), showP = prog(showFS);
95
+ const quad = gl.createVertexArray(); gl.bindVertexArray(quad);
96
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
97
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
98
+ const target = () => {
99
+ const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, GS, GS, 0, gl.RGBA, gl.HALF_FLOAT, null);
100
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
101
+ 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);
102
+ const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);
103
+ gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); return { t, fb };
104
+ };
105
+ let A = target(), B = target();
106
+ let W = 1, H = 1;
107
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = canvas.width = Math.max(1, (stage.clientWidth * d) | 0); H = canvas.height = Math.max(1, (stage.clientHeight * d) | 0); };
108
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
109
+
110
+ // camera: standing on the beach, 2.2 m up, looking out to sea and a little down
111
+ const CH = 2.2, PITCH = 0.2, FOC = 2.2;
112
+ const ptr = { x: 0, z: 0, s: 0, on: false };
113
+ const move = e => {
114
+ const r = canvas.getBoundingClientRect(), qx = (((e.clientX - r.left) / r.width) * 2 - 1) * (r.width / r.height), qy = 1 - ((e.clientY - r.top) / r.height) * 2;
115
+ const cp = Math.cos(PITCH), sp = Math.sin(PITCH), dy = qy * cp - FOC * sp, dz = qy * sp + FOC * cp;
116
+ if (dy >= -0.001) { ptr.on = false; return; }
117
+ const t = CH / -dy; ptr.x = qx * t; ptr.z = dz * t; ptr.on = true;
118
+ };
119
+ const leave = () => { ptr.on = false; };
120
+ canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerleave', leave);
121
+
122
+ let raf, last = performance.now(), t = 0;
123
+ function frame(now) {
124
+ const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt;
125
+ ptr.s += ((ptr.on ? 1 : 0) - ptr.s) * Math.min(1, dt * 8);
126
+ gl.bindVertexArray(quad);
127
+ gl.useProgram(glowP.p); gl.bindFramebuffer(gl.FRAMEBUFFER, B.fb); gl.viewport(0, 0, GS, GS);
128
+ gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, A.t); gl.uniform1i(glowP.u.G, 0);
129
+ gl.uniform1f(glowP.u.T, t); gl.uniform1f(glowP.u.WV, PARAMS.waves); gl.uniform1f(glowP.u.DT, dt); gl.uniform3f(glowP.u.P, ptr.x, ptr.z, ptr.s);
130
+ gl.drawArrays(gl.TRIANGLES, 0, 3); [A, B] = [B, A];
131
+ gl.useProgram(showP.p); gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H);
132
+ gl.bindTexture(gl.TEXTURE_2D, A.t); gl.uniform1i(showP.u.G, 0); gl.uniform2f(showP.u.R, W, H);
133
+ gl.uniform1f(showP.u.T, t); gl.uniform1f(showP.u.WV, PARAMS.waves); gl.uniform1f(showP.u.CH, CH); gl.uniform1f(showP.u.PITCH, PITCH); gl.uniform1f(showP.u.FOC, FOC);
134
+ gl.uniform1f(showP.u.K, PARAMS.glow); gl.uniform1f(showP.u.SP, PARAMS.sparkle);
135
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
136
+ raf = requestAnimationFrame(frame);
137
+ }
138
+ raf = requestAnimationFrame(frame);
139
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerleave', leave); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
140
+ }
@@ -0,0 +1,116 @@
1
+ // Calligraphy: a brush writes 永 (eternity), the character students practise because it holds the eight basic
2
+ // strokes. The brush is forty bristles in a teardrop footprint that turns with the stroke and grows with pressure.
3
+ // Each bristle carries its own ink and lays it down as it moves, so as a stroke runs long the outer bristles run dry
4
+ // first and leave the streaked "flying white" (飛白). Where the brush slows, at a turn or the end of a stroke, ink
5
+ // pools darker; a quick flick lifts it to a sharp point. Then the seal goes on, and a fresh sheet.
6
+ const PARAMS = { pressure: 1, ink: 1, speed: 1 };
7
+
8
+ export default function (stage) {
9
+ const canvas = document.createElement('canvas');
10
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;background:#efe9dc';
11
+ stage.append(canvas);
12
+ const ctx = canvas.getContext('2d');
13
+ let W = 1, H = 1, paper = null, ink = null, ig = null, box = { x: 0, y: 0, s: 1 };
14
+ const resize = () => {
15
+ const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1;
16
+ canvas.width = Math.max(1, (W * d) | 0); canvas.height = Math.max(1, (H * d) | 0); ctx.setTransform(d, 0, 0, d, 0, 0);
17
+ // rice paper: warm, a little uneven, with fibres
18
+ paper = document.createElement('canvas'); paper.width = canvas.width; paper.height = canvas.height;
19
+ const g = paper.getContext('2d'); g.setTransform(d, 0, 0, d, 0, 0); g.fillStyle = '#efe9dc'; g.fillRect(0, 0, W, H);
20
+ for (let i = 0; i < W * H / 40; i++) { g.fillStyle = `rgba(${Math.random() < 0.5 ? '160,140,110' : '255,252,245'},${Math.random() * 0.05})`; g.fillRect(Math.random() * W, Math.random() * H, 1 + Math.random() * 2, 1 + Math.random() * 2); }
21
+ g.lineWidth = 0.6; for (let i = 0; i < W * H / 900; i++) { const x = Math.random() * W, y = Math.random() * H, a = Math.random() * 6.28, l = 6 + Math.random() * 18; g.strokeStyle = `rgba(150,130,100,${0.05 + Math.random() * 0.07})`; g.beginPath(); g.moveTo(x, y); g.quadraticCurveTo(x + Math.cos(a + 0.5) * l * 0.5, y + Math.sin(a + 0.5) * l * 0.5, x + Math.cos(a) * l, y + Math.sin(a) * l); g.stroke(); }
22
+ ink = document.createElement('canvas'); ink.width = canvas.width; ink.height = canvas.height; ig = ink.getContext('2d'); ig.setTransform(d, 0, 0, d, 0, 0);
23
+ const s = Math.min(W, H) * 0.78; box = { x: (W - s) / 2 - s * 0.05, y: (H - s) / 2, s };
24
+ restart();
25
+ };
26
+
27
+ // 永, stroke by stroke: points are [x, y, pressure] in a unit square, y downward
28
+ const STROKES = [
29
+ [[0.46, 0.06, 0.25], [0.5, 0.11, 0.85], [0.54, 0.17, 0.75], [0.53, 0.19, 0.2]], // 點 the dot
30
+ [[0.31, 0.29, 0.5], [0.42, 0.275, 0.55], [0.53, 0.265, 0.6], [0.56, 0.3, 0.95], [0.545, 0.45, 0.7], [0.545, 0.68, 0.72], [0.55, 0.86, 0.85], [0.52, 0.9, 0.7], [0.44, 0.84, 0.05]], // 橫折鉤 across, down, hook
31
+ [[0.17, 0.46, 0.45], [0.3, 0.45, 0.5], [0.4, 0.44, 0.7], [0.36, 0.52, 0.65], [0.27, 0.63, 0.5], [0.12, 0.76, 0.06]], // 橫撇 across, then sweep down-left
32
+ [[0.81, 0.36, 0.7], [0.74, 0.43, 0.6], [0.65, 0.5, 0.35], [0.61, 0.53, 0.08]], // 撇 the short throw
33
+ [[0.6, 0.5, 0.35], [0.68, 0.6, 0.55], [0.77, 0.72, 0.85], [0.86, 0.8, 1], [0.92, 0.82, 0.55], [0.99, 0.83, 0.04]], // 捺 the long press and release
34
+ ];
35
+ const cat = (a, b, c, d, t) => 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t * t + (-a + 3 * b - 3 * c + d) * t * t * t);
36
+ // sample each stroke densely, with a little hand wobble so no two sheets are the same
37
+ let paths = [];
38
+ function prepare() {
39
+ paths = STROKES.map(k => {
40
+ const pts = k.map(([x, y, p]) => [x + (Math.random() - 0.5) * 0.012, y + (Math.random() - 0.5) * 0.012, p]);
41
+ const out = [];
42
+ for (let i = 0; i < pts.length - 1; i++) {
43
+ const a = pts[Math.max(0, i - 1)], b = pts[i], c = pts[i + 1], d = pts[Math.min(pts.length - 1, i + 2)];
44
+ for (let s = 0; s < 24; s++) { const t = s / 24; out.push([cat(a[0], b[0], c[0], d[0], t), cat(a[1], b[1], c[1], d[1], t), Math.max(0.02, cat(a[2], b[2], c[2], d[2], t))]); }
45
+ }
46
+ out.push(pts[pts.length - 1]);
47
+ let L = 0; for (let i = 1; i < out.length; i++) { L += Math.hypot(out[i][0] - out[i - 1][0], out[i][1] - out[i - 1][1]); out[i].push(L); } out[0].push(0);
48
+ return out;
49
+ });
50
+ }
51
+ const NB = 40;
52
+ const bristles = Array.from({ length: NB }, (_, i) => {
53
+ const a = Math.random() * 6.283, r = Math.sqrt(Math.random());
54
+ return { ox: Math.cos(a) * r, oy: Math.sin(a) * r, edge: r, ink: 1, px: 0, py: 0, on: false, w: 0.7 + Math.random() * 0.6 };
55
+ });
56
+ let si = 0, sd = 0, state = 'write', stateT = 0, seal = 0;
57
+ function restart() { ig.clearRect(0, 0, W, H); prepare(); si = 0; sd = 0; state = 'write'; stateT = 0; seal = 0; dip(); }
58
+ function dip() { for (const b of bristles) { b.ink = PARAMS.ink * (1.1 - b.edge * 0.35) * (0.85 + Math.random() * 0.3); b.on = false; } }
59
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
60
+
61
+ // where the brush is along the current stroke, at arc length s
62
+ const at = (path, s) => {
63
+ let i = 1; while (i < path.length - 1 && path[i][3] < s) i++;
64
+ const a = path[i - 1], b = path[i], k = Math.min(1, Math.max(0, (s - a[3]) / Math.max(1e-6, b[3] - a[3])));
65
+ return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k, Math.atan2(b[1] - a[1], b[0] - a[0])];
66
+ };
67
+ function brushTo(x, y, p, dir, speed) {
68
+ const R = box.s * 0.068 * p * PARAMS.pressure, X = box.x + x * box.s, Y = box.y + y * box.s;
69
+ const c = Math.cos(dir), s = Math.sin(dir), slow = Math.min(2.2, 0.35 / Math.max(0.05, speed)); // slow brush: more ink soaks in
70
+ for (const b of bristles) {
71
+ // the footprint is a teardrop trailing behind the tip: long along the stroke, as wide as the pressure
72
+ const along = b.ox * R * 0.75 - R * 0.25, across = b.oy * R;
73
+ const bx = X + c * along - s * across, by = Y + s * along + c * across;
74
+ if (b.on && b.ink > 0.02) {
75
+ const dist = Math.hypot(bx - b.px, by - b.py), a = Math.min(0.9, b.ink * 0.5 * (0.6 + 0.4 * slow));
76
+ ig.strokeStyle = `rgba(18,16,17,${a})`; ig.lineWidth = Math.max(0.8, R * 0.2 * b.w);
77
+ ig.beginPath(); ig.moveTo(b.px, b.py); ig.lineTo(bx, by); ig.stroke();
78
+ if (b.ink > 0.4 && Math.random() < 0.15) { ig.fillStyle = `rgba(18,16,17,${0.04 * slow})`; ig.beginPath(); ig.arc(bx, by, R * 0.35, 0, 7); ig.fill(); } // wet ink feathers into the paper
79
+ b.ink -= dist / box.s * (0.45 + b.edge * 1.5) * (0.8 + Math.random() * 0.4); // edge bristles run dry first
80
+ }
81
+ b.px = bx; b.py = by; b.on = true;
82
+ }
83
+ }
84
+
85
+ let raf, last = performance.now();
86
+ function frame(now) {
87
+ const dt = Math.min(0.05, (now - last) / 1000) * PARAMS.speed; last = now; stateT += dt;
88
+ if (state === 'write') {
89
+ const path = paths[si], L = path[path.length - 1][3];
90
+ // speed along the stroke: settle in slowly, run through the middle, slow again into the end (except flicks)
91
+ const u = sd / L, flick = path[path.length - 1][2] < 0.1;
92
+ const v = 0.35 * (0.35 + Math.sin(Math.PI * Math.min(1, u * 1.05)) * (flick && u > 0.7 ? 2.6 : 1.4));
93
+ const steps = 6, ds = (v * dt) / steps;
94
+ for (let k = 0; k < steps && sd < L; k++) { sd = Math.min(L, sd + ds); const [x, y, p, dir] = at(path, sd); brushTo(x, y, p, dir, v); }
95
+ if (sd >= L) { si++; sd = 0; stateT = 0; for (const b of bristles) b.on = false; if (si >= paths.length) { state = 'seal'; } else { state = 'lift'; dip(); } } // back to the inkstone between strokes
96
+ } else if (state === 'lift' && stateT > 0.32) { state = 'write'; stateT = 0; }
97
+ else if (state === 'seal') { seal = Math.min(1, stateT / 0.25); if (stateT > 4.2) { state = 'fade'; stateT = 0; } }
98
+ else if (state === 'fade' && stateT > 1) restart();
99
+
100
+ ctx.globalAlpha = 1; ctx.drawImage(paper, 0, 0, W, H);
101
+ ctx.globalAlpha = state === 'fade' ? 1 - stateT : 1; ctx.drawImage(ink, 0, 0, W, H);
102
+ if (seal > 0) { // the red seal, pressed in at the lower right
103
+ const s = box.s * 0.1, x = box.x + box.s * 1.13, y = box.y + box.s * 0.95, k = 1 + (1 - seal) * 0.25;
104
+ ctx.save(); ctx.translate(x, y); ctx.scale(k, k); ctx.globalAlpha *= seal * 0.92;
105
+ ctx.fillStyle = '#b3261e'; ctx.beginPath(); ctx.roundRect(-s / 2, -s / 2, s, s, s * 0.08); ctx.fill();
106
+ ctx.strokeStyle = '#efe9dc'; ctx.lineWidth = s * 0.07; ctx.lineCap = 'square';
107
+ const g = [[-0.3, -0.3, 0.3, -0.3], [0, -0.3, 0, 0.32], [-0.3, 0, 0.3, 0], [-0.3, 0.32, -0.05, 0.12], [0.3, 0.32, 0.05, 0.12]]; // carved lines
108
+ ctx.beginPath(); for (const [a, b, c, d] of g) { ctx.moveTo(a * s, b * s); ctx.lineTo(c * s, d * s); } ctx.stroke();
109
+ ctx.restore();
110
+ }
111
+ ctx.globalAlpha = 1;
112
+ raf = requestAnimationFrame(frame);
113
+ }
114
+ raf = requestAnimationFrame(frame);
115
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
116
+ }
@@ -0,0 +1,152 @@
1
+ // Chladni plate: a square brass plate bolted at its centre to a speaker, with sixty-five thousand grains of sand on
2
+ // it. The tone sweeps slowly upward. Near each of the plate's resonant frequencies one standing wave dominates; the
3
+ // plate buzzes everywhere except along its nodal lines, so the sand is bounced off the moving parts and comes to rest
4
+ // on the still ones, drawing the figure Ernst Chladni showed in 1787. Between resonances two modes mix and the sand
5
+ // drifts from one figure toward the next. The grains live on the GPU: each frame every one hops by however hard its
6
+ // spot is shaking, and slides a little downhill toward stillness.
7
+ const PARAMS = { grains: 256, sweep: 1, shake: 1 };
8
+
9
+ export default function (stage) {
10
+ const canvas = document.createElement('canvas');
11
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
12
+ const label = document.createElement('div');
13
+ label.style.cssText = 'position:absolute;left:14px;bottom:12px;font:500 12px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;color:rgba(235,225,205,.6);letter-spacing:.04em;pointer-events:none';
14
+ stage.append(canvas, label);
15
+ const gl = canvas.getContext('webgl2', { antialias: false });
16
+ if (!gl || !gl.getExtension('EXT_color_buffer_float')) { stage.textContent = 'This needs WebGL2 with float render targets.'; return () => canvas.remove(); }
17
+ const G = Math.max(64, Math.round(PARAMS.grains)), COUNT = G * G;
18
+ // the plate's modes: (n, m, sign), in rising frequency; frequency grows with n² + m²
19
+ const MODES = [[1, 2, -1], [2, 2, 1], [1, 3, 1], [2, 3, -1], [1, 4, -1], [3, 3, 1], [2, 4, 1], [3, 4, -1], [1, 5, 1], [2, 5, -1], [4, 4, 1], [3, 5, 1], [4, 5, -1], [2, 6, 1], [5, 5, 1], [4, 6, -1], [3, 7, 1], [5, 6, -1], [6, 6, 1], [5, 7, 1]];
20
+ const FREQ = MODES.map(([n, m]) => 62 * (n * n + m * m));
21
+ const K = MODES.length;
22
+ const WAVE = `
23
+ uniform vec3 MD[${K}]; uniform float WT[${K}];
24
+ // the plate's shape right now: each mode's standing wave, weighted by how close the tone is to its resonance
25
+ float wave(vec2 p) { // p in −1..1
26
+ float s = 0.;
27
+ for (int k = 0; k < ${K}; k++) {
28
+ if (WT[k] < .003) continue;
29
+ vec2 a = MD[k].xy * 1.5707963;
30
+ s += WT[k] * (cos(a.x * p.x) * cos(a.y * p.y) + MD[k].z * cos(a.y * p.x) * cos(a.x * p.y));
31
+ }
32
+ return s;
33
+ }`;
34
+ const QUAD = `#version 300 es
35
+ in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
36
+ // the sand: hop by the shaking, slide toward stillness, stay on the plate
37
+ const sandFS = `#version 300 es
38
+ precision highp float; in vec2 uv; out vec4 o; uniform sampler2D S; uniform float F, SH;
39
+ ${WAVE}
40
+ vec2 h2(vec2 p) { vec3 q = fract(vec3(p.xyx) * vec3(.1031, .103, .0973)); q += dot(q, q.yzx + 33.33); return fract((q.xx + q.yz) * q.zy); }
41
+ void main() {
42
+ vec2 p = texture(S, uv).xy;
43
+ float a = abs(wave(p)), e = .004;
44
+ vec2 g = vec2(abs(wave(p + vec2(e, 0))) - abs(wave(p - vec2(e, 0))), abs(wave(p + vec2(0, e))) - abs(wave(p - vec2(0, e)))) / (2. * e);
45
+ vec2 r = h2(gl_FragCoord.xy + F * 13.7) - .5;
46
+ p += r * (min(a, 1.4) * .02 + .0035) * SH - g * .0007 * SH; // a floor of jitter: real grains have size, so lines have width
47
+ p = clamp(p, -.985, .985);
48
+ o = vec4(p, 0, 1);
49
+ }`;
50
+ // the plate: brushed brass under a soft studio light, with a bevelled edge and the bolt at its heart
51
+ const plateFS = `#version 300 es
52
+ precision highp float; in vec2 uv; out vec4 o; uniform vec2 R; uniform float PS, T;
53
+ ${WAVE}
54
+ float h(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
55
+ float n2(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3. - 2. * f); return mix(mix(h(i), h(i + vec2(1, 0)), f.x), mix(h(i + vec2(0, 1)), h(i + 1.), f.x), f.y); }
56
+ void main() {
57
+ vec2 q = (uv * R - R * .5) / (PS * .5); // plate units: −1..1 across the plate
58
+ vec3 bg = vec3(.07, .068, .066) * (1. - .45 * length(uv - .5));
59
+ vec2 sd = abs(q - vec2(.035, -.05)) - 1.; float shadow = smoothstep(.12, -.02, max(sd.x, sd.y));
60
+ bg *= 1. - .6 * shadow;
61
+ float edge = max(abs(q.x), abs(q.y));
62
+ vec3 col = bg;
63
+ if (edge < 1.) {
64
+ float brushed = n2(vec2(q.x * 3., q.y * 260.)) * .5 + n2(vec2(q.x * 9., q.y * 900.)) * .5;
65
+ vec3 brass = vec3(.74, .55, .27) * (.82 + .25 * brushed);
66
+ float light = .55 + .45 * smoothstep(1.6, -.6, length(q - vec2(-.5, .55))); // a big soft light from the upper left
67
+ float sheen = exp(-pow((q.x + q.y * .6 - .35) * 1.8, 2.)) * .35; // and its long reflection
68
+ col = brass * light + vec3(1., .86, .6) * sheen;
69
+ col *= 1. + .05 * abs(wave(q)) * sin(T * 90.); // the buzz, barely visible
70
+ float bevel = smoothstep(.975, 1., edge);
71
+ col = mix(col, col * .55, bevel) + vec3(1., .9, .7) * .25 * smoothstep(.96, .975, edge) * (1. - bevel);
72
+ float bolt = length(q);
73
+ col = mix(col, vec3(.2, .19, .18), smoothstep(.055, .045, bolt));
74
+ col += vec3(.7) * smoothstep(.045, .03, length(q - vec2(-.012, .014))) * smoothstep(.0, .03, bolt) * .4;
75
+ }
76
+ o = vec4(pow(col, vec3(.95)), 1);
77
+ }`;
78
+ // each grain: a little sand-coloured disc with its shadow drawn first, so the pattern has relief
79
+ const grainVS = `#version 300 es
80
+ uniform sampler2D S; uniform int G; uniform vec2 R; uniform float PS, SZ, SHADOW;
81
+ out float k;
82
+ void main() {
83
+ int i = gl_VertexID; vec2 p = texelFetch(S, ivec2(i % G, i / G), 0).xy;
84
+ vec2 px = p * PS * .5 + SHADOW * vec2(.9, -1.1);
85
+ gl_Position = vec4(px / (R * .5), 0, 1); gl_PointSize = SZ; k = fract(sin(float(i) * 12.9898) * 43758.5453);
86
+ }`;
87
+ const grainFS = `#version 300 es
88
+ precision highp float; in float k; out vec4 o; uniform float SHADOW;
89
+ void main() { vec2 q = gl_PointCoord * 2. - 1.; if (dot(q, q) > 1.) discard;
90
+ o = SHADOW > 0. ? vec4(0, 0, 0, .18) : vec4(mix(vec3(.86, .8, .66), vec3(.98, .95, .86), k) * (1. - .25 * q.y * -1.), 1); }`;
91
+
92
+ 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; };
93
+ const prog = (vs, fs) => {
94
+ 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);
95
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
96
+ const u = {}; for (let i = 0, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); i < n; i++) { const nm = gl.getActiveUniform(p, i).name.replace('[0]', ''); u[nm] = gl.getUniformLocation(p, nm); }
97
+ return { p, u };
98
+ };
99
+ const sandP = prog(QUAD, sandFS), plateP = prog(QUAD, plateFS), grainP = prog(grainVS, grainFS);
100
+ const quad = gl.createVertexArray(); gl.bindVertexArray(quad);
101
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
102
+ gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
103
+ const empty = gl.createVertexArray();
104
+ const init = new Float32Array(COUNT * 4); for (let i = 0; i < COUNT; i++) init.set([Math.random() * 1.96 - 0.98, Math.random() * 1.96 - 0.98, 0, 1], i * 4); // sprinkled evenly
105
+ const tgt = () => {
106
+ const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, G, G, 0, gl.RGBA, gl.FLOAT, init);
107
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
108
+ const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0); return { t, fb };
109
+ };
110
+ let A = tgt(), B = tgt();
111
+ let W = 1, H = 1;
112
+ const resize = () => { const d = Math.min(devicePixelRatio, 2); W = canvas.width = Math.max(1, (stage.clientWidth * d) | 0); H = canvas.height = Math.max(1, (stage.clientHeight * d) | 0); };
113
+ const ro = new ResizeObserver(resize); ro.observe(stage); resize();
114
+
115
+ const md = new Float32Array(K * 3); MODES.forEach((m, i) => md.set(m, i * 3));
116
+ const wt = new Float32Array(K);
117
+ let raf, last = performance.now(), t = 0, frameNo = 0, shown = '';
118
+ function frame(now) {
119
+ const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt; frameNo++;
120
+ // the tone: a slow upward sweep that lingers near each resonance, then starts over
121
+ const span = FREQ.length, pos = (t * 0.11 * PARAMS.sweep + 7) % span, // start partway up, where the figures get intricate
122
+ k0 = Math.floor(pos), frac = pos - k0;
123
+ const linger = frac < 0.62 ? 0 : (frac - 0.62) / 0.38, ease = linger * linger * (3 - 2 * linger);
124
+ const f = FREQ[k0] * Math.pow(FREQ[(k0 + 1) % span] / FREQ[k0], k0 + 1 < span ? ease : 0);
125
+ let sum = 0; for (let k = 0; k < K; k++) { const x = (f - FREQ[k]) / (FREQ[k] * 0.06); wt[k] = 1 / (1 + x * x); sum += wt[k]; }
126
+ for (let k = 0; k < K; k++) wt[k] /= Math.max(1, sum * 0.85);
127
+ const top = wt.indexOf(Math.max(...wt));
128
+ const txt = `${Math.round(f).toLocaleString('en-US')} Hz mode ${MODES[top][0]},${MODES[top][1]}`;
129
+ if (txt !== shown) { label.textContent = txt; shown = txt; }
130
+ const PS = Math.min(W, H) * 0.86;
131
+ const setWave = P => { gl.uniform3fv(P.u.MD, md); gl.uniform1fv(P.u.WT, wt); };
132
+ gl.disable(gl.BLEND); gl.bindVertexArray(quad);
133
+ for (let s = 0; s < 2; s++) {
134
+ gl.useProgram(sandP.p); gl.bindFramebuffer(gl.FRAMEBUFFER, B.fb); gl.viewport(0, 0, G, G);
135
+ gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, A.t); gl.uniform1i(sandP.u.S, 0); gl.uniform1f(sandP.u.F, (frameNo * 2 + s) % 997); gl.uniform1f(sandP.u.SH, PARAMS.shake); setWave(sandP);
136
+ gl.drawArrays(gl.TRIANGLES, 0, 3); [A, B] = [B, A];
137
+ }
138
+ gl.useProgram(plateP.p); gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H);
139
+ gl.uniform2f(plateP.u.R, W, H); gl.uniform1f(plateP.u.PS, PS); gl.uniform1f(plateP.u.T, t); setWave(plateP);
140
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
141
+ gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
142
+ gl.useProgram(grainP.p); gl.bindVertexArray(empty); gl.bindTexture(gl.TEXTURE_2D, A.t);
143
+ gl.uniform1i(grainP.u.S, 0); gl.uniform1i(grainP.u.G, G); gl.uniform2f(grainP.u.R, W, H); gl.uniform1f(grainP.u.PS, PS);
144
+ const sz = Math.max(1.3, PS / 300);
145
+ gl.uniform1f(grainP.u.SZ, sz * 1.15); gl.uniform1f(grainP.u.SHADOW, sz); gl.drawArrays(gl.POINTS, 0, COUNT); // shadows
146
+ gl.uniform1f(grainP.u.SZ, sz); gl.uniform1f(grainP.u.SHADOW, 0); gl.drawArrays(gl.POINTS, 0, COUNT);
147
+ gl.disable(gl.BLEND);
148
+ raf = requestAnimationFrame(frame);
149
+ }
150
+ raf = requestAnimationFrame(frame);
151
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); label.remove(); };
152
+ }