Background
Gamedev is a great way for software engineers to scratch that itch of solving interesting problems with satisfying visual feedback. It's one thing to write Dijkstra's algorithm for a Leetcode question, but it's another to implement A* pathfinding for a videogame and see a player character move accordingly.
A few months ago I had seen some videos about wave-function collapse and saw one of the greatest implementations of it in the game Townscaper (the only App Store purchase I've ever made!), so I tried my hand at implementing it in Python with Pygame for a very simple terrain generator. I played around with unpopulated tile selection, and instead of using the tile with the least possible options, I let the user draw terrain and then automatically draw cliffs where incompatible tiles are adjacent. It was a quick Python project with Pygame, and it scratched that aforementioned itch for me.
As I added features to it, however, I realized I was more serious about gamedev than I initially expected. I had dabbled with gamedev long ago as a middle schooler with Unity, but that never got very far since I never bothered to really learn how to code. Now, with a degree in CS + Math under my belt (and whether relevant or not, over a year of enterprise software engineering experience), I had come full circle. Time to try again.
Anyone with experience in gamedev would expect me to have redownloaded Unity, or Unreal Engine, or even tried Godot, but as the title gives away, I ended up merging my gamedev interest with my interest in Rust. A mature gamedev environment like Unity and Unreal already came with solutions to many of the problems I was interested in solving myself (eg. Grass!). That's not to say I'll never use either of those, but Rust's fledgling gamedev ecosystem makes it an exciting place to do work and contribute to. My goal with the Rust Game Dev Log is to help others in their own Rust game dev journeys and trigger conversations about how to best solve each of the problems I tackle. Ultimately, I am not necessarily doing this to make a game myself, but to solve fun problems and contribute to a growing commmunity.
Getting Started
I initially started with the subject of grass because of a video from SimonDev on the same topic. That video led me to another gamedev youtuber, Acerola who has solved the grass problem himself, as well as others like realistic water that I also plan to try out.
So I opened up a terminal, ran cargo new <project-name>
and installed some dependencies, namely Bevy as the underlying game engine, and the Noise crate we would need for Perlin noise. I also added the Bevy Inspector EGUI crate for inspecting entities in my scene and playing around with variables on the fly, and the Bevy Atmosphere crate so I had something resembling the sky while I worked on my grass.
Quick aside: What is Bevy?
For anyone that hasn't used Bevy before, I highly recommend reading their already clear and concise quickstart, but in summary: Bevy is an ECS (Entity, Component, System) based game engine. Basically, instead of OOP with lots of global variables and logic tightly coupled to those objects, Bevy uses Entities with bundled Components that are acted upon by Systems that you register with the main app. These systems can then query for components directly, which allows for logic to be completely decoupled from data. It's super intuitive and it feels like magic.
Refocus
To get some basic scene going, I used some of the examples on Bevy's github. I swapped out the point light for a directional light, made a flat plane that would serve as the terrain for now, and messed around a bit with the cubes to make one cube shoot at the other cubes with gravity and some compensation for it. (I am debating on whether to implement my own physics engine or to use Rapier, so I'll hold off on a blog post about game logic. For now, grass!)
The first grass blade
Keeping in line with my goal for this project, I wanted to generate the grass entirely from code. For more detailed meshes this obviously is not realistic, but for something simple like grass, it's easy. To draw our grass blade, I initially only used three vertices. I eventually ended up adding 4 more vertices to ultimately have a blade of grass modeled by 5 triangles.
fn generate_single_blade_verts(x: f32, z: f32, blade_number: u32, blade_height: f32) -> (Vec<Vec3>, Vec<u32>) {
let blade_number_shift = blade_number*GRASS_BLADE_VERTICES;
// vertex transforms
let t1 = Transform::from_xyz(x, 0.0, z);
let t2 = Transform::from_xyz(x+GRASS_WIDTH, 0.0, z);
let t3 = Transform::from_xyz(x, blade_height/3.0, z);
let t4 = Transform::from_xyz(x+GRASS_WIDTH, blade_height/3.0, z);
let t5 = Transform::from_xyz(x, 2.0*blade_height/3.0, z);
let t6 = Transform::from_xyz(x + GRASS_WIDTH, 2.0*blade_height/3.0, z);
let t7 = Transform::from_xyz(x+(GRASS_WIDTH/2.0), blade_height, z);
let mut transforms = vec![t1,t2,t3,t4,t5,t6,t7];
// // physical randomization of grass blades
// rotate grass randomly around y
apply_y_rotation(&mut transforms, x, z);
// curve the grass all one way
apply_curve(&mut transforms, x, z);
// rotate grass again
apply_y_rotation(&mut transforms, x, z);
let verts: Vec<Vec3> = transforms.iter().map(|t| t.translation).collect();
let indices: Vec<u32> = vec![
blade_number_shift+0, blade_number_shift+1, blade_number_shift+2,
blade_number_shift+2, blade_number_shift+1, blade_number_shift+3,
blade_number_shift+2, blade_number_shift+3, blade_number_shift+4,
blade_number_shift+4, blade_number_shift+2, blade_number_shift+3,
blade_number_shift+4, blade_number_shift+3, blade_number_shift+5,
blade_number_shift+4, blade_number_shift+5, blade_number_shift+6,
];
(verts, indices)
}
Here you can see it's pretty clear how a grass blade is generated:
- At some given x and z (y is up and down), with some
blade_height
, generate grass blade numberblade_number
by defining 7 vertices relative to x, z, the passed inblade_height
, and the constantGRASS_WIDTH
-
apply_y_rotation
to curve the blade around the y axis -
apply_curve
curves the blade of grass in a defined direction by some random amount -
apply_y_rotation
again so that the grass blades don't all curve in the same direction
We pass in the blade_height
instead of using a constant because we don't want a constant blade height, that's unrealistic.
We pass in the blade_number
because we're not defining a new Mesh for each blade of grass, instead adding these vertices to a giant grass mesh. Defining each blade of grass as its own mesh tanks your fps and is completely unnecessary. As a result, we needed to keep track of which blade of grass the verts corresponded to so that we could provide the correct ordering, or indices.
Quick aside: Vertex ordering
In most rendering frameworks (if not all), a triangle's face is rendered on the side of the vertices where their ordering is counterclockwise.
For example, in 2d space, if vertices a, b, and c are:
a = [0,0]
b = [1,0]
c = [0,1]
The ordering a, b, c will render the triangle facing "you" because a to b to c is counterclockwise, but the ordering a, c, b will not.
Generating a field of grass
So we now have logic to generate a single blade of grass's vertices and indices. How do we generate a whole field?
pub fn generate_grass(
commands: &mut Commands,
meshes: &mut ResMut<Assets<Mesh>>,
materials: &mut ResMut<Assets<StandardMaterial>>,
) {
let mut grass_offsets = vec![];
let mut rng = thread_rng();
let mut mesh = if !ENABLE_WIREFRAME { Mesh::new(PrimitiveTopology::TriangleList) } else { Mesh::new(PrimitiveTopology::LineList)};
let mut all_verts: Vec<Vec3> = vec![];
let mut all_indices: Vec<u32> = vec![];
let mut blade_number = 0;
let perlin = PerlinNoiseEntity::new();
let height_perlin = perlin.grass_height;
for i in 0..NUM_GRASS_X {
let x = i as f32;
for j in 0..NUM_GRASS_Y {
let z = j as f32;
let rand1 = if GRASS_OFFSET!=0.0 {rng.gen_range(-GRASS_OFFSET..GRASS_OFFSET)} else {0.0};
let rand2 = if GRASS_OFFSET!=0.0 {rng.gen_range(-GRASS_OFFSET..GRASS_OFFSET)} else {0.0};
let x_offset = x * GRASS_SPACING + rand1;
let z_offset = z * GRASS_SPACING + rand2;
let blade_height = GRASS_HEIGHT + (height_perlin.get([x_offset as f64, z_offset as f64]) as f32 * GRASS_HEIGHT_VARIATION_FACTOR);
// let blade_height = GRASS_HEIGHT;
let (mut verts, mut indices) = generate_single_blade_verts(x_offset, z_offset, blade_number, blade_height);
for _ in 0..verts.len() {
grass_offsets.push([x_offset,z_offset]);
}
all_verts.append(&mut verts);
all_indices.append(&mut indices);
blade_number += 1;
}
}
generate_grass_geometry(&all_verts, all_indices, &mut mesh);
let grass_material = StandardMaterial {
base_color: Color::DARK_GREEN.into(),
double_sided: false,
perceptual_roughness: 0.1,
diffuse_transmission: 0.5,
reflectance: 0.0,
cull_mode: None,
..default()
};
commands.spawn(PbrBundle {
mesh: meshes.add(mesh),
material: materials.add(grass_material),
transform: Transform::from_xyz(-((NUM_GRASS_X/8) as f32), 0.0, -((NUM_GRASS_Y/8) as f32)).with_scale(Vec3::new(1.0,GRASS_SCALE_FACTOR,1.0)),
..default()
})
.insert(Grass {
initial_vertices: all_verts,
initial_positions: grass_offsets
});
}
Now, this looks like a lot, but it can be broken down simply:
First, this function is actually called from within the terrain generation System (EC*S*), since I intend on having the grass generate based off of the terrain. As a result, I pass a mutable reference to the Commands
and Mesh
and StandardMaterial
assets that the terrain System grabs in its args.
Quick aside: Defining a System in Bevy
A System in Bevy can be roughly described as logic that acts upon data. In practice it is just a function that has defined some args that it expects to receive from Bevy and gets registered with the app. In the case of terrain, it's just the Commands
and Mesh
and StandardMaterial
Assets. Commands
allows you to make changes to the game world such as spawn or despawn entities. The pointers to the meshes and materials allows you to add or modify meshes and materials in the game world. Another type of argument you can have is a Query<(Components to fetch), (Filters)>
argument. This allows you to get any combination of entities and even optionally filter them. You will see this being used in the logic that handles wind simulation.
Refocus
Next, grass_offsets
defines the initial positions in (x,z) of all of the grass blades, and all_verts
defines the initial vertex coordinates of each of the vertices. Knowing the initial state of the grass is crucial for how we handle simulating wind (and other actions) upon the grass.
I have also defined a PerlinNoiseEntity
that wraps all of the perlins we use. In this case I just instantiate a new one, but for real-time game updates, I have one spawned into the game world through the PerlinPlugin
that can be queried for to sample from instead of constantly instantiating a new one.
Quick aside: Plugins?
Plugins are just a way for you to "plug" Systems into the app without having to directly add them in the main function.
Eg. Here's my main function:
fn main() {
std::env::set_var("RUST_BACKTRACE", "1");
App::new()
.insert_resource(AmbientLight {
brightness: 0.5,
color: Color::AZURE,
..default()
})
.add_plugins((
DefaultPlugins,
WorldInspectorPlugin::new(),
LogDiagnosticsPlugin::default(),
FrameTimeDiagnosticsPlugin::default(),
AtmospherePlugin,
util::camera::CameraPlugin,
util::lighting::LightingPlugin,
util::perlin::PerlinPlugin,
ent::terrain::TerrainPlugin,
ent::grass::GrassPlugin,
ent::player::PlayerPlugin,
ent::enemy::EnemyPlugin,
ent::projectiles::ProjectilePlugin,
))
.register_type::<ent::player::Player>()
.register_type::<ent::projectiles::BasicProjectile>()
.run();
}
As you can see, a bunch of plugins defining game logic (as well as some for development).
In perlin.rs, I have:
pub struct PerlinPlugin;
impl Plugin for PerlinPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_perlin);
}
}
Which just says that the PerlinPlugin
adds the system setup_perlin
to the Startup schedule. And here's setup_perlin
:
pub fn setup_perlin(mut commands: Commands) {
commands.spawn(
PerlinNoiseEntity::new()
);
}
Which just spawns a PerlinNoiseEntity
(which is just a wrapper around the Perlin struct in the noise crate) that I can Query for whenever I need to sample the same perlin noise (I keep different instances of Perlin with different seeds for each use case, eg. wind, grass height, and soon, terrain).
Refocus
With my outer Vectors keeping track of all of the starting positions, vertices, and indices of the grass, I use a nested for loop to generate grass for each (x,z), offsetting the position of the grass by some offset and randomizing the height. I add the single blade's generated vertices and indices to the outer vectors, and then call generate_grass_geometry
to populate the actual mesh. This function just sets the positions, normals, and uvs of the mesh. Finally, I define a new material grass_material
, and then spawn the grass mesh in. In spawning it in, I bundle it with a Grass
struct so I can easily query for it. Additionally, the Grass
struct stores the initial state of the grass vertices, so I can implement wind and other changes upon the grass.
The result is not bad for two days' work!
There's more!
This wraps up the first blog post for this project. I already have the wind functionality implemented, so the next post on sampling perlin noise to implement wind is coming soon! If you can't wait for that, or if you want to see more code, everything I'm working on related to this project is available at this Github repo!
Top comments (4)
Sir, your articels are very usefull. but it's would be great if they could write in more details.
Hi PangoSea, thanks for the comment! I appreciate the suggestion, I'm trying to find the balance between not being too short on details while not regurgitating information available in documentation (or that can't be understood from reading code). I can try answering any specific questions you have, or providing resources!
keep going on.
If you've read my other 3 posts, then the next update is coming soon. It's probably the biggest update yet, which is why it's taking so long.