Heightmaps
Some humans prefer mountains; some prefer plains. But no one prefers a wireframe plane. Let's get our camera moving across some real virtual terrain. Though terrain can be modeled in a 3D modeling program, it is often compactly stored as a grayscale image—a heightmap—like this one:

This heightmap was generated from elevation data around Harrisonburg, Virginia, by a heightmap generator.
We need a way for our renderers to read image files. Earlier we read in shaders and meshes using fetch
. That worked well because these files were just text. Image files are far more complex and need to be decoded. A better option is to create a new HTMLImageElement
, which is the type that corresponds to an HTML img
element. It supports many different image formats. Unfortunately, HTMLImageElement
is a bit messy to use.
The constructor for HTMLImageElement
is awkwardly named Image
. When we set an image's src
attribute to a URL, the browser automatically downloads and parses the file in the background. To pause until the image has been fully loaded, we await the promise returned by its decode
method:
export async function fetchImage(url: string): Promise<HTMLImageElement> {
const image = new Image();
image.src = url;
await image.decode();
return image;
}
export async function fetchImage(url: string): Promise<HTMLImageElement> { const image = new Image(); image.src = url; await image.decode(); return image; }
Place this method in lib/web-utilities.js
.
HTMLImageElement
doesn't give us direct access to the pixel data. Therefore we will make our own class for managing a two-dimensional field of grayscale values. It will have several uses later in the semester, so we avoid coupling it specifically to terrain. We give it the vanilla name Field2
and have it store the dimensions and an array of values in [0, 1].
We'll think of the field as existing in the xy-plane, but we'll need to mentally rotate it to the xz-plane when we generate the terrain mesh. Insert this code in lib/field.js
.
To get at the pixels in an HTMLImageElement
, we must draw the image to a HTMLCanvas
element and then pull out the bytes. The canvas stores pixels as 4-byte RGBA values, even if the image is grayscale. This utility method draws, extracts, selects out just the red intensities, and builds a Field2
:
static readFromImage(image: HTMLImageElement): Field2 {
// Go through canvas to get the pixel data.
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d') as CanvasRenderingContext2D;
canvas.width = image.width;
canvas.height = image.height;
context.drawImage(image, 0, 0, image.width, image.height);
const pixels = context.getImageData(0, 0, image.width, image.height);
// The canvas is RGBA. Extract only the red channel.
const grays = new Array(image.width * image.height);
for (let i = 0; i < image.width * image.height; ++i) {
grays[i] = pixels.data[i * 4] / 255;
}
return new Field2(image.width, image.height, grays);
}
static readFromImage(image: HTMLImageElement): Field2 { // Go through canvas to get the pixel data. const canvas = document.createElement('canvas'); const context = canvas.getContext('2d') as CanvasRenderingContext2D; canvas.width = image.width; canvas.height = image.height; context.drawImage(image, 0, 0, image.width, image.height); const pixels = context.getImageData(0, 0, image.width, image.height); // The canvas is RGBA. Extract only the red channel. const grays = new Array(image.width * image.height); for (let i = 0; i < image.width * image.height; ++i) { grays[i] = pixels.data[i * 4] / 255; } return new Field2(image.width, image.height, grays); }
All that remains is a method to inflate the field into a trimesh. The code is nearly identical to what we wrote to generate a grid.
Once we add these methods to your Field2
class and put a heightmap image in our project, we can generate a mesh and render it. The last step is to build a camera that glides over the terrain.