Lab 01: Perlin Clouds

Goals

By the end of this lab, you will:

  • assign pixel colors to an HTML Canvas,
  • practice writing JavaScript classes, functions and creating arrays,
  • practice with linear algebra while becoming familiar with the glMatrix library,
  • make clouds!

In this lab, we will build up our JavaScript skills, practice some linear algebra using an external library (which we will use frequently throughout the semester), all the while implementing an important algorithm that can be used to generate visual effects likes clouds, textures and terrains (example at the top-right of this page which is from Wikipedia). The technique we will implement is called Perlin Noise.

The description in this lab is a bit long, mostly because it's a reference for getting started with JavaScript and glMatrix. We'll implement a lot of the techniques together and I'll leave you to complete the final components of the Perlin noise generator with your lab partner.

To get started, please navigate to our replit Team - you should see that Lab 01 - Perlin Clouds has been published with the initial lab template. For this first lab, please find someone to work with and form a group in replit. Once you've opened the template, please click the "Run" button (each group member should do this), and you should see a gray square in the HTML preview pane.

In the "Webview" tab, notice that there is a wrench icon at the top. Please click this button to open the Developer Tools, which will appear at the bottom of the web preview. You should see an initial message in the Console:

creating 4 x 4 grid

Getting oriented with HTML and JavaScript

Everything we do for the next few weeks will involve directly assigning pixel colors in an HTML "canvas". First, a little bit about HTML - please open up index.html. In a nutshell, HTML consists of a bunch of "elements" described within a Document Object Model (DOM). Each element will have various attributes (like the class, style, id, callbacks etc.). One very useful element (for us) is the HTML canvas. Find the location of <canvas id="perlin-canvas" width=512 height=512></canvas>. Here, we are creating a 512 x 512 HTML Canvas (at the location of the declaration in the index.html) with the "id" called "perlin-canvas". This id could be anything we want (hopefully not the same id as some other element) and we can use it to retrieve the element on the JavaScript side so that we can modify it (like assign pixel colors).

Now, find the location of <script src="perlin.js"></script>. The start and end "script" tags delimit a script, where we can import (and run) the JavaScript code we will implement in this lab. We'll come back to this later.

Notice there is another <script> at the bottom of the page:

<script type="text/javascript">
let perlin = new PerlinNoise('perlin-canvas', 4, 4);
perlin.draw();
</script>

Our perlin.js script (included above) defines the PerlinNoise class, so we have access to this class definition. The first parameter in the PerlinNoise constructor is the ID (a string) of the HTML canvas on which we will render the image. The next two parameters are the number of grid cells in the x- and y-directions, respectively (more on this soon).

Changing the pixel color

The goal of this lab is to make clouds. Let's start by changing the pixel color from gray to blue. In order to change the color, we first need to get the canvas (declared in index.html) as well as a drawing context. Here, we will use the "2d" drawing context of the HTML canvas. Later in the course, we will use another context (WebGL) for drawing in 3d. The 2d context has a lot of built-in functions - please see here for more info. We only need a few functions today. In particular, we just need to create an image, assign pixel colors, and put that image onto the canvas.

let canvas = document.getElementById(this.canvasId);
let context = canvas.getContext("2d");
let image = canvas.createImageData(canvas.width, canvas.height);

let r = 0.5;
let g = 0.5;
let b = 0.5;

for (let j = 0; j < canvas.height; ++j) {
    for (let i = 0; i < canvas.width; ++i) {
        this.setPixel(image, i, j, r, g, b);
    }
}
this.context.putImageData(image, 0, 0); // put our image in the top-left corner of the canvas

Here we are assigning color values between 0 and 1. The 2d context actually works with values between 0 and 255, so note the scaling by 255 in the setPixel function. Try chaning the r, g and b values to make a shade of blue of your choice.

Practicing with glMatrix

Let's now practice with some linear algebra using the glMatrix library. Instead of creating separate variables for the r, g and b values, we will create a single color variable that will interpolate between two colors:

const blue = vec3.fromValues(0, 0, 1);
const white = vec3.fromValues(1, 1, 1);

const weight = 0.5;
const color = vec3.create();
vec3.lerp(color, blue, white, weight);

Here, we are using the lerp function defined in the vec3 namespace of glMatrix. This function performs "linear interpolation" between two vectors. In vector notation, this is equivalent to:
$$
\mathbf{c} = \mathbf{c}_0 + t (\mathbf{c}_1 - \mathbf{c}_0)
$$

where $\mathbf{c}_0$ is blue and $\mathbf{c}_1$ is white in our case, and $\mathbf{c}$ is the color we will assign to the pixel. We'll call $t$ the "interpolation variable" - it can have a value between 0 and 1. When $t = 0$, the color is blue and when $t = 1$, the color is white.

We could have directly just set color = vec3.fromValues(0.5, 0.5, 1) but the reason we did it this way will be helpful for when you work on the rest of the lab. Note that the output variable is the first argument to the vec3.lerp function - a lot of glMatrix functions have this function signature (and it's the source of a lot of bugs!). Please see the glMatrix documentation for a complete description of the library:

https://glmatrix.net/docs/

Why are we allowed to use these glMatrix functions? Note there is another <script> tag which imports gl-matrix in index.html. When imported here, all our scripts will have access to the functions & classes defined in that script.

Finally, change the call to the setPixel function using the three components of your newly created color variable. Hint: the first component is at color[0].

Complete the PerlinNoise class

The primary component of this lab consists of completing the PerlinNoise class, specifically extending the draw function to render Perlin noise. The Perlin noise generation algorithm consists of two main ingredients.

Ingredient 1: Making a grid.

The first ingredient is the idea of creating a background grid with a bunch of 2d vectors defined at the nodes of the grid. A grid is a bunch of square cells, and each corner of a cell is a point (or node) in the grid. Let's say there are nx cells in the x-direction and ny cells in the y-direction (nx * ny cells total). This means there are nx + 1 points in the x-direction and ny + 1 points in the y-direction ((nx + 1) * (ny + 1) nodes total). Here is a 4x4 grid (the dots correspond to the nodes/points):

We will define a random 2d vector at each point in the grid. We can do this by generating a random angle between 0 and $2\pi$ and then using cos and sin to get the x- and y-components of the vector. Create the vector using the glMatrix vec2.fromValues function:

const randomGradient = function () {
  const randomAngle = 2.0 * Math.PI * Math.random();
  let v = vec2.fromValues(Math.cos(randomAngle), Math.sin(randomAngle));
  return v;
};

We will represent our grid as a flattened 1d array of 2d vectors - remember, there is a vector for each grid point:

let grid = new Array((nx + 1) * (ny + 1));
for (let k = 0; k < grid.length; ++k)
    grid[k] = randomGradient();
// it'll be convenient later to store these
grid.nx = nx;
grid.ny = ny;

We could have a multi-dimensional array, but the flattened array will be more efficient and it will be helpful to be familiar with this concept for the rest of the course. For a grid cell with index (gx, gy), what is the index of the cell in our 1d array? (We'll answer this together in class). Hint: have a look at the offset variable in the setPixel function.

How does this relate to the grid of pixels we use to represent an image?

There are actually two grids here (and maybe more in the last section of the lab). The first grid is our usual grid of pixels with, say, w pixels along the width and h pixels along the height of the image. The second grid is overlaid on our image grid. In the image below, each pixel is associated with a blue dot in a 24 x 16 image. The red lines show the boundaries of the cells of the 6 x 4 background grid, and the black dots are the nodes of this background grid. The black dots represent where the random 2d gradients are stored.

Recall that our goal is to assign a color for each pixel. In this lab, we'll need to figure out the coordinates of a pixel in the "grid system" since that's where the gradients are stored. For pixel (i, j), the question we want to answer is:

How many grid cells do we need to traverse in the horizontal direction (x), and how many in the vertical direction (y) in order to get to a particular pixel?

In our example, note that there are 4 full pixels along the width and height of each grid cell. This can be more generally calculated as lx = w / nx and ly = h / ny. Then the answer to the question above is:

x = (i + 0.5) / lx and y = (j + 0.5) / ly.

Why the 0.5? It doesn't really matter, but notice the blue dots are at the center of every pixel. Note that these will be floating-point values.

Ingredient 2: Calculating Noise.

So we can create a grid and calculate the coordinates of pixels within this grid! We will now compute the Perlin noise by calculating a weight (or intensity) for every pixel.

Every pixel will lie within some grid cell and we will use the four gradient vectors (at the black dots) of the enclosing grid cell to calculate the weight. Let's focus in on the green pixel from the image above, which is located within the yellow grid cell:

We can retrieve these four vectors by just knowing the lower-left corner of the grid cell the pixel is in. Letting (ax, ay) denote this corner, the pixel is in the grid indexed by:

const ax = Math.floor(x);
const ay = Math.floor(y);

which are the indices of the lower-left corner. Then the bottom-right corner is (bx, by) = (ax + 1, ay), the top-left corner is (cx, cy) = (ax, ay + 1) and the top-right corner is (dx, dy) = (ax + 1, ay + 1).

Next, Perlin's algorithm says we need to calculate the dot product between the gradient at (ax, ay) and the offset vector from (ax, ay) to (x, y). Since we will do this computation four times, we'll write a function to help us, which takes in a general node of the grid (gx, gy).

const dotGridGradient = function(grid, gx, gy, x, y) {
    // calculate offset vector from g = (gx, gy) to p = (x, y) -> delta = p - g.

    // retrieve gradient at (gx, gy) -> gradient = grid[some index calculated from gx, gy grid.nx and/or grid.ny]

    // return dot product between delta and gradient using vec2.dot function
};

The first call to this function should look like:

const da = dotGridGradient(grid, ax, ay, x, y);

Then we need to calculate the values at the other (three) corners of the grid cell, yielding db, dc and dd.

Finally, we interpolate along the x-direction:

const wab = interpolate(da, db, x - ax);
const wcd = interpolate(dc, dd, x - ax);

And then along the y-direction:

const weight = interpolate(wab, wcd, y - ay);

The interpolate function can be a simple lerp:

const interpolate = function(a, b, t) {
    return a + (b - a) * t;
}

Alternatively, you can use cubic Hermite interpolation: $w = a + (b - a) (3 - 2t) t^2$, or even better Perlin's interpolation: $w = a + (b - a) t^3 (6t^2 - 15t + 10)$. Now, you can call this function for every pixel, which will determine the weight that is used to interpolate between the two colors we had at the start of the lab. Using white and blue with a 8x8 grid should yield the following image:

You can change the blue to vec3.fromValues(0.1, 0.43, 0.78) to make it look more like a sky.

Making clouds

Instead of using a single grid, we can initialize our PerlinNoise class with a sequence of grids. Then to calculate the weight at a particular pixel, loop through all the grids, calculate the weight contributed by each grid and attenuate the weight by some "amplitude". When we add up all of these weight contributions, we obtain:

with amplitudes (from left to right) of 1, 0.5, 0.25, 0.125 on 2x2, 4x4, 8x8 and 16x16 grids to obtain:

Please feel free to be creative with the colors here! With a total of 6 grids, here is what I see using the above scheme (again, the weights are halved with each grid):

Submission

The initial submission for the lab is due on Wednesday 09/20 at 11:59pm EDT. I will then provide feedback within 1 week so you can edit your submission.

Please complete the lab by implementing Perlin's interpolation in the interpolate function and render clouds using multiple grids. Please use at least 4 grids and try to make the number of grids an input variable to the PerlinNoise constructor. I recommend always using powers of 2 for your image & grid sizes.

When you and your partner are ready, please submit the assignment on replit. I will then make comments (directly on your repl) and enter your current grade status at the top of the index.html file.

Please also remember to submit your reflection for this week in the Google Form below (available soon).



© Philip Claude Caplan, 2023 (Last updated: 2023-09-14)