Lab 6: Cloudy with a Meatball

Goals

The initial lab template can be accessed in the Lab 6 link on our discussion board. In Parts 1 - 4 of this lab, we will implement a procedural texturing technique called Perlin noise. Part 5 is optional and will provide more practice with the cut-and-paste texturing technique we implemented in class.

The Perlin noise generation algorithm consists of two main ingredients: (1) a background grid (or series of background grids) in which each node of the background grid(s) stores a random 2d unit vector and (2) a method to calculate noise from these random vectors.

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

Part 1: create random 2d vectors at each grid point.

We will define a random 2d unit vector at each node of the grid. We can do this by generating a random angle $\theta \in [0, 2\pi]$ by scaling Math.random() by $2\pi$ and then using Math.cos and Math.sin to get the x- and y-components of the vector. Create the vector using the glMatrix vec2.fromValues function with ($\cos\theta,\ \sin\theta$) and return this in the randomGradient function.

We will represent our grid as a 2d array of 2d vectors:

let grid = new Array(ngx + 1);
for (let i = 0; i <= ngx; i++) {
  grid[i] = new Array(ngy + 1);
  for (let j = 0; j <= ngy; j++) {
    grid[i][j] = randomGradient();
  }
}

Note that this 2d array is set up so that we can index the grid vectors similar to how point coordinates are represented. So, the gradient at the grid node with indices (gx, gy) can be accessed using grid[gx][gy].

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

There are actually two different grids here (and maybe more in the last section of the lab). The first grid is our usual grid of pixels with, say, npx pixels along the width and npy 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.

Part 2: calculate the grid-system coordinates of each pixel.

We need to figure out the coordinates of each 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 above, note that there are 4 full pixels along the width and height of each grid cell. This can be more generally calculated as lx = npx / ngx and ly = npy / ngy. 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. You can also assume the grid has the $y$-axis pointing downwards so that it aligns with the orientation of the canvas.

Part 3: Making noise.

The next step in the Perlin noise algorithm consists of calculating a weight (or intensity) for every pixel. This weight will be used to interpolate between two colors. Interpolating between white and a blue-ish color will allow us to make clouds.

Let's zoom into the yellow square (from the grid) in the picture above and focus on the green pixel (from the canvas). 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.

We can retrieve these four vectors by knowing the lower-left corner of the grid cell the pixel is in (technically this is the upper-left corner since the grid is aligned with the canvas). Recall that x and y represent how many grid cells (including fractions) to traverse in the horizontal and vertical directions. Therefore, we can round these down to the nearest integer to get the grid indices of the lower left corner. Letting (ax, ay) denote this corner, this grid point is indexed by:

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

So the gradient at $\vec{a} = (a_x, a_y)$ can be retrieved using ga = grid[ax][ay].

Part 3A: Using ax and ay, determine the indices of the grid nodes $\vec{b}$, $\vec{c}$ and $\vec{d}$. Hint: the indices are offsets of +1 on either ax or ay.

Next, Perlin's algorithm says we need to calculate the dot product between the gradient $\vec{g}_a$ at (ax, ay) and the offset vector from (ax, ay) to $(x, y)$. Letting $\vec{p} = (x, y)$, this would be $d_a = \vec{g}_a \cdot (\vec{p} - \vec{a})$.

Part 3B: Calculate $d_a$ as well as $d_b$, $d_c$ and $d_d$ for all four corners of the grid cell. The variables to store these results are already set up for you as da, db, dc and dd.

Finally, we'll interpolate the four scalars (da, db, dc, dd) along the x-direction (this part is already implemented):

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 weight is what we will return in the getPerlinWeight function.

Note that the interpolate function can be a simple lerp:

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

Using the resulting weight (computed for every pixel) to interpolate between blue and white on a 4x4 grid should yield the bottom-left image below (subject to variability because of Math.random). Notice that the result looks a bit blocky.

Part 3C: use a slightly better interpolation technique in the interpolate function. You can implement either cubic Hermite interpolation: $w = a + (b - a) (3 - 2t) t^2$ (middle), or Perlin's interpolation: $w = a + (b - a) t^3 (6t^2 - 15t + 10)$ (right).

Part 4: Making clouds

Instead of using a single grid, we can use multiple grids to create Perlin noise. Where you see Part 4A in the template, please initialize multiple grids (at least $N = 4$ grids), in which the $k^{\mathrm{th}}$ grid size is double the size of the previous $(k - 1)^{\mathrm{th}}$ grid, starting with a $2\times 2$ grid (you no longer need to use the incoming ngx or ngy values in the draw method).

Then, to calculate the weight at a particular pixel (Part 4B), loop through all the grids, calculate the weight contributed by each grid and attenuate the weight by some "amplitude". The final weight we use to interpolate the two colors (white and blue) at a pixel can be expressed as:

$$ w_p = \sum\limits_{k = 1}^{N} w_k a_k, $$

where $N$ is the number of grids, $w_k$ is the weight obtained from getPerlinWeight for grid $k$ and $a_k$ is the attenuation factor we picked for grid $k$. The final color at the pixel would now be:

color = vec3.lerp(vec3.create(), color0, color1, wp);

For example, the values for $w_k$ would produce the following for 2x2, 4x4, 8x8 and 16x16 grids:

When we add up the corresponding $w_k$ values with attenuation factors of 1, 0.5, 0.25, 0.125 on 2x2, 4x4, 8x8 and 16x16 grids (from left to right) we get:

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):

In your final submission, please use at least 4 grids to calculate the color at any given pixel. I recommend always using powers of 2 for your image & grid sizes.

Part 5: Adding a meatball (optional)

To really make this "cloudy with a meatball" (inspired by the movie), add a sphere in the scene and render the sphere with the texture image provided by the mars.jpg image. Adding specular highlights will kind of make Mars look like a meatball. If you do this, I would recommend setting up the sphere (and corresponding texture) similar to what we did in class.

Submission

The initial submission for the lab is due on Thursday 4/10 at 11:59pm EDT. Remember to complete the code for all required parts of the lab: Parts 1, 2, 3A, 3B, 3C, 4A, 4B.

Please see the Setup page for instructions on how to commit and push your work to your GitHub repository and then submit your repository to Gradescope (in the Lab 6 assignment). I will then provide feedback within 1 week so you can edit your submission.


© Philip Caplan, 2025