Posted in: Crystal Engine, RTTI

Custom C++ Reflection & Serialization in Crystal Engine

Reflection is the ability of a program to introspect itself. On surface, it might sound like an unimportant feature, but in the context of game engines and game programming, it is an extremely useful feature.

Reflection enables easy serialization of objects to store them on disk, or just modifying an object’s properties without knowing it’s type at compile-time. And C++ does not fully support reflection natively, although it does have some useful metaprogramming features which are limited to compile-time.

This was one of the first things I worked on in Crystal Engine. In this article, I will walk you through how the C++ reflection system works in Crystal Engine and how to use it. Currently, the following features are supported by the engine’s reflection system:

  • Reflect Classes, Structs, Enum Types and a few “opaque types”.
  • Classes can reflect fields and methods.
  • Structs can reflect only fields.
  • Fields can be given custom attributes to customize their behaviour in editor.
  • Automatic RTTI generation using AutoRTTI tool.
  • Call member functions dynamically through reflection.
  • Edit member fields dynamically.
  • And more…

Here is how you would reflect your classes:

// Material.h file
#pragma once

namespace CE
{
    STRUCT()
    struct ENGINE_API MaterialProperty
    {
        CE_STRUCT(MaterialProperty)
    public:

        FIELD()
        Name name{};

        FIELD()
        MaterialPropertyType propertyType = MaterialPropertyType::None;

        FIELD(EditAnywhere, DisplayName = "UInt Value", ShowIf = ".propertyType == UInt")
        u32 u32Value = 0;

        FIELD(EditAnywhere, DisplayName = "Int Value", ShowIf = ".propertyType == Int")
        s32 s32Value = 0;

        FIELD(EditAnywhere, ShowIf = ".propertyType == Float")
        f32 floatValue = 0;

        FIELD(EditAnywhere, ShowIf = ".propertyType == Vector")
        Vec4 vectorValue = {};

        FIELD(EditAnywhere, ShowIf = ".propertyType == Color")
        Color colorValue = {};

        FIELD()
        Matrix4x4 matrixValue = {};

        FIELD(EditAnywhere, ShowIf = ".propertyType == Texture")
        MaterialTextureValue textureValue = {};
    };

    CLASS()
    class ENGINE_API Material : public MaterialInterface
    {
        CE_CLASS(Material, MaterialInterface)
    public:

        Material();
        ~Material();

        // ... Other methods here ...
        
        FUNCTION()
        void SomeFunction(const String& someString);

    private:

        RPI::Material* material = nullptr;

        FIELD(EditAnywhere, Category = "Shader", CategoryOrder = 0)
        Ref<CE::Shader> shader = nullptr;

        FIELD(EditAnywhere, Category = "Properties", CategoryOrder = 1, ArrayEditorMode = "Static", ArrayElementName = ".name [{}]")
        Array<MaterialProperty> properties{};

        bool valuesModified = true;
        ShaderCollection* shaderCollection = nullptr;

        HashMap<Name, MaterialProperty> propertyMap{};

        friend class MaterialInstance;
    };

} // namespace CE

// Below name should be same as header file name.
#include "Material.rtti.h"

The key thing is to make sure that if the header file name is “Material.h”, then there should be an include for “Material.rtti.h” at the bottom of file. Otherwise AutoRTTI tool will ignore this header.

You can have as many reflected classes, structs and enums in a single header file as you like. The attributes are specified inside the macros like CLASS(), FUNCTION(), FIELD(), etc. Also, there are some special attributes:

  • EditAnywhere: This marks the field such that it is visible and editable in property editors.
  • ArrayEditorMode: Default or Static. If you choose static mode, the user cannot insert, delete or move array elements in the editor. This is useful here because the material properties array is created in code, and we only want the user to edit existing properties.
  • ArrayElementName: “.name [{}]”. This lets you apply custom display text formatting for each individual array element. The “.name” part means — it will be replaced with the “properties[i].name” for each array element. And the “{}” curly braces is replaced by the element index.
  • ShowIf: This is an advanced attribute that makes the property visible in editor only if a sibling property matches the specified value. Currently, this is static i.e. it will only be checked once when property editor is first created.

This is how it looks in the editor:

Reflection in C++

Now let’s talk about using the reflection system in C++ code. There are multiple ways to get a class type:

// 1. Static method 
ClassType* staticClass = MyClass::StaticClass();
// OR
ClassType* staticClass = GetStaticClass<MyClass>();

// 2. Dynamic method
Ref<Object> someUnknownObject = ...;
ClassType* dynamicClass = someUnknownObject->GetClass();

And once you have a ClassType object, you can introspect a lot of things about the class. It includes things like retrieving base class’s ClassType, iterating through the fields, functions, etc.

Below is an advanced example involving member method reflection and ScriptEvents.

CLASS()
class MyClass : public Object
{
    CE_CLASS(MyClass, Object)
public:
    MyClass() = default;
    
    FUNCTION()
    int SomeFunction(const String& text);
    
    FUNCTION()
    void SomeFunction2(const String& text);
    
    void FindExample()
    {
        FunctionType* function = GetClass()->FindFunctionWithName("SomeFunction");
        Variant returnVal = function->Invoke(this, { "textValue" });
        int result = returnVal.GetValue<int>();
    }
    
    void BindExample()
    {
        // Events can only use void as return type!
        sampleEvent.Bind(FUNCTION_BINDING(this, SomeFunction2));
        
        // Lambdas are supported too!
        sampleEvent.Bind([](const String& text) -> void
        {
            // Do something...
        });
        
        // This will call SomeFunction2() and the lambda above!
        sampleEvent.Broadcast("text value");
    }
    
private:

    // YES! Not only you can reflect ScriptEvent, but they are serialized to disk too!
    // Except for the lambdas that is. Only FUNCTION_BINDING()'s can be serialized.
    FIELD()
    ScriptEvent<void(const String&)> sampleEvent;
  
};

#include "MyClass.rtti.h"

And there is still much more that the reflection system in Crystal Engine offers! If you’d like to dive deeper into it, I’d recommend checking out the open source repo here:

https://github.com/neilmewada/CrystalEngine

Leave a Reply

Your email address will not be published. Required fields are marked *