{"slug":"flow","number":"09","title":"Flow field","era":"Vector field · 1996","summary":"A thousand particles following a curling field. Pointer makes a vortex.","description":"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.","apis":["Canvas 2D","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["particles","field"],"hasGlsl":false,"interaction":"Move the pointer over the preview to twist the field into a vortex.","url":"https://3d-retro.com/experiments/flow","example_url":"https://3d-retro.com/examples/flow.html","api_url":"https://3d-retro.com/api/v1/experiments/flow","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"A thousand particles following a curling field. Pointer makes a vortex.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/flow\" />\n  <title>Flow field · 3d retro graphics</title>\n  <style>\n    html, body {\n      margin: 0;\n      width: 100%;\n      height: 100%;\n      overflow: hidden;\n      background: #0b0c0e;\n      color: #e8e6e1;\n      position: absolute;\n      inset: 0;\n    }\n    canvas {\n      display: block;\n      position: absolute;\n      inset: 0;\n      width: 100%;\n      height: 100%;\n      touch-action: none;\n    }\n    .fallback {\n      position: absolute;\n      inset: 0;\n      display: grid;\n      place-items: center;\n      padding: 24px;\n      font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace;\n      text-align: center;\n      color: #e8e6e1;\n      background: #0b0c0e;\n    }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#6ee7b7\",\n    speed: 1\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [110, 231, 183];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n  function rgbCss(hex, a) {\n    const r = hexToRgb(hex);\n    return \"rgba(\" + r[0] + \", \" + r[1] + \", \" + r[2] + \", \" + a + \")\";\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  const COUNT = 1800;\n  const parts = new Array(COUNT);\n  function spawn(p, w, h) {\n    p.x = Math.random() * w;\n    p.y = Math.random() * h;\n    p.vx = 0;\n    p.vy = 0;\n  }\n  for (let i = 0; i < COUNT; i++) {\n    parts[i] = { x: 0, y: 0, vx: 0, vy: 0 };\n  }\n\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  const pointer = { x: 0, y: 0, active: false };\n  let last = 0;\n  let raf = 0;\n  let seeded = false;\n\n  canvas.addEventListener(\"pointermove\", function (e) {\n    const rect = canvas.getBoundingClientRect();\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    pointer.x = (e.clientX - rect.left) * dpr;\n    pointer.y = (e.clientY - rect.top) * dpr;\n    pointer.active = true;\n  }, { passive: true });\n  canvas.addEventListener(\"pointerleave\", function () { pointer.active = false; });\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) {\n      canvas.width = w;\n      canvas.height = h;\n      if (!seeded) {\n        for (let i = 0; i < COUNT; i++) spawn(parts[i], w, h);\n        seeded = true;\n      }\n    }\n  }\n\n  function field(x, y, t, w, h) {\n    const nx = x / w;\n    const ny = y / h;\n    let fx = Math.sin((ny * 7.2 + t * 0.35) * 1.1) + Math.cos((nx * 5.4 - t * 0.22) * 1.3);\n    let fy = Math.cos((nx * 6.1 - t * 0.31) * 1.05) + Math.sin((ny * 4.8 + t * 0.27) * 1.2);\n    const ax = w * (0.5 + 0.28 * Math.cos(t * 0.31));\n    const ay = h * (0.5 + 0.24 * Math.sin(t * 0.24));\n    const dx = x - ax;\n    const dy = y - ay;\n    const r2 = dx * dx + dy * dy + 1800;\n    fx += -dy * 420 / r2;\n    fy += dx * 420 / r2;\n    if (pointer.active) {\n      const pdx = x - pointer.x;\n      const pdy = y - pointer.y;\n      const pr2 = pdx * pdx + pdy * pdy + 900;\n      fx += -pdy * 1400 / pr2;\n      fy += pdx * 1400 / pr2;\n    }\n    return { fx, fy };\n  }\n\n  function frame(now) {\n    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    const t = now * 0.001;\n    resize();\n    const w = canvas.width;\n    const h = canvas.height;\n\n    ctx.fillStyle = reduced ? \"#0b0c0e\" : \"rgba(11, 12, 14, 0.16)\";\n    ctx.fillRect(0, 0, w, h);\n\n    ctx.lineWidth = 1.45;\n    ctx.lineCap = \"round\";\n    for (let i = 0; i < COUNT; i++) {\n      const p = parts[i];\n      const f = field(p.x, p.y, t, w, h);\n      p.vx = p.vx * 0.92 + f.fx * 28 * dt * PARAMS.speed;\n      p.vy = p.vy * 0.92 + f.fy * 28 * dt * PARAMS.speed;\n      const speed = Math.hypot(p.vx, p.vy);\n      const nx = p.x + p.vx;\n      const ny = p.y + p.vy;\n      const a = 0.28 + Math.min(0.72, speed * 0.04);\n      ctx.strokeStyle = rgbCss(PARAMS.color, a);\n      ctx.beginPath();\n      ctx.moveTo(p.x, p.y);\n      ctx.lineTo(nx, ny);\n      ctx.stroke();\n      p.x = nx;\n      p.y = ny;\n      if (p.x < -8 || p.y < -8 || p.x > w + 8 || p.y > h + 8) spawn(p, w, h);\n    }\n\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  if (reduced) {\n    resize();\n    frame(performance.now());\n  } else {\n    raf = requestAnimationFrame(frame);\n  }\n})();\n</script>\n</body>\n</html>\n"}