C++中嵌入C#作脚本引擎(一)
C++中嵌入C#作脚本引擎(二)

编辑器内的工作流

实现思路

上一篇讨论的内容都是在运行时的,其工作流是:引擎从文件加载场景,在Mono Runtime中创建脚本实例,创建时调用OnCreate方法,之后每帧运行时调用OnUpdate方法,脚本实例销毁时调用OnDestroy方法。

而在编辑器中,情况没有那么简单。在编辑器中,有编辑和运行两个模式,当我们在编辑场景而没有运行场景时,为一个Entity添加脚本,我们并不希望OnCreate方法直接调用,而是在点击"Play"按钮后调用,并开始运行脚本。但在编辑模式中,要能够对Entity上已挂载的脚本的属性进行修改,在运行时,脚本内的对应属性就应该是修改过的版本。由此产生了两种设计思路。

第一种:在编辑模式下,为Entity添加脚本,实际并不在Mono Runtime中创建脚本实例,而是由Mono Runtime中加载的脚本类类型获取反射信息,并用C++的自定义数据结构来存储数据,在编辑器中修改的也是该数据。点击"Play"按钮后,Mono Runtime中创建脚本实例,并根据自定义数据结构存储的数据,给实例的属性赋值,此时编辑器内修改的是实际的脚本实例的数据。这种设计的优点在于只用维护一个Mono Domain;但是缺点也很显著:在编辑模式下没有脚本实例,无法实现一些编辑器特有的脚本功能;还有一个很大的问题在于重加载,运行模式下重新加载Assembly会直接破坏当前状态。

第二种:在Mono Runtime中维护两个Domain,每个Domain独立加载Assembly,数据也是独立的。在编辑模式下,添加的脚本在Editor Domain实例化,编辑器修改的已经是Mono Runtime里的数据;点击"Play"按钮后,将Editor Domain的数据“拷贝”一份到新创建的Runtime Domain(可以通过序列化实现),在Runtime Domain中创建一样的脚本实例,并调用OnCreate等方法开始运行,运行结束后,Runtime Domain销毁。这种设计解决了第一种方案的缺陷,其难点在于两个Domain状态的管理。想要实现像Unity那样灵活的脚本控制,必须通过第二种方法,本文正要介绍这种方法的实现思路。

实现细节

首先,要维护两个Mono Domain,每个Domain的部分操作是共通的,如上一篇文章所说的加载程序集,获取脚本类的反射信息等。我们可以将这些操作封装为类。

值得一提的是,我们虽不用创建第一种思路中用来存数据的自定义数据结构,但是依然要在C++端保存Mono Runtime中各个类、实例、属性的指针,目的是快速访问,而不用频繁调用诸如mono_class_from_namemono_class_get_fields等方法,只有在给Mono对象存取值的时候,会用到mono_field_get_valuemono_field_set_value,而这两个是效率相对较高的方法,总而言之就是减少调用mono库方法的频率。

由此,我们定义ScriptDomainScriptClassScriptInstance等类如下:

using ScriptInstanceMap = std::unordered_map<std::string, Ref<ScriptInstance>>;
using EntityMap = std::unordered_map<UUID, ScriptInstanceMap>;
using ScriptClassMap = std::unordered_map<std::string, Ref<ScriptClass>>;

class ScriptDomain
{
  public:
    ScriptDomain(const std::string &name);
    ~ScriptDomain();
    void LoadCoreAssembly(const Path &path);
    void LoadAppAssembly(const Path &path);
    void SetCurrent();
    Ref<ScriptInstance> InstantiateScriptClass(const Ref<ScriptClass> &scriptClass, UUID uuid);

    MonoDomain *GetHandle() const { return handle; }
    MonoAssembly *GetCoreAssembly() const { return coreAssembly; }
    MonoImage *GetCoreAssemblyImage() const { return coreAssemblyImage; }
    MonoAssembly *GetAppAssembly() const { return appAssembly; }
    MonoImage *GetAppAssemblyImage() const { return appAssemblyImage; }
    ScriptClassMap &GetScriptClasses() { return scriptClasses; }
    EntityMap &GetEntities() { return entities; }

  private:
    Ref<ScriptClass> RegisterCoreClass(const std::string &namespaceStr, const std::string &nameStr);
    void RegisterAppClass(const std::string &namespaceStr, const std::string &nameStr);

    MonoDomain *handle;
    std::string name;

    MonoAssembly *coreAssembly;
    MonoImage *coreAssemblyImage;

    MonoAssembly *appAssembly;
    MonoImage *appAssemblyImage;

    Ref<ScriptClass> entityClass;

    ScriptClassMap scriptClasses;
    EntityMap entities;
};

enum class ScriptFieldType
{
    Unknown,
    Bool, Char,
    Int16, Int32, Int64,
    UInt8, UInt16, UInt32, UInt64,
    Float, Double,
    Vector2, Vector3, Vector4,
    Entity
};

struct ScriptField
{
    ScriptFieldType type;
    MonoClassField *handle;
};

struct ScriptMethod
{
    MonoMethod *handle;
    bool isStatic;
};

class ScriptClass
{
  public:
    friend class ScriptInstance;
    friend class ScriptDomain;

    MonoObject *Instantiate() const;
    ScriptField GetField(const std::string &name) const;
    ScriptMethod GetMethod(const std::string &name) const;
    MonoObject *InvokeStaticMethod(const std::string &name, void **params) const;
    std::unordered_map<std::string, ScriptField> GetFields() const { return fields; }
    std::unordered_map<std::string, ScriptMethod> GetMethods() const { return methods; }
    MonoClass *GetHandle() const { return monoClass; }

  private:
    template <typename T, typename... Args>
    friend Ref<T> MakeRef(Args &&...args);

    ScriptClass(MonoClass *monoClass, const std::string &classNamespace, const std::string &className);
    std::string classNamespace;
    std::string className;
    std::unordered_map<std::string, ScriptField> fields;
    std::unordered_map<std::string, ScriptMethod> methods;
    MonoClass *monoClass = nullptr;
};

class ScriptInstance 
{
  public:
    ScriptInstance(MonoObject *instance, const Ref<ScriptClass> &scriptClass);
    MonoObject *InvokeMethod(const std::string &name, void **params);
    MonoObject *InvokeMethod(ScriptMethod method, void **params);

    void TryInvokeOnCreate();
    void TryInvokeOnUpdate(float timestep);
    void TryInvokeOnDestroy();

    Ref<ScriptClass> GetScriptClass() const { return scriptClass; }

    template <typename T>
    T GetFieldValue(const std::string &name)
    {
        auto it = scriptClass->fields.find(name);
        if (it == scriptClass->fields.end())
            return T{};
        ScriptField &field = it->second;
        mono_field_get_value(instance, field.handle, fieldValueBuffer);
        return *(T *)fieldValueBuffer;
    }

    template <typename T>
    void SetFieldValue(const std::string &name, const T &value)
    {
        auto it = scriptClass->fields.find(name);
        if (it == scriptClass->fields.end())
            return;
        ScriptField &field = it->second;
        mono_field_set_value(instance, field.handle, (void *)&value);
    }

    MonoObject *GetHandle() const { return instance; }

  private:
    Ref<ScriptClass> scriptClass;
    MonoObject *instance;
    inline static char fieldValueBuffer[16];
};

其中Script Domain加载Core和App程序集的方法实现如下:

void ScriptDomain::LoadCoreAssembly(const Path &path)
{
    mono_domain_set(handle, false); // 加载前一定要设置domain为当前操作的domain!
    coreAssembly = mono_domain_assembly_open(handle, path.string().c_str());
    if (!coreAssembly)
    {
        Log::CoreError("Failed to Load Core Assembly: {}", path.string());
    }
    coreAssemblyImage = mono_assembly_get_image(coreAssembly);
    if (!coreAssemblyImage)
    {
        Log::CoreError("Failed to Load Core Assembly Image: {}", path.string());
    }

    entityClass = RegisterCoreClass("Zafkiel", "Entity");

    ScriptGlue::AddInternalCalls(); // Internal Calls 都在 CoreAssembly 中
}

void ScriptDomain::LoadAppAssembly(const Path &path)
{
    if (!coreAssembly)
    {
        Log::CoreError("Need to Load Core Assembly first!");
        return;
    }
    mono_domain_set(handle, false);
    appAssembly = mono_domain_assembly_open(handle, path.string().c_str());
    if (!appAssembly)
    {
        Log::CoreError("Failed to App Assembly: {}", path.string());
    }
    appAssemblyImage = mono_assembly_get_image(appAssembly);
    if (!appAssemblyImage)
    {
        Log::CoreError("Failed to App Assembly Image: {}", path.string());
    }

    scriptClasses.clear();
    const MonoTableInfo *typeDefinitionsTable = mono_image_get_table_info(appAssemblyImage, MONO_TABLE_TYPEDEF);
    size_t numTypes = mono_table_info_get_rows(typeDefinitionsTable);
    // 通过反射信息得到所有类型信息
    for (size_t i = 0; i < numTypes; i++)
    {
        uint32_t cols[MONO_TYPEDEF_SIZE];
        mono_metadata_decode_row(typeDefinitionsTable, i, cols, MONO_TYPEDEF_SIZE);

        std::string namespaceStr = mono_metadata_string_heap(appAssemblyImage, cols[MONO_TYPEDEF_NAMESPACE]);
        std::string nameStr = mono_metadata_string_heap(appAssemblyImage, cols[MONO_TYPEDEF_NAME]);

        RegisterAppClass(namespaceStr, nameStr);
    }
}

Ref<ScriptClass> ScriptDomain::RegisterCoreClass(const std::string &namespaceStr, const std::string &nameStr)
{
    auto monoClass = mono_class_from_name(coreAssemblyImage, namespaceStr.c_str(), nameStr.c_str());
    return MakeRef<ScriptClass>(monoClass, namespaceStr, nameStr);
}

void ScriptDomain::RegisterAppClass(const std::string &namespaceStr, const std::string &nameStr)
{
    auto monoClass = mono_class_from_name(appAssemblyImage, namespaceStr.c_str(), nameStr.c_str());
    bool isEntity = mono_class_is_subclass_of(monoClass, entityClass->GetHandle(), false);
    if (isEntity)
    {
        std::string fullName = !namespaceStr.empty() ? std::format("{}.{}", namespaceStr, nameStr) : nameStr;
        scriptClasses[fullName] = MakeRef<ScriptClass>(monoClass, namespaceStr, nameStr);
    }
}

ScriptClass需要获取类型中的所有字段和方法,对应实现如下:

ScriptClass::ScriptClass(MonoClass *monoClass, const std::string &classNamespace, const std::string &className)
    : monoClass(monoClass), classNamespace(classNamespace), className(className)
{
    void *iterator = nullptr;
    while (auto field = mono_class_get_fields(monoClass, &iterator))
    {
        std::string fieldName = mono_field_get_name(field);
        auto fieldType = mono_field_get_type(field);
        std::string typeName = mono_type_get_name(fieldType);
        ScriptFieldType type = stringToScriptFieldType.contains(typeName) ? stringToScriptFieldType[typeName] : ScriptFieldType::Unknown;
        fields[fieldName] = ScriptField{type, field};
    }
    iterator = nullptr;
    while (auto method = mono_class_get_methods(monoClass, &iterator))
    {
        std::string methodName = mono_method_get_name(method);
        uint32_t flags;
        mono_method_get_flags(method, &flags);
        bool isStatic = flags & MONO_METHOD_ATTR_STATIC;
        methods[methodName] = ScriptMethod{method, isStatic};
    }
}

ScriptInstance中,通过存储的方法指针来调用方法:

MonoObject *ScriptInstance::InvokeMethod(ScriptMethod method, void **params)
{
    if (method.isStatic)
    {
        Log::CoreError("Method is Static!");
        return nullptr;
    }
    MonoObject *exc = nullptr;
    MonoObject *ret = mono_runtime_invoke(method.handle, instance, params, &exc);
    if (exc)
    {
        MonoString *excMonoStr = mono_object_to_string(exc, nullptr);
        std::string excStr = MonoStringToCppString(excMonoStr);
        Log::CoreError("Invoke Method Exception: {}", excStr);
        return nullptr;
    }
    return ret;
}

接下来的重点在于Editor Domain和Runtime Domain的关系。

在刚打开编辑器时,脚本引擎创建Editor Domain,并加载程序集。

scriptEngine->CreateEditorDomain();
scriptEngine->LoadEditorCoreAssembly();
scriptEngine->LoadEditorAppAssembly();

脚本引擎内维护一个变量isRuntime,表明当前是编辑模式还是运行模式。之后脚本引擎提供的接口,其数据的存取会相应地指向Editor Domain / Runtime Domain。例如:

class EditorScriptEngine 
{
  public:
    Ref<ScriptDomain> EditorScriptEngine::GetActiveDomain() const
    {
        return isRuntime ? runtimeDomain : editorDomain;
    }

    ScriptClassMap &GetScriptClasses() { return GetActiveDomain()->GetScriptClasses(); }
    const ScriptClassMap &GetScriptClasses() const { return GetActiveDomain()->GetScriptClasses(); }

    EntityMap &GetEntities() { return GetActiveDomain()->GetEntities(); }
    const EntityMap &GetEntities() const { return GetActiveDomain()->GetEntities(); }

    bool HasScriptInstance(UUID uuid, const std::string &scriptName) const 
    {
        auto &entities = GetEntities();
        auto it = entities.find(uuid);
        return it != entities.end() && it->second.contains(scriptName);
    }

    Ref<ScriptInstance> GetScriptInstance(UUID uuid, const std::string &scriptName) const 
    {
        auto &entities = GetEntities();
        if (auto entity = entities.find(uuid); entity != entities.end())
            if (auto it = entity->second.find(scriptName); it != entity->second.end())
                return it->second;
        Log::CoreError("entity script doesn't exist: {} {}", (uint64_t)uuid, scriptName);
        return nullptr;
    }

    Ref<ScriptInstance> AddScriptInstance(UUID uuid, const std::string &scriptName) 
    {
        auto &scriptClasses = GetScriptClasses();
        auto it = scriptClasses.find(scriptName);
        if (it == scriptClasses.end())
        {
            Log::CoreError("Cannot Find Script Class: {}", scriptName);
            return nullptr;
        }
        auto instance = GetActiveDomain()->InstantiateScriptClass(it->second, uuid);
        GetEntities()[uuid][scriptName] = instance;
        return instance;
    }

    void RemoveScriptInstance(UUID uuid, const std::string &scriptName)
    {
        auto &entities = GetEntities();
        if (auto entity = entities.find(uuid); entity != entities.end())
            entity->second.erase(scriptName);
        else
            Log::CoreError("Entity Instance {} - {} doesn't exist!", (uint64_t)uuid, scriptName);
    }
};

在切换到运行模式时,我们需要“拷贝”原来的场景,得到新场景,同时创建Runtime Domain,并转移数据。“拷贝”的过程通过序列化实现,不论是序列化成文本格式还是二进制格式,这里不再赘述。

if (isPlaying) // Edit -> Play
{
    auto worldData = Serialize(Editor::GetEditorScene()->GetWorld()); // 序列化当前场景
    Ref<Scene> newScene = MakeRef<Scene>();
    Engine::SetActiveScene(newScene);
    
    Editor::GetScriptEngine()->OnRuntimeInit();  // 创建Runtime Domain
    Deserialize<World>(worldData, newScene->GetWorld()); // 反序列化
    Editor::GetScriptEngine()->OnRuntimeStart(); // 调用脚本的OnCreate方法
}
else // Play -> Edit
{
    Engine::SetActiveScene(Editor::GetEditorScene());
    Editor::GetScriptEngine()->OnRuntimeStop();  // 调用脚本的OnDestroy方法,并销毁Runtime Domain
}
void EditorScriptEngine::OnRuntimeInit()
{
    CreateRuntimeDomain();
    SwitchToRuntime();
    LoadRuntimeCoreAssembly();
    LoadRuntimeAppAssembly();
}
void EditorScriptEngine::OnRuntimeStart()
{
    for (auto &[uuid, entity] : runtimeDomain->GetEntities())
    {
        for (auto &[scriptName, instance] : entity)
        {
            instance->TryInvokeOnCreate();
        }
    }
}
void EditorScriptEngine::OnRuntimeUpdate(float timestep)
{
    for (auto &[uuid, entity] : runtimeDomain->GetEntities())
    {
        for (auto &[scriptName, instance] : entity)
        {
            instance->TryInvokeOnUpdate(timestep);
        }
    }
}
void EditorScriptEngine::OnRuntimeStop()
{
    for (auto &[uuid, entity] : runtimeDomain->GetEntities())
    {
        for (auto &[scriptName, instance] : entity)
        {
            instance->TryInvokeOnDestroy();
        }
    }
    UnloadRuntimeDomain();
    SwitchToEditor();
}

调试Mono

在使用csc编译C#脚本时,可以通过传入参数-debug:portable,在生成dll文件的目录生成同名的.pdb调试文件。我们需要在引擎中加载该文件来使用mono的调试功能。

首先,在创建Mono Runtime之前,准备好如下参数传递给Mono:

std::vector<const char *> argv = {
    "--debugger-agent=transport=dt_socket,address=localhost:55555,server=y,suspend=n,loglevel=3,logfile=MonoDebugger.log",
    "--soft-breakpoints"};
mono_jit_parse_options(argv.size(), (char **)argv.data());
mono_debug_init(MONO_DEBUG_FORMAT_MONO);

rootDomain = mono_jit_init("ZafkielJITRuntime");

mono_domain_set(rootDomain, true);

详细解释一下传入参数的含义:

  • --debugger-agent为主参数,表示要启动调试器代理;
  • transport=dt_socket指定传输协议,使用 TCP/IP 套接字 进行通信,支持远程调试;
  • address=localhost:55555指定调试器代理监听连接的地址和端口,这里我们本地调试,端口任意;
  • server=y表示程序作为服务器启动,并等待调试器连接;
  • suspend=n表示程序启动时不会暂停等待调试器连接,而是在运行时随时连接。这是合理的,因为编辑器刚启动时在Editor Domain,而在编辑模式下我们不会启用调试,只会在运行模式下调试。
  • loglevel=3设置日志详细程度;
  • logfile=MonoDebugger.log指定调试日志输出的文件,对于诊断问题很有帮助。
  • --soft-breakpoints指定使用软件断点,这样我们就可以像正常调试一样,在IDE中插入断点让代码中断了。

在创建Runtime Domain时,添加一行代码,表示该domain要支持调试。

void EditorScriptEngine::CreateRuntimeDomain()
{
    runtimeDomain = MakeRef<ScriptDomain>("Runtime Domain");
    mono_debug_domain_create(runtimeDomain->GetHandle());
}

之后加载程序集时,对于加载的每个.dll文件,找到对应的.pdb文件并加载,以加载CoreAssembly为例:

void EditorScriptEngine::LoadRuntimeCoreAssembly()
{
    runtimeDomain->LoadCoreAssembly("ScriptCore.dll");

    Path pdbPath = "ScriptCore.pdb";
    if (std::filesystem::exists(pdbPath))
    {
        Buffer pdbFileData = FileSystem::ReadBytes(pdbPath); // 二进制格式读取文件
        mono_debug_open_image_from_memory(runtimeDomain->GetCoreAssemblyImage(), pdbFileData.data(), pdbFileData.size());
        pdbFileData.clear();
    }
}

这样就完成了调试功能的支持。

关于特定IDE调试Mono的尝试

在Vscode中,有Mono Debug这个插件,理论上安装插件后,在.vscode/launch.json文件中添加:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Attach to Mono",
            "request": "attach",
            "type": "mono",
            "address": "localhost",
            "port": 55555,
        }
    ]
}

就应该能调试,但笔者测试时发现由于该插件长久未更新,与引擎内Mono连接时,出现了协议版本不匹配的问题导致无法调试,截至本文写作时依然不行。

而在JetBrains Rider中,在运行/调试配置中,添加"Mono 远程"配置,指定与引擎中一样的地址和端口,就可以成功连接并调试。

脚本热重载

脚本重加载

脚本重加载是在编辑器运行的过程中,通过卸载Domain并创建新的Domain,实现程序集的更新。这个功能无论在编辑模式还是运行模式都应该支持。注意,重新加载的Domain永远是Editor Domain,在运行模式下,Runtime Domain是不能也不应该打断的。

在重新加载的过程中,原Editor Domain中已经有的数据依然需要“拷贝”到新的Editor Domain中(改变了的字段暂不考虑保留其值),因此又涉及到序列化来转移数据。

void EditorScriptEngine::ReloadEditorDomain()
{
    bool currentIsRuntime = isRuntime;
    SwitchToEditor(); // 先切换到Editor Domain进行卸载
    auto data = Serialize(Editor::GetEditorScene()->GetWorld());
    UnloadEditorDomain();
    CompileScripts(); // 重新编译脚本,更新程序集
    CreateEditorDomain();
    LoadEditorCoreAssembly();
    LoadEditorAppAssembly();
    SwitchToEditor();
    Editor::GetEditorScene()->GetWorld() = Deserialize<World>(data);
    if (currentIsRuntime) SwitchToRuntime(); // 原来在什么Domain就切换回去
}

在程序内控制C#脚本的编译,暂时没有什么优越的方法,笔者尝试过在Mono Runtime中,调用C#的编译器,但是各种找不到库,大概是Mono和.Net的兼容问题。因此只能采用调用控制台命令的方式:

void EditorScriptEngine::CompileScripts()
{
    const Path &libraryDir = Editor::GetProject()->GetLibraryDirectory();
    Path appAssemblyPath = libraryDir / "AppAssembly.dll";
    if (!std::filesystem::exists(libraryDir))
    {
        std::filesystem::create_directory(libraryDir);
    }
    Path sourcePath = Editor::GetProject()->GetAssetDirectory() / "scripts" / "*.cs";
    std::string cmd = std::format("csc -target:library -debug:portable -r:ScriptCore.dll -out:{} {}", 
      appAssemblyPath.string(), sourcePath.string());
    
    std::system(cmd.c_str());
}

但是这样还有一个问题,在运行模式下,我们不想在重加载时改变Runtime Domain,但是直接覆写AppAssembly.dll出现了问题——Mono Domain在加载程序集时,并不是直接将其拷贝进内存,而是链接到原文件(不同平台有差异),会锁定文件,因此对原文件进行覆写会直接导致程序崩溃。解决方案也很简单,在编辑器一次运行内,给新创建的程序集一个不断递增的尾号,这样就不会覆写原来的程序集了。

void EditorScriptEngine::CompileScripts()
{
    const Path &libraryDir = Editor::GetProject()->GetLibraryDirectory();
    Path appAssemblyPath = libraryDir / std::format("AppAssembly_{}.dll", assemblyIndex++);
    if (!std::filesystem::exists(libraryDir))
    {
        std::filesystem::create_directory(libraryDir);
    }
    Path sourcePath = Editor::GetProject()->GetAssetDirectory() / "scripts" / "*.cs";
    std::string cmd = std::format("csc -target:library -debug:portable -r:ScriptCore.dll -out:{} {}", 
      appAssemblyPath.string(), sourcePath.string());
    
    std::system(cmd.c_str());
}

文件监视器实现热重载

在实现重加载后,通过一个文件监视器就可以轻松实现热重载,即修改C#文件后,程序检测到并自动进行重加载。

网上有很多开源的文件监视器库,可以随意选择,笔者使用filewatch这个单头文件库来实现。

实现代码如下:

void EditorScriptEngine::WatchScriptFiles(const Path &scriptDir)
{
    scriptFileWatcher = std::make_unique<filewatch::FileWatch<std::filesystem::path>>(
        scriptDir, [this](const std::filesystem::path &file, const filewatch::Event event_type) {
            if (!scriptReloadPending && event_type == filewatch::Event::modified)
            {
                scriptReloadPending = true;
                using namespace std::chrono_literals;
                std::this_thread::sleep_for(100ms);

                Engine::SubmitToMainThread([&]() {
                    ReloadEditorDomain();
                });
            } });
}

由于修改文件时,系统往往会同时产生不止一个事件,因此需要完成去重的工作。这里通过维护一个scriptReloadPending,检测到第一个修改事件后,直到完成重加载之前,对后续的修改事件不再响应,保险起见,可以像代码中一样,等待一小段时间后再执行重加载。

由于文件监视器不在主线程上运行,不能在检测到修改之后直接重加载,因为这时候主线程内的行为未知,如果仍在使用Domain中的数据,可能直接崩溃,因此需要将重加载加入队列,在主线程的合适时机再执行。

在创建脚本引擎时,调用该WatchScriptFiles方法,即启用文件监视。


参考教程:C# Scripting! // Game Engine series (Cherno游戏引擎系列教程)

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐