Advertisement

C++ how do you work with generic programming?

Started by March 18, 2024 12:00 AM
4 comments, last by frob 9 months ago

I'm working on an asset manager for my game engine and I'm still new to C++ so I'm not super interested in doing anything fancy and optimization and all that really just trying to get something that works, however, what I currently have just doesn't sit right with me so although it does work I don't think it's right to leave it as is there's just a bit to more duplication/repetition going on.

// Assets
struct Mesh {};
struct Texture {};
struct Shader {};
struct Material {};
struct Audio {};

// Asset metadata (filepath, asset instance, modified time)
struct MeshAsset {};
struct TextureAsset {};
struct ShaderAsset {};
struct MaterialAsset {};
struct AudioAsset {};

// Asset containers
std::unordered_map<unsigned int, MeshAsset> meshes;
std::unordered_map<unsigned int, MeshAsset> textures;
std::unordered_map<unsigned int, ShaderAsset> shaders;
std::unordered_map<unsigned int, MaterialAsset> materials;
std::unordered_map<unsigned int, AudioAsset> audios;

// Functions to get and load assets
unsigned int LoadMesh() {}
MeshAsset GetMesh(unsigned int id) {}
MeshAsset GetMesh(const std::string& filepath) {}

unsigned int LoadTexture() {}
TextureAsset GetTexture(unsigned int id) {}
TextureAsset GetTexture(const std::string& filepath) {}

unsigned int LoadShader() {}
ShaderAsset GetShader(unsigned int id) {}
ShaderAsset GetShader(const std::string& filepath) {}

unsigned int LoadMaterial() {}
MaterialAsset GetMaterial(unsigned int id) {}
MaterialAsset GetMaterial(const std::string& filepath) {}

unsigned int LoadAudio() {}
AudioAsset GetAudio(unsigned int id) {}
AudioAsset GetAudio(const std::string& filepath) {}

Ideally I'd like to be able to shorten this code to something like this.

// Asset Instance
struct Mesh {};
struct Texture {};
struct Shader {};
struct Material {};
struct Audio {};

// Asset metadata
struct MeshAsset {};
struct TextureAsset {};
struct ShaderAsset {};
struct MaterialAsset {};
struct AudioAsset {};

// Asset containers
std::unordered_map<unsigned int, /* ??? */> assets;

// Functions to get and load assets
unsigned int LoadMesh() {}
unsigned int LoadTexture() {}
unsigned int LoadShader() {}
unsigned int LoadMaterial() {}
unsigned int LoadAudio() {}

/* ??? */ GetAsset(unsigned int id) {}
/* ??? */ GetAsset(const std::string& filepath) {}

I believe templates and/or inheritance comes in handy for this although it's not apparent how to apply those concepts to refactor this code.

The assets are not generic, they live in different places and do different things with different interfaces. You can't dynamically swap out between shaders and meshes and audio, they are not what is meant.

Look at the standard library containers as a better example. They can store a vector or dynamic array, or a list of objects and those DO fit a uniform interface. You can have iterators that move forward or back on any collection of objects. You can have tuples and pairs of objects that behave interchangeably with first and second regardless of the things in the tuple.

Recognize where interfaces are uniform and where they are not, that's a big skill. Recognizing where objects are truly substitutible versus aggregates is a big skill, since usually aggregation is preferred. Recognizing when you can separate form from function, where the algorithm interfaces are independent of the data they operate on, that's a key to reuse.

There are abstractions you can make and algorithmic interferences you can develop. Learning from existing systems can help you find elements that fit the model, and often help you find reasons others don't abstract well. “A thing that exists in the simulated world” works well, “a visual representation of a thing in the world” works well, and they can be independent. A thing in the simulated world might not have any visual representation, or it might be able to switch between a collection of visual representations, and visual representations might be sharable between multiple things in the world or not depending on the details.

Advertisement

Despite what Frob says, it can be very useful to be able to manipulate assets in a common container format that works for any type of asset. Here is a basic outline of how this could work. The two main concepts at work are “type erasure”, and template specialization.

typedef unsigned int TypeID; // Could also be UUID, string, etc
typedef unsigned int AssetID;

template < typename T >
TypeID getTypeID() = delete;

template <>
TypeID getTypeID<Mesh>()
{
    static TypeID MESH_TYPE_ID = ....;
    return MESH_TYPE_ID;
}
template <>
TypeID getTypeID<Material>()
{
    static TypeID MATERIAL_TYPE_ID = ....;
    return MATERIAL_TYPE_ID;
}

struct AssetStoreTypeBase
{
    virtual ~AssetStoreTypeBase() {}
    // virtual functions for accessing assets generically (without knowing concrete type)
};

template < typename T >
struct AssetStoreType : public AssetStoreTypeBase
{
    std::unordered_map<AssetID,T> assets;
};

struct AssetStore
{
    template < typename T >
    AssetStoreType<T>* getType() const
    {
        auto i = types.find(getTypeID<T>(););
        if ( i == types.end() )
            return nullptr;
        
        // Downcast, should be safe. Could use dynamic_cast<>() if paranoid.
        return (AssetStoreType<T>*)(*i);
    }
    std::unordered_map<TypeID,shared_ptr<AssetStoreTypeBase>> types;
};

Here, everything is stored in an AssetStore, and you use getType<T>() functions to get the assets of a given type in a clean way. There can be other functions that lookup the asset given the AssetID and type, or that create assets of a certain type. The getTypeID<T>() function provides the TypeID for a given template type, or emits compiler error if a wrong type is used.

You can extend this design much further so that loading for all asset types can be done through a common interface. Read this recent post for a description of how this could look.

You need several architectural decisions based on your specific needs:

  • What data and what processing are specific of one asset type (e.g. combining palettes with images) or common to all asset types (e.g. figuring out when the asset is ready)? The latter can be part of template classes, functions etc. that don't care about asset types, the former have to be in a specific class or in a template specialization.
  • Where do you draw the boundary between an asset managed by the asset manager (e.g. an image inside an archive) and data your game engine deals with (e.g. a GPU texture buffer)? What processing should take place on each side of the asset manager API?
  • What aspects of the asset manager can be associated with a single asset type (a good fit for straightforward templates, such as a specialized “loader” or a collection of all known assets of a certain type) and what aspects need to consider all assets of all types, requiring something more complex like Aressera's suggested AssetStore?

Omae Wa Mou Shindeiru

Despite what Frob says, it can be very useful to be able to manipulate assets in a common container format that works for any type of asset.

This is why I was careful about what I wrote.

You can have algorithms and containers that do common tasks, but they must be carefully designed to forms that have a uniform interface.

In the methods I have written about for decades with the abstract store / cache / proxy / loader model for resources, the pieces must be composed in a way that the items have an interchangeable interface. Critically the algorithms for working with the interfaces are separated from the content of the resource. The proxy object is not a texture itself or model itself or a sound clip itself, instead it is a uniform proxy that hides whatever internal objects are represented.

The model is used in all the major game engines with a variety of named, you are not working with resources directly but instead with proxies. The concrete object may be silently swapped out like a model swapped for LOD or even for a billboard, or unloaded completely when out of the world or culled. The proxy objects are also independent of the content, some things must be in video memory, some things must be in audio cards or streamed, those details are not part of the interface.

This topic is closed to new replies.

Advertisement