Creating Interesting Paths

Starting from a very simple path, let’s see how easy it can be to turn it into something interesting! The approach below is the basic method I used on my recent fx(hash) release, Crisis Worlds.

Note: The code samples use my own private framework. If you have some basic experience with JavaScript and p5js, it should be easy to adapt them. The variable c represents my personal drawing library, and the methods should translate to p5js almost identically. I also use several random number convenience methods, and what they do should be clear from the function name.

First, we’ll create two starting points, an array to hold them, and then a function to draw them.

image

Below is the output.

image

We need to connect them with a line. We’ll modify the code in the forEach loop to get the next point and draw a line between the current point and the one after it.

image
image

Let’s pull in some code from my last tutorial on generative brushes. But instead of drawing random circles at each step along the line, we’ll use it to build the array of points between the start and end.

image
image

Now we have a perfectly plain line between the start and endpoints. That’s no fun! Let’s add code to move each point around before we add it to the array.

image
image
image

You can go crazy with this, and if you do, you’ll see some interesting behavior soon.

To make things easier from here on, let’s extract the point drawing code into a new function.

image

What if we wanted to further refine and smooth out this line? We need an algorithm to take in all of our rough points and smooth out the corners by adding in new points. The Chaikin Algorithm is the perfect solution for this! Matt DesLauriers has a JavaScript implementation of it that we can use.

Adding the code to our example project and inlining the vec2-copy import, we get this.

image

Then we run it over our points array and get this. The smoothed line is in green.

image
image

Looking nice! What if we want even more smoothing? Just iterate the smoothing function a few times. Let’s modify the chainkinSmooth function to iterate over the input points as many times as we specify.

image

Running this with 3 iterations returns a much smoother line.

image

And that’s it! We’ve gone from a simple line from point A to point B and transformed it into a flowing path. Experiment with different values, even polygons, to see what you can create! The more starting points you have, the wilder the paths will be.

Below are some examples with increasing amounts of starting points (pointsToInsert).

image
image
image

Paths like this are perfect for tracing with a natural media brush 😉. I hope this has given you new ideas and new areas to explore in your work!

Please give me a follow on Twitter @nudoru and Instagram @hfaze! If you use this in a project, give me a shout out, I’d love to see what you make!

Below is the full code for what I’ve created above. As stated above, this is for my own framework and will need to be adapted to p5js.

const chaikinSmooth = (points, itr = 1) => {
    const smoothFn = (input, output = []) => {
        const copy = (out, a) => {
            out[0] = a[0];
            out[1] = a[1];
            return out;
        };

        if (input.length > 0) output.push(copy([0, 0], input[0]));

        for (let i = 0; i < input.length - 1; i++) {
            const p0 = input[i];
            const p1 = input[i + 1];
            const p0x = p0[0];
            const p0y = p0[1];
            const p1x = p1[0];
            const p1y = p1[1];

            const Q = [0.75 * p0x + 0.25 * p1x, 0.75 * p0y + 0.25 * p1y];
            const R = [0.25 * p0x + 0.75 * p1x, 0.25 * p0y + 0.75 * p1y];
            output.push(Q);
            output.push(R);
        }
        if (input.length > 1) output.push(copy([0, 0], input[input.length - 1]));
        return output;
    };

    if (itr === 0) return points;
    const smoothed = smoothFn(points);
    return itr === 1 ? smoothed : chaikinSmooth(smoothed, itr - 1);
};

const drawPoints = (pointsArray, pColor = 'red', lColor = 'blue') => {
    // Loop over the array and pass in the point and the current index
    pointsArray.forEach((point, idx) => {
        c.noStroke();
        c.fill(pColor);
        c.circle(point[0], point[1], 5);

        // Draw a line between the current points, and the next one
        // If it's the last point, don't do anything
        const next = idx < pointsArray.length - 1 ? pointsArray[idx + 1] : null;
        if (next) {
            const px1 = point[0];
            const py1 = point[1];
            const px2 = next[0];
            const py2 = next[1];

            c.stroke(lColor);
            c.line(px1, py1, px2, py2);
        }
    });
};

const draw = () => {
    const cw = c.width; // Width of the canvas
    const ch = c.height; // Height of the canvas
    const m = 200; // Margin

    const x1 = m;
    const x2 = cw - m;
    const y1 = ch / 2 - 50;
    const y2 = ch / 2 + 50;

    const points = [];

    const pointsToInsert = randomWholeBetween(10, 50); // Will insert 1 more than this
    const xIncrement = (x2 - x1) / pointsToInsert;
    const yIncrement = (y2 - y1) / pointsToInsert;

    let currentX = x1;
    let currentY = y1;

    const minOffset = 50;
    const maxOffset = 100;

    for (let i = 0; i <= pointsToInsert; i++) {
        // Only move around the middle points
        if (i > 0 && i < pointsToInsert) {
            // Random radius between the min and max
            const rRadius = randomNumberBetween(minOffset, maxOffset);
            // Random radians between 0 and 2PI, the full circle
            const rRadians = randomNumberBetween(0, Math.PI * 2);
            const offsetX = currentX + rRadius * Math.cos(rRadians);
            const offsetY = currentY + rRadius * Math.sin(rRadians);
            points.push([offsetX, offsetY]);
        } else {
            points.push([currentX, currentY]);
        }

        currentX += xIncrement;
        currentY += yIncrement;
    }

    const smoothPoints = chaikinSmooth(points, 3);

    drawPoints(points, 'red', 'blue');
    drawPoints(smoothPoints, 'green', 'green');

    return false;
};

Generative Brushes Part 1

I make heavy use of ‘brushes’ in most of my generative pieces. I think they’re crucial for adding an extra dimension and a lot of texture to the piece. Below, I’ll create a simple system that you can add and customize on your projects. This technique can be customized and extended to create dry media looks from pencils to charcoal or take a completely new direction!

Note: The code samples use my own private framework. If you have some basic experience with JavaScript and p5js, it should be easy to adapt them. The variable c represents my personal drawing library, and the methods should translate to p5js almost identically. I also use several random number convenience methods, and what they do should be clear from the function name.

Start with two points and a line

The idea is to take two points and draw many little circles between them. It’s the spacing, arrangement, and color of these tiny shapes that determine the look of the final line and what media it looks like. If you have a complex shape, such as a circle, square, etc., then imagine doing this between each pair of points on that shape.

Let’s start with a basic line.

And the code. Here, I define some basic variables, set the context color, and draw the line to the canvas context.

image

The next step is to plot points along the path rather than just drawing a line. We’ll start with a dotted line, drawing a circle at each point as we step between the start and endpoints.

image
image

If you increase the variable stepsBetweenPoints you’ll end up with a more detailed line. The farther apart the start and end points are, the higher you’ll want this value. Here’s what it looks like with 100 steps.

image

Adding interest with randomness and noise

If you’ll notice, the radius of the circles is the thickness of the stroke. By using a random number as the radius, the line becomes much more interesting!

image

This looks like an erratic leaky pen. I’m using this random number utility function to get this value.

image

Let’s make it better with some noise. In this context, it’s helpful to think of noise as a smooth gradation of random values. As we get the 2d noise values of the points on our line, it travels through and noise space and gradually changes. The frequency of the noise will determine how quickly the values change. Simplex noise with a frequency of 0.005 at 1 amplitude is a very smooth place to start.

image
image

Experimenting with this simple approach can yield interesting results! Below, I’ve increased stepsBetweenPoints to 300, changed minRadius to .5, and maxRadius to 5. The result is a wavy ink line.

image

Adding texture

So what about the old-school airbrush look? For that, we need to draw random points around each of the points in our line. For a great explainer of the best way to get a uniform distribution of random points in a circle, watch this excellent video from nubDotDev. These recent tweets from Yazid and Takawo Shunsuke show several methods for point distribution in a circle. Experiment! We’ll the method below for the rest of this article.

image

Using that function to get a uniform random radius, we can pair that with random radians between 0 and two PI to draw random circles around our point, and then we have our old-school spray can.

image

The new function, randomCirclesAroundPoint, creates all of the random circles for us. Thickness is the maximum radius for the points, and density is the number of circles to draw.

image

To visually illustrate what’s going on, here’s a debug view. The green dot is our stepped point, the center of the circle. The red outline is the thickness or the max radius to create the random points.

image
image

Tweaks and ideas

At this point, you have everything needed to create a convincing natural media-drawn line in your projects. You can create a pencil line by tweaking all of the variables we’ve covered so far.

image

One tip, is to use alpha on the circle’s fill color. Below is that line again, but with a fill alpha of 0.25.

image

Below, I’ve increased the alpha to 0.5, and changed the min/max radius of the circles to 0.25 and 1. With circles sizes this small, you’ll need to increase the density quite a bit, but the results are even better.

image

Scaling up the thickness of the line, you can create a charcoal effect.

image

Another trick I’ve used in my work is to snap about 30% of the circles to a multiple of a whole number. This will give the feeling of the media sticking to a paper or canvas grain. Below, 30% of the circles in the randomCirclesAroundPoint function are snapped to an x or y value that’s a multiple of 3.

image

The effect is subtle, but it adds more realistic details. Here’s the updated code with this included.

image

For more complex polygons, like an arc, circle, or square, where the points are close together, the stepsBetweenPoints needs be very low – even 1 in some cases. In my projects, I have all of this wrapped in a function that takes an array of points and the steps, thickness, density, sizes, etc., as options.

Be careful as you start to use this technique in your projects. Drawing too many circles will have a negative performance hit. Experiment with the stepsBetweenPoints and thickness vs density variables. You only need enough to get a convincing look. The closer the points are, the less steps you need between the points. The thinner the line, the less density you need. It’s a good idea to create a function that will lerp these values for you.

I’ve spent a lot of time hunting down performance bottlenecks for it to be a low alpha and very high steps and density. Increasing the alpha let me reduce the other variables and didn’t have a negative impact on the appearance.

A complete function

Below is an example of how this could look in your projects. I’ve taken this code, placed all of the tweaking variables in an object, added checks for different options values, and made it reusable. This is similar to how it looks in my own projects.

Using an object to store and pass the variables makes it easy to change up the looks. You can have one for a pencil, another for charcoal, etc.

const drawLine = (x1, y1, x2, y2, options = {}) => {
    // options defaults, a pencil line
    const {
        stepsBetweenPoints = 10,
        density = 5,
        minThickness = 0.1,
        maxThickness = 2,
        minRadius = 0.25,
        maxRadius = 1,
        noiseFrequency = 0,
        snapPointsPx = 3,
        snapPoinstPct = 0.7,
    } = options;

    const xIncrement = (x2 - x1) / stepsBetweenPoints;
    const yIncrement = (y2 - y1) / stepsBetweenPoints;

    let currentX = x1;
    let currentY = y1;

    // Creates a function that takes x,y as arguments and returns a value from -1 to 1
    // if noiseFrequency === 0 then, it won't be used
    const noiseFn = basicSimplex(noiseFrequency);

    const randomCirclesAroundPoint = (centerX, centerY, thickness, density) => {
        for (let i = 0; i < density; i++) {
            const rndRadius = randomNumberBetweenSq(0, thickness);
            const rndRadians = randomNumberBetween(0, Math.PI * 2);
            let x = centerX + rndRadius * Math.cos(rndRadians);
            let y = centerY + rndRadius * Math.sin(rndRadians);
            const pointRadius = randomNumberBetween(minRadius, maxRadius);

            if (snapPoinstPct && snapPoinstPct < 1) {
                if (randomNumberBetween(0, 1) > snapPoinstPct) x = snapNumber(snapPointsPx, x);
                if (randomNumberBetween(0, 1) > snapPoinstPct) y = snapNumber(snapPointsPx, y);
            }

            c.circle(x, y, pointRadius);
        }
    };

    for (let i = 0; i < stepsBetweenPoints; i++) {
        let rad;
        if (noiseFrequency) {
            rad = (Math.abs(noiseFn(currentX, currentY)) + minThickness) * maxThickness;
        } else {
            rad = randomNumberBetween(minThickness, maxThickness);
        }
        const thickness = rad;
        randomCirclesAroundPoint(currentX, currentY, thickness, density);
        currentX += xIncrement;
        currentY += yIncrement;
    }
};

const draw = () => {
    c.noStroke();

    // slant the lines
    const xOffset = 30;

    // bounds on the color so it's not too dark or light
    const minColor = 50;
    const maxColor = 150;

    // vary the thickness of the lines
    let minOffset = 0;
    let maxOffset = 0;
    const thicknessOffsetStep = 0.05;

    for (let x = 0; x < canvasWidth; x += 15) {
        const r = randomNumberBetween(minColor, maxColor);
        const g = randomNumberBetween(minColor, maxColor);
        const b = randomNumberBetween(minColor, maxColor);
        c.fill(`rgb(${r},${g},${b})`);

        drawLine(x - xOffset, 0, x + xOffset, canvasHeight, {
            stepsBetweenPoints: canvasHeight,
            density: 20,
            noiseFrequency: 0,
            minThickness: 1 + minOffset,
            maxThickness: 3 + maxOffset,
            snapPoinstPct: 0,
        });

        minOffset += thicknessOffsetStep;
        maxOffset += thicknessOffsetStep;
    }
};

I’ll end this article here and continue with simulating paint in a future one. I hope this has given you new ideas and areas to explore in your work!

Please give me a follow on Twitter @nudoru and Instagram @hfaze! If you use this in a project, give me a shout, I’d love to see what you make!