Making a 3D Rust shooter in < 3 months

Introduction:

Hello👋🏾 and thank you for reading about my experience learning Rust and wgpu! As of May 14, 2024, I’ve been learning both for about 2.5 months and have built a 3D web browser shooter in that time.  While I’m still very much a beginner, I wanted to share what I’ve learned from this process.

Gameplay clip

My Background:

I’ve been programming in the Video Games Industry since 2003.  My work covers graphics + engine systems, gameplay coding, tools, vfx, technical art, and platform work.  Most of my programming experience has been in C++ and HLSL with some C# and a bit of Lua, JavaScript, UnrealScript, and a few others languages.

I’ve worked on several first person shooters like Brothers in Arms: Hells Highway, Borderlands, Rage, Valorant and my hobby project Oxi.

All of that to say, my goal with this current project was to spend my time learning Rust + wgpu and not the learning new game algorithms.

Short Cuts

I’ve deliberately taken several short-cuts to get this game done faster…and to make my life easier.  For example:

  • Only ray/axis-aligned box intersections are supported.
  • The level is an axis aligned cube simplifying collision detection and decal placement.
  • Skeletal animation is not supported. Animation is simply lerps and blends on rigid models.
  • Most of the art was already built from previous demos.  The Floor, walls, buildings, and the splatter decals are new.

Some High Level Take Aways:

  • Learn WGPU is a great resource for quickly getting up and running with wgpu.  I consider it also a must read.
  • Have fun with external crates in the beginning!  I’m using cgmath, getrandom, and gltf which are easy to use and very powerful.
  • When the borrow checker comes for you (and it most certainly will), don’t hesitate to get code design feedback from experienced Rust devs.  I have and will continue to do so.
  • Don’t bother trying to disable the borrow checker.  I’ve tried.  Others have tried.  Many more will try.
  • Learn from other Rust projects on GitHub. Using GitHub’s search tool, I found vhiribarren’s Rust+wgpu repo which has useful info on making web builds. (As an aside: I was able to run vhiribarren’s project in Safari on iOS, while my project displays a black screen). Update (5/21/2024): Both demos work on iOS browsers now. Switching from the wgpu::Backend::BrowserWebGpu backend to the widely supported wgpu::Backend::GL fixed that all up! Hyped yo!
  • And when in Rust, do as the Rust devs do!  Follow common conventions, leverage language features, and go with the flow 🏄

Things I like about Rust:

Again, I’m a beginner, so my opinions may change over time.

VARIABLES ARE IMMUTABLE BY DEFAULT

This prevent accidentally changing constant variables.  The mut keyword marks variables as mutable and requires few keystokes. Also, the compiler can sometimes infer mutability without the explicit mut declaration, making the code more condensed (and perhaps less clear).

When I was at id Software (~2010), their coding convention required const-ifying everything possible. Making variables immutable by default is less tedious while offering the same protections.

TUPLES!

Returning multiple values from a function or expression is just rad to me. Sometimes a tuple is more convenient than creating new structures or passing multiple in-out arguments to a function:

  // Return a tuple instead of having to pass in/out an argument
  let (view_matrix, view_dir, right_dir) = self.game_camera.calculate_view_matrix();

  // Can ignore unneeded parts of the tuple for convenience
  let (view_matrix, _, _) = self.game_camera.calculate_view_matrix();

EXPRESSION SYNTAX

Rust is a primarily an expression language”

I rather enjoy writing larger/nested expressions and returning the results. All the relevant code is isolated in it’s own block, nice and tidy. Returning tuples from expression blocks provide additional flexibility.

        let spawn_timer = {
            let t = 1.0 - (self.score as f32 / 20.0).clamp(0.0, 1.0);
            t + 1.0
        };

FUNCTIONS LIKE retain_mut()

While not necessarily specific to Rust, I’m having a blast leveraging these convenience functions.  For example, removing expired particles from a vec could look like this:

	let particles = vec::<Particle>::new();
	…
	particles.retain_mut(|particle| {
		if elapsed_time > particle.start_time + particle.life_time {
			// Remove the particle from the list
 			false
 		} else {
			// Keep the particle
			true
		}
	});

Things I don’t like about Rust:

Rearranging my code to satisfy the borrow checker can result in a code order that I dislike.  Sure the borrow checker is enforcing safety, and perhaps my code isn’t ”Rust-y” enough. But I still don’t like the way this looks:

	// Compiles, but I prefer to group sprite_tex* together and postprocess_tex* together
	let sprite_tex_handle = asset_manager.load_texture("sprite_sheet.png").await;
	let postprocess_tex_handle = asset_manager.load_texture("postprocess_filter.png").await;
	let sprite_tex = asset_manager.get_texture(&sprite_tex_handle);
	let postprocess_tex = asset_manager.get_texture(&postprocess_tex_handle);
	
	// I prefer this, but line 3 fails b/c it gets a mutable reference to asset_manager which is already immutably ref'd in line 2.  See here:
	let sprite_tex_handle = asset_manager.load_texture("sprite_sheet.png").await;
	let sprite_tex = asset_manager.get_texture(&sprite_tex_handle);
	let postprocess_tex_handle = asset_manager.load_texture("postprocess_filter.png").await;
	let postprocess_tex = asset_manager.get_texture(&postprocess_tex_handle);

STEEP LEARNING CURVE WITH SPARSE RESOURCES

A steep learning curve is fine, but it’s sometimes hard to find discussions relevant to your specific problem. This was especially frustrating when trying to prototype quickly while fighting the borrow checker and fumbling throuh the syntax of the language at the same time.

OCCASIONAL BUILD ISSUES

I guess it’s more of a Cargo thing, but there were several times when I was forced to do Build Clean to fix a bug.  One time in particular my wgpu_text and wgpu crates had mismatching versions and yet were compiling fine…until one time they didn’t. My progress came to a screeching halt and was not particularly fun to debug.

BORROW CHECKER

I’m on the fence about the borrow checker.  My game is single-threaded, so while borrow checking still detects reference aliasing and dangling refs, it seems like overkill atm. Otoh, now that my code satisifies the compiler, borrow checker, and cargo clippy, I am confident that my game is less buggy than an equivalent version in C++.

Update (5/15/2024): I received the following feedback from an experienced Rust developer:

“I think it’s a mistake to understand the borrow checker as mostly of benefit to multi-threaded programs. That’s what Send/Sync are for; borrow-checking serves a much more fundamental purpose. Many (perhaps most) routine serious bugs in C++ single-threaded programs are things the borrow checker prevents.

Fun Game Stuff!

For the rest of this blog post, I’m just going to wax lyrical about algorithms, graphics and Fun Game Stuff! 

RENDERDOC

I found that my favorite Graphics Profiler, RenderDoc, works well for profiling Windows Apps written in Rust + wgpu.  wgpu’s labeling system allows marking render passes with human friendly names like WorldCustom Additive, Sunbeams Mask, Sprite Opaque:

Preventing 1st Person Geometry Clipping:

A problem in first person games is that the player’s models can poke into world models. A common fix is to render the world without the player, clear the depth buffer on the GPU, then draw the player model:

Note that in games with dynamic shadows, the first person model may darken as it pokes through the wall.  Here’s an example from Cyberpunk 2077:

Anti-aliased Outlines:

My outlines are done by redrawing the hands/gun a second time, extruding each vertex along its normal, and pushing each vertex away from the camera.  A fresnel style effect (ex: as used in rim lighting) marks the edges and provides an alpha value to blend with:

God Rays:

I first discovered this screen space god ray algorithm reading white papers for the original Xbox ~2003. Since then, I’ve implemented it multiple times in different languages, engines, and hardware. Here’s my write up on how they work.

And that’s all for now! I’ll continue cleaning and optimizing the code, but otherwise this project is done. Feel free to email me feedback, corrections, or anything else at bennywilson@benny-wilson.com

Thank you for reading ❤️

Blender for Modeling: https://www.blender.org/

Tracy Frame Profiler:  https://github.com/wolfpld/tracy

Resources:

GitHub repo for my project: https://github.com/bennywilson/kbEngine3

Online PC Browser Demo: https://www.benny-wilson.com/rust_wgpu/

The Rust Programming Language: https://doc.rust-lang.org/book/title-page.html

Learn WGPU: https://sotrh.github.io/learn-wgpu/

Vulkan Specs: https://registry.khronos.org/vulkan/specs/1.3/html/vkspec.html