VibePen

Flow Field

Dustin Adkison · 2 hours ago · v4

Open standalone ↗

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>

Back to the gallery