DEV Community

Cover image for Rust Game Dev Log #5 | Endless Procedural Terrain Generation + Dynamic Asynchronously updated Grass
Michael Mironidis
Michael Mironidis

Posted on

Rust Game Dev Log #5 | Endless Procedural Terrain Generation + Dynamic Asynchronously updated Grass

Massive update

This is by far the biggest update yet, with significant updates to terrain generation and the grass system bringing me increasingly closer to my desired artistic objective. As a consequence, I've ramped up gameplay brainstorming, as this big open world needs something to do! But that will be for another post... Let's get into it!

Terrain

The terrain update can be summarized to two changes, one minor (vertex coloring in lieu of textures) and one major (endless procedural terrain generation with improved height mapping).

Vertex coloring

I opted to get rid of the terrain texture because texturing terrain is more work than expected (this is not Unity, I can't just paint terrain with various textures using a brush). Additionally, vertex coloring achieves 80% of what I need: a way to visually distinguish the geography and create depth. The end result was incorporating some logic into the terrain generation that set a vertex color based off the height, with some set heights marking the beginning and end of different terrain types (sand for the beach near water, green for temperate, white for peaks). Here's the code I use to choose a color based off of some input y coordinate:

fn get_terrain_color(y: f32) -> [f32;4] {
    if y < HEIGHT_SAND { COLOR_SAND }
    else if y > HEIGHT_PEAKS { COLOR_PEAKS }
    else if y < HEIGHT_TEMPERATE_START {
        terrain_color_gradient(
            (y-HEIGHT_SAND)/(HEIGHT_TEMPERATE_START-HEIGHT_SAND),
            COLOR_SAND,
            COLOR_TEMPERATE
        )
    } else if y < HEIGHT_TEMPERATE_END {
        COLOR_TEMPERATE
    } else {
        terrain_color_gradient(
            (y-HEIGHT_TEMPERATE_END)/(HEIGHT_PEAKS-HEIGHT_TEMPERATE_END),
            COLOR_TEMPERATE,
            COLOR_PEAKS
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

The above code calls the following function:

fn terrain_color_gradient(ratio: f32, rgba1: [f32; 4], rgba2: [f32; 4]) -> [f32;4] {
    let [r1, g1, b1, a1] = rgba1;
    let [r2, g2, b2, a2] = rgba2;

    [
        r1 + (r2-r1)*(ratio),
        g1 + (g2-g1)*(ratio),
        b1 + (b2-b1)*(ratio),
        a1 + (a2-a1)*(ratio)
    ]
}
Enter fullscreen mode Exit fullscreen mode

This all basically just says, for certain areas, use one color, while for others, sample from a gradient between two colors, using some fraction to determine how much of the second color to use. The end result is extremely efficient for the goal of creating visually identifiable beaches, temperate regions, and peaks:

Bumpy mountainous island amongst smaller islands

Stylistically I have opted for an almost light dusting of white at the peaks not even to indicate snow but to make the highest points feel "in the clouds". There's further work in using a browner color on steep inclines, but this involves calculating data that does not exist yet, which is the gradient each point.

Endless procedural terrain

This functionality is somewhat of a bare working demo of endless procedural terrain. In short, we have one main terrain plane surrounded by lower-resolution distant terrain planes. After the player navigates a certain distance away from the center of the main terrain plane, it regenerates the terrain underneath the player. At the moment this causes the game to freeze as it regenerates the terrain synchronously. I intend to adapt the successful asynchronous grass code for the terrain to make the experience navigating the world more seamless, but for now, here's the code that does what is described above.

The system registered to handle terrain generation:

pub fn update_terrain(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    asset_server: Res<AssetServer>,
    mut main_terrain: Query<(Entity,&mut Transform, &Handle<Mesh>), (With<Terrain>, With<MainTerrain>)>,
    mut distant_terrain: Query<(Entity,&mut Transform, &Handle<Mesh>), (With<Terrain>, Without<MainTerrain>)>,
    player: Query<&Transform, (With<player::Player>,Without<Terrain>)>,
) {
    if main_terrain.is_empty() { // scene start
        // spawn chunk at player
        let player_trans = player.get_single().unwrap().translation;
        spawn_terrain_chunk(&mut commands, &mut meshes, &mut materials, &asset_server, 0., 0., true, PLANE_SIZE, SUBDIVISIONS_LEVEL_1);
        // spawn chunks without player in them
        for (dx,dz) in [(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)] {
            let calc_dx = dx as f32 * (PLANE_SIZE/2. + SIZE_NO_PLAYER/2.);
            let calc_dz = dz as f32 * (PLANE_SIZE/2. + SIZE_NO_PLAYER/2.);
            spawn_terrain_chunk(&mut commands, &mut meshes, &mut materials, &asset_server, player_trans.x + calc_dx, player_trans.z + calc_dz, false, SIZE_NO_PLAYER, SUBDIVISIONS_LEVEL_2);
        }
        spawn_water_plane(&mut commands, &mut meshes, &mut materials, &asset_server);
    } else { // main update logic
        if let Ok(terrain) = main_terrain.get_single_mut() {
            let (terrain_ent, terrain_trans, terrain_mesh) = terrain;
            let player_trans = player.get_single().unwrap();
            let mut delta: Option<Vec3> = None;

            // determine player triggering terrain refresh
            if (player_trans.translation.x - terrain_trans.translation.x).abs() > PLANE_SIZE/4. || (player_trans.translation.z - terrain_trans.translation.z).abs() > PLANE_SIZE/4. {
                delta = Some(player_trans.translation - terrain_trans.translation);
            }

            // if they have, regenerate the terrain
            if let Some(delta) = delta {
                println!("Player has triggered terrain regeneration");
                regenerate_terrain(&mut commands, &mut meshes, &mut materials, &asset_server, &mut main_terrain, &mut distant_terrain, delta);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The spawn_terrain_chunk function (features commented out code that used the texture):

fn spawn_terrain_chunk(
    commands: &mut Commands,
    meshes: &mut ResMut<Assets<Mesh>>,
    materials: &mut ResMut<Assets<StandardMaterial>>,
    asset_server: &Res<AssetServer>,
    x: f32, z: f32,
    contains_player: bool,
    size: f32,
    subdivisions: u32
) -> Entity {    
    let mesh = generate_terrain_mesh(x, z, size, subdivisions);

    let sampler_desc = ImageSamplerDescriptor {
        address_mode_u: ImageAddressMode::Repeat,
        address_mode_v: ImageAddressMode::Repeat,
        ..default()
    };
    let settings = move |s: &mut ImageLoaderSettings| {
        s.sampler = ImageSampler::Descriptor(sampler_desc.clone());
    };

    // let texture_handle = asset_server.load_with_settings("terrain/rocky_soil.png", settings.clone());
    // let normal_handle = asset_server.load_with_settings("terrain/rocky_soil_normal.png", settings);
    let terrain_material = StandardMaterial {
        // base_color: if contains_player { Color::WHITE } else { Color::WHITE }, // use to see difference in terrain chunks
        // base_color_texture: Some(texture_handle.clone()),
        // normal_map_texture: Some(normal_handle.clone()),
        alpha_mode: AlphaMode::Opaque,
        double_sided: true,
        perceptual_roughness: 1.0,
        reflectance: 0.4,
        cull_mode: Some(Face::Back),
        flip_normal_map_y: true,
        ..default()
    };

    // terrain
    let collider_shape = ComputedColliderShape::TriMesh;

    let mut binding = commands.spawn(PbrBundle {
            mesh: meshes.add(mesh.clone()),
            material: materials.add(terrain_material),
            transform: Transform::from_xyz(x,0.,z),
            ..default()
        });
    let parent_terrain = binding
        .insert(Terrain)
        .insert(Collider::from_bevy_mesh(&mesh, &collider_shape).unwrap()
    );
    if contains_player {
        parent_terrain.insert(MainTerrain);
    }
    parent_terrain.id()

}
Enter fullscreen mode Exit fullscreen mode

The regenerate_terrain function:

fn regenerate_terrain(
    commands: &mut Commands,
    meshes: &mut ResMut<Assets<Mesh>>,
    materials: &mut ResMut<Assets<StandardMaterial>>,
    asset_server: &Res<AssetServer>,
    main_terrain: &mut Query<(Entity,&mut Transform, &Handle<Mesh>), (With<Terrain>, With<MainTerrain>)>,
    distant_terrain: &mut Query<(Entity,&mut Transform, &Handle<Mesh>), (With<Terrain>, Without<MainTerrain>)>,
    delta: Vec3
) {
    let collider_shape = ComputedColliderShape::TriMesh;

    // shift over and regen terrain
    for (ent, mut trans, mh) in main_terrain.iter_mut() {
        trans.translation = trans.translation + delta;
        trans.translation.y = 0.;
        let mesh = meshes.get_mut(mh).unwrap();
        let new_mesh = &mut generate_terrain_mesh(trans.translation.x, trans.translation.z, PLANE_SIZE, SUBDIVISIONS_LEVEL_1);
        *mesh = new_mesh.clone();
        commands.get_entity(ent).unwrap().insert(Collider::from_bevy_mesh(&mesh, &collider_shape).unwrap());
    }
    for (ent, mut trans, mh) in distant_terrain.iter_mut() {
        trans.translation = trans.translation + delta;
        trans.translation.y = 0.;
        let mesh = meshes.get_mut(mh).unwrap();
        let new_mesh = &mut generate_terrain_mesh(trans.translation.x, trans.translation.z, PLANE_SIZE, SUBDIVISIONS_LEVEL_2);
        *mesh = new_mesh.clone();
        // commands.get_entity(pl_ent).unwrap().insert(Collider::from_bevy_mesh(&mesh, &collider_shape).unwrap()); // no need for collider here atm
    }
}
Enter fullscreen mode Exit fullscreen mode

The water plane is just a regular plane with a normal texture that scrolls by updating the mesh's UV coords every frame by some constant WATER_SCROLL_SPEED:

fn update_water(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    asset_server: Res<AssetServer>,
    mut water: Query<(Entity,&Handle<Mesh>), (With<Water>)>,
) {
    let Ok((water_ent, water_mesh_handle)) = water.get_single_mut() else {
        return
    };
    let water_mesh = meshes.get_mut(water_mesh_handle).unwrap();
    let water_uvs = water_mesh.attribute_mut(Mesh::ATTRIBUTE_UV_0).unwrap();
    let VertexAttributeValues::Float32x2(uv_attr) = water_uvs else {
        panic!("Unexpected vertex format, expected Float32x3");
    };
    for [x,y] in uv_attr.iter_mut() {
        *x = *x + WATER_SCROLL_SPEED;
        *y = *y + WATER_SCROLL_SPEED;
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, in fine-tuning the terrain generation for flatter terrain near the coastline and bumpier, rockier terrain on the mountains, I updated the code in perlin.rs to the following:

pub fn sample_terrain_height(terrain_perlin: &Perlin, x: f32, z: f32) -> f32 {
    terrain::BASE_LEVEL
    // + terrain_perlin.get([x as f64 / 100., z as f64 / 100.]) as f32 * HILL_HEIGHTS // hills
    // + terrain_perlin.get([z as f64 / 16., x as f64 / 16.]) as f32 * TERRAIN_BUMPINESS // finer detail
    + detail_component(terrain_perlin, x, z)
    + hill_component(terrain_perlin, x, z)
    + mountain_component(terrain_perlin, x, z)
}

fn detail_component(terrain_perlin: &Perlin, x: f32, z: f32) -> f32 {
    let mountain_sample = sample_mountain(terrain_perlin, x, z);
    terrain_perlin.get([z as f64 / 16., x as f64 / 16.]) as f32 * (mountain_sample/0.5)*TERRAIN_BUMPINESS // finer detail
}

fn hill_component(terrain_perlin: &Perlin, x: f32, z: f32) -> f32 {
    let mountain_sample = sample_mountain(terrain_perlin, x, z);

    terrain_perlin.get([x as f64 / 100., z as f64 / 100.]) as f32 * (mountain_sample/0.25)*HILL_HEIGHTS
}

fn mountain_component(terrain_perlin: &Perlin, x: f32, z: f32) -> f32 {
    let mountain_sample = sample_mountain(terrain_perlin, x, z);
    MOUNTAIN_HEIGHTS * mountain_sample/(1.4 - mountain_sample)
}

fn sample_mountain(terrain_perlin: &Perlin, x: f32, z: f32) -> f32 {
    terrain_perlin.get([x as f64 / 4096., z as f64 / 4096.]) as f32
}
Enter fullscreen mode Exit fullscreen mode

Basically, I split up the height sample into mountain, hill, and detail-scaled components. I use the mountain sample as a reference for the "main" height that determines how much of the hill and detail components to include. Here's an image where the lighting clearly highlights the geography:

Sunrise on rocky islands

The details can be messed around with, but with this code, we have serviceable endless procedurally generated terrain. Now to the grass!

Dynamic, asynchronously updated grass!

This is the most significant update yet in terms of optimization and player experience, and it starts with adding a GrassGrid resource that just wraps a hashmap:

struct GrassGrid(HashMap<(i32,i32), bool>);
Enter fullscreen mode Exit fullscreen mode

Let's break up the update_grass function into two parts. First, the base state (aka when there's no grass present so it needs to be generated along with adding the GrassGrid as a resource):

let mut grass_grid = GrassGrid(HashMap::new());
// generate grid of grass
for i in -GRID_SIZE_HALF..=GRID_SIZE_HALF {
    for j in -GRID_SIZE_HALF..=GRID_SIZE_HALF {
        let a = x + i as f32 * GRASS_TILE_SIZE_1;
        let b = z + j as f32 * GRASS_TILE_SIZE_1;
        grass_grid.0.insert((a as i32, b as i32), true);
        let contains_player = (player_trans.translation.x - a).abs() < GRASS_TILE_SIZE_1/2. && (player_trans.translation.z - b).abs() < GRASS_TILE_SIZE_1/2.;
        let color = if contains_player { Color::RED } else { Color::PURPLE };
        let (main_mat, main_grass, main_data) = generate_grass(&mut meshes, &mut materials, a, b, NUM_GRASS_1, GRASS_TILE_SIZE_1);
        commands.spawn(main_mat)
            .insert(main_grass)
            .insert(main_data)
            .insert(ContainsPlayer(contains_player))
            // .insert(ShowAabbGizmo {color: Some(color)})
            ;
    }
}
commands.spawn(grass_grid);
Enter fullscreen mode Exit fullscreen mode

The second part is large, so consider opening the code side-by-side and following along with my explanation:

let thread_pool = AsyncComputeTaskPool::get();
let mut grass_grid = grid.get_single_mut().unwrap();
let elapsed_time = time.elapsed_seconds_f64();
let mut grass_w_player: Option<Entity> = None;
for (ent, mh, grass_data, grass_trans, visibility, mut contains_player) in grass.iter_mut() {
    // remove or add ContainsPlayer if applicable
    if (player_trans.translation.x - grass_trans.translation.x).abs() >= GRASS_TILE_SIZE_1/2. || (player_trans.translation.z - grass_trans.translation.z).abs() >= GRASS_TILE_SIZE_1/2. {
        if contains_player.0 {
            *contains_player = ContainsPlayer(false);
        }
    } else {
        if !contains_player.0 {
            *contains_player = ContainsPlayer(true);
            // generate new grass
            for i in -GRID_SIZE_HALF..=GRID_SIZE_HALF {
                for j in -GRID_SIZE_HALF..=GRID_SIZE_HALF {
                    let a = grass_trans.translation.x + i as f32 * GRASS_TILE_SIZE_1;
                    let b = grass_trans.translation.z + j as f32 * GRASS_TILE_SIZE_1;
                    if let false = *grass_grid.0.get(&(a as i32,b as i32)).unwrap_or(&false) {
                        grass_grid.0.insert((a as i32, b as i32), true);
                        // todo: async way
                        let transform = Transform::from_xyz(a,0.,b);

                        let task_entity = commands.spawn_empty().id();
                        let task = thread_pool.spawn(async move {
                            let mut command_queue = CommandQueue::default();
                            let (mesh, grass_data) = generate_grass_mesh(a, b, NUM_GRASS_1, GRASS_TILE_SIZE_1);

                            command_queue.push(move |world: &mut World| {
                                let (grass_mesh_handle, grass_mat_handle) = {
                                    let mut system_state = SystemState::<(ResMut<Assets<Mesh>>, ResMut<Assets<StandardMaterial>>)>::new(world);
                                    let (mut meshes, mut mats) = system_state.get_mut(world);

                                    (meshes.add(mesh), mats.add(grass_material()))
                                };

                                world.entity_mut(task_entity)
                                .insert(PbrBundle {
                                    mesh: grass_mesh_handle,
                                    material: grass_mat_handle,
                                    transform,
                                    ..default()
                                })
                                .insert(Grass)
                                .insert(grass_data)
                                .insert(ContainsPlayer(false))
                                // .insert(ShowAabbGizmo {color: Some(Color::PURPLE)})
                                .remove::<GenGrassTask>();
                            });

                            command_queue
                        });

                        commands.entity(task_entity).insert(GenGrassTask(task)); // spawn a task marked GenGrassTask in the world to be handled by handle_tasks fn when complete

                    //     // old way (sync)
                    //     let (main_mat, main_grass, main_data) = generate_grass(&mut commands, &mut meshes, &mut materials, a, b, NUM_GRASS_1, GRASS_TILE_SIZE_1);
                    //     commands.spawn(main_mat)
                    //         .insert(main_grass)
                    //         .insert(main_data)
                    //         .insert(ContainsPlayer(false))
                    //         // .insert(ShowAabbGizmo {color: Some(Color::PURPLE)})
                    //         ;
                    }
                }
            }

        }
    }
    if contains_player.0 {
        grass_w_player = Some(ent);
    }
    // simulate wind only if close enough and if visible
    if (player_trans.translation.x - grass_trans.translation.x).abs() < WIND_SIM_TRIGGER_DISTANCE && (player_trans.translation.z - grass_trans.translation.z).abs() < WIND_SIM_TRIGGER_DISTANCE && visibility.get() {
        if let Some(mesh) = meshes.get_mut(mh) {
            apply_wind(mesh, grass_data, &perlin, elapsed_time, player_trans.translation.xz());
        }
    } else if (player_trans.translation.x - grass_trans.translation.x).abs() > DESPAWN_DISTANCE || (player_trans.translation.z - grass_trans.translation.z).abs() > DESPAWN_DISTANCE {
        grass_grid.0.insert((grass_trans.translation.x as i32, grass_trans.translation.z as i32), false);
        commands.get_entity(ent).unwrap().despawn_recursive();
    }
}

if let Some(grass_w_player) = grass_w_player {
    // update aabb color
    // commands.get_entity(grass_w_player).unwrap().insert(AabbGizmo {color: Some(Color::RED)});
}
Enter fullscreen mode Exit fullscreen mode

If the player is within GRASS_TILE_SIZE_1/2 from this grass tile's center, then we check if ContainsPlayer is not true. If it isn't, that means we need to update the grid because the player has entered a tile that they previously were not in. So we enter a nested for loop to regenerate all the terrain based on whether or not it already has been generated (using the grass_grid hashmap to save time). The beauty here is in the asynchronous code: instead of generating the grass that needs to be generated all in the same frame, for each grass that needs to be generated we spawn a task into the AsyncComputeTaskPool and a corresponding task entity into the world. Each task generates a new grass mesh and material and pushes a command to a CommandQueue to add the mesh and materials to their respective Asset stores (returning the handles pointing to each) before inserting these and the rest of the grass-related components into the task entity. It also removes the GenGrassTask component that wraps the task, indicating task completion. To make the above work, we have the handle_tasks function that does the following:

fn handle_tasks(mut commands: Commands, mut grass_tasks: Query<&mut GenGrassTask>) {
    for mut task in &mut grass_tasks {
        if let Some(mut commands_queue) = block_on(poll_once(&mut task.0)) {
            // append the returned command queue to have it execute later
            commands.append(&mut commands_queue);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It just queries for entities with GenGrassTask components, and once they are complete, adds the returned CommandsQueue to the Commands.

Here is the end result, going from stuttering every time the player entered a new grass tile to no stutter at all:

GIF of player moving around and grass updating asynchronously around them

Smooth transitions

With a dynamic grid system like above, its important that detail changes from tile to tile are not jarring. This will be especially true if in the future I implement grass density relative to player position. One jarring detail was the wind. Since it is still a CPU-bound task and slows down the game a lot, I restrict it only to tiles close to the player. As a consequence, when moving around, grass would snap into position as it became close enough to simulate. To combat this, I added code to reduce the effect of wind the farther from the player it is, tapering off to 0 by the wind simulation boundary. You can see this code in the apply_wind function where I added the following coefficient to the curve_amount variable:

((WIND_SIM_DISTANCE - player_xz.distance(Vec2::new(*x,*z))) / WIND_SIM_DISTANCE).powi(2)
Enter fullscreen mode Exit fullscreen mode

Some additional decorations I've added are the bevy_atmosphere crate for a Nishita skybox to simulate a day-night cycle, and a point-light representing a torch that samples the perlin noise I already use for wind but for a flicker effect. These are relatively minor additions that you can check out, along with the rest of the code for this project, at the repo!

All of these come together for the following player experience:

Walking through grass during sunset

Walking through grass during sunrise

Walking through grass during moonlit night

I just love the way the light interacts with the grass (I made the stylistic choice of turning off shadows from the point light, which makes the grass look better, particularly at night). The state of the visuals is inspiring a lot of gameplay brainstorming these days...

What's next

As impressive as this project is getting, there's still a decent performance hit to simulating wind on the grass. Recently, Bevy introduced the ability to spawn meshes only in the Render world. For static meshes, this can be quite useful, but for simulating wind, it still looks like I'm headed towards a custom render pipeline writing my own grass shader. This would involve moving wind simulation off of the CPU and onto the GPU per some wgsl shader file. Other grass optimizations include making grass blades wider farther away while also having less grass density. This would reduce vertex count but minimally affect the visuals. However, it can also only really be accomplished through a shader.

Another major improvement that could come down the line is in terrain generation. I can at least update the regeneration to be asynchronous, but as part of moving off of the deprecated Plane struct, the successor of which lacks control over size and subdivisions, I will need to reconsider my approach.

Additional features I'm looking to add are procedurally generated trees, paths, and points of interest, setting the stage for the open world gameplay I'd be introducing. This would require implementing further control over grass generation so that grass doesn't grow on paths, near tree roots, or anywhere else it shouldn't.

Finally, as this project is increasing in visual quality and complexity, I'm finding low-resolution giphys to be insufficient in showcasing the results of my work. I'm considering transitioning these logs to Youtube in a vlog format, but am unsure about the added workload of writing a script, narrating, and editing videos. Regardless of how I document my progress, you can always keep up with the latest development by starring my repo!

That's all for this log. Please leave any comments if anything requires clarification, and thanks for reading!

Top comments (0)