Randomness and Noise

Each SCanvas has a seeded pseudorandom number generator. The same seed produces the same sequence of values. Keep the seed to reproduce a sketch, or change it to try another variation.

The SCanvas methods below share this generator. The standalone noise functions use fixed noise patterns, independent of the sketch seed.

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 }) offsets a point randomly by up to magnitude / 2 in each direction:

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

For values that cluster around a mean or represent counts, use:

  • 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

perlin2(x, y) returns smooth 2D noise, approximately in [-1, 1]. Nearby inputs give similar values. Multiply coordinates by a larger factor for finer detail, or a smaller one for broader variation.

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" })
      )
    })
  })
})

Use noise as an angle to guide lines through a flow field:

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

fbm2 (fractional Brownian motion) combines layers, or octaves, of perlin2. Each octave adds finer detail at a lower amplitude. The result is normalised to approximately [-1, 1].

  • octaves — how many layers (default 4); one octave is exactly perlin2
  • persistence — amplitude multiplier per octave (default 0.5); higher values add stronger detail
  • lacunarity — frequency multiplier per octave (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 }))
})

Compare a single octave with several octaves below:

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)
})

perlin2 and fbm2 depend only on their coordinates and options. Changing the sketch seed does not change them.

Curl noise and flow fields

curl2 returns (∂n/∂y, -∂n/∂x), estimated from the noise field. The underlying curl field is divergence-free, making it useful for swirling flows without sources or sinks.

It accepts the same octaves, persistence and lacunarity options as fbm2, with one octave by default. epsilon sets the derivative sampling step. The vector's length depends on how quickly the noise changes.

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],
    ])
  )
})

SimplePath.flowLine traces a vector field with fixed-length steps. Pass a function from position to direction, and use until to stop early, for example at the canvas edge.

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),
    })
  )
})

flowLine accepts any vector field. This example traces spirals 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

worley2 returns distances to feature points scattered across a grid. Use it for cellular textures such as cobbles or stained glass.

Coordinates use grid units: worley2(x * 8, y * 8) gives eight grid cells across the canvas. Distances are zero at feature points and increase away from them; they are not clamped to [0, 1].

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 selects the nearest distance ("f1"), second-nearest ("f2") or their "difference". The difference is zero where two feature points are equally near, outlining cell boundaries:

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 }))
})

metric selects the distance measure: "euclidean" (default), "manhattan" or "chebyshev". jitter controls feature-point placement, from 0 (cell centres) to 1 (anywhere in each grid cell, the default).

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 }))
})

worleyCell2 returns the nearest feature point, its grid coordinates, a stable id and the two nearest distances. Points nearest the same feature share its id; use that to give each cell a colour.

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

Pass a seed to the SCanvas constructor, or use this site's refresh control to try another. Within a sketch, reset the random generator explicitly:

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

For seeded randomness outside a sketch, use the exported generator:

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