Introduction
Welcome! This book will guide you through the creation and design of a 3D game engine using the Rust programming language. A variety of popular open source libraries will be used to achieve this goal in a reasonable amount of time.
Purpose
As of June 2022, the resources for learning game engine creation are scarce. The existing resources largely focus on C++
and cover various rendering techniques rather than topics that would be useful for designing gameplay mechanics. This is excellent! However, the focus for many of these resources is not on building a structured program that goes beyond the scope of tutorial code. This book is meant to be higher level, demonstrating how to build a 3D world and render it in realtime. This will be particularly of use to indie game developers looking to create a 3D game from scratch without getting overwhelmed.
Why Rust?
Rust is a great alternative to C++
! A few of the benefits:
- It provides a smooth workflow for developers with clear, specific error messages from the compiler
- rustup and cargo make managing rust toolchain installations and rust projects straightforward
- The lints from clippy help to improve the code quality and catch certain common mistakes
- rustfmt handles formatting the code and code style
- Memory safety. Code written outside of
unsafe
blocks is checked by the borrow checker
Target Audience
The target audience of this book is moderately experienced developers with an interest in graphics programming and game development. Prior graphics programming experience will be particularly useful. This is the book I would have wanted to read when first starting out with designing games from scratch.
What Is Covered
This book is very code-heavy and implementation focused, as opposed to other resources that may be more focused on theory.
What Is Not Covered
This book will not go into detail on linear algebra concepts or mathematics, as there are already great resources available for deep information those topics. A list of useful external resources for building upon the content of this book can be found in the Further Reading
section of the appendix.
Project Repo
All of the source code for the Phantom
engine built in this book can be found on github:
https://github.com/matthewjberger/phantom
Final Project Preview
Chapter 1
- Set up the project structure
- Create the boilerplate code used throughout the rest of the book
- Get a window up and running
The code in this chapter will serve as the project's foundation.
Chapter 2
- Create a renderer
- Setup a GUI
- Render a basic scene
Getting Started
Dependencies
This project requires the Rust programming language.
Development Environment Setup
Using vscode and rust-analyzer with the rust-analyzer vscode extension is recommended. However, any Rust development environment you are comfortable with will work.
The official Rust book is a great resource if you are new to Rust.
Quick Windows Setup
On windows, installing programs can be trickier than on other platforms. It is recommended to use a package manager such as Scoop or chocolatey.
First, make sure PowerShell 5 (or later, include PowerShell Core) and .NET Framework 4.5 (or later) are installed. Then run:
# Install scoop
Set-ExecutionPolicy RemoteSigned -scope CurrentUser
Invoke-Expression (New-Object System.Net.WebClient).DownloadString('https://get.scoop.sh')
# Scoop uses git to update itself and 7zip to extract archives
scoop install git 7zip
# Install the project's dependencies
scoop install rustup
# Set the stable rust toolchain as the default toolchain
rustup default stable
# Install vscode, kept in a separate bucket called 'extras'
scoop bucket add extras
scoop install vscode
Project Setup
If you wish to make an apple pie from scratch,
you must first invent the universe.
~ Carl Sagan
Welcome to the exciting world of game development!
To get started, we'll first need to setup our project structure.
Creating the Project Structure
Let's create a new project! We will call our engine the phantom
game engine.
cargo new --lib phantom
cd phantom
Now we can create all of the libraries and applications we will need for this project.
# Applications
cargo new --vcs none apps/editor
cargo new --vcs none apps/viewer
# Libraries
cargo new --lib crates/phantom_app
cargo new --lib crates/phantom_audio
cargo new --lib crates/phantom_dependencies
cargo new --lib crates/phantom_gui
cargo new --lib crates/phantom_render
cargo new --lib crates/phantom_world
A Window Icon
For our window, we'll want a nice looking icon. Let's copy the following png into a folder for later use.
mkdir assets/icons
pushd assets/icons
curl -O https://matthewjberger.xyz/phantom/images/phantom.png
popd
Code Linting
To perform code linting we'll use clippy.
First, let's install clippy
:
rustup update
rustup component add clippy
And then add a configuration file at the root named clippy.toml
with the following contents:
too-many-lines-threshold = 80
too-many-arguments-threshold = 5
These can be any settings you like, of course. Here is a list of valid clippy options.
Lints will be performed automatically by vscode
. To lint manually, you can run cargo clippy -p phantom
.
Code Formatting
To perform code formatting we'll use rustfmt.
First, let's install rustfmt
:
rustup update
rustup component add rustfmt
And then add a configuration file at the root named rustfmt.toml
with the following contents:
max_width = 100
These can be any settings you like, of course. Here is a list of valid rustfmt settings.
Formatting can be performed automatically by vscode
. To format the project manually, you can run cargo fmt --workspace
.
Add a Readme
Our README.md
looks like this:
# Phantom
Phantom is a 3D game engine written in Rust!
## Development Prerequisites
- [Rust](https://www.rust-lang.org/)
## Instructions
To run the visual editor for Phantom, run this command in the root directory:
`cargo run --release --bin editor`.
Putting It All Together
Now to connect our existing projects to one another, we'll have to update the contents of some of our new source files.
Connecting Our Libraries
Our game engine is designed as a library. We will want to make the various parts of the engine accessible by re-exporting them.
Our Cargo.toml
at the root should look like this:
[package]
name = "phantom"
version = "0.1.0"
edition = "2021"
[workspace]
default-members = ["apps/*"]
members = ["apps/*", "crates/*"]
[dependencies]
phantom_app = { path = "crates/phantom_app" }
phantom_audio = { path = "crates/phantom_audio" }
phantom_dependencies = { path = "crates/phantom_dependencies" }
phantom_gui = { path = "crates/phantom_gui" }
phantom_render = { path = "crates/phantom_render" }
phantom_world = { path = "crates/phantom_world" }
Next, the src/lib.rs
should look like this:
pub mod app {
pub use phantom_app::*;
}
pub mod audio {
pub use phantom_audio::*;
}
pub mod dependencies {
pub use phantom_dependencies::*;
}
pub mod gui {
pub use phantom_gui::*;
}
pub mod render {
pub use phantom_render::*;
}
pub mod world {
pub use phantom_world::*;
}
This lets us access the public exports of all of our engine libraries, and applications will only need a single import of our main engine library.
Handling Dependencies
To handle dependencies consistently across all of our projects,
we have created a phantom_dependencies
project. Here will we
list all of our dependencies and re-export them. This helps
ensure the same version of any given dependency is used across all of our modules.
For the following apps:
phantom_app
phantom_audio
phantom_gui
phantom_render
phantom_world
Add our phantom_dependencies
crate as a dependency in the corresponding Cargo.toml
:
phantom_dependencies = { path = "../phantom_dependencies" }
Some macros only work if the crate they are exported from is included in the dependencies for the project it is used in, but this will cover the majority of our dependencies.
Connecting our Apps
For the following apps:
editor
viewer
Add our main phantom
crate as a dependency in the corresponding Cargo.toml
:
phantom = { path = "../.." }
Verifying your Project
Check your project so far with the following command:
cargo check
Adding dependencies
To make adding dependencies easier, we'll install a cargo extension called cargo-edit.
cargo install cargo-edit
Then we can use it to add some dependencies to our phantom_dependencies
crate.
cargo add anyhow -p phantom_dependencies
cargo add env_logger -p phantom_dependencies
cargo add log -p phantom_dependencies
cargo add thiserror -p phantom_dependencies
Now we re-export these dependencies in phantom_dependencies/lib.rs
:
pub use anyhow;
pub use env_logger;
pub use log;
pub use thiserror;
We will continue following this pattern whenever we add dependencies to the project!
The dependencies we have added are:
- anyhow
- A flexible concrete Error type built on
std::error::Error
. We will use this in our applications, which do not need to return detailed, complex error types.
- A flexible concrete Error type built on
- thiserror
- A
derive(Error)
for struct and enum error types. We will use this in our engine and its libraries, so that we can return detailed, descriptive error types. This will ultimately make debugging easier.
- A
- log
- The standard Rust logger facade.
- env_logger
- An implementation of the standard rust logger facade that is configured using environment variables.
Creating an Application
To create various applications and games with our engine, we will need to handle tasks such as creating a window, tracking input from various sources (keyboard, controllers, etc), and keeping track of our application state. These tasks and more will all be encapsulated within our phantom_app
crate!
Application Configuration
We'll start off by creating a data structure representing our application's initial configuration. This will allow consumers of the crate to customize features such as window size, the title, the icon, etc.
In our crates/phantom_app/lib.rs
we'll replace the contents of the file with the following:
mod app;
pub use self::app::*;
Now we will create a file named crates/phantom_app/app.rs
and begin building our application config.
pub struct AppConfig {
pub width: u32,
pub height: u32,
pub is_fullscreen: bool,
pub title: String,
pub icon: Option<String>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
width: 1024,
height: 768,
is_fullscreen: false,
title: "Phantom Application".to_string(),
icon: None,
}
}
}
Application Dependencies
Creating a window requires different boilerplate on each platform. Thankfully an excellent open source library for handling window creation named winit exists.
Let's add that to our phantom_dependencies
project:
cargo add winit -p phantom_dependencies
And let's not forget to re-export it in crates/phantom_dependencies/lib.rs
!
pub use winit;
Application Resources
Let's create another module to store resources necessary for the application to run.
Declaring the Resources Module
In our crates/phantom_app/lib.rs
we'll declare and export the resources
module.
...
mod resources;
pub use self::{
// ...
resources::*,
};
Storing Application Resources
Create the file phantom_app/src/resources.rs
with the following contents:
use phantom_dependencies::winit::window::Window;
pub struct Resources<'a> {
pub window: &'a mut Window,
}
impl<'a> Resources<'a> {
pub fn set_cursor_visible(&mut self, visible: bool) {
self.window.set_cursor_visible(visible)
}
}
Application Creation
Now we can do something exciting and get a window visible on screen!
First, run the following command to allow macros from thiserror
to work for phantom_app
:
cargo add thiserror -p phantom_app
Now, let's add the following code to phantom_app/src/app.rs
.
use crate::Resources;
use phantom_dependencies::{
env_logger,
image::{self, io::Reader},
log,
thiserror::Error,
winit::{
self,
dpi::PhysicalSize,
event::{ElementState, Event, VirtualKeyCode, WindowEvent},
event_loop::{ControlFlow, EventLoop},
window::{Icon, WindowBuilder},
},
};
#[derive(Error, Debug)]
pub enum ApplicationError {
#[error("Failed to create a window!")]
CreateWindow(#[source] winit::error::OsError),
}
type Result<T, E = ApplicationError> = std::result::Result<T, E>;
// ...
pub fn run(config: AppConfig) -> Result<()> {
env_logger::init();
log::info!("Phantom app started");
let event_loop = EventLoop::new();
let mut window_builder = WindowBuilder::new()
.with_title(config.title.to_string())
.with_inner_size(PhysicalSize::new(config.width, config.height));
// TODO: Load the window icon
let mut window = window_builder
.build(&event_loop)
.map_err(ApplicationError::CreateWindow)?;
event_loop.run(move |event, _, control_flow| {
let resources = Resources {
window: &mut window,
};
if let Err(error) = run_loop(&event, control_flow, resources) {
log::error!("Application error: {}", error);
}
});
}
fn run_loop(event: &Event<()>, control_flow: &mut ControlFlow, resources: Resources) -> Result<()> {
match event {
Event::WindowEvent {
ref event,
window_id,
} if *window_id == resources.window.id() => match event {
WindowEvent::CloseRequested => *control_flow = ControlFlow::Exit,
WindowEvent::KeyboardInput { input, .. } => {
if let (Some(VirtualKeyCode::Escape), ElementState::Pressed) =
(input.virtual_keycode, input.state)
{
*control_flow = ControlFlow::Exit;
}
}
_ => {}
},
_ => {}
}
Ok(())
}
Loading the Window Icon
To load the window icon, we'll need a library for loading images. We'll use the image crate.
cargo add image -p phantom_dependencies
Then re-export it in phantom_dependencies/lib.rs
.
pub use image;
We can now add errors for our icon loading code:
#[error("Failed to create icon file!")]
CreateIcon(#[source] winit::window::BadIcon),
...
#[error("Failed to decode icon file at path: {1}")]
DecodeIconFile(#[source] image::ImageError, String),
#[error("Failed to open icon file at path: {1}")]
OpenIconFile(#[source] io::Error, String),
Now we can replace our TODO
with the following code!
if let Some(icon_path) = config.icon.as_ref() {
let image = Reader::open(icon_path)
.map_err(|error| ApplicationError::OpenIconFile(error, icon_path.to_string()))?
.decode()
.map_err(|error| ApplicationError::DecodeIconFile(error, icon_path.to_string()))?
.into_rgba8();
let (width, height) = image.dimensions();
let icon = Icon::from_rgba(image.into_raw(), width, height)
.map_err(ApplicationError::CreateIcon)?;
window_builder = window_builder.with_window_icon(Some(icon));
}
Instantiating the Editor
Now that we've written the necessary window creation code in our library, we can setup our editor
application.
Replace the contents of apps/editor/src/main.rs
with the following code.
use phantom::{
app::{run, AppConfig},
dependencies::anyhow::Result,
};
#[derive(Default)]
pub struct Editor;
fn main() -> Result<()> {
Ok(run(AppConfig {
icon: Some("assets/icon/phantom.png".to_string()),
..Default::default()
})?)
}
Run the application again with cargo run -r --bin editor
and you should see a blank window with our phantom logo!
Viewing the console logs
To view the console logs, set the RUST_LOG
environment variable to debug
.
Mac/Linux:
RUST_LOG="debug"
Windows (powershell):
$env:RUST_LOG="debug"
Game States
Games often have separate maps, levels, areas, and more to wander through. It would not make sense to have all of the assets loaded in memory all of the time, because it would easily consume too much memory and cause unplayable framerates or worse, crashes!
A common way of determining which assets to load and what to display on screen is through the use of GameStates
. Using these in a stack effectively gives us a state machine that composes our GameStates
.
The state on top of the stack is what will be shown in the application. Imagine you are playing a game and you press the pause button. With a state machine, we can simply push another state onto the stack (such as a GamePaused
state). When we unpause, the state can be popped off the stack and gameplay will resume. This opens the doors to splash screens, loading screens, pause menus, and much more. This flexibility is valuable when trying to manage resources efficiently and write modular code for your game.
Designing a State Machine
To begin, let's define a trait to represent state in our games. For the Result
type, we will use anyhow::Result
because these will be implemented by the application rather than our library.
Declare the state
module in crates/phantom_app/lib.rs
.
...
mod state;
pub use self::{ ..., state::* };
Create a file named crates/phantom_app/state.rs
with the following contents.
use crate::Resources;
use phantom_dependencies::{
thiserror::Error,
winit::{
dpi::PhysicalSize,
event::{ElementState, Event, KeyboardInput, MouseButton},
},
};
use std::path::Path;
#[derive(Error, Debug)]
pub enum StateMachineError {
#[error("Failed to get the current surface texture!")]
NoStatesPresent,
}
type Result<T, E = StateMachineError> = std::result::Result<T, E>;
pub type StateResult<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
pub struct EmptyState {}
impl State for EmptyState {}
pub trait State {
fn label(&self) -> String {
"Unlabeled Game State".to_string()
}
fn on_start(&mut self, _resources: &mut Resources) -> StateResult<()> {
Ok(())
}
fn on_pause(&mut self, _resources: &mut Resources) -> StateResult<()> {
Ok(())
}
fn on_stop(&mut self, _resources: &mut Resources) -> StateResult<()> {
Ok(())
}
fn on_resume(&mut self, _resources: &mut Resources) -> StateResult<()> {
Ok(())
}
fn update(&mut self, _resources: &mut Resources) -> StateResult<Transition> {
Ok(Transition::None)
}
fn on_gamepad_event(
&mut self,
_resources: &mut Resources,
_event: GilrsEvent,
) -> StateResult<Transition> {
Ok(Transition::None)
}
fn on_file_dropped(
&mut self,
_resources: &mut Resources,
_path: &Path,
) -> StateResult<Transition> {
Ok(Transition::None)
}
fn on_resize(
&mut self,
_resources: &mut Resources,
_physical_size: &PhysicalSize<u32>,
) -> StateResult<Transition> {
Ok(Transition::None)
}
fn on_mouse(
&mut self,
_resources: &mut Resources,
_button: &MouseButton,
_button_state: &ElementState,
) -> StateResult<Transition> {
Ok(Transition::None)
}
fn on_key(
&mut self,
_resources: &mut Resources,
_input: KeyboardInput,
) -> StateResult<Transition> {
Ok(Transition::None)
}
fn on_event(
&mut self,
_resources: &mut Resources,
_event: &Event<()>,
) -> StateResult<Transition> {
Ok(Transition::None)
}
}
Now, let's define a type for representing transitions between game states.
pub enum Transition {
None,
Pop,
Push(Box<dyn State>),
Switch(Box<dyn State>),
Quit,
}
With these traits defined, we are ready to define our StateMachine
.
Note that the states are not public, as they should be accessed internally by the state machine itself. All methods on the state resolve to a Transition
that is used to determine whether or not transition the state machine. These transitions should happen automatically from the user's perspective!
pub struct StateMachine {
running: bool,
states: Vec<Box<dyn State>>,
}
impl StateMachine {
pub fn new(initial_state: impl State + 'static) -> Self {
Self {
running: false,
states: vec![Box::new(initial_state)],
}
}
pub fn active_state_label(&self) -> Option<String> {
if !self.running {
return None;
}
self.states.last().map(|state| state.label())
}
pub fn is_running(&self) -> bool {
self.running
}
pub fn start(&mut self, resources: &mut Resources) -> StateResult<()> {
if self.running {
return Ok(());
}
self.running = true;
self.active_state_mut()?.on_start(resources)
}
pub fn handle_event(
&mut self,
resources: &mut Resources,
event: &Event<()>,
) -> StateResult<()> {
if !self.running {
return Ok(());
}
let transition = self.active_state_mut()?.on_event(resources, event)?;
self.transition(transition, resources)
}
pub fn update(&mut self, resources: &mut Resources) -> StateResult<()> {
if !self.running {
return Ok(());
}
let transition = self.active_state_mut()?.update(resources)?;
self.transition(transition, resources)
}
pub fn on_gamepad_event(
&mut self,
resources: &mut Resources,
event: GilrsEvent,
) -> StateResult<()> {
if !self.running {
return Ok(());
}
let transition = self
.active_state_mut()?
.on_gamepad_event(resources, event)?;
self.transition(transition, resources)
}
pub fn on_file_dropped(&mut self, resources: &mut Resources, path: &Path) -> StateResult<()> {
if !self.running {
return Ok(());
}
let transition = self.active_state_mut()?.on_file_dropped(resources, path)?;
self.transition(transition, resources)
}
pub fn on_resize(
&mut self,
resources: &mut Resources,
physical_size: &PhysicalSize<u32>,
) -> StateResult<()> {
if !self.running {
return Ok(());
}
let transition = self
.active_state_mut()?
.on_resize(resources, physical_size)?;
self.transition(transition, resources)
}
pub fn on_mouse(
&mut self,
resources: &mut Resources,
button: &MouseButton,
button_state: &ElementState,
) -> StateResult<()> {
if !self.running {
return Ok(());
}
let transition = self
.active_state_mut()?
.on_mouse(resources, button, button_state)?;
self.transition(transition, resources)
}
pub fn on_key(&mut self, resources: &mut Resources, input: KeyboardInput) -> StateResult<()> {
if !self.running {
return Ok(());
}
let transition = self.active_state_mut()?.on_key(resources, input)?;
self.transition(transition, resources)
}
pub fn on_event(&mut self, resources: &mut Resources, event: &Event<()>) -> StateResult<()> {
if !self.running {
return Ok(());
}
let transition = self.active_state_mut()?.on_event(resources, event)?;
self.transition(transition, resources)
}
fn transition(&mut self, request: Transition, resources: &mut Resources) -> StateResult<()> {
if !self.running {
return Ok(());
}
match request {
Transition::None => Ok(()),
Transition::Pop => self.pop(resources),
Transition::Push(state) => self.push(state, resources),
Transition::Switch(state) => self.switch(state, resources),
Transition::Quit => self.stop(resources),
}
}
fn active_state_mut(&mut self) -> Result<&mut Box<(dyn State + 'static)>> {
self.states
.last_mut()
.ok_or(StateMachineError::NoStatesPresent)
}
fn switch(&mut self, state: Box<dyn State>, resources: &mut Resources) -> StateResult<()> {
if !self.running {
return Ok(());
}
if let Some(mut state) = self.states.pop() {
state.on_stop(resources)?;
}
self.states.push(state);
self.active_state_mut()?.on_start(resources)
}
fn push(&mut self, state: Box<dyn State>, resources: &mut Resources) -> StateResult<()> {
if !self.running {
return Ok(());
}
if let Ok(state) = self.active_state_mut() {
state.on_pause(resources)?;
}
self.states.push(state);
self.active_state_mut()?.on_start(resources)
}
fn pop(&mut self, resources: &mut Resources) -> StateResult<()> {
if !self.running {
return Ok(());
}
if let Some(mut state) = self.states.pop() {
state.on_stop(resources)?;
}
if let Some(state) = self.states.last_mut() {
state.on_resume(resources)
} else {
self.running = false;
Ok(())
}
}
pub fn stop(&mut self, resources: &mut Resources) -> StateResult<()> {
if !self.running {
return Ok(());
}
while let Some(mut state) = self.states.pop() {
state.on_stop(resources)?;
}
self.running = false;
Ok(())
}
}
Using the State Machine
To use the state machine, we'll want to modify our crates/phantom_app/src/app.rs
.
We should now create a state_machine and pass it to our run_loop
function.
pub fn run(initial_state: impl State + 'static, ...) {
...
let mut state_machine = StateMachine::new(initial_state);
...
event_loop.run(move |event, _, control_flow| {
...
if let Err(error) = run_loop(&mut state_machine, &event, control_flow, resources) {
...
}
});
}
fn run_loop(
state_machine: &mut StateMachine,
...,
) {
...
}
This allows us to use the state_machine
in our event handlers!
#[derive(Error, Debug)]
pub enum ApplicationError {
#[error("Failed to handle an event in the state machine!")]
HandleEvent(#[source] Box<dyn std::error::Error>),
#[error("Failed to start the state machine!")]
StartStateMachine(#[source] Box<dyn std::error::Error>),
#[error("Failed to stop the state machine!")]
StopStateMachine(#[source] Box<dyn std::error::Error>),
#[error("Failed to update the state machine!")]
UpdateStateMachine(#[source] Box<dyn std::error::Error>),
...
}
fn run_loop(
...
) -> Result<()> {
if !state_machine.is_running() {
state_machine
.start(&mut resources)
.map_err(ApplicationError::StartStateMachine)?;
}
state_machine
.handle_event(&mut resources, event)
.map_err(ApplicationError::HandleEvent)?;
match event {
Event::MainEventsCleared => {
state_machine.update(&mut resources)?;
}
Event::WindowEvent {
ref event,
window_id,
} if *window_id == resources.window.id() => match event {
WindowEvent::CloseRequested => *control_flow = ControlFlow::Exit,
WindowEvent::KeyboardInput { input, .. } => {
...
state_machine.on_key(&mut resources, *input)?;
}
WindowEvent::MouseInput { button, state, .. } => {
state_machine.on_mouse(&mut resources, button, state)?;
}
WindowEvent::DroppedFile(ref path) => {
state_machine.on_file_dropped(&mut resources, path)?;
}
WindowEvent::Resized(physical_size) => {
state_machine
.on_resize(&mut resources, physical_size)
.map_err(ApplicationError::HandleEvent)?;
}
_ => {}
},
Event::LoopDestroyed => {
state_machine
.stop(&mut resources)
.map_err(ApplicationError::StopStateMachine)?;
}
_ => {}
}
Ok(())
}
Setting up the Editor
With gamestates in place, we can refactor our apps/editor/src/main.rs
to the following.
use phantom::{
app::{run, AppConfig, Resources, State, Transition},
dependencies::{
anyhow::{Context, Result},
egui::{global_dark_light_mode_switch, menu, SidePanel, TopBottomPanel},
gilrs::Event as GilrsEvent,
log,
winit::event::{ElementState, Event, KeyboardInput, MouseButton},
},
};
#[derive(Default)]
pub struct Editor;
impl State for Editor {
fn label(&self) -> String {
"Phantom Editor - Main".to_string()
}
fn on_start(&mut self, _resources: &mut Resources) -> Result<()> {
log::info!("Starting the Phantom editor");
Ok(())
}
fn on_stop(&mut self, _resources: &mut Resources) -> Result<()> {
log::info!("Stopping the Phantom editor");
Ok(())
}
fn on_pause(&mut self, _resources: &mut Resources) -> Result<()> {
log::info!("Editor paused");
Ok(())
}
fn on_resume(&mut self, _resources: &mut Resources) -> Result<()> {
log::info!("Editor unpaused");
Ok(())
}
fn update(&mut self, _resources: &mut Resources) -> Result<Transition> {
Ok(Transition::None)
}
fn on_file_dropped(
&mut self,
_resources: &mut Resources,
path: &std::path::Path,
) -> Result<Transition> {
log::info!(
"File dropped: {}",
path.as_os_str()
.to_str()
.context("Failed to convert path!")?
);
Ok(Transition::None)
}
fn on_resize(
&mut self,
_resources: &mut Resources,
physical_size: &PhysicalSize<u32>,
) -> StateResult<Transition> {
log::trace!("Window resized: {:#?}", physical_size);
Ok(Transition::None)
}
fn on_mouse(
&mut self,
_resources: &mut Resources,
button: &MouseButton,
button_state: &ElementState,
) -> Result<Transition> {
log::trace!("Mouse event: {:#?} {:#?}", button, button_state);
Ok(Transition::None)
}
fn on_key(&mut self, _resources: &mut Resources, input: KeyboardInput) -> Result<Transition> {
log::trace!("Key event received: {:#?}", input);
Ok(Transition::None)
}
fn on_event(&mut self, _resources: &mut Resources, _event: &Event<()>) -> Result<Transition> {
Ok(Transition::None)
}
}
fn main() -> Result<()> {
Ok(run(
Editor::default(),
AppConfig {
icon: Some("assets/icons/phantom.png".to_string()),
..Default::default()
},
)?)
}
Tracking Input
Using the events we receive from Winit, we have a way of responding to events as they happen, but we don't necessarily know the state of a given key, the mouse, the window, etc. What we want is for that information to be tracked and made available to gamestates, so they can make informed decisions using up-to-date information.
Necessary Dependencies
First, let's add a math library to simplify some of the calculations.
This will be heavily used when we design our world crate!
cargo add anyhow -p nalgebra_glm
Then, export it in crates/phantom_dependencies/src/lib.rs
:
pub use nalgebra_glm as glm;
Adding an Input Resource
Let's add a file at crates/phantom_app/src/resources/input.rs
.
use phantom_dependencies::{
glm,
winit::{
dpi::PhysicalPosition,
event::{
ElementState, Event, KeyboardInput, MouseButton, MouseScrollDelta, VirtualKeyCode,
WindowEvent,
},
},
};
use std::collections::HashMap;
pub type KeyMap = HashMap<VirtualKeyCode, ElementState>;
pub struct Input {
pub keystates: KeyMap,
pub mouse: Mouse,
pub allowed: bool,
}
impl Default for Input {
fn default() -> Self {
Self {
keystates: KeyMap::default(),
mouse: Mouse::default(),
allowed: true,
}
}
}
impl Input {
pub fn is_key_pressed(&self, keycode: VirtualKeyCode) -> bool {
self.keystates.contains_key(&keycode) && self.keystates[&keycode] == ElementState::Pressed
}
pub fn handle_event<T>(&mut self, event: &Event<T>, window_center: glm::Vec2) {
if !self.allowed {
return;
}
if let Event::WindowEvent {
event:
WindowEvent::KeyboardInput {
input:
KeyboardInput {
virtual_keycode: Some(keycode),
state,
..
},
..
},
..
} = *event
{
*self.keystates.entry(keycode).or_insert(state) = state;
}
self.mouse.handle_event(event, window_center);
}
}
#[derive(Default)]
pub struct Mouse {
pub is_left_clicked: bool,
pub is_right_clicked: bool,
pub position: glm::Vec2,
pub position_delta: glm::Vec2,
pub offset_from_center: glm::Vec2,
pub wheel_delta: glm::Vec2,
pub moved: bool,
pub scrolled: bool,
}
impl Mouse {
pub fn handle_event<T>(&mut self, event: &Event<T>, window_center: glm::Vec2) {
match event {
Event::NewEvents { .. } => self.new_events(),
Event::WindowEvent { event, .. } => match *event {
WindowEvent::MouseInput { button, state, .. } => self.mouse_input(button, state),
WindowEvent::CursorMoved { position, .. } => {
self.cursor_moved(position, window_center)
}
WindowEvent::MouseWheel {
delta: MouseScrollDelta::LineDelta(h_lines, v_lines),
..
} => self.mouse_wheel(h_lines, v_lines),
_ => {}
},
_ => {}
}
}
fn new_events(&mut self) {
if !self.scrolled {
self.wheel_delta = glm::vec2(0.0, 0.0);
}
self.scrolled = false;
if !self.moved {
self.position_delta = glm::vec2(0.0, 0.0);
}
self.moved = false;
}
fn cursor_moved(&mut self, position: PhysicalPosition<f64>, window_center: glm::Vec2) {
let last_position = self.position;
let current_position = glm::vec2(position.x as _, position.y as _);
self.position = current_position;
self.position_delta = current_position - last_position;
self.offset_from_center = window_center - glm::vec2(position.x as _, position.y as _);
self.moved = true;
}
fn mouse_wheel(&mut self, h_lines: f32, v_lines: f32) {
self.wheel_delta = glm::vec2(h_lines, v_lines);
self.scrolled = true;
}
fn mouse_input(&mut self, button: MouseButton, state: ElementState) {
let clicked = state == ElementState::Pressed;
match button {
MouseButton::Left => self.is_left_clicked = clicked,
MouseButton::Right => self.is_right_clicked = clicked,
_ => {}
}
}
}
This lets us easily track keystates and mouse information!
Adding a System Resource
Let's add a file at crates/phantom_app/src/resources/system.rs
.
use phantom_dependencies::{
glm,
winit::{
dpi::PhysicalSize,
event::{Event, WindowEvent},
},
};
use std::{cmp, time::Instant};
pub struct System {
pub window_dimensions: [u32; 2],
pub delta_time: f64,
pub last_frame: Instant,
pub exit_requested: bool,
}
impl System {
pub fn new(window_dimensions: [u32; 2]) -> Self {
Self {
last_frame: Instant::now(),
window_dimensions,
delta_time: 0.01,
exit_requested: false,
}
}
pub fn aspect_ratio(&self) -> f32 {
let width = self.window_dimensions[0];
let height = cmp::max(self.window_dimensions[1], 0);
width as f32 / height as f32
}
pub fn window_center(&self) -> glm::Vec2 {
glm::vec2(
self.window_dimensions[0] as f32 / 2.0,
self.window_dimensions[1] as f32 / 2.0,
)
}
pub fn handle_event<T>(&mut self, event: &Event<T>) {
match event {
Event::NewEvents { .. } => {
self.delta_time = (Instant::now().duration_since(self.last_frame).as_micros()
as f64)
/ 1_000_000_f64;
self.last_frame = Instant::now();
}
Event::WindowEvent { event, .. } => match *event {
WindowEvent::CloseRequested => self.exit_requested = true,
WindowEvent::Resized(PhysicalSize { width, height }) => {
self.window_dimensions = [width, height];
}
_ => {}
},
_ => {}
}
}
}
Now, we won't have to pollute our main game loop just to keep track of window dimensions and frame latency.
Adding Resources
Now, we can modify our crates/phantom_app/src/resources.rs
to include our new resources.
mod input;
mod system;
pub use self::{input::*, system::*};
use phantom_dependencies::winit::window::Window;
pub struct Resources<'a> {
pub window: &'a mut Window,
pub input: &'a mut Input,
pub system: &'a mut System,
}
...
Instantiating Resources
Finally, we can instantiate our resources in our crates/phantom_app/src/app.rs
.
use crate::{Input, Resources, State, StateMachine, System};
pub fn run(...) {
...
let physical_size = window.inner_size();
let window_dimensions = [physical_size.width, physical_size.height];
let mut input = Input::default();
let mut system = System::new(window_dimensions);
...
event_loop.run(move |event, _, control_flow| {
let resources = Resources {
...
input: &mut input,
system: &mut system,
};
...
})
}
...
Gamepads
To handle gamepads, we will use the gilrs library. This library abstracts platform specific APIs to provide unified interfaces for working with gamepads and supports a wide variety of controllers.
To integrate this library, let's first add gilrs
as a dependency.
cargo add gilrs -p phantom_dependencies
Then, export it in crates/phantom_dependencies/src/lib.rs
:
pub use gilrs as glm;
Let's extend this gamepad support to our game states by adding it to our Resources
bundle in crates/phantom_app/src/resources.rs
.
...
use phantom_dependencies::{
gilrs::Gilrs,
...
};
pub struct Resources<'a> {
...
pub gilrs: &'a mut Gilrs,
}
Next, we'll have to add an instance of Gilrs
to the Resources
that we declare in the application boilerplate in crates/phantom_app/src/app.rs
.
...
use phantom_dependencies::{
anyhow::{self, anyhow},
env_logger,
gilrs::Gilrs,
...
}
#[derive(Error, Debug)]
pub enum ApplicationError {
...
#[error("Failed to initialize the gamepad input library!")]
InitializeGamepadLibrary(#[source] gilrs::Error),
...
}
...
pub fn run(...) {
...
let mut gilrs = Gilrs::new().map_err(ApplicationError::InitializeGamepadLibrary)?;
...
event_loop.run(move |event, _, control_flow| {
let resources = Resources {
...
gilrs: &mut gilrs,
};
...
})
}
pub fn run_loop(...) {
...
if let Some(event) = resources.gilrs.next_event() {
state_machine
.on_gamepad_event(&mut resources, event)
.map_err(ApplicationError::HandleEvent)?;
}
...
}
...
At this point, you'll notice that we haven't implemented anything in our state machine to handle gamepad events. Let's add a method to our State
trait in crates/phantom_app/src/state.rs
!
...
use phantom_dependencies::{
...
gilrs::Event as GilrsEvent,
};
...
trait State {
...
fn on_gamepad_event(
&mut self,
_resources: &mut Resources,
_event: GilrsEvent,
) -> Result<Transition> {
Ok(Transition::None)
}
}
With this declared, we can now command our state machine to forward gamepad events to the game states.
Add the following method to our StateMachine
in crates/phantom_app/src/state.rs
.
pub fn on_gamepad_event(&mut self, resources: &mut Resources, event: GilrsEvent) -> StateResult<()> {
if !self.running {
return Ok(());
}
let transition = self
.active_state_mut()?
.on_gamepad_event(resources, event)?;
self.transition(transition, resources)
}
Let's make use of this new event handler by adding the following code to our editor at apps/editor/src/main.rs
.
use phantom::{
...
dependencies::{
anyhow::{Context, Result},
gilrs::Event as GilrsEvent,
...
},
};
...
impl State for Editor {
...
fn on_gamepad_event(
&mut self,
_resources: &mut Resources,
event: GilrsEvent,
) -> Result<Transition> {
let GilrsEvent { id, time, event } = event;
log::trace!("{:?} New gamepad event from {}: {:?}", time, id, event);
Ok(Transition::None)
}
}
Now, with a controller hooked up you'll be able to interact with your application!
Rendering
For rendering, we'll use wgpu, which is a safe and portable GPU abstraction in rust that implements the WebGPU API. Without this library, we would have write our own GPU abstraction and create separate backends for that abstraction. Supporting as many different platforms as possible for the sake of this engine provides flexibility. We could render on Android
with Vulkan, Windows
with DirectX, IOS
and MacOS
with Metal, or maybe even the web using webgl (and eventually webgpu!).
Creating the Renderer
Let's begin by adding the dependencies we need for rendering.
cargo add wgpu@0.13.1 -p phantom_dependencies
cargo add raw_window_handle@0.4.3 -p phantom_dependencies
Then, export these dependencies in crates/phantom_dependencies/src/lib.rs
:
pub use raw_window_handle;
pub use wgpu;
Creating the Render Module
First, we'll modify crates/phantom_render/src/lib.rs
to
specify a renderer
module.
mod renderer;
pub use self::renderer::*;
Then in our new renderer
module, we can begin creating the actual renderer.
TODO: Break this up into sections and explain each section
// crates/phantom_render/src/renderer.rs
use phantom_dependencies::{
log, pollster,
raw_window_handle::HasRawWindowHandle,
thiserror::Error,
wgpu::{
self, Device, Queue, RequestDeviceError, Surface, SurfaceConfiguration, SurfaceError,
TextureViewDescriptor,
},
};
use std::cmp::max;
#[derive(Error, Debug)]
pub enum RendererError {
#[error("Failed to get the current surface texture!")]
GetSurfaceTexture(#[source] SurfaceError),
#[error("No suitable GPU adapters found on the system!")]
NoSuitableGpuAdapters,
#[error("Failed to find a support swapchain format!")]
NoSupportedSwapchainFormat,
#[error("Failed to request a device!")]
RequestDevice(#[source] RequestDeviceError),
}
type Result<T, E = RendererError> = std::result::Result<T, E>;
#[derive(Default, Copy, Clone)]
pub struct Viewport {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl Viewport {
pub fn aspect_ratio(&self) -> f32 {
self.width as f32 / max(self.height, 0) as f32
}
}
pub struct Renderer {
pub surface: Surface,
pub device: Device,
pub queue: Queue,
pub config: SurfaceConfiguration,
}
impl Renderer {
pub fn new(window_handle: &impl HasRawWindowHandle, viewport: &Viewport) -> Result<Self> {
pollster::block_on(Renderer::new_async(window_handle, viewport))
}
pub fn update(&mut self) -> Result<()> {
Ok(())
}
pub fn resize(&mut self, dimensions: [u32; 2]) {
log::info!(
"Resizing renderer surface to: ({}, {})",
dimensions[0],
dimensions[1]
);
if dimensions[0] == 0 || dimensions[1] == 0 {
return;
}
self.config.width = dimensions[0];
self.config.height = dimensions[1];
self.surface.configure(&self.device, &self.config);
}
pub fn render_frame(&mut self) -> Result<()> {
let surface_texture = self
.surface
.get_current_texture()
.map_err(RendererError::GetSurfaceTexture)?;
let view = surface_texture
.texture
.create_view(&TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Render Encoder"),
});
encoder.insert_debug_marker("Render scene");
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.1,
g: 0.2,
b: 0.3,
a: 1.0,
}),
store: true,
},
})],
depth_stencil_attachment: None,
});
self.queue.submit(std::iter::once(encoder.finish()));
surface_texture.present();
Ok(())
}
pub fn aspect_ratio(&self) -> f32 {
self.config.width as f32 / std::cmp::max(1, self.config.height) as f32
}
async fn new_async(
window_handle: &impl HasRawWindowHandle,
viewport: &Viewport,
) -> Result<Self> {
let instance = wgpu::Instance::new(Self::backends());
let surface = unsafe { instance.create_surface(window_handle) };
let adapter = Self::create_adapter(&instance, &surface).await?;
let (device, queue) = Self::request_device(&adapter).await?;
let swapchain_format = *surface
.get_supported_formats(&adapter)
.first()
.ok_or(RendererError::NoSupportedSwapchainFormat)?;
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: swapchain_format,
width: viewport.width,
height: viewport.height,
present_mode: wgpu::PresentMode::Fifo,
};
surface.configure(&device, &config);
Ok(Self {
surface,
device,
queue,
config,
})
}
fn backends() -> wgpu::Backends {
wgpu::util::backend_bits_from_env().unwrap_or_else(wgpu::Backends::all)
}
fn required_limits(adapter: &wgpu::Adapter) -> wgpu::Limits {
wgpu::Limits::default()
// Use the texture resolution limits from the adapter
// to support images the size of the surface
.using_resolution(adapter.limits())
}
fn required_features() -> wgpu::Features {
wgpu::Features::empty()
}
fn optional_features() -> wgpu::Features {
wgpu::Features::empty()
}
async fn create_adapter(
instance: &wgpu::Instance,
surface: &wgpu::Surface,
) -> Result<wgpu::Adapter> {
wgpu::util::initialize_adapter_from_env_or_default(
instance,
Self::backends(),
Some(surface),
)
.await
.ok_or(RendererError::NoSuitableGpuAdapters)
}
async fn request_device(adapter: &wgpu::Adapter) -> Result<(wgpu::Device, wgpu::Queue)> {
log::info!("WGPU Adapter Features: {:#?}", adapter.features());
adapter
.request_device(
&wgpu::DeviceDescriptor {
features: (Self::optional_features() & adapter.features())
| Self::required_features(),
limits: Self::required_limits(adapter),
label: Some("Render Device"),
},
None,
)
.await
.map_err(RendererError::RequestDevice)
}
}
Using the Renderer
Creating a GUI
Appendix
A - Further Reading
-
This is a website about graphics programming that focuses on the modern OpenGL API specifically. However, the graphics programming techniques are all applicable to wgpu.