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,181 @@
|
|
|
1
|
+
// Pin art: the pin-screen toy, in 3D. Three thousand steel pins slide through a frame. Something pushes up from
|
|
2
|
+
// behind (a hand, then a face, then a ripple) and every pin it touches slides forward, so the shape appears in relief.
|
|
3
|
+
// The pins only move one way: when the shape pulls back, its imprint stays. Then a sweep flattens the board for the
|
|
4
|
+
// next one. Each pin is an instanced cylinder with a domed head, shaded like polished steel, and darkened where taller
|
|
5
|
+
// neighbours shade it. Press with the pointer to push pins back in and draw into the relief.
|
|
6
|
+
const PARAMS = { pins: 72, depth: 1.2, 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;touch-action:none';
|
|
11
|
+
stage.append(canvas);
|
|
12
|
+
const gl = canvas.getContext('webgl2', { antialias: true });
|
|
13
|
+
if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
|
|
14
|
+
const NX = Math.max(16, Math.round(PARAMS.pins)), NY = Math.round(NX * 0.68), N = NX * NY, HMAX = 8 * PARAMS.depth;
|
|
15
|
+
const h = new Float32Array(N), occ = new Float32Array(N);
|
|
16
|
+
|
|
17
|
+
// the shapes that push from behind, as height fields over the board (x across −1..1, y up −0.68..0.68)
|
|
18
|
+
const cap = (px, py, ax, ay, bx, by, r) => { const dx = bx - ax, dy = by - ay, t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy))), qx = px - ax - dx * t, qy = py - ay - dy * t, d2 = qx * qx + qy * qy; return d2 < r * r ? Math.sqrt(r * r - d2) / r : 0; };
|
|
19
|
+
const SHAPES = [
|
|
20
|
+
(x, y) => { // a hand, fingers spread
|
|
21
|
+
let v = cap(x, y, 0.02, -0.5, 0.02, -0.12, 0.27) * 0.9;
|
|
22
|
+
const F = [[-0.27, -0.12, -0.52, 0.12, 0.075], [-0.14, 0.04, -0.2, 0.5, 0.075], [0.02, 0.06, 0.03, 0.56, 0.08], [0.16, 0.04, 0.24, 0.48, 0.075], [0.27, -0.02, 0.42, 0.33, 0.065]];
|
|
23
|
+
for (const [ax, ay, bx, by, r] of F) v = Math.max(v, cap(x, y, ax, ay, bx, by, r) * 0.75);
|
|
24
|
+
return v;
|
|
25
|
+
},
|
|
26
|
+
(x, y) => { // a face, like a mask
|
|
27
|
+
const e = (x * x) / 0.16 + ((y + 0.02) ** 2) / 0.3; if (e > 1) return 0;
|
|
28
|
+
let v = Math.sqrt(1 - e) * 0.7;
|
|
29
|
+
v += 0.28 * Math.exp(-((x * x) / 0.004 + ((y + 0.05) ** 2) / 0.03)); // nose
|
|
30
|
+
v -= 0.16 * (Math.exp(-(((x - 0.15) ** 2) + ((y - 0.12) ** 2)) / 0.006) + Math.exp(-(((x + 0.15) ** 2) + ((y - 0.12) ** 2)) / 0.006)); // eye sockets
|
|
31
|
+
v += 0.08 * (Math.exp(-(((x - 0.15) ** 2) / 0.012 + ((y - 0.22) ** 2) / 0.0015)) + Math.exp(-(((x + 0.15) ** 2) / 0.012 + ((y - 0.22) ** 2) / 0.0015))); // brows
|
|
32
|
+
v += 0.1 * Math.exp(-((x * x) / 0.014 + ((y + 0.3) ** 2) / 0.002)) - 0.05 * Math.exp(-((x * x) / 0.02 + ((y + 0.33) ** 2) / 0.0004)); // lips
|
|
33
|
+
return Math.max(0, v);
|
|
34
|
+
},
|
|
35
|
+
(x, y) => { const r = Math.hypot(x, y); return r > 0.62 ? 0 : (0.5 + 0.5 * Math.cos(r * 26)) * (1 - r / 0.62) * 0.9; }, // a ripple
|
|
36
|
+
];
|
|
37
|
+
const ptr = { x: 0, y: 0, down: false };
|
|
38
|
+
let shape = 0, phaseT = 0;
|
|
39
|
+
|
|
40
|
+
// GL
|
|
41
|
+
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; };
|
|
42
|
+
const prog = (vs, fs) => {
|
|
43
|
+
const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(p);
|
|
44
|
+
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
|
|
45
|
+
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); }
|
|
46
|
+
return { p, u };
|
|
47
|
+
};
|
|
48
|
+
const ENV = `
|
|
49
|
+
// a studio: dark floor, soft grey walls, one big window up and to the left
|
|
50
|
+
vec3 env(vec3 r) {
|
|
51
|
+
vec3 c = mix(vec3(.012, .012, .015), vec3(.11, .115, .13), smoothstep(-.3, .6, r.y));
|
|
52
|
+
c += vec3(2.6, 2.45, 2.2) * smoothstep(.82, .93, dot(r, normalize(vec3(-.55, .65, .5)))); // the key: a big softbox up-left
|
|
53
|
+
c += vec3(.55, .42, .3) * smoothstep(.72, .95, dot(r, normalize(vec3(.85, .05, .5)))); // a warm kicker from the right
|
|
54
|
+
return c;
|
|
55
|
+
}`;
|
|
56
|
+
const pinVS = `#version 300 es
|
|
57
|
+
layout(location = 0) in vec3 P; layout(location = 1) in vec3 Nrm; // unit pin: shaft along z 0..1, head beyond
|
|
58
|
+
layout(location = 2) in vec2 G; layout(location = 3) in float Hh; layout(location = 4) in float O;
|
|
59
|
+
uniform mat4 M; uniform float SP; out vec3 n; out vec3 w; out float oc; out float head;
|
|
60
|
+
void main() {
|
|
61
|
+
vec3 p = vec3(G * SP, 0) + vec3(P.xy, P.z <= 1. ? P.z * (Hh + 1.) : Hh + 1. + (P.z - 1.));
|
|
62
|
+
n = Nrm; w = p; oc = O; head = step(1.001, P.z);
|
|
63
|
+
gl_Position = M * vec4(p, 1);
|
|
64
|
+
}`;
|
|
65
|
+
const pinFS = `#version 300 es
|
|
66
|
+
precision highp float; in vec3 n; in vec3 w; in float oc; in float head; out vec4 o; uniform vec3 E;
|
|
67
|
+
${ENV}
|
|
68
|
+
void main() {
|
|
69
|
+
vec3 N = normalize(n), V = normalize(E - w), R = reflect(-V, N);
|
|
70
|
+
float fr = .55 + .45 * pow(1. - max(dot(N, V), 0.), 5.);
|
|
71
|
+
vec3 c = env(R) * vec3(.82, .84, .88) * fr;
|
|
72
|
+
c *= mix(1., .12, oc) * mix(.7, 1., head) * (.7 + .5 * clamp(w.z / 8., 0., 1.)); // shadowed by taller neighbours; the furthest-out pins catch the most light
|
|
73
|
+
o = vec4(pow(c / (1. + c * .3), vec3(1. / 2.2)), 1);
|
|
74
|
+
}`;
|
|
75
|
+
const boardFS = `#version 300 es
|
|
76
|
+
precision highp float; in vec2 v; out vec4 o; uniform vec2 R;
|
|
77
|
+
void main() { vec2 q = v; vec3 c = mix(vec3(.012, .012, .014), vec3(.003, .003, .004), length(q * vec2(.7, 1.))); o = vec4(pow(c, vec3(1. / 2.2)), 1); }`;
|
|
78
|
+
const plateP = prog(`#version 300 es
|
|
79
|
+
layout(location = 0) in vec3 P; uniform mat4 M; out vec2 g; void main() { g = P.xy; gl_Position = M * vec4(P, 1); }`, `#version 300 es
|
|
80
|
+
precision highp float; in vec2 g; out vec4 o; uniform vec2 S;
|
|
81
|
+
void main() { vec2 k = g / S; float edge = max(abs(k.x), abs(k.y)); vec3 c = vec3(.04, .04, .045) * (1. - .3 * length(k)) * (edge > .97 ? 1.8 : 1.); o = vec4(pow(c, vec3(1. / 2.2)), 1); }`);
|
|
82
|
+
const pinP = prog(pinVS, pinFS), boardP = prog(`#version 300 es
|
|
83
|
+
in vec2 p; out vec2 v; void main() { v = p; gl_Position = vec4(p, .9999, 1); }`, boardFS);
|
|
84
|
+
|
|
85
|
+
// one pin: an 10-sided shaft and a rounded head, a little wider
|
|
86
|
+
const SEG = 10, R0 = 0.26, RH = 0.47, verts = [];
|
|
87
|
+
const ring = (z, r, nz) => Array.from({ length: SEG + 1 }, (_, i) => { const a = (i / SEG) * Math.PI * 2; return [Math.cos(a) * r, Math.sin(a) * r, z, Math.cos(a) * (1 - Math.abs(nz)), Math.sin(a) * (1 - Math.abs(nz)), nz]; });
|
|
88
|
+
const band = (A, B) => { for (let i = 0; i < SEG; i++) verts.push(...A[i], ...B[i], ...A[i + 1], ...A[i + 1], ...B[i], ...B[i + 1]); };
|
|
89
|
+
band(ring(0, R0, 0), ring(1, R0, 0)); // shaft
|
|
90
|
+
band(ring(1.0001, R0, -0.6), ring(1.05, RH, -0.2)); band(ring(1.05, RH, 0), ring(1.3, RH, 0)); // head: flare, side
|
|
91
|
+
const dome = [0.3, 0.6, 0.85, 1].map((k, j) => ring(1.3 + Math.sin(k * Math.PI / 2) * 0.22, RH * Math.cos(k * Math.PI / 2) + 0.001, Math.sin(k * Math.PI / 2)));
|
|
92
|
+
band(ring(1.3, RH, 0.05), dome[0]); band(dome[0], dome[1]); band(dome[1], dome[2]); band(dome[2], dome[3]);
|
|
93
|
+
const mesh = new Float32Array(verts), VCOUNT = mesh.length / 6;
|
|
94
|
+
const grid = new Float32Array(N * 2); for (let j = 0; j < NY; j++) for (let i = 0; i < NX; i++) grid.set([i - (NX - 1) / 2, j - (NY - 1) / 2], (j * NX + i) * 2);
|
|
95
|
+
const vao = gl.createVertexArray(); gl.bindVertexArray(vao);
|
|
96
|
+
const buf = (data, loc, size, stride, off, div, usage = gl.STATIC_DRAW) => { const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, data, usage); return b; };
|
|
97
|
+
const mb = buf(mesh); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12);
|
|
98
|
+
buf(grid); gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(2, 1);
|
|
99
|
+
const hb = buf(h.byteLength, 0, 0, 0, 0, 0, gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3, 1, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(3, 1);
|
|
100
|
+
const ob = buf(occ.byteLength, 0, 0, 0, 0, 0, gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4, 1, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(4, 1);
|
|
101
|
+
gl.bindVertexArray(null);
|
|
102
|
+
const bx = NX / 2 + 1.2, by = NY / 2 + 1.2;
|
|
103
|
+
const pv = gl.createVertexArray(); gl.bindVertexArray(pv); buf(new Float32Array([-bx, -by, 0, bx, -by, 0, -bx, by, 0, bx, -by, 0, bx, by, 0, -bx, by, 0])); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); gl.bindVertexArray(null);
|
|
104
|
+
const qv = gl.createVertexArray(); gl.bindVertexArray(qv); buf(new Float32Array([-1, -1, 3, -1, -1, 3])); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.bindVertexArray(null);
|
|
105
|
+
|
|
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
|
+
// camera: in front of the board, a little left and above, so the relief reads
|
|
110
|
+
const SP = 1;
|
|
111
|
+
let EYE = [0, 0, 0];
|
|
112
|
+
const matrix = t => {
|
|
113
|
+
const az = -0.5 + 0.12 * Math.sin(t * 0.21), el = 0.32, dist = NX * 1.02;
|
|
114
|
+
EYE = [Math.sin(az) * Math.cos(el) * dist, Math.sin(el) * dist, Math.cos(az) * Math.cos(el) * dist];
|
|
115
|
+
const T = [0, 0, HMAX * 0.25], f = EYE.map((v, i) => T[i] - v), fl = Math.hypot(...f); f.forEach((v, i) => f[i] = v / fl);
|
|
116
|
+
const rl0 = Math.hypot(f[2], f[0]), r = [-f[2] / rl0, 0, f[0] / rl0]; // right = forward × up
|
|
117
|
+
const u = [r[1] * f[2] - r[2] * f[1], r[2] * f[0] - r[0] * f[2], r[0] * f[1] - r[1] * f[0]];
|
|
118
|
+
const V = [r[0], u[0], -f[0], 0, r[1], u[1], -f[1], 0, r[2], u[2], -f[2], 0, -(r[0] * EYE[0] + r[1] * EYE[1] + r[2] * EYE[2]), -(u[0] * EYE[0] + u[1] * EYE[1] + u[2] * EYE[2]), f[0] * EYE[0] + f[1] * EYE[1] + f[2] * EYE[2], 1];
|
|
119
|
+
const F = 1 / Math.tan(0.36), A = W / H, n = 1, fa = 600, nf = 1 / (n - fa);
|
|
120
|
+
const P = [F / A, 0, 0, 0, 0, F, 0, 0, 0, 0, (fa + n) * nf, -1, 0, 0, 2 * fa * n * nf, 0], out = new Float32Array(16);
|
|
121
|
+
for (let c = 0; c < 4; c++) for (let rr = 0; rr < 4; rr++) { let s = 0; for (let k = 0; k < 4; k++) s += P[k * 4 + rr] * V[c * 4 + k]; out[c * 4 + rr] = s; }
|
|
122
|
+
return out;
|
|
123
|
+
};
|
|
124
|
+
// pointer: find the pin under it (on the board plane, at mid relief) and press nearby pins back
|
|
125
|
+
const move = e => {
|
|
126
|
+
const r = canvas.getBoundingClientRect(); ptr.sx = ((e.clientX - r.left) / r.width) * 2 - 1; ptr.sy = 1 - ((e.clientY - r.top) / r.height) * 2;
|
|
127
|
+
};
|
|
128
|
+
const down = e => { ptr.down = true; move(e); canvas.setPointerCapture(e.pointerId); };
|
|
129
|
+
const up = () => { ptr.down = false; };
|
|
130
|
+
canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', up);
|
|
131
|
+
|
|
132
|
+
let raf, last = performance.now(), t = 0, M = null;
|
|
133
|
+
function frame(now) {
|
|
134
|
+
const dt = Math.min(0.05, (now - last) / 1000) * PARAMS.speed; last = now; t += dt; phaseT += dt;
|
|
135
|
+
// a cycle: push in (3 s), pull back leaving the imprint (hold 3 s), then sweep the board flat (1.5 s)
|
|
136
|
+
const f = SHAPES[shape], push = phaseT < 3 ? phaseT / 3 : phaseT < 4 ? 1 - (phaseT - 3) : 0;
|
|
137
|
+
const sweep = phaseT > 6.2 ? (phaseT - 6.2) / 1.5 : -1;
|
|
138
|
+
if (phaseT > 7.9) { phaseT = 0; shape = (shape + 1) % SHAPES.length; }
|
|
139
|
+
const pushE = push * push * (3 - 2 * push);
|
|
140
|
+
for (let j = 0; j < NY; j++) for (let i = 0; i < NX; i++) {
|
|
141
|
+
const k = j * NX + i, x = (i / (NX - 1)) * 2 - 1, y = ((j / (NY - 1)) * 2 - 1) * (NY / NX);
|
|
142
|
+
if (sweep >= 0) { if ((x + 1) / 2 < sweep * 1.15 - 0.08) h[k] += (0 - h[k]) * Math.min(1, dt * 14); continue; } // a palm sweeps them flat, left to right
|
|
143
|
+
const target = f(x, y) * HMAX * (0.25 + 0.75 * pushE) * (push > 0 ? 1 : 0);
|
|
144
|
+
if (target > h[k]) h[k] += Math.min(target - h[k], dt * 26); // pins only ever get pushed forward
|
|
145
|
+
}
|
|
146
|
+
if (ptr.down && M) { // press: pins near the pointer go back in
|
|
147
|
+
for (let k = 0; k < N; k++) {
|
|
148
|
+
const gx = grid[k * 2], gy = grid[k * 2 + 1], z = h[k] + 1.5;
|
|
149
|
+
const cx = M[0] * gx + M[4] * gy + M[8] * z + M[12], cy = M[1] * gx + M[5] * gy + M[9] * z + M[13], cw = M[3] * gx + M[7] * gy + M[11] * z + M[15];
|
|
150
|
+
const d = Math.hypot(cx / cw - ptr.sx, (cy / cw - ptr.sy) * H / W);
|
|
151
|
+
if (d < 0.05) h[k] = Math.max(0, h[k] - dt * 30 * (1 - d / 0.05));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// occlusion: a pin standing lower than its neighbours sits in their shade
|
|
155
|
+
for (let j = 0; j < NY; j++) for (let i = 0; i < NX; i++) {
|
|
156
|
+
const k = j * NX + i; let m = 0, c = 0;
|
|
157
|
+
for (let dj = -1; dj <= 1; dj++) for (let di = -1; di <= 1; di++) { const ii = i + di, jj = j + dj; if (ii < 0 || jj < 0 || ii >= NX || jj >= NY || (!di && !dj)) continue; m += h[jj * NX + ii]; c++; }
|
|
158
|
+
// and a raking light from the upper left: a taller pin a step or three that way casts this one into shadow
|
|
159
|
+
let sh = 0; for (let st = 1; st <= 4; st++) { const ii = i - st, jj = j + st; if (ii < 0 || jj >= NY) break; sh = Math.max(sh, (h[jj * NX + ii] - h[k] - st * 0.9) / 1.5); }
|
|
160
|
+
occ[k] = Math.min(1, Math.max(0, (m / c - h[k]) / 2.2, sh));
|
|
161
|
+
}
|
|
162
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H);
|
|
163
|
+
gl.clearColor(0.03, 0.03, 0.035, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.enable(gl.DEPTH_TEST);
|
|
164
|
+
gl.useProgram(boardP.p); gl.bindVertexArray(qv); gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
165
|
+
M = matrix(t);
|
|
166
|
+
gl.useProgram(plateP.p); gl.uniformMatrix4fv(plateP.u.M, false, M); gl.uniform2f(plateP.u.S, bx, by); gl.bindVertexArray(pv); gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
167
|
+
gl.useProgram(pinP.p); gl.uniformMatrix4fv(pinP.u.M, false, M); gl.uniform1f(pinP.u.SP, SP); gl.uniform3f(pinP.u.E, ...EYE);
|
|
168
|
+
gl.bindVertexArray(vao);
|
|
169
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, hb); gl.bufferSubData(gl.ARRAY_BUFFER, 0, h);
|
|
170
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, ob); gl.bufferSubData(gl.ARRAY_BUFFER, 0, occ);
|
|
171
|
+
gl.drawArraysInstanced(gl.TRIANGLES, 0, VCOUNT, N);
|
|
172
|
+
gl.bindVertexArray(null); gl.disable(gl.DEPTH_TEST);
|
|
173
|
+
raf = requestAnimationFrame(frame);
|
|
174
|
+
}
|
|
175
|
+
raf = requestAnimationFrame(frame);
|
|
176
|
+
return () => {
|
|
177
|
+
cancelAnimationFrame(raf); ro.disconnect();
|
|
178
|
+
canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', up);
|
|
179
|
+
gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove();
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Rainbow from raindrops: the sun is low behind you and rain falls in front. Light that enters a drop, bounces once
|
|
2
|
+
// inside and comes back out leaves at about 42° from the direction opposite the sun, red a little wider than violet.
|
|
3
|
+
// So each falling drop sends you one colour only at the moment it crosses that angle, and the bow is simply the place
|
|
4
|
+
// where thousands of drops agree. A second bounce makes the fainter outer bow at about 51°, colours reversed, and the
|
|
5
|
+
// sky between the two (Alexander's band) is darker, because no drop sends light from there. The drops are drawn as
|
|
6
|
+
// streaks, each coloured by its angle from the anti-solar point.
|
|
7
|
+
const PARAMS = { drops: 24000, rain: 1, bow: 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
|
+
stage.append(canvas);
|
|
13
|
+
const gl = canvas.getContext('webgl2', { antialias: true });
|
|
14
|
+
if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
|
|
15
|
+
const N = Math.max(2000, Math.round(PARAMS.drops));
|
|
16
|
+
// the sun: low (11°), behind and a little to the right; the bow is centred opposite it, below the horizon
|
|
17
|
+
const SUN = (() => { const e = 0.19, a = 0.06; return [Math.sin(a) * Math.cos(e), Math.sin(e), -Math.cos(a) * Math.cos(e)]; })();
|
|
18
|
+
const PITCH = 0.13, FOC = 1 / Math.tan(0.62), EYE = 1.7;
|
|
19
|
+
const COMMON = `
|
|
20
|
+
uniform vec3 SUN; uniform float PITCH, FOC, A, T, BOW;
|
|
21
|
+
vec3 hsv(float h, float s, float v) { vec3 k = clamp(abs(fract(h + vec3(0., 2. / 3., 1. / 3.)) * 6. - 3.) - 1., 0., 1.); return v * mix(vec3(1), k, s); }
|
|
22
|
+
// what a drop at angle th (degrees) from the anti-solar point sends back
|
|
23
|
+
vec3 bow(float th) {
|
|
24
|
+
vec3 c = vec3(0);
|
|
25
|
+
float x = (th - 40.4) / 2.1; // primary: violet inside, red outside
|
|
26
|
+
c += hsv(.78 * (1. - clamp(x, 0., 1.)), .9, 1.) * smoothstep(-.35, .25, x) * (1. - smoothstep(.92, 1.12, x));
|
|
27
|
+
float y = (53.6 - th) / 2.9; // secondary: reversed, and fainter
|
|
28
|
+
c += hsv(.78 * (1. - clamp(y, 0., 1.)), .85, .42) * smoothstep(-.35, .25, y) * (1. - smoothstep(.92, 1.12, y));
|
|
29
|
+
c += vec3(.16) * smoothstep(10., 40.5, th) * step(th, 40.6); // inside the bow the sky is brighter
|
|
30
|
+
return c;
|
|
31
|
+
}
|
|
32
|
+
vec3 ray(vec2 q) { float cp = cos(PITCH), sp = sin(PITCH); return normalize(vec3(q.x, q.y * cp + FOC * sp, -q.y * sp + FOC * cp)); }`;
|
|
33
|
+
// the sky (a storm, lit from behind the viewer), the bow's faint continuous glow, sunlit hills
|
|
34
|
+
const sceneFS = `#version 300 es
|
|
35
|
+
precision highp float; in vec2 uv; out vec4 o; uniform vec2 R;
|
|
36
|
+
${COMMON}
|
|
37
|
+
float h(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
|
38
|
+
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); }
|
|
39
|
+
float fbm(vec2 p) { float s = 0., a = .5; for (int i = 0; i < 5; i++) { s += a * n2(p); p = p * 2.03 + 7.1; a *= .5; } return s; }
|
|
40
|
+
void main() {
|
|
41
|
+
vec2 q = (uv * 2. - 1.) * vec2(A, 1.);
|
|
42
|
+
vec3 d = ray(q);
|
|
43
|
+
float th = degrees(acos(clamp(dot(d, -SUN), -1., 1.)));
|
|
44
|
+
vec3 col;
|
|
45
|
+
float hill1 = .02 + .07 * fbm(vec2(d.x / d.z * 1.4, 3.)), hill2 = .004 + .035 * fbm(vec2(d.x / d.z * 3.1, 9.));
|
|
46
|
+
if (d.y > hill1) {
|
|
47
|
+
float cl = fbm(vec2(d.x / max(d.y + .25, .05) * .6 + T * .006, d.y * 4.));
|
|
48
|
+
col = mix(vec3(.2, .22, .26), vec3(.36, .38, .42), cl); // the storm: slate, lit from behind you
|
|
49
|
+
col = mix(col, vec3(.44, .46, .5), smoothstep(.55, .0, d.y) * .35);
|
|
50
|
+
col *= 1. - .28 * smoothstep(42.6, 44., th) * (1. - smoothstep(49.5, 51., th)); // Alexander's dark band
|
|
51
|
+
col += bow(th) * .16 * BOW; // the glow of countless drops too far to see
|
|
52
|
+
} else {
|
|
53
|
+
// the land: two lines of hills in low gold light, then a meadow
|
|
54
|
+
float near = step(d.y, hill2);
|
|
55
|
+
vec3 far = mix(vec3(.38, .42, .3), vec3(.5, .5, .32), fbm(vec2(d.x / d.z * 6., 1.)));
|
|
56
|
+
vec3 hill = mix(vec3(.3, .38, .14), vec3(.56, .52, .2), fbm(vec2(d.x / d.z * 9., d.y * 60.)));
|
|
57
|
+
col = mix(far, hill, near);
|
|
58
|
+
float shade = fbm(vec2(d.x / d.z * 2. + T * .01, 5.)); col *= .7 + .45 * smoothstep(.35, .7, shade); // cloud shadows drifting over
|
|
59
|
+
if (d.y < -.01) { // the meadow up close, fading into the hills
|
|
60
|
+
float z = EYE_ / -d.y; vec2 g = vec2(d.x * z, z);
|
|
61
|
+
vec3 m = mix(vec3(.26, .32, .11), vec3(.44, .46, .17), fbm(g * .06)) * (.88 + .2 * n2(g * .5));
|
|
62
|
+
col = mix(m, col, smoothstep(25., 120., z));
|
|
63
|
+
}
|
|
64
|
+
col = mix(col, vec3(.48, .5, .52), smoothstep(.0, -.12, -abs(d.y)) * .35);
|
|
65
|
+
}
|
|
66
|
+
o = vec4(pow(col, vec3(.95)), 1);
|
|
67
|
+
}`.replace('EYE_', EYE.toFixed(2));
|
|
68
|
+
// rain: each drop a short streak (two vertices per drop, instanced), coloured by its angle to the anti-solar point
|
|
69
|
+
const rainVS = `#version 300 es
|
|
70
|
+
layout(location = 0) in float end; // 0 = head, 1 = tail
|
|
71
|
+
layout(location = 1) in vec4 D; // x, starting height, z, phase
|
|
72
|
+
${COMMON}
|
|
73
|
+
uniform float SPD; out vec3 col; out float a;
|
|
74
|
+
void main() {
|
|
75
|
+
float fall = mod(D.y - (T * SPD * 9. + D.w * 40.), 40.);
|
|
76
|
+
vec3 p = vec3(D.x + fall * .12, fall - ${EYE.toFixed(2)}, D.z);
|
|
77
|
+
p -= vec3(.12, -1., 0.) * end * .33 * SPD; // the streak a drop draws in one exposure
|
|
78
|
+
float cp = cos(PITCH), sp = sin(PITCH);
|
|
79
|
+
vec3 c = vec3(p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp); // into the camera's frame
|
|
80
|
+
gl_Position = vec4(c.x * FOC / (c.z * A), c.y * FOC / c.z, 0, 1);
|
|
81
|
+
vec3 d = normalize(p);
|
|
82
|
+
float th = degrees(acos(clamp(dot(d, -SUN), -1., 1.)));
|
|
83
|
+
col = bow(th) * BOW * 1.6 + vec3(.1, .11, .12);
|
|
84
|
+
a = smoothstep(150., 20., D.z) * (1. - end * .7);
|
|
85
|
+
}`;
|
|
86
|
+
const rainFS = `#version 300 es
|
|
87
|
+
precision mediump float; in vec3 col; in float a; out vec4 o; void main() { o = vec4(col * a, 1); }`;
|
|
88
|
+
const QUAD = `#version 300 es
|
|
89
|
+
in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
|
|
90
|
+
|
|
91
|
+
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; };
|
|
92
|
+
const prog = (vs, fs) => {
|
|
93
|
+
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);
|
|
94
|
+
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
|
|
95
|
+
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); }
|
|
96
|
+
return { p, u };
|
|
97
|
+
};
|
|
98
|
+
const sceneP = prog(QUAD, sceneFS), rainP = prog(rainVS, rainFS);
|
|
99
|
+
const quad = gl.createVertexArray(); gl.bindVertexArray(quad);
|
|
100
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
|
101
|
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
102
|
+
// drops fill a slab in front of the viewer; more of them near, where each is bigger
|
|
103
|
+
const inst = new Float32Array(N * 4);
|
|
104
|
+
for (let i = 0; i < N; i++) { const z = 8 + 140 * Math.pow(Math.random(), 1.6); inst.set([(Math.random() * 2 - 1) * z * 1.15, Math.random() * 40, z, Math.random()], i * 4); }
|
|
105
|
+
const rv = gl.createVertexArray(); gl.bindVertexArray(rv);
|
|
106
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 1]), gl.STATIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 1, gl.FLOAT, false, 0, 0);
|
|
107
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, inst, gl.STATIC_DRAW); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(1, 1);
|
|
108
|
+
gl.bindVertexArray(null);
|
|
109
|
+
|
|
110
|
+
let W = 1, H = 1;
|
|
111
|
+
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); };
|
|
112
|
+
const ro = new ResizeObserver(resize); ro.observe(stage); resize();
|
|
113
|
+
const common = (P, t) => { gl.uniform3f(P.u.SUN, ...SUN); gl.uniform1f(P.u.PITCH, PITCH); gl.uniform1f(P.u.FOC, FOC); gl.uniform1f(P.u.A, W / H); gl.uniform1f(P.u.T, t); gl.uniform1f(P.u.BOW, PARAMS.bow); };
|
|
114
|
+
let raf, last = performance.now(), t = 0;
|
|
115
|
+
function frame(now) {
|
|
116
|
+
const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt;
|
|
117
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H); gl.disable(gl.BLEND);
|
|
118
|
+
gl.useProgram(sceneP.p); common(sceneP, t); gl.uniform2f(sceneP.u.R, W, H); gl.bindVertexArray(quad); gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
119
|
+
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
|
|
120
|
+
gl.useProgram(rainP.p); common(rainP, t); gl.uniform1f(rainP.u.SPD, PARAMS.rain); gl.bindVertexArray(rv);
|
|
121
|
+
gl.drawArraysInstanced(gl.LINES, 0, 2, Math.round(N * Math.min(1, PARAMS.rain + 0.2)));
|
|
122
|
+
gl.bindVertexArray(null); gl.disable(gl.BLEND);
|
|
123
|
+
raf = requestAnimationFrame(frame);
|
|
124
|
+
}
|
|
125
|
+
raf = requestAnimationFrame(frame);
|
|
126
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
|
|
127
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Roche limit: a rubble-pile moon spirals in toward an ice giant. Its own gravity holds it together, but the planet
|
|
2
|
+
// pulls harder on the near side than the far side, and that difference (the tide) grows as 1/d³. Inside about 2.4
|
|
3
|
+
// planet radii the tide wins: the moon stretches, then sheds two streams, one inward and one outward, from the points
|
|
4
|
+
// where the pull balances. Inner rubble orbits faster than outer rubble, so the streams shear and wind round the
|
|
5
|
+
// planet until they close into a ring. Twenty thousand particles, each lit by the sun and shadowed by the planet.
|
|
6
|
+
const PARAMS = { particles: 20000, speed: 1, glow: 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: false });
|
|
13
|
+
if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
|
|
14
|
+
const N = Math.max(2000, Math.round(PARAMS.particles));
|
|
15
|
+
const SUN = (() => { const v = [0.8, 0.32, 0.35], l = Math.hypot(...v); return v.map(x => x / l); })();
|
|
16
|
+
|
|
17
|
+
// ── the simulation (planet radius 1, GM = 1)
|
|
18
|
+
const px = new Float32Array(N), py = new Float32Array(N), pz = new Float32Array(N), vx = new Float32Array(N), vy = new Float32Array(N), vz = new Float32Array(N);
|
|
19
|
+
const bound = new Uint8Array(N), lit = new Float32Array(N);
|
|
20
|
+
const RM = 0.24, KB = 2 / 2.4 ** 3; // moon radius; self-gravity strength that fails at d ≈ 2.4
|
|
21
|
+
let com = [0, 0, 0], comV = [0, 0, 0], simT = 0, cycleT = 0;
|
|
22
|
+
function reset() {
|
|
23
|
+
const d0 = 3.5, v0 = Math.sqrt(1 / d0);
|
|
24
|
+
for (let i = 0; i < N; i++) {
|
|
25
|
+
let x, y, z; do { x = Math.random() * 2 - 1; y = Math.random() * 2 - 1; z = Math.random() * 2 - 1; } while (x * x + y * y + z * z > 1);
|
|
26
|
+
px[i] = d0 + x * RM; py[i] = y * RM; pz[i] = z * RM; vx[i] = 0; vy[i] = 0; vz[i] = v0; bound[i] = 1;
|
|
27
|
+
}
|
|
28
|
+
simT = 0; cycleT = 0;
|
|
29
|
+
}
|
|
30
|
+
reset();
|
|
31
|
+
function step(h) {
|
|
32
|
+
// the clump that is still a moon: its centre and velocity
|
|
33
|
+
let n = 0, cx = 0, cy = 0, cz = 0, cvx = 0, cvy = 0, cvz = 0;
|
|
34
|
+
for (let i = 0; i < N; i++) if (bound[i]) { n++; cx += px[i]; cy += py[i]; cz += pz[i]; cvx += vx[i]; cvy += vy[i]; cvz += vz[i]; }
|
|
35
|
+
if (n) { com = [cx / n, cy / n, cz / n]; comV = [cvx / n, cvy / n, cvz / n]; }
|
|
36
|
+
const decay = 0.0032; // the moon's orbit decays (tides raised on the planet)
|
|
37
|
+
for (let i = 0; i < N; i++) {
|
|
38
|
+
const r2 = px[i] * px[i] + py[i] * py[i] + pz[i] * pz[i], r = Math.sqrt(r2), g = -1 / (r2 * r);
|
|
39
|
+
let ax = px[i] * g, ay = py[i] * g, az = pz[i] * g;
|
|
40
|
+
if (bound[i]) {
|
|
41
|
+
const dx = px[i] - com[0], dy = py[i] - com[1], dz = pz[i] - com[2];
|
|
42
|
+
if (dx * dx + dy * dy + dz * dz > (RM * 1.5) ** 2) bound[i] = 0; // pulled too far from the clump: free rubble now
|
|
43
|
+
else {
|
|
44
|
+
ax -= KB * dx + 0.3 * (vx[i] - comV[0]); ay -= KB * dy + 0.3 * (vy[i] - comV[1]); az -= KB * dz + 0.3 * (vz[i] - comV[2]); // self-gravity, and friction inside the pile
|
|
45
|
+
ax -= decay * vx[i]; ay -= decay * vy[i]; az -= decay * vz[i];
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
// free rubble collides and settles: radial and vertical motion are slowly damped, so the ring turns round and flat
|
|
49
|
+
const vr = (vx[i] * px[i] + vy[i] * py[i] + vz[i] * pz[i]) / r;
|
|
50
|
+
const kick = (Math.random() - 0.5) * 0.05; // collisions also jostle them, spreading the streams into a band
|
|
51
|
+
ax += (kick - 0.03 * vr) * px[i] / r; ay -= 0.03 * vr * py[i] / r + 0.04 * vy[i]; az += (kick - 0.03 * vr) * pz[i] / r;
|
|
52
|
+
}
|
|
53
|
+
vx[i] += ax * h; vy[i] += ay * h; vz[i] += az * h;
|
|
54
|
+
px[i] += vx[i] * h; py[i] += vy[i] * h; pz[i] += vz[i] * h;
|
|
55
|
+
if (r < 1.02) { const k = 1.02 / r; px[i] *= k; py[i] *= k; pz[i] *= k; vx[i] *= 0.5; vy[i] *= 0.5; vz[i] *= 0.5; } // grazes the cloud tops
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── GL
|
|
60
|
+
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; };
|
|
61
|
+
const prog = (vs, fs) => {
|
|
62
|
+
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);
|
|
63
|
+
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
|
|
64
|
+
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); }
|
|
65
|
+
return { p, u };
|
|
66
|
+
};
|
|
67
|
+
const CAMERA = `uniform vec3 CP, CR, CU, CF, SUN; uniform float FOC, A;`;
|
|
68
|
+
// the planet and the stars, per pixel: a ray against a sphere, banded clouds, a soft terminator, a blue rim
|
|
69
|
+
const planetP = prog(`#version 300 es
|
|
70
|
+
in vec2 p; out vec2 v; void main() { v = p; gl_Position = vec4(p, 0, 1); }`, `#version 300 es
|
|
71
|
+
precision highp float; in vec2 v; out vec4 o; ${CAMERA} uniform float T, RING;
|
|
72
|
+
float h(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
|
73
|
+
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); }
|
|
74
|
+
void main() {
|
|
75
|
+
vec3 d = normalize(CF * FOC + CR * v.x * A + CU * v.y);
|
|
76
|
+
float b = dot(CP, d), c = dot(CP, CP) - 1., disc = b * b - c;
|
|
77
|
+
vec3 col = vec3(0);
|
|
78
|
+
vec2 sp = vec2(atan(d.z, d.x), asin(d.y)) * 230., ci = floor(sp);
|
|
79
|
+
vec3 p3 = fract(vec3(ci.xyx) * .1031); p3 += dot(p3, p3.yzx + 33.33); float s = fract((p3.x + p3.y) * p3.z);
|
|
80
|
+
col += vec3(.8, .85, 1.) * step(.992, s) * (s - .992) * 70. * smoothstep(.45, .1, length(fract(sp) - .5));
|
|
81
|
+
if (disc > 0.) {
|
|
82
|
+
float t = -b - sqrt(disc); vec3 pos = CP + d * t, n = normalize(pos);
|
|
83
|
+
float lat = n.y, lon = atan(n.z, n.x);
|
|
84
|
+
float turb = n2(vec2(lon * 3. + T * .02, lat * 14.)) * .6 + n2(vec2(lon * 9. - T * .03, lat * 40.)) * .25;
|
|
85
|
+
float band = sin(lat * 15. + turb * 3.);
|
|
86
|
+
vec3 base = mix(vec3(.28, .52, .66), vec3(.55, .78, .84), .5 + .5 * band);
|
|
87
|
+
base = mix(base, vec3(.85, .93, .95), smoothstep(.6, 1., band) * .35);
|
|
88
|
+
float dl = dot(n, SUN), lambert = smoothstep(-.12, .55, dl);
|
|
89
|
+
// the ring's shadow on the cloud tops, once there is a ring
|
|
90
|
+
vec3 toS = SUN; float tr = -pos.y / toS.y; vec3 hit = pos + toS * tr; float rr = length(hit.xz);
|
|
91
|
+
float shade = 1. - RING * .55 * step(0., tr) * smoothstep(1.35, 1.5, rr) * smoothstep(3.2, 2.7, rr);
|
|
92
|
+
col = base * (.035 + .965 * lambert) * shade + base * .03 * RING; // a little ring-shine on the night side
|
|
93
|
+
col += vec3(.35, .6, 1.) * pow(1. - max(dot(n, -d), 0.), 3.) * (.25 + .75 * smoothstep(-.2, .4, dl)) * .8; // atmosphere at the limb
|
|
94
|
+
}
|
|
95
|
+
o = vec4(col, 1);
|
|
96
|
+
}`);
|
|
97
|
+
// the rubble: points, hidden when the planet is in the way, dark in its shadow, lit by the sun
|
|
98
|
+
const rockP = prog(`#version 300 es
|
|
99
|
+
layout(location = 0) in vec3 P; layout(location = 1) in float L; ${CAMERA} uniform float PS; out float b;
|
|
100
|
+
void main() {
|
|
101
|
+
vec3 rel = P - CP; float dist = length(rel); vec3 d = rel / dist;
|
|
102
|
+
float bb = dot(CP, d), cc = dot(CP, CP) - 1., disc = bb * bb - cc, t = -bb - sqrt(max(disc, 0.));
|
|
103
|
+
float hidden = disc > 0. && t < dist ? 0. : 1.;
|
|
104
|
+
float sd = dot(P, SUN); vec3 perp = P - SUN * sd; float shadow = sd < 0. && dot(perp, perp) < 1. ? .06 : 1.;
|
|
105
|
+
vec3 c = vec3(dot(rel, CR), dot(rel, CU), dot(rel, CF));
|
|
106
|
+
gl_Position = vec4(c.x * FOC / (c.z * A), c.y * FOC / c.z, 0, 1);
|
|
107
|
+
gl_PointSize = PS * 6. / max(c.z, .5);
|
|
108
|
+
b = hidden * shadow * L;
|
|
109
|
+
}`, `#version 300 es
|
|
110
|
+
precision mediump float; in float b; out vec4 o; uniform float K;
|
|
111
|
+
void main() { vec2 q = gl_PointCoord * 2. - 1.; float f = max(0., 1. - dot(q, q)); o = vec4(vec3(1., .96, .9) * b * f * K, 1); }`);
|
|
112
|
+
|
|
113
|
+
const quad = gl.createVertexArray(); gl.bindVertexArray(quad);
|
|
114
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
|
115
|
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
116
|
+
const rv = gl.createVertexArray(); gl.bindVertexArray(rv);
|
|
117
|
+
const pos = new Float32Array(N * 3), pb = gl.createBuffer(), lb = gl.createBuffer();
|
|
118
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, pb); gl.bufferData(gl.ARRAY_BUFFER, pos.byteLength, gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
|
|
119
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, lb); gl.bufferData(gl.ARRAY_BUFFER, lit.byteLength, gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 1, gl.FLOAT, false, 0, 0);
|
|
120
|
+
gl.bindVertexArray(null);
|
|
121
|
+
|
|
122
|
+
let W = 1, H = 1;
|
|
123
|
+
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); };
|
|
124
|
+
const ro = new ResizeObserver(resize); ro.observe(stage); resize();
|
|
125
|
+
|
|
126
|
+
let raf, last = performance.now(), t = 0;
|
|
127
|
+
function frame(now) {
|
|
128
|
+
const dt = Math.min(0.05, (now - last) / 1000); last = now; t += dt;
|
|
129
|
+
const simDt = dt * 6.5 * PARAMS.speed;
|
|
130
|
+
for (let s = 0; s < 3; s++) step(simDt / 3);
|
|
131
|
+
simT += simDt; cycleT += dt * PARAMS.speed;
|
|
132
|
+
// the story repeats: once the ring has closed and settled for a while, a fresh moon arrives
|
|
133
|
+
const fade = Math.min(1, cycleT / 1.2) * Math.min(1, Math.max(0, (52 - cycleT) / 2));
|
|
134
|
+
if (cycleT > 52) reset();
|
|
135
|
+
// light each particle: the moon is a lit ball (day side and night side); loose rubble just reflects
|
|
136
|
+
let free = 0;
|
|
137
|
+
for (let i = 0; i < N; i++) {
|
|
138
|
+
pos[i * 3] = px[i]; pos[i * 3 + 1] = py[i]; pos[i * 3 + 2] = pz[i];
|
|
139
|
+
if (bound[i]) { const dx = px[i] - com[0], dy = py[i] - com[1], dz = pz[i] - com[2], l = Math.hypot(dx, dy, dz) + 1e-6; lit[i] = (0.12 + 0.88 * Math.max(0, (dx * SUN[0] + dy * SUN[1] + dz * SUN[2]) / l)) * fade * 0.4; }
|
|
140
|
+
else { lit[i] = fade; free++; }
|
|
141
|
+
}
|
|
142
|
+
// camera: circling slowly, a little above the ring plane
|
|
143
|
+
const az = 0.6 + t * 0.05, el = 0.26, dist = 5.4;
|
|
144
|
+
const CP = [Math.cos(az) * Math.cos(el) * dist, Math.sin(el) * dist, Math.sin(az) * Math.cos(el) * dist];
|
|
145
|
+
let CF = CP.map(v => -v / dist), CR = [CF[2], 0, -CF[0]]; const rl = Math.hypot(CR[0], CR[2]); CR = CR.map(v => v / rl);
|
|
146
|
+
const CU = [CF[1] * CR[2] - CF[2] * CR[1], CF[2] * CR[0] - CF[0] * CR[2], CF[0] * CR[1] - CF[1] * CR[0]];
|
|
147
|
+
const set = P => { gl.uniform3f(P.u.CP, ...CP); gl.uniform3f(P.u.CR, ...CR); gl.uniform3f(P.u.CU, ...CU); gl.uniform3f(P.u.CF, ...CF); gl.uniform3f(P.u.SUN, ...SUN); gl.uniform1f(P.u.FOC, 2.1); gl.uniform1f(P.u.A, W / H); };
|
|
148
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H); gl.disable(gl.BLEND);
|
|
149
|
+
gl.useProgram(planetP.p); set(planetP); gl.uniform1f(planetP.u.T, t); gl.uniform1f(planetP.u.RING, Math.min(1, free / N) * fade);
|
|
150
|
+
gl.bindVertexArray(quad); gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
151
|
+
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
|
|
152
|
+
gl.useProgram(rockP.p); set(rockP); gl.uniform1f(rockP.u.PS, Math.max(1.4, H / 380)); gl.uniform1f(rockP.u.K, 0.5 * PARAMS.glow * Math.sqrt(20000 / N));
|
|
153
|
+
gl.bindVertexArray(rv);
|
|
154
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, pb); gl.bufferSubData(gl.ARRAY_BUFFER, 0, pos);
|
|
155
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, lb); gl.bufferSubData(gl.ARRAY_BUFFER, 0, lit);
|
|
156
|
+
gl.drawArrays(gl.POINTS, 0, N); gl.bindVertexArray(null); gl.disable(gl.BLEND);
|
|
157
|
+
raf = requestAnimationFrame(frame);
|
|
158
|
+
}
|
|
159
|
+
raf = requestAnimationFrame(frame);
|
|
160
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
|
|
161
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Sand mandala: chalk guide lines first, then coloured sand poured grain by grain from eight funnels moving in step,
|
|
2
|
+
// from the centre outward, ring by ring: a lotus, a palace with four gates, flames, petals, a ring of fire. Every
|
|
3
|
+
// grain takes the colour of the design under it with a little variation, so the colours read as sand. When it is
|
|
4
|
+
// finished it is swept away (impermanence is the point): the whole picture is turned and drawn in on itself a little
|
|
5
|
+
// every frame, so the colours spiral into a mixed pile at the centre. Then a fresh cloth.
|
|
6
|
+
const PARAMS = { grains: 2200, symmetry: 8, 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:#3a1416';
|
|
11
|
+
stage.append(canvas);
|
|
12
|
+
const ctx = canvas.getContext('2d');
|
|
13
|
+
let W = 1, H = 1, D = 1, R = 1, cx = 0, cy = 0, cloth = null, sand = null, sg = null, tmp = null, design = null, guide = null;
|
|
14
|
+
const PAL = { saffron: [232, 163, 23], crimson: [178, 30, 40], white: [240, 236, 226], blue: [30, 62, 150], green: [24, 130, 82], gold: [240, 200, 60], black: [24, 20, 22], pink: [232, 120, 140], sky: [90, 170, 220], orange: [236, 104, 30] };
|
|
15
|
+
// the design, as a colour at polar position (r 0..1 of the mandala's radius, angle a)
|
|
16
|
+
function paint(r, a, x, y) {
|
|
17
|
+
const S = Math.round(PARAMS.symmetry);
|
|
18
|
+
if (r < 0.06) return r < 0.035 ? PAL.gold : PAL.crimson;
|
|
19
|
+
if (r < 0.2) { const petal = 0.07 + 0.13 * Math.pow(Math.abs(Math.cos(a * S / 2)), 0.7); return r < petal ? (r > petal - 0.015 ? PAL.white : PAL.crimson) : PAL.saffron; }
|
|
20
|
+
if (r < 0.235) { const k = (a / (Math.PI * 2)) * 32; return Math.hypot((k - Math.round(k)) * 0.04, r - 0.218) < 0.011 ? PAL.white : PAL.blue; }
|
|
21
|
+
// the palace: a square with a T-shaped gate in each side; each quarter its direction's colour
|
|
22
|
+
const ax = Math.abs(x), ay = Math.abs(y), m = Math.max(ax, ay);
|
|
23
|
+
if (r < 0.54) {
|
|
24
|
+
const gate = Math.min(ax, ay) < 0.07 && m > 0.33 && m < 0.44;
|
|
25
|
+
if (m < 0.38 || gate) {
|
|
26
|
+
if (m > 0.355 || (gate && (m > 0.42 || Math.min(ax, ay) > 0.055))) return PAL.gold;
|
|
27
|
+
const q = ax > ay ? (x > 0 ? 0 : 2) : (y > 0 ? 1 : 3);
|
|
28
|
+
return [PAL.white, PAL.saffron, PAL.crimson, PAL.green][q];
|
|
29
|
+
}
|
|
30
|
+
return m < 0.39 ? PAL.black : PAL.blue;
|
|
31
|
+
}
|
|
32
|
+
if (r < 0.6) { const k = ((a / (Math.PI * 2)) * 48) % 1, tri = (r - 0.54) / 0.06 < 1 - Math.abs(k - 0.5) * 2; return tri ? (Math.floor((a / (Math.PI * 2)) * 48) % 2 ? PAL.orange : PAL.gold) : PAL.crimson; }
|
|
33
|
+
if (r < 0.68) { const k = ((a / (Math.PI * 2)) * 36) % 1, petal = 0.6 + 0.08 * Math.sin(k * Math.PI); return r < petal ? (Math.abs(k - 0.5) < 0.06 ? PAL.white : PAL.pink) : PAL.green; }
|
|
34
|
+
if (r < 0.76) { const k = ((a / (Math.PI * 2)) * 24) % 1, mid = Math.abs(r - 0.72); return (Math.abs(k - 0.5) < 0.05 && mid < 0.03) || (mid < 0.006 && Math.abs(k - 0.5) < 0.2) ? PAL.gold : PAL.blue; }
|
|
35
|
+
if (r < 0.84) { const flame = Math.sin(a * 60 + Math.sin(a * 15) * 2) * 0.018, k = (r - 0.76 + flame) / 0.08; return k < 0.33 ? PAL.gold : k < 0.66 ? PAL.orange : PAL.crimson; }
|
|
36
|
+
if (r < 0.86) return PAL.black;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const resize = () => {
|
|
40
|
+
D = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1;
|
|
41
|
+
canvas.width = Math.max(1, (W * D) | 0); canvas.height = Math.max(1, (H * D) | 0); ctx.setTransform(D, 0, 0, D, 0, 0);
|
|
42
|
+
R = Math.min(W, H) * 0.47 / 0.86; cx = W / 2; cy = H / 2;
|
|
43
|
+
// cloth: deep red, a woven texture
|
|
44
|
+
cloth = document.createElement('canvas'); cloth.width = canvas.width; cloth.height = canvas.height; const c = cloth.getContext('2d'); c.setTransform(D, 0, 0, D, 0, 0);
|
|
45
|
+
c.fillStyle = '#3a1416'; c.fillRect(0, 0, W, H);
|
|
46
|
+
for (let y = 0; y < H; y += 2) { c.fillStyle = `rgba(0,0,0,${0.05 + Math.random() * 0.05})`; c.fillRect(0, y, W, 1); }
|
|
47
|
+
for (let x = 0; x < W; x += 3) { c.fillStyle = `rgba(255,200,180,${Math.random() * 0.03})`; c.fillRect(x, 0, 1, H); }
|
|
48
|
+
const v = c.createRadialGradient(cx, cy, R * 0.3, cx, cy, Math.max(W, H) * 0.7); v.addColorStop(0, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,.45)'); c.fillStyle = v; c.fillRect(0, 0, W, H);
|
|
49
|
+
// chalk guides: the rings and the palace square, thin and pale
|
|
50
|
+
guide = document.createElement('canvas'); guide.width = canvas.width; guide.height = canvas.height; const g = guide.getContext('2d'); g.setTransform(D, 0, 0, D, 0, 0);
|
|
51
|
+
g.strokeStyle = 'rgba(240,230,215,.35)'; g.lineWidth = 0.8;
|
|
52
|
+
for (const k of [0.06, 0.2, 0.235, 0.54, 0.6, 0.68, 0.76, 0.84, 0.86]) { g.beginPath(); g.arc(cx, cy, k * R, 0, 7); g.stroke(); }
|
|
53
|
+
g.strokeRect(cx - 0.38 * R, cy - 0.38 * R, 0.76 * R, 0.76 * R);
|
|
54
|
+
for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2; g.beginPath(); g.moveTo(cx, cy); g.lineTo(cx + Math.cos(a) * 0.86 * R, cy + Math.sin(a) * 0.86 * R); g.stroke(); }
|
|
55
|
+
sand = document.createElement('canvas'); sand.width = canvas.width; sand.height = canvas.height; sg = sand.getContext('2d'); sg.setTransform(D, 0, 0, D, 0, 0);
|
|
56
|
+
tmp = document.createElement('canvas'); tmp.width = canvas.width; tmp.height = canvas.height;
|
|
57
|
+
restart();
|
|
58
|
+
};
|
|
59
|
+
let progress = 0, phase = 'build', phaseT = 0, theta = 0;
|
|
60
|
+
function restart() { sg.setTransform(1, 0, 0, 1, 0, 0); sg.clearRect(0, 0, sand.width, sand.height); sg.setTransform(D, 0, 0, D, 0, 0); progress = 0; phase = 'build'; phaseT = 0; }
|
|
61
|
+
let swept = [];
|
|
62
|
+
function gather() {
|
|
63
|
+
const img = sg.getImageData(0, 0, sand.width, sand.height).data, groups = new Map();
|
|
64
|
+
for (let n = 0; n < 70000; n++) {
|
|
65
|
+
const px = (Math.random() * sand.width) | 0, py = (Math.random() * sand.height) | 0, o = (py * sand.width + px) * 4;
|
|
66
|
+
if (img[o + 3] < 200) continue;
|
|
67
|
+
const key = `rgb(${img[o] & 0xf8},${img[o + 1] & 0xf8},${img[o + 2] & 0xf8})`;
|
|
68
|
+
let gp = groups.get(key); if (!gp) groups.set(key, gp = { style: key, g: [] });
|
|
69
|
+
const x = px / D - cx, y = py / D - cy;
|
|
70
|
+
gp.g.push(Math.hypot(x, y) / R, Math.atan2(y, x), 0.8 + Math.random() * 1.6, Math.random() ** 2 * 0.12); // radius, angle, speed, where it ends up in the pile
|
|
71
|
+
}
|
|
72
|
+
swept = [...groups.values()];
|
|
73
|
+
}
|
|
74
|
+
const ro = new ResizeObserver(resize); ro.observe(stage); resize();
|
|
75
|
+
|
|
76
|
+
let raf, last = performance.now();
|
|
77
|
+
function frame(now) {
|
|
78
|
+
const dt = Math.min(0.05, (now - last) / 1000) * PARAMS.speed; last = now; phaseT += dt;
|
|
79
|
+
if (phase === 'build') {
|
|
80
|
+
// the funnels work outward; each grain lands near one of them, mirrored round the circle
|
|
81
|
+
progress = Math.min(1, progress + dt / 21); theta += dt * 2.4;
|
|
82
|
+
const S = Math.max(3, Math.round(PARAMS.symmetry)), band = 0.07, rc = 0.03 + progress * 0.85;
|
|
83
|
+
const n = Math.round(PARAMS.grains), buckets = new Map();
|
|
84
|
+
for (let i = 0; i < n; i++) {
|
|
85
|
+
const k = i % S, a = theta * (0.8 + 0.4 * ((i >> 3) % 2)) + (k / S) * Math.PI * 2 + (Math.random() - 0.5) * 0.9, r = Math.max(0, rc - Math.random() * band);
|
|
86
|
+
const x = Math.cos(a) * r, y = Math.sin(a) * r, col = paint(r, ((a % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2), x, y);
|
|
87
|
+
if (!col) continue;
|
|
88
|
+
const j = (Math.random() * 3) | 0, key = col.join() + j; let b = buckets.get(key); if (!b) buckets.set(key, b = { col, j, pts: [] });
|
|
89
|
+
b.pts.push(cx + x * R, cy + y * R);
|
|
90
|
+
}
|
|
91
|
+
for (const { col, j, pts } of buckets.values()) {
|
|
92
|
+
const f = [0.82, 1, 1.12][j]; sg.fillStyle = `rgb(${Math.min(255, col[0] * f) | 0},${Math.min(255, col[1] * f) | 0},${Math.min(255, col[2] * f) | 0})`;
|
|
93
|
+
const s = Math.max(1, R / 300);
|
|
94
|
+
for (let i = 0; i < pts.length; i += 2) sg.fillRect(pts[i], pts[i + 1], s, s);
|
|
95
|
+
}
|
|
96
|
+
if (progress >= 1) { phase = 'admire'; phaseT = 0; }
|
|
97
|
+
} else if (phase === 'admire' && phaseT > 5) { phase = 'sweep'; phaseT = 0; gather(); }
|
|
98
|
+
else if (phase === 'sweep') {
|
|
99
|
+
// a brush comes in from the rim; every grain it passes is swept round and inward to the pile at the centre
|
|
100
|
+
const brush = 1 - phaseT / 4.2;
|
|
101
|
+
sg.setTransform(1, 0, 0, 1, 0, 0); sg.clearRect(0, 0, sand.width, sand.height); sg.setTransform(D, 0, 0, D, 0, 0);
|
|
102
|
+
const sz = Math.max(1, R / 300);
|
|
103
|
+
for (const b of swept) {
|
|
104
|
+
sg.fillStyle = b.style;
|
|
105
|
+
for (let i = 0; i < b.g.length; i += 4) {
|
|
106
|
+
let r = b.g[i], a = b.g[i + 1]; const end = b.g[i + 3];
|
|
107
|
+
if (r > brush * 0.86 && r > end) { const k = Math.min(1, dt * b.g[i + 2] * 0.55); r += (end - r) * k; a += dt * 3.2 * (1.1 - r) * b.g[i + 2] * 0.5; b.g[i] = r; b.g[i + 1] = a; }
|
|
108
|
+
sg.fillRect(cx + Math.cos(a) * r * R, cy + Math.sin(a) * r * R, sz, sz);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (phaseT > 6.5) restart();
|
|
112
|
+
}
|
|
113
|
+
ctx.drawImage(cloth, 0, 0, W, H);
|
|
114
|
+
if (phase === 'build') { ctx.globalAlpha = 1 - progress * 0.6; ctx.drawImage(guide, 0, 0, W, H); ctx.globalAlpha = 1; }
|
|
115
|
+
ctx.drawImage(sand, 0, 0, W, H);
|
|
116
|
+
if (phase === 'sweep' && phaseT > 5.5) { ctx.fillStyle = `rgba(58,20,22,${(phaseT - 5.5)})`; ctx.fillRect(0, 0, W, H); }
|
|
117
|
+
raf = requestAnimationFrame(frame);
|
|
118
|
+
}
|
|
119
|
+
raf = requestAnimationFrame(frame);
|
|
120
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
|
|
121
|
+
}
|