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
- Load GIS heightfield data from earth or space datasets
- Define edge behavior and tiling
- Smoothing operations
- Take more operations from Houdini docs
- Heman
- HiRISE
- Pirate Maps - Michael Fogleman
- Terrarium - Michael Fogleman
- 3d Planet Generation
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