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-1

Chapter 2

  • Create a renderer
  • Setup a GUI
  • Render a basic scene

chapter-2

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.

phantom-icon

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.
  • 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.
  • 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

  • Learn OpenGL

    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.

B - Keywords