Skip to main content

Engine Architecture

We have all the low level mechanics we need to build an engine. We can draw arbitrary meshes, and send data like buffers and textures to the shaders. What we need now is to make a proper architecture for drawing objects, and stop going with the hardcoded structures on the Engine structure.

The architecture here is meant to mimic GLTF a bit, as in the next chapter we will be loading whole scenes dynamically from GLTF files. We will create our base GLTF pipelines following what GLTF PBR spec gives us, and our first mesh objects.

The architecture will be based around 2 levels of structures. On one side, we will have the Render_Object structure.

Render_Object :: struct {
index_count: u32,
first_index: u32,
index_buffer: vk.Buffer,
material: ^Material_Instance,
transform: la.Matrix4f32,
vertex_buffer_address: vk.DeviceAddress,
}

This structure is a completely flattened abstraction of the parameters we need for a single vk.CmdDrawIndexed call. We have the structures needed for the indexing of the mesh, then a Material_Instance pointer, which will point to the vk.Pipeline and vk.DescriptorSet for a given material. After that, we have the matrix of the object for 3d rendering, and its vertex buffer pointer. Those 2 will go into push-constants as they are per-object dynamic data.

This structure will be written dynamically every frame, and the renderer logic will go through an array of these Render_Object structures and directly record the draw commands.

The Material_Instance struct looks like this.

Material_Pipeline :: struct {
pipeline: vk.Pipeline,
layout: vk.PipelineLayout,
}

Material_Instance :: struct {
pipeline: ^Material_Pipeline,
material_set: vk.DescriptorSet,
pass_type: Material_Pass,
}

For the material system, we are going to hardcode into 2 pipelines, GLTF PBR Opaque, and GLTF PBR Transparent. They all use the same pair of vertex and fragment shader. We will be using 2 descriptor sets only. Slot 0 will be our "global" descriptor set, which gets bound once, and then used for all draws, and will contain the global data such as camera and environment information. Later we will also add things like lights into it. The slot 1 will be a per-material descriptor set, and it will bind textures and material parameters. We will directly mirror gltf, and have the textures the PBR GLTF material demands, plus a uniform buffer with the color constants such as object color. the GLTF PBR material allows textures to not be set, but in those cases we will be binding the default white or default black texture, depending on what we need there. The Material_Instance struct also has a Material_Pass enum which lets us separate between a opaque render object and a transparent one.

The reason why we have exclusively 2 pipelines is because we want to keep pipeline amount to the absolute minimum. If we have less pipelines, we can preload them at startup, and it makes the renderer much faster, specially once we start doing bindless and draw-indirect logic. Our goal is that we will have a small amount of pipelines for each material type we have, such as GLTF PBR material. The number of pipelines a engine needs to use directly affects performance. A engine like the Doom Eternal one has ~200 total pipelines for the game, while unreal engine projects often end up at 100.000+ pipelines, and compiling that many pipelines causes lots of stutters, uses lots of space, and prevents advanced render functionality like draw-indirect.

Those Render_Object are very low level, so we need a way to write them. We will use a scene-graph for this. This way we can have a hierarchy where some meshes are children of other meshes, and we have empty non-mesh scenenodes too. This is typical on engines to be able to build levels.

The type of scene-graph we will have is a medium/low performance design (we will improve this later), but with the bonus of being very dynamic and easy to extend. Its also still fast enough to render tens of thousands of objects.

Renderable :: struct {
draw: proc(self: ^Renderable, top_matrix: la.matrix4x4f, ctx: ^Draw_Context),
}

We have a Renderable "interface" that defines a single draw() procedure. This takes a matrix to use as parent, and a Draw_Context. The render context is just an array of render objects for now. The idea is that when the draw() procedure is called, the object will insert the renderables into the list to be drawn this frame. This is commonly known as a immediate design, and the big win it has is that we can draw the same object multiple times per frame with different matrices to duplicate the object, or decide that one frame we dont draw it by just skipping calling draw() according to some logic. This approach is great for dynamic objects as resource management and lifetime is simplified a lot, and its also easy to write. The downside is that we are going through the different objects calling a procedure to draw things every frame, which adds up at higher object counts.

The Node object will derive from Renderable, and will have a local transform matrix + an array of child nodes. When draw() is called on it, it calls draw() on its children

We then have a Mesh_Node object, that derives from Node. It holds the draw resources needed, and when draw() is called on it, it builds the Render_Object and adds it to the Draw_Context for drawing.

When we add other drawing types, such as lights, it will still work the same. We will hold a list of lights on the Draw_Context, and a Light_Node will add its parameters to it if the light is enabled. Same with other things we might want to draw such as a terrain, particles, etc.

A trick we will be doing too is that once we add GLTF, we will also have a Loaded_GLTF object as a Renderable (not a Node). this will hold the entire state and all the resources like textures and meshes of a given GLTF file, and when draw() is called it will draw the contents of the GLTF. Having a similar structure for OBJ and other formats will be useful.

Loading the GLTF itself will be done next chapter, but we will ready the mechanics of the Render_Object's and gltf material now.