Digitial watercolours with Solandra

We're going to create a watercolour like effect with Solandra. We'll be following Tyler Hobb's approach, technically at second hand, as I'm adapting my own previous effort to do this in Kotlin with (JVM) Processing. First we need to figure out how to generate a base shape, then we spread this iteratively. To give a watercolour like effect we then layer these shapes on top of each other with low opacity.

Create a base shape

Solandra nearly does what we want out of the box with a regular polygon. But a lower level approach like below allows us to perturb the points. It also doesn't produce a closed path (start = end). We use the higher order Solandra utility build which allows us to call another Solandra function, together with a configuration, and build an array of results. The code just takes each point round a circle, then perturbs it randomly.

const startShape = (r: number = 0.2, magnitude: number = 0.2): Point2D[] => {
  const at = s.meta.center
  // A regular polygon is close to what we want but that would be closed (start = end) which we don't want here
  return s.build(s.aroundCircle, { at, r, n: 12 }, pt =>
    s.perturb(pt, { magnitude })
  )
}
          

Spreading

We spread in a simple way. For each edge (connected point pair) we generate a new point, which is a perpendicular extension outwards at a random point along the line.

To spread our shape we just apply this to the entire shape, working along the edges one at a time.

const newPoint = (a: Point2D, b: Point2D): Point2D => {
  // Perpendicular outwards
  const beta = -Math.PI / 2
  const u = b[0] - a[0]
  const v = b[1] - a[1]
  const m = baseM * s.random()
  const d = s.random()
  const cb = m * Math.cos(beta)
  const sb = m * Math.sin(beta)

  console.log({ a, b, u, v, m, d })
  return [
    a[0] + u * d + (cb * u - sb * v),
    a[1] + v * d + (sb * u + cb * v),
  ]
}

const spread = (points: Point2D[]): Point2D[] => {
  var spreaded: Point2D[] = []
  const l = points.length
  for (let i = 0; i < l; i++) {
    const p1 = points[i]
    const p2 = points[i > l - 2 ? 0 : i + 1]
    spreaded.push(p1)
    spreaded.push(hs.newPoint(p1, p2))
  }
  return spreaded
}

The 'watercolours'

And we are basically done. Let's generate a shape, then draw layers of it, spreading after each drawing. This already gives us a remarkably pleasing result, for such simple code.

(s: SCanvas) => {
  s.background(45, 40, 95)
  const { startShape, spread } = helpers(s)
  s.setFillColor(5, 95, 60, 0.25)

  let sh = startShape(0.3, 0.25)
  sh = spread(spread(sh))

  s.times(5, () => {
    sh = spread(sh)
    s.fill(SimplePath.withPoints(sh))
  })
}

A lot of the code is (pseudo-)random. Click here to change the random seed.

If we layer three different shapes we get an even nicer effect.

(s: SCanvas) => {
  s.background(45, 40, 95)
  const { startShape, spread } = helpers(s)
  s.setFillColor(5, 95, 60, 0.2)

  let shapes = [startShape(), startShape(), startShape()]
  shapes = shapes.map(sh => spread(spread(sh)))

  s.times(5, () => {
    shapes = shapes.map(sh => spread(sh))
    shapes.forEach(sh => s.fill(SimplePath.withPoints(sh)))
  })
}

We can blend colours together. Just create a bunch of shapes and draw them in an interleaved manner (draw first layer for all shapes, then second, then ...).

(s: SCanvas) => {
  s.background(45, 40, 95)
  const { startShape, spread, move } = helpers(s, 0.35)
  let shapes = [
    move(startShape(), [-0.2, 0]),
    startShape(),
    move(startShape(), [0.2, 0]),
  ]
  shapes = shapes.map(sh => spread(spread(sh)))

  s.times(5, () => {
    shapes = shapes.map(sh => spread(sh))

    shapes.forEach((sh, i) => {
      s.setFillColor([0, 25, 340][i], 95, [50, 50, 35][i], 0.18)
      s.fill(SimplePath.withPoints(sh))
    })
  })
}

We can take this further. Using one of Solandra's convenient functions (forTiling) we can iterate across our canvas (both horizontally and vertically), then at the centre of each 'tile' we'll start a new shape. We interleave the drawing as before for the blending effect.

(s: SCanvas) => {
  s.background(45, 40, 95)
  const { startShape, spread, move } = helpers(s, 0.35)

  const { center } = s.meta
  let shapes = s.build(
    s.forTiling,
    { margin: 0.15, n: 3 },
    (_p, [dX, dY], c) =>
      move(startShape(0.1, 0.22), v.subtract(c, center))
  )

  shapes = shapes.map(sh => spread(spread(sh)))

  s.times(5, () => {
    shapes = shapes.map(sh => spread(sh))

    shapes.forEach((sh, i) => {
      s.setFillColor(170 + i * 12, 95, [30, 50][i % 2], 0.2)
      s.fill(SimplePath.withPoints(sh))
    })
  })
}

We could continue to make our 'paint' more naturalistic (that is where Tyler goes) but instead, let's try and do something that real watercolours can't do: proper gradients. HTML Canvas (and Solandra) allow you to fill with gradients. So it is a very small change to our code to enable this. Again we stack several shapes (and this time I've adjusted the starting size a bit).

(s: SCanvas) => {
  s.background(45, 40, 95)
  const { startShape, spread } = helpers(s)
  s.setFillGradient(
    new RadialGradient({
      start: s.meta.center,
      end: s.meta.center,
      rStart: 0,
      rEnd: 0.4,
      colours: [
        [0, { h: 5, s: 80, l: 60, a: 0.5 }],
        [1, { h: 45, s: 90, l: 50, a: 0.2 }],
      ],
    })
  )

  let shapes = [startShape(0.2), startShape(0.3), startShape(0.34)]
  shapes = shapes.map(sh => spread(spread(sh)))

  s.times(5, () => {
    shapes = shapes.map(sh => spread(sh))
    shapes.forEach(sh => s.fill(SimplePath.withPoints(sh)))
  })
}

And here is linear gradient example. Note you might see some banding in the colours. Indeed the Canvas seems to struggle when combining gradients and low opacities.