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
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT No Attribution
|
|
2
|
+
|
|
3
|
+
Copyright 2026 AJ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
|
6
|
+
software and associated documentation files (the "Software"), to deal in the Software
|
|
7
|
+
without restriction, including without limitation the rights to use, copy, modify,
|
|
8
|
+
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
|
|
9
|
+
permit persons to whom the Software is furnished to do so.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
12
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
13
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
14
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
15
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
16
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# motion-library
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Hand-made web animations: springs, page transitions, text, scroll, particles, fluids, chrome and shaders. Each one is a single plain-JavaScript file with no dependencies, and comes with notes on why it feels right.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx motion-library add confetti --pieces 300 --gravity 0.6
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
That writes `src/animations/confetti.js` (or `animations/confetti.js` if you have no `src` folder) with your settings in its `PARAMS` line.
|
|
10
|
+
|
|
11
|
+
## Use it
|
|
12
|
+
|
|
13
|
+
Every animation's default export draws into a container, sizes itself to it, and returns a cleanup function.
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import confetti from './animations/confetti.js';
|
|
17
|
+
|
|
18
|
+
const stop = confetti(document.querySelector('#hero')); // give the container a size
|
|
19
|
+
// later, e.g. when a component unmounts:
|
|
20
|
+
stop();
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
In React that's a `useEffect` that returns `stop`; in Vue, `onMounted` and `onUnmounted`; in a web component, `connectedCallback` and `disconnectedCallback`.
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
| Command | What it does |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `npx motion-library list [category]` | All animations, or one category |
|
|
30
|
+
| `npx motion-library info <id>` | What it does, how it works, the settings you can tune |
|
|
31
|
+
| `npx motion-library add <id> [--setting value]` | Copy it into your project, tuned. `--dir` picks the folder, `--html` writes a standalone page, `--force` overwrites |
|
|
32
|
+
| `npx motion-library prompt <id>` | A prompt for an AI coding assistant to fit it into your project |
|
|
33
|
+
|
|
34
|
+
Settings are checked against each animation's range: unknown names are ignored, and values outside the range are clamped (you'll see a note when that happens).
|
|
35
|
+
|
|
36
|
+
## Licence
|
|
37
|
+
|
|
38
|
+
MIT-0. Use it in anything, including paid work, change it freely, no credit needed.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Anticipation & follow-through: crouch before the jump, stretch in flight, antenna keeps going after landing.
|
|
2
|
+
const PARAMS = { anticipation: 0.25, overshoot: 0.18 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const canvas = document.createElement('canvas');
|
|
6
|
+
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
|
|
7
|
+
stage.append(canvas);
|
|
8
|
+
const ctx = canvas.getContext('2d');
|
|
9
|
+
let W = 0, H = 0;
|
|
10
|
+
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); };
|
|
11
|
+
const ro = new ResizeObserver(resize); ro.observe(stage); resize();
|
|
12
|
+
|
|
13
|
+
const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
|
|
14
|
+
const smooth = x => x * x * (3 - 2 * x);
|
|
15
|
+
// damped sine: the "wobble" after an impact
|
|
16
|
+
const wob = (t, z = 0.25, w = 18) => t <= 0 ? 0 : Math.exp(-z * w * t) * Math.sin(w * Math.sqrt(1 - z * z) * t);
|
|
17
|
+
|
|
18
|
+
const CYCLE = 2.6; let raf, t0 = performance.now();
|
|
19
|
+
function frame(now) {
|
|
20
|
+
const T = ((now - t0) / 1000) % (CYCLE * 2), dir = T < CYCLE ? 1 : -1, t = T % CYCLE;
|
|
21
|
+
const floor = H * 0.72, size = Math.min(W, H) * 0.16, span = W * 0.42;
|
|
22
|
+
const x0 = W / 2 - dir * span / 2;
|
|
23
|
+
// phases: rest .3 · crouch .35 · flight .55 · land and settle
|
|
24
|
+
const crouch = clamp((t - 0.3) / 0.35), flight = clamp((t - 0.65) / 0.55), landT = t - 1.2;
|
|
25
|
+
let x = x0, y = floor, sx = 1, sy = 1, lean = 0;
|
|
26
|
+
if (flight <= 0) { const c = smooth(crouch); sy = 1 - PARAMS.anticipation * c; sx = 1 + PARAMS.anticipation * 0.7 * c; lean = -dir * 0.12 * c; }
|
|
27
|
+
else if (flight < 1) {
|
|
28
|
+
x = x0 + dir * span * flight; y = floor - Math.sin(Math.PI * flight) * H * 0.38;
|
|
29
|
+
const v = Math.cos(Math.PI * flight); sy = 1 + 0.28 * Math.abs(v); sx = 1 / sy; lean = dir * 0.25 * v;
|
|
30
|
+
} else {
|
|
31
|
+
x = x0 + dir * span; const w = wob(landT, 0.3, 16);
|
|
32
|
+
sy = 1 - PARAMS.overshoot * 2 * w; sx = 1 / Math.max(0.4, sy);
|
|
33
|
+
}
|
|
34
|
+
// antenna lags behind the body: follow-through
|
|
35
|
+
const tip = flight > 0 && flight < 1 ? -dir * 0.5 * Math.cos(Math.PI * flight) : flight >= 1 ? dir * 0.9 * wob(landT, 0.18, 14) : dir * 0.3 * smooth(crouch);
|
|
36
|
+
|
|
37
|
+
ctx.fillStyle = '#e9edfb'; ctx.fillRect(0, 0, W, H);
|
|
38
|
+
ctx.fillStyle = '#c9d2f2'; ctx.fillRect(0, floor, W, H - floor);
|
|
39
|
+
const shadowS = 1 - (floor - y) / (H * 0.5);
|
|
40
|
+
ctx.fillStyle = `rgba(40,52,110,${0.25 * shadowS})`;
|
|
41
|
+
ctx.beginPath(); ctx.ellipse(x, floor + 2, size * 0.6 * shadowS * sx, size * 0.12, 0, 0, Math.PI * 2); ctx.fill();
|
|
42
|
+
|
|
43
|
+
ctx.save(); ctx.translate(x, y); ctx.rotate(lean); ctx.scale(sx, sy);
|
|
44
|
+
const w = size, h = size * 1.1;
|
|
45
|
+
ctx.strokeStyle = '#3b5bdb'; ctx.lineWidth = 3 / Math.max(sx, sy); ctx.lineCap = 'round';
|
|
46
|
+
ctx.beginPath(); ctx.moveTo(0, -h); ctx.quadraticCurveTo(tip * size * 0.3, -h - size * 0.35, tip * size * 0.6, -h - size * 0.55); ctx.stroke();
|
|
47
|
+
ctx.fillStyle = '#ff6b6b'; ctx.beginPath(); ctx.arc(tip * size * 0.6, -h - size * 0.55, size * 0.09, 0, Math.PI * 2); ctx.fill();
|
|
48
|
+
ctx.fillStyle = '#3b5bdb'; ctx.beginPath(); ctx.roundRect(-w / 2, -h, w, h, w * 0.42); ctx.fill();
|
|
49
|
+
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(dir * w * 0.14, -h * 0.62, w * 0.13, 0, Math.PI * 2); ctx.fill();
|
|
50
|
+
ctx.fillStyle = '#15171a'; ctx.beginPath(); ctx.arc(dir * w * 0.18, -h * 0.6, w * 0.06, 0, Math.PI * 2); ctx.fill();
|
|
51
|
+
ctx.restore();
|
|
52
|
+
raf = requestAnimationFrame(frame);
|
|
53
|
+
}
|
|
54
|
+
raf = requestAnimationFrame(frame);
|
|
55
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// 3D card flip: rotateY driven by a spring, with backface-visibility hiding the far face.
|
|
2
|
+
const PARAMS = { perspective: 900, stiffness: 160, damping: 13 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'cf-root';
|
|
7
|
+
root.innerHTML = `<style>
|
|
8
|
+
.cf-root { position: absolute; inset: 0; display: grid; place-items: center; background: #dfe6fb; font-family: "Mona Sans", system-ui, sans-serif; }
|
|
9
|
+
.cf-card { position: relative; width: min(46%, 190px); aspect-ratio: 5 / 7; transform-style: preserve-3d; }
|
|
10
|
+
.cf-face { position: absolute; inset: 0; border-radius: 14px; backface-visibility: hidden; display: grid; place-items: center; }
|
|
11
|
+
.cf-front { background: #fff; color: #3b5bdb; }
|
|
12
|
+
.cf-front b { font-size: 64px; font-weight: 800; font-stretch: 112%; line-height: 1; }
|
|
13
|
+
.cf-front span { position: absolute; left: 14px; top: 12px; font-weight: 700; font-size: 15px; }
|
|
14
|
+
.cf-back { background: #3b5bdb; transform: rotateY(180deg); overflow: hidden; }
|
|
15
|
+
.cf-back i { position: absolute; width: 140%; height: 18px; background: #5c7cfa; transform: rotate(-35deg); }
|
|
16
|
+
</style>
|
|
17
|
+
<div class="cf-card"><div class="cf-face cf-front"><span>A</span><b>A</b></div><div class="cf-face cf-back"></div></div>`;
|
|
18
|
+
stage.append(root);
|
|
19
|
+
root.style.perspective = PARAMS.perspective + 'px';
|
|
20
|
+
const card = root.querySelector('.cf-card');
|
|
21
|
+
const back = root.querySelector('.cf-back');
|
|
22
|
+
for (let i = 0; i < 9; i++) { const s = document.createElement('i'); s.style.top = (i * 34 - 60) + 'px'; back.append(s); }
|
|
23
|
+
|
|
24
|
+
let angle = 0, vel = 0, target = 0, last = performance.now(), raf;
|
|
25
|
+
const flip = setInterval(() => { target = target === 0 ? 180 : 0; }, 1800);
|
|
26
|
+
function frame(now) {
|
|
27
|
+
const dt = Math.min(0.032, (now - last) / 1000); last = now;
|
|
28
|
+
// semi-implicit Euler, 4 substeps for stability
|
|
29
|
+
for (let i = 0; i < 4; i++) {
|
|
30
|
+
const h = dt / 4, acc = -PARAMS.stiffness * (angle - target) - PARAMS.damping * vel;
|
|
31
|
+
vel += acc * h; angle += vel * h;
|
|
32
|
+
}
|
|
33
|
+
const lift = 1 + 0.06 * Math.abs(Math.sin(angle * Math.PI / 180));
|
|
34
|
+
card.style.transform = `rotateY(${angle}deg) scale(${lift})`;
|
|
35
|
+
raf = requestAnimationFrame(frame);
|
|
36
|
+
}
|
|
37
|
+
raf = requestAnimationFrame(frame);
|
|
38
|
+
return () => { clearInterval(flip); cancelAnimationFrame(raf); root.remove(); };
|
|
39
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Easing curves race: four dots, same distance, same time, four different easings.
|
|
2
|
+
const PARAMS = { duration: 1400, stiffness: 180, damping: 14 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'er-root';
|
|
7
|
+
root.innerHTML = `<style>
|
|
8
|
+
.er-root { position: absolute; inset: 0; background: #f4f6fb; display: grid; align-content: center; gap: 14px; padding: 0 9%; font-family: "Mona Sans", system-ui, sans-serif; }
|
|
9
|
+
.er-lane { display: grid; grid-template-columns: 72px 1fr; align-items: center; gap: 12px; }
|
|
10
|
+
.er-name { font-size: 13px; color: #3d4148; font-weight: 600; }
|
|
11
|
+
.er-track { position: relative; height: 26px; border-radius: 13px; background: #e3e8f4; }
|
|
12
|
+
.er-dot { position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: var(--c); will-change: transform; }
|
|
13
|
+
</style>`;
|
|
14
|
+
const lanes = [
|
|
15
|
+
{ name: 'linear', c: '#868e96', f: t => t },
|
|
16
|
+
{ name: 'ease-in', c: '#f08c00', f: t => t * t * t },
|
|
17
|
+
{ name: 'ease-out', c: '#3b5bdb', f: bezier(0.23, 1, 0.32, 1) },
|
|
18
|
+
{ name: 'spring', c: '#e03131', spring: true },
|
|
19
|
+
];
|
|
20
|
+
for (const l of lanes) {
|
|
21
|
+
const lane = document.createElement('div'); lane.className = 'er-lane';
|
|
22
|
+
lane.innerHTML = `<span class="er-name">${l.name}</span><div class="er-track"><div class="er-dot" style="--c:${l.c}"></div></div>`;
|
|
23
|
+
root.append(lane); l.dot = lane.querySelector('.er-dot'); l.track = lane.querySelector('.er-track');
|
|
24
|
+
}
|
|
25
|
+
stage.append(root);
|
|
26
|
+
|
|
27
|
+
// Closed-form spring (mass 1): position from 0 to 1 at time t seconds.
|
|
28
|
+
function spring(t) {
|
|
29
|
+
const k = PARAMS.stiffness, c = PARAMS.damping, w0 = Math.sqrt(k), z = c / (2 * w0);
|
|
30
|
+
if (z < 1) { const wd = w0 * Math.sqrt(1 - z * z); return 1 - Math.exp(-z * w0 * t) * (Math.cos(wd * t) + (z * w0 / wd) * Math.sin(wd * t)); }
|
|
31
|
+
return 1 - Math.exp(-w0 * t) * (1 + w0 * t);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let raf, t0 = performance.now();
|
|
35
|
+
const hold = 900;
|
|
36
|
+
function frame(now) {
|
|
37
|
+
const el = now - t0, total = PARAMS.duration + hold;
|
|
38
|
+
if (el > total) t0 = now;
|
|
39
|
+
const t = Math.min(1, (now - t0) / PARAMS.duration);
|
|
40
|
+
for (const l of lanes) {
|
|
41
|
+
const dist = l.track.clientWidth - 26;
|
|
42
|
+
const p = l.spring ? spring((now - t0) / 1000) : l.f(t);
|
|
43
|
+
l.dot.style.transform = `translateX(${p * dist}px)`;
|
|
44
|
+
}
|
|
45
|
+
raf = requestAnimationFrame(frame);
|
|
46
|
+
}
|
|
47
|
+
raf = requestAnimationFrame(frame);
|
|
48
|
+
return () => { cancelAnimationFrame(raf); root.remove(); };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// cubic-bezier(x1, y1, x2, y2) as a function of progress, solved with Newton's method.
|
|
52
|
+
function bezier(x1, y1, x2, y2) {
|
|
53
|
+
const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
|
|
54
|
+
const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
|
|
55
|
+
const X = t => ((ax * t + bx) * t + cx) * t, Y = t => ((ay * t + by) * t + cy) * t, dX = t => (3 * ax * t + 2 * bx) * t + cx;
|
|
56
|
+
return x => { let t = x; for (let i = 0; i < 6; i++) { const d = dX(t); if (Math.abs(d) < 1e-6) break; t -= (X(t) - x) / d; } return Y(Math.min(1, Math.max(0, t))); };
|
|
57
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Fade & scale in: cards rise from 94% scale with a strong ease-out, then leave faster than they came.
|
|
2
|
+
const PARAMS = { duration: 520, startScale: 0.94, stagger: 70 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'fs-root';
|
|
7
|
+
root.innerHTML = `
|
|
8
|
+
<style>
|
|
9
|
+
.fs-root { position: absolute; inset: 0; display: grid; place-content: center; gap: 10px; background: #e7ecfb; font-family: "Mona Sans", system-ui, sans-serif; }
|
|
10
|
+
.fs-card { width: min(300px, 74vw); display: flex; gap: 12px; align-items: center; padding: 12px 14px; background: #fff; border-radius: 12px; opacity: 0; }
|
|
11
|
+
.fs-dot { flex: none; width: 34px; height: 34px; border-radius: 10px; background: #3b5bdb; display: grid; place-items: center; color: #fff; font-weight: 700; font-size: 15px; }
|
|
12
|
+
.fs-card:nth-child(2) .fs-dot { background: #12b886; } .fs-card:nth-child(3) .fs-dot { background: #f08c00; }
|
|
13
|
+
.fs-t { font-size: 14px; font-weight: 600; color: #15171a; } .fs-s { font-size: 12.5px; color: #5d6166; margin-top: 2px; }
|
|
14
|
+
</style>
|
|
15
|
+
<div class="fs-card"><div class="fs-dot">R</div><div><div class="fs-t">Render finished</div><div class="fs-s">Shot 04 · 15 seconds</div></div></div>
|
|
16
|
+
<div class="fs-card"><div class="fs-dot">C</div><div><div class="fs-t">New comment</div><div class="fs-s">“Love the overshoot on frame 12”</div></div></div>
|
|
17
|
+
<div class="fs-card"><div class="fs-dot">E</div><div><div class="fs-t">Export ready</div><div class="fs-s">motion-reel.mp4 · 38 MB</div></div></div>`;
|
|
18
|
+
stage.append(root);
|
|
19
|
+
|
|
20
|
+
const cards = [...root.querySelectorAll('.fs-card')];
|
|
21
|
+
const easeOut = 'cubic-bezier(0.23, 1, 0.32, 1)';
|
|
22
|
+
let timers = [];
|
|
23
|
+
const later = (fn, ms) => timers.push(setTimeout(fn, ms));
|
|
24
|
+
|
|
25
|
+
function cycle() {
|
|
26
|
+
cards.forEach((card, i) => {
|
|
27
|
+
card.animate([
|
|
28
|
+
{ opacity: 0, transform: `translateY(14px) scale(${PARAMS.startScale})` },
|
|
29
|
+
{ opacity: 1, transform: 'none' },
|
|
30
|
+
], { duration: PARAMS.duration, delay: i * PARAMS.stagger, easing: easeOut, fill: 'both' });
|
|
31
|
+
});
|
|
32
|
+
const shown = PARAMS.duration + PARAMS.stagger * cards.length + 1300;
|
|
33
|
+
later(() => {
|
|
34
|
+
cards.forEach((card, i) => card.animate([
|
|
35
|
+
{ opacity: 1, transform: 'none' },
|
|
36
|
+
{ opacity: 0, transform: 'translateY(-6px) scale(0.98)' },
|
|
37
|
+
], { duration: Math.round(PARAMS.duration * 0.4), delay: i * 30, easing: easeOut, fill: 'both' }));
|
|
38
|
+
}, shown);
|
|
39
|
+
later(cycle, shown + PARAMS.duration * 0.4 + 400);
|
|
40
|
+
}
|
|
41
|
+
cycle();
|
|
42
|
+
|
|
43
|
+
return () => { timers.forEach(clearTimeout); cards.forEach(c => c.getAnimations().forEach(a => a.cancel())); root.remove(); };
|
|
44
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Stagger grid: each cell's delay grows with its distance from the origin, so 100 pops read as one ripple.
|
|
2
|
+
const PARAMS = { delayPerCell: 28, cols: 11 };
|
|
3
|
+
|
|
4
|
+
export default function (stage) {
|
|
5
|
+
const root = document.createElement('div');
|
|
6
|
+
root.className = 'sg-root';
|
|
7
|
+
root.innerHTML = `<style>
|
|
8
|
+
.sg-root { position: absolute; inset: 0; background: #15171a; display: grid; place-items: center; }
|
|
9
|
+
.sg-grid { display: grid; gap: 6px; }
|
|
10
|
+
.sg-cell { width: var(--s); height: var(--s); border-radius: 30%; background: #2a3550; transform: scale(0.45); }
|
|
11
|
+
</style><div class="sg-grid"></div>`;
|
|
12
|
+
stage.append(root);
|
|
13
|
+
const grid = root.querySelector('.sg-grid');
|
|
14
|
+
|
|
15
|
+
const cols = Math.round(PARAMS.cols);
|
|
16
|
+
const size = Math.max(8, Math.floor((stage.clientWidth * 0.8 - (cols - 1) * 6) / cols));
|
|
17
|
+
const rows = Math.max(3, Math.floor((stage.clientHeight * 0.8 + 6) / (size + 6)));
|
|
18
|
+
grid.style.gridTemplateColumns = `repeat(${cols}, ${size}px)`;
|
|
19
|
+
root.style.setProperty('--s', size + 'px');
|
|
20
|
+
const cells = [];
|
|
21
|
+
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
|
|
22
|
+
const el = document.createElement('div'); el.className = 'sg-cell'; grid.append(el); cells.push({ el, r, c });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const colours = ['#4c6ef5', '#15aabf', '#fab005', '#ff6b6b'];
|
|
26
|
+
let timers = [], n = 0;
|
|
27
|
+
function ripple() {
|
|
28
|
+
const or = Math.floor(Math.random() * rows), oc = Math.floor(Math.random() * cols);
|
|
29
|
+
const hot = colours[n++ % colours.length];
|
|
30
|
+
for (const cell of cells) {
|
|
31
|
+
const d = Math.hypot(cell.r - or, cell.c - oc);
|
|
32
|
+
cell.el.animate([
|
|
33
|
+
{ transform: 'scale(0.45)', background: '#2a3550' },
|
|
34
|
+
{ transform: 'scale(1)', background: hot, offset: 0.3 },
|
|
35
|
+
{ transform: 'scale(0.45)', background: '#2a3550' },
|
|
36
|
+
], { duration: 900, delay: d * PARAMS.delayPerCell, easing: 'cubic-bezier(0.33, 1, 0.68, 1)' });
|
|
37
|
+
}
|
|
38
|
+
timers.push(setTimeout(ripple, 900 + Math.hypot(rows, cols) * PARAMS.delayPerCell * 0.6));
|
|
39
|
+
}
|
|
40
|
+
ripple();
|
|
41
|
+
|
|
42
|
+
return () => { timers.forEach(clearTimeout); root.remove(); };
|
|
43
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Black hole: each pixel's ray is stepped through space and bent toward the hole a little at every step (gravity
|
|
2
|
+
// pulls light by an amount that grows as 1/r²). Rays that come too close fall in and stay black; the rest curve round
|
|
3
|
+
// and pick up the stars behind, which is why the background swirls into an Einstein ring. A hot accretion disk lies in
|
|
4
|
+
// the plane; because rays bend, you also see its far side arching over the top. The side turning toward you is
|
|
5
|
+
// brighter and bluer (Doppler beaming), the receding side dimmer and redder.
|
|
6
|
+
const PARAMS = { spin: 1, tilt: 0.18 };
|
|
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 });
|
|
13
|
+
if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
|
|
14
|
+
const vs = `#version 300 es
|
|
15
|
+
in vec2 p; out vec2 uv; void main() { uv = p * .5 + .5; gl_Position = vec4(p, 0, 1); }`;
|
|
16
|
+
const fs = `#version 300 es
|
|
17
|
+
precision highp float; in vec2 uv; out vec4 o;
|
|
18
|
+
uniform vec2 R; uniform float T, TILT, SPIN;
|
|
19
|
+
float hash(vec3 p) { p = fract(p * .3183099 + .1); p *= 17.; return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); }
|
|
20
|
+
float noise(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3. - 2. * f); float a = hash(vec3(i, 1)), b = hash(vec3(i + vec2(1, 0), 1)), c = hash(vec3(i + vec2(0, 1), 1)), d = hash(vec3(i + 1., 1)); return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); }
|
|
21
|
+
vec3 stars(vec3 d) { // a starfield plus a faint band of galaxy
|
|
22
|
+
vec3 c = vec3(0); vec3 g = d * 220.; vec3 id = floor(g); float h = hash(id);
|
|
23
|
+
if (h > .985) { float s = (h - .985) / .015; c += vec3(.8 + .2 * s, .85, 1.) * s * smoothstep(.5, .0, length(fract(g) - .5)) * 2.5; }
|
|
24
|
+
float band = exp(-pow(d.y * 3. + sin(d.x * 2.) * .3, 2.)) * (.5 + .5 * noise(d.xz * 9.));
|
|
25
|
+
return c + vec3(.35, .3, .45) * band * .25;
|
|
26
|
+
}
|
|
27
|
+
vec3 aces(vec3 x) { return clamp((x * (2.51 * x + .03)) / (x * (2.43 * x + .59) + .14), 0., 1.); }
|
|
28
|
+
void main() {
|
|
29
|
+
vec2 q = (uv * 2. - 1.) * vec2(R.x / R.y, 1.);
|
|
30
|
+
float ct = cos(TILT), st = sin(TILT);
|
|
31
|
+
vec3 ro = vec3(0, st * 9., -ct * 9.); // camera a little above the disk plane
|
|
32
|
+
vec3 f = normalize(-ro), r = normalize(cross(vec3(0, 1, 0), f)), u = cross(f, r);
|
|
33
|
+
vec3 rd = normalize(f * 1.7 + r * q.x + u * q.y), p = ro;
|
|
34
|
+
vec3 col = vec3(0); float alpha = 1.; bool fell = false;
|
|
35
|
+
const float RS = 1.; // event horizon radius
|
|
36
|
+
for (int i = 0; i < 260; i++) {
|
|
37
|
+
float rr = length(p);
|
|
38
|
+
if (rr < RS) { fell = true; break; }
|
|
39
|
+
float dt = clamp(rr * .05, .02, .4);
|
|
40
|
+
vec3 np = p + rd * dt;
|
|
41
|
+
// crossing the disk plane between 2.2 and 7 radii: add its light, tinted by speed toward the camera
|
|
42
|
+
if (p.y * np.y < 0.) {
|
|
43
|
+
vec3 c = mix(p, np, p.y / (p.y - np.y)); float d = length(c.xz);
|
|
44
|
+
if (d > 2.2 && d < 7.) {
|
|
45
|
+
float ang = atan(c.z, c.x) + T * SPIN * 1.2 / d; // inner rings turn faster
|
|
46
|
+
float lanes = .55 + .45 * noise(vec2(ang * 3., d * 2.5)) * (.6 + .4 * sin(d * 9. + ang * 2.));
|
|
47
|
+
vec3 vel = normalize(vec3(-c.z, 0, c.x)); // orbital direction
|
|
48
|
+
float dop = 1. + .75 * dot(vel, -rd); // beaming: brighter toward us
|
|
49
|
+
float heat = smoothstep(7., 2.2, d);
|
|
50
|
+
vec3 tint = mix(vec3(1., .35, .1), vec3(1., .85, .65), heat) * mix(vec3(1., .7, .5), vec3(.75, .85, 1.2), clamp(dop - .6, 0., 1.));
|
|
51
|
+
float a = lanes * smoothstep(2.2, 2.6, d) * smoothstep(7., 5.5, d);
|
|
52
|
+
col += alpha * tint * a * dop * dop * (1.2 + 2.2 * heat);
|
|
53
|
+
alpha *= 1. - a * .85;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
rd = normalize(rd - p / (rr * rr * rr) * RS * 1.5 * dt); // bend toward the hole
|
|
57
|
+
p = np;
|
|
58
|
+
if (rr > 30.) break;
|
|
59
|
+
}
|
|
60
|
+
if (!fell) col += alpha * stars(rd);
|
|
61
|
+
col = aces(col * .9);
|
|
62
|
+
col *= 1. - .3 * pow(length(uv - .5) * 1.3, 2.);
|
|
63
|
+
o = vec4(pow(col, vec3(1. / 2.2)), 1);
|
|
64
|
+
}`;
|
|
65
|
+
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; };
|
|
66
|
+
const prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, fs)); gl.bindAttribLocation(prog, 0, 'p'); gl.linkProgram(prog); gl.useProgram(prog);
|
|
67
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
|
68
|
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
69
|
+
const U = {}; ['R', 'T', 'TILT', 'SPIN'].forEach(n => U[n] = gl.getUniformLocation(prog, n));
|
|
70
|
+
const resize = () => { const w = stage.clientWidth || 1, h = stage.clientHeight || 1, d = Math.min(devicePixelRatio, Math.max(1, Math.sqrt(320000 / (w * h)))); canvas.width = w * d | 0; canvas.height = h * d | 0; gl.viewport(0, 0, canvas.width, canvas.height); };
|
|
71
|
+
const ro = new ResizeObserver(resize); ro.observe(stage); resize();
|
|
72
|
+
let raf; const t0 = performance.now();
|
|
73
|
+
function frame(now) {
|
|
74
|
+
const t = (now - t0) / 1000;
|
|
75
|
+
gl.uniform2f(U.R, canvas.width, canvas.height); gl.uniform1f(U.T, t); gl.uniform1f(U.TILT, PARAMS.tilt + Math.sin(t * 0.1) * 0.03); gl.uniform1f(U.SPIN, PARAMS.spin);
|
|
76
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
77
|
+
raf = requestAnimationFrame(frame);
|
|
78
|
+
}
|
|
79
|
+
raf = requestAnimationFrame(frame);
|
|
80
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
|
|
81
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Cloth reveal: a silk sheet falls over something on a plinth, drapes, and is whipped off by one corner to unveil a
|
|
2
|
+
// chrome sculpture. The sheet is a grid of points joined by distance constraints (neighbours, diagonals, and every
|
|
3
|
+
// second point, so it resists folding too sharply), moved with Verlet integration and solved a few times a frame;
|
|
4
|
+
// it collides with the sculpture, the plinth and the floor. Silk is lit with a soft sheen that runs along the folds.
|
|
5
|
+
const PARAMS = { grid: 46, stiffness: 1, speed: 1 };
|
|
6
|
+
|
|
7
|
+
export default function (stage) {
|
|
8
|
+
const canvas = document.createElement('canvas');
|
|
9
|
+
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
|
|
10
|
+
stage.append(canvas);
|
|
11
|
+
const gl = canvas.getContext('webgl2', { antialias: true });
|
|
12
|
+
if (!gl) { stage.textContent = 'This needs WebGL2.'; return () => canvas.remove(); }
|
|
13
|
+
const G = Math.max(16, Math.min(70, Math.round(PARAMS.grid))), NP = G * G, SIZE = 3.4, SC = [0, 1.55, 0], SR = 0.72, PED = 0.55;
|
|
14
|
+
const x = new Float32Array(NP * 3), px = new Float32Array(NP * 3), cons = [];
|
|
15
|
+
const id = (i, j) => j * G + i;
|
|
16
|
+
for (let j = 0; j < G; j++) for (let i = 0; i < G; i++) {
|
|
17
|
+
if (i < G - 1) cons.push(id(i, j), id(i + 1, j)); if (j < G - 1) cons.push(id(i, j), id(i, j + 1));
|
|
18
|
+
if (i < G - 1 && j < G - 1) { cons.push(id(i, j), id(i + 1, j + 1)); cons.push(id(i + 1, j), id(i, j + 1)); }
|
|
19
|
+
if (i < G - 2) cons.push(id(i, j), id(i + 2, j)); if (j < G - 2) cons.push(id(i, j), id(i, j + 2));
|
|
20
|
+
}
|
|
21
|
+
const C = new Int32Array(cons), rest = new Float32Array(C.length / 2);
|
|
22
|
+
function drop() {
|
|
23
|
+
for (let j = 0; j < G; j++) for (let i = 0; i < G; i++) { const k = id(i, j) * 3; x[k] = (i / (G - 1) - 0.5) * SIZE + (Math.random() - 0.5) * 0.002; x[k + 1] = 3.3; x[k + 2] = (j / (G - 1) - 0.5) * SIZE; px[k] = x[k]; px[k + 1] = x[k + 1]; px[k + 2] = x[k + 2]; }
|
|
24
|
+
for (let c = 0; c < rest.length; c++) { const a = C[c * 2] * 3, b = C[c * 2 + 1] * 3; rest[c] = Math.hypot(x[a] - x[b], x[a + 1] - x[b + 1], x[a + 2] - x[b + 2]); }
|
|
25
|
+
}
|
|
26
|
+
drop();
|
|
27
|
+
const collide = k => {
|
|
28
|
+
let dx = x[k] - SC[0], dy = x[k + 1] - SC[1], dz = x[k + 2] - SC[2], d = Math.hypot(dx, dy, dz), R = SR + 0.035;
|
|
29
|
+
if (d < R) { const s = R / d; x[k] = SC[0] + dx * s; x[k + 1] = SC[1] + dy * s; x[k + 2] = SC[2] + dz * s; px[k] += (x[k] - px[k]) * 0.6; px[k + 2] += (x[k + 2] - px[k + 2]) * 0.6; } // friction on the sculpture
|
|
30
|
+
const rr = Math.hypot(x[k], x[k + 2]);
|
|
31
|
+
if (x[k + 1] < SC[1] - SR * 0.6 && rr < PED + 0.03) { const s = (PED + 0.03) / rr; x[k] *= s; x[k + 2] *= s; }
|
|
32
|
+
if (x[k + 1] < 0.01) { x[k + 1] = 0.01; px[k] += (x[k] - px[k]) * 0.8; px[k + 2] += (x[k + 2] - px[k + 2]) * 0.8; }
|
|
33
|
+
};
|
|
34
|
+
function step(h, pin) {
|
|
35
|
+
for (let i = 0; i < NP; i++) {
|
|
36
|
+
const k = i * 3, vx = (x[k] - px[k]) * 0.995, vy = (x[k + 1] - px[k + 1]) * 0.995, vz = (x[k + 2] - px[k + 2]) * 0.995;
|
|
37
|
+
px[k] = x[k]; px[k + 1] = x[k + 1]; px[k + 2] = x[k + 2];
|
|
38
|
+
x[k] += vx; x[k + 1] += vy - 9.8 * h * h; x[k + 2] += vz;
|
|
39
|
+
}
|
|
40
|
+
const st = Math.min(1, PARAMS.stiffness);
|
|
41
|
+
for (let it = 0; it < 10; it++) {
|
|
42
|
+
for (let c = 0; c < rest.length; c++) {
|
|
43
|
+
const a = C[c * 2] * 3, b = C[c * 2 + 1] * 3, dx = x[b] - x[a], dy = x[b + 1] - x[a + 1], dz = x[b + 2] - x[a + 2], d = Math.hypot(dx, dy, dz) || 1e-6;
|
|
44
|
+
const f = (d - rest[c]) / d * 0.5 * st;
|
|
45
|
+
x[a] += dx * f; x[a + 1] += dy * f; x[a + 2] += dz * f; x[b] -= dx * f; x[b + 1] -= dy * f; x[b + 2] -= dz * f;
|
|
46
|
+
}
|
|
47
|
+
if (pin) { const k = id(G - 1, G - 1) * 3; x[k] = pin[0]; x[k + 1] = pin[1]; x[k + 2] = pin[2]; }
|
|
48
|
+
for (let i = 0; i < NP; i++) collide(i * 3);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
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 ENV = `vec3 env(vec3 d) {
|
|
60
|
+
vec3 c = mix(vec3(.03, .03, .04), vec3(.2, .2, .23), smoothstep(-.1, .7, d.y));
|
|
61
|
+
vec2 q = d.xz / max(d.y, .05);
|
|
62
|
+
c += vec3(2.4) * smoothstep(.06, .0, abs(q.x - .1) - .55) * smoothstep(.06, .0, abs(q.y + .2) - .16) * step(0., d.y);
|
|
63
|
+
float az = atan(d.x, d.z);
|
|
64
|
+
c += vec3(1., .55, .3) * 1.5 * smoothstep(.1, .02, abs(az - 1.9)) * smoothstep(-.05, .2, d.y) * smoothstep(.75, .3, d.y);
|
|
65
|
+
c += vec3(.35, .6, 1.) * 1.3 * smoothstep(.1, .02, abs(az + 1.6)) * smoothstep(-.05, .2, d.y) * smoothstep(.75, .3, d.y);
|
|
66
|
+
return c + vec3(1.1) * smoothstep(.4, .9, d.z) * smoothstep(-.3, .5, d.y) * .6;
|
|
67
|
+
}`;
|
|
68
|
+
const VS = `#version 300 es
|
|
69
|
+
layout(location = 0) in vec3 P; layout(location = 1) in vec3 Nn; uniform mat4 M; out vec3 n; out vec3 w; void main() { n = Nn; w = P; gl_Position = M * vec4(P, 1); }`;
|
|
70
|
+
const clothP = prog(VS, `#version 300 es
|
|
71
|
+
precision highp float; in vec3 n; in vec3 w; out vec4 o; uniform vec3 E;
|
|
72
|
+
void main() {
|
|
73
|
+
vec3 V = normalize(E - w), N = normalize(n); if (dot(N, V) < 0.) N = -N; // two-sided
|
|
74
|
+
vec3 L = normalize(vec3(-.4, .9, .5)), H = normalize(L + V);
|
|
75
|
+
float dif = max(dot(N, L), 0.) * .8 + .2, sheen = pow(1. - max(dot(N, V), 0.), 2.5), spec = pow(max(dot(N, H), 0.), 24.);
|
|
76
|
+
vec3 silk = vec3(.42, .03, .07);
|
|
77
|
+
vec3 c = silk * dif + vec3(.9, .3, .35) * sheen * .55 + vec3(1., .85, .85) * spec * .35;
|
|
78
|
+
c *= .6 + .4 * smoothstep(-.05, .6, w.y); // folds near the floor sit in shadow
|
|
79
|
+
o = vec4(pow(c, vec3(1. / 2.2)), 1);
|
|
80
|
+
}`);
|
|
81
|
+
const chromeP = prog(VS, `#version 300 es
|
|
82
|
+
precision highp float; in vec3 n; in vec3 w; out vec4 o; uniform vec3 E; uniform float MAT;
|
|
83
|
+
${ENV}
|
|
84
|
+
void main() {
|
|
85
|
+
vec3 V = normalize(E - w), N = normalize(n), R = reflect(-V, N);
|
|
86
|
+
vec3 c = MAT < .5 ? env(R) * vec3(.92, .94, .98) * (.6 + .4 * pow(1. - max(dot(N, V), 0.), 4.)) : vec3(.06, .055, .05) * (.4 + .6 * max(dot(N, normalize(vec3(-.4, .9, .5))), 0.)) + env(R) * .05;
|
|
87
|
+
o = vec4(pow(c / (1. + c * .25), vec3(1. / 2.2)), 1);
|
|
88
|
+
}`);
|
|
89
|
+
const floorP = prog(`#version 300 es
|
|
90
|
+
layout(location = 0) in vec2 P; uniform mat4 M; out vec2 g; void main() { g = P; gl_Position = M * vec4(P.x, 0, P.y, 1); }`, `#version 300 es
|
|
91
|
+
precision highp float; in vec2 g; out vec4 o; void main() { float r = length(g); vec3 c = vec3(.07, .065, .07) * (1. - .55 * exp(-r * r * 1.4)) * smoothstep(9., 2., r); o = vec4(pow(c, vec3(1. / 2.2)), 1); }`);
|
|
92
|
+
// meshes: the cloth (updated every frame), a sphere for the sculpture, a cylinder for the plinth
|
|
93
|
+
const idx = []; for (let j = 0; j < G - 1; j++) for (let i = 0; i < G - 1; i++) idx.push(id(i, j), id(i + 1, j), id(i, j + 1), id(i + 1, j), id(i + 1, j + 1), id(i, j + 1));
|
|
94
|
+
const cv = gl.createVertexArray(); gl.bindVertexArray(cv);
|
|
95
|
+
const cb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, cb); gl.bufferData(gl.ARRAY_BUFFER, NP * 24, gl.DYNAMIC_DRAW);
|
|
96
|
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12);
|
|
97
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(idx), gl.STATIC_DRAW);
|
|
98
|
+
const mesh = data => { const v = gl.createVertexArray(); gl.bindVertexArray(v); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); return { v, n: data.length / 6 }; };
|
|
99
|
+
// the sculpture: a sphere with a twisted band through it, i.e. a sphere whose radius ripples along a spiral
|
|
100
|
+
const sph = []; const LAT = 48, LON = 72;
|
|
101
|
+
const sp = (a, b) => { const th = (a / LAT) * Math.PI, ph = (b / LON) * Math.PI * 2, r = SR * (0.94 + 0.06 * Math.cos(ph * 3 + th * 6)); const d = [Math.sin(th) * Math.cos(ph), Math.cos(th), Math.sin(th) * Math.sin(ph)]; return d.map((v, i) => v * r + SC[i]); };
|
|
102
|
+
const nn = (a, b) => { const p = sp(a, b), q = sp(a + 0.01, b), r = sp(a, b + 0.01), u = q.map((v, i) => v - p[i]), v2 = r.map((v, i) => v - p[i]); const n = [u[1] * v2[2] - u[2] * v2[1], u[2] * v2[0] - u[0] * v2[2], u[0] * v2[1] - u[1] * v2[0]], l = Math.hypot(...n) || 1; const c = p.map((v, i) => v - SC[i]); return Math.sign(n[0] * c[0] + n[1] * c[1] + n[2] * c[2]) * 1 === -1 ? n.map(v => -v / l) : n.map(v => v / l); };
|
|
103
|
+
for (let a = 0; a < LAT; a++) for (let b = 0; b < LON; b++) for (const [i, j] of [[a, b], [a + 1, b], [a, b + 1], [a, b + 1], [a + 1, b], [a + 1, b + 1]]) sph.push(...sp(i, j), ...nn(Math.min(i, LAT - 0.02), j));
|
|
104
|
+
const cyl = []; for (let b = 0; b < 48; b++) { const a0 = (b / 48) * Math.PI * 2, a1 = ((b + 1) / 48) * Math.PI * 2, top = SC[1] - SR * 0.75; for (const [a, y] of [[a0, 0], [a1, 0], [a0, top], [a0, top], [a1, 0], [a1, top]]) cyl.push(Math.cos(a) * PED, y, Math.sin(a) * PED, Math.cos(a), 0, Math.sin(a)); }
|
|
105
|
+
const sphM = mesh(sph), cylM = mesh(cyl);
|
|
106
|
+
const fv = gl.createVertexArray(); gl.bindVertexArray(fv); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-12, -12, 12, -12, -12, 12, 12, -12, 12, 12, -12, 12]), gl.STATIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
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
|
+
let E = [0, 0, 0];
|
|
113
|
+
const matrix = t => {
|
|
114
|
+
const az = 0.5 + 0.15 * Math.sin(t * 0.2), el = 0.28, dist = 6.4; E = [Math.sin(az) * Math.cos(el) * dist, 1.0 + Math.sin(el) * dist, Math.cos(az) * Math.cos(el) * dist]; const T = [0, 1.1, 0];
|
|
115
|
+
const f = T.map((v, i) => v - E[i]), fl = Math.hypot(...f); for (let i = 0; i < 3; i++) f[i] /= fl;
|
|
116
|
+
const rl = Math.hypot(f[2], f[0]), r = [-f[2] / rl, 0, f[0] / rl], 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]];
|
|
117
|
+
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] * E[0] + r[1] * E[1] + r[2] * E[2]), -(u[0] * E[0] + u[1] * E[1] + u[2] * E[2]), f[0] * E[0] + f[1] * E[1] + f[2] * E[2], 1];
|
|
118
|
+
const F = 1 / Math.tan(0.38), A = W / H, n = 0.1, fa = 60, nf = 1 / (n - fa), 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);
|
|
119
|
+
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; }
|
|
120
|
+
return out;
|
|
121
|
+
};
|
|
122
|
+
const verts = new Float32Array(NP * 6), nrm = new Float32Array(NP * 3);
|
|
123
|
+
let raf, last = performance.now(), t = 0, c = 0, grab = null;
|
|
124
|
+
function frame(now) {
|
|
125
|
+
const dt = Math.min(0.033, (now - last) / 1000) * PARAMS.speed; last = now; t += dt; c += dt;
|
|
126
|
+
// falls and drapes (3.2 s), a corner is grabbed and whipped up and away (1.8 s), the sculpture alone (2.6 s), again
|
|
127
|
+
let pin = null;
|
|
128
|
+
if (c > 3.2 && c < 5) {
|
|
129
|
+
if (!grab) { const k = id(G - 1, G - 1) * 3; grab = [x[k], x[k + 1], x[k + 2]]; }
|
|
130
|
+
const u = (c - 3.2) / 1.8, e = u * u;
|
|
131
|
+
pin = [grab[0] + 6 * e, grab[1] + 4.5 * Math.sin(Math.min(1, u * 1.3) * Math.PI / 2) + 1.5 * e, grab[2] + 5 * e];
|
|
132
|
+
}
|
|
133
|
+
if (c > 7.6) { c = 0; grab = null; drop(); }
|
|
134
|
+
const sub = 2; for (let s = 0; s < sub; s++) step(dt / sub, pin);
|
|
135
|
+
// normals for the silk
|
|
136
|
+
nrm.fill(0);
|
|
137
|
+
for (let q = 0; q < idx.length; q += 3) {
|
|
138
|
+
const a = idx[q] * 3, b = idx[q + 1] * 3, d = idx[q + 2] * 3, ux = x[b] - x[a], uy = x[b + 1] - x[a + 1], uz = x[b + 2] - x[a + 2], vx = x[d] - x[a], vy = x[d + 1] - x[a + 1], vz = x[d + 2] - x[a + 2];
|
|
139
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
140
|
+
for (const k of [a, b, d]) { nrm[k] += nx; nrm[k + 1] += ny; nrm[k + 2] += nz; }
|
|
141
|
+
}
|
|
142
|
+
for (let i = 0; i < NP; i++) { verts.set([x[i * 3], x[i * 3 + 1], x[i * 3 + 2], nrm[i * 3], nrm[i * 3 + 1], nrm[i * 3 + 2]], i * 6); }
|
|
143
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H); gl.clearColor(0.025, 0.024, 0.027, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.enable(gl.DEPTH_TEST);
|
|
144
|
+
const M = matrix(t);
|
|
145
|
+
gl.useProgram(floorP.p); gl.uniformMatrix4fv(floorP.u.M, false, M); gl.bindVertexArray(fv); gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
146
|
+
gl.useProgram(chromeP.p); gl.uniformMatrix4fv(chromeP.u.M, false, M); gl.uniform3f(chromeP.u.E, ...E);
|
|
147
|
+
gl.uniform1f(chromeP.u.MAT, 0); gl.bindVertexArray(sphM.v); gl.drawArrays(gl.TRIANGLES, 0, sphM.n);
|
|
148
|
+
gl.uniform1f(chromeP.u.MAT, 1); gl.bindVertexArray(cylM.v); gl.drawArrays(gl.TRIANGLES, 0, cylM.n);
|
|
149
|
+
gl.useProgram(clothP.p); gl.uniformMatrix4fv(clothP.u.M, false, M); gl.uniform3f(clothP.u.E, ...E);
|
|
150
|
+
gl.bindVertexArray(cv); gl.bindBuffer(gl.ARRAY_BUFFER, cb); gl.bufferSubData(gl.ARRAY_BUFFER, 0, verts); gl.drawElements(gl.TRIANGLES, idx.length, gl.UNSIGNED_SHORT, 0);
|
|
151
|
+
gl.bindVertexArray(null); gl.disable(gl.DEPTH_TEST);
|
|
152
|
+
raf = requestAnimationFrame(frame);
|
|
153
|
+
}
|
|
154
|
+
raf = requestAnimationFrame(frame);
|
|
155
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); };
|
|
156
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// DNA double helix: two backbones winding round a common axis, a quarter turn apart in the minor/major groove way real
|
|
2
|
+
// DNA is, joined by base pairs (A–T, C–G). Everything is built as points in 3D, turned, projected and drawn back to
|
|
3
|
+
// front as shaded spheres, so it reads as solid without a 3D engine. A replication fork travels along it: there the
|
|
4
|
+
// strands peel apart, rungs split into their two halves, then zip back together behind it.
|
|
5
|
+
const PARAMS = { spin: 1, unzip: 1 };
|
|
6
|
+
|
|
7
|
+
export default function (stage) {
|
|
8
|
+
const canvas = document.createElement('canvas');
|
|
9
|
+
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
|
|
10
|
+
stage.append(canvas);
|
|
11
|
+
const ctx = canvas.getContext('2d');
|
|
12
|
+
let W = 0, H = 0;
|
|
13
|
+
const resize = () => { const d = Math.min(devicePixelRatio, 2); W = stage.clientWidth || 1; H = stage.clientHeight || 1; canvas.width = W * d; canvas.height = H * d; ctx.setTransform(d, 0, 0, d, 0, 0); };
|
|
14
|
+
const ro = new ResizeObserver(resize); ro.observe(stage); resize();
|
|
15
|
+
const BASES = { A: '#ff5d73', T: '#ffd166', C: '#4cc9f0', G: '#7bd389' }, PAIR = { A: 'T', T: 'A', C: 'G', G: 'C' };
|
|
16
|
+
const N = 44, seq = Array.from({ length: N }, () => 'ATCG'[Math.random() * 4 | 0]);
|
|
17
|
+
// a shaded sphere sprite per colour, made once
|
|
18
|
+
const ball = col => { const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d'), gr = g.createRadialGradient(24, 22, 2, 32, 32, 31); gr.addColorStop(0, '#fff'); gr.addColorStop(0.25, col); gr.addColorStop(1, 'rgba(0,0,0,.85)'); g.fillStyle = gr; g.beginPath(); g.arc(32, 32, 31, 0, 7); g.fill(); return c; };
|
|
19
|
+
const SPR = Object.fromEntries([...Object.entries(BASES), ['bb1', '#9aa7ff'], ['bb2', '#c7a0ff']].map(([k, v]) => [k, ball(v)]));
|
|
20
|
+
let raf; const t0 = performance.now();
|
|
21
|
+
function frame(now) {
|
|
22
|
+
const t = (now - t0) / 1000, S = Math.min(W, H), F = S * 2.4;
|
|
23
|
+
const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.7); bg.addColorStop(0, '#141a33'); bg.addColorStop(1, '#05060d');
|
|
24
|
+
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
|
|
25
|
+
const spin = t * 0.6 * PARAMS.spin, tilt = 1.05, ct = Math.cos(tilt), st = Math.sin(tilt);
|
|
26
|
+
const fork = ((t * 0.09) % 1.4 - 0.2) * N; // where the strands are open, travelling along
|
|
27
|
+
const items = [];
|
|
28
|
+
const put = (x, y, z, spr, r) => {
|
|
29
|
+
// tilt the helix's axis diagonally across the view, then project
|
|
30
|
+
const X = x * ct - y * st, Y = x * st + y * ct, k = F / (F + z * S * 0.5);
|
|
31
|
+
items.push({ x: W / 2 + X * S * 0.34 * k, y: H / 2 + Y * S * 0.34 * k, z, s: r * S * 0.045 * k, spr });
|
|
32
|
+
};
|
|
33
|
+
for (let i = 0; i < N; i++) {
|
|
34
|
+
const y = (i / (N - 1) - 0.5) * 2.6, a = i * 0.6 + spin;
|
|
35
|
+
const open = PARAMS.unzip * Math.max(0, 1 - Math.abs(i - fork) / 5) ** 1.5; // how far apart the strands are here
|
|
36
|
+
const R = 0.32 + open * 0.35, a2 = a + 2.4 + open * 0.8; // the second strand sits ~140° round (the grooves)
|
|
37
|
+
const p1 = [Math.cos(a) * R, y, Math.sin(a) * R], p2 = [Math.cos(a2) * R, y, Math.sin(a2) * R];
|
|
38
|
+
put(...p1, 'bb1', 0.55); put(...p2, 'bb2', 0.55);
|
|
39
|
+
const b1 = seq[i], b2 = PAIR[b1], gap = 0.5 + open * 0.5;
|
|
40
|
+
for (let k = 1; k <= 4; k++) { // the rung: each half its base's colour, split where open
|
|
41
|
+
const u = k / 9; put(p1[0] + (p2[0] - p1[0]) * u * gap, y, p1[2] + (p2[2] - p1[2]) * u * gap, b1, 0.36);
|
|
42
|
+
put(p2[0] + (p1[0] - p2[0]) * u * gap, y, p2[2] + (p1[2] - p2[2]) * u * gap, b2, 0.36);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
items.sort((a, b) => b.z - a.z);
|
|
46
|
+
for (const it of items) { const fog = Math.min(1, Math.max(0.35, 0.75 - it.z * 0.5)); ctx.globalAlpha = fog; ctx.drawImage(SPR[it.spr], it.x - it.s, it.y - it.s, it.s * 2, it.s * 2); }
|
|
47
|
+
ctx.globalAlpha = 1;
|
|
48
|
+
raf = requestAnimationFrame(frame);
|
|
49
|
+
}
|
|
50
|
+
raf = requestAnimationFrame(frame);
|
|
51
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.remove(); };
|
|
52
|
+
}
|