Blitz.Engine: Asset System



Before we understand how the asset system of the Blitz.Engine engine works , we need to decide what asset is and what exactly we mean by asset system. According to Wikipedia, a game asset is a digital object, mainly consisting of the same data, an indivisible entity that represents part of the game content and has certain properties. From the point of view of the program model, an asset can appear as an object created on some data set. Assets can be stored as a separate file. In turn, an asset system is a lot of program code responsible for loading and operating assets of various types.

In fact, an asset system is a large part of the game engine, which can become a loyal assistant for game developers or turn their lives into hell. In my opinion, the logical decision was to concentrate this “hell” in one place, carefully protecting other team developers from it. We will tell you about what we did in this series of articles - let's go!

Planned articles on the topic:

  • Statement of requirements and architecture overview
  • Asset Life Cycle
  • Detailed AssetManager class overview
  • Integration in ECS
  • GlobalAssetCache

Requirements and Reasons


Asset loading system requirements were born between a rock and a hard place. An anvil was the desire to do something enclosed in itself so that it would work without writing external code. Well, or almost without writing external code. The hammer became reality. And here's what we ended up with:

  1. Automatic memory management , which means there is no need to call the release function for the asset. That is, as soon as all external objects using the asset are destroyed, the asset is destroyed. The motivation here is simple - write less code. Less code means fewer errors.
  2. , ( AssetManager’a). , . — . , «» .
    , , (). — , . , , . , , . , , .
  3. . : . , .
  4. (shared) . , . , . «» , .
  5. Prioritize asset loading . There are only 3 priority levels: High, Medium, Low. Within the same priority, assets are loaded in the order of the request. Imagine a situation: a player clicks “To battle”, and the loading of the level begins. Along with this, the task of preparing the sprite of the loading screen falls into the download queue. But since some of the level assets got into the queue before the sprite, the player looks at the black screen for quite some time.

In addition, we formulated a simple rule for ourselves: "Everything that can be done on the AssetManager thread must be done on the AssetManager thread." For example, preparing a partition of the landscape and texture of normals based on a height map, linking a GPU program, etc.

Some implementation details


Before we begin to understand how the asset loading system works, we need to familiarize ourselves with two classes that are widely used in the Blitz.Engine engine:

  • Type: runtime information about some type. This type is similar to the type Typefrom the C # language, with the exception that it does not provide access to the fields and methods of the type. Contains: type name, a number of signs like is_floating, is_pointer, is_const, etc. The method Type::instance<T>returns a constant within one application launch const Type*, which allows you to do checks of the formif (type == Type::instance<T>())
  • Any: allows you to package the value of any movable or copyable type. The knowledge of what type is packaged is Anystored as const Type*. Anyknows how to calculate a hash according to its contents, and also knows how to compare contents for equality. Along the way, it allows you to make conversions from the current type to another. This is a kind of rethinking of the any class from the standard library or boost library.

All assets list loading system is based on three classes: AssetManager, AssetBase, IAssetSerializer. However, before proceeding to the description of these classes, it must be said that the external code uses an alias Asset<T>that is declared like this:

Asset = std::shared_ptr<T>

where T is an AssetBase or a specific type of asset. Using shared_ptr everywhere, we achieve the fulfillment of requirement number 1 (Automatic memory management).

AssetManager- This is a finite class that does not have heirs. This class defines the life cycle of an asset and sends out messages about changes in the state of an asset. It also AssetManagerstores a dependency tree between assets and an asset binding to files on disk, listens FileWatcherand implements an asset reload. And most importantly, it AssetManagerlaunches a separate thread, implements a task queue for preparing the asset, and encapsulates all synchronization with other application threads (the asset request can be executed from any application thread, including the download stream).

At the same time AssetManageroperates with an abstract assetAssetBase, delegating the task of creating and loading an asset of a specific type to the heir from IAssetSerializer. I will tell you more about how this happens in subsequent articles.

As part of requirement number 4 (Asset sharing), one of the hottest questions was “what to use as an asset identifier?” The simplest and seemingly obvious solution would be to use the path to the file to be downloaded. However, this decision imposes a number of serious limitations:

  1. To create an asset, the latter must be represented as a file on disk, which removes the ability to create runtime assets based on other assets.
  2. . , GPUProgram (defines). , .
  3. , .
  4. .

We did not consider paragraphs 3 and 4 as an argument at the very beginning, since there was not even a thought that this might come in handy. However, these features subsequently greatly facilitated the development of the editor.

Thus, we decided to use the asset key as an identifier, which at the level AssetManageris represented by the type Any. The Anyheir knows how to interpret IAssetSerializer. Itself AssetManagerknows only the relationship between the type of key and the heir IAssetSerializer. The code that requests an asset usually knows what type of asset it needs and operates with a key of a specific type. It all goes something like this:


class Texture: public AssetBase
{
public:
    struct PathKey
    {
        FilePath path;
        size_t hash() const;
        bool operator==(const PathKey& other);
    };

    struct MemoryKey
    {
        u32 width = 1;
        u32 height = 1;
        u32 level_count = 1;
        TextureFormat format = RBGA8;
        TextureType type = TEX_2D;
        Vector<Vector<u8*>> data; // Face<MipLevels<Image>>

        size_t hash() const;
        bool operator==(const MemoryKey& other);
    };
};

class TextureSerializer: public IAssetSerializer
{
};

class AssetManager final
{
public:
    template<typename T>
    Asset<T> get_asset(const Any& key, ...);
    Asset<AssetBase> get_asset(const Any& key, ...);
};

int main()
{
   ...
   Texture::PathKey key("/path_to_asset");
   Asset<Texture> asset = asset_manager->get_asset<Texture>(key);
   ...

   Texture::MemoryKey mem_key;
   mem_key.width = 128;
   mem_key.format = 128;
   mem_key.level_count = 1;
   mem_key.format = A8;
   mem_key.type = TEX_2D;
   Vector<u8*>& mip_chain = mem_key.data.emplace_back();
   mip_chain.push_back(generage_sdf_font());
   
   Asset<Texture> sdf_font_texture = asset_manager->get_asset<Texture>(mem_key);
};

The method hashand comparison operator inside PathKeyare needed for the functioning of the corresponding class operations Any, but we will not dwell on this in detail.

So, what happens in the code above: at the time of the call, the get_asset(key)key will be copied to a temporary object of the type Any, which, in turn, will be passed to the method get_asset. Next, AssetManagertake the type of key from the argument. In our case, it will be:

Type::instance<MyAsset::PathKey>

By this type, he will find the serializer object and delegate to the serializer all subsequent operations (creation and loading).

AssetBase- This is the base class for all types of assets in the engine. This class stores the asset key, the current state of the asset (loaded, queued, etc.), as well as the error text if the asset loading failed. In fact, the internal structure is a little more complicated, but we will consider this together with the asset life cycle.

IAssetSerializer, as the name implies, is the base class for the entity that is preparing the asset. In fact, the heir to this class is not only loading the asset:

  • Allocation and deallocation of an asset object of a specific type.
  • Loading an asset of a specific type.
  • Compiling a list of file paths on the basis of which the asset is built. This list is needed for the asset reload mechanism when a file changes. The question arises: why the list of paths, and not one path? Simple assets, like textures, can really be built on the basis of a single file. However, if we look at the shader, we will see that the reboot should occur not only if the shader text is changed, but also if the file connected to the shader is changed via the include directive.
  • Saving asset to disk. It is actively used both when editing assets and in preparing assets for the game.
  • Reports the types of keys that it supports.

And the last question I want to cover in the framework of this article: why might you need to have several types of keys for one serializer / asset? Let's sort it out in turn.

One serializer - several types of keys


Let's take an example of an asset GPUProgram(that is, a shader). In order to load a shader in our engine, the following information is required:

  1. The path to the shader file.
  2. List of preprocessor definitions.
  3. The stage for which the shader is assembled and compiled (vertex, fragment, compute).
  4. The name of the entry point.

Gathering this information together, we get the shader key, which is used in the game. However, during the development of a game or engine, it is often necessary to display some debugging information on the screen, sometimes with a specific shader. And in this situation it can be convenient to write the text of the shader directly in the code. To do this, we can get the second type of key, which instead of the path to the file and the list of preprocessor definitions will contain the text of the shader.

Consider another example: texture. The easiest way to create a texture is to load it from disk. To do this, we need the path to the file ( PathKey). But we can also generate the contents of the texture algorithmically and create a texture from an array of bytes ( MemoryKey). The third type of key can be a key to create a RenderTargettexture ( RTKey).

Depending on the type of key, various glyph rasterization engines can be used: stb (StbFontKey), FreeType (FTFontKet) or a self-signed signed distance field font generator (SDFFontKey).

Keyframe animation can be loaded ( PathKey) or generated by code ( MemoryKey).

One asset - several types of keys


Imagine that we have an ParticleEffectasset that describes the rules for particle generation. In addition, we have a convenient editor for this asset. At the same time, the level editor and particle editor are one multi-window application. This is convenient because you can open a level, place a particle source in it and look at the effect in the environment of the level, while editing the effect itself. If we have one type of key, then the effect object that is used in the world of effect editing and in the level world is one and the same. All changes made in the effect editor will immediately be visible in the level. At first glance, this might seem like a cool idea, but let's look at the following scenarios:

  1. , , , . , .
  2. - , . , .

In addition, a situation is possible in which we create two different types of assets from one file on a disk using two different types of keys. Using the “game” type of key, we create a data structure optimized for quick work in the game. Using the "editorial" type of key, we create a data structure that is convenient for editing. In about this way, our editor implements editing BlendTreefor skeletal animations. Based on one type of key, the asset system builds us an asset with an honest tree inside and a bunch of signals about changing topology, which is very convenient when editing, but rather slowly in the game. Using a different type of key, the serializer creates another type of asset: the asset has no methods for changing the tree, and the tree itself is turned into an array of nodes, where the link to the node is an index in the array.

Epilogue


Summing up, I would like to concentrate your attention on the solutions that have most of all influenced the further development of the engine:

  1. Using a custom structure as an asset key, not a file path.
  2. Asset loading only in asynchronous mode.
  3. A flexible scheme for managing asset sharing (one asset - several types of keys).
  4. The ability to receive assets of the same type using different data sources (support for several types of keys in one serializer).

You will learn how exactly these decisions influenced the implementation of both the internal code and the external one in the next series.

Author:Exmix

All Articles