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