DEV Community

Cover image for Rust Game Dev Log #2: Grass Part 2 | Wind Simulation!
Michael Mironidis
Michael Mironidis

Posted on

Rust Game Dev Log #2: Grass Part 2 | Wind Simulation!

Where we left off

Last time, we went over how to get grass physically modelled in a scene in Rust with Bevy. Now, we want to give life to this grass by giving it some movement! We are going to accomplish this very simply: We will sample Perlin noise to get the displacement of the grass vertices from their original positions. We will apply this displacement based on a vertex's y position. The closer to the base of the grass blade the vertex is, the less we apply that displacement.

Observant readers of the last post will notice I ignored the y position when generating grass and just use 0 as a hardcoded base y coordinate. While writing this post, I refactored grass.rs to support generating grass blades at different y coordinates, for now hardcoding it to still be 0. What this refactor enables is programmatically generating grass on terrain with varying elevation. A topic for another post...

Setting things up

For our Perlin noise, we use the noise crate. We create a new file in util, perlin.rs, with the following code:

use bevy::prelude::*;
use noise::Perlin;

pub const WIND_SEED: u32 = 0;
pub const GRASS_HEIGHT_SEED: u32 = 1;
pub const TERRAIN_SEED: u32 = 127;
#[derive(Component)]
pub struct PerlinNoiseEntity {
    pub wind: Perlin,
    pub grass_height: Perlin,
    pub terrain: Perlin
}

impl PerlinNoiseEntity {
    pub fn new() -> Self {
        PerlinNoiseEntity {
            wind: Perlin::new(WIND_SEED),
            grass_height: Perlin::new(GRASS_HEIGHT_SEED),
            terrain: Perlin::new(TERRAIN_SEED)
        }
    }
}

pub fn setup_perlin(mut commands: Commands) {
    commands.spawn(
        PerlinNoiseEntity::new()
    );
}

pub struct PerlinPlugin;

impl Plugin for PerlinPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Startup, setup_perlin);
    }
}
Enter fullscreen mode Exit fullscreen mode

This module features the PerlinNoiseEntity, which wraps the perlins with their respective seeds that we will use for sampling. As we saw in the previous post, we set up a plugin that adds this struct to the world on Startup so we can have access to it across screen updates for consistent wind simulation.

The Grass Update System

In grass.rs, we add a new function, update_grass, and register it to the GrassPlugin:

impl Plugin for GrassPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Startup, add_grass);
        app.add_systems(Update, update_grass);
    }
}
Enter fullscreen mode Exit fullscreen mode

In update_grass, we have our logic for all dynamic grass behavior. For now, it's just wind, but in the future it could be displacement based off of a player walking over it, fire, etc.
Here's the code:

fn update_grass(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut grass: Query<(&Handle<Mesh>, &Grass)>,
    mut perlin: Query<&PerlinNoiseEntity>,
    time: Res<Time>
) {
    let time = time.elapsed_seconds_f64();
    let (mesh_handle, grass) = grass.get_single_mut().unwrap();
    let mesh = meshes.get_mut(mesh_handle).unwrap();
    let perlin = perlin.get_single_mut().unwrap();
    apply_wind(mesh, grass, perlin, time);
}
Enter fullscreen mode Exit fullscreen mode

Fairly straightforward, since the meat of the wind simulation is in apply_wind:

fn apply_wind(mesh: &mut Mesh, grass: &Grass, perlin: &PerlinNoiseEntity, time: f64) {
    let wind_perlin = perlin.wind;
    let pos_attr = mesh.attribute_mut(Mesh::ATTRIBUTE_POSITION).unwrap();
    let VertexAttributeValues::Float32x3(pos_attr) = pos_attr else {
        panic!("Unexpected vertex format, expected Float32x3");
    };

    for i in 0..pos_attr.len() {
        let pos = pos_attr.get_mut(i).unwrap(); // current vertex positions
        let initial = grass.initial_vertices.get(i).unwrap(); // initial vertex positions
        let grass_pos = grass.initial_positions.get(i).unwrap(); // initial grass positions

        let [x, y, z] = grass_pos;

        let relative_vertex_height = pos[1] - y;

        let curve_amount = WIND_STRENGTH * (sample_noise(&wind_perlin, *x, *z, time) * (relative_vertex_height.powf(CURVE_POWER)/GRASS_HEIGHT.powf(CURVE_POWER)));
        pos[0] = initial.x + curve_amount;
        pos[2] = initial.z + curve_amount;
    }
}
Enter fullscreen mode Exit fullscreen mode

To summarize what happens in apply_wind, we:
(1) Grab the wind_perlin for sampling
(2) Get the vertex positions in the mesh
(3) Unwrap the positions into an array of f32 of length 3
(4) In a loop over every current vertex position in the mesh, sample the perlin noise using the current time and the x and z positions of the grass (wind kind of moves up and down terrain, so we don't use the y position of the grass). We reduce the magnitude of the noise based off the y position of the vertex relative to the grass blade's base. A CURVE_POWER of 1.0 gives the grass a linear displacement. Values greater than 1.0 give a curvature more realistic for taller grass. The current x and z positions are updated with the sum of the initial x and z positions and this curve_amount. This does mean the wind direction is hardcoded, so another task would be to use perlin sampling for wind direction and modify the application of the displacement on the grass blades accordingly. Also, with greater values for CURVE_POWER and WIND_STRENGTH, it becomes apparent that the grass is growing and shrinking with the wind, so our approach has its limitations.

Finally, the last piece is sampling the perlin noise in sample_noise:

fn sample_noise(perlin: &Perlin, x: f32, z: f32, time: f64) -> f32 {
    WIND_LEAN + perlin.get([WIND_SPEED * time + (x as f64/WIND_CONSISTENCY), WIND_SPEED * time + (z as f64/WIND_CONSISTENCY)]) as f32
}
Enter fullscreen mode Exit fullscreen mode

First, we apply a base WIND_LEAN (how much we want our grass blades to already lean in a certain direction due to the wind). Then, we call perlin.get, which takes a sample of 2d perlin noise. Without time, each blade of grass is assigned based on it's x and z coordinates directly onto the 2d perlin "grid". We then use the increasing time value to "scroll" along the grid. I am choosing to scroll diagonally, but you can just as easily remove the term with time from one of the dimensions of the sample to scroll in either x or z. Finally, we have WIND_SPEED to control how fast we scroll through the noise.

The end result is not too shabby!
wind_gif_1

And here's one from a top view which gives a better view of the underlying perlin noise:
wind_gif_2

With that, we have a functioning, realistic-looking wind simulation on grass using perlin noise!

Conclusion and Final Thoughts

This has been a fun deliverable and is quickly approaching a passable condition. For further grass-related work, in no particular order, I intend to:

  • Write more realistic calculation of displacement from wind to account for stronger wind conditions and solve grass growing and shrinking.
  • Apply a rounded normal map to make these flat grasses look 3d.
  • Place grass on a terrain with varying elevation
  • Dynamically render grass in chunks, with closer grass being denser than farther chunks until a certain render distance where the grass no longer appears

As always, check out the repo for this project to keep up to date with the latest developments, and feel free to leave comments or suggestions!

Top comments (0)