Kytten 的 C# 脚本系统正在开发中,部分技术还在调研和测试。
C# 脚本系统概述
什么是 C# 脚本系统?
C# 脚本系统,是指在引擎非托管层面内嵌 C# 托管运行时,从而实现非托管代码 和 C# 托管代码的相互调用,进而进行封装,实现一套机制,允许用户使用脚本编写游戏逻辑、插件、编辑器扩展等等。
游戏引擎为什么通常需要 C# 脚本系统?
因为,C# 相比其他底层语言 (C/C++/Rust) 更容易掌握和理解,开发者如果使用 C# 进行脚本编程的话,上手会更简单,学习成本更低,开发效率更高。同时,C# 是类型安全的语言,这能保证绝大多数情况下代码的安全性。并且,考虑到开发者不希望手动管理内存,C# 就更合适了,它有垃圾回收器。
还有一个原因是 C# 有更好的生态系统,C# 的背后是 .NET,而 .NET 具有庞大的生态体系。未来,.NET 将会成为一个真正大一统的平台,届时,C# 就是 YYDS。
Mono VS CoreCLR
Mono
Mono 是一个开源项目,由 Xamarin 公司发起。由于 .NET Framework 只支持 Windows,Mono 应运而生,它的目标是实现跨平台的运行时,实现与 .NET Framework 相同的 ECMA 规范,兼容大部分 .NET Framework 的代码,从而支持使用 C# 编写主流的桌面平台和移动平台的应用程序和库。后来,微软收购了 Xamarin。现在,Mono 具有两个版本,一个由原 Mono 团队继续维护,另一个由微软自己维护,两者功能有所区别,前者更通用,是游戏引擎脚本系统的常用第三方库,后者主要针对微软自家的产品做出了更多的兼容和优化,内嵌到游戏引擎的话并不合适。
GitHub - mono/mono: Mono open source ECMA CLI, C# and .NET implementation.
runtime/src/mono at main · dotnet/runtime
CoreCLR
CoreCLR 也是一个开源项目,由微软发起。考虑到 .NET Framework 不支持跨平台,不开源,无法得到社区的支持,微软决定开发一款全新的开源跨平台公共语言运行时,也就是 CoreCLR。它基于 .NET Framework CLR,但它针对平台相关实现进行了重写,对 GC 部分进行了升级和优化,同时提高了多线程和任务等核心功能的性能,其中最重要的是支持跨平台。不过目前,CoreCLR 只支持主流的桌面平台,对于移动平台,微软暂时还是采用 Mono。
GitHub - dotnet/runtime: .NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
Mono 与 CoreCLR 的优缺点
Mono 的优势在于,比 CoreCLR 支持更多平台,且内嵌更方便。为什么更方便?因为它的绑定机制更灵活。它支持两种绑定机制:
-
P/Invoke (平台调用)
-
Mono 内嵌 API
P/Invoke 涉及到更多关于 DLLImport 特性 (最新的为 LibraryImport 特性)、调用约定 (Calling Convention)、封送 (Marshaling) 相关的种种内容,相对而言更加复杂,且容易出错,其中任意一个环节出了问题,绑定就失败了,如果我们使用 Mono 的话,通常不会使用 P/Invoke 进行内嵌。
Mono 内嵌 API 则支持在 C++ 层面添加内部调用 (Internal Call),然后在 C# 层面声明为外部方法 (External Method),只要命名空间、类名、方法名一一匹配,即可完成绑定。并且,封送也更加方便。以字符串为例,如果是 P/Invoke,则需要指定字符串的封送方式,编码格式等等。Mono 内嵌 API 使用 MonoString 则可以很好地解决字符串封送问题,mono 为此提供了很多便捷的 API。
C Type | Mono Type | C to Mono | Mono to C |
---|---|---|---|
strings, char * | MonoString * | mono_string_new, mono_string_new_len, mono_string_new_wrapper, mono_string-new_utf16 | mono_string_to_utf8, mono_string_to_utf16 |
CoreCLR 的优势在于它具有更高的性能和更高的 C# 版本。它的 GC 比 Mono GC 性能要好很多,且提供了更多现代化的功能,例如 Span、Memory 这些为了提高性能而诞生的结构,提供了更多优化性能后的功能组件,支持最新的 C# 版本 (目前是 11)。但是,CoreCLR 目前仅支持以 P/Invoke 的形式进行代码绑定,所以对于内嵌到游戏引擎中而言更为复杂。我们需要在 C++ 层面加载 hostfxr,进而使得游戏引擎成为 CoreCLR 的宿主,这样可以调用 C# 托管代码。但是对于 C# 调用 C++ 代码的情况,我们还是得通过 P/Invoke 去实现。
Kytten Roadmap
在 Kytten 0.1.0 的 Roadmap 中,我们计划将 mono 嵌入到引擎中,作为脚本系统的底层核心之一,这样就可以最简单地实现 C# 脚本编程,不过支持的 C# 版本会相对低一些,性能也会差一些。
在未来,我们计划将 .NET 8.0 (LTS) 嵌入到引擎中,作为脚本系统的又一底层核心。.NET 的 CoreCLR 会给脚本系统带来巨大的性能提升以及最新 C# 语言版本带来的新特性。
如果 CoreCLR 未来提供内嵌 API,那么绑定就更加方便了,希望微软后面会提供这些 API。
如果 C# 脚本系统有两套不同的运行时方案,根据编译器宏参数进行开启和关闭,那么我们还需要编写绑定代码生成器,或者利用源生成器 (Source Generators) ,根据选用的运行时 (Mono 或者 CoreCLR) 来生成相应的绑定代码 (使用 Internal Call 或者 使用 DLLImport / LibraryImport)。
游戏引擎内嵌 Mono (以 Windows 为例)
在游戏引擎中内嵌 Mono 是比较轻松的,因为 Mono 已经为我们提供了内嵌 API,直接使用即可。在正式内嵌 Mono 之前,我们需要编译或者安装 Mono 以获得必要的文件。
编译 Mono 需要一定的时间,直接安装 Mono 一般而言是不错的选择。不过一旦你需要更改 Mono 中的部分逻辑以实现新功能,那就必须编译自己更改后的 Mono。
内嵌 Mono 的原理
简单描述,就是,C/C++ 中,托管了一个 Mono 运行时,在 Mono 运行时中初始化 AppDomain,加载程序集等等。
编译 Mono 并获得必要文件
提示:需要科学上网环境,需要消耗较长时间。
需要 Visual Studio 环境,并且安装了 C++ 桌面开发环境。
开启 Windows 开发者模式
设置→更新和安全→开发者选项→开发人员模式→开
下载 Cygwin 并安装依赖组件
点击下载
下载后,从 CMD 在下载目录执行以下命令进行安装:
setup-x86_64.exe -P autoconf,automake,bison,gcc-core,gcc-g++,mingw64-i686-runtime,mingw64-i686-binutils,mingw64-i686-gcc-core,mingw64-i686-gcc-g++,mingw64-i686-pthreads,mingw64-i686-w32api,mingw64-x86_64-runtime,mingw64-x86_64-binutils,mingw64-x86_64-gcc-core,mingw64-x86_64-gcc-g++,mingw64-x86_64-pthreads,mingw64-x86_64-w32api,libtool,make,python,gettext-devel,gettext,intltool,libiconv,pkg-config,git,curl,wget,libxslt,bc,patch,cmake,perl,yasm,unzip
如果遇到 libusb0 安装失败的问题,可以暂时忽略它,点击下一步即可。
编译 Mono
打开 Cygwin 以后,会进入命令行窗口。
我们执行以下命令克隆 Mono 仓库,并切换到最新发布的 Tag,设置安装路径为 mono 源码根目录中的 install 文件夹:
cd ~
git clone https://github.com/mono/mono.git # Or your own mono git repo
cd mono
git checkout 73df89a # The latest released tag's commit hash
export PREFIX=~/mono/install
export PATH=$PREFIX/bin:$PATH
此时 mono 仓库已经克隆下来了,我们在文件资源浏览器中打开 (Cygwin根目录/home/[用户名]/mono
,进入 msvc
文件夹,双击打开 mono.sln
解决方案,然后编译解决方案。可以选择 Debug / Release + x64 / Win32,根据需求编译即可。此步的目的是获得 MSVC 支持的静态库文件。
可能会遇到 libtest 编译没有通过 (常量中有换行符)的情况,没有关系,应该是测试用例或者代码页出现的编码问题,不影响其他核心库的编译即可。
编译并安装 64 位版本 Mono:
./autogen.sh --prefix=$PREFIX --host=x86_64-w64-mingw32 --disable-boehm
make get-monolite-latest
make -j8
make install
编译并安装 32 位版本 Mono:
./autogen.sh --prefix=$PREFIX --host=i686-w64-mingw32 --disable-boehm
make get-monolite-latest
make -j8
make install
获得必要的文件
需要获得的文件包括:
-
静态库文件
-
头文件
-
配置文件
-
运行时 DLL 集合
静态库文件在 msvc/build/
中,例如,如果我在 mono.sln
中选择的编译配置为 Debug + x64,那么对应的需要获得的静态库文件为在 msvc/build/sgen/x64/lib/Debug/
中的 libmono-static-sgen.lib
。
头文件在 msvc/include/
中,mono
文件夹中包含的就是我们所需要的所有头文件。
配置文件在 install/etc/
中,mono
文件夹中包含了我们所需要的所有配置文件。
运行时 DLL 集合则在 install/lib/mono/
中,通常而言,我们只需要其中的 4.5
文件夹,并且,该文件夹中我们只关心 .dll 后缀的程序集文件,其他文件我们不需要。
安装 Mono 并获得必要文件
下载并安装 Mono
进入 Mono 官网,下载最新稳定版,具体选择 32 位 还是 64 位看需求。
下载后运行安装即可。
获得必要的文件
需要获得的文件包括:
-
静态库文件
-
头文件
-
配置文件
-
运行时 DLL 集合
以 Mono 安装目录为准。 Mono 64 位默认的的安装目录为 C:\Program Files\Mono
。后续 Mono 安装目录以 %MONO_DIR%
代替。
静态库文件为在 %MONO_DIR%/lib/
中的 libmono-static-sgen.lib
。
头文件在 %MONO_DIR%/include/mono-2.0/
中,mono
文件夹中包含的就是我们所需要的所有头文件。
配置文件在 %MONO_DIR%/etc/
中,mono
文件夹中包含了我们所需要的所有配置文件。
运行时 DLL 集合则在 %MONO_DIR%/lib/mono/
中,通常而言,我们只需要其中的 4.5
文件夹,并且,该文件夹中我们只关心 .dll 后缀的程序集文件,其他文件我们不需要。
项目设置
添加静态链接
根据你的引擎项目结构,链接 libmono-static-sgen.lib
。在 Windows 下,直接链接它是存在链接错误的,还需要同时链接以下库:
-
Mswsock.lib
-
ws2_32.lib
-
psapi.lib
-
version.lib
-
winmm.lib
-
Bcrypt.lib
Kytten 目前基于 CMake 构建,以下是 Kytten 关于链接 mono 静态库的部分 CMake 代码,省略了 Linux 和 MacOS 的部分:
# Link Mono
set(MONO_HEADER_FILES_DIR ${THIRD_PARTY_DIR}/mono)
if (PLATFORM_WINDOWS)
set(MONO_ROOT_DIR ${PLATFORM_ROOT_DIR}/Windows/Binaries/Mono)
set(MONO_LIB_DIR ${MONO_ROOT_DIR}/${ARCH}/lib/${CMAKE_BUILD_TYPE})
target_link_libraries(${TARGET_NAME} PUBLIC ${MONO_LIB_DIR}/libmono-static-sgen.lib)
target_include_directories(${TARGET_NAME} PUBLIC $<BUILD_INTERFACE:${MONO_HEADER_FILES_DIR}>)
# Link mono dependencies on Windows
target_link_libraries(${TARGET_NAME} PUBLIC "Mswsock.lib")
target_link_libraries(${TARGET_NAME} PUBLIC "ws2_32.lib")
target_link_libraries(${TARGET_NAME} PUBLIC "psapi.lib")
target_link_libraries(${TARGET_NAME} PUBLIC "version.lib")
target_link_libraries(${TARGET_NAME} PUBLIC "winmm.lib")
target_link_libraries(${TARGET_NAME} PUBLIC "Bcrypt.lib")
elseif(PLATFORM_LINUX)
# ...
elseif(PLATFORM_DARWIN)
# ...
else()
message(WARNING "Currently, Kytten Mono doesn't support this platform.")
endif()
关键步骤
包含头文件
#include <mono/jit/jit.h>
#include <mono/metadata/assembly.h>
#include <mono/metadata/mono-config.h>
设置 Mono 的相关路径和配置
Mono 可以指定 .NET 运行库路径和配置路径。如果不指定,则会默认尝试从常用安装目录寻找,如果最终找不到,后续初始化 AppDomain 时则会报错。
mono_set_dirs("lib/mono/lib", "lib/mono/etc");
除此之外,我们还需要解析 config 文件以初始化 DLL 映射,否则在 MacOS 和 Linux 上可能会出现找不到 DLL 的问题。
mono_config_parse("lib/mono/etc/mono/config");
初始化 AppDomain
AppDomain 主要用于隔离应用程序。一般,对于游戏引擎而言,我们创建一个 AppDomain 即可。
MonoDomain* domain = mono_jit_init("KyttenEngineScripting");
上述代码创建了一个名为 “KyttenEngineScripting” 的 AppDomain。
加载程序集
我们首先创建一个 .NET Framework 控制台程序,项目名为 MonoTest,编写 Program.cs:
using System;
namespace MonoTest
{
internal class Program
{
static int Main(string[] args)
{
Console.WriteLine("Hello, Mono!");
return 0;
}
}
}
这个程序的主方法会打印一行 “Hello, Mono!”,返回 0。
由于仅仅是为了测试,我们生成程序集 (此处是 exe 文件),并将程序集拷贝到合适的位置,以供后续加载。
然后我们在引擎内部尝试加载这个程序集。
我们在我们之前初始化的 AppDomain 中加载程序集:
MonoAssembly* assembly = mono_domain_assembly_open(domain, "MonoTest.exe");
if (!assembly)
{
// Handle error
}
添加内部调用 (Internal Call)
加下来我们添加一个内部调用,以供后续 C# 层面绑定。
我们创建一个静态方法 Hello:
// internal call test function
static void Hello(MonoString* name)
{
std::cout << "Hello, " << mono_string_to_utf8(name) << "!" << std::endl;
}
然后添加内部调用:
mono_add_internal_call("SomeNameSpace.SomeClass::Hello", reinterpret_cast<void*>(&Hello));
这样,C# 那边就可以声明一个外部方法来与之绑定了。
C# 调用 C++
我们需要根据内部调用所定义的命名空间名称、类型名称、方法名称来一一匹配,声明一个外部方法。我们在 MonoTest 项目中创建一个 InternalCall.cs:
using System.Runtime.CompilerServices;
namespace SomeNameSpace
{
class SomeClass
{
[MethodImpl(MethodImplOptions.InternalCall)]
public static extern void Hello(string name);
}
}
到这里,Hello 方法就完成了绑定。我们在 Main 方法中调用这个外部方法:
using System;
namespace MonoTest
{
internal class Program
{
static int Main(string[] args)
{
Console.WriteLine("Hello, Mono!");
// Invoke an external method
SomeNameSpace.SomeClass.Hello("Game Engine Developer");
return 0;
}
}
}
C++ 调用 C#
当然,我们最后希望在 C++ 层面调用 Main 方法,我们可以这样实现:
void* args[1];
args[0] = mono_array_new(domain, mono_get_string_class(), 0);
MonoClass* programClass = mono_class_from_name(image, "MonoTest", "Program");
MonoMethod* mainMethod = mono_class_get_method_from_name(programClass, "Main", 1);
MonoObject* result = mono_runtime_invoke(mainMethod, nullptr, args, nullptr);
int resultCode = *static_cast<int*>(mono_object_unbox(result));
这个代码片段,首先使用 mono_array_new
定义了一个空字符串数组,作为 Main 方法的参数。
然后使用 mono_class_from_name
通过命名空间名称、类型名称找到对应的类型,在 Mono 中对应 MonoClass* 。
然后使用 mono_class_get_method_from_name
根据方法名称和参数个数,找到对应的方法,在 Mono 中对应 MonoMethod* 。
最后,使用 mono_runtime_invoke
调用找到的方法,传入之前创建的空字符串数组作为参数,返回一个 MonoObject* 。如果该方法没有返回值,则此处返回 nullptr。
当然,我们实现的 Main 方法返回 0,我们需要对 MonoObject 进行拆箱,得到实际返回的 int 值。
这是一个比较典型的调用方法的例子。除了这种调用方式,Mono 还提供了其他非常多的方式,API 众多,这里不多提及。
最终执行效果:
Hello, Mono!
Hello, Game Engine Developer!
引擎内部封装
由于 Mono 提供的是面向过程的 C API,接下来只需要根据引擎的项目结构,对 Mono 相关结构和函数进行基于 C++ 的 OOP 封装,自己设计并实现脚本的生命周期,实现事件/消息系统,实现一套运行时反射系统等等。
更多
MacOS 和 Linux 平台上 Mono 的内嵌步骤与 Windows 平台实际上大同小异。
下一篇脚本系统相关的内容应该就是在引擎中内嵌 .NET 8.0 (LTS) 了,请耐心等候。
相关引用
Mono Embedding for Game Engines, Peter Nilsson, http://nilssondev.com/mono-guide/book/
Compiling Mono on Windows, mono-project.com, https://www.mono-project.com/docs/compiling-mono/windows/
Embedding Mono, mono-project.com, https://www.mono-project.com/docs/advanced/embedding/
扩展阅读
Porting the Unity Engine to .NET CoreCLR | xoofx
runtime/native-hosting.md at main · dotnet/runtime
Interop with Native Libraries | Mono
FlaxEngine/Source/Engine/Scripting at master · FlaxEngine/FlaxEngine