Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Terrain

Live Demo: Terrain

Terrain is a regular mesh generated from a noise-based heightmap. The engine builds a grid of vertices at configurable resolution, samples a noise function at each vertex for its height, computes per-vertex normals from the surrounding triangle faces, and registers the result in the mesh cache. The terrain entity then renders through the standard mesh pipeline, with full PBR material support, shadow casting, and physics collision.

Mesh generation

generate_terrain_mesh builds the terrain in four steps.

  1. Vertex generation. A grid of resolution_x * resolution_z vertices, centered at the origin. X coordinates run from -width/2 to +width/2 and Z from -depth/2 to +depth/2. Each vertex's Y coordinate is the noise sample multiplied by height_scale. UV coordinates are the normalized grid position multiplied by uv_scale, which controls how textures tile across the surface.

  2. Index generation. Two triangles per grid cell, counter-clockwise. The two triangles for cell (x, z) use indices [top_left, bottom_left, top_right] and [top_right, bottom_left, bottom_right]. Total indices: (resolution_x - 1) * (resolution_z - 1) * 6.

  3. Normal calculation. Per-vertex normals are accumulated from the face normals of every adjacent triangle. Each face normal is the cross product of two triangle edges. After accumulation, normals are normalized to unit length.

  4. Bounding volume. An oriented bounding box is computed from the min and max heights, used for frustum culling during rendering.

Noise sampling

The terrain uses the noise crate with four algorithms.

NoiseTypeAlgorithmCharacter
PerlinFbmSmooth rolling hills
SimplexFbmSimilar to Perlin with fewer directional artifacts
BillowBillowRounded, cloud-like features
RidgedMultiRidgedMultiSharp ridges, suited to mountains

All four are wrapped in multi-octave fractional Brownian motion. fBm layers multiple noise samples at increasing frequency and decreasing amplitude. octaves is the number of layers. More octaves add finer detail at higher evaluation cost. lacunarity is the frequency multiplier per octave (default 2.0) and persistence is the amplitude multiplier per octave (default 0.5).

Physics integration

spawn_terrain automatically attaches a static rigid body with a heightfield collider. The heightfield shape stores a 2D grid of height values plus a scale factor. The height data is transposed from mesh ordering (z * resolution_x + x) to heightfield ordering (z + resolution_z * x) because Rapier expects column-major layout. The collider has friction 0.9, restitution 0.0, and every collision group enabled.

Enabling terrain

Terrain requires the terrain feature.

[dependencies]
nightshade = { git = "...", features = ["engine", "terrain"] }

Basic terrain

#![allow(unused)]
fn main() {
use nightshade::ecs::terrain::*;

fn initialize(&mut self, world: &mut World) {
    let config = TerrainConfig::new(100.0, 100.0, 64, 64)
        .with_height_scale(10.0)
        .with_frequency(0.02);

    spawn_terrain(world, &config, Vec3::zeros());
}
}

Terrain configuration

#![allow(unused)]
fn main() {
pub struct TerrainConfig {
    pub width: f32,           // Terrain width in world units
    pub depth: f32,           // Terrain depth in world units
    pub resolution_x: u32,   // Vertex count along X
    pub resolution_z: u32,   // Vertex count along Z
    pub height_scale: f32,   // Height multiplier for noise values
    pub noise: NoiseConfig,  // Noise generation settings
    pub uv_scale: [f32; 2],  // Texture tiling [u, v]
}
}

Builder methods

#![allow(unused)]
fn main() {
let config = TerrainConfig::new(200.0, 200.0, 128, 128)
    .with_height_scale(25.0)
    .with_noise(NoiseConfig {
        noise_type: NoiseType::RidgedMulti,
        frequency: 0.01,
        octaves: 6,
        lacunarity: 2.0,
        persistence: 0.5,
        seed: 42,
    })
    .with_uv_scale([8.0, 8.0]);
}

Terrain with material

#![allow(unused)]
fn main() {
let material = Material {
    base_color: [0.3, 0.5, 0.2, 1.0],
    roughness: 0.85,
    metallic: 0.0,
    ..Default::default()
};

spawn_terrain_with_material(world, &config, Vec3::zeros(), material);
}

Sampling terrain height

sample_terrain_height evaluates the noise function directly, without consulting the mesh. The cost is bounded by the octave count.

#![allow(unused)]
fn main() {
let height = sample_terrain_height(x, z, &config);
}

Place objects on the terrain surface by sampling the height at their XZ position and writing it back into the transform.

#![allow(unused)]
fn main() {
fn place_on_terrain(world: &mut World, entity: Entity, x: f32, z: f32, config: &TerrainConfig) {
    let y = sample_terrain_height(x, z, config);

    if let Some(transform) = world.core.get_local_transform_mut(entity) {
        transform.translation = Vec3::new(x, y, z);
    }
}
}

Rendering

Terrain renders through MeshPass, the same standard mesh pipeline used for every other mesh. That means terrain gets the full PBR pipeline.

  • Directional and point light shadows from cascaded shadow maps
  • Screen-space ambient occlusion (SSAO)
  • Screen-space global illumination (SSGI)
  • Image-based lighting (IBL)
  • Normal mapping when a normal texture is supplied on the material
  • The full Cook-Torrance BRDF with metallic-roughness workflow

The mesh is uploaded to GPU vertex and index buffers once at creation. During rendering, MeshPass performs frustum culling against the terrain's bounding volume, then draws it with the material's textures bound.

Entity components

spawn_terrain creates an entity with the following components.

ComponentPurpose
NAME"Terrain"
LOCAL_TRANSFORMPosition in world
GLOBAL_TRANSFORMComputed world matrix
LOCAL_TRANSFORM_DIRTYTriggers transform update
RENDER_MESHReferences cached mesh
MATERIAL_REFPBR material
BOUNDING_VOLUMEOBB for frustum culling
CASTS_SHADOWEnabled by default
RIGID_BODYStatic physics body
COLLIDERHeightField shape
VISIBILITYVisible by default