In my third year at IGAD we had to do big group projects that could last for a whole year. Me and some class mates decided on building a fully functioning game engine. The first iteration was dubbed Moonshine. I was part of implementing the asset pipeline and helped implement the graphics module. In the second iteration we changed the name to Hyphen.
We needed a robust and reliable way of loading and managing our assets. Yet, it had to be easy to use. I was responsible for implementing and managing these systems.
Loading and Managing
The first problem I wanted to tackle was finding the asset files on the hard drive. We could not use absolute paths as they would not be the same for every machine. So, we needed a way of finding the asset files. I decided that we would have a folder called “assets”. This folder would, relatively, always be in the same directory. This way, no matter where the project was located I could find the files and load them.
Users will call the LoadAssetFromFile function, using the relative asset path as an argument. The system will concatenate the relative path to the known absolute asset folder. Using the file’s extension, a file type can be determined, which in turn we can use to find a suitable asset importer.
If no error occurs during this process, the asset is added to the corresponding asset list in the resource manager. The user can then get the resource handle by calling the correct Get function, GetTextureResource, GetMeshResource, GetAudioResource, etc. These handles can then be used to tell the engine what resources you wish to use.
These resources are actually a 20-byte SHA1 hash. The hashes are used to find the correct asset in the corresponding hash map. Generally, the user does not have to worry about handling the actual resource data. Most of the APIs accept resources when needed and translating/error checking will be handled internally.
When the user is done with the resource the correct Unload function can be called. This will delete the data from the resource manager and invalidate all outstanding resources handles.
We wanted our engine to be able to do preprocessing on our assets. For example we could generate mip map chains or flip triangle winding orders in meshes if needed. Doing this every time we run the game would be huge waste of time. Preferably we want to do it once and store the result. I decided I would create an asset preprocessor tool that will find assets in the asset directory and process them if necessary.
Processed assets will be outputted into a new directory, which I named “library”. This folder will be in the same directory as the asset folder and will contain all processed assets. However, processed files will have a different filename and/or extension. For example, textures will be converted to DDS format and “myHighResTexture.png” will become “myHighResTexture.dds”. Finding assets by file name was done via hashing the relative file name, hashing “myHighResTexture.png” will not result in the same hash as hashing “myHighResTexture.DDS”. I also wanted to keep this easy to use for the user, forcing the user to use the processed file name could potentially cause confusion and bugs. I solved all these issues by creating an Asset Library file. This file will hold an entry for all assets that were processed. The hashed relative path of the uncooked asset will be used as a key in a key value pair. The second part of the key value pair being a struct of information about the cooked asset. Currently I store the relative path of the cooked asset and the asset type, but we could store anything we want in this file. When a user loads an asset we hash the relative uncooked asset path and look up the path to the cooked asset. We then concatenate this relative path with our known absolute base path and load the file. We can find the correct file importer using the stored asset type.
We wanted the cooking and loading of assets to be done in acceptable times, keeping iteration times low. Memory footprints should be kept as small as possible, both for RAM and hard drive. We picked file formats that would satisfy these conditions.
We currently have no need for preprocessing on sound files. Sound files are copied to the library folder as is.
The DDS, Direct Drawable Surface, file format is highly suited for use in graphics. The way the data is stored is exactly how it is stored on modern graphics cards. It allows for storing mip map chains and compresses blocks of pixels. Different versions exist, all having a different use and compression rate. Different versions of DDS compressions are called BC1, BC2, BC3, etc.
Figure 1: Different BCn encodings
For now we do not have a way of specifying compression formats per asset, instead we will default to BC3, this is sub optimal but can be used for both textures and normal maps. The DirectX Tex library has functionality for converting to DDS format.
Meshes can have a lot of issues, triangle winding order can be flipped, vertex tangents could be missing, etc. We want to avoid these issues by fixing them when we import the mesh data. Assimp has import options for all these issues. We tell Assimp to:
- Calculate tangents
- Merge identical vertices
- Convert to a left handed coordinate system
- Generate normals
- Validate data
- Improve cache locality
- Remove degenerate triangles
- Remove duplicate mesh entries
- Optimize the meshes
- Optimize the scene graph
We then use Assimp to export the file to their binary format, assbin. When we import the assbin file again we parse their aiScene struct and convert to our own structs and formats.
I was responsible for the first iteration of the graphics pipeline and helped expand it in the second iteration of the engine.
Stateless rendering aims to reduce the amount of API calls and their overhead. This is done by preventing as much state changes as possible. The name “Stateless Rendering” comes from the idea that state changes in the API affect all subsequent draw calls, not just one.
We achieve this by recording a list of draw commands first. This list is than submitted to the API where it is translated into API calls. We can sort these draw commands based on the resources that they use. For example, we would want to submit all draw commands that render mesh A in subsequent order. After we are done we move on to all draw commands that render Mesh B. This way we only have to bind Mesh A and/or B once for all the draw commands that use these meshes, saving us API calls. We will have to keep track of the current state of the pipeline to determine if we have to bind new resources but this is trivial.
Sorting can be done based on mesh, materials, textures, shaders, etc. We use a 64 bit integers as sorting keys. Sorting the draw command list from highest to lowest. This means that setting higher bits will cause the draw command to appear earlier in the list. In Moonshine we have divided the key into 7 parts:
- Phase : 3 bits
- Layer : 3 bits
- Reserved : 4 bits
- Mesh: 16 bits
- Material: 12 bits
- Shader: 10 bits
- Sort: 16 bits
All resources will carry an unique ID. We submit the current Phase, Layer, Sort method, and resource IDs to the API to have it generate a sort key for us. Generation of sorting key is API specific, different graphics APIs suffer different overheads for different API calls. This means that you want to prioritize sorting for different things for different APIs.
We now can keep track of our current state and only do API calls when we absolutely need to.
Figure 2: Profiling Results
Figure 3: Comparing sorted to unsorted
Figure 4: Sorting on Mesh over Materials