Magical vector fields
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
// 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()
}
})
// 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.
import { createNoise2D } from 'simplex-noise'
const noise2D = createNoise2D()
const getAngle = (x, y, frequency) => {
return noise2D(x * frequency, y * frequency) * Math.PI * 2
}
import { createNoise2D } from 'simplex-noise'
const noise2D = createNoise2D()
const getAngle = (x, y, frequency) => {
return noise2D(x * frequency, y * frequency) * Math.PI * 2
}
The noise2D
noise2D
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:
- Octaves - the number of layers of noise
- Lacunarity - frequency multiplier between octaves
- Persistence - amplitude (opacity) multiplier between octaves
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
}
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.
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)
}
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.
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)
}
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.
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)
}
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
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!
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…
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 🙏
Keir replied :
Nice, maps.probabletrain.com is built on something similar
Sally Lait 🦦 replied :
@charlottedann You are a WIZARD. (This is very good)
Dr. Mark Stanley Everitt bookmarked :
A really nice deep dive on noise and vector fields with inline demos to play with!