nudoru
natural media
tutorial
Generative Dry Media Brushes

Generative Dry Media Brushes

written by nudoru

05 Jan 2024500 EDITIONS
1 TEZ

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.

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.

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.

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!

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

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.

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.

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. We'll use the method below for the rest of this article.

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.

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.

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.

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.

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.

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.

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

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.

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

For more complex polygons, like an arc, circle, or square, where the points are close together, the stepsBetweenPoints need to 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 fewer 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 X/Twitter @nudoru and Instagram @hfaze! If you use this in a project, give me a shout, I’d love to see what you make!

stay ahead with our newsletter

receive news on exclusive drops, releases, product updates, and more

feedback