序言

游戏引擎中引入脚本系统,可以将引擎底层逻辑与Gameplay逻辑分离,由于脚本语言相对简单,也提升了游戏开发效率;除此之外,将脚本视作一种资源进行管理,可以实现热重载。

C#,lua和python是常见的脚本语言,其中,lua和python都是解释型语言,无需编译即时运行,lua的轻量级特性使得它有着比python快的运行效率,广泛用于中小型项目中;而C#虽然使用JIT编译(运行时执行编译),但是可以通过重新加载程序集(Assembly)的方式实现热更新,因此达到了与解释型语言类似的脚本的功能,同时不失性能优势。

对笔者自己来说,C#相比于lua,作为一门编程语言来说更加完备(静态类型检查等),兼顾了性能与开发效率。本文记录学习使用Mono,将C#嵌入C++,实现两者互通的方法。

Mono环境

Mono 是一个跨平台的开源 .NET 运行时和开发框架,现在广泛用在游戏引擎中嵌入C#。之所以选择Mono而非另一个 .NET 实现 .NET Core (CoreCLR 作为运行时),是因为Mono的C/C++ API更友好,可以简单上手。就性能来说,.NET Core经过深度优化,比Mono的性能好很多。考虑到我们只是在C游戏引擎中嵌入C#,不会用到C#的现代特性等,性能瓶颈主要在C方面。

Windows

可以从Mono 官网下载预编译的版本,也可以从源码用Visual Studio编译。

Linux (Ubuntu)

通过以下命令安装:

1
sudo apt install mono-complete

将include文件夹和lib中的monosgen-2.0静态库复制到项目文件目录中,本文只需要用到该库。

在CMakeLists.txt中添加:

1
2
3
4
5
6
7
8
9
add_library(mono STATIC IMPORTED GLOBAL)

set_target_properties(mono PROPERTIES
IMPORTED_LOCATION path/to/libmonosgen-2.0.a # monosgen-2.0静态库位置
)

target_include_directories(mono INTERFACE path/to/include) # include文件夹位置

target_link_libraries(YourProject PUBLIC mono) # 链接到C++项目

配置Mono运行时

现在,通过引入相关头文件(主要是<mono/jit/jit.h><ono/metadata/assembly.h>),就可以在C++中创建Mono运行时的环境了。
我们将脚本引擎相关代码置于ScriptEngine类中。

初始化Mono

要启动Mono运行时环境,我们要调用mono_jit_init(const char *),传入一个字符串表示运行时环境的名字。该方法返回一个MonoDomain *指针,指向的是"根域",我们称为rootDomain。我们需要存储这个指针,以便在程序最后清理Mono运行时环境。除此之外,rootDomain本身没什么用。

之后,我们创建应用程序域appDomain,作为程序后面所有操作所在的域。调用mono_domain_create_appdomain(char *friendly_name, char *configuration_file),第一个参数为该域的名称,第二个参数为配置,填nullptr即可。

创建好后,用mono_domain_set来指定当前的域为appDomain。

接下来,我们就可以挂载C#程序集了,对于游戏引擎来说,我们要挂载包含引擎端实现脚本功能的coreAssembly以及用户端实现Gameplay逻辑的appAssembly。通过mono_domain_assembly_open(MonoDomain *domain, const char *name)方法,第一个参数为当前的域(即上文的appDomain),第二个参数为程序集的路径,我们需要维护返回的MonoAssembly *对象,用于后续操作。

综上,初始化Mono运行时的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
void ScriptEngine::Init()
{
rootDomain = mono_jit_init("JITRuntime");
appDomain = mono_domain_create_appdomain((char *)"ScriptRuntime", nullptr);
mono_domain_set(appDomain, true);

ScriptEngine::coreAssembly = mono_domain_assembly_open(appDomain,
"path/to/yourCoreAssembly.dll");

ScriptEngine::appAssembly = mono_domain_assembly_open(appDomain,
"path/to/yourAppAssembly.dll");
}

关闭Mono

首先,很重要的一步是将当前的Mono域设回根域rootDomain,否则后面卸载appDomain时会报错。然后使用mono_domain_unload卸载appDomain,最后将rootDomain也清理掉即可:

1
2
3
4
5
6
7
8
9
void ScriptEngine::Shutdown()
{
mono_domain_set(rootDomain, false);

mono_domain_unload(appDomain);
appDomain = nullptr;
mono_jit_cleanup(rootDomain);
rootDomain = nullptr;
}

从C++调用C#

目前,我们还没有可供加载的C#程序集。接下来创建游戏引擎中需要的,连接C++和C#的coreAssembly。

构建 Mono C# 项目

值得注意的是,此处尽量不使用dotnet命令来构建C#项目,因为dotnet默认的构建框架与Mono运行时不兼容,可能报错,因此我们使用Mono配备的mcs (Mono C#编译器) 来构建C#项目。(安装Mono时mcs就会安装)

在CMakeLists.txt中添加一个自定义的构建项目:

1
2
3
4
5
add_custom_target(
ScriptCore
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
COMMAND mcs -target:library -out:${CMAKE_BINARY_DIR}/bin/ScriptCore.dll *.cs
)

C#脚本基础功能

参考Unity中,任何一个脚本类都继承MonoBehaviour,提供一系列与C交互的接口,我们定义一个Entity类。一个脚本的核心功能是:在创建它时调用OnCreate()方法,每帧更新时调用OnUpdate()方法,以及销毁时调用OnDestroy()方法。但是这些方法并不需要作为虚函数定义在Entity中,可以直接在用户脚本中定义,函数名一致就行,因为C完全托管了C#运行时,完全可以通过反射来指定调用这几个方法。

假设在用户端有这样一个脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Player : Entity
{
void OnCreate()
{
// ...
}
void OnUpdate(float timestep)
{
// ...
}
void OnDestroy()
{
// ...
}
}

如何在C++中调用呢?

实现细节

由于游戏引擎使用ECS架构,不妨用一个脚本组件来存储一个entity上挂载的所有脚本。

1
2
3
4
struct ScriptComponent
{
std::vector<std::string> scripts;
};

在加载程序集时,我们遍历其中的所有类,找到继承自Entity的类,这些类就是我们关注的脚本类。将类名与MonoClass对象关联用std::unordered_map保存下来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void ScriptEngine::LoadAssemblyClasses()
{
const MonoTableInfo *typeDefinitionsTable = mono_image_get_table_info(appAssemblyImage, MONO_TABLE_TYPEDEF);
size_t numTypes = mono_table_info_get_rows(typeDefinitionsTable);
MonoClass *entityClass = mono_class_from_name(coreAssemblyImage, "YourNamespace", "Entity");
for (size_t i = 0; i < numTypes; i++)
{
uint32_t cols[MONO_TYPEDEF_SIZE];
mono_metadata_decode_row(typeDefinitionsTable, i, cols, MONO_TYPEDEF_SIZE);

const char *namespaceStr = mono_metadata_string_heap(appAssemblyImage, cols[MONO_TYPEDEF_NAMESPACE]);
const char *nameStr = mono_metadata_string_heap(appAssemblyImage, cols[MONO_TYPEDEF_NAME]);
std::string fullName = (strlen(namespaceStr) != 0) ? std::format("{}.{}", namespaceStr, nameStr) : nameStr;

MonoClass *monoClass = mono_class_from_name(appAssemblyImage, namespaceStr, nameStr);

bool isEntity = mono_class_is_subclass_of(monoClass, entityClass, false);

if (isEntity && monoClass != entityClass)
{
// 这里做了一层简单的封装
entityClasses[fullName] = std::make_shared<ScriptClass>(namespaceStr, nameStr, false);
}
}
}

在场景运行时,可以遍历脚本组件,通过脚本名得到MonoClass对象,创建对应类型的实例,并调用OnCreate方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void ScriptEngine::OnRuntimeStart(World &world)
{
for (auto entity : world.Query<ScriptComponent>())
{
const auto &scriptComponent = entity.GetComponent<ScriptComponent>();
for (const auto &scriptName : scriptComponent.scripts)
{
if (entityClasses.contains(scriptName))
{
auto instance = std::make_shared<ScriptInstance>(entityClasses[scriptName], entity.GetHandle());
// 存储创建的实例
entityInstances[entity.GetHandle()][scriptName] = instance;
instance->InvokeOnCreate();
}
}
}
}

创建的实例同样用一个std::unordered_map存储,简单起见,键类型这里选用ECS系统中entity本身的id,但其实是有问题的,更好的办法是给每个entity添加一个通用唯一标识符(UUID),在序列化时记录下来,这样才能将挂载的脚本信息保存在场景中。

调用的InvokeOnCreate使用Mono的API调用该实例的字为OnCreate的方法,OnUpdate的调用同理。使用双重指针的形式给调用的函数传参。

1
2
3
4
5
6
7
8
9
10
11
12
void ScriptInstance::InvokeOnCreate()
{
MonoMethod* method = mono_class_get_method_from_name(monoClass, "OnCreate", 0);
mono_runtime_invoke(method, instance, nullptr, nullptr);
}

void ScriptInstance::InvokeOnUpdate(float timestep)
{
void *param = &timestep;
MonoMethod* method = mono_class_get_method_from_name(monoClass, "OnUpdate", 1);
mono_runtime_invoke(method, instance, &param, nullptr);
}

传递自定义结构体时,C#的成员顺序和类型要与C++的成员一一对应。

从C#调用C++

在更多的情景下,需要从C#调用C++,毕竟我们是将C#嵌入C的,希望通过C#脚本,像牵线木偶的线一样操作C引擎。

这时,我们要通过InternalCall的机制,在C++注册需要调用的函数,然后Mono运行时就能找到并调用了。

封送数据

当C和C#交互时传输时,如果数据类型不是基础类型(整型,浮点型等),而是C#中的托管类型(如string)或者自定义类型时,涉及到数据类型在C和C#中的内存排布不同的情况时,需要进行数据的封送(Marshalling)。

以字符串为例,现在我们要在C#脚本中调用C的打印日志函数,参数为一个字符串,那么我们需要注册以下的C函数:

1
2
3
4
5
6
7
8
9
10
void Log_Info(MonoString *string)
{
char *cStr = mono_string_to_utf8(string);
std::string str(cStr);
mono_free(cStr);
Log::Info("{}", str);
}

// 注册,此处第一个参数最后的名字就是C#中要用的方法名,前面的命名空间和类名可以自定义
mono_add_internal_call("YourNamespace.InternalCalls::Log_Info", Log_Info);

这个方法传入的字符串是MonoString类型,而不是C的内置类型,需要通过MonoAPI的转换,转换成C/C字符串再处理。

然后在C#中,我们就可以这样来声明注册的函数:

1
2
3
4
5
6
7
8
9
10
using System.Runtime.CompilerServices;

namespace YourNamespace
{
internal static class InternalCalls
{
[MethodImpl(MethodImplOptions.InternalCall)]
internal extern static void Log_Info(string message);
}
}

实现 GetComponent

脚本中的常见操作是获取entity关联的各个Component,比如更改位置要用TransformComponent,在引擎ECS架构下,这些Component的数据存放在C++中,必须通过InternalCall来访问。

在C#中,我们定义基类Component,该Component与引擎的ECS中的Component完全不是一个概念,只是维护一个Entity的ID,表示该Entity有一个特定类型的Component。然后,对于所有C++中的Component,我们都在C#定义对应的类型。

1
2
3
4
5
6
7
8
9
public abstract class Component
{
public Entity Entity { get; internal set; }
}

public class Transform : Component
{
public Vector3 Position { get; set; }
}

C#的Entity中的GetComponent方法只需要构造一个持有EntityID的Component,不需要访问C数据。
相应地在HasComponent中,要通过InternalCall询问C
,该Entity有没有特定Component。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Entity
{
public Entity() { }
public Entity(uint id)
{
ID = id;
}
public readonly uint ID;
public bool HasComponent<T>() where T : Component, new()
{
Type componentType = typeof(T);
return InternalCalls.Entity_HasComponent(ID, componentType);
}
public T GetComponent<T>() where T : Component, new()
{
if (!HasComponent<T>())
return null;

T component = new T() { Entity = this };
return component;
}
}

C++方面,在初始化时,可以注册好所有组件类型,根据类型生成HasComponent<T>函数并存储。实现InternalCall的Entity_HasComponent的细节如下:

1
2
3
4
5
6
7
static bool Entity_HasComponent(EntityID ID, MonoReflectionType *componentType)
{
MonoType *type = mono_reflection_type_get_type(componentType);
assert(entityHasComponentFuncs.contains(type));
Entity entity = ScriptEngine::GetWorldContext()->GetEntityByID(ID);
return entityHasComponentFuncs[type](entity);
}

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