DEV Community

Cover image for Rust Game Dev Log #4: Terrain Generation!
Michael Mironidis
Michael Mironidis

Posted on

Rust Game Dev Log #4: Terrain Generation!

Big changes!

This dev log will cover the changes I have made implementing terrain. I will save Rapier physics integration for when it is more relevant to the work I'm doing, but if you'd like to see how I got a rectangular player character to navigate the terrain with a 3rd person camera, check out the repo. Anyway, let's get right into it!

Terrain

My initial approach in generating terrain was much like how I handled grass. I programmatically built the mesh in a nested for loop for the width and height of the terrain, accounting for a constant TILE_WIDTH. That's all fine, but I realized after I started writing this post that I was reinventing existing logic. Bevy already comes with Plane generation, and if you go into the Bevy code for impl From<Plane> for Mesh, you can compare it to my code for generating the terrain to see that they were very similar. So, as of 2/13, I modified the code to instead use this Plane object. This is the terrain generation code within the setup_terrain function:

    let num_vertices: usize = (SUBDIVISIONS as usize + 2)*(SUBDIVISIONS as usize + 2);
    let height_map = perlin::terrain_perlin();
    let mut uvs: Vec<[f32;2]> = Vec::with_capacity(num_vertices);
    let mut mesh: Mesh = bevy::prelude::shape::Plane {
        size: PLANE_SIZE,
        subdivisions: SUBDIVISIONS
    }.into();
    // get positions
    let pos_attr = mesh.attribute_mut(Mesh::ATTRIBUTE_POSITION).unwrap();
    let VertexAttributeValues::Float32x3(pos_attr) = pos_attr else {
        panic!("Unexpected vertex format, expected Float32x3");
    };
    // modify y with height sampling
    for i in 0..pos_attr.len() {
        let pos = pos_attr.get_mut(i).unwrap();
        pos[1] = sample_terrain_height(&height_map, pos[0], pos[2]);
        uvs.push([pos[0]/(TILE_WIDTH as f32*TEXTURE_SCALE), pos[2]/(TILE_WIDTH as f32*TEXTURE_SCALE)]);
    };

    mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);

    let _ = mesh.generate_tangents();

Enter fullscreen mode Exit fullscreen mode

First, I get the height_map (which currently is a perlin noise map with a specific seed) and also instantiate the uvs vector. I instantiate the Plane and then modify its position attributes (just like we saw when updating grass for wind simulation) based off of a sampling function sample_terrain_height which regulates how we sample the height perlin so that we can then consistently place grass on terrain correctly without the grass knowing where the terrain actually is. We also are populating our uvs vector which will determine the tiling of the texture we use for the terrain. The position attribute is modified in place, but for the uvs, it is easier to just replace the attribute with our new vector. Finally, we generate tangents for light simulation with our normal map. Following this code, we need to provide a Handle for the texture and normal map we use. This is accomplished in the following code:

    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: Color::WHITE,
        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()
    };
Enter fullscreen mode Exit fullscreen mode

Here, we define a sampler descriptor that tiles images. We then move that into settings for loading images. Finally, we load our soil texture and normal using those settings, and use them in the creation of our terrain_material. The way Bevy handles such a thing feels funky, but this code gets the job done. By the way, the texture is a seamless texture I downloaded for free from the internet, and the normal map I created in GIMP 2.0 using a normal map plugin (I will look into adding this to the grass to give it rounded normals for more depth).

The rest of the code spawns in the terrain, as well as a placeholder plane that represents water. That is, water is represented by a plane that spawns in at some constant WATER_LEVEL.

Updating grass generation with the new terrain and water

Now that we have terrain and cheap water, we need to update the grass to generate accordingly. This ends up being incredibly simple: In our generate_grass function, instead of let y = 0;, we have:

let y = sample_terrain_height(&terrain_perlin, x_offset, z_offset) - 0.2; // minus small amount to avoid floating
Enter fullscreen mode Exit fullscreen mode

sample_terrain_height is a pure function, meaning that the same input always yields the same output, that we use for retrieving the height of the terrain. We subtract 0.2 from the result just to avoid edge cases where the grass might show its base (eg. steep inclines). Now we just need to not generate grass in water, which just means only calling generate_single_blade_verts if the y value above is greater than terrain::WATER_LEVEL.
The end result is really starting to come together!

Here's a still of the progress so far:

Grass on hills

Here's a gif with the grass waving:
Grass on hills

And here's a quick flyover of the terrain!
Flyover of terrain partially covered in grass

Final notes

I ended up going back and correcting some of the grass functions such as rotation because it was not using the y position of the vertex relative to the grass base for applying curvature, and I also reverted back to only 3 vertices for each grass blade until I figure out how to implement a custom rendering pipeline for the grass so that I can have far more blades on screen at a time. I also turned off the shooting of capsules at the closest red rectangle because I found it distracting. It is important to keep your scope small when doing solo gamedev, and having that system constantly firing was putting gameplay on my mind when I am not ready to work on that yet.

In all, this is progressing much faster than I anticipated, thanks in part to the incredible team behind Bevy providing intuitive APIs for accomplishing all of this. At this rate, I'll hopefully have realistic enough natural environments complete soon, and then will shift focus to some much needed optimization, such as generating terrain in chunks dynamically relative to player position, a custom render pipeline for the grass to implement GPU instancing, etc. Don't forget to follow me to keep up with developments on this project!

Top comments (0)