Procedural graphics is an odd obsession of mine. It combines deep technical knowledge with the creative arts, but I realize that I often struggle to explain exactly what it is. I will say something akin to
Instead of drawing out the pixels, you create the recipe for drawing out the pixels.
and I’m not always convinced I’ve helped clarify anything. So I thought I’d show what procedural graphics is by a few simple code examples.
Without having any experience in coding, these examples will still be hard to follow, so let’s say that this is an introduction to procedural graphics for the non-graphics programmer. I’ll start with a one-file C
program which will display an image on the screen, and gradually improve on the program to turn it into something that I would call procedural. I always appreciate it when coding tutorials give working pieces of code—no matter how basic—instead of abstractions, so that’s how I will do it.
How to display an image
You’ll need a C
/C++
compiler (e.g. gcc
, g++
, or Visual Studio) and only one external library called GLFW3. I would have preferred to only use the C-standard library but each platform has their own specific methods for creating windows so this is an easy way to take care of that. If you’re on linux, just use the package manager to install the library with header files. On Windows, the process is a little more involved. Any of these code samples should work when linked correctly.
The static way
Seeing as we’re living in the internet age, an image is most often synonymous with its digital representation, i.e. a file living on your harddrive. In reality, a file is just agnostic data; it only becomes an image once you double-click on it, causing a piece of software to interpret the data and send it to your screen. The following code gives a rudimentary implementation of how something like that might work.
#include <GLFW/glfw3.h>
#define N 8 // Image size.
#define Z 20 // Zoom factor.
// Raw pixel data.
unsigned char data[N][N] = {
{0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0},
{0, 0,255,255, 0,255,255, 0},
{0, 0,255,255, 0,255,255, 0},
{0, 0, 0, 0,255,255,255, 0},
{0, 0,255,255,255, 0, 0, 0},
{0, 0,255,255,255, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0}
};
int main() {
// Initialize window.
glfwInit();
GLFWwindow* win = glfwCreateWindow(N*Z, N*Z, "", NULL, NULL);
glfwMakeContextCurrent(win);
// Continuously render.
while (!glfwWindowShouldClose(win)) {
glClear(GL_COLOR_BUFFER_BIT);
glPixelZoom(Z, Z);
glDrawPixels(N, N, GL_LUMINANCE, GL_UNSIGNED_BYTE, data);
glfwSwapBuffers(win);
glfwPollEvents();
}
glfwTerminate();
return 0;
}
A walk-through
Let’s go through the program step by step.
- First, you include the GLFW3 library header file, and define some constants.
N
is the width of the image in pixels, andZ
is the zoom factor which will become apparent later. - The image data is represented by a two-dimensional unsigned char (= byte) array. Each byte is a pixel; zero means black, and 255 (the highest value for an 8-bit byte) means white. If you squint, you can already slightly see what the image might look like. So far, we have only looked at the static data, next, comes the actual execution of the program.
glfwInit()
is called to initialize all settings, and the next line creates an opaque handler for the graphical window. The window has dimensionsN*Z
(160x160) just because an 8x8 window is really annoyingly small to see.glfwMakeContextCurrent(win)
makes sure that we are now able to send OpenGL commands to the GPU.- We now enter the infinite render loop with a
while
statement:glClear(GL_COLOR_BUFFER_BIT)
clears the renderbuffer at each step, andglPixelZoom(Z, Z)
just zooms the image dataZ
times.glDrawPixels
is the interesting step: it sends the raw data to the GPU, to your screen. Besides giving the dimensions of the data, you need to specify the size (GL_UNSIGNED_BYTE
) and how to interpret each number (GL_LUMINANCE
).- The next two GLFW3 commands take care of switching each frame, and querying for any events (i.e. keyboard and click events).
- Once you step out of the loop, by, for example, closing the window,
glfwTerminate()
will call any cleanup functions.
If you compile and run this program, you’ll get the mind-blowing result given in Fig. 1. This is a barebones example of how a standard image viewer works. Whenever you click on a .jpg
or .png
file, your browser, for example, will interpret this data and send it to your screen to render. The separation between data and code is really clear here. One part is completely static — unchanging — and then there is a dynamic program which actively updates the screen. In this case not much is happening, but you could see how this leaves room for improvement and creativity.
Calculating it on the spot
We will now try to replicate the exact same image, but this time we will calculate it in real-time, instead of just reading a bunch of bits. If you look closely at the emitted picture, it looks like there is a pattern. This image can in fact be made with a simple bit-flip operation over each pixel. All a bit flip does is look at the previous pixel, and change it to its opposite value. A white pixel becomes black, and a black pixel becomes white. We now have a new program, with a few minor things changed.
#include <GLFW/glfw3.h>
#define N 8
#define Z 20
// Uninitialized data.
unsigned char data[N][N];
// Bit-flip square function.
void square(int x, int y) {
for (int i = x-1; i <= x+1; ++i)
for (int j = y-1; j <= y+1; ++j)
data[i][j] = ~data[i][j];
}
int main() {
glfwInit();
GLFWwindow* win = glfwCreateWindow(N*Z, N*Z, "", NULL, NULL);
glfwMakeContextCurrent(win);
// Calculate image.
square(5, 3);
square(3, 5);
square(3, 3);
while (!glfwWindowShouldClose(win)) {
glClear(GL_COLOR_BUFFER_BIT);
glPixelZoom(Z, Z);
glDrawPixels(N, N, GL_LUMINANCE, GL_UNSIGNED_BYTE, data);
glfwSwapBuffers(win);
glfwPollEvents();
}
glfwTerminate();
return 0;
}
In this case, the pixel data data[N][N]
is left with an empty black background. Next, we define a bit-flip function called void square(int x, int y)
. The way that it works is that you supply an x
and y
value, and the function will bit-flip a 3×3 square grid around that central coordinate. With a bit of thought, we can recreate the image using three calls to the bit-flip function at three coordinates: (5, 3), (3, 5), and (3, 3). And as expected, we get the exact same image on our screen.
To the viewer, not much has changed, but what happens under the hood is important: we now have control over the algorithm to create this image, and we can then use this creatively. Furthermore, since we now know how to compute this image, we can do this on-the-fly, we could make the image move by modulating the parameters.
An example of this would be when you are editing an image in an image-editing application. The software has access to all the parameters, rules, and objects which, when put together, create your image—something you wouldn’t have access to with a static image. Another advantage of this is that it is a subtle form of data compression. I mentioned before that there was an easy distinction between code and data, but, to a computer, they look the same, they are both just bytes on a hard-disk, one is interpreted, while the other is read and written. There are many cases where the rules to create an image took up a lot less space than the raw emitted data, so this is an important technique to use in industries where that is an important problem.
Making it procedural
The previous example showed a way to create an image algorithmically, but it is not yet procedural. For something to be considered procedural, it needs an extra important element: randomness. Let’s take a look at the final version of our image rendering program.
#include <GLFW/glfw3.h>
#include <stdlib.h> // Get random function.
#define N 8
#define Z 20
unsigned char data[N][N];
void square(int x, int y) {
for (int i = x-1; i <= x+1; ++i)
for (int j = y-1; j <= y+1; ++j)
data[i][j] = ~data[i][j];
}
int main() {
glfwInit();
GLFWwindow* win = glfwCreateWindow(N*Z, N*Z, "", NULL, NULL);
glfwMakeContextCurrent(win);
// Seed random number generator.
srand(glfwGetTimerValue());
// Generate image procedurally.
int a = rand()%4, b = rand()%3, c = rand()%2;
square(a+2, (a+b+1)%4+2);
square((a+b+1)%4+2, a+2);
square(c+2, c+2);
while (!glfwWindowShouldClose(win)) {
glClear(GL_COLOR_BUFFER_BIT);
glPixelZoom(Z, Z);
glDrawPixels(N, N, GL_LUMINANCE, GL_UNSIGNED_BYTE, data);
glfwSwapBuffers(win);
glfwPollEvents();
}
glfwTerminate();
return 0;
}
We have added an extra line to the C-standard library stdlib.h
, where the functions for random number generation are defined. No computer is ever truly random; all a random function does is return a sequence of numbers which seem to be unrelated. (This is harder to do than it sounds.) So to improve this, the first number of the sequence has to be determined by a parameter that is different every time the program is run; a timer is most often used. This is called ‘seeding’ the random number generator: srand(glfwGetTimerValue())
. Now, each time rand()
is called, you will get a integer value between 0 and whatever your stdlib implementation has defined as RAND_MAX
, which has to be at least 32767.
Using any number in that range will mostly create junk, so you have to limit the range of random numbers. With some experience and trial and error you’ll find a way to map your random function to a parameter space that gets you the results you’re looking for. And that’s all that procedural graphics is, it’s the art of mapping randomness to color.
Now, every time this program is run, you’ll get a different image—one that will always have the same aesthetics, but not the exact same data. A well designed algorithm will allow us to create things that are perceptually unique, but algorithmically identical. I created a gif which shows a few possible iterations in Fig. 3.
The bigger picture
Clearly, this isn’t all that impressive, but I hope you get the idea. You could see how, when taken to its limit, procedural graphics can really allow for some amazing displays of creativity. This approach also has huge benefits, the most obvious one being that it’s an even more extreme form of compression. The simple addition of a random function can allow someone to create a multitude of images and data out of one single algorithm. This is a technique which has been used countless times in the video game industry, especially in the days where memory was a scarce asset.
Procedural generation might have a fancy name, but it’s actually the closer approximation to how real-life works. Plants are created through a cellular algorithm, clouds are formed through complicated physics, and even mountains have rules they must abide by. And each plant, cloud, or mountain had slightly different inputs into their generational algorithm, giving rise to their uniqueness. I want to make the distinction here that procedural graphics is not the same as simulation: one strives for physical accuracy while the other is simply a creative tool that takes a lot of inspiration from physics. And this illustrates perfectly what, to me, is the best reason to create things procedurally: it gives you a deeper understanding of why something is aesthetically pleasing.