DEV Community

Cover image for Building Procedural Motion for the Web: Geometry, Particles, and Deterministic Canvas Animation
bimohxh
bimohxh

Posted on

Building Procedural Motion for the Web: Geometry, Particles, and Deterministic Canvas Animation

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.

The problem is that many animations on the web are distributed as finished assets.

A GIF.

A video.

A Lottie file.

Or a component with dozens of hard-coded constants buried somewhere inside it.

While building Limot, I started thinking about animation differently:

What if an animation was not an asset, but a function?

Something you could parameterize, reproduce, export, and reuse.

That idea eventually became the basic architecture behind the animations I’ve been building.

In this post, I want to break down some of the techniques behind that approach.


1. Treat animation as a function of time

A useful mental model for procedural animation is surprisingly simple:

frame = render(time, parameters)
Enter fullscreen mode Exit fullscreen mode

Instead of thinking in terms of keyframes, think of the entire visual state as something that can be calculated from:

{
  time,
  width,
  height,
  parameters
}
Enter fullscreen mode Exit fullscreen mode

For example:

function render(ctx, time, config) {
  const {
    speed,
    particleCount,
    particleSize,
    scale
  } = config;

  const t = time * speed;

  // calculate geometry
  // update positions
  // draw frame
}
Enter fullscreen mode Exit fullscreen mode

Then the browser preview becomes just one way of driving that renderer:

function loop(timestamp) {
  ctx.clearRect(0, 0, width, height);

  render(ctx, timestamp / 1000, config);

  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);
Enter fullscreen mode Exit fullscreen mode

This separation becomes extremely useful later.

The renderer doesn’t need to know whether the frame is being:

  • displayed in the browser
  • rendered inside a React component
  • rendered inside a Vue component
  • captured as a PNG
  • encoded into a GIF
  • rendered frame-by-frame into a video

The renderer just renders.

That sounds obvious, but it changes how you design the entire animation system.


2. Geometry is often more important than animation

One thing I learned pretty quickly is that complex-looking motion often starts with surprisingly simple geometry.

Take a sphere made from particles.

A naive solution would generate random points on a sphere.

The problem is that random spherical coordinates can produce visible clustering.

Instead, you can use a Fibonacci sphere.

One implementation looks roughly like this:

function fibonacciSphere(count, radius = 1) {
  const points = [];
  const goldenAngle = Math.PI * (3 - Math.sqrt(5));

  for (let i = 0; i < count; i++) {
    const y = 1 - (i / (count - 1)) * 2;

    const r = Math.sqrt(1 - y * y);
    const theta = goldenAngle * i;

    const x = Math.cos(theta) * r;
    const z = Math.sin(theta) * r;

    points.push({
      x: x * radius,
      y: y * radius,
      z: z * radius
    });
  }

  return points;
}
Enter fullscreen mode Exit fullscreen mode

This produces a much more even distribution.

Now you have something useful before animation even begins:

3D points
    ↓
rotation
    ↓
perspective projection
    ↓
Canvas coordinates
Enter fullscreen mode Exit fullscreen mode

Once the geometry is stable, rotation becomes relatively trivial.

A point can be rotated around the Y axis using:

function rotateY(p, angle) {
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);

  return {
    x: p.x * cos - p.z * sin,
    y: p.y,
    z: p.x * sin + p.z * cos
  };
}
Enter fullscreen mode Exit fullscreen mode

Then project it onto a 2D canvas:

function project(p, cameraDistance = 4) {
  const perspective =
    cameraDistance / (cameraDistance - p.z);

  return {
    x: p.x * perspective,
    y: p.y * perspective,
    scale: perspective
  };
}
Enter fullscreen mode Exit fullscreen mode

With just these pieces you can already create a convincing 3D particle object using a 2D Canvas.

One of the effects I experimented with in Limot's Particle Sphere uses this idea for a particle sphere containing thousands of points, combined with drag inertia and cursor interaction.


3. Canvas can fake a surprising amount of 3D

You don’t always need WebGL for 3D-looking effects.

Canvas 2D works surprisingly well when the geometry is simple enough.

A common pipeline looks like this:

generate 3D geometry
        ↓
transform / rotate
        ↓
calculate depth
        ↓
perspective projection
        ↓
sort by depth
        ↓
draw on Canvas
Enter fullscreen mode Exit fullscreen mode

The important part is often depth sorting.

Imagine particles orbiting around a black hole.

If you render them in their original order, particles behind the object may accidentally appear in front of it.

Instead:

particles.sort((a, b) => a.z - b.z);
Enter fullscreen mode Exit fullscreen mode

Then render from back to front.

You can also use depth to modify appearance:

const alpha = mapDepthToOpacity(p.z);
const size = baseSize * perspective;
Enter fullscreen mode Exit fullscreen mode

This creates a surprisingly strong illusion of volume.

For the Black Hole experiment in Limot, the animation combines orbiting particles, simulated inward motion, perspective, and depth sorting around the central void.

The interesting part is not really the black hole itself.

It is how far you can push:

points + transforms + projection + sorting
Enter fullscreen mode Exit fullscreen mode

before needing a full 3D engine.


4. Moving along a curve is harder than it looks

Another interesting problem appears when particles follow curved paths.

Suppose you have a parametric curve:

function curve(t) {
  return {
    x: Math.cos(t),
    y: Math.sin(t) * 0.5
  };
}
Enter fullscreen mode Exit fullscreen mode

You might animate a particle like this:

const position = curve(time);
Enter fullscreen mode Exit fullscreen mode

But there is a subtle problem.

Equal changes in t do not necessarily represent equal distances along the curve.

The particle speeds up in some sections and slows down in others.

Sometimes that looks fine.

But for things like streamlines or field lines, you usually want constant apparent velocity.

One approach is to build an arc-length lookup table.

First sample the curve:

const samples = [];

let previous = curve(0);
let distance = 0;

for (let i = 0; i <= 500; i++) {
  const t = i / 500;
  const p = curve(t);

  if (i > 0) {
    distance += Math.hypot(
      p.x - previous.x,
      p.y - previous.y
    );
  }

  samples.push({
    t,
    distance
  });

  previous = p;
}
Enter fullscreen mode Exit fullscreen mode

Now instead of asking:

where is t = 0.5?
Enter fullscreen mode Exit fullscreen mode

you can ask:

where is 50% of the total curve length?
Enter fullscreen mode Exit fullscreen mode

Then interpolate between neighboring samples.

This 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.

The Magnetic Field effect in Limot uses this idea of arc-length-based particle flow.


5. Deterministic randomness is incredibly useful

Generative animation usually needs randomness.

But Math.random() creates a problem.

Reload the animation:

different particles
different composition
different motion
Enter fullscreen mode Exit fullscreen mode

Capture a video:

different again
Enter fullscreen mode Exit fullscreen mode

Generate a thumbnail:

different again
Enter fullscreen mode Exit fullscreen mode

That makes reproducibility difficult.

A seeded pseudo-random generator solves this.

For example:

function mulberry32(seed) {
  return function () {
    let t = seed += 0x6D2B79F5;

    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);

    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
Enter fullscreen mode Exit fullscreen mode

Now:

const random = mulberry32(12345);

const x = random();
const y = random();
Enter fullscreen mode Exit fullscreen mode

will always produce the same sequence.

This is extremely useful for visual tools.

A configuration like:

{
  seed: 12345,
  particleCount: 4000,
  speed: 0.8
}
Enter fullscreen mode Exit fullscreen mode

can represent an exact visual state.

The same configuration can be loaded tomorrow and still generate the same composition.

Several of the animations I’ve been working on use deterministic or seeded motion for exactly this reason. Particle Breath, for example, is designed around deterministic standalone animation behavior.


6. Springs make interactions feel much better

Linear interpolation is everywhere:

value += (target - value) * 0.1;
Enter fullscreen mode Exit fullscreen mode

It works.

But for physical interfaces, spring motion often feels much better.

The basic model is:

force =
  spring force
  +
  damping force
Enter fullscreen mode Exit fullscreen mode

Or approximately:

const springForce =
  -stiffness * (position - target);

const dampingForce =
  -damping * velocity;

const acceleration =
  (springForce + dampingForce) / mass;

velocity += acceleration * dt;
position += velocity * dt;
Enter fullscreen mode Exit fullscreen mode

Now you get parameters developers may recognize from animation libraries:

{
  stiffness: 200,
  damping: 20,
  mass: 1
}
Enter fullscreen mode Exit fullscreen mode

This is useful when elements should feel mechanical rather than merely interpolated.

For example, I use spring-like motion in a Particle Cube experiment where individual layers rotate by quarter turns before snapping back onto the cube's discrete grid.

That combination is interesting:

continuous physics
       +
discrete geometry
Enter fullscreen mode Exit fullscreen mode

The spring handles movement.

The grid defines the final valid state.


7. The editor and the renderer should share the same schema

Once I started adding controls to animations, another architectural pattern became important.

Don't make this:

UI controls

and separately...

animation configuration
Enter fullscreen mode Exit fullscreen mode

Make them two views of the same data.

For example:

const schema = {
  particleCount: {
    type: "number",
    min: 100,
    max: 10000,
    default: 3000
  },

  speed: {
    type: "number",
    min: 0,
    max: 5,
    default: 1
  },

  color: {
    type: "color",
    default: "#ffffff"
  }
};
Enter fullscreen mode Exit fullscreen mode

From that schema you can generate:

control panel
     ↓
runtime config
     ↓
component props
     ↓
export configuration
Enter fullscreen mode Exit fullscreen mode

So changing:

particleCount = 8000
Enter fullscreen mode Exit fullscreen mode

is not just changing a slider.

It changes the configuration used by every representation of the animation.

This is how a visual experiment starts becoming a reusable tool.

In Limot, 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.


8. Preview rendering and export rendering should be separate

There is another important consequence of treating animation as a function of time.

Your preview might run like this:

requestAnimationFrame(loop);
Enter fullscreen mode Exit fullscreen mode

But an export should not depend on real elapsed browser time.

Suppose you're generating a 5-second animation at 60 FPS.

That is exactly:

const fps = 60;
const duration = 5;

const totalFrames = fps * duration;

for (let frame = 0; frame < totalFrames; frame++) {
  const time = frame / fps;

  render(ctx, time, config);

  // capture frame
}
Enter fullscreen mode Exit fullscreen mode

Now rendering becomes deterministic.

Frame 147 will always represent:

147 / 60
Enter fullscreen mode Exit fullscreen mode

seconds.

Whether the computer renders that frame in 2 ms or 200 ms doesn't matter.

This makes it possible to use the same visual model for both interactive previews and exported media.

That 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.

You can explore the available output options on the Limot pricing page.


9. Performance is mostly about avoiding unnecessary work

Particle animations make performance mistakes very visible.

If you're rendering thousands of points every frame, a few habits help a lot.

Precompute everything you can

Bad:

for (const particle of particles) {
  calculateBaseGeometry(particle);
  animateParticle(particle);
  drawParticle(particle);
}
Enter fullscreen mode Exit fullscreen mode

Better:

const baseGeometry =
  calculateGeometryOnce();

function render(time) {
  transformGeometry(baseGeometry, time);
  draw();
}
Enter fullscreen mode Exit fullscreen mode

Avoid allocations inside the frame loop

Instead of:

const point = {
  x,
  y,
  z
};
Enter fullscreen mode Exit fullscreen mode

thousands of times per frame, reuse objects or arrays where appropriate.

Typed arrays are especially useful for large particle systems:

const positions =
  new Float32Array(particleCount * 3);
Enter fullscreen mode Exit fullscreen mode

Respect device pixel ratio

High-DPI rendering usually needs something like:

const dpr = window.devicePixelRatio || 1;

canvas.width = width * dpr;
canvas.height = height * dpr;

canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;

ctx.scale(dpr, dpr);
Enter fullscreen mode Exit fullscreen mode

Without it, thin geometric lines often look blurry.

With very high-DPR devices, you may even want to cap it:

const dpr =
  Math.min(window.devicePixelRatio || 1, 2);
Enter fullscreen mode Exit fullscreen mode

There is always a trade-off between sharpness and fill rate.

That matters particularly for designs made almost entirely from 1px lines and tiny particles.


10. The interesting part is not the effect — it's the system

At first I was mostly making individual visual experiments.

A particle sphere.

A black hole.

Orbital typography.

Magnetic field lines.

A Fourier drawing machine.

But eventually I realized that the more interesting problem was not:

How do I make this animation?

It was:

How do I make an animation reusable?

That adds an entirely different set of constraints.

It should be:

parameterized
deterministic
interactive
responsive
exportable
portable
Enter fullscreen mode Exit fullscreen mode

And ideally the same effect should work as:

a website background
a React component
a Vue component
a standalone JavaScript animation
a transparent image
a GIF
a video
Enter fullscreen mode Exit fullscreen mode

That is what I've been exploring with Limot.

It has grown into a collection of geometric, line-based, and particle-based web animations with live controls and reusable outputs.

If 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:

Explore Limot →

You can also jump directly into a few of the examples mentioned in this article:

I'd also love to know which part deserves a deeper technical write-up next:

particle systems, fake 3D projection, deterministic animation, or video export?

Top comments (0)