Lab 01: Perlin CloudsGoalsBy the end of this lab, you will:
|
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
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).
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.
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
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:
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]
.
PerlinNoise
classThe 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.
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 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.
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.
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:
You can change the blue to vec3.fromValues(0.1, 0.43, 0.78)
to make it look more like a sky.
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):
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)