Home

09. Particle flow field · interpretation · Retro techniques

Flow field

Demos are animating. Following your system setting.
Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

Each particle integrates a procedural vector field: sines for the ambient flow, plus a moving singularity that acts as a slow cyclone. The pointer adds a second vortex wherever it sits. Trails are a cheap canvas fade. Flow-field particles are a generic generative-art idiom with no single origin; nothing historical is claimed.

Move the pointer over the preview to twist the field into a vortex.

Reference

Interpretation: this evokes a period look and does not reproduce one specific original.

Browser APIs

  • Canvas 2D
  • Pointer Events
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

Source

(function () {
  const PARAMS = {
    color: "#6ee7b7",
    speed: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [110, 231, 183];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }
  function rgbCss(hex, a) {
    const r = hexToRgb(hex);
    return "rgba(" + r[0] + ", " + r[1] + ", " + r[2] + ", " + a + ")";
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const COUNT = 1800;
  const parts = new Array(COUNT);
  function spawn(p, w, h) {
    p.x = Math.random() * w;
    p.y = Math.random() * h;
    p.vx = 0;
    p.vy = 0;
  }
  for (let i = 0; i < COUNT; i++) {
    parts[i] = { x: 0, y: 0, vx: 0, vy: 0 };
  }

  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.matches;
  const pointer = { x: 0, y: 0, active: false };
  let last = 0;
  let raf = 0;
  let seeded = false;

  canvas.addEventListener("pointermove", function (e) {
    const rect = canvas.getBoundingClientRect();
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    pointer.x = (e.clientX - rect.left) * dpr;
    pointer.y = (e.clientY - rect.top) * dpr;
    pointer.active = true;
    wake();
  }, { passive: true });
  canvas.addEventListener("pointerleave", function () { pointer.active = false; wake(); });

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
      if (!seeded) {
        for (let i = 0; i < COUNT; i++) spawn(parts[i], w, h);
        seeded = true;
      }
    }
  }

  function field(x, y, t, w, h) {
    const nx = x / w;
    const ny = y / h;
    let fx = Math.sin((ny * 7.2 + t * 0.35) * 1.1) + Math.cos((nx * 5.4 - t * 0.22) * 1.3);
    let fy = Math.cos((nx * 6.1 - t * 0.31) * 1.05) + Math.sin((ny * 4.8 + t * 0.27) * 1.2);
    const ax = w * (0.5 + 0.28 * Math.cos(t * 0.31));
    const ay = h * (0.5 + 0.24 * Math.sin(t * 0.24));
    const dx = x - ax;
    const dy = y - ay;
    const r2 = dx * dx + dy * dy + 1800;
    fx += -dy * 420 / r2;
    fy += dx * 420 / r2;
    if (pointer.active) {
      const pdx = x - pointer.x;
      const pdy = y - pointer.y;
      const pr2 = pdx * pdx + pdy * pdy + 900;
      fx += -pdy * 1400 / pr2;
      fy += pdx * 1400 / pr2;
    }
    return { fx, fy };
  }

  function frame(now) {
    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
    last = now;
    const t = now * 0.001;
    resize();
    const w = canvas.width;
    const h = canvas.height;

    ctx.fillStyle = reduced ? "#0b0c0e" : "rgba(11, 12, 14, 0.16)";
    ctx.fillRect(0, 0, w, h);

    ctx.lineWidth = 1.45;
    ctx.lineCap = "round";
    for (let i = 0; i < COUNT; i++) {
      const p = parts[i];
      const f = field(p.x, p.y, t, w, h);
      p.vx = p.vx * 0.92 + f.fx * 28 * dt * PARAMS.speed;
      p.vy = p.vy * 0.92 + f.fy * 28 * dt * PARAMS.speed;
      const speed = Math.hypot(p.vx, p.vy);
      const nx = p.x + p.vx;
      const ny = p.y + p.vy;
      const a = 0.28 + Math.min(0.72, speed * 0.04);
      ctx.strokeStyle = rgbCss(PARAMS.color, a);
      ctx.beginPath();
      ctx.moveTo(p.x, p.y);
      ctx.lineTo(nx, ny);
      ctx.stroke();
      p.x = nx;
      p.y = ny;
      if (p.x < -8 || p.y < -8 || p.x > w + 8 || p.y > h + 8) spawn(p, w, h);
    }

    if (!reduced) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();