{"slug": "building-procedural-motion-for-the-web-geometry-particles-and-deterministic", "title": "Building Procedural Motion for the Web: Geometry, Particles, and Deterministic Canvas Animation", "summary": "A developer built Limot, a procedural web animation system that treats animations as parameterized functions of time rather than fixed assets like GIFs or Lottie files. The approach uses a render(time, parameters) model, Fibonacci sphere point distribution, and 2D Canvas rotation and perspective projection to create 3D-looking particle effects without WebGL.", "body_md": "I’ve always liked a particular kind of web animation: thin lines, geometric structures, particles, orbital motion, and visuals that feel somewhere between an engineering diagram and generative art.\n\nThe problem is that many animations on the web are distributed as finished assets.\n\nA GIF.\n\nA video.\n\nA Lottie file.\n\nOr a component with dozens of hard-coded constants buried somewhere inside it.\n\nWhile building [Limot](https://limot.dev/), I started thinking about animation differently:\n\nWhat if an animation was not an asset, but a function?\n\nSomething you could parameterize, reproduce, export, and reuse.\n\nThat idea eventually became the basic architecture behind the animations I’ve been building.\n\nIn this post, I want to break down some of the techniques behind that approach.\n\nA useful mental model for procedural animation is surprisingly simple:\n\n```\nframe = render(time, parameters)\n```\n\nInstead of thinking in terms of keyframes, think of the entire visual state as something that can be calculated from:\n\n```\n{\n  time,\n  width,\n  height,\n  parameters\n}\n```\n\nFor example:\n\n```\nfunction render(ctx, time, config) {\n  const {\n    speed,\n    particleCount,\n    particleSize,\n    scale\n  } = config;\n\n  const t = time * speed;\n\n  // calculate geometry\n  // update positions\n  // draw frame\n}\n```\n\nThen the browser preview becomes just one way of driving that renderer:\n\n```\nfunction loop(timestamp) {\n  ctx.clearRect(0, 0, width, height);\n\n  render(ctx, timestamp / 1000, config);\n\n  requestAnimationFrame(loop);\n}\n\nrequestAnimationFrame(loop);\n```\n\nThis separation becomes extremely useful later.\n\nThe renderer doesn’t need to know whether the frame is being:\n\nThe renderer just renders.\n\nThat sounds obvious, but it changes how you design the entire animation system.\n\nOne thing I learned pretty quickly is that complex-looking motion often starts with surprisingly simple geometry.\n\nTake a sphere made from particles.\n\nA naive solution would generate random points on a sphere.\n\nThe problem is that random spherical coordinates can produce visible clustering.\n\nInstead, you can use a **Fibonacci sphere**.\n\nOne implementation looks roughly like this:\n\n``` js\nfunction fibonacciSphere(count, radius = 1) {\n  const points = [];\n  const goldenAngle = Math.PI * (3 - Math.sqrt(5));\n\n  for (let i = 0; i < count; i++) {\n    const y = 1 - (i / (count - 1)) * 2;\n\n    const r = Math.sqrt(1 - y * y);\n    const theta = goldenAngle * i;\n\n    const x = Math.cos(theta) * r;\n    const z = Math.sin(theta) * r;\n\n    points.push({\n      x: x * radius,\n      y: y * radius,\n      z: z * radius\n    });\n  }\n\n  return points;\n}\n```\n\nThis produces a much more even distribution.\n\nNow you have something useful before animation even begins:\n\n```\n3D points\n    ↓\nrotation\n    ↓\nperspective projection\n    ↓\nCanvas coordinates\n```\n\nOnce the geometry is stable, rotation becomes relatively trivial.\n\nA point can be rotated around the Y axis using:\n\n``` js\nfunction rotateY(p, angle) {\n  const cos = Math.cos(angle);\n  const sin = Math.sin(angle);\n\n  return {\n    x: p.x * cos - p.z * sin,\n    y: p.y,\n    z: p.x * sin + p.z * cos\n  };\n}\n```\n\nThen project it onto a 2D canvas:\n\n``` js\nfunction project(p, cameraDistance = 4) {\n  const perspective =\n    cameraDistance / (cameraDistance - p.z);\n\n  return {\n    x: p.x * perspective,\n    y: p.y * perspective,\n    scale: perspective\n  };\n}\n```\n\nWith just these pieces you can already create a convincing 3D particle object using a 2D Canvas.\n\nOne of the effects I experimented with in [Limot's Particle Sphere](https://limot.dev/effects/particlesphere/) uses this idea for a particle sphere containing thousands of points, combined with drag inertia and cursor interaction.\n\nYou don’t always need WebGL for 3D-looking effects.\n\nCanvas 2D works surprisingly well when the geometry is simple enough.\n\nA common pipeline looks like this:\n\n```\ngenerate 3D geometry\n        ↓\ntransform / rotate\n        ↓\ncalculate depth\n        ↓\nperspective projection\n        ↓\nsort by depth\n        ↓\ndraw on Canvas\n```\n\nThe important part is often **depth sorting**.\n\nImagine particles orbiting around a black hole.\n\nIf you render them in their original order, particles behind the object may accidentally appear in front of it.\n\nInstead:\n\n``` js\nparticles.sort((a, b) => a.z - b.z);\n```\n\nThen render from back to front.\n\nYou can also use depth to modify appearance:\n\n``` js\nconst alpha = mapDepthToOpacity(p.z);\nconst size = baseSize * perspective;\n```\n\nThis creates a surprisingly strong illusion of volume.\n\nFor the [Black Hole experiment in Limot](https://limot.dev/effects/blackhole/), the animation combines orbiting particles, simulated inward motion, perspective, and depth sorting around the central void.\n\nThe interesting part is not really the black hole itself.\n\nIt is how far you can push:\n\n```\npoints + transforms + projection + sorting\n```\n\nbefore needing a full 3D engine.\n\nAnother interesting problem appears when particles follow curved paths.\n\nSuppose you have a parametric curve:\n\n```\nfunction curve(t) {\n  return {\n    x: Math.cos(t),\n    y: Math.sin(t) * 0.5\n  };\n}\n```\n\nYou might animate a particle like this:\n\n``` js\nconst position = curve(time);\n```\n\nBut there is a subtle problem.\n\nEqual changes in `t` do **not necessarily represent equal distances along the curve**.\n\nThe particle speeds up in some sections and slows down in others.\n\nSometimes that looks fine.\n\nBut for things like streamlines or field lines, you usually want constant apparent velocity.\n\nOne approach is to build an arc-length lookup table.\n\nFirst sample the curve:\n\n``` js\nconst samples = [];\n\nlet previous = curve(0);\nlet distance = 0;\n\nfor (let i = 0; i <= 500; i++) {\n  const t = i / 500;\n  const p = curve(t);\n\n  if (i > 0) {\n    distance += Math.hypot(\n      p.x - previous.x,\n      p.y - previous.y\n    );\n  }\n\n  samples.push({\n    t,\n    distance\n  });\n\n  previous = p;\n}\n```\n\nNow instead of asking:\n\n```\nwhere is t = 0.5?\n```\n\nyou can ask:\n\n```\nwhere is 50% of the total curve length?\n```\n\nThen interpolate between neighboring samples.\n\nThis technique is useful for effects like magnetic field visualizations, where small particles need to flow smoothly along curved field lines instead of visibly accelerating around sections of the curve.\n\nThe [Magnetic Field effect in Limot](https://limot.dev/effects/magnetic-field/) uses this idea of arc-length-based particle flow.\n\nGenerative animation usually needs randomness.\n\nBut `Math.random()` creates a problem.\n\nReload the animation:\n\n```\ndifferent particles\ndifferent composition\ndifferent motion\n```\n\nCapture a video:\n\n```\ndifferent again\n```\n\nGenerate a thumbnail:\n\n```\ndifferent again\n```\n\nThat makes reproducibility difficult.\n\nA seeded pseudo-random generator solves this.\n\n``` js\nfunction mulberry32(seed) {\n  return function () {\n    let t = seed += 0x6D2B79F5;\n\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n```\n\nNow:\n\n``` js\nconst random = mulberry32(12345);\n\nconst x = random();\nconst y = random();\n```\n\nwill always produce the same sequence.\n\nThis is extremely useful for visual tools.\n\nA configuration like:\n\n```\n{\n  seed: 12345,\n  particleCount: 4000,\n  speed: 0.8\n}\n```\n\ncan represent an exact visual state.\n\nThe same configuration can be loaded tomorrow and still generate the same composition.\n\nSeveral of the animations I’ve been working on use deterministic or seeded motion for exactly this reason. [Particle Breath](https://limot.dev/effects/particle-breath/), for example, is designed around deterministic standalone animation behavior.\n\nLinear interpolation is everywhere:\n\n```\nvalue += (target - value) * 0.1;\n```\n\nIt works.\n\nBut for physical interfaces, spring motion often feels much better.\n\nThe basic model is:\n\n```\nforce =\n  spring force\n  +\n  damping force\n```\n\nOr approximately:\n\n``` js\nconst springForce =\n  -stiffness * (position - target);\n\nconst dampingForce =\n  -damping * velocity;\n\nconst acceleration =\n  (springForce + dampingForce) / mass;\n\nvelocity += acceleration * dt;\nposition += velocity * dt;\n```\n\nNow you get parameters developers may recognize from animation libraries:\n\n```\n{\n  stiffness: 200,\n  damping: 20,\n  mass: 1\n}\n```\n\nThis is useful when elements should feel mechanical rather than merely interpolated.\n\nFor example, I use spring-like motion in a [Particle Cube experiment](https://limot.dev/effects/particle-cube/) where individual layers rotate by quarter turns before snapping back onto the cube's discrete grid.\n\nThat combination is interesting:\n\n```\ncontinuous physics\n       +\ndiscrete geometry\n```\n\nThe spring handles movement.\n\nThe grid defines the final valid state.\n\nOnce I started adding controls to animations, another architectural pattern became important.\n\nDon't make this:\n\n```\nUI controls\n\nand separately...\n\nanimation configuration\n```\n\nMake them two views of the same data.\n\n``` js\nconst schema = {\n  particleCount: {\n    type: \"number\",\n    min: 100,\n    max: 10000,\n    default: 3000\n  },\n\n  speed: {\n    type: \"number\",\n    min: 0,\n    max: 5,\n    default: 1\n  },\n\n  color: {\n    type: \"color\",\n    default: \"#ffffff\"\n  }\n};\n```\n\nFrom that schema you can generate:\n\n```\ncontrol panel\n     ↓\nruntime config\n     ↓\ncomponent props\n     ↓\nexport configuration\n```\n\nSo changing:\n\n```\nparticleCount = 8000\n```\n\nis not just changing a slider.\n\nIt changes the configuration used by every representation of the animation.\n\nThis is how a visual experiment starts becoming a reusable tool.\n\nIn [Limot](https://limot.dev/), animation parameters such as density, speed, line width, scale, geometry, interaction strength, and colors can be exposed directly in the editor and reused by exported code.\n\nThere is another important consequence of treating animation as a function of time.\n\nYour preview might run like this:\n\n```\nrequestAnimationFrame(loop);\n```\n\nBut an export should not depend on real elapsed browser time.\n\nSuppose you're generating a 5-second animation at 60 FPS.\n\nThat is exactly:\n\n``` js\nconst fps = 60;\nconst duration = 5;\n\nconst totalFrames = fps * duration;\n\nfor (let frame = 0; frame < totalFrames; frame++) {\n  const time = frame / fps;\n\n  render(ctx, time, config);\n\n  // capture frame\n}\n```\n\nNow rendering becomes deterministic.\n\nFrame 147 will always represent:\n\n```\n147 / 60\n```\n\nseconds.\n\nWhether the computer renders that frame in 2 ms or 200 ms doesn't matter.\n\nThis makes it possible to use the same visual model for both interactive previews and exported media.\n\nThat became an important design idea for Limot because the same animation can ultimately be used as live code or exported as a still or motion asset.\n\nYou can explore the available output options on the [Limot pricing page](https://limot.dev/pricing/).\n\nParticle animations make performance mistakes very visible.\n\nIf you're rendering thousands of points every frame, a few habits help a lot.\n\nBad:\n\n``` js\nfor (const particle of particles) {\n  calculateBaseGeometry(particle);\n  animateParticle(particle);\n  drawParticle(particle);\n}\n```\n\nBetter:\n\n``` js\nconst baseGeometry =\n  calculateGeometryOnce();\n\nfunction render(time) {\n  transformGeometry(baseGeometry, time);\n  draw();\n}\n```\n\nInstead of:\n\n``` js\nconst point = {\n  x,\n  y,\n  z\n};\n```\n\nthousands of times per frame, reuse objects or arrays where appropriate.\n\nTyped arrays are especially useful for large particle systems:\n\n``` js\nconst positions =\n  new Float32Array(particleCount * 3);\n```\n\nHigh-DPI rendering usually needs something like:\n\n``` js\nconst dpr = window.devicePixelRatio || 1;\n\ncanvas.width = width * dpr;\ncanvas.height = height * dpr;\n\ncanvas.style.width = `${width}px`;\ncanvas.style.height = `${height}px`;\n\nctx.scale(dpr, dpr);\n```\n\nWithout it, thin geometric lines often look blurry.\n\nWith very high-DPR devices, you may even want to cap it:\n\n``` js\nconst dpr =\n  Math.min(window.devicePixelRatio || 1, 2);\n```\n\nThere is always a trade-off between sharpness and fill rate.\n\nThat matters particularly for designs made almost entirely from 1px lines and tiny particles.\n\nAt first I was mostly making individual visual experiments.\n\nA particle sphere.\n\nA black hole.\n\nOrbital typography.\n\nMagnetic field lines.\n\nA Fourier drawing machine.\n\nBut eventually I realized that the more interesting problem was not:\n\nHow do I make this animation?\n\nIt was:\n\nHow do I make an animation reusable?\n\nThat adds an entirely different set of constraints.\n\nIt should be:\n\n```\nparameterized\ndeterministic\ninteractive\nresponsive\nexportable\nportable\n```\n\nAnd ideally the same effect should work as:\n\n```\na website background\na React component\na Vue component\na standalone JavaScript animation\na transparent image\na GIF\na video\n```\n\nThat is what I've been exploring with [Limot](https://limot.dev/).\n\nIt has grown into a collection of geometric, line-based, and particle-based web animations with live controls and reusable outputs.\n\nIf you're into creative coding, generative interfaces, Canvas, or just unnecessarily complicated ways of drawing dots on a screen, you might enjoy playing with it:\n\nYou can also jump directly into a few of the examples mentioned in this article:\n\nI'd also love to know which part deserves a deeper technical write-up next:\n\n**particle systems, fake 3D projection, deterministic animation, or video export?**", "url": "https://wpnews.pro/news/building-procedural-motion-for-the-web-geometry-particles-and-deterministic", "canonical_source": "https://dev.to/bimotalk/building-procedural-motion-for-the-web-geometry-particles-and-deterministic-canvas-animation-1d66", "published_at": "2026-09-22 02:19:19+00:00", "updated_at": "2026-09-22 02:54:02.371890+00:00", "lang": "en", "topics": ["generative-ai", "developer-tools"], "entities": ["Limot"], "alternates": {"html": "https://wpnews.pro/news/building-procedural-motion-for-the-web-geometry-particles-and-deterministic", "markdown": "https://wpnews.pro/news/building-procedural-motion-for-the-web-geometry-particles-and-deterministic.md", "text": "https://wpnews.pro/news/building-procedural-motion-for-the-web-geometry-particles-and-deterministic.txt", "jsonld": "https://wpnews.pro/news/building-procedural-motion-for-the-web-geometry-particles-and-deterministic.jsonld"}}