Randomness and Noise

Randomness is the raw material of generative art, and Solandra builds it in properly: every SCanvas has a seeded pseudo-random number generator. The same seed always produces exactly the same picture, which means you can reproduce, iterate on, and export the exact variation you like. Change the seed, get a sibling artwork.

All the methods below live on SCanvas and draw from that seeded generator.

The basics

s.random() // uniform number in [0, 1)
s.uniformRandomInt({ from: 3, to: 6 }) // integer: 3, 4, 5 or 6
s.uniformRandomInt({ to: 6, inclusive: false }) // 0..5
s.randomPoint() // a random point on the canvas
s.randomAngle() // uniform in [0, 2π)
s.randomPolarity() // -1 or 1
s.uniformGridPoint({ minX: 0, maxX: 4, minY: 0, maxY: 4 }) // random integer point
s.background(220, 25, 12)
s.times(150, () => {
  s.setFillColor(s.uniformRandomInt({ from: 150, to: 220 }), 80, 60, 0.8)
  s.fill(
    new Star({
      at: s.randomPoint(),
      r: 0.01 + 0.04 * s.random(),
      n: s.uniformRandomInt({ from: 4, to: 7 }),
      a: s.randomAngle(),
    })
  )
})

Working with collections

sample picks one element from an array, samples picks n (with replacement), and shuffle reorders an array in place:

const palette = [200, 215, 340, 45]
s.sample(palette) // e.g. 340
s.samples(3, palette) // e.g. [45, 200, 45]
s.shuffle([...palette]) // e.g. [215, 45, 200, 340]
s.background(0, 0, 96)
const hues = [200, 215, 340, 45]
s.forTiling({ n: 9, type: "square", margin: 0.05 }, ([x, y], [dX]) => {
  s.setFillColor(s.sample(hues), 70, s.sample([45, 55, 65]))
  s.fill(new Square({ at: [x + dX * 0.05, y + dX * 0.05], s: dX * 0.9 }))
})

perturb

perturb({ at, magnitude }) nudges a point by a uniform random offset up to magnitude / 2 in each direction; the quickest way to make something regular feel hand-made:

s.background(35, 40, 94)
s.lineWidth = 0.005
s.setStrokeColor(25, 60, 30)
s.forTiling({ n: 12, type: "square" }, ([x, y], [dX, dY]) => {
  s.draw(
    SimplePath.withPoints([
      [x, y],
      [x + dX, y],
      [x + dX, y + dY],
      [x, y + dY],
    ])
      .close()
      .transformLoopedPoints((pt) => s.perturb({ at: pt, magnitude: 0.02 }))
  )
})

Other distributions

Uniform randomness often looks too random. Solandra also offers:

  • gaussian({ mean, sd }) — normally distributed values, which cluster around the mean
  • poisson(lambda) — non-negative integers with mean (and variance) lambda, good for "how many things here?" decisions
s.background(0, 0, 12)
s.times(400, () => {
  const x = s.gaussian({ mean: 0.5, sd: 0.12 })
  const y = s.gaussian({ mean: 0.5, sd: 0.12 })
  const d = v.distance([x, y], [0.5, 0.5])
  s.setFillColor(200 + d * 300, 80, 60, 0.7)
  s.fill(new Circle({ at: [x, y], r: 0.012 }))
})

Also see proportionately and doProportion on the Iteration page for weighted random choices, and forPoissonDiskPoints for evenly spread random points.

Perlin noise

Random numbers are independent of each other; noise is randomness with continuity: nearby inputs give nearby outputs. Solandra exports a 2D Perlin noise function, perlin2(x, y), returning values in roughly [-1, 1]. Scale your inputs to control the frequency (zoomed-in inputs change slowly, zoomed-out ones quickly).

import { perlin2 } from "solandra"
 
s.background(0, 0, 96)
s.forTiling({ n: 30, type: "square" }, ([x, y], [dX], [cX, cY]) => {
  const n = perlin2(x * 3, y * 3)
  s.setFillColor(190 + n * 60, 70, 50)
  s.withTranslation([cX, cY], () => {
    s.withRotation(n * Math.PI, () => {
      s.fill(
        new Rect({ at: [0, 0], w: dX * 0.9, h: dX * 0.18, align: "center" })
      )
    })
  })
})

A classic use is a flow field: use noise as an angle everywhere and let particles follow it:

s.background(220, 40, 14)
s.lineWidth = 0.003
s.times(120, (i) => {
  let pt = s.randomPoint()
  const path = SimplePath.startAt(pt)
  s.times(30, () => {
    const a = perlin2(pt[0] * 2.5, pt[1] * 2.5) * Math.PI * 2
    pt = v.add(pt, [0.008 * Math.cos(a), 0.008 * Math.sin(a)])
    path.addPoint(pt)
  })
  s.setStrokeColor(170 + i, 70, 60, 0.7)
  s.draw(path.chaiken({ n: 2 }))
})

Fractal noise

Perlin noise is smooth at exactly one scale, which is why hand-rolled terrain and clouds tend to look soft and samey. fbm2 (fractional Brownian motion) sums several octaves of perlin2, each one at a higher frequency and lower amplitude than the last, so the large shapes survive whilst fine detail piles up on top of them. The result is scaled back into roughly [-1, 1], so it drops into anywhere perlin2 fits.

  • octaves — how many layers (default 4); one octave is exactly perlin2
  • persistence — how much quieter each octave is than the last (default 0.5); higher is rougher
  • lacunarity — how much finer each octave is than the last (default 2)
import { fbm2 } from "solandra"
 
s.background(205, 45, 70)
s.forTiling({ n: 60, type: "square" }, ([x, y], [dX, dY]) => {
  const n = fbm2(x * 3, y * 3, { octaves: 6, persistence: 0.55 })
  s.setFillColor(210 - n * 30, 40 + n * 20, 55 + n * 45)
  s.fill(new Rect({ at: [x, y], w: dX * 1.2, h: dY * 1.2 }))
})

Side by side with a single octave the difference is the detail, not the shape: the same hills, but with texture on them.

s.background(30, 30, 12)
s.lineWidth = 0.004
s.range({ from: 0.1, to: 0.9, n: 24 }, (y) => {
  const line = SimplePath.withPoints(
    s.build(s.range, { from: 0, to: 1, n: 80 }, (x) => [
      x,
      y * s.meta.bottom + fbm2(x * 2.5, y * 2.5, { octaves: 5 }) * 0.07,
    ])
  )
  s.setStrokeColor(40, 70, 85, 0.8)
  s.draw(line)
})

Like perlin2, fbm2 is a pure function of its coordinates, not of the sketch's random number generator: the same point always gives the same value.

Curl noise and flow fields

The obvious way to build a flow field is to take a noise value as an angle and follow it. Do that and everything eventually drains into the same few places: such a field has sources and sinks. curl2 takes the curl of the noise instead, (∂n/∂y, -∂n/∂x), which is divergence free — nothing accumulates anywhere, so lines following it swirl endlessly around each other.

It takes the same octaves, persistence and lacunarity as fbm2 (one octave by default, i.e. plain perlin2), plus epsilon, the step used for the derivative. What matters is the vector's direction; its length depends on how fast the noise is changing.

import { curl2, v } from "solandra"
 
s.background(220, 25, 12)
s.lineWidth = 0.002
s.forTiling({ n: 22, type: "square" }, (_, [dX], [cX, cY]) => {
  const [uX, uY] = v.normalize(curl2(cX * 2.5, cY * 2.5))
  s.setStrokeColor(190 + uX * 60, 60, 70)
  s.draw(
    SimplePath.withPoints([
      [cX, cY],
      [cX + uX * dX, cY + uY * dX],
    ])
  )
})

Rather than drawing the field, follow it: SimplePath.flowLine starts somewhere and repeatedly steps in the direction the field points in. It samples the field for direction only, so every step is the same length and any function of a point will do. until stops a line early, most usefully when it wanders off the canvas.

s.background(215, 30, 10)
s.lineWidth = 0.003
s.times(120, () => {
  const from = s.randomPoint()
  s.setStrokeColor(180 + from[0] * 80, 70, 60, 0.7)
  s.draw(
    SimplePath.flowLine({
      from,
      field: ([x, y]) => curl2(x * 2.5, y * 2.5),
      n: 120,
      step: 0.005,
      until: (at) => !s.inDrawing(at),
    })
  )
})

Nothing ties flowLine to noise: the field is just a function from a point to a direction. This one pulls everything into a circling orbit around the centre.

s.background(45, 30, 95)
s.lineWidth = 0.004
s.aroundCircle({ n: 24, r: 0.42 }, (from, i) => {
  s.setStrokeColor(10 + i * 6, 65, 50, 0.8)
  s.draw(
    SimplePath.flowLine({
      from,
      // at right angles to the centre, so it circles rather than falls in
      field: (at) => v.rotate(v.subtract(s.meta.center, at), Math.PI / 2.2),
      n: 200,
      step: 0.005,
    })
  )
})

Worley noise

Perlin and its fractal cousins give soft, cloudy noise. worley2 gives structure. It divides space into cells, drops a feature point in each, and answers "how far is the nearest one?". That single idea produces scales, cobbles, cracked mud, stained glass and cells under a microscope.

Coordinates are in cells, so worley2(x * 8, y * 8) puts eight cells across the canvas. The value is a distance, roughly in [0, 1]: zero on a feature point, largest in the awkward gaps between them.

s.forTiling({ n: 150, type: "square" }, ([x, y], [dX, dY]) => {
  const d = worley2(x * 7, y * 7)
  s.setFillColor(25 + d * 25, 55, 12 + d * 55)
  s.fill(new Rect({ at: [x, y], w: dX, h: dY }))
})

feature picks which distance you get. "difference" — the second nearest point minus the nearest — falls to zero exactly on the boundary between two cells, so it draws the cracks rather than the cells:

s.background(205, 25, 96)
s.forTiling({ n: 200, type: "square" }, ([x, y], [dX, dY]) => {
  const d = worley2(x * 5, y * 5, { feature: "difference" })
  if (d > 0.14) return
  s.setFillColor(215, 45, 20, 1 - d / 0.14)
  s.fill(new Rect({ at: [x, y], w: dX, h: dY }))
})

Two more knobs change the character entirely. metric decides what "near" means: "euclidean" (the default) gives round cells, "manhattan" diamonds, "chebyshev" squares. jitter says how far a feature point may stray from the middle of its cell, from 1 (anywhere, the default) down to 0 (a perfectly regular grid).

s.background(175, 35, 12)
s.forTiling({ n: 160, type: "square" }, ([x, y], [dX, dY], at) => {
  const d = worley2(at[0] * 9, at[1] * 9, { metric: "chebyshev", jitter: 0.35 })
  s.setFillColor(170 + d * 40, 55, 15 + d * 65)
  s.fill(new Rect({ at: [x, y], w: dX, h: dY }))
})

To colour each cell as a whole rather than shading by distance, ask which cell a point is in. worleyCell2 gives the cell's coordinates, its feature point, a stable id (handy modulo something for a colour) and both distances — and every point in a cell gets the same answer.

s.background(0, 0, 10)
s.forTiling({ n: 160, type: "square" }, ([x, y], [dX, dY], at) => {
  const { id, f1, f2 } = worleyCell2(at[0] * 8, at[1] * 8, { jitter: 0.9 })
  if (f2 - f1 < 0.04) return // grouting between the tiles
  s.setFillColor(190 + (id % 100), 55, 30 + (id % 45))
  s.fill(new Rect({ at: [x, y], w: dX, h: dY }))
})

Seeds and reproducibility

The seed is supplied when the SCanvas is created (in this site's canvases there's a refresh control for it; with the raw API it's a constructor argument). Within a sketch you can also rewind determinism explicitly:

s.resetRandomNumberGenerator(42) // restart the sequence from a known seed

If you need seeded randomness outside a sketch, the underlying generator is exported directly:

import { RNG } from "solandra"
 
const rng = new RNG(42)
rng.number() // deterministic sequence of numbers in [0, 1)

Next: Colour, Gradients and Palettes.

Solandra was made by James Porter.

Check out the GitHub page or install with npm i solandra