Thursday, March 10, 2022

Bevy Game Engine Tutorial: Introduction and First Sprite

Introduction

Bevy is a game engine written in the Rust programming language with a strong ergonomic entity component system model.  Bevy focuses on fast iteration times and being a massively parallel, easy to use engine.  Currently there is not a graphical editor for Bevy but that is in their future plans. This tutorial will target version 0.6 of the engine which is the current version as of March 2022.

This text tutorial is made to complement a video tutorial at LogicProject's Youtube Channel


Project Creation

A Bevy project is started like any other Rust project by running:

cargo new bevy_tutorial

Next we will set up the Cargo.toml file to optimize the engine code as much as possible even when debugging.  This will give us fast debug builds to test but will cause longer compile times when building the engine.  Thankfully Bevy is designed so that we rarely will have to rebuild the entire engine so this is acceptable.  Also we will link dynamically to the engine which will help even more with fast compile times.

[package]
name = "bevy_tutorial"
version = "0.1.0"
edition = "2021"

[profile.dev]
opt-level = 1

[profile.dev.package."*"]
opt-level = 3

[dependencies]
bevy = { version = "0.6", features = ["dynamic"] }

First Window

Now we are ready to create a simple window using Bevy.  Bevy is centered around an App data structure which we create and customize with the builder pattern.  In main we will construct an App and add the DefaultPlugins to it.  The DefaultPlugins set up asset management, rendering, input, diagnostics, and many other things that almost every game will need.  Finally we call run() which will start the game loop and give control to Bevy until the game is closed.

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .run();
}

Now we can customize this window by adding resources to the App.  A resource in bevy is any Rust data structure that we only need one copy of for our game.  The simplest included resource is ClearColor which is used by the DefaultPlugins to set the background of our new blank window.  Adding a resource is as simple as calling insert_resource with an instance of the data structure we want to add. 

use bevy::prelude::*;

pub const CLEAR: Color = Color::rgb(0.1, 0.1, 0.1);

fn main() {
    App::new()
        .insert_resource(ClearColor(CLEAR))
        .add_plugins(DefaultPlugins)
        .run();
}

We can also add a WindowDescriptor resource to the App which will setup things such as the width/height, title, and vsync settings.

use bevy::prelude::*;

pub const CLEAR: Color = Color::rgb(0.1, 0.1, 0.1);
pub const RESOLUTION: f32 = 16.0 / 9.0;

fn main() {
    let height = 900.0;
    App::new()
        .insert_resource(ClearColor(CLEAR))
        .insert_resource(WindowDescriptor {
            width: height * RESOLUTION,
            height: height,
            title: "Bevy Tutorial".to_string(),
            vsync: true,
            resizable: false,
            ..Default::default()
        })
        .add_plugins(DefaultPlugins)
        .run();
}

Our First System

Now we are ready to start writing some real code for our game.  The first thing we need is a system to spawn a camera.  Systems are a major part of the ECS design mode and thankfully Bevy has made it incredibly easy to create a system.  A system is just a standard Rust function with some limited options for parameters.  Here we need Commands which will allow us to spawn an entity with all the components we need for it to behave like a camera.  Specifically the command we want is spawn_bundle because in Bevy, bundles are groups of components for easy use and we will be using the OrthographicCameraBundle. Cameras are used by rendering systems in the DefaultPlugins so we need one for anything to appear on screen. After we create the function we add it as a startup system to the app so it runs once right when the game starts.

fn main() {
    let height = 900.0;
    App::new()
        // Cut
        .add_startup_system(spawn_camera)
        .run();
}

fn spawn_camera(mut commands: Commands) {
    let mut camera = OrthographicCameraBundle::new_2d();

    camera.orthographic_projection.top = 1.0;
    camera.orthographic_projection.bottom = -1.0;

    camera.orthographic_projection.right = 1.0 * RESOLUTION;
    camera.orthographic_projection.left = -1.0 * RESOLUTION;

    camera.orthographic_projection.scaling_mode = ScalingMode::None;

    commands.spawn_bundle(camera);
}

One thing I did was setup the camera top, bottom, left and right coordinates by hand and set the scaling mode to none.  This is a personal preference that makes it easier to rationalize about where things are on the screen for me.  The default uses x and y values that depend on window size which is more difficult for me to manage.

Asset Management

Bevy handles all game assets for us with their AssetServer and Assets structures. The AssetServer handles loading files and gives us a Handle to use the data when the file is loaded.  It is important to note files are loaded in the background and may not be available for the first few frames after loading.  You can query the AssetServer to see when the data is truly loaded.  The Assets structures are generic maps over the different types of assets and let you go between a handle to an asset and the actual asset data.  

For our game we want to use a sprite-sheet (Specifically an ASCII Code page available from the assets on the github link for this project) which in Bevy would be called a TextureAtlas.  First we need the asset in a folder named "assets" for the AssetServer to find our file.  Then we just need to load the image giving us a handle to an image then we create a TextureAtlas which will reference that image.  Finally we will keep that handle in a custom resource so we can easily access it in other systems to create sprites.

The sprite-sheet I use for test graphics


struct AsciiSheet(Handle<TextureAtlas>);

fn load_ascii(
    mut commands: Commands,
    assets: Res<AssetServer>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    let image = assets.load("Ascii.png");
    let atlas = TextureAtlas::from_grid_with_padding(
        image, 
        Vec2::splat(9.0), 
        16, 
        16, 
        Vec2::splat(2.0));

    let atlas_handle = texture_atlases.add(atlas);

    commands.insert_resource(AsciiSheet(atlas_handle));
}

One thing to note is I chose to us an image with padding to prevent bleeding of pixels from one sprite to the next.  This is a common problem and padding the sprites is the easiest fix I have found.

Example of pixels bleeding between sprites

Finally, we need to add this as a startup system to the app.  A critical thing here is this system needs to run before any other systems that will be expecting the AsciiSheet resource.  Here I achieve this by adding the system to the PreStartupStage in Bevy which will make this run before the rest of startup functions.  There are many different ways to handle system ordering in Bevy.

fn main() {
    let height = 900.0;
    App::new()
        // Cut
        .add_startup_system(spawn_camera)
        .add_startup_system_to_stage(StartupStage::PreStartup, load_ascii)
        .add_plugins(DefaultPlugins)
        .run();
}

First Sprite

Now we are finally ready to draw a sprite on screen.  All we need is another system which will spawn our player.  We are going to make the player a TextureAtlasSprite as we want to use the atlas we just loaded and the index is going to be 1 which is the smiley face on the sheet.  This time the bundle we want to spawn is called SpriteSheetBundle and we will give it the sprite we just created and the handle to the atlas from the resource.  We set the translation z value of our new sprite to 900 to render it on top of most things in the game as visible z values range from 0 to 1000 with higher values on top by default. We also add a Name component for easier debugging in the future.

fn spawn_player(mut commands: Commands, ascii: Res<AsciiSheet>) {
    let mut sprite = TextureAtlasSprite::new(1);
    sprite.color = Color::rgb(0.3, 0.3, 0.9);
    sprite.custom_size = Some(Vec2::splat(1.0));

    commands
        .spawn_bundle(SpriteSheetBundle {
            sprite: sprite,
            texture_atlas: ascii.0.clone(),
            transform: Transform {
                translation: Vec3::new(0.0, 0.0, 900.0),
                ..Default::default()
            },
            ..Default::default()
        })
        .insert(Name::new("Player"));


We now have a sprite drawn on the screen and we could call it a day.  But as one extra push I want to add a background to the face as it is transparent and will look weird wandering around with the grass showing through.

To do this we create another sprite in the same way.  We then use id() at the end of commands to force commands to return the entity we just created.  Next we need to add the background as a child of the player using Bevy's hierarchy functionality.  Because the hierarchy will handle the transforms we set the z of the background to be -1 so it appears behind the player. 

fn spawn_player(mut commands: Commands, ascii: Res<AsciiSheet>) {
    let mut sprite = TextureAtlasSprite::new(1);
    sprite.color = Color::rgb(0.3, 0.3, 0.9);
    sprite.custom_size = Some(Vec2::splat(1.0));

    let player = commands
        .spawn_bundle(SpriteSheetBundle {
            sprite: sprite,
            texture_atlas: ascii.0.clone(),
            transform: Transform {
                translation: Vec3::new(0.0, 0.0, 900.0),
                ..Default::default()
            },
            ..Default::default()
        })
        .insert(Name::new("Player"))
        .id();

    let mut background_sprite = TextureAtlasSprite::new(0);
    background_sprite.color = Color::rgb(0.5, 0.5, 0.5);
    background_sprite.custom_size = Some(Vec2::splat(1.0));

    let background = commands
        .spawn_bundle(SpriteSheetBundle {
            sprite: background_sprite,
            texture_atlas: ascii.0.clone(),
            transform: Transform {
                translation: Vec3::new(0.0, 0.0, -1.0),
                ..Default::default()
            },
            ..Default::default()
        })
        .insert(Name::new("Background"))
        .id(); //id() gives back the entity after creation

    commands.entity(player).push_children(&[background]);
}

Don't forget to add spawn_player as a startup system or cargo will give you a dead_code warning!



Conclusion

In this tutorial, we covered the basics of creating a window and loading/displaying a sprite in Bevy.  I hope this series will be helpful to people trying to learn the engine and feel free to leave any comments or suggestions. Thank you for reading!



Bevy Game Engine Tutorial: Introduction and First Sprite

Introduction Bevy is a game engine written in the Rust programming language with a strong ergonomic entity component system model.  Bevy foc...