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);
}
}
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);
}
}
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);
}
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;
}
}
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
}
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!
And here's one from a top view which gives a better view of the underlying perlin noise:
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)