Agent skill
ta-procedural-terrain
Procedural terrain generation algorithms and techniques for Three.js/R3F. Research-based skill with patterns from THREE.Terrain, Nathan Pointer's landscape rendering, and academic papers.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/ta-procedural-terrain
SKILL.md
Procedural Terrain Generation Skill
"Research-backed algorithms for creating natural, playable terrain in multiplayer games."
Overview
This skill consolidates research from multiple sources on procedural terrain generation:
- THREE.Terrain library (IceCreamYou/THREE.Terrain) - Comprehensive procedural terrain engine
- Nathan Pointer's landscape rendering - Production techniques for semi-realistic browser terrain
- Academic research - FBM, Perlin/Simplex noise, Diamond-Square algorithms
When to Use This Skill
Use when your task involves:
- Creating procedural heightmap generators
- Implementing terrain generation algorithms (Perlin, Simplex, Diamond-Square)
- Building caldera/crater terrain with radial patterns
- Multi-pass noise composition for detail
- Terrain filtering and smoothing
- Height-based vertex coloring
Core Concepts
1. Heightmap Representation
// Heightmap as Float32Array - most efficient for WebGL
type Heightmap = Float32Array; // [0, 1] or actual heights
// 2D access pattern
function getAt(heightmap: Float32Array, x: number, z: number, size: number): number {
return heightmap[z * size + x];
}
function setAt(heightmap: Float32Array, x: number, z: number, size: number, value: number): void {
heightmap[z * size + x] = value;
}
2. Noise Fundamentals
Perlin Noise (Classic)
// Using Three.js ImprovedNoise
import { ImprovedNoise } from 'three/examples/jsm/math/ImprovedNoise.js';
const noise = new ImprovedNoise();
const value = noise.noise(x, y, z); // Returns [-1, 1]
Simplex Noise (Faster, Less Directional Artifacts)
// Simplex is ~10% faster than Perlin
// Better dimensional scaling
// Fewer directional artifacts
FBM (Fractal Brownian Motion)
/**
* FBM stacks multiple octaves of noise:
* - Low frequency: Large terrain shapes (hills, valleys)
* - High frequency: Small details (bumps, texture)
*/
function fbm(
x: number, z: number,
octaves: number,
persistence: number,
lacunarity: number,
noise: ImprovedNoise
): number {
let total = 0;
let frequency = 1;
let amplitude = 1;
let maxValue = 0;
for (let i = 0; i < octaves; i++) {
total += noise.noise(x * frequency, z * frequency, 0) * amplitude;
maxValue += amplitude;
amplitude *= persistence; // Amplitude decay (0.5)
frequency *= lacunarity; // Frequency increase (2.0)
}
return total / maxValue; // Normalize to [-1, 1]
}
3. Generation Algorithms
Diamond-Square (Classic Heightmap)
/**
* Diamond-Square Algorithm
* Good for: Self-similar terrain, fractal appearance
* Time: O(n log n) where n = grid size
*
* Based on: https://github.com/IceCreamYou/THREE.Terrain
*/
function diamondSquare(
size: number, // Must be power of 2 + 1
maxHeight: number,
roughness: number // Height randomness (0-1)
): Float32Array {
const segments = Math.pow(2, Math.ceil(Math.log2(size - 1)));
const gridSize = segments + 1;
const heightmap = new Float32Array(gridSize * gridSize);
const smoothing = maxHeight * 2;
// Initialize corners
heightmap[0] = Math.random() * maxHeight;
heightmap[gridSize - 1] = Math.random() * maxHeight;
heightmap[(gridSize - 1) * gridSize] = Math.random() * maxHeight;
heightmap[gridSize * gridSize - 1] = Math.random() * maxHeight;
for (let step = segments; step >= 2; step /= 2) {
const half = step / 2;
smoothing /= 2;
// Diamond step
for (let x = 0; x < segments; x += step) {
for (let z = 0; z < segments; z += step) {
const avg = (
heightmap[z * gridSize + x] +
heightmap[z * gridSize + x + step] +
heightmap[(z + step) * gridSize + x] +
heightmap[(z + step) * gridSize + x + step]
) / 4;
const center = (z + half) * gridSize + (x + half);
heightmap[center] = avg + (Math.random() * 2 - 1) * smoothing;
}
}
// Square step
for (let x = 0; x <= segments; x += half) {
for (let z = (x + half) % step; z <= segments; z += step) {
let count = 0;
let sum = 0;
// Sample neighbors
const neighbors = [
[x - half, z], [x + half, z],
[x, z - half], [x, z + half]
];
for (const [nx, nz] of neighbors) {
if (nx >= 0 && nx <= segments && nz >= 0 && nz <= segments) {
sum += heightmap[nz * gridSize + nx];
count++;
}
}
heightmap[z * gridSize + x] = sum / count + (Math.random() * 2 - 1) * smoothing;
}
}
}
return heightmap;
}
Perlin Layers (Multi-Octave)
/**
* Perlin Layers - Multiple Perlin passes at different frequencies
* Good for: Natural-looking hills and mountains
*/
function perlinLayers(config: {
size: number,
maxHeight: number,
seed: number
}): Float32Array {
const { size, maxHeight, seed } = config;
const noise = new ImprovedNoise();
const heightmap = new Float32Array(size * size);
// Layer 1: Base terrain (large features)
for (let z = 0; z < size; z++) {
for (let x = 0; x < size; x++) {
let height = 0;
// Octave 1: Large shapes (frequency: 1.25)
height += noise.noise(x * 0.01 + seed, z * 0.01 + seed, 0) * 0.5 * maxHeight;
// Octave 2: Medium detail (frequency: 2.5)
height += noise.noise(x * 0.02 + seed, z * 0.02 + seed, 0) * 0.25 * maxHeight;
// Octave 3: Fine detail (frequency: 5.0)
height += noise.noise(x * 0.05 + seed, z * 0.05 + seed, 0) * 0.125 * maxHeight;
// Octave 4: Micro detail (frequency: 10.0)
height += noise.noise(x * 0.1 + seed, z * 0.1 + seed, 0) * 0.0625 * maxHeight;
heightmap[z * size + x] = (height / maxHeight + 1) * 0.5 * maxHeight;
}
}
return heightmap;
}
Fault Lines (Dramatic Terrain)
/**
* Fault Line Algorithm
* Good for: Canyons, cliffs, dramatic elevation changes
*
* Based on: http://www.lighthouse3d.com/opengl/terrain/index.php3?fault
*/
function faultLines(config: {
size: number,
maxHeight: number,
iterations: number
}): Float32Array {
const { size, maxHeight, iterations } = config;
const heightmap = new Float32Array(size * size);
const displacement = maxHeight / iterations / 2;
const d = Math.sqrt(size * size * 2); // Diagonal distance
for (let k = 0; k < iterations; k++) {
const v = Math.random();
const a = Math.sin(v * Math.PI * 2); // Random direction
const b = Math.cos(v * Math.PI * 2);
const c = Math.random() * d - d / 2; // Random offset
for (let z = 0; z < size; z++) {
for (let x = 0; x < size; x++) {
const distance = a * x + b * z - c;
const idx = z * size + x;
if (distance > 0) {
heightmap[idx] += displacement;
} else {
heightmap[idx] -= displacement;
}
}
}
}
return normalize(heightmap, 0, maxHeight);
}
Hill/Radial Deposition
/**
* Hill Algorithm
* Good for: Rolling hills, islands, organic shapes
*
* Based on: THREE.Terrain.Hill
*/
function hillAlgorithm(config: {
size: number,
maxHeight: number,
numHills: number
}): Float32Array {
const { size, maxHeight, numHills } = config;
const heightmap = new Float32Array(size * size);
for (let i = 0; i < numHills; i++) {
// Random hill center
const hx = Math.random() * size;
const hz = Math.random() * size;
// Random radius and height
const radius = 5 + Math.random() * 15;
const hillHeight = maxHeight * (0.1 + Math.random() * 0.2);
// Apply hill to heightmap
for (let z = 0; z < size; z++) {
for (let x = 0; x < size; x++) {
const dist = Math.sqrt((x - hx) ** 2 + (z - hz) ** 2);
if (dist < radius) {
// Cosine hill shape
const t = dist / radius;
const influence = (Math.cos(t * Math.PI) + 1) / 2;
heightmap[z * size + x] += hillHeight * influence;
}
}
}
}
return normalize(heightmap, 0, maxHeight);
}
4. Caldera/Crater Terrain
/**
* Generate Caldera/Crater Terrain
* Creates a volcanic caldera topology with:
* - Central water body (deep depression below water level)
* - Steep rising walls around the center
* - Dramatic crater rim with directional ridges (starburst pattern)
* - Rugged outer mountains
*
* @param size Grid size (128 = 128x128 heightmap)
* @param maxHeight Maximum terrain height in meters
* @param waterLevel Water level in meters (typically 4-5m)
* @param seed Random seed for noise variation
*/
function generateCaldera(
size: number,
maxHeight: number,
waterLevel: number,
seed: number
): Float32Array {
const noise = new ImprovedNoise();
const heightmap = new Float32Array(size * size);
const centerX = size / 2;
const centerZ = size / 2;
// Define ridge directions for "starburst" pattern (8 ridges radiating from center)
const ridgeAngles = [
0, Math.PI / 4, Math.PI / 2, 3 * Math.PI / 4,
Math.PI, 5 * Math.PI / 4, 3 * Math.PI / 2, 7 * Math.PI / 4
];
for (let z = 0; z < size; z++) {
for (let x = 0; x < size; x++) {
// Normalized distance from center (0 at center, ~1.4 at corners)
const dx = (x - centerX) / (size / 2);
const dz = (z - centerZ) / (size / 2);
const dist = Math.sqrt(dx * dx + dz * dz);
// Calculate angle from center for directional variation
const angle = Math.atan2(dz, dx); // -PI to PI
// Calculate directional multiplier for ridges (starburst pattern)
let ridgeMultiplier = 1.0;
for (const ridgeAngle of ridgeAngles) {
const angleDiff = Math.abs(angle - ridgeAngle);
const angleDiffAlt = Math.abs(angle - (ridgeAngle + Math.PI));
const minDiff = Math.min(angleDiff, angleDiffAlt);
// Gaussian-like bump centered on ridge direction
ridgeMultiplier += Math.exp(-minDiff * minDiff * 3) * 0.4;
}
// Caldera zone definitions (normalized distance)
const craterFloorRadius = 0.25; // Central lake floor
const wallStartRadius = 0.25; // Where steep walls begin
const wallEndRadius = 0.45; // Where walls end, rim begins
const rimPeakRadius = 0.55; // Peak of crater rim
const outerSlopeStart = 0.60; // Start of outer mountains
let height = 0;
if (dist < craterFloorRadius) {
// CENTRAL CRATER LAKE (deep depression)
const t = dist / craterFloorRadius;
const bowlShape = t * t;
const depthBelowWater = 3.0; // meters
height = (waterLevel - depthBelowWater + bowlShape * depthBelowWater) / maxHeight;
// Add subtle variation to lake floor
height += noise.noise(x * 0.15 + seed, z * 0.15 + seed, 0) * 0.03;
} else if (dist < wallEndRadius) {
// CRATER WALLS (steep rise from lake)
const t = (dist - wallStartRadius) / (wallEndRadius - wallStartRadius);
const wallShape = t * t * (3 - 2 * t); // Smoothstep
const baseHeight = waterLevel / maxHeight + wallShape * 0.7;
// Add directional ridge variation
height = baseHeight * ridgeMultiplier;
// Add rugged noise to walls
const wallNoise = fbm(x, z, 4, 0.5, 2.0, noise);
height += wallNoise * 0.15 * t;
} else if (dist < outerSlopeStart) {
// CRATER RIM (highest point with ridges)
const t = (dist - wallEndRadius) / (outerSlopeStart - wallEndRadius);
const normalizedDist = (dist - wallEndRadius) / (rimPeakRadius - wallEndRadius);
const domeShape = Math.max(0, 1 - normalizedDist * normalizedDist * 4);
height = 0.85 + domeShape * 0.25; // Base rim height
// Strong directional variation for starburst ridges
height *= ridgeMultiplier;
// Add rocky detail
height += noise.noise(x * 0.1, z * 0.1, seed + 50) * 0.08;
} else {
// OUTER MOUNTAINS (descending from rim)
const t = (dist - outerSlopeStart) / (1.0 - outerSlopeStart);
const slopeShape = Math.exp(-t * 2.5) * 0.6;
height = 0.3 + slopeShape;
// Add mountain noise
const mountainNoise = fbm(x, z, 5, 0.5, 2.0, noise);
height += mountainNoise * 0.4;
// Preserve some ridge structure in outer mountains
height *= (0.7 + ridgeMultiplier * 0.3);
// Add variation
height += noise.noise(x * 0.05, z * 0.05, seed + 100) * 0.1;
}
// Add base FBM noise for overall terrain detail
const baseNoise = fbm(x, z, 4, 0.5, 2.0, noise);
height += baseNoise * 0.08;
// Clamp and scale
height = Math.max(0, Math.min(1, height));
heightmap[z * size + x] = height * maxHeight;
}
}
return heightmap;
}
5. Filtering and Smoothing
/**
* Clamping - Rescale heightmap to fit within range
*/
function clamp(
heightmap: Float32Array,
minHeight: number,
maxHeight: number,
stretch: boolean = true
): Float32Array {
let min = Infinity;
let max = -Infinity;
// Find actual range
for (let i = 0; i < heightmap.length; i++) {
if (heightmap[i] < min) min = heightmap[i];
if (heightmap[i] > max) max = heightmap[i];
}
const actualRange = max - min;
const targetRange = maxHeight - minHeight;
for (let i = 0; i < heightmap.length; i++) {
const normalized = (heightmap[i] - min) / actualRange;
if (stretch) {
heightmap[i] = normalized * targetRange + minHeight;
} else {
heightmap[i] = Math.max(minHeight, Math.min(maxHeight, heightmap[i]));
}
}
return heightmap;
}
/**
* Smooth - Set each point to mean of neighbors
*/
function smooth(
heightmap: Float32Array,
size: number,
weight: number = 0
): Float32Array {
const smoothed = new Float32Array(heightmap.length);
for (let z = 0; z < size; z++) {
for (let x = 0; x < size; x++) {
let sum = 0;
let count = 0;
// 3x3 neighborhood
for (let nz = -1; nz <= 1; nz++) {
for (let nx = -1; nx <= 1; nx++) {
const cx = x + nx;
const cz = z + nz;
if (cx >= 0 && cx < size && cz >= 0 && cz < size) {
sum += heightmap[cz * size + cx];
count++;
}
}
}
const avg = sum / count;
smoothed[z * size + x] = (avg + heightmap[z * size + x] * weight) / (1 + weight);
}
}
return smoothed;
}
/**
* Edges - Create island walls or borders
*/
function edges(
heightmap: Float32Array,
size: number,
direction: 'up' | 'down',
distance: number,
easing: (t: number) => number = (t => t)
): Float32Array {
const peak = direction === 'up' ? 24 : 0;
const numSegments = Math.floor(distance / (256 / size)) || 1;
const xl = size;
const yl = size;
for (let i = 0; i < xl; i++) {
for (let j = 0; j < numSegments; j++) {
const multiplier = easing(1 - j / numSegments);
// Top edge
const k1 = j * xl + i;
heightmap[k1] = direction === 'up'
? Math.max(heightmap[k1], (peak - heightmap[k1]) * multiplier + heightmap[k1])
: Math.min(heightmap[k1], (peak - heightmap[k1]) * multiplier + heightmap[k1]);
// Bottom edge
const k2 = (size - 1 - j) * xl + i;
heightmap[k2] = direction === 'up'
? Math.max(heightmap[k2], (peak - heightmap[k2]) * multiplier + heightmap[k2])
: Math.min(heightmap[k2], (peak - heightmap[k2]) * multiplier + heightmap[k2]);
}
}
return heightmap;
}
6. Easing Functions
/**
* Common easing functions for terrain shaping
* From: THREE.Terrain core.js
*/
const Easing = {
Linear: (x: number) => x,
// x^2 - accelerates from zero
EaseIn: (x: number) => x * x,
// -x(x-2) - decelerates to zero
EaseOut: (x: number) => -x * (x - 2),
// x^2(3-2x) - smooth S-curve
EaseInOut: (x: number) => x * x * (3 - 2 * x),
// 0.5*(2x-1)^3+0.5 - inverse S-curve
InEaseOut: (x: number) => {
const y = 2 * x - 1;
return 0.5 * y * y * y + 0.5;
},
// x^1.55 - gentle ease-in
EaseInWeak: (x: number) => Math.pow(x, 1.55),
// x^7 - strong ease-in
EaseInStrong: (x: number) => Math.pow(x, 7)
};
7. Multi-Pass Composition
/**
* MultiPass - Layer multiple generation methods
* From: THREE.Terrain.MultiPass
*/
interface Pass {
method: (heightmap: Float32Array, config: any) => Float32Array;
amplitude?: number; // Height multiplier
frequency?: number; // Noise frequency (if supported)
}
function multiPass(
size: number,
passes: Pass[],
baseConfig: any
): Float32Array {
let heightmap = new Float32Array(size * size);
for (const pass of passes) {
const amp = pass.amplitude ?? 1;
const passConfig = { ...baseConfig, frequency: pass.frequency };
// Generate this pass
const passHeightmap = pass.method(new Float32Array(size * size), passConfig);
// Add to result with amplitude
for (let i = 0; i < heightmap.length; i++) {
heightmap[i] += passHeightmap[i] * amp;
}
}
return heightmap;
}
// Example: Layered terrain
const layeredTerrain = multiPass(128, [
{
method: (h) => perlinLayers({ size: 128, maxHeight: 24, seed: 42 }),
amplitude: 1.0,
frequency: 1.25
},
{
method: (h) => perlinLayers({ size: 128, maxHeight: 24, seed: 43 }),
amplitude: 0.5,
frequency: 2.5
},
{
method: (h) => perlinLayers({ size: 128, maxHeight: 24, seed: 44 }),
amplitude: 0.25,
frequency: 5.0
}
], {});
8. Applying to Three.js Geometry
/**
* Apply heightmap to Three.js PlaneGeometry
* Critical: Must recompute bounding sphere after vertex modification
*/
function applyHeightmapToGeometry(
geometry: THREE.PlaneGeometry,
heightmap: Float32Array,
config: { mapSize: number; segments: number; maxHeight: number }
): void {
const positions = geometry.attributes.position.array as Float32Array;
const colors = new Float32Array(positions.length);
const verticesPerRow = config.segments + 1;
for (let i = 0; i < positions.length; i += 3) {
const vertexIndex = i / 3;
const row = Math.floor(vertexIndex / verticesPerRow);
const col = vertexIndex % verticesPerRow;
const heightmapIndex = row * verticesPerRow + col;
const height = heightmap[heightmapIndex];
// Apply height to Y coordinate (vertices[i + 1])
positions[i + 1] = height;
// Height-based coloring
const color = getTerrainColor(height, config.maxHeight);
colors[i] = color.r;
colors[i + 1] = color.g;
colors[i + 2] = color.b;
}
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.computeVertexNormals();
// CRITICAL: Recompute bounding sphere after modifying vertices
// Without this, frustum culling may incorrectly cull the entire mesh
geometry.computeBoundingSphere();
}
function getTerrainColor(height: number, maxHeight: number): THREE.Color {
const waterLevel = maxHeight * 0.1875; // 4.5m / 24m
if (height < waterLevel) {
// Water gradient
const depth = waterLevel - height;
const t = Math.min(1, depth / waterLevel);
const deepWater = new THREE.Color(0x1a5276);
const shallowWater = new THREE.Color(0x2980b9);
return new THREE.Color().lerpColors(shallowWater, deepWater, t);
}
if (height < maxHeight * 0.33) return new THREE.Color(0xc2b280); // Sand
if (height < maxHeight * 0.58) return new THREE.Color(0x4d7c0f); // Grass
if (height < maxHeight * 0.83) return new THREE.Color(0x6b5344); // Dirt
return new THREE.Color(0x8b7355); // Rock
}
Algorithm Selection Guide
| Desired Terrain | Best Algorithm | Parameters |
|---|---|---|
| Rolling hills | Perlin Layers | octaves: 4, persistence: 0.5 |
| Mountains | Perlin + Fault | High frequency, high iterations |
| Crater/Caldera | Radial + FBM | Custom radial zones |
| Islands | Hill + Edges | numHills: 50+, edges: 'down' |
| Canyons | Fault Lines | iterations: 1000+ |
| Natural mixed | MultiPass | Layer multiple methods |
Performance Considerations
Triangle Count Budget
// PlaneGeometry(256, 256, 128, 128)
// Non-indexed: 128 * 128 * 2 = 32,768 triangles
// This is within the <50K budget for terrain
// For larger maps, consider LOD
function getTerrainSegments(distance: number): number {
if (distance < 50) return 128; // High detail nearby
if (distance < 100) return 64; // Medium
return 32; // Low detail far away
}
Memory Optimization
// Use Float32Array, not regular Array
const heightmap = new Float32Array(size * size); // ~4x less memory
// Reuse noise instance
const noise = new ImprovedNoise(); // Create once, use many times
// Avoid creating temporary arrays in loops
Common Issues and Solutions
Terrain Looks Flat
Cause: Heightmap values not properly scaled or noise frequency too high.
Fix:
// Check actual height range
const heights = Array.from(heightmap);
console.log('Height stats:', {
min: Math.min(...heights),
max: Math.max(...heights),
avg: heights.reduce((a, b) => a + b, 0) / heights.length
});
// Use clamp to normalize
heightmap = clamp(heightmap, 0, maxHeight, true);
Visible Grid Artifacts
Cause: Frequency too low relative to grid size.
Fix:
// Increase noise frequency or reduce grid size
const frequency = size / 100; // Adjust based on grid size
Terrain Too Smooth
Cause: Not enough octaves or persistence too low.
Fix:
// Add more octaves with higher persistence
fbm(x, z, 6, 0.6, 2.0, noise); // More detail
Research Sources
-
THREE.Terrain Library - https://github.com/IceCreamYou/THREE.Terrain
- Multi-pass composition
- Diamond-Square implementation
- Filtering and smoothing algorithms
-
Nathan Pointer's Landscape Rendering - https://nathanpointer.com/blog/landscapes
- Heightmap/displacement maps
- Splat texturing for biomes
- Normal map blending
-
Perlin Noise Research - https://rtouti.github.io/graphics/perlin-noise-algorithm
- FBM fundamentals
- Octave summation
-
Procedural Generation Academic Papers:
- "Procedural terrain generation plane and spherical surfaces" - L Belda Calvo (2021)
- "TERRAIN GENERATION ALGORITHMS" - Niko Sainio
Related Skills
ta-terrain-mesh- Mesh-based terrain componentta-water-shader- Water plane with Gerstner wavesta-foliage-instancing- Grass placement on terrainta-paint-territory- Paint overlay systemta-territory-grid-cpu- CPU-based territory gridta-terrain-testing- E2E testing patterns
Implementation Checklist
When implementing procedural terrain:
- Select appropriate generation algorithm(s)
- Configure noise parameters (scale, octaves, persistence)
- Generate heightmap as Float32Array
- Apply filtering (smooth, clamp) as needed
- Apply heightmap to geometry vertices
- Add height-based vertex colors
- Recompute bounding sphere and vertex normals
- Test with wireframe to verify mesh structure
- Validate height range matches expectations
- Performance test at 60 FPS
Didn't find tool you were looking for?