Reconstructing Tyler Hobbs’ Watercolor Effect

in

Here, we will talk about implementing a recreation of Tyler Hobbs’ algorithm for achieving a watercolor-like effect—as described in How to Hack a Painting—from first principles in JavaScript/TypeScript.

As in Hobbs’ original talk, we will need to start with a base-polygon. This polygon will serve as a basis, which we will apply our algorithmic process that will result in a new set of polygons that when rendered achieve an effect that is similar to having been painted with watercolor on paper. I won’t go through all the artistic steps/inspirations—see Hobbs’ original talk at Strange L∞p for more artistic details. Instead, here we will focus on implementing these in-code

Distorting a Single Edge

So, starting off, we will begin with a regular hexagon. We can quickly generate one using a simple trigonometric comprehension:

type Vec2 = [number, number];
type Polygon = Vec2[];

const nGon = (length: number): Polygon =>
  Array.from(
    { length },
    (_, i): Vec2 => [
      Math.cos((i / length) * (Math.PI * 2)),
      -Math.sin((i / length) * (Math.PI * 2)),
    ],
  );

…and then use it to generate a simple, regular hexagon:

const hexagon = nGon(6);
render(hexagon);

And render it:

Canvas is not supported

Great! This will serve as the base-polygonal-shape for our algorithm to consume.

Now, we want to distort this polygon into a new polygon. This can be achieved by splitting each edge into two parts and then adjusting the new mid-points by some value. There are three variables that we can exploit when breaking the edges of the existing polygon: position, angle and magnitude.

In order to achieve this, we will iterate through each segment—or each pair of vertices—and split that segment into two parts. With the help of a generator-function, this can easily be achieved with something like this:

function* segments<T>(points: T[]): Generator<[T, T]> {
  const length = points.length;
  for (let i = 0; i < length; i++) {
    const next = (i + 1) % length;
    yield [points[i], points[next]];
  }
}

Now, we can use this generator to iterate over each edge. Firstly, let us look at position. We want to pick a new point along this edge, somewhere between vertex AA and vertex BB. This can be simply achieved by using a linear-interpolation between the vectors of each vertex.

const midPoints: Vec2[] = [];

for(const [a, b] of segments(hexagon)) {
  const midPoint = vecLerp(a, b, 0.5);
  midPoints.push(midPoint);
}

With a value of 0.5, that will give us a new point directly half-way between the two endpoints of our segment. By adjusting this value, we can split the existing line-segment where-ever we desire. Let’s call this function midPointFn().

Canvas is not supported

Now that we have a new mid-point, we need to determine which direction we will want to move. We can do this by using the winding-order of the given polygon, and selecting the outward tangent to the edge in question.

Using that angle as the base-angle, it can be adjusted in radians. Let’s call that function thetaFn(), which returns the number of radians to adjust the tangent angle.

const PI1_2 = Math.PI / 2;
const midPoints: Vec2[] = [];

/* Determine which direction to rotate by looking at the
 * winding order of the given polygon
*/
const outerTangent = windingOrder === 'cw' ? PI1_2 : -PI1_2;

for(const [a, b] of segments(hexagon)) {
  const midPoint = vecLerp(a, b, midPointFn());

  /* subtract vector A from B, and normalize. */
  const unitAB = vecNorm(vecSub(b, a));
  /* rotate our vector by the outer tangent */
  const unitTangent = vecRotate(unitAB, outerTangent + thetaFn());

  midPoints.push(midPoint);
}
Canvas is not supported

Finally, once we have our starting point and our direction, we will need to determine how far we should move in that direction. Let’s call that function magntitudeFn()

const PI1_2 = Math.PI / 2;
const midPoints: Vec2[] = [];

/* Determine which direction to rotate by looking at the
 * winding order of the given polygon
*/
const outerTangent = windingOrder === 'cw' ? PI1_2 : -PI1_2;

for(const [a, b] of segments(hexagon)) {
  const midPoint = vecLerp(a, b, midPointFn());

  /* subtract vector A from B, and normalize. */
  const angleAB = vecNorm(vecSub(b, a));
  /* rotate our vector by the outer tangent */
  const unitTangent = vecRotate(
    angleAB,
    outerTangent + thetaFn(),
  );

  /* Scale the tangent vector to the magnitude
   * and add it to the mid-point
   */
  const newPoint = vecAdd(
    midPoint,
    vecScale(unitTangent, magnitudeFn()),
  );

  midPoints.push(newPoint);
}

This gives us a brand-new point that we can use to extend our original polygon. That gives us something like this:

Canvas is not supported

We can then repeat that same process for each segment in the original polygon and weave these new midpoints in with the original vertices and get a new polygon. Using another simple generator function:

function* zipper<T>(...sources: T[][]) {
  const longest = sources.reduce(
    (prev, { length }) => Math.max(prev, length),
    0,
  );

  for (let i = 0; i < longest; i++)
    for (let s = 0; s < sources.length; s++) {
      if (i < sources[s].length) yield sources[s][i];
    }
}

We can use this function to “zipper” together our original points with our freshly generated new mid-points:

for(const [a, b] of segments(hexagon)) {
  /* snip */
  midPoints.push(newPoint);
}

const distoredPolygon = Array.from(zipper(hexagon, newPoints));

…and we get:

Canvas is not supported

Adding Randomness

So far, this doesn’t look very good. Using the same values for every edge doesn’t make it look distorted; instead, we have what one might describe as a star generator, which is… not exactly what we were going for. However, what if instead of using the same static values for each new point we generate, if we use some amount of randomization. We could add a little bit of randomness to each time we generate our three variables: position, angle and magnitude.

Math.random() will generate a random value that is uniformly distributed between [0,1)[0,1). This type of randomization can be quite valuable, e.g. it is easy to use to model something like a “coin-flip”. You are just as likely to get a value <0.5< 0.5 as you are to get a value 0.5\geq 0.5; i.e. a fair coin.

Let’s plot 1,000 points, whose coordinates are selected a uniform distribution of random numbers:

Canvas is not supported

However, in the “natural-world”, uniform distribution of randomness are not as-common. Often, you are more likely to observe randomness in nature that is better approximated with a Gaussian distribution, or the normal distribution. Going into the history and details of the gaussian distribution is beyond the scope of this post, however, essentially, what we can use this function to model a random-number-generator that will return values around a particular value—the median—and where the distance away from that average value is random—the deviation.

Luckily, we can use the Box-Muller transformation to transform our uniformly generated random values from Math.random() into random values that fit the gaussian distribution.

export function gaussRng(
  μ: number = 0,
  σ: number = 1,
) {
  const u = 1 - Math.random();
  const v = Math.random();
  const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
  return z * σ + μ;
}

Where μ\mu is the mean around which the values are centered, and σ\sigma is the width of the standard deviation.

Now, lets plot those same 1,000 points, but this time use a guassian distrubtion. The center of the distrubtion will be the center of the canvas, and the standard-deviation will be 16\frac{1}{6} of the canvas height:

Canvas is not supported

While the dots are still randomly placed around the canvas, you can see there is a noticeable cluster of points. There is a higher likelyhood that the point is near the center, but there are still some points possibly farther away. In fact, there a few that are very far away from the “median value”, however they are quite rare. There is ≈68% chance random value will be within one standard deviation from the median, plus or minus. There is only a ≈27.2% chance that more than one but less than two. More than two, on either side, is a ≈4%. Going even further, the odds approach zero.

We can use these properties to drive our three parametersposition, angle, magnitude—from before. Let’s use the following functions to dynamically generate the parameters to distort. For position, consider:

const midPointFn = () => clamp(0.001, 0.999, gaussRng(0.5, 2 / 15));

This will return a value between [0.001,0.999][0.001, 0.999], with values that are near 0.50.5 with a standard-deviation of 215\frac{2}{15} or 7.67.6. For our angle, consider:

const thetaFn = () => gaussRng(0, Math.PI / 12);

This uses a standard-deviation of π12\frac{\pi}{12}, or 15°. This means that around 70% of the angles we generate will be within a 30° cone from the true tangent. Finally, for our magnitude, we are going to do something a little different. We are going to use the length of the edge that we are splitting, to derive the standard-deviation;

const magnitudeFn = (len) => Math.abs(gaussRng(0, len / 4));

Here we are using a standard-deviation value of length4\frac{length}{4}; this means that long edges have a greater chance of distorting further, while shorter edges will result in more subtle distortions. These are just a starting point for the types of functions you could use to derive these parameters, the possiblities are endless and can have a major impacts on the output of the algorithm. Let’s take a look at how this looks now:

Canvas is not supported

Iterating the Effect

However, we still aren’t quite at the effect we are trying to get. Doing this once, doesn’t really look like watercolor… It just looks like a more “blobby” version of original polygon. Using recursion—or in this case iterative progressions of distortion, applied one-on-top-of-each-other— we can algorithmically build up extremely complex shapes. Consider the following pseudocode:

const final = Array.from({ length: 5 }).reduce((prev) => {
  return render(prev, distortOptions);
}, hexagon)

We start with our original hexagon, and then apply five iterations of distortion to it. Rendering the resulting polygon we get:

Canvas is not supported

As you can see, using this seemingly “basic” algorithm—with careful adjustment of the function parameters—we can achieve some very organic looking shapes! Also, like giving a shake to a kaleidoscope every generation of this effect can produce drastically different results, where even small changes in early iterations result in vastly different, organic-looking shapes. These shapes will serve as the basis for our final effect.

The Final Effect

By combining multiple-evolutions, that combine randomness and recursive-distortion, it is relatively easy to generate organic looking layers. We can use a transparent fill for each layer to achieve a visual effect that can come very close to watercolor on paper. Using the package @watercolorizer/watercolorizer handles all the low-level math/distortions described above, and just exposes a generator that can be used to render the layers:

import { watercolorize } from "@watercolorizer/watercolorizer"

for (const layer of watercolorize(hexagon)) {
  drawPolygon(layer, `rgba(0 100 255 / 10%)`);
}
Canvas is not supported

In the next entry, we will look at how to control the randomization to get reproducible effects and how this effect can be used to create watercolor-like paintings programatically…