Lately, I have decided to take a deep look at luminance. Issues, current code, frustration I had using it, feedback from people using it, etc. I realized two things:

  1. The first commit of luminance was made in 2016, as I migrated it away from its Haskell version, which first commit was made in 2015. Since then, my knowledge of programming, both Haskell and Rust, but also in general has enhanced quite a lot.
  2. The current design has some flaws, both in terms of usability and maintainability.

About the first point, I had a look at code that barely changed since I wrote it in 2016, and I’m not completely satisfied with it anymore. Lots of things happened ever since: I have a sharper vision of how things should be done, Rust itself has changed quite a lot (back in 2016, Rust 1.0 was just released!), we have more type system features that, back then, I was lacking, etc.

About the second point, some issues in the current version of luminance (v0.47 as writing this blog article) are complex to solve, such as the Drop problem. Other parts of the API are insanely complex, like the TessBuilder related code.

I want all that to change, so I started working on a completely new graphics crate, just to see what kind of interface I could come up with. And I came up with something much, much easier. So I faced a dilemma: should I release that other crate and give up on luminance… or should I just port all the new knowledge and ideas from that crate into luminance?

Well, I have decided to keep luminance around and update it. It is going to take time, because luminance is an ecosystem of many crates, it has lots of examples (which is great to ensure that its features are still working correctly once I start redesign it) and because some features are obviously entangled. However, I think it’s worth it, because even though luminance is not as famous or widely used as wgpu, it has a special place in the Rust Graphics community.

So I’m going to blog about the redesign of luminance and it’s going to be a blog article series. I wanted to start with a very new exciting feature that I have been wanting for a very long time: compatible vertex types.

The vertex compatibility problem

When you’re like me and are obsessed with tracking as much information as possible at compile-time, you start imagining lots of features and ways to prevent people from doing things. Preventing and forbidding is actually much more complex than allowing something. Think about it like this: assembly allows for everything and is not a complex language. It has zero constructs, besides opcodes. However, as you add more constructs and abstractions, those abstractions restricts the application domain; they forbid usage, in a constrained way. There’s a reason why, in Haskell, we use the term Contraint — which is called, basically, trait bound in Rust.

The way luminance allows a programmer to render objects, which must have a vertex type V that must implement Vertex. luminance-derive eases the process of implementing lots of traits from luminance. For instance:

#[repr(C)]
#[derive(Clone, Debug, Vertex)] // < notice the Vertex derive here
pub struct MyVertex {
  position: Vector3<f32>,
  normal: Vector3<f32>,
  color: Vector3<u8>,
  weight: Vector3<f32>,
}

This is great, because the user just has to focus on writing types and just using them. For vertex objects, which can be imagined as big arrays / buffers of MyVertex here, there are types and functions allowing to basically render such object, and those render functions expects MyVertex. However, rendering is a multi-stage process. As you may know, the graphics pipeline contains different steps, and among the first ones, you have a vertex shader, which is responsible in mapping vertices (to transform them, for instance). In luminance, vertex shaders are strongly-typed with the kind of vertices they expect. That is to prevent a user from using a vertex shader that expects some vertex attributes that a vertex object doesn’t have. Imagine that we write a vertex shader that expects a position and a normal but our vertex object, which uses OtherVertex vertices, only has position. That would result in probably some visual glitches or just a black screen.

So, we want to have something like this pseudocode:

type VertexShader<V>; // accept only vertex type V
type RenderVertexObjects<W>; // render only vertex type W

If we want to have a fully typed graphics pipeline, we must have V = W. However, that is going to be very annoying. Imagine a vertex shader that only needs position, normal and color. Something like:

#[repr(C)]
#[derive(Clone, Debug, Vertex)]
pub struct ShaderVertexInput {
  position: Vector3<f32>,
  normal: Vector3<f32>,
  color: Vector3<u8>,
}

This is a different type than MyVertex, so if we try to render our MyVertex vertex objects with a vertex shader that works on ShaderVertexInput, it will get a type mismatch at compile-time. The only way to fix that is to create a copy of the shader program that works with MyVertex and ignore the weight field. Meh. Waste of resources and duplication. Everything we don’t want.

When you look at it, the vertex shader is going to use position, normal and color, but it doesn’t have to know there are other vertex attributes. So it should be able, in theory, to work with MyVertex, since MyVertex has position, normal and color

The solution

One solution to fix the problem is to introduce a trait, CompatibleVertex, that we will use on the vertex shader code. The vertex shader still has its ShaderVertexInput type, but it will not require vertex objects to use that type. Instead, it will require vertex objects to use V and will require ShaderVertexInput: CompatibleVertex<V>. What it means is that ShaderVertexInput must be included in V. Said otherwise, V must have all of ShaderVertexInput’s fields (but it can have more).

pub trait CompatibleVertex<V> {}

Now the big question: how do we implement CompatibleVertex for a given concrete vertex type? Well, in the previous version of luminance, that used to be implemented manually by users of luminance. It was both unsafe and not really practical, so I just removed the concept (and luminance in version v0.47 and less is subject to the problem I described above, where you can render a vertex object which vertex type is not compatible with what the shader expects).

Const generics to the rescue

Since rustc-1.51, const generics can be used at compile-time. The feature allows to manipulate integers at compile-time. For instance:

pub struct Array<const N: usize> {
  // …
}

Here, N is a const generics, and you use it with types like Array<3> or Array<14>. However, that’s currently (rust-1.63) all you can do with a stable compiler… but we can do much more with a nightly compiler.

There is a feature called adt_const_params that allows using more types, and one type that is very important is &'static str. Yes, you heard right. Constant strings. We can write something like this:

#![allow(incomplete_features)] // as of rustc-1.65 nightly, this is still required
#![feature(adt_const_params)]

fn hello<const N: &'static str>() {
  println!("Hello {}!", N);
}

fn main() {
  hello::<"world">();
}

That doesn’t seem like much, but this small change is going to save so many frustration in luminance, and fix our compatible vertex problems.

Applying adt_const_params to CompatibleVertex

What we basically want for one vertex type to compatible with another (i.e. one is included into the other) is to ensure that the all the fields of one are present in the other. Let’s recall our vertex type definitions and trait:

#[repr(C)]
#[derive(Clone, Debug, Vertex)]
pub struct MyVertex {
  position: Vector3<f32>,
  normal: Vector3<f32>,
  color: Vector3<u8>,
  weight: Vector3<f32>,
}

#[repr(C)]
#[derive(Clone, Debug, Vertex)]
pub struct ShaderVertexInput {
  position: Vector3<f32>,
  normal: Vector3<f32>,
  color: Vector3<u8>,
}

pub trait CompatibleVertex<V> {}

We can write a HasField trait like this:

pub trait HasField<const NAME: &'static str> {
  type FieldType;
}

And now, we can implement that trait for all the fields of our vertex types:

// for MyVertex
impl HasField<"position"> for MyVertex {
  type FieldType = Vector3<f32>;
}

impl HasField<"normal"> for MyVertex {
  type FieldType = Vector3<f32>;
}

impl HasField<"color"> for MyVertex {
  type FieldType = Vector3<u8>;
}

impl HasField<"weight"> for MyVertex {
  type FieldType = f32;
}

// for ShaderInputVertex
impl HasField<"position"> for ShaderInputVertex {
  type FieldType = Vector3<f32>;
}

impl HasField<"normal"> for ShaderInputVertex {
  type FieldType = Vector3<f32>;
}

impl HasField<"color"> for ShaderInputVertex {
  type FieldType = Vector3<u8>;
}

Now, we can write a more meaningful implementor for CompatibleVertex and ShaderInputVertex:

impl<V> CompatibleVertex<V> for ShaderInputVertex where
  V: HasField<"position", FieldType = Vector3<f32>>
    + HasField<"normal", FieldType = Vector3<f32>>
    + HasField<"color", FieldType = Vector3<u8>>
{
}

Because MyVertex implements the three HasField trait bounds required, it can be used, as well as ShaderInputVertex.

Obviously, because luminance-derive is a thing, people will never have to write any of those implementors. Actually, deriving Vertex will do two things (besides what it already does) for a given vertex type:

What it means is that, as soon as you start using #[derive(Vertex)], you will automatically have compatible vertex types behind the scenes working for you.

Conclusion

That feature is already, by itself, worth requiring a nightly compiler, as it brings the exact kind of type safety I want for my code. And since I started with const generics strings, the next blog article will be about the redesign of vertex objects (called Tess in the current version of luminance), since, you might have guessed, it will use const generics strings to upload vertex data per-attribute, and map / update them.

Thanks for having read, and keep the vibe!


↑ luminance redesign; Part 1: compatible vertex types
luminance, redesign
Tue Sep 6 11:30:00 2022 UTC