Monomorphization x dyn x piston x 🤯

I've been learning Rust and Piston at the same time.

For people unaware, Piston is a modular game engine written in Rust.
How modular?

  • You can choose between Vulkan, OpenGl, Metal and DirectX for rendering
  • You can choose between Glutin, GLFW or SDL2 to create windows and handle input

It does all that while proposing a single interface for all, that completely abstract which backend and window system you are using.

At compile time, thanks to the power of monomorphization, you end up with one single implementation being chosen and compiled.

Coming from the java world, I thought it would be handy to create a list of Renderable, so a trait that provides a render function, so I can do something like this:

for renderable in renderables.iter() {
    renderable.render();
}

For example, I could have a renderer to draw my map, a renderer to draw the hud, a renderer to draw the inventory or any tooltip window...

Well, we are talking about piston, so when you need to render something, you need to pass in a context, graphics, device...

So I created something like that:

pub trait Renderable {
    fn position(&self) -> (i32, i32);
    fn size(&self) -> (i32, i32);
    fn render<'a, C, G>(&self, render_context: &mut RenderContext<'a, C, G>)
    where
        C: CharacterCache,
        G: Graphics<Texture = <C as CharacterCache>::Texture>;
}

pub struct RenderContext<'a, C, G>
where
    C: CharacterCache,
    G: Graphics<Texture = <C as CharacterCache>::Texture>,
{
    pub grid_size: u32,
    pub character_cache: &'a mut C,
    pub context: Context,
    pub graphics: &'a mut G,
}

(The RenderContext is a way to pass all the needed bit to my renderable in the render method, instead of making the method super long. That might be a mistake too. I should probably ditch it.)

Each rendering backend propose it's own implementation of the Graphics trait, and that gets monomorphized at compile time to use the one backend you selected, the window you selected.

Then, I wrote a few implementation of the trait for a few of the things I wanted to render, and sure enough, it worked well. I could call on my hud a hud.render(context);, map.render(context); and so on.

And then I thought "let's create a RenderList".

pub struct RenderList {
    pub renderables: Vec<Box<dyn Renderable>>,
}

And of course, it doesn't compile. I get

31 |     pub renderables: Vec<Box<dyn Renderable>>,
   |                      ^^^^^^^^^^^^^^^^^^^^^^^^ the trait `renderer::Renderable` cannot be made into an object
   |
   = help: consider moving `render` to another trait

and okay, fair enough, it's not possible like that.

So I got a new idea: Let's make my RenderList itself monomorphizable!
So I adjusted my Renderable trait and my RenderList like this:

pub trait Renderable<C, G>
where
    C: CharacterCache,
    G: Graphics<Texture = <C as CharacterCache>::Texture>,
{
    fn position(&self) -> (i32, i32);
    fn size(&self) -> (i32, i32);
    fn render(&self, render_context: &mut RenderContext<C, G>);
}

pub struct RenderList<C, G>
where
    C: CharacterCache,
    G: Graphics<Texture = <C as CharacterCache>::Texture>,
{
    pub renderables: Vec<Box<dyn Renderable<C, G>>>,
}

Then, when implementing the trait, I write this:

struct SomethingToDisplay {}

impl<C, G> Renderable<C, G> for SomethingToDisplay
where
    C: CharacterCache,
    G: Graphics<Texture = <C as CharacterCache>::Texture>,
{
    fn position(&self) -> (i32, i32) {
        todo!()
    }

    fn size(&self) -> (i32, i32) {
        todo!()
    }
    fn render(&self, render_context: &mut RenderContext<C, G>) {
        todo!()
    }
}

And it all works, compile, I can add any Renderable in my vector.

But. A big, BUT.

Isn't it a bit insane? Am I not falling into anti-patterns, because I'm so used to objects that I want to fit objects in my world views?

Would someone have suggestion for a saner solution, to have a render list with piston and rust?

I'm looking forward to your suggestions :pray:

1 Like

Have you tried adding a bound Self:Sized?

trait Renderable {
    fn position(&self) -> (i32, i32);
    fn size(&self) -> (i32, i32);
    fn render<'a, C, G>(&self, render_context: &mut RenderContext<'a, C, G>)
    where
        Self : Sized,
        C: CharacterCache,
        G: Graphics<Texture = <C as CharacterCache>::Texture>;
}

I checked this in the playground and it compiles. Of course I am assuming that your actual types implementing Renderable are Sized, but that is typically the case.

I am finding it odd that the compiler does not make mention of the Sized trait. I am sure I have seen a message other times.

1 Like

You made my day.

Without Sized, I get the following error:

error[E0038]: the trait `renderer::Renderable` cannot be made into an object
  --> src\renderer.rs:31:18
   |
10 | pub trait Renderable {
   |           ---------- this trait cannot be made into an object...
...
13 |     fn render<'a, C, G>(&self, render_context: &mut RenderContext<'a, C, G>)
   |        ------ ...because method `render` has generic type parameters
...
31 |     renderables: Vec<Box<dyn Renderable>>,
   |                  ^^^^^^^^^^^^^^^^^^^^^^^^ the trait `renderer::Renderable` cannot be made into an object
   |
   = help: consider moving `render` to another trait

With Sized, it compiles nicely.

I'll admit though, I don't really understand what happens there :stuck_out_tongue:

Erratum, it works until it doesn't :stuck_out_tongue:

So I have my RenderList like so:

pub struct RenderList {
    pub renderables: Vec<Box<dyn Renderable>>,
}

And this compile fine, since the Renderable is Sized now.

But later, I write this:

for renderable in render_list.renderables.iter() {
    renderable.render::<G, C>(render_context);
}

and then it will not compile, with the following error:

error: the `render` method cannot be invoked on a trait object
   --> src\pistonengine.rs:371:24
    |
371 |             renderable.render(render_context);
    |                        ^^^^^^
    | 
   ::: src\renderer.rs:17:15
    |
17  |         Self: Sized,
    |               ----- this has a `Sized` requirement

error: aborting due to previous error; 1 warning emitted

Which makes sense I guess, the dyn renderer doesn't provide enough data to the compiler to know what exactly is the renderable.

One thing to think about with this use of dynamic dispatch is that it will be very hard for the CPU to predict what code to execute next during the render loop, to the extent that it needs to read potentially a different memory address and wait for the result for every renderable, and the instruction and data caches will be of no help.

If you can at least sort/group your renderables by concrete type that would help, or if you can find a design where you constrain the types of rendering (to eg sprites or meshes or composites of those)—so you can e.g. draw opaque objects front to back and then draw translucent objects back to front.

Basically if you can avoid this kind of runtime-determined dispatch in a very tight loop like this you should try to do so. Otherwise you’ll have a “death by a thousand cuts” performance wise: no render implementation is slow, but rendering takes too long (because it spends too much time waiting to hear back from RAM).

2 Likes

I think the issue comes from: https://github.com/rust-lang/rfcs/blob/master/text/0255-object-safety.md

Almost surely you can make it work by adding additional T:Sized bounds. Each time you call thing.render(ctx) you need have that bound on the type of thing.

If you care about performance then you should take what @JosephOsborn says into account, and rethink the architecture. For example, if supporting just a few kinds of context, render could receive just an enum.

3 Likes

If the enum is exhaustive, I’d even suggest sorting renderables by enum or having e.g. three vecs of specialized renderables if your enum has three values. We really want the computer to do the same thing over and over again as much as possible with a minimum of branching. It also might help with debuggability since you have more ways to slice and dice the stuff being presented and fewer paths through the code.

1 Like

To be honest, I was really thinking in terms of "high level" Renderable.

The Hud, the map, the inventory window...
I agree that having small Renderable unit dynamically dispatched at every single frame would be a disaster.

1 Like

Sorry for my faulty assumption! I’ve just seen (and written!) the disastrous version so many times (:

1 Like

I'm pretty sure I did too in Java :smiley:
My day job is Android Developer, and I tried OpenGl ES on Android, written in Java.
The performances were underwhelming at best :smiley:

Your approach seems very sane to me.

1 Like

FWIW, you don't have to copy what piston does here: if you are happy to pick the graphics backend at compile time, then you can simply create a type alias for the chosen backend and use that everywhere instead of making everything be generic.

eg.

#[cfg(feature = "opengl")]
type Context = OpenGlContext;

etc..

Then you can select the back-end by passing in different feature flags when compiling.

1 Like

I think you are right.

The only reason i kept the code agnostic was because I also wanted to use another backend, graphics_buffer, as a way to dump screenshot, as can seen here: https://github.com/redwarp/ambergris/blob/0f466ef1dc49d071a4861f264cd56622ba2e0381/src/pistonengine.rs#L343

But I should probably trash that, and find a better way to get a dump of the content of the window (I haven't found another solution yet)