Dividing a square

Recently I released a long-form series on fxHash called Fält. It is an exploration of shape, colour and texture based on the concept of recursive subdivision with a few twists. Here I want to guide you through a few of the concepts behind the final artwork.

Subdivision

The main concept of subdivision is one of recursion, a quite beautiful programming concept in where a function calls itself from within, creating a recursive loop that reinforces and repeats whatever happens within the piece of code. Perhaps the most famous example of recursion is the fibonacci sequence.

function fibonacci(n) {
  if (n <= 1) return 1;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

Here, we begin by checking if n is less than or equal to 1 (every recursive function must have a safe exit branch) and in that case return 1. If n is larger than 1, we return the sum of the previous numbers in the sequence which happens to be the exact definition of the fibonacci sequence!

The purpose of this text is not to go over recursion in depth, so let’s jump in and take a look at what happens inside Fält.

function subdivide(p5, polygons, maxDepth, depth) {
  let output = [];
  polygons.forEach(polygon => {
    output.push(...doSubdivide(p5, polygon));
  });
  if (depth === maxDepth) {
    return output;
  }
  else {
    return subdivide(p5, output, maxDepth, depth + 1);
  }
}

At the most basic, this is one way of doing a recursive subdivision of a polygon. The doSubdivide function looks something like this

function doSubdivide(p5, polygon) {
  let splitStart = p5.random([0, 1]);
  let h = p5.constrain(p5.randomGaussian(0.5, 0.25), 0.1, 0.9);

  let r1 = [polygon[0 + splitStart],
  p5.constructor.Vector.lerp(polygon[0 + splitStart], polygon[1 + splitStart], h),
  p5.constructor.Vector.lerp(polygon[2 + splitStart], polygon[(3 + splitStart) % 4], 1 - h),
  polygon[(3 + splitStart) % 4]];
  let r2 = [p5.constructor.Vector.lerp(polygon[0 + splitStart], polygon[1 + splitStart], h),
  polygon[1 + splitStart],
  polygon[2 + splitStart],
  p5.constructor.Vector.lerp(polygon[2 + splitStart], polygon[(3 + splitStart) % 4], 1 - h)];

  return [r1, r2];
}

This slightly verbose function takes a polygon with 4 points, picks a direction to slice and a height to slice at, then splits it into 2 polygons. From the start we’ve already introduced 2 things; dividing in 2 directions (horizontal and vertical) and a varying cut height for each branch.

If we do this for an increasing maxDepth (remember a recursive function must have an exit clause) we get something like this.

1 subdivision
1 subdivision
2 subdivisions
2 subdivisions
3 subdivisions
3 subdivisions
4 subdivisions
4 subdivisions

Not too complicated, right?

It’s a great start, but it’s a bit too clinical for my taste. We need to shake it up a bit and make it more noisy and imperfect.

Margin

Let’s see what happens if we introduce a margin to the doSubdivision function to separate the divided shapes a bit.

Subdivision with a margin
Subdivision with a margin

Here we also make the stroke a bit smaller to see what’s happening. With margin applied on each cut, we end up with standalone boxes. These boxes however are quite varied in shape - some are still square while others are very elongated. This is because we are splitting randomly horizontally or vertically, so for some branches of the recursion we might always split in the same direction, causing these very long, thin shapes.

This doesn’t have to be a bad thing, but let’s see how we can control it a bit more.

Balancing the cuts

If we want more square shapes, and less thin ones, we can tweak the code that applies each cut. Previously, we had equal probability to cut horizontally or vertically. Now, let’s modify the code with this

let weights = [polygon[0].dist(polygon[1]) + polygon[2].dist(polygon[3]), polygon[1].dist(polygon[2]) + polygon[3].dist(polygon[0])];
let splitStart = weighted(weights);

Where weighted is a function that samples based on the passed weights. What happens here is that we pass the length of the horizontal and vertical sides as weights, resulting in a higher probability of cuts happening along the longer sides, giving more square shapes.

Much more symmetric subdivision!
Much more symmetric subdivision!

We could also apply this the other way to make sure we create as thin shapes as possible. This is simply done by reversing the weights so that we give the short edges a higher probability to be cut.

Stripy subdivision
Stripy subdivision

Slicing on the bias

Just like a great grilled cheese sandwich, sometimes you want to slice your shape not horizontally or vertically, but on an angle. We can do this by tweaking the lerp which creates the new polygons inside doSubdivide.

let straightCut = p5.random() < straightCutProbability;
...
let r1 = [...,
    p5.constructor.Vector.lerp(..., ..., straightCut ? (1 - h + margin) : h + margin), ...]];

If we want to cut straight, we go to 1-h on the opposing side, otherwise we go to h directly, causing the cut to traverse the bias.

100 % slanted cuts
100 % slanted cuts
Some slanted cuts.
Some slanted cuts.

This is starting to look very different now, but still using the same familiar techniques.

Varying depth

To get shapes which are both large and small in the resulting shape we can truncate the subdivision sometimes. The code looks something like this

if (p5.random() < skipProbability) { 
    return [polygon];
}

Which effectively acts as a second safe exit branch of the recursive function. What happens is that for some branches of the recursion we will stop earlier, leaving larger shapes cut fewer times, while other branches continue down into smaller shapes.

Let’s see it in action!

Truncating some branches
Truncating some branches

This can be combined with more advanced concepts such as choosing certain areas of the canvas where the probability to stop is higher or lower, or to vary the probability as the depth increases, or something else entirely.

Distortion

Despite all the randomness applied, we are still working in a safe space where not much can go wrong, courtesy of the way subdivision works. If we want to make the results a bit less clinical, we can apply a random distortion to each polygon after the cut. Call it channeling our inner Vera, or rebelling against the perfection of the transistor, the results are right away more interesting and organic.

Distorted subdivision
Distorted subdivision

Another way to shake things up a bit is to not apply the same margin on each cut, but to modify it for each branch of the recursion.

Varying margin
Varying margin

The effects are subtle, but in my humble opinion result in much more interesting pieces.

Squares vs soft shapes

Sometimes you want squares, or polygons, with sharp edges and defined, sharp, borders. Other times you want soft, round shapes. In Fält, I achieve the second using Chaikin curves. There’s a great writeup on it by Sighack, so I won’t repeat the details here.

There are however many things one can do with Chaikin curves, ranging from how many iterations to pass through, where to place the cuts, apply it to all shapes or not etc.

Smooth subdivision
Smooth subdivision
Varying degrees of smoothness
Varying degrees of smoothness

Domain Warping

The final trick applied in Fält is a mild form of domain warping, where each shape is distorted using a smooth function (to avoid overlap). This makes shapes even more organic and breaks the rigidness of the square even further.

Domain warping is a very interesting concept, although tricky to master. I am by no means an expert, but have found a few tricks that I want to share.

First, we need to expand our polygons so that we have enough points to distort. If we only distort the corners we will not see the effects clearly. Here we replace each edge by 1000 points to create a much more dense representation that we can mould to our hearts desire.

function expandPolygon(p5, polygon){
    let res = [];

    for (let i = 0; i < polygon.length; i++) {
        for (let k = 0; k <= 1; k += 1 / 1000) {
            res.push(p5.constructor.Vector.lerp(polygon[i], polygon[(i + 1) % polygon.length], k))
        }
    }
    return res;
}

Once we’ve expanded our polygons, we can distort them. Here it is important that we utilise a distortion field that is relatively smooth. If we have sharp edges or an outright discontinuous field we will end up with overlapping polygons and a general mess of things that can be jarring. Some distortions that work well are sinusoid waves, noise fields, zig-zag patterns, distance modulation (e.g. pinching everything towards a single point, like gravity).

The resulting code looks something like this

function distortPolygons(p5, polygons){
    let res = [];
    polygons.map(polygon => res.push(expandPolygon(p5, polygon)));
    res.forEach(r => {
        r.forEach(p => {
            p.add(getDistortion(p5, p));
        })
    })
    return res;
}

where getDistortion returns a p5 vector that gets added to the polygon edge at that position.

Zig-Zag distortion
Zig-Zag distortion
Sinusoidal distortion
Sinusoidal distortion
A sharp subdivision resulting in artefacts
A sharp subdivision resulting in artefacts

Wrapping up

And there we have it! A few simple concepts with plenty of creative freedom to explore in a generative fashion. We started out with a humble square and with not too much code we managed to end up with something interesting, intriguing and almost natural.

Fält is to me a playful and open algorithm with lots of colour and joy. The act of subdivision and exploring the resulting shapes should be bright, and I hope that you, after having read about the underlying code, can enjoy the series with a deeper appreciation.

Thank you for reading!

Tengil.

Subscribe to TΞNGIL
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.