Part 5 · Chapter 13 · ~8 min prose skim
A procedural world
A valley, its colouring, a scatter of rocks and a photographic sky, generated from code and a single image rather than modelled by hand.
Overview The big picture
The valley is a function
The ground isn't a model file. It's a function: give it an x and z position, and it returns the height of the ground there. At start-up the renderer samples that function on a 201 × 201 grid of points one metre apart, joins neighbouring points into triangles, and uploads the result as an ordinary mesh. The GPU never knows it was generated.
The same function is used everywhere the ground matters: to stand the hut, rocks and cubes on it, to keep the camera 1.8 m above it, and to plant every blade of grass (Chapter 14). Because they all ask the same question, nothing floats or sinks.
The height is built from layers:
- Gentle rolling ground, up to about 2 m, from value noise: smooth pseudo-random bumps, made by blending random values placed on a grid, at three different scales (waves roughly 55, 21 and 8 m long), often called octaves.
- Hills around the edge that rise from 36 m out, peak at 82 m, and fall back to the base level at the edge of the 200 m patch, so the playable centre feels enclosed.
- A flat pad under the hut, blended smoothly into the surrounding ground.
Colour without textures
The terrain has no texture image. Each vertex gets a colour from the same kind of noise: dark, fertile soil where grass will be dense, warm dry earth in the bare patches, and grey near rocks and on steep slopes. Its fragment shader then adds fine, centimetre-scale grit and pebbles, also generated from noise, so the ground holds up close to the camera without a large texture.
Rocks and sky
Sixteen hand-placed spots each get a cluster of three rocks, chosen from five imported models and rotated and sized by a fixed recipe, so the world is identical every run. The grass thins out around each cluster.
The sky is a single 360° photograph (an equirectangular panorama, like a world map wrapped around you). It's drawn first each frame with a trick: one triangle large enough to cover the whole screen, whose fragment shader works out which direction each pixel looks in and reads the matching point of the panorama. Because it ignores the camera's position, the sky stays infinitely far away however far you walk.
Settings, and a height function
impl Default for TerrainSettings {
fn default() -> Self {
Self {
// The outer half of this larger patch forms the distant valley walls.
// One-metre cells are still sufficient for their broad, smooth slopes.
world_size: 200.0,
// One-metre cells are sufficient for the broad contours; fine colour
// texture is evaluated by the fragment shader rather than extra geometry.
cells_per_side: 200,
max_height: 2.1,
valley_floor_radius: 36.0,
valley_crest_radius: 82.0,
// The distant wall adds three metres above the local rolling terrain.
valley_wall_height: 3.0,
pad_centre: [0.0, -2.0],
pad_radius: 2.5,
pad_falloff: 2.0,
seed: 0x5EED_C0DE,
}
}
}TerrainSettings is a small Copy struct, passed by value everywhere. The
natural height combines three noise octaves into a landform, then adds the valley wall:
fn natural_terrain_height(x: f32, z: f32, settings: TerrainSettings) -> f32 {
// Broad rolling forms dominate. A ridged version of the medium octave adds occasional
// shoulders and shallow gullies without turning the valley floor into sharp noise.
let broad = value_noise(x, z, 0.018, settings.seed);
let medium = value_noise(x, z, 0.047, settings.seed.wrapping_add(1));
let detail = value_noise(x, z, 0.13, settings.seed.wrapping_add(2));
let ridge = 1.0 - (medium * 2.0 - 1.0).abs();
let landform = 0.58 * broad + 0.24 * medium + 0.12 * ridge * ridge + 0.06 * detail;
let noisy_height = settings.max_height * smoothstep(0.08, 0.92, landform);
// A fourth-power radius produces a rounded square: it follows the square mesh's
// boundary more closely than a circle, without introducing sharp diagonal corners.
let boundary_radius = (x.powi(4) + z.powi(4)).sqrt().sqrt();
let half_size = settings.world_size * 0.5;
let climb = smoothstep(
settings.valley_floor_radius,
settings.valley_crest_radius,
boundary_radius,
);
let outer_slope = 1.0 - smoothstep(settings.valley_crest_radius, half_size, boundary_radius);
// Multiplying two smooth curves gives a rounded crest: the hill rises away from
// the playable centre, then falls back toward the base terrain before the mesh ends.
let valley_wall = settings.valley_wall_height * climb * outer_slope;
noisy_height + valley_wall
}The "ridge" term folds the medium octave around its midpoint, which turns soft bumps into occasional shoulders and gullies. The wall is the product of a rising and a falling smooth step, which gives a rounded crest. Its distance uses x⁴ + z⁴ rather than x² + z², which bends the circle towards the square shape of the mesh. Finally the hut pad is blended in:
let pad_height =
natural_terrain_height(settings.pad_centre[0], settings.pad_centre[1], settings);
let dx = x - settings.pad_centre[0];
let dz = z - settings.pad_centre[1];
let distance_from_pad = (dx * dx + dz * dz).sqrt();
let blend_to_terrain = smoothstep(
settings.pad_radius,
settings.pad_radius + settings.pad_falloff,
distance_from_pad,
);
lerp(pad_height, natural_height, blend_to_terrain)Inside the pad radius, the height is fixed at whatever the natural ground would be at the pad's centre, rather than zero, so the hut sits on a gentle shelf instead of in a dug-out bowl.
Noise from a hash
Value noise needs a repeatable random value at every integer grid point. That comes from a hash: mixing the coordinates' bits until they look random:
/// Converts integer grid coordinates to a stable pseudo-random value in `0.0..=1.0`.
fn hash_2d(x: i32, z: i32, seed: u32) -> f32 {
let mut bits =
(x as u32).wrapping_mul(0x8DA6_B343) ^ (z as u32).wrapping_mul(0xD816_3841) ^ seed;
bits ^= bits >> 16;
bits = bits.wrapping_mul(0x7F4A_7C15);
bits ^= bits >> 15;
bits = bits.wrapping_mul(0x846C_A68B);
bits ^= bits >> 16;
bits as f32 / u32::MAX as f32
}value_noise looks up the four grid points around a position and blends them with a smooth
step in each direction, giving hills with no visible grid. (A smooth step is an S-shaped curve from 0 to 1 that eases in
and out, used throughout this file for soft transitions.)
Building the mesh
for row in 0..vertices_per_side {
// Rows travel toward -Z. This preserves the existing plane's winding order,
// so counter-clockwise triangles continue to face upward.
let z = half_size - row as f32 * cell_size;
for column in 0..vertices_per_side {
let x = -half_size + column as f32 * cell_size;
let height = settings.height_at(x, z);
// A central difference samples the same height function on each side of
// this vertex. Its slope gives a normal for the smoothly lit surface.
let slope_x = (settings.height_at(x + cell_size, z)
- settings.height_at(x - cell_size, z))
/ (2.0 * cell_size);
let slope_z = (settings.height_at(x, z + cell_size)
- settings.height_at(x, z - cell_size))
/ (2.0 * cell_size);
let normal = normalise([-slope_x, 1.0, -slope_z]);
// The mesh UVs increase in +X and -Z, matching the original unit plane.
let tangent = normalise([1.0, slope_x, 0.0]);
let bitangent = normalise(cross(normal, tangent));
let slope = (slope_x * slope_x + slope_z * slope_z).sqrt();Each vertex samples the height function, then samples it again one cell either side in x and in z. Those central differences give the slope, and from the slope come a smooth normal, a tangent along +X and a bitangent from their cross product (the surface's local axes, which normal-mapped shading needs). So the lighting is smooth even though the mesh is made of flat triangles. A second loop emits two triangles per cell, wound counter-clockwise when seen from above, so their front faces point up (Chapter 7).
The vertex colour comes from the grass density at that spot, whether it's near a rock, and how steep it is:
/// Fraction of candidate grass retained at this point. Two overlapping noise scales
/// make small dry pockets without carving broad empty bands through the field.
pub fn grass_density_at(&self, x: f32, z: f32) -> f32 {
let broad = value_noise(x, z, 0.055, self.seed.wrapping_add(101));
let detail = value_noise(x, z, 0.17, self.seed.wrapping_add(102));
let moisture = broad * 0.45 + detail * 0.55;
let biome_density = smoothstep(0.20, 0.47, moisture);
biome_density * rock_grass_clearance(x, z)
}
/// Vertex colour beneath the grass. Dense regions expose dark, fertile soil;
/// dry regions become warm sandy earth, while slopes and rock skirts turn greyer.
fn ground_color_at(&self, x: f32, z: f32, slope: f32) -> [f32; 3] {
let grass = self.grass_density_at(x, z);
let dry_soil = [0.22, 0.145, 0.065];
let fertile_soil = [0.085, 0.075, 0.025];
let mut color = mix_color(dry_soil, fertile_soil, grass);
let rock_skirt = 1.0 - rock_grass_clearance(x, z);
let steep_ground = smoothstep(0.28, 0.72, slope);
let rock_amount = rock_skirt.max(steep_ground * 0.75);
color = mix_color(color, [0.25, 0.235, 0.19], rock_amount);
let variation = 0.82 + 0.30 * value_noise(x, z, 0.31, self.seed.wrapping_add(109));
[
color[0] * variation,
color[1] * variation,
color[2] * variation,
]
}grass_density_at is public because the grass generator (Chapter 14) samples the same
function into a coarse map, so dense grass always stands on dark soil. The rock clearing is the minimum, over all sixteen clusters, of a
smooth step around each cluster's clearing_radius.
Detail in the terrain shader
terrain.vert is a plain transform that also passes the world position along, and
terrain.frag starts from that vertex colour and adds detail at scales the one-metre mesh
can't represent, all from noise and hashes of the world position. The pebbles are the most interesting
part:
vec2 pebble_grid = vTerrainPosition * 5.0;
vec2 pebble_cell = floor(pebble_grid);
vec2 pebble_offset = vec2(
hash21(pebble_cell + 13.7),
hash21(pebble_cell + 47.1)
) - 0.5;
float pebble_seed = hash21(pebble_cell + 79.3);
float pebble_distance = length(fract(pebble_grid) - 0.5 - pebble_offset * 0.45);
float pebble_radius = mix(0.08, 0.23, hash21(pebble_cell + 101.9));
float pebble_edge = max(fwidth(pebble_distance), 0.015);
float pebble = step(0.70, pebble_seed)
* (1.0 - smoothstep(pebble_radius - pebble_edge, pebble_radius + pebble_edge,
pebble_distance));
vec3 pebble_colour = mix(vec3(0.075, 0.065, 0.050), vec3(0.29, 0.27, 0.22),
hash21(pebble_cell + 131.2));
albedo = mix(albedo, pebble_colour, pebble * 0.58);The ground is divided into 20 cm cells. A hash decides whether each cell has a pebble (30% do), where in
the cell it sits, how big it is and what shade. fwidth measures how much the distance changes
from one pixel to the next and widens the pebble's edge to match, so pebbles soften rather than shimmer as
they shrink into the distance. The lighting that follows has the same structure as
Chapter 6's, including its own copy of the shadow lookup.
Rock clusters
pub fn cluster_members(cluster_index: usize, placement: RockClusterPlacement) -> [RockMember; 3] {
let seed = cluster_index as f32 * std::f32::consts::GOLDEN_RATIO;
let main_variant = (placement.variant + cluster_index) % ROCK_ASSET_PATHS.len();
let secondary_angle = placement.rotation_degrees + 55.0 + seed.sin() * 24.0;
let tertiary_angle = placement.rotation_degrees - 68.0 + seed.cos() * 21.0;
[
RockMember {
variant: main_variant,
offset: [0.0, 0.0],
rotation_degrees: placement.rotation_degrees,
size: placement.scale * 1.95,
},Each anchor in ROCK_CLUSTERS (a constant array in terrain.rs) has a position,
rotation, scale and starting variant. cluster_members derives three stones from it: a main
stone at the anchor, and two smaller ones placed at angles nudged by sin and cos
of the golden ratio times the cluster's index. That's deterministic "randomness": every cluster looks
different, but the same on every run. The three variants step two entries along the five-model list,
wrapping round, so each cluster mixes shapes. When placed (Chapter 12),
each stone is also sunk by 7% of its size so it looks embedded. A test checks that all five models get used.
The rock models come from two asset packs, each sharing one texture atlas (one image holding
the textures for several models), so all five share just two materials. Their vertex shader, rock.vert, is a plain transform; their fragment shader deliberately
mutes the atlas's colours, keeping its brightness as the surface colour (albedo), so the packs look
like the same stone:
vec3 atlas = texture(diffuseMap, vUV).rgb;
float atlas_value = dot(atlas, vec3(0.2126, 0.7152, 0.0722));
vec3 neutral_grey = vec3(mix(0.30, 0.48, atlas_value));
// Four percent of the atlas chroma is enough to retain slight warm/cool identity
// without allowing one source model to become visibly brown, green, or blue.
vec3 albedo = neutral_grey + (atlas - vec3(atlas_value)) * 0.04;The sky
SkyRenderer borrows its panorama from the material manager's texture cache, so the image
joins the start-up upload batch (Chapter 5). It gets its own sampler,
though:
let (image_view, _shared_material_sampler) =
material_manager.load_shared_texture(vb, SKY_TEXTURE_PATH)?;
// Longitude repeats at the left/right seam, but latitude must stop at the poles.
// Material textures repeat both axes, so the sky needs this one specialised sampler.
let sampler = create_sampler(&vb.device)?;
let descriptor_set_layout = create_descriptor_set_layout(&vb.device)?;
let image_info = vk::DescriptorImageInfo {
sampler,
image_view,
image_layout: vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
};The sampler and view go into a DescriptorImageInfo, which draw pushes as set 0
each frame, the same way materials push their textures (Chapter 8).
Its pipeline is the new_background variant from Chapter 7: no
depth test, no blending, no culling, and no vertex input at all. The three corners are built in the vertex
shader from gl_VertexIndex:
void main() {
const vec2 POSITIONS[3] = vec2[](
vec2(-1.0, -1.0),
vec2( 3.0, -1.0),
vec2(-1.0, 3.0)
);
vec2 screen_position = POSITIONS[gl_VertexIndex];
gl_Position = vec4(screen_position, 0.0, 1.0);
// A point on the far clip plane becomes a direction once transformed by the
// inverse projection and rotation-only view matrix.
vec4 world_position = pc.inverse_view_projection * vec4(screen_position, 1.0, 1.0);
world_direction = world_position.xyz / world_position.w;
}The triangle's corners at (−1, −1), (3, −1) and (−1, 3) cover the whole −1..1 screen with one triangle, which is a little cheaper than two triangles meeting along a diagonal. For each corner, the inverse of projection × view turns a point on the far plane back into a world-space direction. The view has its translation zeroed first, which is what makes the sky infinitely distant:
fn sky_push_constants(camera: &Camera) -> [u8; 80] {
let mut rotation_only_view = *camera.get_view();
rotation_only_view.w = Vector4::new(0.0, 0.0, 0.0, 1.0);
let inverse_view_projection = (*camera.get_projection() * rotation_only_view)
.invert()
.unwrap_or_else(Matrix4::identity);The fragment shader converts the interpolated direction to longitude and latitude, the panorama's two texture coordinates:
void main() {
vec3 direction = normalize(world_direction);
float longitude = atan(direction.z, direction.x) / TAU + 0.5;
float latitude = acos(clamp(direction.y, -1.0, 1.0)) / PI;
vec2 uv = vec2(fract(longitude + pc.settings.x / TAU), latitude);
out_color = texture(sky_panorama, uv);
}Recap
- The terrain is a deterministic height function (noise at three scales, a valley wall, a flat hut pad), sampled on a 201 × 201 grid and uploaded as an ordinary mesh. Everything placed on the ground asks the same function.
- Normals come from central differences. Colour comes from grass density, rock proximity and slope, with grit and pebbles added in the fragment shader.
- Sixteen anchors each yield three rocks from five shared models, by a fixed recipe; the grass clears around them.
- The sky is one fullscreen triangle sampling a panorama by view direction, with translation removed so it stays infinitely far away.