Skip to content

Magical vector fields

Published
Reading time
9 minutes
Exploring different forms of noise-based 2D vector fields and how to manipulate them.

This is the second article related to my first long-form generative art project Ceramics, you can read about making 3D surfaces with three.js in my first post here.

Ceramics uses a noise-based vector field* to determine the path of each carved stroke. Vector fields describe a direction at any point on a canvas, and curves are made by particles following the path of the vectors. In this article, I’m going to explore several ways of making vector fields using simplex noise and how to add more visual interest to them.

Each of the interactive demos has one canvas showing the value of the underlying noise function and arrows indicating the vector field, and another canvas showing how curves would follow the vector field.

Code snippet for rendering vector field curves
.js
// setup canvas
const canvas = document.createElement('canvas')
const width = 500
const height = 200
canvas.width = width
canvas.height = height
document.body.appendChild(canvas)
const c = canvas.getContext('2d')
 
const strokeMaxLength = width * 0.2
const strokeMinLength = width * 0.05
const strokeStep = width * 0.005
 
// circle pack some random points
let points = []
const gapBetweenPoints = width * 0.01
for (let i = 0; i < 5000; i++) {
  const x = Math.random() * width
  const y = Math.random() * height
 
  let hasHit = false
  points.forEach(([px, py]) => {
    const d = Math.sqrt((x - px) ** 2 + (y - py) ** 2)
    if (d < gapBetweenPoints) {
      hasHit = true
    }
  })
  if (!hasHit) points.push([x, y])
}
 
// loop through starting points
points.forEach(([initialX, initialY]) => {
  let x = initialX
  let y = initialY
  let strokePoints = [[x, y]]
 
  // add points to stroke
  for (let i = 0; i < strokeMaxLength / strokeStep; i++) {
    const angle = getAngle({
      x: x / width,
      y: y / width,
    })
    x += Math.cos(angle) * strokeStep
    y += Math.sin(angle) * strokeStep
    strokePoints.push([x, y])
  }
 
  // draw stroke
  if (strokePoints.length > strokeMinLength / strokeStep) {
    c.beginPath()
    strokePoints.forEach(([x, y]) => {
      if (x >= 0 && x <= width && y >= 0 && y <= height) {
        c.lineTo(x, y)
      }
    })
    c.stroke()
  }
})
.js
// setup canvas
const canvas = document.createElement('canvas')
const width = 500
const height = 200
canvas.width = width
canvas.height = height
document.body.appendChild(canvas)
const c = canvas.getContext('2d')
 
const strokeMaxLength = width * 0.2
const strokeMinLength = width * 0.05
const strokeStep = width * 0.005
 
// circle pack some random points
let points = []
const gapBetweenPoints = width * 0.01
for (let i = 0; i < 5000; i++) {
  const x = Math.random() * width
  const y = Math.random() * height
 
  let hasHit = false
  points.forEach(([px, py]) => {
    const d = Math.sqrt((x - px) ** 2 + (y - py) ** 2)
    if (d < gapBetweenPoints) {
      hasHit = true
    }
  })
  if (!hasHit) points.push([x, y])
}
 
// loop through starting points
points.forEach(([initialX, initialY]) => {
  let x = initialX
  let y = initialY
  let strokePoints = [[x, y]]
 
  // add points to stroke
  for (let i = 0; i < strokeMaxLength / strokeStep; i++) {
    const angle = getAngle({
      x: x / width,
      y: y / width,
    })
    x += Math.cos(angle) * strokeStep
    y += Math.sin(angle) * strokeStep
    strokePoints.push([x, y])
  }
 
  // draw stroke
  if (strokePoints.length > strokeMinLength / strokeStep) {
    c.beginPath()
    strokePoints.forEach(([x, y]) => {
      if (x >= 0 && x <= width && y >= 0 && y <= height) {
        c.lineTo(x, y)
      }
    })
    c.stroke()
  }
})

Flow fields

Flow fields are a common feature of generative art, widely popularised by Tyler Hobbs’ Fidenza project and documented in his essay on the subject.

I first used flow fields in 2020 in Abstract Puzzles and I loved how easy it was to create expressive and organic curves with a relatively simple mathematical formula.

Flow fields work by using a 2D noise function (I’ll be using the simplex-noise npm package) and mapping the value at any point to an angle.

.js
import { createNoise2D } from 'simplex-noise'
 
const noise2D = createNoise2D()
 
const getAngle = (x, y, frequency) => {
  return noise2D(x * frequency, y * frequency) * Math.PI * 2
}
.js
import { createNoise2D } from 'simplex-noise'
 
const noise2D = createNoise2D()
 
const getAngle = (x, y, frequency) => {
  return noise2D(x * frequency, y * frequency) * Math.PI * 2
}

The noise2Dnoise2D function outputs values from -1 to +1 but mostly centred around 0, so mapping the angle from -2π to +2π distributes the angles fairly evenly in every direction. More subtle and directional flows can be achieved by reducing the multiplier.

Octaval flow fields

This is a great intro to vector fields but basic flow fields create a limited range of forms and become overly familiar very quickly. One way to add more depth to them is by layering noise fields with different frequencies on top of one another and calculating the angle from their cumulative value.

There are a few terms we have to learn here:

.js
const getAngle = (x, y) => {
  let val = 0
  let lacuna = frequency
  let opacity = 1
 
  for (let i = 0; i < octaves; i++) {
    val += opacity * noise2D(x * lacuna, y * lacuna)
    lacuna *= lacunarity
    opacity *= persistence
  }
 
  return val * Math.PI * angleMultiplier
}
.js
const getAngle = (x, y) => {
  let val = 0
  let lacuna = frequency
  let opacity = 1
 
  for (let i = 0; i < octaves; i++) {
    val += opacity * noise2D(x * lacuna, y * lacuna)
    lacuna *= lacunarity
    opacity *= persistence
  }
 
  return val * Math.PI * angleMultiplier
}

The first octave describes the general direction of the flow field but it’s distorted by each successive octave. Tweaking the lacunarity and persistence values result in drastically different textures. They look much more organic than a single layer of noise, often resembling the paths of river tributaries.

Emergent flow fields

This method is a bit cheeky because it’s more of a particle system than a vector field (the angle at any point in the field doesn’t stay constant), but it only uses noise and can create some interesting effects when combined with collision-detection algorithms.

Instead of using 2D noise this uses 3D noise and the third parameter is based on time; for a static result the time parameter can be the index of each step in the curve.

import { createNoise3D } from 'simplex-noise'
 
const noise3D = createNoise3D()
 
const getAngle = (x, y, i) => {
  return noise3D(x, y, i * step * 0.01) * Math.PI * 2
}
import { createNoise3D } from 'simplex-noise'
 
const noise3D = createNoise3D()
 
const getAngle = (x, y, i) => {
  return noise3D(x, y, i * step * 0.01) * Math.PI * 2
}

This can also be octaval, and even more interesting effects can be achieved by varying the step value for each octave.

Curl noise

Curl noise is an alternative algorithm that differentiates the value of the noise, effectively pointing all the vectors along the contours of the noise field.

.js
const DERIVATIVE_SAMPLE = 0.001
 
const getAngle = (x, y) => {
  const x1 = noise2D(x + DERIVATIVE_SAMPLE, y)
  const x2 = noise2D(x - DERIVATIVE_SAMPLE, y)
  const y1 = noise2D(x, y + DERIVATIVE_SAMPLE)
  const y2 = noise2D(x, y - DERIVATIVE_SAMPLE)
  const xD = x2 - x1
  const yD = y2 - y1
  const angle = Math.atan2(yD, xD)
}
.js
const DERIVATIVE_SAMPLE = 0.001
 
const getAngle = (x, y) => {
  const x1 = noise2D(x + DERIVATIVE_SAMPLE, y)
  const x2 = noise2D(x - DERIVATIVE_SAMPLE, y)
  const y1 = noise2D(x, y + DERIVATIVE_SAMPLE)
  const y2 = noise2D(x, y - DERIVATIVE_SAMPLE)
  const xD = x2 - x1
  const yD = y2 - y1
  const angle = Math.atan2(yD, xD)
}

By adding π/2 to the angle we can point the paths to the high points in the noise field, or we can make a swirled effect with an intermediary angle.

Two distinct visual motifs appear – circles at the high points and X’s at the low points – but the advantage of curl noise over flow fields is that the lines never converge.

Octaval curl noise

We can add more interest to curl noise by layering the noise like we did with flow fields.

.js
const DERIVATIVE_SAMPLE = 0.001
 
const getOctavalNoise = (x, y) => {
  let val = 0
  let lacuna = frequency
  let opacity = 1
 
  for (let i = 0; i < octaves; i++) {
    val += opacity * noise2D(x * lacuna, y * lacuna)
    lacuna *= lacunarity
    opacity *= persistence
  }
}
 
const getAngle = (x, y) => {
  const x1 = getOctavalNoise(x + DERIVATIVE_SAMPLE, y)
  const x2 = getOctavalNoise(x - DERIVATIVE_SAMPLE, y)
  const y1 = getOctavalNoise(x, y + DERIVATIVE_SAMPLE)
  const y2 = getOctavalNoise(x, y - DERIVATIVE_SAMPLE)
 
  const xD = x2 - x1
  const yD = y2 - y1
 
  return Math.atan2(yD, xD)
}
.js
const DERIVATIVE_SAMPLE = 0.001
 
const getOctavalNoise = (x, y) => {
  let val = 0
  let lacuna = frequency
  let opacity = 1
 
  for (let i = 0; i < octaves; i++) {
    val += opacity * noise2D(x * lacuna, y * lacuna)
    lacuna *= lacunarity
    opacity *= persistence
  }
}
 
const getAngle = (x, y) => {
  const x1 = getOctavalNoise(x + DERIVATIVE_SAMPLE, y)
  const x2 = getOctavalNoise(x - DERIVATIVE_SAMPLE, y)
  const y1 = getOctavalNoise(x, y + DERIVATIVE_SAMPLE)
  const y2 = getOctavalNoise(x, y - DERIVATIVE_SAMPLE)
 
  const xD = x2 - x1
  const yD = y2 - y1
 
  return Math.atan2(yD, xD)
}

With a low initial frequency we can avoid most of those tell-tale curl noise O’s and X’s, resulting in really intricate contours.

Combining flow fields and curl noise

These two methods of making vector fields have very different aesthetics – flow fields have organic convergence and curl noise creates organic contours – I wondered whether I could combine them to create a totally unique field that has elements of each.

In curl noise we set the angle based on the derivative of the noise field, but the derivative is a vector so it also has a magnitude. My idea was to add small amounts of the flow field vector to the derivative and use the angle of that vector to guide the strokes. Since the magnitude of the derivative is variable, in areas of high change the curl will be more prominent, and in areas of low change the flow will be more prominent.

.js
const DERIVATIVE_SAMPLE = 0.001
 
const getAngle = (x, y) => {
  const flowAngle = noise2D(x * flowFrequency, y * flowFrequency) * Math.PI * 2
  let xD = Math.cos(flowAngle) * flowAmount * DERIVATIVE_SAMPLE
  let yD = Math.sin(flowAngle) * flowAmount * DERIVATIVE_SAMPLE
 
  x *= curlFrequency
  y *= curlFrequency
 
  const x1 = noise2D(x + DERIVATIVE_SAMPLE, y)
  const x2 = noise2D(x - DERIVATIVE_SAMPLE, y)
  const y1 = noise2D(x, y + DERIVATIVE_SAMPLE)
  const y2 = noise2D(x, y - DERIVATIVE_SAMPLE)
  xD += x2 - x1
  yD += y2 - y1
 
  return Math.atan2(yD, xD)
}
.js
const DERIVATIVE_SAMPLE = 0.001
 
const getAngle = (x, y) => {
  const flowAngle = noise2D(x * flowFrequency, y * flowFrequency) * Math.PI * 2
  let xD = Math.cos(flowAngle) * flowAmount * DERIVATIVE_SAMPLE
  let yD = Math.sin(flowAngle) * flowAmount * DERIVATIVE_SAMPLE
 
  x *= curlFrequency
  y *= curlFrequency
 
  const x1 = noise2D(x + DERIVATIVE_SAMPLE, y)
  const x2 = noise2D(x - DERIVATIVE_SAMPLE, y)
  const y1 = noise2D(x, y + DERIVATIVE_SAMPLE)
  const y2 = noise2D(x, y - DERIVATIVE_SAMPLE)
  xD += x2 - x1
  yD += y2 - y1
 
  return Math.atan2(yD, xD)
}

I love that this algorithm creates a wide range of effects depending on how much flow is added; low-frequency flow can be kinked using high-frequency curl, and low-frequency curl can be bizarrely distorted by small amounts of flow. I think that this takes the best of both worlds because there is some convergence but it’s not finite. It can be further enhanced by adding octaves to the flow and noise fields.

Final thoughts

Thanks for following along on this curvy journey into the various vector field methods I used in Ceramics. There are plenty of other ways of making organic-looking lines with code – using physics simulations like boids or reaction diffusion, external input like images or sensors, or even trigonometric functions – but there’s something pure about noise-based methods that I’m particularly drawn to.

Although I’ve shared a very basic method for rendering lines guided by vector fields, the sky’s the limit on how they can be used to create generative art. Using colour, different stroke styles and thicknesses, controlling the starting point of the lines, collisions, layering, animation, or using them to make 3D art like I have in Ceramics.

I hope this article inspires you to think about how vector fields can be incorporated into your own art practice, or inspires you to start making generative art if it’s new to you!

Likes and comments

  1. Todl
  2. haroot
  3. jebus
  4. 🐋
  5. Nicolas Giethlen
  6. Varun Vachhar
  7. Amelia Wattenberger 🪷
  8. Daniele Ciminieri
  9. Chris Johnson
  10. 古柳GuLiu
  11. Yann de Perrot
  12. Alex Slade 🐘
  13. jml
  14. mello
  15. murmurs
  16. Arthur Yidi
  17. Sally Lait 🦦
  1. Paul Hebert

    Paul Hebert replied :

    @charlottedann This is a great article!

    Octaval curl noise in particular seems perfect for an idea that's been bouncing around my head.

    Thanks for sharing!

  2. Varun Vachhar

    Varun Vachhar replied :

    So good! 👏🏽 Also, I must have missed this one earlier. But it's a fascinating technique. You're essentially creating dynamic bump maps. charlottedann.com/article/cerami…

  3. Matt Ström

    Matt Ström replied :

    oh! this is the perfect essay! i've been banging my head against the question of how to make noise fields with loops and swirls! thank you so so much 🙏

  4. Keir

    Keir replied :

    Nice, maps.probabletrain.com is built on something similar

  5. Sally Lait 🦦

    Sally Lait 🦦 replied :

    @charlottedann You are a WIZARD. (This is very good)

  6. Dr. Mark Stanley Everitt

    Dr. Mark Stanley Everitt bookmarked :

    A really nice deep dive on noise and vector fields with inline demos to play with!