Flow Field
Dustin Adkison · 2 hours ago · v4
Code written by a VibePen user, not by VibePen. It runs sandboxed on an isolated origin with no network access. Nobody at VibePen has reviewed it — treat it like any page from a stranger, and never enter a password or payment details.
Twenty-two hundred particles drifting through a Perlin noise field, leaving luminous trails that never quite repeat. Pure canvas, no dependencies. Pauses when scrolled offscreen and renders a single still frame if you prefer reduced motion.
Source
6.1 KB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Flow Field</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #04060d; }
canvas { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
(() => {
"use strict";
var cv = document.getElementById("c");
var ctx = cv.getContext("2d", { alpha: false });
var TAU = Math.PI * 2;
var COUNT = 2600;
// Sea state is a superposition of swells, so the field is too: two long
// wave trains crossing at a slight angle, a shorter chop on top, and a
// little noise for texture. All lengths are relative to the canvas and
// all rates are per second, so it reads the same at any size or refresh.
var WAVES = 2.3; // long-swell wavelengths across the short edge
var CROSSING = 15.0; // seconds to drift across at mean speed
var LIFE_MIN = 1.4, LIFE_MAX = 5.0;
var FADE = 2.4;
var w = 0, h = 0, dpr = 1, k = 1, speed = 1, nscale = 1;
var px = new Float32Array(COUNT);
var py = new Float32Array(COUNT);
var life = new Float32Array(COUNT);
var tint = new Float32Array(COUNT);
var bias = new Float32Array(COUNT);
var rate = new Float32Array(COUNT);
var t = 0;
var perm = new Uint8Array(512);
(function seed() {
var i, j, s;
for (i = 0; i < 256; i++) perm[i] = i;
for (i = 255; i > 0; i--) {
j = (Math.random() * (i + 1)) | 0;
s = perm[i]; perm[i] = perm[j]; perm[j] = s;
}
for (i = 0; i < 256; i++) perm[256 + i] = perm[i];
})();
function fade(x) { return x * x * x * (x * (x * 6 - 15) + 10); }
function lerp(a, b, x) { return a + (b - a) * x; }
function grad(hash, x, y) {
var hh = hash & 3;
var u = hh < 2 ? x : y;
var v = hh < 2 ? y : x;
return ((hh & 1) ? -u : u) + ((hh & 2) ? -v : v);
}
function noise(x, y) {
var X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
x -= Math.floor(x); y -= Math.floor(y);
var u = fade(x), v = fade(y);
var A = perm[X] + Y, B = perm[X + 1] + Y;
return lerp(
lerp(grad(perm[A], x, y), grad(perm[B], x - 1, y), u),
lerp(grad(perm[A + 1], x, y - 1), grad(perm[B + 1], x - 1, y - 1), u),
v
);
}
function place(i) {
px[i] = Math.random() * w;
py[i] = Math.random() * h;
life[i] = LIFE_MIN + Math.random() * (LIFE_MAX - LIFE_MIN);
tint[i] = Math.random();
bias[i] = (Math.random() - 0.5) * 0.34; // own slight heading
rate[i] = 0.75 + Math.random() * 0.5; // own slight pace
}
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 1.5);
var nw = Math.max(1, Math.floor(cv.clientWidth * dpr));
var nh = Math.max(1, Math.floor(cv.clientHeight * dpr));
if (nw === w && nh === h) return;
w = nw; h = nh;
cv.width = w; cv.height = h;
var shortEdge = Math.min(w, h);
k = TAU * WAVES / shortEdge; // one spatial scale, so waves aren't stretched
nscale = 2.2 / shortEdge;
speed = Math.max(w, h) / CROSSING;
ctx.fillStyle = "#04060d";
ctx.fillRect(0, 0, w, h);
for (var i = 0; i < COUNT; i++) place(i);
}
function frame(dt) {
var decay = 1 - Math.exp(-FADE * dt);
ctx.fillStyle = "rgba(4, 6, 13, " + decay.toFixed(4) + ")";
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = "lighter";
ctx.lineWidth = dpr * 0.8;
var d = speed * dt;
for (var i = 0; i < COUNT; i++) {
var x = px[i], y = py[i];
var u = x * k, v = y * k;
// --- the swells -------------------------------------------------
var s1 = Math.sin(u * 1.00 + v * 0.28 - t * 0.85); // primary
var s2 = Math.sin(u * 0.63 - v * 0.74 + t * 0.52 + 1.9); // secondary, crossing
var chop = Math.sin(u * 2.60 + v * 1.70 + t * 1.90); // short chop
var a = 0.18 // mean heading, roughly rightward
+ s1 * 0.62
+ s2 * 0.46
+ chop * 0.15
+ noise(x * nscale, y * nscale + t * 0.1) * 0.30
+ bias[i];
// crests run, troughs drag — this is what draws the wave lines
var crest = (s1 * 0.6 + s2 * 0.4) * 0.5 + 0.5;
var sp = d * rate[i] * (0.55 + crest * 0.95);
var nx = x + Math.cos(a) * sp;
var ny = y + Math.sin(a) * sp;
// colour rides the crest: deep blue in the troughs, pale foam on top
var c = crest * crest;
var kk = tint[i];
var r = 26 + c * 150 + kk * 40;
var g = 90 + c * 130 + kk * 30;
var b = 150 + c * 95;
ctx.strokeStyle = "rgba(" + (r | 0) + "," + (g | 0) + "," + (b | 0) + "," + (0.22 + c * 0.4).toFixed(3) + ")";
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(nx, ny);
ctx.stroke();
px[i] = nx; py[i] = ny;
life[i] -= dt;
if (life[i] <= 0) { place(i); continue; }
// wrap horizontally so the drift never empties the left edge
if (nx > w + 20) { px[i] = -20; py[i] = Math.random() * h; }
else if (nx < -20) { px[i] = w + 20; py[i] = Math.random() * h; }
if (ny < -20 || ny > h + 20) place(i);
}
ctx.globalCompositeOperation = "source-over";
t += dt;
}
var raf = 0, running = false, last = 0;
function tick(now) {
resize();
var dt = last ? Math.min((now - last) / 1000, 0.05) : 1 / 60;
last = now;
frame(dt);
raf = requestAnimationFrame(tick);
}
function play() {
if (running) return;
running = true; last = 0;
raf = requestAnimationFrame(tick);
}
function stop() { running = false; cancelAnimationFrame(raf); }
resize();
var reduced = typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
for (var f = 0; f < 320; f++) frame(1 / 60);
} else {
if (typeof IntersectionObserver === "function") {
new IntersectionObserver(function (e) {
e[0].isIntersecting ? play() : stop();
}, { threshold: 0 }).observe(cv);
}
document.addEventListener("visibilitychange", function () {
document.hidden ? stop() : play();
});
play();
}
window.addEventListener("resize", resize);
})();
</script>
</body>
</html>