The Developer’s Cry

Yet another blog by a hobbyist programmer

Rust and SDL2 : Fighting With Lifetimes (part 1)

Let’s make a small game in Rust. Should be easy, right? The SDL in Rust turns out to be a bit different from the SDL that we know from C/C++. Where is SDL_Renderer? It’s now a Canvas<Window> type. How to load a texture? You need a TextureCreator<WindowContext> instance. Rather than being a straight port, it’s a rusty port that looks like SDL, but feels foreign. It is different enough that you won’t be able to figure it out just by reading the docs. Have no fear, there are tutorials on the net! Google for “sunjay.dev rendering image” and copy-paste from his code to setup a window and render that first texture.

Knock, knock

Now try do something more serious with it, and you are bound to run into lifetime issues with textures. For example, let’s make a TextureAtlas:

struct TextureAtlas {
    texture: Texture,
    cell_width: u32,
    cell_height: u32,
}

Nope! It does not compile.

|     texture: Texture,
|              ^^^^^^^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
~ struct TextureAtlas<'a> {
~     texture: Texture<'a>,

What is going on here? I quote, from the documentation:

struct Texture<'r> { /* private fields */ }

That lifetime annotation there is a source of many headaches, and we need to have a good chat about it. Lifetimes are frustrating for two reasons:

According to the authors of Rust-SDL2, the Texture is annotated because a texture is valid for as long as the “canvas” lives. This is only half of the story however.

Follow the white rabbit

SDL2 itself is built in C, a language that is heavy on pointers. When you load or create a texture in C/C++ then you get a SDL_Texture* (ie. pointer) to allocated memory in return. There, it is the programmer’s job to get the memory management right.

The Rust implementation is a wrapper around C code. Directly calling C from Rust is unsafe. So in order to get rid of unsafe blocks, all the code is wrapped. Moreover the C pointer is unsafe, so that needs to be wrapped as well. Dealing with pointers means dealing with their lifetimes, [at least if done proper].

What would a possible implementation of Texture look like?

pub struct Texture {
    raw: *mut sys::SDL_Texture,
}

The raw pointer is unsafe, so to make it safer they added a lifetime annotation. The actual implementation of Texture is:

pub struct Texture<'r> {
    raw: *mut sys::SDL_Texture,
    _marker: PhantomData<&'r ()>,
}

[The marker PhantomData is only there to make it compile. Yes, really].

Now, because there is a lifetime annotation on Texture, we must also annotate our TextureAtlas. The lifetime annotations spread through the code. I will show how to fix it, but before we do that, back to Sunjay’s code for a moment. He does not seem to have any problems whatsoever.

Sunjay loads the texture in the main() function, and then passes it by reference to a render function. In later examples, he stores the texture in an array, and passes the array around by reference.

let textures = [
    texture_creator.load_texture("assets/bardo.png")?,
];

...

render(&mut canvas, &textures, ...)?;

His API is built around functions that take in a Canvas and a slice of Textures every time. Personally I prefer a different kind of interface, but in doing so he cleverly avoids having to deal with the lifetime on the Texture.

A technique resembling this is making a TextureManager that basically turns textures into handles:

let mut tex_mgr: Vec<Texture> = Vec::new();
tex_mgr.push(my_texture);

// my texture is now effectively handle #0

Having a texture system that works with handles is much more convenient than dealing with pointers or lifetimes. Ironically, textures in pure OpenGL are handles, and it is SDL that made things harder by putting it into an allocated struct.

I should mention it is possible to enable the “unsafe_textures” feature via Cargo.toml. It removes the lifetime annotation from the Texture type declaration. It’s a cheat, so it’s not recommended.

I should also mention that if we similarly try to abstract the Canvas, then again we run into issues with lifetimes.

Do not try and bend the spoon

Suppose we sidestep the lifetime checker by using handles. Theoretically you should now be worried about managing the lifetime of that handle, but you know that’s never going to happen, because in practice it’s simply not that big of a problem.

It’s even hard to imagine how a game code could possibly fail in trying to render an invalid texture in the first place. You load a texture, and then you render it. That’s all there is to it. The texture remains valid for either the lifetime of the complete program, or maybe for the current level. If you don’t get this right then your game won’t work, and you’ll have to fix it anyway. You don’t need the compiler to tell you that.

If you have a triple-A game with a streaming texture engine, then you will already have a mechanism in place for tracking the validity of the texture in memory. This SDL Texture lifetime problem seems like a very computer science-y exercise that is just not realistic in real gamedev. Which brings us to an existentional question:

Is Rust fit for gamedev programming at all?

Rust is a systems language with a focus on error checking and memory safety. It’s awfully nice that the game won’t crash (well, no guarantees there) and that the code is robust. This is extremely important for any software. At the same time, games are a class apart. Game code tends to be heavy on pointers and all sorts of clever hacks because games are high performance codes. Dealing with a loading error? Then the game won’t run. Yes of course there should be a message about that to the user. But being forced to deal with an error that happens deep in the SDL render code seems a bit much. A construct that is very good for systems programming feels out of place in gamedev.

Jonathan Blow (game developer, superstar programmer) didn’t like C++ very much, and recognized that the programming language of choice should fit the problem. In this case, the problem of developing games.

“Games are machines that fill memory.”

Jonathan Blow

Game code has special needs. Game developers crave certain features to make them be more productive. Lifetimes are not one of them. They want the compiler to get out of their face, and just build the executable as instructed. That type of game developer thoroughly understands how computers work, and knows exactly what they need to do to achieve the goal of writing a high performance game engine. Often that means ignoring textbook teachings and pulling off some hackish wizardry.

And so he created the language Jai. Jai features things like data-oriented structures (important for CPU cache lines), semi-invisible memory allocator contexts, a special syntax for easy code refactoring, runtime type information (important for implementing a live debugger), and a hacker’s dream: running code while compiling, as some kind of alternative to macros and templates.

It’s pretty wild, but since it’s a one-man’s project it’s yet to really take off (even after many years in development). But Jai is looking very, very interesting, and potentially could take the world of gamedev by storm.

Meanwhile droves of people already converted to Rust.

Blue pill, red pill

The lifetime problem of Textures in SDL2 have made me seriously doubt the viability of Rust as a game programming language, and I’m sure I’m not alone.

We could go back to C++ (never!) or drop SDL2 in favor of something else, like raylib or SFML.

This is your last chance. After this, there is no turning back. You take the blue pill, the story ends, you wake up in your bed and believe whatever you want to believe. Take the red pill, you stay in Wonderland, and I show you how deep the rabbit hole goes. Remember: all I’m offering is the truth. Nothing more.

Morpheus

The truth is that if we are going to write any good programs in Rust at all (be it games or whatever), we’re going to run into lifetimes sooner or later anyway. Sidestepping is not a solution.

Kansas is going bye-bye

Before going into a short mental crisis and doing some introspection I said above that I would show how to fix it.

We wish to implement a TextureAtlas that holds a Texture. Our TextureAtlas “auto-inherits” the lifetime of the Texture, and will have to live for at least as long as the Texture, which itself lives at least as long as the associated TextureCreator<WindowContext>.

struct TextureAtlas<'a> {
    texture: Texture<'a>,
    cell_width: u32,
    cell_height: u32,
}

[By the way, choosing a &Texture reference type here may have seemed better at a glance. Keep in mind though that Texture already holds a pointer. There is little sense in double indirection].

Next we annotate the implementation, which is valid for as long as the struct is valid. We have a function that loads and creates a TextureAtlas, and returns a newly allocated one. That function must also be annotated. The Texture instance will be created by a TextureCreator that we will pass in. We need to be clear about the lifetime of that TextureCreator.

impl<'a> TextureAtlas<'a> {

    fn load(filename: &str,
        cell_width: u32,
        cell_height: u32,
        creator: &'a TextureCreator<WindowContext>,
    ) -> TextureAtlas<'a> {

        let texture = creator
            .load_texture(filename)
            .unwrap_or_else(|filename|
            panic!("failed to load {}", &filename));

        TextureAtlas {
            texture,
            cell_width,
            cell_height
        }
    }
}

Next we wish to have a Sprite class struct that has a reference to the TextureAtlas. Why? Because I like my data model to be that way. You don’t have to do it this way, but this is how I structured my implementation of my API.

Having a reference inside a struct implies having to deal with lifetimes. Moreover, our TextureAtlas already had a lifetime annotation on it, which now also infects the Sprite. Anyway, let’s deal with it.

struct Sprite<'a> {
    atlas: &'a TextureAtlas<'a>;
    nr: u32,
}

Next we annotate the implementation of Sprite:

impl<'a> Sprite<'a> {
    fn new(atlas: &'a TextureAtlas, nr: u32) -> Self {
        Sprite { atlas, nr }
    }
}

I hope by now you can start to make out a pattern of how this works. Or rather, try not to treat it as a merely a pattern, but understand how the annotations mark things that belong together, because they share the same lifetime.

Declaring the types and annotating the implementations was the hard part. Using them is easy, no different than usual:

let atlas = TextureAtlas::load_texture("assets/sprites.png",
    64, 64, &texture_creator);
let sprite1 = Sprite::new(&atlas, 70);

Next we can go and render the sprite on a canvas. Something like this:

fn render_sprite(canvas: &mut WindowCanvas,
    sprite: &sprite,
    xpos: i32, ypos: i32) {

    let sprite_rect = Rect::new(
        // TODO calculate x, y for sprite.nr
        0, 0,
        sprite.atlas.cell_width,
        sprite.atlas.cell_height,
    );

    let dest_rect = Rect::new(
        xpos, ypos,
        sprite.atlas.cell_width,
        sprite.atlas.cell_height,
    );

    canvas
        .copy(&sprite.atlas.texture,
            sprite_rect,
            dest_rect)
        .expect("I expected SDL_rendercopy() \
                 not to fail, really");
}

… and have a taste of victory. We have rendered a sprite!

Slight criticism on the Rust-SDL2 API here; Rect is not the same as an SDL_Rect; again it is a wrapper. Therefore you can’t do something like

let src_rect = Rect {0, 0, 64, 64};

which would have been nice, idiomatic Rust.

Another thing is that canvas.copy() takes two Rects by value, and not by reference. By contrast, the SDL_RenderCopy() C function takes pointers to the rectangles. Also note how canvas.copy() takes a reference to a Texture. A case of double indirection..?

Where we go from there is a choice I leave to you

Concluding I can say the Rust-SDL2 API feels quirky, or is it just that Rust is quirky in itself. Maybe I know the C API a bit too well. Having to deal with lifetimes was an unexpected setback. I can totally understand people leaving Rust-SDL2 for this reason, but they should be aware that in order to become a good Rust programmer you are going to have to learn how to handle these situations. Coding in Rust without ever touching lifetimes is like programming in C without pointers. Maybe you can do it, but it just isn’t right—you’re not using the language to its fullest potential.

If all you want is to easily create a game, then look elsewhere. Tools like Unity and Godot are for making games in scripting languages. SDL and Rust-SDL2 are for programming game engines the hard-core way.

To be continued …