AGuideToRustGameFrameworks2019
Another guide giving an overview of a segment of the Rust ecosystem. Goodness, is this becoming a habit?
This information is up to date as of: June 1st, 2019.
Introduction
Anyway, so here’s the story. ggez
is, naturally, The Best Rust Game Framework, ’cause I maintain it. But there’s several other game framework crates that have very similar goals to ggez
: Make it easy to make a nice 2D game with minimum friction. However, they have different approaches and make different tradeoffs. Because ggez
was the first Rust project to try to fill this niche, other projects often took a bit of inspiration from ggez
’s API to a greater or lesser degree, at least to begin with, but none of them are direct forks. tetra
was made because the author wanted to make it. quicksilver
was made because the author wanted to be able to target the web directly instead of ggez
’s approach of “let dependencies do the heavy lifting”. coffee
was made because the creator wanted to play with the wgpu
graphics library. And so on. I’ve mostly just left these other libraries to do their own thing and never dug into any of them in much depth, but when I recently found myself looking at quicksilver
for some other reason I realized that the only time I’d looked at it was right after it was first announced, and it had diverged quite from ggez
’s model in the mean time. So I’m writing this both to attempt to see what’s different, and to catch up on what’s new.
To compare all these various game libraries, we’re going to use a case study: we will take the ggez
Astroblasto example game, and port it to each game framework. Astroblasto is a very simple but complete Asteroids-like game: there’s levels, a score, and a lose condition. It uses asset loading, sprite drawing, audio, input, timing and text rendering. These are the fundamental pieces of ggez
. ggez
has other features like mesh rendering, shaders, render targets and so on, but I basically want to make SNES-style games most of the time, and so drawing, audio, input, timing and text are the necessary features I cannot do without.
I’m going to look at these basically in order of how different they are (or seem to be) from ggez. I am going to include highlights of the source code focusing on the interesting bits, with links to the entire project on Sourcehut (partially so I can play with Sourcehut).
Because information on the internet rots quickly, here are the versions of everything we are going to use:
- ggez: 0.5.0-rc.2 (I’ll turn it into an actual release someday, I promise!)
- tetra: 0.2.18
- quicksilver: 0.3.12
- coffee: 0.2.0
- piston: 0.43.0
- amethyst: 0.10.0
All code produced in this tutorial is MIT licensed. Also the creators of most of these libraries seem to hang out on the unofficial Rust Discord server, including myself, so drop by and say hi if you have any questions. I wanted to have an unbiased and naive view of these libraries so I didn’t ask any of them for help, though. Thus any mistakes are mine alone, and any awesome things are entirely theirs.
Also I’m going to be writing all of this as I am porting the code, so it’s probably going to be long, rambly, and a little disjointed. This is also going to be an opinion piece, so recognize these are opinions, not objective truth. This is definitely not a Proper Technical Essay, though I’ll try to at least have some sort of introduction and conclusion to each part. And I will assume that you know at least a little bit about Rust and game development, or are willing to learn on the fly (LD42Postmortem and AGuideToRustGraphicsLibraries2019 might be interesting to you, if so).
So now that that’s clear, let’s fire up the stream-of-conciousness cannon and dive in!
ggez
First off, the difference between a lot of these libraries lies in what other libraries they use, so for each one we’re going to see what the major dependencies are. All of these are easy to find on http://crates.io/. For ggez
0.5, that list looks like this:
- Graphics:
gfx-rs
(pre-HAL) - Windowing and input:
winit
andgilrs
- Audio:
rodio
- Math:
nalgebra
internally, though all public API’s usemint
- Other interesting stuff:
lyon
for mesh generation,log
for logging,image
for image loading,glyph-brush
for text rendering
I don’t need to find out how ggez
works, I already know. So let’s just introduce Astroblasto and look over the basic structure of it. Astroblasto is an Asteroids-like game I threw together to be an example of a simple but complete example game for ggez
, ’cause I’d already written a Space Invaders clone and Pac-Man or Tetris sounded like too much work. It’s actually even simpler than Asteroids because the rocks don’t split when you shoot them. It’s about 600 lines in a single file, and is written to be read top to bottom, with comments explaining what is going on and why. So, Astroblasto is not supposed to be the fanciest or most efficient setup, just a good one that’s easy to understand and doesn’t need a bunch of external libraries. The full code considered in this article is here, and whatever version is most up to date for ggez is available in its examples folder.
So instead of going through the whole thing in detail, let’s give the highlights of how the non-ggez
parts of the code work, then talk a little about the ggez
-specific parts before moving on to porting it to other libraries.
We only have three types of things in the world: the player, shots, and rocks. (Well, and the score and level text, but those don’t interact with any other object, so they’re not really “in the world”.) These things all follow the same physics and almost the same rules, with a few special cases for each. They all move according to newtonian rules (there’s no drag) and wrap around the edges of the screen. The player is controlled with the arrow keys and spacebar, and can turn and thrust and shoot things. Shots destroy rocks that they hit and die after a certain time. Rocks destroy the player if they hit it. When all the rocks are gone it adds more, and the number of rocks increases every time this happens. That’s about it. So for this I made essentially make a very ghetto entity-component system, where all entities have the same fields and we only have a few different systems. I call our shared entity object the Actor
, and it is just a tagged struct. It may be more proper to make it an enum or whatever and organize things a bit differently, but for a game this small there’s really no advantages of one variation over another. So, it looks like this:
#[derive(Debug)]
enum ActorType {
Player,
Rock,
Shot,
}
#[derive(Debug)]
struct Actor {
tag: ActorType,
pos: Point2,
facing: f32,
velocity: Vector2,
ang_vel: f32,
bbox_size: f32,
life: f32,
}
To make different actors, we just have functions that give them different tags and initial properties, like so:
fn create_player() -> Actor {
Actor {
tag: ActorType::Player,
pos: Point2::origin(),
facing: 0.,
velocity: na::zero(),
ang_vel: 0.,
bbox_size: PLAYER_BBOX,
life: PLAYER_LIFE,
}
}
We have a function that does basic movement physics to any actor:
fn update_actor_position(actor: &mut Actor, dt: f32) {
let norm_sq = actor.velocity.norm_squared();
if norm_sq > MAX_PHYSICS_VEL.powi(2) {
actor.velocity = actor.velocity / norm_sq.sqrt() * MAX_PHYSICS_VEL;
}
let dv = actor.velocity * (dt);
actor.pos += dv;
actor.facing += actor.ang_vel;
}
And a few utility functions I won’t show to let the player move around, resolve collisions, spawn new rocks when they all are destroyed while making sure that none of them appear on top of/right next to the player, etc. None of these are particularly surprising. If you’re interested, check out the source code.
Next, asset handling! One of the important but tedious things about any game. For this, we just load everything we need into a struct when the game starts and have it sitting there until the game ends:
struct Assets {
player_image: graphics::Image,
shot_image: graphics::Image,
rock_image: graphics::Image,
font: graphics::Font,
shot_sound: audio::Source,
hit_sound: audio::Source,
}
On to the next tedious part of any game, events! Sometimes you want input to be edge-triggered, that is, if you hold a button down you get a single event saying “this button was pushed”. Sometimes you want it to be level-triggered, so if you hold a button down you get events saying “this button is down” for as long as the button is held down. These are complementary models, and which is preferable depends entirely on what you’re trying to do with it. You can write code to turn one model into the other and back, which is never hard but always just more work than it should be. ggez
tries to make life simple by just having all events be edge-triggered, since I find it easier to be consistent if you only have to deal with one sort of event, and I find edge-triggered easier to turn into level-triggered events than going the other way around. It’s not really hard and fast though, ggez
0.5 will probably have the option to give you both. Anyway, for this game, we want level-triggered input for the player – the game doesn’t care how often you mash the thrust or fire button, it just checks whether it’s currently pushed each frame. So we have a struct to store our current input state:
#[derive(Debug)]
struct InputState {
xaxis: f32,
yaxis: f32,
fire: bool,
}
So this gives us all we need for our game state! Hopefully it’s pretty self-explanatory.
struct MainState {
player: Actor,
shots: Vec<Actor>,
rocks: Vec<Actor>,
assets: Assets,
input: InputState,
level: i32,
score: i32,
screen_width: f32,
screen_height: f32,
player_shot_timeout: f32,
}
And then we can make our main loop! ggez
does let you just write a loop and poll for events by hand and such, but it also offers the EventHandler
trait which simplifies this a little by letting you provide a trait with callbacks and a run()
function that calls them for you. The callbacks are update()
, draw()
and an optional input event handler method for each event type, such as key_down_event()
. Let’s look at each part of Astroblasto’s EventHandler
implementation individually. Hopefully they’re pretty easy to follow:
fn update(&mut self, ctx: &mut Context) -> GameResult {
const DESIRED_FPS: u32 = 60;
let seconds = 1.0 / (DESIRED_FPS as f32);
while timer::check_update_time(ctx, DESIRED_FPS) {
// Update the player state based on the user input.
player_handle_input(&mut self.player, &self.input, seconds);
self.player_shot_timeout -= seconds;
if self.input.fire && self.player_shot_timeout < 0.0 {
self.fire_player_shot();
}
// Update the physics for all actors.
// First the player...
update_actor_position(&mut self.player, seconds);
wrap_actor_position(
&mut self.player,
self.screen_width as f32,
self.screen_height as f32,
);
// Then the shots...
for act in &mut self.shots {
update_actor_position(act, seconds);
wrap_actor_position(act, self.screen_width as f32, self.screen_height as f32);
handle_timed_life(act, seconds);
}
// And finally the rocks.
for act in &mut self.rocks {
update_actor_position(act, seconds);
wrap_actor_position(act, self.screen_width as f32, self.screen_height as f32);
}
// Deal with collisions and any consequences.
self.handle_collisions();
self.clear_dead_stuff();
self.check_for_level_respawn();
if self.player.life <= 0.0 {
println!("Game over!");
ggez::quit(ctx);
}
}
Ok(())
}
So, that was simple. Long, but simple. Drawing is similarly straightforward. We start with graphics::clear()
, we end with graphics::present()
, and mostly just call draw_actor()
in between.
fn draw(&mut self, ctx: &mut Context) -> GameResult {
graphics::clear(ctx, graphics::BLACK);
// Loop over all objects and draw them...
{
let assets = &mut self.assets;
let coords = (self.screen_width, self.screen_height);
let p = &self.player;
draw_actor(assets, ctx, p, coords)?;
for s in &self.shots {
draw_actor(assets, ctx, s, coords)?;
}
for r in &self.rocks {
draw_actor(assets, ctx, r, coords)?;
}
}
// And draw the GUI elements in the right places.
let level_dest = Point2::new(10.0, 10.0);
let score_dest = Point2::new(200.0, 10.0);
let level_str = format!("Level: {}", self.level);
let score_str = format!("Score: {}", self.score);
let level_display = graphics::Text::new((level_str, self.assets.font, 32.0));
let score_display = graphics::Text::new((score_str, self.assets.font, 32.0));
graphics::draw(ctx, &level_display, (level_dest, 0.0, graphics::WHITE))?;
graphics::draw(ctx, &score_display, (score_dest, 0.0, graphics::WHITE))?;
graphics::present(ctx)?;
timer::yield_now();
Ok(())
}
draw_actor()
looks like this:
fn draw_actor(
assets: &mut Assets,
ctx: &mut Context,
actor: &Actor,
world_coords: (f32, f32),
) -> GameResult {
let (screen_w, screen_h) = world_coords;
let pos = world_to_screen_coords(screen_w, screen_h, actor.pos);
let image = assets.actor_image(actor);
let drawparams = graphics::DrawParam::new()
.dest(pos)
.rotation(actor.facing as f32)
.offset(Point2::new(0.5, 0.5));
graphics::draw(ctx, image, drawparams)
}
Event handling just updates our InputState
struct based on events. Like I said, these functions are not called every frame, they are called whenever a button is pressed or released.
fn key_down_event(
&mut self,
ctx: &mut Context,
keycode: KeyCode,
_keymod: KeyMods,
_repeat: bool,
) {
match keycode {
KeyCode::Up => {
self.input.yaxis = 1.0;
}
KeyCode::Left => {
self.input.xaxis = -1.0;
}
KeyCode::Right => {
self.input.xaxis = 1.0;
}
KeyCode::Space => {
self.input.fire = true;
}
KeyCode::Escape => ggez::quit(ctx),
_ => (), // Do nothing
}
}
fn key_up_event(&mut self, _ctx: &mut Context, keycode: KeyCode, _keymod: KeyMods) {
match keycode {
KeyCode::Up => {
self.input.yaxis = 0.0;
}
KeyCode::Left | KeyCode::Right => {
self.input.xaxis = 0.0;
}
KeyCode::Space => {
self.input.fire = false;
}
_ => (), // Do nothing
}
}
}
And that’s basically it! There’s a little boilerplate for setup that’s standard to basically all the ggez
examples, and a variety of actual gameplay stuff like draw_actor()
and clear_dead_stuff()
but really those do absolutely zero stuff that’s surprising or ggez
-specific.
Observations on ggez’s style:
- The
EventHandler
trait is your usual entry point to the game. We’ve already discussed how that works. - Everything
ggez
offers is in the form of functions, not methods onContext
orImage
or such. That’s mainly a matter of taste. It’s also how LÖVE does it, whichggez
is based on, so I kept the same idiom. graphics::draw()
takesInto<DrawParam>
, andDrawParam
is a structure that defines all the options you have for how you can draw something: position, color, rotation, etc.DrawParam
then implements the builder pattern to set these values, and also hasFrom
impl’s for various tuple types such asFrom<(Point2, f32, Color)>
which is just a shortcut forDrawParam::default().dest(...).rotation(...).color(...)
. This is basically how you get named args, variable-length args and default args in Rust. Like many things in Rust, it’s a little bit verbose and a pain in the butt to write, and then once it’s written it works really well and basically never silently breaks.- ggez doesn’t try to handle any timing, FPS locking or anything for you. It provides
timer::check_update_time()
andtimer::yield_now()
for you to control how you want to do it. USUALLY the way you want to do it is what’s demonstrated in Astroblasto’s code:update()
callstimer::check_update_time()
in a loop calculating fixed timesteps until it’s caught up with how much time has passed since the last frame, and if vsync is enabled thengraphics::present()
will block while waiting for it anyway. This is a compromise between making things easy for the user, and letting the user not have to hack their way through anything if they want to make their own code to, for example, do lock-step network updates or whatever. - I didn’t show the code for it but we’ll mention it later:
ggez
has a filesystem abstraction layer. Inggez
you choose a set of directories and/or zip files, and it will give you something that looks like an isolated filesystem that contains only the contents of those directories. This is partially for convenience, and partially so that you can, in theory, use the same filesystem hierarchy on any OS or platform andggez
will be able to put the files in the right places for you. - Pretty much everything requires a
ggez::Context
: drawing, events, etc. This contains allggez
state: OS window and graphics handles, textures for the font cache, timing info, etc. Really it’s basically “graphics stuff” and “everything else”. I grew up on functional programming languages, so passing a “world” object around as an explicit function argument feels much more reasonable than the SDL2-esque solution of “lol just make everything global”. Passing a&mut Context
everywhere you need it is also kinda irritating, but much like mayonnaise, it’s good for you. You end up trying to avoid passing aContext
everywhere, which results in a program with very explicit separation of “game logic” and “stuff the game needs to do to interact with the outside world”. This keeps the order things are done in explicit and makes it easier to debug what is happening where. To quote Rob Pike: “Data dominates. If you’ve chosen the right data structures and organized things well, the algorithms will almost always be self-evident. Data structures, not algorithms, are central to programming.” (Of course, as any Lisper will point out, programs are data. But limiting the extent to which you treat programs like data makes it easier to do what Rust does, namely make programs that execute quickly and are easy for both humans and computers to understand at compile-time. It’s a trade-off.)
Anyway, that more or less wraps up our intro to ggez
in general and Astroblasto specifically. We have our baseline to compare against.
tetra
Now, on to tetra
! tetra
was started in December 2018 by 17cupsofcoffee because, as they said, “I want to see what it’s like to make something like ggez”. The readme warns that it’s early in development and unstable, but the current version is 0.2.18, so it’s gone through a lot of incremental evolution without breaking API compatibility very much. My impression is that it’s the closest to ggez
of the libraries we’re describing, though it lists XNA/Monogame as the main inspiration, which is a slightly higher-level framework than ggez
’s original inspiration of LÖVE. Just looking at it, the main departures from ggez
include animations/spritesheets, the fact that the framework controls your mainloop timing by default, and some other minor things here and there. It also lacks ggez
’s filesystem abstraction layer. To me the main technological difference appears to be that tetra
does draw batching by default, and so should get faster drawing of quads/sprites with less work. In ggez
you have to use a SpriteBatch
to get a similar speedup, while tetra
basically automatically uses a SpriteBatch
-like setup under the hood whenever possible. I’ve tried to implement this feature in ggez
but it would take significant rewriting of the graphics system. It’s on the books to do when I rewrite the graphics system to use gfx-hal
anyway, but who knows when that will happen.
Entertainingly though, looking at tetra
’s issue tracker, it asks many of the same fundamental design questions as ggez
has grappled with, or is still grappling with. Also a lot of the slightly-higher-level features it wants to add from XNA and such are things that I always intended to have solid implementations of in the ggez-goodies
crate, but alas… writing about game framework code is more fun than writing game framework code, it appears, so I’ve never really made that library production-quality.
tetra
’s docs are also very good, with a nice readme, FAQ and tutorial, and good examples that show off lots of things. The tutorial doesn’t go very deep, but is well written. Frankly, if all ggez
ever does is set the bar for the documentation quality a Rust game framework should have, my life has meaning. tetra
seems to be following well in its footsteps there.
Anyway! Technology stack of tetra
:
- Graphics: OpenGL using the unsafe
gl
crate, with its own abstraction layer. - Windowing and input:
sdl2
- Audio:
rodio
- Math:
nalgebra-glm
- Other interesting stuff:
image
for image loading,glyph-brush
for text rendering
All right, on to the show! tetra
seems pretty similar to ggez
, so I’m just going to copy-paste ggez
’s Astroblasto code into a new project, change the dependencies, and let the compiler tell me what to fix.
tetra
provides its own Vec2
type and such, which are re-exported from nalgebra-glm
. So essentially, if you use tetra
, you will probably be using nalgebra-glm
for most of your math. This is exactly the same as how ggez
0.4 works with nalgebra
, but it makes porting code from one framework to another a bit of a pain, especially since I don’t know nalgebra-glm
that well. ggez 0.5 uses mint
for all its public math types, which is basically just a portability layer. If you wanted to convert nalgebra
vector and matrix types to cgmath
or euclid
equivalents, instead of nalgebra
’s Vector2
type implementing From<cgmath::Vector2>
and From<euclid::Vector2D>
and so on for every single one of those libraries, each of those libraries just implements Into
and From
for mint
’s vector and matrix types. Then instead of fn draw(destination: nalgebra::Vector2)
you have fn draw(destination: impl Into<mint::Vector2>)
and you can just shove nalgebra::Vector2
into it, or cgmath::Vector2
, or whatever. All these libraries implement mint
bindings, and they all do it behind a feature flag so if you don’t use mint
then there’s no cost. It’s a sorta weird idea that I’ve only ever seen work in Rust… but work it does, and I’ve found using it to be very nice. Maybe tetra
0.3 will use mint
? Dunno, there’s definitely a bit of a tradeoff to make there in terms of complexity.
tetra
’s audio is arguably cleaner than ggez
’s. It has a Sound
type which is loaded audio data ready to play, and SoundInstance
which is explicitly a handle to a playing sound that lets you control what’s going on with that particular playback. These are roughly equivalent to ggez
’s SoundData
and Source
, kinda, but I find tetra
to have a much more explicit and cleaner separation that makes it more clear what’s going on. I like it. Both tetra
and ggez
use rodio
for sound, and rodio
does a lot of stuff behind the scenes and is kind of hard to figure out because it also doesn’t make this split very clear. ggez
inherits that ’cause I don’t know enough about what a good sound API should look like. Not to disparage, but I sorta think that rodio
’s creator tomaka doesn’t either, which is why rodio
looks the way it does… I keep meaning to do more sound stuff and learn how to Make It Work Well but it’s just not that interesting to me, I guess.
Exactly like ggez
, tetra
has a Drawable
trait that takes Into<DrawParams>
and things that are, well, drawable, implement that trait, such as Texture
and Canvas
. There’s an issue that questions this design and I kinda agree with what it says; it’s hard to say what a Drawable
trait should and should not have for different object types, and maybe the trait itself is just unnecessary. It would be interesting to see what ggez
or tetra
would look like without it.
tetra
controls the game’s mainloop, including timing, while ggez
is a little lower-level and forces the user to do a little more work. Again, there’s an issue discussing this, but it’s a tricky detail of API design tradeoffs to work with. It’s also essentially something you write once per game and never touch again, so I’m fine with tetra
’s solution as long as it works well (I seldom need to do fancy timing in my games), and I’m also fine with ggez
’s solution. The job of a game framework is to make the design decisions that are obvious and mostly irrelevant to your game, but also really important to get right, and timing is high on the list. Though it would also be nice if you could set the target FPS you want in tetra
’s ContextBuilder
– oh wait, you can. ♥
That said, the hook that tetra
offers you to interact with its mainloop, the State
trait, is pretty spartan:
pub trait State {
fn update(&mut self, ctx: &mut Context) -> Result { ... }
fn draw(&mut self, ctx: &mut Context, dt: f64) -> Result { ... }
}
Ah, here’s an actual architectural difference: tetra
doesn’t seem to have event callbacks, you just iterate over events manually with tetra::input::get_keys_down()
or such. I actually don’t like that as much; I talked about edge-based and level-based input before, and it annoys me when the distinction and interaction between the two is not clear. SDL2 is guilty of this too; tetra
uses SDL2 for input handling, and so probably inherits this tendancy from that. I dunno, it just feels like tetra
’s model will make it easy to miss things or have weird edge interactions. This isn’t something I have a great solution for myself though. If you stop worrying about these design issues; tetra
’s input handling is perfectly fine to actually use correctly, and for Astroblasto looks like this:
fn update(&mut self, ctx: &mut Context) -> tetra::Result {
// ...snip...
let mut quit = false;
for key in tetra::input::get_keys_pressed(ctx) {
match key {
Key::Up => {
self.input.yaxis = 1.0;
}
Key::Left => {
self.input.xaxis = -1.0;
}
Key::Right => {
self.input.xaxis = 1.0;
}
Key::Space => {
self.input.fire = true;
}
Key::Escape => {
quit = true;
}
_ => (), // Do nothing
}
}
for key in tetra::input::get_keys_released(ctx) {
match key {
Key::Up => {
self.input.yaxis = 0.0;
}
Key::Left | Key::Right => {
self.input.xaxis = 0.0;
}
Key::Space => {
self.input.fire = false;
}
_ => (), // Do nothing
}
}
...
}
One semantic difference, too. In both ggez
and tetra
, when you rotate an image/texture, it rotates from its origin – the top-left corner. In both, the DrawParam
has a parameter that lets you move that origin so you can, for example, rotate things around their center. In ggez
, you specify this rotation point in the range [0-1.0]
, so to rotate an image around its center you specify (0.5, 0.5)
. In tetra
you specify it in pixels, so for a 16x16 image you have to specify (8,8)
and for an 8x8 image you have to specify (4,4)
. Astroblasto has sprites of different sizes, so you have to check them and do the math in draw_actor()
instead of just telling it to rotate around (0.5, 0.5)
every time. This is precisely why ggez
uses a range of [0-1.0]
in the first place, so you don’t have to do that. It’s one of those tiny decisions that doesn’t actually MATTER, but changes how an API feels.
Hmm, now that I look at that screenshot, the drawing is notably glitchy on rotated quads. I’m not sure what’s up with that; is there a filtering mode or something somewhere that isn’t getting set properly? Something to dig into later, perhaps.
All in all though, apart from event handling, porting Astroblasto to tetra
is basically a straight copy and a bunch of very minor fiddling of names, function parameters, and so on. There’s probably some other edge cases somewhere, but I didn’t run into them. tetra
has some more advanced features like animations and spritesheet support, but Astroblasto doesn’t use those, so I have no idea how it works. Overall, tetra
is easy to use and well-documented. It’s a little more opinionated and has fewer moving parts, omits some of ggez
’s more sophisticated lower-level features, and adds some more sophisticated higher-level features. It looks like the main thing that ggez
has that tetra
doesn’t is mesh drawing and the filesystem layer.
quicksilver
Okay, this is exciting. Despite all my big talk, I’ve never actually used Webassembly for anything real, and ggez
cannot currently make games for the web. quicksilver
can. That’s its claim to fame in my eyes, and it seems to actually live up to it from what I’ve heard. So let’s see how well it works!
quicksilver
0.1 was released January 2018 and it’s currently on version 0.3. It is made by one ryanisaacg, and as well as all the basic pieces it appears to offer all the more powerful things that I don’t want to include with ggez
: collision detection, GUI, animation, etc. Its main dependencies look like this:
- Graphics: OpenGL with
gl
orwebgl_stdweb
- Windowing and input:
winit
andgilrs
- Audio:
rodio
- Math:
nalgebra
- Other interesting stuff:
lyon
for mesh generation,log
for logging,image
for image loading,rusttype
for text rendering (noglyph-brush
, so probably no text caching),ncollide2d
for collision,immi
for GUI,future
for… something, whew!
Documentation is okayish but not great. The readme is good and there’s a good assortment of example code, but the examples are mostly short and effectively uncommented. It seems to suffer from Programmer Documentation Disease from what I can tell, where all the individual bits have (kinda obvious) explanations but there’s no really good explanation of how things fit together. Especially given that there’s a module called quicksilver::lifecycle
that seems to be necessary to make a game run, it has stuff in it for events and main loops and async asset loading and who knows what else and has some futures involved with it somehow. A bit of background on what the heck is actually going on there would be pretty helpful.
So, let’s suck it and see.
Okay, first off, main loop handling appears to be much more like ggez
0.2 where you impl a trait on your game state object, that trait includes a new()
method, and you just have some object or function that magically creates your game state for you. This is fine and semi-obvious to the library creator but is more complicated than it needs to be for a user to understand, especially people who are new to Rust. It’s also less flexible because it limits what you can actually do in the process of creating your game state. quicksilver
seems to do stuff with futures and whatever, and running on web imposes some weird constraints on how you program event loops, but when ggez
did it this way I got lots of questions about it, so I’d say this model isn’t really the best for people who are new to Rust. The full trait you implement is called State
:
pub trait State: 'static {
fn new() -> Result<Self>
where
Self: Sized;
fn update(&mut self, _window: &mut Window) -> Result<()> { ... }
fn event(&mut self, _event: &Event, _window: &mut Window) -> Result<()> { ... }
fn draw(&mut self, window: &mut Window) -> Result<()> { ... }
fn handle_error(error: Error) { ... }
}
Hm, actually, I hadn’t noticed the handle_error()
method before. That’s nice to have. As the docs explain, on the web doing logging and stuff can be tricky, so errors pass through handle_error()
where you can try to do something useful to them before you panic or exit.
Oh wait, hang on, all the how-this-works docs are in the API docs for the quicksilver::tutorials
module. This is actually mentioned in the readme, upon inspection, but my eyes glazed right past it and I automatically went on to the example code in Github, which is not as good. Hmm.
Hmm, drawing is… weird. You have Window::draw(&mut self, draw: &impl Drawable, bkg: Background)
… a Drawable
is a shape like a rectangle or line, while Background
is a fill color, a texture, or a texture with a blend color. This feels… off, and not composable. Then there’s Window::draw_ex()
which adds arguments for a Transform
for rotation and scaling and whatever, and a z-value for layering. That at least makes sense, I suppose, but it feels weird. Probably just ’cause it’s not ggez. Grr, rar, it’s different, how dare! Where’s my DrawParam
!? Programmer smash!
The Drawable
/Background
division actually annoys me, ’cause… well, it soooort of matches how a GPU thinks about the world, you have your geometry and then you have a mesh bound to it or something. But it feels like a somewhat arbitrary and imprecise division. There’s nothing actually WRONG with it but I feel like there should be a better way. And it appears there’s no actual Mesh
drawable, but there is Circle
, Line
, Rectangle
, and Triangle
. What if I want to draw a Moebius strip? Oh, there is a quicksilver::graphics::Mesh
type, but it doesn’t actually implement Drawable
, so… how do you draw it? Mysteries abound.
Input handling is state-based via function calls, similar to tetra
, except you can’t get a list of key events to iterate through, you have to call window.keyboard()[Key::Right].is_down()
or whatever in your update()
method. Again, I’m biased but indexing it feels like unnecessary cuteness. You can’t iterate over the results of window.keyboard()
so why pretend it’s a collection? Can indexing it panic with an out-of-bounds error? Looks like it can’t, unless there’s a bug in quicksilver, but the fact that I felt obligated to check makes me uncomfortable.
Then you end up with input handling code in the tutorial that does this:
if window.keyboard()[Key::Right].is_down() ||
window.mouse()[MouseButton::Right].is_down() ||
window.gamepads().iter().any(|pad| pad[GamepadButton::TriggerRight].is_down())
{
self.position.x += 2.5;
}
Ick. Ugh. Why can’t it be something like this?
for event in window.events() {
match event {
| GamepadEvent(GamepadButtonState::Down(GamepadButton::TriggerRight), _gamepad)
| MouseEvent(MouseButtonState::Down(MouseButton::Right))
| KeyboardEvent(KeyState::Down(Key::Right) => {
self.position.x += 2.5;
}
_ => ()
}
}
Looks like you can do it more or less that way if you use the State::event()
callback, and the tutorial even talks about the distinction between edge-triggered and level-triggered events. But the tutorials generally actually use the ugly way, so people writing games are going to as well. Why is the ugly way bad? Basically, because it’s global state. First, if you have nested states (such as a menu screen with a game behind it) there’s no way for them to dispatch events to each other, decide which of them get to handle events at all, etc. It gets complicated. And second, you can have these state checks scattered throughout a function or several functions or wherever else you want, which is easy to figure out for small and simple programs but can easily start getting annoying if you have larger programs written over long times – you have to remember where events actually get handled because it’s not instantly obvious, and it could happen in several places. Event handling gets complicated when you have multiple things going on at once, so you want it all to be kept in one place.
Also it appears that quicksilver’s Window
type is basically what ggez
or tetra would call Context
, or the closest thing to it, so we’re going to be seeing it a lot.
Okay, enough racism against imperative programming! Back to business. The tutorials DO explain a little about what’s going on with this lifecycle
nonsense, and as expected, it’s basically the web’s fault. Asset loading can’t block, and so has to be async, so other things are forced to be async. I… really kinda hate that, but I seem to be in a grumpy mood anyway. But also, a game is already essentially a polling loop, so adding futures feels like it doesn’t get you much unless you’re actually using them for timing or such, like Unity’s coroutines. (Someone should make a fully futures-based game engine in Rust someday just to see how it looks.) And since quicksilver
doesn’t use tokio
or anything like that, I would bet that it implements its own polling loop as part of its run
function. But on web you are forced into this callback-y async model by the platform, and there’s not really any way to get around it, so maybe quicksilver
is just trying to make that model work as nicely as it can.
Darn it, I don’t know why this irritates me so much. Is it actually irritating, or is it just that it’s different from what I’m used to dealing with? Human brains suck.
Oh, this tutorial does explain how you draw a Mesh
though: window.mesh().extend(&mesh);
OF COURSE! Okay, THAT isn’t just things being different than what I expect, that’s leaky and inconsistent abstraction. It has nothing to do with the rest of quicksilver
’s drawing model, it’s just a hook into its underlying implementation that happens to be useful for this one single thing and doesn’t combine with anything else. If your drawing API is immediate-mode, don’t have the retained-mode innards leak through, especially when there’s no benefit to it (the window’s Mesh
gets cleared every frame so you have to keep adding to it anyway). It doesn’t even follow Rust’s naming convention properly; extend()
should take an iterator, not a single object.
Also there appears to be no way to attach a Transform
or z-order to a Mesh
? How is that supposed to work? Okay, the z
at least is part of the GpuTriangle
structure you build a Mesh
from. GpuTriangle
is otherwise just an index list, so z
is not actually a vertex property. Ugh. Augh. How in the name of Eris do your shaders work, quicksilver
? Time to read the source! It looks like its shaders don’t know about z
at all, so the meshes must get sorted back-to-front on the CPU sneakily behind the scenes somewhere. Yep, that happens in Window::flush()
. I wonder if it would be better to just keep the meshes all in a BTreeSet
so they always stay sorted? Depends on how you write your game’s drawing code, probably. Or you can just use a depth buffer, since that’s literally what they’re there for. But the fact that I have to ask these questions feels problematic.
Unfortunately though, shortly after basic asset loading the tutorials peter out a little. They’re there but in a more to-be-finished state. But I think we have enough to go on to start actually implementing things…
Error handling is a little squirrelly ’cause, for example, Image::load
doesn’t return a Result
, it returns impl Future<Item = Image, Error = QuicksilverError>
. Well, that’s just how that goes, I suppose; we won’t know whether an image actually works or such until we try to use it. However, the way it’s set up it currently looks like you always have to check whether it’s loaded, every time you try to use it; not sure if there’s a good way around that. More interesting is that there appears to be no Context
type or equivalent for graphics loading. ggez
needs a Context
even to just load an image, since it has to access the GPU to turn that image into a texture. I suppose like OpenGL itself, quicksilver
just says “okay that’s just in some hidden global somewhere”.
So because of this future-y stuff, the basic sprite-drawing code looks like this:
fn draw_actor(
assets: &mut Assets,
window: &mut Window,
actor: &Actor,
world_coords: (f32, f32),
) -> quicksilver::Result<()> {
let (screen_w, screen_h) = world_coords;
let pos = world_to_screen_coords(screen_w, screen_h, actor.pos);
// `image` is type `Asset<Image>`, `Asset` is basically a future executor that
// provides combinators for `Future<Item = Image, Error = QuicksilverError>`
let image = assets.actor_image(actor);
image.execute(|i| {
let transform = geom::Transform::rotate(actor.facing);
let target_rect = i.area().with_center((pos.x, pos.y));
window.draw_ex(
&target_rect,
Background::Img(&i),
transform,
0,
);
Ok(())
})
}
The future handling is… actually not awful, I think. Asset::execute()
returns a Result
, and so either the image loading has succeeded and it executes the given function returning Ok
, or it has failed and it returns Err
, or the future is not done yet and execute()
does nothing. There’s also an Asset::execute_or()
combinator that takes another function and executes that when asset loading is not done yet, so you could use that to draw a placeholder, print an error, etc. I could definitely imagine using this system to have assets pop in as needed, or just delay with a loading screen until they’re all there, or whatever.
Hmmmm, quicksilver
’s State
trait requires you to define a new()
method. But this method does not get passed reference to quicksilver
’s Window
. So it can’t do things like, say, set parameters based on the size of the screen. Not actually a huge problem for this game, but irritating. That sort of thing is one reason ggez
doesn’t define what a new()
method looks like for you though.
Gotta say that, despite my bitching about global state, if you are using the event()
callback then iterating over events is quite nice and arguably more convenient than ggez
’s key_down_event()
style of having different callbacks for different event types. On the other hand there appears to be no way to actually quit a game. Or if there is I can’t find it. Well, for now let’s just call std::process:exit()
. I’m sure THAT will do something sensible in a web browser!
There is no text cache, so rendering text happens in two steps: you take a font and render it to an image (which is slow), then draw the image (which is fast). A glyph cache renders characters or sets of characters on the fly as needed and saves the results to a texture, so then you can draw any text just by creating a mesh that draws from that texture as if it were a spritesheet. This is a tiny bit slower if your text never changes and is much, much faster if it does. The current best-in-class text cache crate is probably glyph-brush
, which is used by tetra
and by newer versions of ggez
(anything after 0.4.2 I think?) Astroblasto used to make sure it only re-rendered text when necessary because ggez
had no glyph cache, but now just draws stuff on the fly. I haven’t bothered re-doing the “save rendered text and only re-draw it if it changes” bit, so our text drawing is slow, and looks like this:
self.assets.font.execute(|f| {
let style = FontStyle::new(24.0, Color::WHITE);
let text = f.render(&level_str, &style)?;
window.draw(&text.area().with_center(level_dest), Background::Img(&text));
let text = f.render(&score_str, &style)?;
window.draw(&text.area().with_center(score_dest), Background::Img(&text));
Ok(())
})?;
(Note from the future: in practice, for Astroblasto this is Fast Enough.)
And, that’s about it! Despite my bitching about API details, pretty painless.
Now the real test… how well does it work online?
The first trial of getting stuff to work on a foreign platform is always heckin’ building it at all. quicksilver
’s docs claim that you can use cargo-web
and it will just handle everything for you. Let’s try it… A quick cargo install cargo-web
followed by cargo web build
, and, about five minutes later out pops a bouncing baby target/wasm32-unknown-unknown/debug/astro_quicksilver.wasm
. Huzzah! …now how to actually run it? Aha, cargo web deploy
will generate a HTML page and JS stub to actually load the thing. And we need to put all our assets in a folder called static/
. Right. Copy that all to a web server, load the webpage, and…
We get a blank screen.
Excellent! We can stop wondering when this house of cards will break, and move on to fixing it. Open the JS console and it helpfully says Error loading Rust wasm module 'astro_quicksilver': TypeError: "Response has unsupported MIME type"
. Open Firefox’s page inspector, go to the networking tab and we can see that astro_quicksilver.wasm
is indeed getting downloaded, but the web server is giving it content-type: application/octet-stream
, which is apparently not what Firefox wants. cargo-web
doesn’t seem to have any helpful advice for this, but I own the web server, so a quick edit to /etc/nginx/mime.types
to add application/wasm wasm;
, a quick config reload, and we get an empty black screen instead of an empty white one! The console shows some asset paths are messed up; that’s my bad, and easy to fix.
And it works!
…well. Sorta works. Looks like quicksilver
’s rotations are in degrees rather than radians, so that needs some fixing. (Boo, hiss, radians are sacred!) And text sizes need some fiddling to make them look nice. Aaaaaand it looks like when we die, std::process::exit()
on Webassembly panics with unreachable
, which isn’t really ideal. (Actually, since quicksilver
knows how to create our game state with a new()
method, it should be able to provide a quit()
method that just restarts the game for us on web.) And the rotations are ugly as heck for some reason; something weird is going on with filtering perhaps. That’s probably not quicksilver’s fault, or at least, there’s probably an option somewhere to play with. Those are all either easy fixes or minor inconveniences.
But it works! Woohoo! Pure Rust! No Emscripten necessary! Runs fast! Play it here! (~7 MB)
Actually, let’s see if we can make the binary smaller. Building in debug vs. release mode doesn’t seem to change the size much. Enter the Webassembly Binary Toolkit, wabt
, which is now actually available as a package on Debian 10. wabt
is the reference implementation of various tools for working with wasm, such as an assembler, disassembler, etc, so we can take apart our wasm file and see what’s taking up so much space. It also has wasm-strip
to strip symbols, which shaves about 1.5 MB off of Astroblasto; less than I expected. wasm-objdump -h astro_quicksilver.wasm
gives a breakdown of the executable’s segments, with sizes, which reveals the Code
segment is about 5.3 MB and defines 18,884 functions. Rummaging into it a little reveals that, as is kinda typical for Rust code, almost all of the functions are just endless generic specializations of various standard library types. So, probably not much to be gained there. HOWEVER, the original 7 MB does compress down to 1.7 MB on the wire, so it’s not actually as bad as it looks. …wait, isn’t there any way to tell rustc
to optimize for size? There is, it’s the opt-level = "z"
option! That gets us a mere 1.5 megabyte binary! MUCH better.
Oh, it also works on desktop:
And even on desktop it still has the same issue as on the web, where rotated quads look weird. It looks different from tetra
’s weird-rotated-quad-drawing, but occurs in a similar place, so I’m going to assume they’re related, and both just have some setting somewhere that needs to be poked at.
All in all? I feel like there’s still work to do in quicksilver
’s ergonomics. But most of it is just work that comes from using an API a bunch and obsessing over all the tiny details. It builds games for both Webassembly and desktop, basically transparently, and that’s damn impressive.
coffee
Okay, let’s look at coffee
next! coffee
ALSO is not in the least based on ggez
’s API, so let’s find out if my frustration with quicksilver
is semi-justified or whether I just hate anything different.
coffee
is made by hecrj, aka lone_scientist
, and is BRAND SPANKIN’ NEW. First published in April 2019. It is advertised as being massively incomplete, and it has a really pretty particle demo you should download and run. coffee
exists mainly ’cause the author wanted to play with wgpu
, which I’ve talked about elsewhere. coffee
’s dependency list is:
- Graphics:
wgpu
, or “gfx
pre-ll for OpenGL support, based heavily on the ggez codebase”. Oh man, I’m sorry. XD - Windowing and input:
winit
- Audio: uh, nothing, it appears. Guess they weren’t kidding about it being incomplete! :D
- Math:
nalgebra
- Other interesting stuff: Nothing else! That’s it! …Oh, I take it back, there’s
wgpu_glyph
, a separate crate wrappingglyph-brush
, also made by hecrj.
…hmm, beware though, the developer is doing the thing where they push their in-progress work directly to the master
branch on Github, and so what code you see in the examples and such drifts out of sync with the published docs. I do wish that wasn’t the default way of doing things…
Now, I’ve never actually looked at anything written in coffee
before, but like everything else so far it seems to have a trait defining what a gameloop looks like, so let’s take a loo–
pub trait Game {
type View;
type Input;
const TICKS_PER_SECOND: u16;
const DEBUG_KEY: Option<KeyCode>;
fn new(window: &mut Window) -> Result<(Self, Self::View, Self::Input)> where Self: Sized;
fn update(&mut self, view: &Self::View, window: &Window);
fn draw(&self, view: &mut Self::View, window: &mut Window, timer: &Timer);
fn on_input(&self, _input: &mut Self::Input, _event: Event) { ... }
fn on_close_request(&self, _input: &mut Self::Input) -> bool { ... }
fn interact(
&mut self,
_input: &mut Self::Input,
_view: &mut Self::View,
_gpu: &mut Gpu
) { ... }
fn debug(
&self,
_input: &Self::Input,
_view: &Self::View,
window: &mut Window,
debug: &mut Debug
) { ... }
fn run(window_settings: WindowSettings) -> Result<()> where Self: Sized,
{ ... }
}
…huh. Now that’s interesting. Notable features:
- debug key! Defaults to F12, can be overridden or set to
None
… if you press it, it flips a toggle that makes the main loop call yourdebug()
method afterdraw()
, it appears. That method gets passed theDebug
struct which contains tons of metrics about your game, and can be drawn instead of/along with your own debug view, or you can ignore it and collect your own metrics. I like this. on_close_request()
which returns abool
, giving you a single place to handle logic and determine whether or not the game should quit. Not sure this is really necessary, but it’s a nice touch. You’re going to need SOMETHING like this no matter what, to handle “do you want to save your game before quitting?” situations. I’m just unconvinced this is the best place for it.- It gives you
on_input()
to let you handle edge-triggered events, and passes it anInput
type you define so that you can turn those into the level-triggered events you care about as you wish – or map key bindings, or whatever. - Then there’s a
View
type, which it appears should be where you put all your graphics-related stuff. As the docs for theGame
trait say: “Coffee forces you to decouple your game state from your view and input state. While this might seem limiting at first, it helps you to keep mutability at bay and forces you to think about the architecture of your game.” - What’s this
interact()
method? It looks like that’s where input actually gets handled; notice thatupdate()
anddraw()
don’t get passed anInput
object. So the main loop goesframe start -> on_input(&self, &mut Input, &Events) -> interact(&mut self, &mut Input, &mut View) -> update(&mut self, &View) -> draw(&self, &mut View) -> debug(&self, &Input, &View, &Debug) -> frame end
. Note thatupdate()
’s access toView
is read-only, anddraw()
’s access toself
is read-only!
Veeeeeery interesting!
The interesting-ness carries on to the drawing stuff. So far everything we’ve looked at has presented a fairly implicit immediate-mode style of API. You call a function that says “draw this thing” or “clear the screen” or “switch this graphics setting” and it does it “immediately”. You can imagine sitting down at a REPL and typing these commands in one by one and having a screen update after each one to reflect the change. In ALL of these cases though, it’s a lie. A total, complete and bald-faced lie! Graphics cards do NOT work this way! GPU’s achieve absurd throughput by having absurd latency, and so to make a graphics card run fast at ALL you need to send it big queues of drawing commands in batches and then let them get handled asynchronously. As discussed previously (maybe not terribly clearly), this immediate mode vs. batched drawing distinction is the main philosophical difference between OpenGL and Vulkan. ggez
, tetra
and quicksilver
all make an immediate mode API by recording each “immediate” command you give it in a buffer behind the scenes, and one way or another sending that buffer to the GPU once per frame (ggez
when you call flush()
, tetra
and quicksilver
implicitly at the end of your draw()
callback).
coffee
, it appears, does not do immediate mode drawing, and instead makes this batching process and the data it depends on more explicit. Everything specifies a target that it is drawn on and all properties it is drawn with. You start with a Frame
, which is the final render target that your game will be shown on. This turns into a Target
, which is a slightly more abstract render target that could come from an off-screen Canvas
as well. There’s only a few things you can actually draw to a Target
: Image
, Canvas
, or Batch
(sprite batch). (I can’t find any way to create arbitrary meshes, and text drawing looks… weird and different, so for now I’m going to just consider those unfinished.) The draw()
method of these types usually takes an IntoQuad
value that specifies where to draw it, and there’s a Sprite
type which is just a Quad
-like thing with a different coordinate system which implements IntoQuad
. (Not Into<Quad>
? Odd.)
Earlier I wondered what ggez
or tetra
would look like without a Drawable
trait? I suppose it could look kinda like this: mostly the same, but with less guidance about what actually is drawable and how the bits fit together. In that case the Drawable
trait isn’t there for the computer, it’s for the human.
Ooooh, the Target
also incorporates a Transformation
; you can create a new Target
from an old one plus a transformation. So to draw something with a transform you do something like this:
let target1 = frame.as_target();
thing.draw(some_quad, target1);
{
// Draw the thing again, scaled down
let scaled = Transformation::scale(0.5);
let target2 = target1.transform(scaled);
thing.draw(some_quad, target2);
}
// Draw again with original transform in a different target location
thing.draw(some_other_quad, target1);
Very clever! It’s not perfect, there’s some work to be done, but it looks like it could be an extremely nice approach.
Oh, documentation! coffee'
s documentation is good! Good enough I didn’t notice it, because it explained everything I needed. Examples could use some work, but that takes time.
Okay, so can we make Astroblasto fit into this sort of model? Let’s see how that goes…
First off, as is common, coffee
exports vector and point types from nalgebra
. (Nitpick: It should re-export nalgebra
so you can always easily find the precise version it uses.) Second off, it sorta has an async style of asset loading as well, with a Task
abstraction that has nothing to do with Rust’s future
types but sort of seems to be approaching the same thing from a different direction. The goal of it seems to be to make progress bars work nicely for loading assets, and it does that just fine, but when you start making combinators to combine them they start looking more and more like futures in any other async framework. Third, unlike ggez
which intended to have multiple different graphics backends but never actually got around to it, coffee
seems to live up to the goal; there are different feature flags for running it on Vulkan, DirectX, etc. I… uh, forgot to actually try out anything besides Vulkan though, so I don’t know how well they work in practice. Probably about as well as wgpu
itself does, which is to say, very well, but not always flawlessly.
Image::load()
takes a AsRef<Path>
but Font::load()
takes a &[u8]
. Tsk tsk, inconsistent API’s, for shame! You’ve had almost two months to make everything perfect!
Looking at Astroblasto, we already have the appropriate types for Game::View
and Game::Input
: our Assets
and InputState
structs. These fit those roles perfectly. Zero modifications necessary, this is exactly what coffee
wants. Really, this is one of those coincidences that is, actually, not very coincidental at all… But it still leaves me flabbergasted, in a happy way.
Okay, so, loading assets. The easy way to run a Task<Foo>
to completion and get Result<Foo, whatever>
out of it is to make a loading screen with loading_screen::ProgressBar::new(window.gpu())
, then run it with loading_screen.run(some_task, window)
. The run()
method basically takes over display and and input and draws the loading screen until everything’s loaded, then returns. Hm, does that not cause a double borrow between &window
and &window.gpu()
? Apparently not, huh.
So implementing the Game
trait for our main state object starts something like this:
impl Game for MainState {
type View = Assets;
type Input = InputState;
const TICKS_PER_SECOND: u16 = 60;
fn new(window: &mut Window) -> Result<(Self, Self::View, Self::Input)> {
print_instructions();
let player = create_player();
let rocks = create_rocks(5, player.pos, 100.0, 250.0);
let asset_loading = Task::stage("Loading assets...", Assets::new());
let mut loading_screen = loading_screen::ProgressBar::new(window.gpu());
let view = loading_screen.run(asset_loading, window)?;
let s = MainState {
player,
shots: Vec::new(),
rocks,
level: 0,
score: 0,
screen_width: window.frame().width(),
screen_height: window.frame().height(),
player_shot_timeout: 0.0,
};
Ok((s, view, InputState::default()))
}
...
}
on_input()
and interact()
are also quite simple to sort out:
fn on_input(&self, input: &mut Self::Input, event: Event) {
match event {
Event::KeyboardInput {
state: ButtonState::Pressed,
key_code: KeyCode::Up,
} => { input.yaxis = 1.0;}
Event::KeyboardInput {
state: ButtonState::Pressed,
key_code: KeyCode::Left,
} => { input.xaxis = -1.0;}
// ...and so on...
_ => (),
}
}
fn interact(&mut self, input: &mut Self::Input, _view: &mut Self::View, _gpu: &mut Gpu) {
const SECONDS: f32 = 1.0 / (MainState::TICKS_PER_SECOND as f32);
player_handle_input(&mut self.player, input, SECONDS);
self.player_shot_timeout -= SECONDS;
if input.fire && self.player_shot_timeout < 0.0 {
self.fire_player_shot();
}
// This is a sort of odd place to put end-of-game checking, but
// we need access to input for it, so here is where it goes.
if self.player.life <= 0.0 || input.quit_requested {
println!("Game over!");
// Like quicksilver, we still seem to have no easy way to signal
// that the game is over!
std::process::exit(0);
}
}
It’s occasionally asked why ggez
uses a single big honkin’ Context
object instead of breaking it into pieces and only having functions take the sub-context types they actually need. The answer is basically “it would be harder than it looks and more trouble than it’s worth”. ggez
is quite small so passing a single Context
around doesn’t get in your way much, and it’s already fairly well modularized under the hood so making it more broken apart wouldn’t make maintenance much easier. However, with it’s very-explicit drawing API it feels like coffee
takes precisely that approach. It’s interesting to see how it works out. It DOES get fiddly and confusing to figure out: drawing something in coffee
starts with a Window
, from that you get a Frame
, from that you get a Target
and NOW you can actually draw your stuff. Though there’s also a Gpu
object floating around doing stuff for some reason I still haven’t figured out. (Looks like it’s mainly used for asset loading?) But once you learn what goes where, I bet it becomes much clearer how things interact because each object only does one part of the drawing process.
Actually drawing a full image is a little fiddly ’cause you have to get the width and height of it, then specify those as the image’s source. This is similar to tetra
’s rotation origin point issue. Again, ggez
uses float coordinates in the range [0-1.0]
for its source rectangle, so you don’t have to treat different images differently. You can use the exact same drawing code for a 16x16 pixel image as for a 91x14 image. I mean… I guess you can do that in coffee
too, but you need an extra step to check the size of the image, if a small one. A nice ergonomic improvement would be an Image::get_rect()
function or such that just returns a Rectangle
representing the full image. On the other hand, now that I think of it ggez
basically has to call the equivalent of Image::get_rect()
in its Image::draw()
implementation and scale the drawing to fit it, and it gets hairy anyway; ggez
just hides the hair from you. Anyway, once you work it out it is pretty simple:
fn draw_actor(assets: &mut Assets, target: &mut graphics::Target, actor: &Actor, world_coords: (f32, f32)) {
let (screen_w, screen_h) = world_coords;
let pos = world_to_screen_coords(screen_w, screen_h, actor.pos);
let image = assets.actor_image(actor);
let dest = graphics::Sprite {
source: Rectangle {
x: 0,
y: 0,
width: image.width(),
height: image.height(),
},
position: pos,
};
image.draw(dest, target);
}
Though, ahahahaha, I just noticed. I want to make a Transformation
that encodes a rotation and apply that to the Target
here, to draw rotated images. However, Transformation
does not seem to have a constructor to make a rotation. If there is any way to rotate an image, I can’t find it in the docs! Whoops! Buddy. Buddy, this is a slightly fundamental piece of functionality you’ve overlooked! XD Well, you warned me the library was unfinished!
Okay, Transformation
does implement From<[[f32;4];4]>
, so you can create a rotation matrix by hand or from nalgebra
or whatever if you want. I’m not going to bother in this case though, since this write-up is already way huger than I intended it to be, we’ll just assume it works and press onwards without rotating anything.
Text drawing is somewhat crude, but again, it’s a young project. Its API makes perfect sense if you know the innards of glyph-brush
, but isn’t the most convenient. glyph-brush
actually makes it kinda hard to make a nice drawing API atop it, for a variety of very good and rather annoying technical reasons. (Basically, because it does rendering to a texture in a separate step from the rest of your system, it needs its own graphics pipeline, and it’s hard to make that look and act like whatever graphics pipeline you’re already trying to build.) A notable roadbump in coffee
is that you have to specify some sort of bounds
vector for the Text
object you are creating… but how do you know what the bounds SHOULD be to render text without distortion, until you’ve drawn it already? You don’t, as far as I can tell. …Actually, I’m not quite sure what the bounds
field is for, I just filled in some values that looked semi-sane and it seemed to work. Also, Font::draw()
taking Into<Text>
instead of just Text
would be nice, ’cause constructing a full Text
object every time is tedious. And Font::draw()
takes a Frame
instead of a Target
, so you can’t apply a transformation to it. No swirly demoscene effects here!
It works fine though once you actually get it all done:
fn draw(&self, view: &mut Self::View, window: &mut Window, _timer: &Timer) {
let frame = &mut window.frame();
let target = &mut frame.as_target();
target.clear(Color::BLACK);
// ...Bunches of calls to draw_actor() omitted, they're basically unchanged...
let level_dest = Point2::new(10.0, 10.0);
let score_dest = Point2::new(200.0, 10.0);
let level_str = format!("Level: {}", self.level);
let score_str = format!("Score: {}", self.score);
let level_display = graphics::Text {
content: level_str,
position: level_dest,
bounds: (100.0, 50.0),
size: 24.0,
color: Color::WHITE,
};
let score_display = graphics::Text {
content: score_str,
position: score_dest,
bounds: (100.0, 50.0),
size: 24.0,
color: Color::WHITE,
};
view.font.add(level_display);
view.font.add(score_display);
view.font.draw(frame);
}
And our update()
method needs basically no changing. Again, there’s no audio support in coffee
yet, so I’ve omitted the sounds. I could easily just pull in rodio
and use it directly, but as I said, this is already taking a long time so I’m going to skip that.
But after that, it builds! Woohoo, how does it run?
Nice. I like the color scheme. Needs one of those little loading-screen mini-games built in though. :D Maybe one where you have to mash buttons to drink enough coffee
for your next coding binge?
Heyyyyy, looks good, apart from the whole not having rotations thing! What does the debug screen look like?
Nice. Though its text blends into the white level display text. Perhaps add a semi-transparent background behind it?
Conclusion: coffee
is not finished, but is finished enough to be real cool. The fact that I took an existing game from an entirely unrelated engine and it trivially slotted into this very opinionated structure is mind-croggling, and says to me that coffee
’s opinions are extremely good ones (or at least, a good fit with my opinions, which is the same thing, right?). This makes me super happy, and I am very excited to see where it goes next.
Piston
So, Piston is the first Rust game engine project that went anywhere, as far as I know. It started in the pre-1.0 days, it has lots of contributors and an order of magnitude more downloads than its closest competition. I have Opinions about Piston, mainly that it’s poorly documented and poorly designed with no direction. This is what lead me to make ggez
in the first place, I wanted something simpler and better! But these Opinions are now going on three years out of date, so it’s long past time I came back and gave it a fair go again. So I will try not to rant too much. I honestly want to learn how it works and try to make an unbiased assessment here.
Technology stack! Okay, first off, Piston is a giant buttload of modules, so you have to choose the bits you want. Going off its basic spinning-square example, we will use:
- Graphics:
piston2d-graphics
andpiston2d-opengl_graphics
. Or maybepiston2d-gfx_graphics
. I dunno. Which is better? Well the tutorial seems to usepiston2d-opengl_graphics
so let’s use that. - Windowing and input:
pistoncore-glutin_window
, which usesglutin
, which is basically justwinit
with some OpenGL stuff. - Audio:
piston-music
, which appears to use SDL2 for audio. Odd choice there, but there doesn’t seem to be any other sound-related crate. - Math: Nothing, it appears? I’ll just use
nalgebra
; I likeeuclid
better these days butnalgebra
is what the Astroblasto code already uses. - Other interesting stuff: Not sure where to find what it would use for text or such. It’s a billion different modules in unrelated repos, so I can’t just open up a single
Cargo.toml
and see what depends on what.
Oy, where to start… there’s just so much STUFF going on and still no clear high-level overview. Though it’s better than it was, I suppose. There doesn’t appear to be an event-loop trait of any kind, it seems, so we’ll have to make our own. No big deal. Let’s start with loading assets, that’s usually a decent entry point.
Okay, to draw an image, we need two things: an Image
and a Texture
. This is not an uncommon division it seems, it’s basically the same concept represented by Drawable
/Background
in quicksilver
and Image
/Sprite
in coffee
. It divides geometry and texture into separate objects, because they are separate objects on the GPU. I can complain about Image
being an unhelpful name when contrasted with Texture
, but whatever. We have to know how big a Texture
is so we can make our Image
the size we want, and Texture
is a generic associated type on the graphics backend trait – ah, but it must implement the graphics::ImageSize
trait, which gives us the methods we want. So to create and draw an image it looks like this:
let texture = opengl_graphics::Texture::from_path(Path::new("resources/player.png")).unwrap();
let (width, _height) = texture.get_size();
let rectangle = graphics::rectangle::square(0.0, 0.0, width as f32);
let image = graphics::Image::new().rect(rectangle);
image.draw(&texture, default_draw_state(), transform, gl);
Wait, hang on, look at the rectangle
definition again. What if our width
and height
are not equal? In Astroblasto all the assets are square, but if we have both width and height from our texture we might as well use them – and I’m going to need to draw non-square stuff like text eventually. Okay, looking at the graphics::rectangle::Rectangle
struct I see fields for corner shape, border, color, constructors for various variants of these, some draw methods… wait, where is the size of the rectangle at all? Rectangle::new()
just takes a Color
. What? There is no graphics::rectangle::rectangle()
function, just square()
and some other stuff. There’s graphics::rectangle::centered()
which does a coordinate change but that takes a Rectangle
as input already. How the heck do I make a rectangle that’s actually, you know, a rectangle? Fine, RTFS, how does the square()
function make a rectangle?
pub fn square(x: Scalar, y: Scalar, size: Scalar) -> types::Rectangle {
[x, y, size, size]
}
wat
wait
there’s two Rectangle
types?
There’s graphics::types::Rectangle
, which is type Rectangle<T = Scalar> = [T; 4];
, and there’s graphics::rectangle::Rectangle
, which is pub struct Rectangle { pub color: Color, pub shape: Shape, pub border: Option<Border> }
. And just to ice the cake there’s also a Rectangled
trait, which does nothing useful for this and is only implemented for types::Rectangle
.
Okay. Okay, deep breaths.
I don’t want to have to store an Image
in our Assets
structure alongside our Texture
, so we’ll just make it on the fly as we need it. We need a DrawState
struct too, but that’s just blend, stencil and scissor settings, so we’re fine with the defaults. Oh, we also need a Transform
. Piston defines its own transform type – oh wait, no, dig deep enough and it uses the vecmath
crate, whatever that is. So how do we create a transform? It looks like one is provided to us from the GlGraphics
object, indirectly, and we have to pass it around and use it as a base for further transformations, I think? FINE. This results in our drawing looking something like this:
fn draw_actor(
assets: &mut Assets,
gl: &mut GlGraphics,
transform: graphics::math::Matrix2d,
actor: &Actor,
world_coords: (f32, f32),
) {
let (screen_w, screen_h) = world_coords;
let pos = world_to_screen_coords(screen_w, screen_h, actor.pos);
let texture = assets.actor_image(actor);
use graphics::ImageSize;
let (width, height) = texture.get_size();
let rect = [0.0f64, 0.0f64, width as f64, height as f64];
let image = graphics::Image::new().rect(rect);
use graphics::Transformed;
// heckin' nalgebra makes it hard to turn a Point into a [f32;2]
let pos_x = actor.pos.x as f64;
let pos_y = actor.pos.y as f64;
let transform = transform.rot_deg(actor.facing as f64 * 180.0f64 * std::f64::consts::FRAC_1_PI)
.trans_pos([pos_x, pos_y]);
image.draw(texture, &graphics::default_draw_state(), transform, gl);
}
HOPEFULLY the transform math works out. Also, graphics::default_draw_state()
? Really? There’s no DrawState::default()
? Really? Wait a second, graphics::default_draw_state()
doesn’t exist. DrawState
DOES implement Default
, though presumably at one point it didn’t. Your tutorials are out of date, buddy. …actually, I sympathize. Keeping code examples in sync with what actually exists is hard. skeptic
helps a lot but it isn’t a panacea and still takes a fair amount of work to use well. The example of how to load a texture has also drifted out of date, but I think I got it worked out with a little digging.
Also, apparently all the transforms and such default to the scalar type being f64
for some reason instead of f32
. This is an easy trap to fall into these days, and I’ve certainly done it, but really for graphics stuff you almost never need to use f64
. I only noticed ’cause Astroblasto uses f32
everywhere anyway. You might want f64
for physics in extreme situations, but using f32
for all your drawing code is just fine. Your screen is still only 4000 pixels across or whatever, max.
Hmm, upon inspection, Piston having separate backends for different drawing libraries is useless here. Lots of types leak through the abstraction layer from your underlying graphics implementation: Texture
being the main one so far. And gl_graphics::Texture
is not interchangable with gfx_graphics::Texture
; they have different methods, different signatures for things like new()
, and don’t implement a useful set of shared traits to provide a common API. So while a small portion of your code doesn’t care whether your Texture
comes from gl_graphics
or gfx_graphics
and can be generic over both… large parts of your program can NOT do this, at least not without a whole lot of extra work. If you want to actually be able to pull one drawing backend out of your code and drop another one in, then you have to really commit to it and make EVERYTHING go through an abstract API. Otherwise you’re just adding a lot of complexity for no real gain.
Right, sprite drawing should work, what about text? There’s graphics::text::Text
which looks pretty straightforward, but… where do I specify a font? I can specify a font SIZE, but not a font? There’s a glyph cache trait in piston::graphics::character
, but I don’t see a way to actually create something that implements this trait. But you need to pass a glyph cache to Text::draw()
. What the heck implements this trait? Nothing in piston2d_graphics
. Is there a piston_text
crate somewhere or something?
Ah, after rummaging around in the tutorials it appears that you use piston_window::Window::load_font()
. You load a font through the window handle?
I… I don’t even. That’s even beyond an M. Bison clip. I honestly have no words.
Right, so we’re not going to be drawing any text, on to audio! piston-music
appears to be your only choice here, at least that I can find, so let’s dig into it. Docs are nigh-nonexistent, but there’s only like six functions, so we should be able to figure it out. piston_music::start()
appears to be the place to start, it just does some setup and then… takes a callback thunk to actually play your music in? I think that’s what’s happening? Yeah, looking at the source code that’s what you’re supposed to do, piston_music::start(whatever, || stuff_that_plays_music() );
, ’cause it calls your thunk and then drops all the audio context objects after it returns. I wonder what happens if you try to call play_sound()
outside of that thunk, hm? And just how ARE you supposed to make this work well together with your event and drawing loops, which use the same model of “pass a thunk in to be called”? You can nest the closures I suppose but that starts getting… eeeeugh.
Actually, hang on, play_sound()
is fishy too. There’s no docs so all we have to go on is the function signature, which is this: pub fn play_sound<T>(val: &T, repeat: Repeat, volume: f64) where T: 'static + Eq + Hash + Any
. What in the world is this T
type for? There’s no particular trait attached to it, and no documentation explaining it. Is it… some sort of context? Or handle? Or arbitrary user-data or something? Wait, there’s also this function: pub fn bind_sound_file<T, P>(val: T, file: P) where T: 'static + Eq + Hash + Any, P: AsRef<Path>
. So you bind a sound file to some sort of entirely arbitrary type to use as a handle, and then feed that handle into play_sound()
to index which sound to play? Let’s look at the source…
pub fn bind_sound_file<T, P>(val: T, file: P)
where T: 'static + Eq + Hash + Any,
P: AsRef<Path>
{
let track = mixer::Chunk::from_file(file.as_ref()).unwrap();
unsafe { current_sound_tracks() }.insert(val, track);
}
YEP that’s EXACTLY what’s going on! And what the hell is unsafe { current_sound_tracks() }
? That leads me to the current
crate, which describes itself as “A library for setting current values for stack scope, such as application structure”. Digging into the source a bit, it looks like some sort of weird, ghetto, unsafe-as-hell stack-ish-something thread-local version of lazy_static
that just lets you shove global mutable state into an AnyMap
from wherever you want.
Man, you know what ELSE is good for setting current values for stack scope such as application structure? LOCAL VARIABLES. Is piston-music
some kind of ancient experiment that just got made and then forgotten about and nobody was ever intended to use it? ’Cause that’s what it looks like. …no, it’s actively maintained. The latest version was published in April 2019.
So we’re not going to have any audio! Are we even going to get this game to work at all? I really don’t know if I can handle it. I… I’ll try, I suppose. This stopped being fun with text rendering, but all we have left is event handling and main loop.
Dum de dum, more out of date tutorial docs, more useless backend abstraction with piston_window
wrapping glutin_window
plus one or two other options that aren’t at all the same and so aren’t interchangable. Actually, now that I try to look at it more, piston_window
for some reason seems to offer a lot of graphics drawing stuff itself, and doesn’t seem to use piston_graphics
or anything. It also uses the gfx
drawing backend instead of OpenGL– okay. Shit. It turns out that the Piston-Tutorials
repo and piston-examples
repo are mutually exclusive things that both use different subsections of the ecosystem that don’t seem to overlap. piston_window
looks like it’s an entirely different thing that doesn’t interact with piston_graphics2d
or piston-glutin_window
at all. My bad, I have no idea how I could have thought that piston_window
and piston-glutin_window
were related! So let’s kick piston_window
in the head and go back to what it turns out we’ve been trying to use from the FIRST tutorial.
So I’m TRYING to make a main loop that looks like this tutorial code:
pub fn main() {
let mut state = MainState::new();
let opengl = OpenGL::V3_2;
// Create an Glutin window.
let mut window: glutin_window::GlutinWindow = piston::window::WindowSettings::new(
"spinning-square",
[200, 200]
)
.opengl(opengl)
.exit_on_esc(true)
.build()
.unwrap();
let mut events = Events::new(EventSettings::new());
while let Some(e) = events.next(&mut window) {
if let Some(r) = e.render_args() {
state.draw(&r);
}
if let Some(u) = e.update_args() {
state.update();
}
}
}
Except I can’t find docs for Events
or the types that render_args()
and update_args()
gives me. Okay, it looks like those are from piston::event_loop
, which is a re-export of pistoncore-event_loop
and for whatever reason rustdoc on docs.rs isn’t following the link to it, and so I search for the function render_args
and find nothing in the piston
crate. FINE, that’s not Piston’s fault, docs.rs has had that bug forever. So I find the docs for the pistoncore-event_loop
crate and it looks like events.next()
returns Option<Event>
… except Event
has no link on docs.rs either, so it comes from some OTHER crate! It looks like it comes from pistoncore-input
… I HOPE. Well there’s an Event::render_args()
there, so I assume that’s what I’m actually using! So render_args()
gets me a pistoncore-input::RenderArgs
… which is absolutely useless and has nothing on it that I need. How the heck do I get my OpenGL context handle?!
Oh, I missed it, the tutorial I’m trying to follow demonstrates that you have to call GlGraphics::new()
by hand and store the GlGraphics
object yourself. Though creating the window also takes the opengl
object that says what version you want to use, so it presumably ALSO creates an OpenGL context. Yeah this seems okay.
Right, so pistoncore-input::Event
implements a fuckzillion traits that all are different event types and all do basically the same thing and it’s the ONLY thing that implements any of those traits and there’s no generics involved so all those traits are useless. But eventually I find that Event::button_events()
is probably what I want for input. …well, it’s both keyboard AND mouse buttons for some reason, but forget that. So our input handling looks like this:
fn input(&mut self, event: ButtonArgs) {
match event {
ButtonArgs {
state: ButtonState::Press,
button: Button::Keyboard(Key::Up),
..
} => {
self.input.yaxis = 1.0;
}
ButtonArgs {
state: ButtonState::Press,
button: Button::Keyboard(Key::Left),
..
} => {
self.input.xaxis = -1.0;
}
...
}
}
Okay so now that we’ve gotten this window-creation fuckup sorted out, we have to go back to drawing and try to smooth it over. …um. Hmmm. Wait, I need a base transform to feed into my draw functions, right. Where does that come from again? Right, you call self.gl.draw(draw_args.viewport(), |ctx, gl| ...)
and then ctx.transform
has the projection or whatever that I’m supposed to start from. Then you call graphics::clear(color, gl)
and such in that thunk. …But you could call graphics::clear(color, self.gl)
anyway, outside of the draw()
thunk. What the hell happens THERE I wonder? Let’s… let’s just not find out. And I also CAN’T do what the tutorial does and store the GlGraphics
object in my MainState
’cause that causes a double-borrow elsewhere in draw_actor()
. Whatever, I’ll just store it in a local in main()
and pass it in to MainState::draw()
. So in the end, our drawing looks like this:
fn draw(&mut self, gl: &mut GlGraphics, render_args: &RenderArgs) {
gl.draw(render_args.viewport(), |ctx, gl| {
graphics::clear([0.0, 0.0, 0.0, 1.0], gl);
{
let assets = &mut self.assets;
let coords = (self.screen_width, self.screen_height);
let p = &self.player;
draw_actor(assets, gl, ctx.transform, p, coords);
for s in &self.shots {
draw_actor(assets, gl, ctx.transform, s, coords);
}
for r in &self.rocks {
draw_actor(assets, gl, ctx.transform, r, coords);
}
}
})
}
And our setup and main loop looks like this:
pub fn main() {
let gl_version = OpenGL::V3_2;
let gl = &mut GlGraphics::new(gl_version);
let mut state = MainState::new();
let mut window: glutin_window::GlutinWindow =
piston::window::WindowSettings::new("Astroblasto Piston", [800, 600])
.opengl(gl_version)
.exit_on_esc(true)
.build()
.unwrap();
let mut events = Events::new(EventSettings::new());
while let Some(e) = events.next(&mut window) {
if let Some(b) = e.button_args() {
state.input(b)
}
if let Some(_u) = e.update_args() {
state.update();
}
if let Some(r) = e.render_args() {
state.draw(gl, &r);
}
}
}
Right! Finally. I THINK that’s it. It all builds! Let’s see what it looks like!
icefox@localhost ~/RustGameFrameworkGuide/astro_piston $ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.11s
Running `target/debug/astro_piston`
thread 'main' panicked at '
OpenGL function pointers must be loaded before creating the `Gl` backend!
For more info, see the following issue on GitHub:
https://github.com/PistonDevelopers/opengl_graphics/issues/103
', ~/.cargo/registry/src/github.com-1ecc6299db9ec823/piston2d-opengl_graphics-0.62.0/src/back_end.rs:317:9
note: Run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
Great. Hm, maybe I just have to call GlGraphics::new()
after creating the window instead of before?
thread 'main' panicked at 'gl function was not loaded', ~/RustGameFrameworkGuide/astro_piston/target/debug/build/gl-833fcc759a3b905e/out/bindings.rs:20624:13
Okay. I think that’s all the work I’m going to put into that. How depressing. What a waste of time.
Amethyst
Fortunately, we’re on the finishing stretch!
So this is it. We’re off the edge of the map. The last familiar pixel-y mountain has drifted below the horizon with some very nice layering and parallax effects, we’ve glitched through the skybox, and we are now sailing into the shadowy, geometry-shader-tesselated waters of 3D game engines. Not that we’re going to make Astroblasto 3D of course; a Real Programmer can write a SNES-style game in any engine. And Astroblasto is barely even Atari 2600 quality!
Similar to Piston, Amethyst is a giant pile of sub-crates. I usually dislike this sort of structure because it makes it harder to understand how things fit together. You can arrange things so the sub-crates form nice layers and it’s more clear what depends on what, but this takes work and is not easy to do well. The tradeoff is that, for large programs, it’s far easier to understand them one sub-crate at a time, the sub-crates can be versioned independently, and you can pick and choose which pieces you actually need… but few projects are actually large enough that this matters. Is Amethyst in this category? Dunno, let’s find out!
(Oh– Dissimilar to Piston, these crates are all in one git repo so you can actually see what exists. However they are still versioned separately, which to my mind only increases the complexity without much gain. How do I see the source code on github for the latest published crate-set? Well… uh, I don’t think I can. There’s a branch marked v0.10, but there’s no v0.9 so I don’t trust it. – Oh, though actually the top-level amethyst
crate does the right thing and re-exports known versions of all its subcrates. Woohoo!)
So with that in mind, let’s take a look at what other dependencies Amethyst uses:
- Graphics:
gfx
, word on the street is that the next release will usegfx-hal
andrendy
and it will be super sexy - Windowing and input:
winit
- Audio:
rodio
- Math:
nalgebra
- Other interesting stuff:
specs
for the entity-component system,fluent
for localization(!),gfx-glyph
for text rendering (basically an earlier version ofglyph-brush
; I assume they’ll switch toglyph-brush
as part of the renderer upgrade)
Well, Amethyst has a full book-style Getting Started guide, so let’s dive into that! …it also has a whole CLI tool of its own to set up a new project. Hoo boy. Run amethyst new astro_amethyst
and it looks like it creates… a Cargo.toml
, a single short config file, and a main.rs
with a non-trivial-but-still-simple Hello World application. …Is that really worth having a whole separate tool for?
No, no, I’m being positive, honest! Well, trying to. I don’t hate everything! Really!
The docs are quite good; the book has sections on both abstract concepts and on practical implementations. It explains Amethyst’s state-stack system, which is a useful structure for just about any game. It explains how the entity-component-system model works, though I would probably try to organize the explaination a bit differently. It explains how Amethyst’s event channels work, which is something I haven’t seen before. It also explains all the deps you need to set up to run it, though it misses a minor thing or two. Like I said, keeping docs in sync is hard.
Okay, so the “make a window and clear it” hello-world program of Amethyst looks something like this:
extern crate amethyst;
use amethyst::{
prelude::*,
renderer::{DisplayConfig, DrawFlat, Pipeline, PosNormTex, RenderBundle, Stage},
utils::application_root_dir,
};
struct MainState;
impl SimpleState for MainState {
fn on_start(&mut self, data: StateData<'_, GameData<'_, '_>>) {
}
}
fn main() -> amethyst::Result<()> {
amethyst::start_logger(Default::default());
let path = format!(
"{}/resources/display_config.ron",
application_root_dir()
);
let config = DisplayConfig::load(&path);
let pipe = Pipeline::build().with_stage(
Stage::with_backbuffer()
.clear_target([0.0, 0.0, 0.0, 1.0], 1.0)
.with_pass(DrawFlat::<PosNormTex>::new()),
);
let game_data =
GameDataBuilder::default().with_bundle(RenderBundle::new(pipe, Some(config)))?;
let mut game = Application::new("./", MainState, game_data)?;
game.run();
Ok(())
}
Application
is our entry point. GameDataBuilder
appears to create our assets and graphics setup…? No, it has something to do with initializing the entity-component system. Pipeline
defines the steps in which we draw things. Amethyst uses the specs
ECS for everything, so we’re going to have to rewrite quite a bit of our game structure to re-arrange our stuff into specs
’s data structures. For example creating a camera:
fn create_camera(world: &mut World) {
let mut transform = Transform::default();
transform.set_z(1.0);
world
.create_entity()
.with(Camera::from(Projection::orthographic(
0.0,
SCREEN_WIDTH,
0.0,
SCREEN_HEIGHT,
)))
.with(transform)
.build();
}
I’ve used specs
a fair bit before, so this feels pretty familiar to me. We’re just going to shove what we have into this structure and make it fit without worrying too much about making it super nice, so let’s just make our Actor
a component. Ideally we’d break it apart into pieces like Life
and Motion
and whatever else, which I’d do if this were a bigger game. However, we don’t NEED to, and this will take a fair bit of re-architecting of Astroblasto’s guts to work though already, so I’m going to do it the quick-and-dirty way for now. Let’s get to it!
Hm, does Amethyst not expose the tragically-underdocumented-and-underused specs-derive
? Nope, doesn’t look like it, but it uses the most recent version of specs
so we can just pull in the matching version of specs_derive
, slap #[derive(Component)]
on our Actor
type and that’s about done. Update our creation functions to take a World
and create a new entity in it, and that should work fine.
I couldn’t find any math library associated with Amethyst, but it turns out it uses nalgebra
, it just has it tucked away in amethyst::core
. Having the whole thing be a pile of different crates really makes searching for things on docs.rs a pain. I think that’s the aforementioned bug with docs.rs not handling re-exported crates properly; I can’t test it just now but I vaguely recall that using rustdoc to build docs locally makes it work properly. Ah, wait, Amethyst provides working docs at https://docs.amethyst.rs/stable/amethyst, much better. Not perfect still, ’cause it doesn’t include major and complex dependencies like specs
and nalgebra
, but better.
So we now have a component, and turning our functions like update_actor_position()
and handle_timed_life()
and such into systems should be straightforward, but first there’s a bit of boilerplate to handle. You have to register components with specs
, basically so it can do some bookkeeping and allocating of structures and such ahead of time and not have to check it every time you iterate through a system. Looks like Amethyst has some shortcuts for this in its GameDataBuilder
, which it calls “bundles”, which let you automatically register a bunch of related components such as Transform
and Camera
.
I’m not going to explain how specs
works in depth, I’ve done so elsewhere and Amethyst doesn’t seem to add anything special to it. Suffice to say that our “move actor” system looks like this:
pub struct MotionSystem;
impl<'s> System<'s> for MotionSystem {
type SystemData = (
WriteStorage<'s, Actor>,
);
fn run(&mut self, (mut actors,): Self::SystemData) {
for (actor,) in (&mut actors,).join() {
// Clamp the velocity to the max efficiently
let norm_sq = actor.velocity.norm_squared();
if norm_sq > MAX_PHYSICS_VEL.powi(2) {
actor.velocity = actor.velocity / norm_sq.sqrt() * MAX_PHYSICS_VEL;
}
let dv = actor.velocity * SECONDS_PER_FRAME;
actor.pos += dv;
actor.facing += actor.ang_vel;
}
}
}
The rest of the systems are also pretty simple to rewrite into specs
, though the ones that add and remove entities are kind of a pain in the butt. It’s much easier to explicitly know “this runs at time T, this runs at time T+1, and they will not conflict because I wrote them in the right order” than it is to handle the general case of expressing “we must lazily sum up all the changes to this collection, then apply them all at once after we’re done mutating the things in it.”
But once all these are defined we add the systems to our world in our GameDataBuilder
. However, since specs
will run multiple systems in parallel threads if possible, we have to provide explicit sequencing of how these systems may be executed. We must update actor positions, then do the bit that makes them wrap around the screen if they’re outside of it, then check for collisions and decrement timed life, then remove actors that are dead. specs
lets you give a system a name as a string, and specify by name which systems it depends on, which feels a little crude ’cause you can misspel a name or something and it won’t notice until runtime. But nobody’s ever been able to come up with something better, including me. So, when we add our systems to the GameDataBuilder
, it looks like this:
let game_data = GameDataBuilder::default()
// ...snip bundle stuff ...
.with(InputSystem, "input_system", &[])
.with(MotionSystem, "motion_system", &["input_system"])
.with(WrapSystem, "wrap_system", &["motion_system"])
.with(CollisionSystem, "collision_system", &["wrap_system"])
.with(TimedLifeSystem, "timed_life_system", &[])
.with(
CheckDeadSystem,
"check_dead_system",
&["timed_life_system", "collision_system"],
)
.with(
RespawnLevelSystem,
"respawn_level_system",
&["check_dead_system"],
);
Sure, we could just have one system that does our entire update()
function, but I wanna do it this way. Interesting to see how complicated the sequencing dependencies actually are for something this simple, isn’t it?
Ah, but we never made an InputSystem
! I was putting that off, but let’s attack it now. It appears that Amethyst provides an InputHandler
resource and system that can load input mappings from a config file. So we throw something like the following into resources/bindings_config.ron
:
(
axes: {
"turn": Emulated(pos: Key(Right), neg: Key(Left)),
"thrust": Emulated(pos: Key(Up), neg: Key(Down)),
},
actions: {
"fire": Key(Space),
},
)
I’m guessing a little on how the actions
bit works, I can’t find any documentation explaining it. Well, we’ll see how it works. We just load the config file up and register the InputHandler
as shown in the tutorial. Then our InputSystem
becomes:
pub struct InputSystem;
impl<'s> System<'s> for InputSystem {
type SystemData = (
WriteStorage<'s, Actor>,
Read<'s, InputHandler<String, String>>,
Entities<'s>,
Read<'s, LazyUpdate>,
);
fn run(&mut self, (mut actors, input, entities, lazy): Self::SystemData) {
for (actor,) in (&mut actors,).join() {
// Matching on the actor tag is a hack but it works.
// I don't feel like breaking Actor apart into proper
// components.
if let ActorType::Player = actor.tag {
let xaxis = input.axis_value("turn").unwrap_or(0.0) as f32;
let yaxis = input.axis_value("thrust").unwrap_or(0.0) as f32;
actor.facing += SECONDS_PER_FRAME * PLAYER_TURN_RATE * xaxis;
if yaxis > 0.0 {
let direction_vector = vec_from_angle(actor.facing);
let thrust_vector = direction_vector * PLAYER_THRUST;
actor.velocity += thrust_vector * SECONDS_PER_FRAME;
}
if input.action_is_down("fire").unwrap_or(false) {
//self.player_shot_timeout = PLAYER_SHOT_TIME;
let direction = vec_from_angle(actor.facing);
let velocity =
na::Vector2::new(SHOT_SPEED * direction.x, SHOT_SPEED * direction.y);
lazy.create_entity(&entities)
.with(Actor {
tag: ActorType::Shot,
pos: actor.pos,
facing: actor.facing,
velocity: velocity,
ang_vel: SHOT_ANG_VEL,
bbox_size: SHOT_BBOX,
life: SHOT_LIFE,
})
.build();
}
}
}
}
}
To me this supports my assertion that input always kinda sucks, and adding/removing entities is always a pain. Amethyst does make it about as nice as is possible, though. Part of the pain is just that I’m not taking the time to REALLY reorganize this to fit nicely into a specs
-y style. I’m tired, and I think I’m going to leave collision and level respawning as exercises to the reader. They’re a lot of reorganization that isn’t terribly interesting, and I want to get on to asset loading and drawing.
Ah, Amethyst actually has a full asset loading system! As usually happens whenever you try to make an asset loading system in Rust, it looks like an asset is basically an Arc<Whatever>
. It separates asset loaders and asset storage, and makes them explicit objects that are stored as resources in the specs
World
. Unfortunately, it seems to always expect a sprite sheet with a specification file telling it where things are, instead of just letting us handle each image as its own thing. So, let’s create a spritesheet config file for each of our three sprites, such as:
(
spritesheet_width: 16,
spritesheet_height: 16,
sprites: [
(
x: 0,
y: 0,
width: 16,
height: 16,
),
]
)
Amethyst’s config files all appear to be RON, which is a bit too “cute” IMO – Yay, yet another config file format to figure out! On the other hand, it IS very nice for representing Rust’s enums and options, which JSON never does well and TOML doesn’t really handle either. So as Yet Another Config File Format’s go, it serves a useful purpose. And at least it’s not YAML!
So as part of our on_start()
method we load the images, load the spritesheet specifications, slap them together, and create Amethyst’s SpriteRender
components out of them and register them. Then we update our actor creation functions to add those components to our Actor
entities when we create them. …except it won’t work ’cause we don’t use Amethyst’s Transform
component so it doesn’t know where to draw our stuff. Augh, doing things the quick and dirty way has bitten me in the ass! Plus, I’m not sure if it’s possible to get an AssetHandle
back out of the AssetStorage
after the asset has been created; I don’t see a way to feed it a name or label or such and pull an Asset
out. So we’d have to rework our Assets
type and shove it into the World
as a resource.
…
…Okay, you know what, I think I’m done. Not because Amethyst is bad, ’cause it isn’t, but just because I’ve ported this game to four and a half different systems already and I’m tired, and I’ve gone far enough to get a feel for Amethyst works. Amethyst offers a lot of functionality, but much like with other Real Game Engines, you have to organize your program to its expectations. For Astroblasto that would basically be a complete rewrite, and I don’t have the energy to do that. This is an overview, not a tutorial on Amethyst, so when I mostly end up copy-pasta’ing code from its existing tutorial I’m not making anything new.
Don’t get the feeling that I am being a downer about Amethyst’s boilerplate, though. If you’re making a FULL game, with all the things a full game needs like a menu screen, screen config options, and so on, then you will end up with this sort of stuff even if you use something like ggez
or coffee
. You can write all this yourself, it’s not too hard… but it’s boring and you end up with a lot of boilerplate that way as well. Past a certain complexity you need an asset system, you need an input abstraction layer, you need a scene system, you need some abstraction over your lower-level drawing engine, and so on and so forth. However, this is all stuff that mostly gets written once, and then increases in size far more slowly than your game itself. If your game logic is 500 lines, then adding 500 lines of boilerplate is pretty painful. But when your game logic becomes 10,000 lines, then your boilerplate is probably about 1500 lines or so, and the amount of power you get from it is incredibly useful. Creating a new object type becomes a matter of throwing some components together and maybe changing a system or two, saving and loading games just dumps existing state with built-in serialization using serde
, going from one screen to another is a function call or two, and life is great.
So, Amethyst looks like it would do just fine for a 2D game: it has sprite sheets, it has animations that let you interpolate things around, and it gives you a ridiculous amount of control over the rendering pipeline. It doesn’t look like quite a full skeletal animation system, and it doesn’t seem to let you do flipbook-style sprite animations easily, but the renderer submodule is big enough I could easily have missed those features, and if someone wanted to write them I’m sure the Amethyst devs would love contributions. Its input system is exactly what you want. It exposes specs
for you to use exactly how you would expect to use it, and then adds a few ergonomic shortcuts. I haven’t touched its audio system, but at a glance it looks like a thin layer over rodio
, and so is fairly similar to everything else that’s a thin layer over rodio
, nothing special but perfectly usable.
And, more to the point, Amethyst’s library structure is fairly sensible, its documentation is good, and there’s tons of example code. It’s a large, old open-source project being worked on by many people (at least by the standards of the Rust gamedev community), so I expected it to be very fuzzy and poorly organized, the epitome of design-by-committee. While it may or may not have been that way once, and there may still be places that’s the case, the current structure is sensible and everything seems to fit together as far as I could explore in an afternoon. It’s not flawless, but there’s piles of small conveniences that give the feeling that, yes, this is a framework that people are actively using to make real games. These people know what they are doing, and they are not so caught up with their grand design that sharp edges and impedance mismatches get ignored because their design doesn’t account for them. This feeling of polish is a big deal for a user. Amethyst also seems to have a good half-dozen serious contributors, so hopefully they’re getting to the point where the size of the project can snowball a bit more and they can start doing things that one-maintainer projects really can’t.
So I said in my graphics library guide that Amethyst feels like vaporware? Welp, I was wrong. Hopefully it will be the next Godot instead.
Conclusions
Whew! So, that’s what Astroblasto looks like in each of the major Rust game frameworks. Took a bit more work than I hoped, but I also learned a hell of a lot about how my stuff fits into the larger picture. Time for random observations!
First, wow, I didn’t realize ggez
was so low level! I guess I kinda like writing lower-level code. ggez
also goes more for completeness than most of these libraries, though part of that is just ’cause it’s been around the longest and so has had the most people saying “hey, can you make it so it’s possible to do X?”. But it has also been a deliberate design decision all along to expose ggez
’s guts to the user more than is typical: you can use your own event loop, you can pull the gfx-rs
renderer out of it and make your own drawing pipeline with it, there’s provisions for saving and loading config files full of graphics options, etc. It offers lots of hooks, and tries to be moooooostly unopinionated about which ones you care to use or not. This leads to a more powerful API that is very broadly usable, but also rather larger and hairier than a nice minimalistic thing. ggez
also has deliberately avoided including animation, physics and so on, because those things really don’t need to be built in to it. I once had hopes that multiple people, including me, would write libraries for those features atop it. Alas, that doesn’t seem to have happened; instead everyone just writes their own whole framework. XD
Also, I just realized, none of the other 2D libraries offer general purpose mesh rendering as a first-class API. I admit its a niche thing for 2D that’s not often used for games, and optimizing your drawing code to only handle quads makes your life a bit easier, but its not THAT much work.
Turns out that one way or another, event handling always sucks! No matter what you do you end up with big long ugly match statements or stuff, fiddling random state variables that then get checked by update functions. As I said, IMO it’s easier to go from edge-triggered to level-triggered input than the other way around, so that’s what I usually do. Having something like Astroblasto’s InputState
struct that only has the abstract input that your specific game cares about also makes it less hairy… or at least isolates all the hair to one place. Life gets much cleaner when you have a pure function that’s basically fn(Events, KeyMap) -> InputState
, and then your event handling just becomes another function, fn(&mut GameWorld, InputState)
. Making InputState
actually represent everything you want is still a pain in the butt though, especially if you add or remove capabilities or have a GUI or a complex interface with text-box input or roguelike-style hotkeys… Like I said, it always sucks. Doesn’t look like anyone’s come up with a significantly better way! Though coffee
tries.
It would appear that thinking about graphics and main loop structure is more fun than implementing “solved” problems like audio and asset loading, so people put more work into those. It’s weird, ’cause I think the filesystem abstraction layer was the first thing I actually wrote for ggez
.
Async asset loading actually works pretty well… Most of the time you don’t need to get fancy about things though, so all you actually need to do with async stuff is “update loading screen and otherwise basically block until stuff is ready”. Limiting use cases make complicated problems simpler!
Measuring graphics coordinates in our 2D games in units of pixels with (0,0)
at the top-left corner with Y increasing downwards is generally a stupid way to do things… but it’s also what everyone actually wants for 2D rendering, so that’s just gonna stay that way for a while. And it actually has its advantages ’cause it makes life simpler for rendering left-to-right text. But apart from that there’s no reason to do it anymore… besides the fact that everyone expects it to be that way. So, everyone does it.
I’ve noticed this is really one of the sorts of things that is a fundamental different mindset between 2D and 3D graphics, actually. 2D graphics people want everything to be in pixels, because their art editors use pixels, their images are pixels, the screens are pixels, and it’s very easy to just want to slap one onto the other. But all our graphics goes through a 3D rendering pipeline these days, even 2D stuff, and 3D fundamentally does not care about pixels. Everything is in floating point coordinates. Textures don’t have pixels, they have texels; these are not the same, because the graphics card can and will do filtering and resampling and whatever else it feels like before some part of your texel gets mapped to some little blinky lights and actually put on the screen. Even if you just have a 16x16 texture and slap it on the screen at a 1-to-1 scaling, there are MANY opportunities for a graphics card to do various transformations to it under the hood. Beyond filtering and projection you have mipmaps, shaders, render targets, and Eris only knows what else. Pixels do not exist until the very, very last moment in the rendering process.
The GPU wants to ignore pixels as much as possible, and does everything possible to cover them up. But if you’re doing retro 2D graphics, you want to think about pixels as much as possible, because your art style relies on Getting It Right. With 3D graphics you rely on anti-aliasing and filtering to smooth out rough edges, but with pixel-art graphics those rough edges are part of the art. Smoothing and anti-aliasing of pixel-art graphics tends to look like crap, so you want your graphics pipeline to avoid it, and if you’re the type of person to make pixel-art graphics in the first place you’re probably the sort of person to obsess over these sorts of details. This can lead to some contention. So it’s a hard choice for a game library to make: Do you represent things in the way humans want, and lie to them about what the world looks like to the GPU? Or do you represent things the way that the GPU wants, and make life harder for the human?
ggez
sort of takes the middle road, leaning a little towards the GPU side. Coordinates on the screen default to units of pixels, and images are scaled to their size in pixels. But coordinates within an image, for origin points of rotation and source rectangles, are in normalized floating point units. On the other hand, all of the other 2D libraries seem to take the first option: lie to the human about the GPU’s world, pixel coordinates everywhere. And I actually find it less convenient, not more. But it’s interesting that ggez
alone is the odd one out in this. Is there a better way to have it both ways? Dunno.
Error handling is also something ggez
pays a lot of attention to, everything that can concievably fail returns Result
. Some other libraries do as well. Others don’t. It’s surprising how unimportant it is sometimes though. I mean, really, if you try to draw an image, and can’t ’cause, say, a graphics driver has crashed and your OpenGL context is Just Gone… what ARE you going to do besides panic anyway? Plus that sort of thing really doesn’t happen often, so it’s not something that we the developers exercise a lot in day-to-day programming. So perhaps instead of returning Result
wherever it’s remotely possible it would make life marginally smoother to hide it and instead offer a centralized hook that errors get dispatched to, perhaps in the vein of quicksilver
’s handle_error()
or coffee
’s on_close_request()
? Dunno, would be neat to play with.
I hadn’t realized nalgebra
had taken over the world so thoroughly, but I couldn’t find anything that didn’t use it. It’s wayyyyy more complicated than it needs to be for video game math… but it’s also very high quality, and it’s nice to have a de-facto standard even if the standard is not optimal. It would be cool to see more things using mint
though!
Also, on the meta level, Sourcehut works pretty well. Using it with Mercurial is sometimes slowish but works fine. There was a problem once where I couldn’t pull from HTTP due to some server-side error… but then I tried again a few hours later and it had already been fixed. Try it out, or run your own instance if gitlab is too heavy for you. It doesn’t really provide a full project-management suite, but for smaller projects it’s probably perfect.
Dang, I need to make a non-trivial game with Amethyst someday. Coffee, too. Maybe next Ludum Dare?
…You know, I kinda want to make a new 2D game framework too, now. I have some new ideas to try out.
Appendix: Those New Ideas To Try Out
I’m putting some thoughts here ’cause I don’t have anywhere else to put them. sylvite, maybe? I have no particular plans on actually acting on these ideas yet, I just want to brainstorm.
- coffee’s
View
andInput
types are very interesting, but as demonstrated, not actually essential. Helpful though… Keep thinking about that sort of model and see how it evolves. - coffee’s debug screen is excellent and I want it.
- Making better keybinding options and axes and such built in a la Amethyst would be very, very nice. Hard to do well in the case of roguelike-ish games or text box input or such though… Maybe two different API’s for raw or cooked events? dunno.
- A built-in scene stack might be very nice.
- tetra’s audio API is probably the best way to go. Skip
rodio
and build something like that atopcpal
perhaps? - ggez’s VFS layer is best in class and deserves to be its own thing
- ggez’s mesh API is best in class but could still be better. Might have some things to learn from Amethyst.
- being moar opinionated about drawing and coordinate systems might make life simpler.
- A clearer separation between geometry and textures, as well as different methods for addressing textures by pixels or by normalized float coordinates (coffee’s
Sprite
vsQuad
) might also make life simpler. - Life would get simpler if we go for a higher level API in general. For example, entirely controlling the event loop.
- the world really needs a good, general-purpose animation crate. FFI binding to Spine, whatever.
- You must treat wasm as a first-class citizen from the start instead of trying to tack it on later
- You must not treat Mac as a first-class citizen from the start because they are a huge maintenance burden and it’s not worth it. :|
- Avoid success at all costs.
- Async might be worth experimenting with, and probably will be necessary to handle somehow on web.
- Keep using
mint
.