Just-in-Time Learning: tutorial script

Version 1.0

In our last tutorial, we showed you how to draw a single shape like a star with a loop in P5js. What if you want to make a combination of shapes that fit into some kind of pattern--maybe as a background for your computer desktop, or for use in a printed fabric or product design, or just to make some digital art?

Today we'll learn how to repeat a combination of shapes horizontally and vertically to create a two-dimensional pattern. We'll start from the function we made last time to add a star, but instead of using a loop to make individual shapes we'll use one to repeat tiles of shapes in horizontal and vertical directions. As with our star-building function, we'll sprinkle in some variables and make some calculations so that just changing a couple of numbers will transform the pattern for us.

In so doing, we'll create a simple example of generative art, where the computer does most of the work and you can just tweak the parameters until you like the results.

Patterns in art and design

Making artful patterns from a set of instructions has a history long before computers. In the 1300s, the Moors tiled mesmerizing patterns into buildings like the Alhambra in Spain. The Amish sew repeating geometric designs into their quilts; Tibetan monks create intricate patterned mandalas out of sand. The wall drawings of Sol LeWitt are executed by volunteers with pencils rather than a laptop, but they are also instruction-based works.

Set up your screen-based pattern

We have the luxury of a computer under our fingertips, so our pattern will take a few milliseconds to draw rather than the days it takes monks to create a sand painting. We just need to write some loops to repeat our shapes, and then tweak the parameters until we like the results.

I'll start with the sketch I made in the last tutorial, but let's assume we're going to use our design for something bigger than a little square in a P5js sketch. I'll choose 960 x 540 pixels, a common aspect ratio for computer desktops and Zoom backgrounds. And I'll give it a celestial background color that suits my star motif. Remembering that I'm experimenting with loops, I'll uncheck the "auto-refresh" option so I don't get stuck in an infinite loop just because I haven't finished typing my code.

Build a row of tiles

For these to make a pattern, I'll need to repeat this "tile" of three stars across the entire canvas. The easiest way to organize this is a grid with rows and columns. That said, I hope when I'm done the viewer won't be able to discern the edges of the tiles, but just a continuous stretch of visual fabric.

Let's start easy by repeating our tiles horizontally. In our last screencast, we showed you how to build a radial shape like a star using a loop. We'll also use a loop in this tutorial, but it will be on a larger scale, to add more and more shapes until the row is filled.

As you remember, the most universal loop uses the for keyword, which is followed by three conditions. First we set the starting value of a counter; then we write the condition necessary for the loop to continue; and finally we add 1 to the counter after each loop, as abbreviated by the ++ operator.

In our case, we were smart and wrote our addStar() function to take a number of variable parameters. Two of these are the star's starting x and y position. If we just add an arbitrary value to each star--say, 150 pixels--that will move the entire tile over to the right by that amount. If we do this in a loop, we should be able to march the tiles across the canvas, one column at a time.

In the spirit of play, let's try just putting some arbitrary numbers in our loop and see what happens. We'll ask for 3 columns.


function draw() {
	background("hsla(220,40%,40%,1)");
	// Draw a row of stars.
	for (let columnsDrawn = 0; columnsDrawn < 3; columnsDrawn++) {
		addStar(star1);
		addStar(star2);
		addStar(star3);
	}
}

It may not look like we added any new tiles, but in fact there are three on the canvas--they're just superimposed on each other because we didn't actually move the stars' positions. So we'll add our arbitrary number, 150 pixels, to the horizontal position of each star. You might recall from our last tutorial that we called that property startX.

By the way, I'm going to take advantage of a special feature of the P5js editor that is also found in some other code editors. By holding down the Option key (Alt on Windows), I can select and change more than one line at a time. Nifty!


function draw() {
	background("hsla(220,40%,40%,1)");
	// Draw a row of stars.
	for (let columnsDrawn = 0; columnsDrawn < 3; columnsDrawn++) {
		star1.startX = star1.startX + 150;
		star2.startX = star2.startX + 150;
		star3.startX = star3.startX + 150;
		addStar(star1);
		addStar(star2);
		addStar(star3);
	}
}

That's better, since I see three tiles. But all the stars are aligned vertically for each tile, which defeats the natural look we got originally by giving each star a different startX value. Fortunately, we can get that staggered look back if we add a new variable to our star function to represent the current position, rather than the "headstart" position each star gets from the left edge. I'll call this new variable x instead of startX, and I'll make sure my addStar() function uses that instead of startX.


function draw() {
	background("hsla(220,40%,40%,1)");
	// Draw a row of stars.
	for (let columnsDrawn = 0; columnsDrawn < 3; columnsDrawn++) {
		star1.x = star1.startX + 150;
		star2.x = star2.startX + 150;
		star3.x = star3.startX + 150;
		addStar(star1);
		addStar(star2);
		addStar(star3);
	}
}
function addStar(star) { // star is an object passed with multiple properties.
	// Assumes angleMode(DEGREES).
	push();
	translate(star.x, star.startY); // For a single row, the startY's don't change.
	...
}

By distinguishing these variables, I got my stagger back--but I lost the tile progression to the right. The problem is that I'm always adding only 150 to my initial star positions; I need to have that rightward offset increase every time I run through the loop.

This is a common problem with loops, but my friend the for loop is here to save the day. You'll recall that every for loop has a counter, which usually increases by one every time the computer runs through another iteration of the loop. I called our counter columnsDrawn. For the first tile, columnsDrawn is 0; for the next, it's 1; for the next, it's 2, and so forth.

So if we want the column offsets to be multiples of 150, we just need to multiply that number by the counter, columnsDrawn.


function draw() {
	background("hsla(220,40%,40%,1)");
	// Draw a row of stars.
	for (let columnsDrawn = 0; columnsDrawn < 3; columnsDrawn++) {
		star1.x = star1.startX + 150 * columnsDrawn;
		star2.x = star2.startX + 150 * columnsDrawn;
		star3.x = star3.startX + 150 * columnsDrawn;
		addStar(star1);
		addStar(star2);
		addStar(star3);
	}
}

Our tiles are too scrunched together. We could space them out by guesswork--maybe the offset should be 200 pixels, or 250? But a smarter approach is basing the offset on the width of our canvas, which is what P5's built-in variable width represents.

To figure out the offset, we just need to know how many tiles we want to fit horizontally. Let's make this a global variable, which is best added to our setup() function. I'll call it tilesPerRow, and for now set it to 3. Note that I put this after my createCanvas command, so the canvas width and height would already be defined so my variable can refer to them.

We can then calculate the width of each tile, which I'll call tileWidth; I'll put it in setup() so it's a global variable I can refer to anywhere.


	function setup() {
	createCanvas(960, 540); // This is the aspect ratio of a Zoom background.
	angleMode(DEGREES);
	// Put these global variables after createCanvas so you can refer to width and height.
	tilesPerRow = 3;
	tileWidth = width / tilesPerRow;
	...
}

I'll also calculate the rightward displacement of each column, which I'll call offsetRight. I'll put that in draw(), and use the let keyword to make it a local variable, since it changes with each turn of the loop.


function draw() {
	background("hsla(220,40%,40%,1)");
	// Draw a row of stars.
	for (let columnsDrawn = 0; columnsDrawn < tilesPerRow; columnsDrawn++) {
		let offsetRight = columnsDrawn * tileWidth;
		star1.x = star1.startX + offsetRight;
		star2.x = star2.startX + offsetRight;
		star3.x = star3.startX + offsetRight;
		addStar(star1);
		addStar(star2);
		addStar(star3);
	}
}

Now we have each star moving in lockstep with the loop, while each maintaining its own original stagger from the left. Not only that, but by doing that little calculation, we optimized the spread of our stars across the campus. If we change the number of tiles from 5 to 7 or 9, the spread still looks even. That gives us a lot of control over the density of our final design.

Add more than one row of tiles

It's time to add more rows to fill out our grid vertically. Now that we've figured out how to repeat tiles in a column, it shouldn't be hard to convert that same code for a row, just by copying the code and switching the names. We'll start by adding analogous global variables to our setup() function. Let's start with 3 rows.


function setup() {
	createCanvas(960, 540); // This is the aspect ratio of a Zoom background.
	angleMode(DEGREES);
	// Put these global variables after createCanvas so you can refer to width and height.
	tilesPerRow = 5;
	tilesPerColumn = 3;
	tileWidth = width / tilesPerRow;
	tileHeight = height / tilesPerColumn;
	...
}

We'll also need to add an analogous loop in our draw() function, but where should we put it? Let's try right after the loop that made a column.


function draw() {
	background("hsla(220,40%,40%,1)");
	// Draw a row of stars.
	for (let columnsDrawn = 0; columnsDrawn < tilesPerRow; columnsDrawn++) {
		let offsetRight = columnsDrawn * tileWidth;
		star1.x = star1.startX + offsetRight;
		star2.x = star2.startX + offsetRight;
		star3.x = star3.startX + offsetRight;
		addStar(star1);
		addStar(star2);
		addStar(star3);
	}
	// Draw a column of stars.
	for (let rowsDrawn = 0; rowsDrawn < tilesPerColumn; rowsDrawn++) {
		// Draw a column of stars.
		let offsetDown = rowsDrawn * tileHeight;
		star1.y = star1.startY + offsetDown;
		star2.y = star2.startY + offsetDown;
		star3.y = star3.startY + offsetDown;
		addStar(star1);
		addStar(star2);
		addStar(star3);
	}
}

Ugh! That only left us with one column of stars and one row of stars. If you think about it, that sort of makes sense: the position of our tile when we left the column loop was at the far right, so of course the row would be over there. What to do?

If we want every column we draw to have its own rows, the solution is to nest one loop inside the other. It can be tricky to get this right in the code, so we'll trigger P5's auto-indent feature with shift-tab to make this clearer.


function draw() {
	background("hsla(220,40%,40%,1)");
	// Draw all rows of stars.
	for (let rowsDrawn = 0; rowsDrawn < tilesPerColumn; rowsDrawn++) {
		// Draw all the columns within a row.
		for (let columnsDrawn = 0; columnsDrawn < tilesPerRow; columnsDrawn++) {
			let offsetRight = columnsDrawn * tileWidth;
			let offsetDown = rowsDrawn * tileHeight;
			star1.x = star1.startX + offsetRight;
			star2.x = star2.startX + offsetRight;
			star3.x = star3.startX + offsetRight;
			star1.y = star1.startY + offsetDown;
			star2.y = star2.startY + offsetDown;
			star3.y = star3.startY + offsetDown;
			addStar(star1);
			addStar(star2);
			addStar(star3);
		}
	}
}

If we run this nested loop, we seen a nice, full grid of tiles. Furthermore, because we used parameters to calculate their offsets, the tiles fill the canvas perfectly. As before, we can tweak values like the number of rows and the number of columns to make our design more dense or more sparse--but now in two dimensions.

Loop through other parameters

In this tutorial, we only incremented the left and top positions of the tiles as we marched through our loops. But we could have simultaneously incremented other parameters, such as the colors or the shapes themselves. A good example from art history is the Dutch graphic artist MC Escher (and no he wasn't a DJ). Although he never used a computer, his prints frequently make use of tiles that interlock in interesting ways, or shapes that progressively morph as they move across the page.

Conclusion

We could keep adding shapes and colors all day, but one thing that wouldn't change is that our pattern will always look very regular--and frankly a little predictable. There's no problem with that; after all, most printed fabrics have a predictable pattern. But if we want to spice things up, we could add a little randomness. We'll talk about how to do that in our next screencast.