C++中嵌入C#作脚本引擎(二)
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_name,mono_class_get_fields等方法,只有在给Mono对象存取值的时候,会用到mono_field_get_value和mono_field_set_value,而这两个是效率相对较高的方法,总而言之就是减少调用mono库方法的频率。
由此,我们定义ScriptDomain,ScriptClass,ScriptInstance等类如下:
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游戏引擎系列教程)
更多推荐


所有评论(0)