When you ask a computer to draw a line, being a helpful but literal-minded creature, it will give you just that, and not a pixel out of place. But ask a paintbrush for a line and you’ll get something a little more imaginative.
We can convince computers to show a little imagination too, and with some care we can get in spitting distance of something like a brush stroke.
Recently I’ve been playing with a technique I call shells, which helps you do just that.
What’s in a line
There’s a lot that goes into making a good line. Two of the most important bits are texture and thickness.
Most programming environments push you towards a flat texture and uniform thickness because that’s what’s easy. Want to do something more adventurous? Things get harder. But with shells, not too much harder. And with something as fundamental as lines, every little bit of friction makes a difference.
Making a shell
In generative art, we often represent lines as a list of points. Shells are only slightly more complicated. Instead of a list of points, we use two lists of points.
We’ll see how that makes life easier in a bit, but for now let’s talk about turning a line into a shell.
Let’s imagine there’s a line drawn on the ground. We’ll call it the original line.
Now imagine a person walking along the original line. And as they walk, they’re holding their hands out to the left and right, straight out from their body.
Their body will pass over the points in the original line. Their left hand will trace another line that we’ll call the left half of the shell, while their right hand traces the right half of the shell. Together these two halves make a single shell.
(If you’re thinking about actually implementing this, I have a thoroughly commented JavaScript implementation at the bottom of the page that gets into the details.)
Adding texture
Shells are great because it’s easy to find points inside them, which opens the door for all kinds of nice visual effects.
You can pick a random point inside the shell pretty easily:
- Pick a random index.
- Get the corresponding points from both the left and right halves of the shell. Remember, they’re both just lists of points!
- Interpolate a random amount between those points.
Picking fewer random points gives you a softer line, while more random points gives you a heavier line.
This algorithm gives you points that are stacked perpendicular to the direction of the original line. Which looks pretty nice, but I also like adding a small amount of noise to soften the line even more.
Playing with stroke weight
So far we’ve kept the number of interpolated points the same throughout the curve, but we can vary that too, creating almost brush-like effects. Here’s what it looks like to start with a small number of interpolated points, then gradually increase them over the course of the curve.
Playing with line thickness
If the person walking along our original line keeps their hands stretched all the way out, our shell will have a uniform thickness. But we can also imagine someone moving their hands back and forth away from their body. If we do this, the thickness of the shell changes, giving us yet another knob to turn.
For example, here I’m feeding each point’s x and y coordinate into a 2D curl noise function to decide how much to translate each point away from the original line. But you can use any kind of function you like — I’ve used sine and cosine, Perlin noise, and Gaussian noise too.
Thinking artistically
For me, the hardest part of generative art is looking at an algorithm and thinking to myself, “How can I use this to get an interesting image?” Let’s perform some experiments using the things we’ve talked about and see what happens.
For this one I started by turning a circle into a shell.
When generating the shell, I used curl noise to determine the translation amount at each point, giving our shell some gentle bulges.
Finally, I sampled random points from inside the shell using the technique I talked about above. To decide how many points to sample, I fed my current progress around the circle into a 1D Perlin noise function. Notice how sometimes the noise function said to sample 0 points.
I also started with a circle for this one. Like before, I used curl noise to vary the shell width. But instead of sampling random points, I drew lines interpolating between the two halves of the shell.
The variation in shell width naturally creates different textures as the interpolated lines get closer together or farther apart.
I kept with the circle motif again, but this time I used big values for the shell width. I gradually increased the shell width along the length of the circle to get a spiral shape.
Once I had my spiral, I made lines interpolating between the two halves of the shell, just like the previous study. Then I dashed the interpolated lines.
Finally, I made those dashes into shells themselves. To add a little more interest, I varied the width of these dash shells using curl noise.
In my art, I like to create interest at multiple scales. This piece does that pretty well, and I think that makes it stronger than the other two studies.
- Big spiral shape = large scale interest.
- Varying dash thickness = medium scale interest.
- Individual dashes = small scale interest.
Appendix: An example JavaScript implementation
If this caught your eye and you’re thinking about actually implementing it, here’s some code you can adapt.
const originalLine = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }]
const leftHalf = []
const rightHalf = []
// Iterate through all the points in the original line.
for (let index = 0; index < originalLine.length; index++) {
// First we need to figure out what direction left and right are.
// We'll do that by figuring out which direction is forward,
// then rotating.
const point = originalLine[index]
const nextPoint = originalLine[index + 1]
// Get the vector between the current point and the next point.
// This tells us which direction is forward.
let forward = getVectorBetween(point, nextPoint)
// Normally we get the vector between the current point and the
// the next point. But if we're currently on the last point in the
// line, that won't work. So instead we get the vector between the
// previous point and the current point.
if (nextPoint == null) {
const previousPoint = originalLine[index - 1]
forward = getVectorBetween(previousPoint, point)
}
// Now that we have our forward direction, we can use it compute
// left and right.
//
// Rotate 90° counter-clockwise.
const left = rotateVector(forward, -90)
// Rotate 90° clockwise.
const right = rotateVector(forward, 90)
// Translate the current point left and right. Translation
// amount can be any number, and like we talked about, varying
// it can have some pleasant results.
const translationAmount = 1
const leftPoint = addVector(point, left, translationAmount)
const rightPoint = addVector(point, right, translationAmount)
// Append our new points to the left and right halves of our shell.
leftHalf.push(leftPoint)
rightHalf.push(rightPoint)
}
// We're done! Here's our shell.
const shell = {
left: leftHalf,
right: rightHalf,
}