C++中嵌入C#作脚本引擎(一)
序言
游戏引擎中引入脚本系统,可以将引擎底层逻辑与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 | add_library(mono STATIC IMPORTED GLOBAL) |
配置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 | void ScriptEngine::Init() |
关闭Mono
首先,很重要的一步是将当前的Mono域设回根域rootDomain,否则后面卸载appDomain时会报错。然后使用mono_domain_unload
卸载appDomain,最后将rootDomain也清理掉即可:
1 | void ScriptEngine::Shutdown() |
从C++调用C#
目前,我们还没有可供加载的C#程序集。接下来创建游戏引擎中需要的,连接C++和C#的coreAssembly。
构建 Mono C# 项目
值得注意的是,此处尽量不使用dotnet
命令来构建C#项目,因为dotnet默认的构建框架与Mono运行时不兼容,可能报错,因此我们使用Mono配备的mcs
(Mono C#编译器) 来构建C#项目。(安装Mono时mcs
就会安装)
在CMakeLists.txt中添加一个自定义的构建项目:
1 | add_custom_target( |
C#脚本基础功能
参考Unity中,任何一个脚本类都继承MonoBehaviour
,提供一系列与C交互的接口,我们定义一个Entity
类。一个脚本的核心功能是:在创建它时调用OnCreate()
方法,每帧更新时调用OnUpdate()
方法,以及销毁时调用OnDestroy()
方法。但是这些方法并不需要作为虚函数定义在Entity
中,可以直接在用户脚本中定义,函数名一致就行,因为C完全托管了C#运行时,完全可以通过反射来指定调用这几个方法。
假设在用户端有这样一个脚本:
1 | public class Player : Entity |
如何在C++中调用呢?
实现细节
由于游戏引擎使用ECS架构,不妨用一个脚本组件来存储一个entity上挂载的所有脚本。
1 | struct ScriptComponent |
在加载程序集时,我们遍历其中的所有类,找到继承自Entity
的类,这些类就是我们关注的脚本类。将类名与MonoClass对象关联用std::unordered_map
保存下来。
1 | void ScriptEngine::LoadAssemblyClasses() |
在场景运行时,可以遍历脚本组件,通过脚本名得到MonoClass
对象,创建对应类型的实例,并调用OnCreate
方法。
1 | void ScriptEngine::OnRuntimeStart(World &world) |
创建的实例同样用一个std::unordered_map
存储,简单起见,键类型这里选用ECS系统中entity本身的id,但其实是有问题的,更好的办法是给每个entity添加一个通用唯一标识符(UUID),在序列化时记录下来,这样才能将挂载的脚本信息保存在场景中。
调用的InvokeOnCreate
使用Mono的API调用该实例的字为OnCreate
的方法,OnUpdate
的调用同理。使用双重指针的形式给调用的函数传参。
1 | void ScriptInstance::InvokeOnCreate() |
传递自定义结构体时,C#的成员顺序和类型要与C++的成员一一对应。
从C#调用C++
在更多的情景下,需要从C#调用C++,毕竟我们是将C#嵌入C的,希望通过C#脚本,像牵线木偶的线一样操作C引擎。
这时,我们要通过InternalCall的机制,在C++注册需要调用的函数,然后Mono运行时就能找到并调用了。
封送数据
当C和C#交互时传输时,如果数据类型不是基础类型(整型,浮点型等),而是C#中的托管类型(如string)或者自定义类型时,涉及到数据类型在C和C#中的内存排布不同的情况时,需要进行数据的封送(Marshalling)。
以字符串为例,现在我们要在C#脚本中调用C的打印日志函数,参数为一个字符串,那么我们需要注册以下的C函数:
1 | void Log_Info(MonoString *string) |
这个方法传入的字符串是MonoString类型,而不是C的内置类型,需要通过MonoAPI的转换,转换成C/C字符串再处理。
然后在C#中,我们就可以这样来声明注册的函数:
1 | using System.Runtime.CompilerServices; |
实现 GetComponent
脚本中的常见操作是获取entity关联的各个Component,比如更改位置要用TransformComponent,在引擎ECS架构下,这些Component的数据存放在C++中,必须通过InternalCall来访问。
在C#中,我们定义基类Component,该Component与引擎的ECS中的Component完全不是一个概念,只是维护一个Entity的ID,表示该Entity有一个特定类型的Component。然后,对于所有C++中的Component,我们都在C#定义对应的类型。
1 | public abstract class Component |
C#的Entity中的GetComponent
方法只需要构造一个持有EntityID的Component,不需要访问C数据。
相应地在HasComponent
中,要通过InternalCall询问C,该Entity有没有特定Component。
1 | public class Entity |
C++方面,在初始化时,可以注册好所有组件类型,根据类型生成HasComponent<T>
函数并存储。实现InternalCall的Entity_HasComponent
的细节如下:
1 | static bool Entity_HasComponent(EntityID ID, MonoReflectionType *componentType) |
参考教程:C# Scripting! // Game Engine series (Cherno游戏引擎系列教程)