Texturing is an effective way to add detail without adding geometry to your scene. However, sometimes you really do want the texture to add geometric detail. A common example is terrain. The elevations of the terrain are compactly stored in an image called a heightmap, such as this one that was created in The GIMP using the Solid Noise plugin:
You don't just paint this heightmap onto a quadrilateral; you expand it into a bumpy triangle mesh upon which you can walk. This renderer shows the vertices of the triangle mesh:
Use the WASD keys and mouse to control the camera.
Managing a terrain is complex enough to warrant some utility methods and abstractions.
Heightmap images are often grayscale images. Each pixel is a single intensity representing an elevation of the land. When you read an image into a browser, however, it gets converted to a 4-channel RGBA image. The red, green, and blue intensities are all the same; the extra data isn't helpful.
Another issue is that the native Image
object doesn't give you access to the raw pixel data. This isn't a concern when you're using the image as a normal texture. WebGL knows how to extract out the pixel data to send it up to the GPU. But when you're generating a heightmap, you're building the terrain mesh on the CPU. You need those pixels.
You overcome both of these issues by writing a little utility method that converts an Image
into an array of grayscale values. The image is drawn to a canvas element and then the canvas's RGBA colors are extracted. You loop through the color data and pull out just the red intensities into an array. The method might look like this:
function imageToGrayscale(image) {
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0, image.width, image.height);
const pixels = context.getImageData(0, 0, image.width, image.height);
const grays = new Array(image.width * image.height);
for (let i = 0; i < image.width * image.height; ++i) {
grays[i] = pixels.data[i * 4];
}
return grays;
}
The returned array is a row-major serialization of the 2D elevation grid.
When you're using a heightmap to add terrain into your renderer, there are two big algorithmic tasks that you need to solve. One is building the triangular mesh. The other is positioning entities like the viewer, trees, and rocks on the terrain. You want to make sure the entity is placed at the right elevation. Encapsulating these operations in a reusable Terrain
class is a good idea. A possible interface is described here.
The constructor receives the one-dimensional array of elevations as returned by imageToGrayscale
and the heightmap's dimensions. It hangs on to them for later use. The dimensions are the original image's width and height. In the context of the heightmap, however, they are width and depth. The terrain will span the xz-plane, with the elevations bumping the terrain up and down on the y-axis.
The array of elevations is a serialized array, so you want to provide a more natural 2D interface for accessing a cell's elevation with this getter and setter:
get(x, z)
return elevations[z * width + x]
set(x, z, elevation)
elevations[z * width + x] = elevation
Parameters x
and z
are expected be integers. These methods should only be called to determine the elevation at integer locations on the terrain's lattice.
The heightmap isn't really a texture; it's just a compact way of expressing the shape of the terrain. What you need is to turn the heightmap into a triangular mesh. Generating the mesh's geometry is a lot like generating other 2D parametric surfaces.
Your algorithm might look something like this:
toTrimesh()
positions = []
for z in [0, depth)
for x in [0, width)
y = get(x, z)
positions.push(x, y, z)
faces = []
for z in [0, depth - 1)
nextZ = z + 1
for x in [0, width - 1)
nextX = x + 1
faces.push([
z * width + x,
z * width + nextX,
nextZ * width + x
])
faces.push([
z * width + nextX,
nextZ * width + nextX,
nextZ * width + x
])
return new Trimesh(positions, faces)
Once this method is written, you can render your terrain just as you render any triangular mesh. You may want to compute its normals. If you plan to apply textures to the terrain, you may also want to give it texture coordinates.
As your camera moves across the terrain, you want to adjust its y-coordinate so that it floats above the terrain. That means you'll need to be able to ask the terrain what the elevation is at the camera's xz-position. The position will likely not be exact integers, so you need a way of computing the elevation at locations between the lattice points. This sounds a lot like the bilinear interpolation that happens in texturing.
Your elevation lookup function blerp
needs to perform three linear interpolations as you saw earlier. This pseudocode implementation interpolates along the x-axis first and then along the z-axis:
blerp(x, z)
floorX = floor(x)
floorZ = floor(z)
fractionX = x - floorX
fractionZ = z - floorZ
nearLeft = get(floorX, floorZ)
nearRight = get(floorX + 1, floorZ)
nearMix = lerp(fractionX, nearLeft, nearRight)
farLeft = get(floorX, floorZ + 1)
farRight = get(floorX + 1, floorZ + 1)
farMix = lerp(fractionX, farLeft, farRight)
return lerp(fractionZ, nearMix, farMix)
You may want to add bounds-checking to make sure the parameters and their successors are valid indices.
The Camera
class you wrote earlier allows free movement in all directions. The viewer can fly into sky or descend into the nether. You need a version that makes the viewer stick to the terrain, adjusting its elevation on each advance and strafe. Instead of writing a brand new class, you can extend the original one you wrote.
The subclass below introduces two big changes: a terrain and eye level have been added as parameters and there's now a buoy
method that must be called whenever the camera's position changes.
class TerrainCamera extends Camera
constructor(from, to, worldUp, terrain, eyeLevel)
// ...
buoy
reorient
buoy()
// clamp from.x and from.z to valid terrain coordinates
from.y = interpolated height + eye level
advance(delta)
calculate new from
buoy
reorient
strafe(delta)
calculate new from
buoy
reorient
The buoy
method adjusts the camera's y-coordinate so that the eye is always at a fixed height above the terrain. If uses the camera's x- and z-coordinates to look up the interpolated elevation.