Intro to flow fields

8 min read October 25, 2023 1506 words
Let’s see how to translate the theory from vector fields to amazing visual creations that can be devirtualized with a CNC.

The theory

I spent the last week playing with a physical concept that fascinates me and is widely used in the generative art world: flow fields (a.k.a. vector fields).

Simply put, a flow field is nothing but a lot of arrows thrown in a plane. If we want to be more technical, a flow field is a function that returns a vector for every point in the space.

Vector field

Flow fields have a variety of uses, from modelling wind, magnetic or gravitational forces to solving pathfinding in video-games (specifically the ones that use voxels, where A* is far from optimal).

For generative purposes, there are many ways you can define such a field, you can use perlin-noise or image gradients. For these cases, you just have to find a way to transform these black and white representations to vectors. For this post, I will use Peter de Jong Attractors. When used in flow fields, attractors are functions that tend to move particles traveling the field to a stable state, no matter where they start.

Credit to Paul Burke for this the beautiful visualization of a Peter de Jong Attractor

Credit to Paul Burke for this the beautiful visualization of a Peter de Jong Attractor

The simulation

Let’s move onto creating beautiful visuals with flow fields. Again, there are many ways we can approach the simulation but in this post I’ll focus on simulating particles in a 2D field. In order to do that, we will use a canvas node, create an array of particles - randomly distributed in the canvas - and draw them.

<html>
  <head>
    <!-- metas, imports, etc -->
  </head>
  <body>
    <canvas id="canvas"></canvas>
  </body>
</html>
// Get the canvas and its context to draw things on it
const canvas = document.querySelector("#canvas");
const context = canvas.getContext("2d");

// Make the canvas fill the whole screen
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

// Create and place 1000 particles randomly in the canvas with no velocity
const particles = [];
for (let i = 0; i < 1000; i += 1) {
  particles.push({
    x: Math.random() * window.innerWidth,
    y: Math.random() * window.innerHeight,
    vx: 0,
    vy: 0,
  });
}

// Render
context.fillStyle = "#000";
particles.forEach((particle) => {
  context.beginPath();
  context.arc(particle.x, particle.y, 1, 0, 2 * Math.PI);
  context.fill();
});

/*
 * WIP Simulation (keep reading! 👇)
 */

Slowly getting there, a thousand particles ready to be in motion

Slowly getting there, a thousand particles ready to be in motion

Once that’s done, we will start moving the particles following the flow field direction. For each particle, we get the vector in its position, move the particle following that vector and draw a line from the previous position of the particle, to the new one. We are adding some code to account for velocity, friction and so on, in order to make the simulation more physically accurate.

context.lineWidth = 0.1;

const render = () => {
  particles.forEach((particle) => {
    context.beginPath();
    context.moveTo(particle.x, particle.y);

    // Get the vector at the particle position
    const { force, angle } = getFlowFieldVector(particle.x, particle.y);

    // Update the particle velocity following the vector
    particle.vx += Math.cos(angle) * force;
    particle.vy += Math.sin(angle) * force;

    // Move the particle according to its velocity
    particle.x += particle.vx;
    particle.y += particle.vy;

    // Render the particle movement as a line
    context.lineTo(particle.x, particle.y);
    context.stroke();

    // Apply friction, reducing velocity to control that particles do not fly away
    particle.vx *= 0.95;
    particle.vy *= 0.95;

    // Wrap around the edges
    if (particle.x < 0) particle.x = window.innerWidth;
    if (particle.x > window.innerWidth) particle.x = 0;
    if (particle.y < 0) particle.y = window.innerHeight;
    if (particle.y > window.innerHeight) particle.y = 0;
  });

  requestAnimationFrame(render);
};

requestAnimationFrame(render);

We define the flow field function using a de Jong Attractor:

const a = Math.random() * 8 - 4;
const b = Math.random() * 8 - 4;
const c = Math.random() * 8 - 4;
const d = Math.random() * 8 - 4;

const getFlowFieldVector = (x, y) => {
  // Scale down x and y
  x = (x - window.innerWidth / 2) * 0.005;
  y = (y - window.innerHeight / 2) * 0.005;

  // The attactor returns a new x, y from the old one
  // Here we are using Johnny Svensson's variation
  const newX = d * Math.sin(a * y) - Math.sin(b * x);
  const newY = c * Math.cos(a * x) + Math.cos(b * y);

  // Get the vector from the old position to the new one
  // and return the vector attributes as force (magnitude) + angle (in radians)
  const vector = { x: newX - x, y: newY - y };
  return {
    force: Math.sqrt(vector.x * vector.x + vector.y * vector.y),
    angle: Math.atan2(vector.y, vector.x),
  };
};

Flow field animation

With this, we have the core of our simulation working. If you want to continue improving how it looks, you have an endless number of ways to do this, here are some ideas:

  • Color gradients - You can make particles change color with each simulation step, or change the color depending on how many particles travelled over a coordinate. You can pick gradients from Coolors and use a library like Chroma.js to define gradients.
  • Particle size - Why use a single particle size when you can play with it? Make particles grow in size when they are faster, younger or any other property you want.
  • Particle recreation - Once you play with flow fields for a while you will see some particles find a stable spot and stop contributing to the visuals. You can find particles that stopped moving and place them in a random spot again. Or why do we wrap the space? Once a particle leaves the screen we can recreate it somewhere else.
  • Dimensions - To make things simple we only represented the field in 2D space, but there is so much more you can do with these functions. You can simulate particles in a three dimensional space, or even better, account for time and make the flow field function slightly change over time.

The real world

Finally, let’s make this nice visualisation tangible! To me this is the most exciting (and sometimes frustrating) part of the process and the one with the hardest/more creative problems. Now we could just print the graphic in our classic printer but that wouldn’t be much fun. Instead, we’ll transform our simulation into something we can feed a CNC.

I use my CNC with two different tool heads nowadays: a pen plotter and a laser. We are going to use the first one to create a small piece of art.

In order to do that, we need a way to transform the particle paths into G-code (which is one of the many formats CNC usually understand). Unfortunately, our HTML canvas does not know anything about G-code, but it does know about SVG, which is a vector-based graphics format that can be easily transformed into robotic movements!

Now, if you try to transform our simulation into SVG directly you will notice that our output files are huge. That’s where libraries like simplify-path and fit-curve appear. The first one immensely reduces the number of intermediate points in our simulation and the second one finds bezier curves to fit the paths (and therefore smooths the resulting line).

const paths = [];
particles.forEach((particle) => {
  paths.push([]);

  // Simulate 1000 ticks in total
  for (let step = 0; step < 1000; step++) {
    // Simulate particle as we just did in the render function
    paths.push([{ x: particle.x, y: particle.y }]);
  }
});

// Simplify and smooth the resulting paths
const smoothedPaths = paths
  .map((path) => simplify(path, 10))
  .map((path) =>
    fitCurve(
      path.map((p) => [p.x, p.y]),
      200
    )
  );

// Poor man's SVG export
let svg = `<svg width="${config.width}" height="${config.height}" xmlns="http://www.w3.org/2000/svg">\n`;
for (const path of curvePaths) {
  const firstCurve = path[0];

  svg += `\t<path stroke-width="0.1" d="M ${firstCurve[0][0]} ${firstCurve[0][1]} `;
  for (const curve of path) {
    svg += `C ${curve[1][0]} ${curve[1][1]}, ${curve[2][0]} ${curve[2][1]}, ${curve[3][0]} ${curve[3][1]} `;
  }
  svg += `" stroke="black" fill="none" />\n`;
}
svg += `</svg>`;

The resulting SVG is quite big, but still manageable. With it, I used DrawingBot to transform the SVG into G-Code and fed it into the CNC. Here is the result with a field flow of only 200 particles (imagine one with thousands!)

Pen Plotter Flow Field

The bloopers

I also tried using the laser in a more creative way. I covered a piece of wood with many layers of paint, creating a fire-like gradient, to later use the laser to peel off the layers.

gradientengravingresult
Color gradientLaser timelapseLaser result

This worked moderately well in small tests but when I tried porting it to a bigger piece, it wouldn’t work.

References