Adventures in the Absurd

A blog about programming, languages and Rust. Mostly Rust.

Graphics Engine From Scratch - Pt 1

Computer graphics has always been an interesting topic for me. The large amout of theory and maths required to render complex, detailed scenes at interactive framerates tickles a particular part of my brain. Fortunately for me, I’m doing a computer graphics course at university starting in a couple months.

However I doubt they’ll be using Rust in the course and I wouldn’t mind getting a bit of a headstart anyway. I’ve also thought about writing a graphics on and off frequently, but end up getting overloaded by the volume of information out there. So my goal here is to try to build a 3D graphics engine, in Rust, from scratch.

Keeping it Simple & Stupid

The gap between “textured triangle” and AAA-esque graphics is so absurdly large that it seems impossible to get from one to the other. To achieve the absurd level of graphical fidelity at framerates higher than making stop-motion, AAA games use a ton of advanced tools and techniques that help them squeeze every last drop out of your poor graphics card. Hell, even that isn’t enough, as Vulkan is largely driven by a desire to be able to better manage the resources handled by OpenGL or Direct3D.

It’s natural, for me at least, to want to do stuff “properly” right off the bat. I want to play with the big kid’s toys and use the same techniques as the AAA studios. However, those techniques are implemented in mature codebases by people that are experts in their fields, so I get overwhelmed and give up in despair. So this time, I’m going to do it differently.

Unlimited Power!

The main thing I’m going to do differently is lock up the voice inside my head that counts CPU cycles and work as if I have all the computing power in the world. The point being to write code that works, and to focus on organisation instead of performance. These systems are just too big to be able to care about performance at a high level.

Once performance does become a concern, I’ll already have code to work with. If the code is unworkable, I’ll scrap it and start again with my hard-earned knowledge. The goal here isn’t necessarily to build a usable library for the community, it’s a learning experience for me. If I end up with something I think others could use, awesome! If not, nevermind.

The Initial Structure

Before I even start coding, I want to figure out what the structure should be. Remembering to keep it simple, it seems to me that the easist way forward is this:

  1. Get all the objects to be drawn this frame.
  2. Draw all the objects.
  3. Swap buffers.
  4. GOTO 1

Ok, seems simple enough, probably a little too simple. Feels like it’s hiding some complexity in there, I’d like to dig that out if I can. Since swapping the buffers is pretty simple, and “getting the objects” is really just “use the list of objects” at this stage, the complexity is somewhere in step 2.

How do we draw an object? Sure, we can call a draw method on it, or pass it to some draw function, but how does that work? There’s clearly a few parts needed to make this work, we need:

  1. A 3D representation of the object, i.e. a mesh.
  2. Where that object is in space.
  3. What the object looks like.
  4. Where the camera is in space.
  5. What lights are around.

For the sake of simplicity, let’s ignore the lights and camera. The camera will be fixed for now, and we’ll ignore lighting. That leaves representation, position and appearance for each object.

Representation

As already mentioned, the representation is a mesh, but how does that work? Well we could use a list of triangles, each one containing three points, but that seems wasteful, most of the time each vertex is shared with multiple triangles. So how about a set of points and then a list of triangles that reference those points by index instead? That’s an index list. So our Object should look like this right now:

struct Object {
    vertices: Vec<Vertex>,
    indices: Vec<u32>,
}

So far, so good. We’ll also define Vertex:

struct Vertex {
    point: Vec3,
}

Position & Orientation

Assuming we want multiple objects in our scene, we should probably have a way of positioning them in space. Rotating them around is good too. I hear matrices are good for this, but let’s keep it a little more abstract for the moment and just assume we have a Transform type floating around.

struct Object {
    vertices: Vec<Vertex>,
    indices: Vec<u32>,
    transform: Transform,
}

Appearance

This is probably the most complex one, and also the one that needs the most care when thinking about. Being stupidly simple is all well and good, but I don’t want to code myself into a corner before I’ve even started. I know I’ll definitely need a texture for the object, no matter what, so I’ll definitely need to have texture coordinates for each vertex. I hear you can have separate attribute lists, but having the texture coordinate be part of the Vertex seems easier:

struct Vertex {
    point: Vec3,
    tex_coord: Vec2
}

The other thing is shaders. The fixed-function pipeline is well-and-truly obsolete now, so using the programmable pipeline makes the most sense. The key decision here is how to organise it: do objects hold the shaders used to render them, or is there one shader to “rule them all” as it were? Looking forward for a moment, I know that switching shaders can be slow, I also know that rendering often takes multiple passes, in the long run, I think that a central “shader store” is a good direction to go, so the simpler option right is probably to have a single shader for all objects.

We still need some object-specific information though, at least the object’s texture. We’ll use a separate Material type for that information though.

struct Object {
    vertices: Vec<Vertex>,
    indices: Vec<u32>,
    transform: Transform,
    material: Material,
}

struct Material {
    texture: Texture
}

Putting it Together

Putting all that together, our draw_frame function should look something like this:

fn draw_frame(objects: &[Object]) {
    for obj in objects {
        draw_object(&global_shader, obj);
    }
}

With the draw_object function doing the actual work of converting the data in an Object to something OpenGL can use. On that note, the next part will be about OpenGL.