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.
-
Vertex generation. A grid of
resolution_x * resolution_zvertices, centered at the origin. X coordinates run from-width/2to+width/2and Z from-depth/2to+depth/2. Each vertex's Y coordinate is the noise sample multiplied byheight_scale. UV coordinates are the normalized grid position multiplied byuv_scale, which controls how textures tile across the surface. -
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. -
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.
-
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.
| NoiseType | Algorithm | Character |
|---|---|---|
Perlin | Fbm | Smooth rolling hills |
Simplex | Fbm | Similar to Perlin with fewer directional artifacts |
Billow | Billow | Rounded, cloud-like features |
RidgedMulti | RidgedMulti | Sharp 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.
| Component | Purpose |
|---|---|
NAME | "Terrain" |
LOCAL_TRANSFORM | Position in world |
GLOBAL_TRANSFORM | Computed world matrix |
LOCAL_TRANSFORM_DIRTY | Triggers transform update |
RENDER_MESH | References cached mesh |
MATERIAL_REF | PBR material |
BOUNDING_VOLUME | OBB for frustum culling |
CASTS_SHADOW | Enabled by default |
RIGID_BODY | Static physics body |
COLLIDER | HeightField shape |
VISIBILITY | Visible by default |