Heightfields

Created May 13, 2025 (Today)Updated May 13, 2025 (Today)

Understanding Heightfields

A heightfield is a 2D array of height values that can represent terrain or other 3D surfaces. Each value in the array represents the elevation at that point. This note discusses and shows some interesting ways to work with heightfield data.

Note: Heightfields cannot have overhangs since they are a one to one mapping of height values to x, y coordinates. An overhang or cave would require more than one height value per point.

Data Structure

A basic heightfield can be represented as a 2D array where each value represents the elevation at that point.

const heightfield = [
    [0.4, 0.6, 1.0, 0.5],
    [0.5, 0.7, 0.8, 0.6],
    [0.2, 0.5, 0.6, 0.4],
    [0.0, 0.2, 0.4, 0.2],
]

I prefer to use a 1D array and convert to 2D with proper indexing.

How Are These Different From Heightmaps?

they aren't really, just different implementations

  • Data Structure: Heightfields typically store raw height values (like floating point numbers) while heightmaps often store height data encoded as grayscale image values (0-255)
  • Usage: Heightfields are commonly used in terrain generation and simulation while heightmaps are more common in graphics/rendering pipelines
  • Precision: Heightfields can store more precise height values since they aren't limited to 8-bit color depth
  • Format: Heightfields are usually stored as arrays/matrices while heightmaps are often stored as image files

In practice, heightmaps can be thought of as a specific representation of heightfield data optimized for graphics usage.

Displaying a Heightfield

This is a simplified pixel exact rendering of a heightfield. It takes advantage of Canvas upscaling and forces pixels to be square. Canvas pixel data size and heightfield size should match.

// Create an ImageData object to manipulate pixels directly
const imageData = ctx.createImageData(gridSize, gridSize)
const pixels = imageData.data // Uint8ClampedArray of RGBA values

// Iterate through each pixel in the image data
for (let i = 0; i < pixels.length; i += 4) {
    // Calculate x,y coordinates from the pixel index
    const x = (i / 4) % gridSize
    const y = Math.floor(i / 4 / gridSize)

    // Get height value from 1D heightfield array
    // values are expected to be between 0-1
    const height = heightfield[y * gridSize + x]

    // Convert height to RGB color using color mapping function
    const [r, g, b] = getColor(height)

    // Set RGBA values in pixel array
    pixels[i] = r // Red
    pixels[i + 1] = g // Green
    pixels[i + 2] = b // Blue
    pixels[i + 3] = 255 // Alpha (fully opaque)
}

// Draw the image data to the canvas
ctx.putImageData(imageData, 0, 0)

Simple Heightfield

White is 1.0, black is 0.0

Create a heightfield array and fill with values from a simple math function.

const gridSize = 64
let heightfield = new Float32Array(gridSize * gridSize)
for (let y = 0; y < gridSize; y++) {
    for (let x = 0; x < gridSize; x++) {
        const dx = x - gridSize / 2
        const dy = y - gridSize / 2
        const dist = Math.sqrt(dx * dx + dy * dy)
        heightfield[y * gridSize + x] = Math.sin(dist * 0.5)
    }
}

Heightfield Terrain Generation

Terrain generation using FBM noise

Terrain Height Values

A simple way to generate terrain is to layer different noise functions together. This is a technique called Fractal Brownian Motion (FBM) popularized by Inigo Quilez. I also like the interactive explanation by Patricio Gonzalez Vivo in his The Book of Shaders.

We're using the simplex noise here instead of Perlin noise because it's faster to compute and produces better results for our use case.

/**
 * Generates Fractal Brownian Motion noise by layering multiple octaves of simplex noise
 * @param {number} x - X coordinate to sample
 * @param {number} y - Y coordinate to sample
 * @param {number} scale - Base frequency scale. Higher values = more zoomed out noise
 * @param {number} octaves - Number of noise layers to combine
 * @param {number} persistance - How quickly amplitude decreases per octave (0-1)
 * @param {number} lacunarity - How quickly frequency increases per octave (typically 2)
 * @returns {number} Combined noise value between 0 and 1
 */
function noiseFBM(x, y, scale = 1.0, octaves = 4, persistance = 0.5, lacunarity = 2.0) {
    let amplitude = 1.0
    let frequency = scale
    let height = 0.0
    let maxAmplitude = 0.0
    for (let i = 0; i < octaves; i++) {
        height += simplex2(x * frequency, y * frequency) * amplitude
        maxAmplitude += amplitude

        amplitude *= persistance
        frequency *= lacunarity
    }
    return height / maxAmplitude
}

FBM noise can then be used to generate heightfield values.

const scale = 0.005
const octaves = 6
const persistance = 0.4
for (let y = 0; y < gridSize; y++) {
    for (let x = 0; x < gridSize; x++) {
        heightfield[y * gridSize + x] = noiseFBM(x, y, scale, octaves, persistance)
    }
}

Color Mapping

const terrainColors = [
    [0.3, [0, 0, 128]], // Deep water (dark blue)
    [0.4, [0, 0, 255]], // Shallow water (blue)
    [0.45, [194, 178, 128]], // Sand (beige)
    [0.6, [0, 170, 0]], // Grass 1 (darker green)
    [0.7, [0, 255, 0]], // Grass 2 (lighter green)
    [0.8, [96, 96, 96]], // Rock 1 (darker gray)
    [0.9, [160, 160, 160]], // Rock 2 (lighter gray)
    [1.0, [255, 255, 255]], // Snow (white)
]

function getTerrainColor(height) {
    for (let i = 0; i < terrainColors.length; i++) {
        const [level, rgb] = terrainColors[i]
        if (height <= level) {
            return rgb
        }
    }
    return [255, 255, 255] // Default to snow (fallback)
}

Better Terrain

Some useful techniques and ideas from Sebastian Lague in his Terrain Generation series.

Apply Falloff Mask

Simulate islands by applying a falloff mask to the edges of the heightfield. This falloff mask will be multiplied with the heightfield to diminish the height at the edges.

function generateFalloffMask(size, strength = 1.0, offset = 0.0) {
    const map = new Float32Array(size * size)

    for (let i = 0; i < size; i++) {
        for (let j = 0; j < size; j++) {
            // Convert coordinates to -1 to 1 range
            const x = (i / size) * 2 - 1
            const y = (j / size) * 2 - 1

            // Calculate distance from center (0,0)
            const distanceFromCenter = Math.sqrt(x * x + y * y)

            // Create smooth falloff with configurable strength
            let value = 1 - Math.pow(distanceFromCenter, strength)

            // Add offset and clamp between 0 and 1
            value = Math.max(0, Math.min(1, value + offset))

            map[i * size + j] = value
        }
    }

    return map
}

Apply Height Curve

Apply a height curve function to increase dramatic differences in height. This will compress lower heights and emphasize higher ones.

function applyHeightCurve(height, power = 2.0) {
    // Apply a power curve to emphasize higher elevations
    // Values < 1 will compress lower heights and emphasize higher ones
    return Math.pow(height, power)
}

Updated Example

Combining the falloff mask and height curve when generating the heightfield terrain, we get:

function generateTerrain(gridSize, scale, octaves, persistance, lacunarity) {
    let heightfield = new Float32Array(gridSize * gridSize)

    // new terrain each load
    seed(Math.random())

    // fill with heighfield values
    for (let y = 0; y < gridSize; y++) {
        for (let x = 0; x < gridSize; x++) {
            heightfield[y * gridSize + x] = noiseFBM(x, y, scale, octaves, persistance, lacunarity)
        }
    }

    // normalize for better display
    heightfield = normalizeHeightfield(heightfield)

    // apply falloff mask
    const falloffMap = generateFalloffMask(gridSize)
    for (let i = 0; i < heightfield.length; i++) {
        heightfield[i] *= falloffMap[i] * 2.0 // why 2.0? I don't remember
    }

    // apply height curve
    for (let i = 0; i < heightfield.length; i++) {
        heightfield[i] = applyHeightCurve(heightfield[i], 1.6)
    }

    return heightfield
}

Terrain Normals

In order to calculate normals, we need to calculate the gradient of the heightfield. This is a vector that points in the direction of the steepest ascent. The method we will use is called finite difference approximation.

∂h/∂x  (heightfield[x+1, y] - heightfield[x-1, y]) / 2
∂h/∂y  (heightfield[x, y+1] - heightfield[x, y-1]) / 2

However, at the edges of the grid, x-1, x+1, y-1, or y+1 might go out of bounds. So we clamp indices to stay within the grid (0 to gridSize - 1):

  • At the left edge: (heightfield[x+1] - heightfield[x])
  • At the right edge: (heightfield[x] - heightfield[x-1])

Giving us the final implementation:

function calcGrad(x, y, gridSize, hf) {
    // Clamp helper
    const clamp = (val, min, max) => Math.max(min, Math.min(max, val))

    // Get clamped neighbors
    const xm1 = clamp(x - 1, 0, gridSize - 1)
    const xp1 = clamp(x + 1, 0, gridSize - 1)
    const ym1 = clamp(y - 1, 0, gridSize - 1)
    const yp1 = clamp(y + 1, 0, gridSize - 1)

    // Look up height values
    const left = hf[y * gridSize + xm1]
    const right = hf[y * gridSize + xp1]
    const top = hf[ym1 * gridSize + x]
    const bottom = hf[yp1 * gridSize + x]

    // Compute gradient
    const gradX = xp1 !== xm1 ? (right - left) / (xp1 - xm1) : 0
    const gradY = yp1 !== ym1 ? (bottom - top) / (yp1 - ym1) : 0

    return [gradX, gradY]
}

3D Terrain

Heightfield to Mesh Code:

function heightfieldToMesh(heightfield, gridSize, scale = 1.0, heightScale = 50.0) {
    const vertices = new Float32Array(gridSize * gridSize * 3) // x, y, z for each vertex
    const indices = new Uint32Array((gridSize - 1) * (gridSize - 1) * 6) // 2 triangles (6 indices) per grid cell
    const normals = new Float32Array(gridSize * gridSize * 3)

    // Generate vertices
    for (let z = 0; z < gridSize; z++) {
        for (let x = 0; x < gridSize; x++) {
            const vertexIndex = (z * gridSize + x) * 3
            vertices[vertexIndex] = (x - gridSize / 2) * scale // X position
            vertices[vertexIndex + 1] = heightfield[z * gridSize + x] * scale * heightScale // Y position (height)
            vertices[vertexIndex + 2] = (z - gridSize / 2) * scale // Z position
        }
    }

    // Generate indices for triangles
    let indexCount = 0
    for (let z = 0; z < gridSize - 1; z++) {
        for (let x = 0; x < gridSize - 1; x++) {
            const topLeft = z * gridSize + x
            const topRight = topLeft + 1
            const bottomLeft = (z + 1) * gridSize + x
            const bottomRight = bottomLeft + 1

            // First triangle
            indices[indexCount++] = topLeft
            indices[indexCount++] = bottomLeft
            indices[indexCount++] = topRight

            // Second triangle
            indices[indexCount++] = topRight
            indices[indexCount++] = bottomLeft
            indices[indexCount++] = bottomRight
        }
    }

    // Calculate normals
    for (let z = 0; z < gridSize; z++) {
        for (let x = 0; x < gridSize; x++) {
            const vertexIndex = (z * gridSize + x) * 3

            // Get heights of neighboring vertices
            const left = x > 0 ? heightfield[z * gridSize + (x - 1)] : heightfield[z * gridSize + x]
            const right = x < gridSize - 1 ? heightfield[z * gridSize + (x + 1)] : heightfield[z * gridSize + x]
            const top = z > 0 ? heightfield[(z - 1) * gridSize + x] : heightfield[z * gridSize + x]
            const bottom = z < gridSize - 1 ? heightfield[(z + 1) * gridSize + x] : heightfield[z * gridSize + x]

            // Calculate normal using central differences
            const dx = (right - left) * scale * 50
            const dz = (bottom - top) * scale * 50

            // Cross product of tangent vectors
            normals[vertexIndex] = -dx
            normals[vertexIndex + 1] = 2.0 * scale
            normals[vertexIndex + 2] = -dz

            // Normalize
            const length = Math.sqrt(
                normals[vertexIndex] * normals[vertexIndex] +
                    normals[vertexIndex + 1] * normals[vertexIndex + 1] +
                    normals[vertexIndex + 2] * normals[vertexIndex + 2]
            )

            normals[vertexIndex] /= length
            normals[vertexIndex + 1] /= length
            normals[vertexIndex + 2] /= length
        }
    }

    return {
        vertices,
        indices,
        normals,
    }
}

export { heightfieldToMesh }

Resources

Further Study

https://www.youtube.com/watch?v=eaXk97ujbPQ https://www.youtube.com/watch?v=gsJHzBTPG0Y&t=850s https://entagma.com/live-rendering-of-heightfields-in-rs