# Reconstructing Tyler Hobbs’ Watercolor Effect

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:

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 $A$ and vertex $B$. 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()`

.

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

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:

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:

## 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)$. 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$ as you are to get a value $\geq 0.5$; *i.e.* a *fair coin*.

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

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 $\frac{1}{6}$ of the canvas height:

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 *parameters*—**position**, **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]$, with values that are *near* $0.5$ with a *standard-deviation* of $\frac{2}{15}$ or $7.6$. For our angle, consider:

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

This uses a standard-deviation of $\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 $\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*:

## 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:

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%)`);
}
```

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…