Build a Game Engine

ScriptSystem - Passing Objects

Prerequisites

Article

Welcome to Part II of our ScriptSystem! In the last article, we covered loading scripts and libraries and starting up our system. In this article, we'll concentrate on tracking C++ and C# objects and converting between Mono and UVariant types. In the next article, we'll pull it all together and write a bunch of wrapper functions and a fully functioning script.

Our ScriptSystem is responsible for interfacing between what we call "local" C++ and "remote" C# objects. When we get write a C++ callback function that will be called from C#, all objects - including the one the function is being called on - will be passed as MonoObjects. We'll need a way to find the C++ object we're operating on.

In the last article, we saw two std::map properties and two functions that help us do this:

    /**
     * Get or create an object relationship from Mono to local
     */
    UScriptObject* GetRemoteObject(std::string className, UObject* localObject);

    /**
     * Gets a local object, given a MonoObject
     */
    UScriptObject* GetLocalObject(void* remoteObject);

    ...

protected:
    // Mono objects
    std::map<UObject*, UMonoObject*> m_monoObjects;
    std::map<MonoObject*, UMonoObject*> m_localObjects;

The first function (GetRemoteObject) helps us find or create a C# object given a C++ UObject. It will use the m_monoObjects map from UObjects to UMonoObjects:

UScriptObject* UMonoScriptSystem::GetRemoteObject(std::string className, UObject* localObject) {
    // Check cache
    auto it = m_monoObjects.find(localObject);
    if (it != m_monoObjects.end()) {
        return(it->second);
    }

    // Otherwise, create an object, start by finding class type
    auto ci = m_monoClasses.find(className);
    UASSERT(ci != m_monoClasses.end(), "Unable to find class type.");

    // Create a new object
    MonoObject* newMonoObject = mono_object_new(m_monoDomain, ci->second);

    // Create a new relationship between local and remote
    UMonoObject* monoObject = new UMonoObject();
    monoObject->localObject = localObject;
    monoObject->remoteObject = newMonoObject;

    // Store in both directions
    m_monoObjects[localObject] = monoObject;
    m_localObjects[newMonoObject] = monoObject;

    // Call mono constructor
    mono_runtime_object_init(newMonoObject);

    return(monoObject);
}

This function takes two parameters: the first is a string C# class name, and the second is an existing UObject (pretty much any object in our engine). The first thing it does is check the cache - does a UMonoObject already exist for this UObject? If so, just return that one. If not, it tries to find the specified class name in our list of classes loaded from libraries so far.

Given the class, we can create a new MonoObject pointer by calling mono_object_new with the domain we want to create it in and the class type we want to create. We then save all of that in a new UMonoObject pointer and save it in both of our caches (local to remote and remote to local).

The last step is to call mono_runtime_object_init, passing in our new MonoObject pointer. This function calls the constructor on the C# object we created. Finally, we return the new UMonoObject.

Now let's look at the inverse, getting a local C++ object given a C# object, which is shorter:

UScriptObject* UMonoScriptSystem::GetLocalObject(void* object) {
    // Convert
    MonoObject* obj = (MonoObject*)object;

    // Check cache
    auto it = m_localObjects.find(obj);
    if (it != m_localObjects.end()) {
        return(it->second);
    }

    // Otherwise, need to create a new object - do we want to do this?
    return(0);
}

This function simply casts the object to a MonoObject and checks our cache. If it doesn't exist, it simply returns zero. Now, as you can see by my comment, it currently does not create a new C++ object. That's because all of the objects we call from C# generally already exist in C++ and we're just trying to retrieve them. And the objects we do create are typically done explicitly - components, entities, and others are created through functions, not by calling new directly. I've been writing quite a few scripts and haven't needed to do this yet.

The other functions we need to round out are those for converting types. We want to be able to convert from UVariant to C# types and vice versa. We've got three functions to do that:

    /**
     * Convert a MonoObject to a Variant
     */
    UVariant MonoObjectToVariant(MonoObject* object);

    /**
     * Convert a variant to a Mono object
     */
    MonoObject* VariantToMonoObject(UVariant variant, std::string classHint = "");

    /**
     * Handle array case
     */
    MonoArray* VariantListToMonoArray(std::vector<UVariant> arr, std::string classHint = "");

The first converts a MonoObject to a UVariant, aptly named MonoObjectToVariant. Both this and the opposite function do the same thing, largely iterating over types and doing comparisons. Let's take a look:

UVariant UMonoScriptSystem::MonoObjectToVariant(MonoObject* object) {
    MonoClass* _class = mono_object_get_class(object);

    if (_class == m_cachedTypes[UVariant::VAR_BOOL]) {
        bool value = *(bool*)mono_object_unbox(object);
        return(value);
    }

    if (_class == m_cachedTypes[UVariant::VAR_INT32]) {
        int32_t value = *(int32_t*)mono_object_unbox(object);
        return(value);
    }

    if (_class == m_cachedTypes[UVariant::VAR_INT64]) {
        int64_t value = *(int64_t*)mono_object_unbox(object);
        return(value);
    }

    if (_class == m_cachedTypes[UVariant::VAR_UINT32]) {
        uint32_t value = *(uint32_t*)mono_object_unbox(object);
        return(value);
    }

    if (_class == m_cachedTypes[UVariant::VAR_UINT64]) {
        uint64_t value = *(uint64_t*)mono_object_unbox(object);
        return(value);
    }

    if (_class == m_cachedTypes[UVariant::VAR_UINT64]) {
        double value = *(double*)mono_object_unbox(object);
        return((float)value);
    }

    if (_class == m_cachedTypes[UVariant::VAR_STRING]) {
        MonoString* str = (MonoString*)object;
        char* value = mono_string_to_utf8(str);
        return(value);
    }

    if (_class == m_cachedTypes[UVariant::VAR_VECTOR2]) {
        float x, y;

        MonoClassField* field = mono_class_get_field_from_name(_class, "x");
        mono_field_get_value(object, field, &x);

        field = mono_class_get_field_from_name(_class, "y");
        mono_field_get_value(object, field, &y);

        return(vector2(x, y));
    }

    if (_class == m_cachedTypes[UVariant::VAR_VECTOR3]) {
        float x, y, z;

        MonoClassField* field = mono_class_get_field_from_name(_class, "x");
        mono_field_get_value(object, field, &x);

        field = mono_class_get_field_from_name(_class, "y");
        mono_field_get_value(object, field, &y);

        field = mono_class_get_field_from_name(_class, "z");
        mono_field_get_value(object, field, &z);

        return(vector3(x, y, z));
    }

    if (_class == m_cachedTypes[UVariant::VAR_VECTOR4] || _class == m_cachedTypes[UVariant::VAR_QUATERNION]) {
        float x, y, z, w;

        MonoClassField* field = mono_class_get_field_from_name(_class, "x");
        mono_field_get_value(object, field, &x);

        field = mono_class_get_field_from_name(_class, "y");
        mono_field_get_value(object, field, &y);

        field = mono_class_get_field_from_name(_class, "z");
        mono_field_get_value(object, field, &z);

        field = mono_class_get_field_from_name(_class, "w");
        mono_field_get_value(object, field, &w);

        if (_class == m_cachedTypes[UVariant::VAR_VECTOR4])
            return(vector4(x, y, z, w));

        return(quaternion(w, x, y, z));
    }

    UASSERT(false, "Unable to determine variant type.");
    return(0);
}

Given an object, the first thing we do is get the class by calling mono_object_get_class. In this function, we're comparing against the cached types that we set up in our Start function in the last article. The basic types are straightforward: integers, bool, float, etc. Strings are slightly more interesting - we convert those to a character pointer by calling mono_string_to_utf8. Vectors and quaternions are where it gets interesting... for these types, we actually read into the variables in the object and get their values using the mono_class_get_field_from_name and mono_field_get_value functions to read the X, Y, Z, and possibly the W components. We then create the vector or quaternion and return it into our UVariant.

Now let's look at the inverse function, converting UVariants to MonoObjects:

MonoObject* UMonoScriptSystem::VariantToMonoObject(UVariant var, std::string classHint) {
    MonoObject* mobj = 0;

    if (var.IsInt()) {
        MonoClass* cl = m_cachedTypes[UVariant::VAR_INT32];
        int iv = var.AsInt();
        mobj = mono_value_box(mono_domain_get(), cl, &iv);
    }

    if (var.IsBool()) {
        MonoClass* cl = m_cachedTypes[UVariant::VAR_BOOL];
        bool bv = var.AsBool();
        mobj = mono_value_box(mono_domain_get(), cl, &bv);
    }

    if (var.IsUInt()) {
        MonoClass* cl = m_cachedTypes[UVariant::VAR_UINT32];
        unsigned int iv = var.AsUInt();
        mobj = mono_value_box(mono_domain_get(), cl, &iv);
    }

    if (var.IsFloat()) {
        MonoClass* cl = m_cachedTypes[UVariant::VAR_FLOAT];
        double dv = var.AsFloat();
        mobj = mono_value_box(mono_domain_get(), cl, &dv);
    }

    if (var.IsString()) {
        mobj = (MonoObject*)mono_string_new(mono_domain_get(), var.AsString().c_str());
    }

    if (var.IsObject()) {
        UMonoObject* monoObject = (UMonoObject*)this->GetRemoteObject(classHint, var.AsObject());
        mobj = monoObject->remoteObject;
    }

    if (var.IsVector2()) {
        MonoClass* cl = m_cachedTypes[UVariant::VAR_VECTOR2];
        mobj = mono_object_new(mono_domain_get(), cl);
        mono_runtime_object_init(mobj);
        vector2 vec = var.AsVector2();

        MonoClassField* field = mono_class_get_field_from_name(cl, "x");
        mono_field_set_value(mobj, field, &vec.x);

        field = mono_class_get_field_from_name(cl, "y");
        mono_field_set_value(mobj, field, &vec.y);
    }

    if (var.IsVector3()) {
        MonoClass* cl = m_cachedTypes[UVariant::VAR_VECTOR3];
        mobj = mono_object_new(mono_domain_get(), cl);
        mono_runtime_object_init(mobj);
        vector3 vec = var.AsVector3();

        MonoClassField* field = mono_class_get_field_from_name(cl, "x");
        mono_field_set_value(mobj, field, &vec.x);

        field = mono_class_get_field_from_name(cl, "y");
        mono_field_set_value(mobj, field, &vec.y);

        field = mono_class_get_field_from_name(cl, "z");
        mono_field_set_value(mobj, field, &vec.z);
    }

    if (var.IsVector4()) {
        MonoClass* cl = m_cachedTypes[UVariant::VAR_VECTOR4];
        mobj = mono_object_new(mono_domain_get(), cl);
        mono_runtime_object_init(mobj);
        vector4 vec = var.AsVector4();

        MonoClassField* field = mono_class_get_field_from_name(cl, "x");
        mono_field_set_value(mobj, field, &vec.x);

        field = mono_class_get_field_from_name(cl, "y");
        mono_field_set_value(mobj, field, &vec.y);

        field = mono_class_get_field_from_name(cl, "z");
        mono_field_set_value(mobj, field, &vec.z);

        mono_class_get_field_from_name(cl, "w");
        mono_field_set_value(mobj, field, &vec.w);
    }

    if (var.IsQuaternion()) {
        MonoClass* cl = m_cachedTypes[UVariant::VAR_QUATERNION];
        mobj = mono_object_new(mono_domain_get(), cl);
        mono_runtime_object_init(mobj);
        quaternion quat = var.AsQuaternion();

        MonoClassField* field = mono_class_get_field_from_name(cl, "x");
        mono_field_set_value(mobj, field, &quat.x);

        mono_class_get_field_from_name(cl, "y");
        mono_field_set_value(mobj, field, &quat.y);

        mono_class_get_field_from_name(cl, "z");
        mono_field_set_value(mobj, field, &quat.z);

        mono_class_get_field_from_name(cl, "w");
        mono_field_set_value(mobj, field, &quat.w);
    }

    return(mobj);
}

Here we do exactly the inverse of what we did in in the previous function. We check the UVariant type and convert it to one of our cached types. We do use a new function here called mono_value_box. As the name may imply, this function "boxes" up a value into a MonoObject. We do this for all of the basic plain old data types.

Strings are created using a function called mono_string_new, which returns a MonoString pointer, which can be downcasted into a MonoObject pointer. If this is an object, we call our GetRemoteObject function to find out UMonoObject for our UObject and return the remote object.

Vectors and quaternions are the inverse of what we saw before - this time, we will create a new MonoObject, call its constructor by calling mono_runtime_object_init, and we still call mono_class_get_field_from_name, but this time we call mono_field_set_value to set the value of those variables instead of getting them.

Finally, we have one more function that will return an array of MonoObjects to our C# environment. This takes a std::vector of UVariants and returns a MonoArray:

MonoArray* UMonoScriptSystem::VariantListToMonoArray(std::vector<UVariant> arr, std::string classHint) {
    // Get class image
    auto it = m_monoClasses.find(classHint);
    UASSERT(it != m_monoClasses.end(), "Cannot find class type.");

    MonoArray* monoarr = mono_array_new(m_monoDomain, it->second, arr.size());
    for (int i = 0; i < arr.size(); i++) {
        std::string clname = arr[i].AsObject()->UType()->className;
        mono_array_setref(monoarr, i, VariantToMonoObject(arr[i], clname));
    }

    return(monoarr);
}

Given a class type from the class hint, we create a new array by calling mono_array_new. Then we simply iterate through our vector and call our VariantToMonoObject function to convert them. We use the mono_array_setref function to set the variables at different indexes to the returned MonoObject.

Conclusion

That's it! We now have all of the pieces in place to write some scripts! We can write C# classes, load them into our C++ engine, and differentiate "scripts" as classes that inherit from UMonoScript. We can attach those scripts to objects using ScriptComponents and our ScriptSystem will call the Update and FixedUpdate C# functions, passing objects back and forth between C++ and C#.

Let's pull it all together in our next article and write some helper functions and a real script.