写点什么

从游戏脚本语言说起,剖析 Mono 搭建的脚本基础

2015 年 7 月 27 日

前言

在日常的工作中,我偶尔能遇到这样的问题:“为何游戏脚本在现在的游戏开发中变得不可或缺?”。那么这周我就写篇文章从游戏脚本聊起,分析一下游戏脚本因何出现,而 Mono 又能提供怎样的脚本基础。最后会通过模拟 Unity3D 游戏引擎中的脚本功能,将 Mono 运行时嵌入到一个非托管(C/C++)程序中,实现脚本语言和“引擎”之间的分离。

Why?从为何需要游戏脚本说起

首先聊聊为何现在的游戏开发需要使用游戏脚本这个话题。

为何需要有脚本系统呢?脚本系统又是因何而出现的呢?其实游戏脚本并非一个新的名词或者技术,早在暴雪的《魔兽世界》开始火爆的年代,人们便熟知了一个叫做 Lua 的脚本语言。而当时其实有很多网游都不约而同地使用了 Lua 作为脚本语言,比如网易的大话西游系列。但是在单机游戏流行的年代,我们却很少听说有什么单机游戏使用了脚本技术。这又是为什么呢?因为当时的硬件水平不高,所以需要使用 C/C++ 这样的语言尽量压榨硬件的性能,同时,单机游戏的更新换代并不如网游那么迅速,所以开发时间、版本迭代速度并非其考虑的第一要素,因而可以使用 C/C++ 这样开发效率不高的语言来开发游戏。

但是随着时间的推移,硬件水平逐年提升,压榨硬件性能的需求已经不再迫切。相反,此时网游的兴起却对开发速度、版本更迭提出了更高的要求。所以开发效率并不高效,且投资、巨大风险很高的 C/C++ 便不再适应市场的需求了。更加现实的问题是,随着 Java、.Net 甚至是 JavaScript 等语言的流行,程序员可以选择的语言越来越多,这更加导致了优秀的 C/C++ 程序员所占比例越来越小。而网游市场的不断扩大,对人才的需求也越来越大,这就造成了大量的人才空缺,也就反过来提高了使用 C/C++ 开发游戏的成本。而由于 C/C++ 是门入门容易进阶难的语言,其高级特性和高度灵活性带来的高风险也是每个项目使用 C/C++ 进行开发时所不得不考虑的问题。

一个可以解决这种困境的举措便是在游戏中使用脚本。可以说游戏脚本的出现,不仅解决了由于 C/C++ 难以精通而带来的开发效率问题,而且还降低了使用 C/C++ 进行开发的项目风险和成本。从此,脚本与游戏开发相得益彰,互相促进,逐渐成为了游戏开发中不可或缺的一个部分。

而到了如今手游兴起的年代,市场的需求变得更加庞大且变化更加频繁。这就更加需要用脚本语言来提高项目的开发效率、降低项目的成本。
而作为游戏脚本,它具体的优势都包括哪些呢?

  1. 易于学习,代码方便维护。适合快速开发。
  2. 开发成本低。因为易于学习,所以可以启用新人,同时开发速度快,这些都是降低成本的方法。

因此,包括 Unity3D 在内的众多游戏引擎,都提供了脚本接口,让开发者在开发项目时能够摆脱 C/C++(注:Unity3D 本身是用 C/C++ 写的)的束缚,这其实是变相降低了游戏开发的门槛,吸引了很多独立开发者和游戏制作爱好者。

What? Mono 提供的脚本机制

首先一个问题:Mono 是什么?

Mono 是一个由 Xamarin 公司赞助的开源项目。它基于通用语言架构(Common Language Infrastructure ,缩写为 CLI)和 C#的 ECMA 标准(Ecma-335、Ecam-334),提供了微软的.Net 框架的另一种实现。与微软的.Net 框架不同的是,Mono 具备了跨平台的能力,也就是说它不仅能运行在 Windows 系统上,而且还可以运行在 Mac OSX、Linux 甚至是一些游戏平台上。

所以把 Mono 作为跨平台的方案是一个不错的选择。但 Mono 又是如何提供这种脚本功能的呢?

如果需要利用 Mono 为应用开发提供脚本功能,那么其中一个前提就是需要将 Mono 的运行时嵌入到应用中,因为只有这样才有可能使托管代码和脚本能够在原生应用中使用。我们可以发现,将 Mono 运行时嵌入应用中是多么的重要。但在讨论如何将 Mono 运行时嵌入原生应用中去之前,我们首先要搞清楚 Mono 是如何提供脚本功能的,以及 Mono 提供的到底是怎样的脚本机制。

Mono 和脚本

本小节将会讨论如何利用 Mono 来提高我们的开发效率以及拓展性而无需将已经写好的 C/C++ 代码重新用 C#写一遍,也就是 Mono 是如何提供脚本功能的。

使用一种编程语言开发游戏是比较常见的一种情况。因而游戏开发者往往需要在高效率的低级语言和低效率的高级语言之间抉择。例如一个用 C/C++ 开发的应用的结构如下图:

可以看到低级语言和硬件打交道的方式更加直接,所以其效率更高。

可以看到高级语言并没有和硬件直接打交道,所以效率较低。

如果以速度作为衡量语言的标准,那么语言从低级到高级的大体排名如下:

  • 汇编语言;
  • C/C++,编译型静态不安全语言;
  • C#、Java,编译型静态安全语言;
  • Python, Perl, JavaScript,解释型动态安全语言。

开发者在选择适合自己的开发语言时,的确面临着很多现实的问题。

高级语言对开发者而言效率更高,也更加容易掌握,但高级语言并不具备低级语言的那种运行速度,甚至对硬件的要求更高,这在某种程度上的确也决定了一个项目到底是成功还是失败。

因此,如何平衡两者,或者说如何融合两者的优点,便变得十分重要和迫切。脚本机制便在此时应运而生。游戏引擎由富有经验的开发人员使用 C/C++ 开发,而一些具体项目中功能的实现,例如 UI、交互等等则使用高级语言开发。

通过使用高级脚本语言,开发者便融合了低级语言和高级语言的优点。同时提高了开发效率,如同第一节中所讲的,引入脚本机制之后开发效率提升了,可以快速开发原型,而不必把大量的时间浪费在 C/C++ 上。

脚本语言同时提供了安全的开发沙盒模式,也就是说开发者无需担心 C/C++ 引擎中的具体实现细节,也无需关注例如资源管理和内存管理这些事情的细节,这在很大程度上简化了应用的开发流程。

而 Mono 则提供了这种脚本机制实现的可能性。即允许开发者使用 JIT 编译的代码作为脚本语言为他们的应用提供拓展。

目前很多脚本语言趋向于选择解释型语言,例如 cocos2d-js 使用的 JavaScript,因此效率无法与原生代码相比。而 Mono 则提供了一种将脚本语言通过 JIT 编译为原生代码的方式,提高了脚本语言的效率。例如,Mono 提供了一个原生代码生成器,可以提高应用的运行效率。它同时提供了很多方便的调用原生代码的接口。

在为一个应用提供脚本机制时,往往需要和低级语言交互。这便不得不提到将 Mono 的运行时嵌入到应用中的必要性了。那么接下来,我将会讨论一下如何将 Mono 运行时嵌入到应用中。

Mono 运行时的嵌入

既然我们明确了 Mono 运行时嵌入应用的重要性,那么如何将它嵌入应用中就成为了下一个值得讨论的话题。

这个小节我会为大家分析一下 Mono 运行时究竟是如何被嵌入到应用中的,以及如何在原生代码中调用托管方法,以及如何在托管代码中调用原生方法。而众所周知的一点是,Unity3D 游戏引擎本身是用 C/C++ 写成的,所以本节就以 Unity3D 游戏引擎为例,假设此时我们已经有了一个用 C/C++ 写好的应用(Unity3D)。

将 Mono 运行时嵌入到这个应用之后,应用就获取了一个完整的虚拟机运行环境。而这一步需要将“libmono”和应用链接,链接完成后,C++ 应用的地址空间就会像下图这样:

而在 C/C++ 代码中,我们需要将 Mono 运行时初始化,一旦 Mono 运行时初始化成功,那么下一步最重要的就是将 CIL/.Net 代码加载进来。加载之后的地址空间将会如下图所示:

那些 C/C++ 代码,我们通常称之为非托管代码,而通过 CIL 编译器生成 CIL 代码我们通常称之为托管代码。

将 Mono 运行时嵌入应用可以分为 3 个步骤:

  1. 编译 C++ 程序和链接 Mono 运行时;
  2. 初始化 Mono 运行时;
  3. C/C++ 和 C#/CIL 的交互。

下面我们一步一步地进行。首先我们需要将 C++ 程序进行编译并链接 Mono 运行时。此时我们会用到 pkg-config 工具。在 Mac 上使用 homebrew 进行安装,在终端中输入命令”brew install pkgconfig”即可。
待 pkg-config 安装完毕之后,我们新建一个 C++ 文件,命名为 unity.cpp,作为原生代码部分。我们需要将这个 C++ 文件进行编译,并和 Mono 运行时链接。

在终端输入:

复制代码
g++ unity.cpp -framework CoreFoundation -lobjc -liconv `pkg-config --cflags --libs mono-2`

此时,经过编译和链接之后,unity.cpp 和 Mono 运行时被编译成了可执行文件。

到此,我们需要将 Mono 的运行时初始化。所以再重新回到刚刚新建的 unity.cpp 文件中,我们要在 C++ 文件中来进行运行时的初始化工作,即调用 mono_jit_init 方法。代码如下:

复制代码
#include <mono/jit/jit.h>
#include <mono/metadata/assembly.h>
#include <mono/metadata/class.h>
#include <mono/metadata/debug-helpers.h>
#include <mono/metadata/mono-config.h>
MonoDomain* domain;
domain = mono_jit_init(managed_binary_path);

mono_jit_init 这个方法会返回一个 MonoDomain,用来作为盛放托管代码的容器。其中的参数 managed_binary_path,即应用运行域的名字。除了返回 MonoDomain 之外,这个方法还会初始化默认框架版本,即 2.0 或 4.0,这个主要由使用的 Mono 版本决定。当然,我们也可以手动指定版本。只需要调用下面的方法即可:

复制代码
domain = mono_jit_init_version ("unity", ""v2.0.50727);

这样就获取应用域——domain。但是当 Mono 运行时被嵌入一个原生应用时,它显然需要一种方法来确定自己所需要的运行时程序集以及配置文件。默认情况下它会使用系统中定义的位置。

如图,可以看到,在一台电脑上可以存在很多不同版本的 Mono,如果我们的应用需要特定的运行时,就也需要指定其程序集和配置文件的位置。

为了选择所需 Mono 版本,可以使用 mono_set_dirs 方法:

复制代码
mono_set_dirs("/Library/Frameworks/Mono.framework/Home/lib", "/Library/Frameworks/Mono.framework/Home/etc");

这样,我们就设置了 Mono 运行时的程序集和配置文件路径。

当然,Mono 运行时在执行一些具体功能时,可能还需要依靠额外的配置文件来进行。所以我们有时也需要为 Mono 运行时加载这些配置文件,通常我们使用 mono_config_parse 方法来加载这些配置文件。

当 mono_config_parse 的参数为 NULL 时,Mono 运行时将加载 Mono 的配置文件。当然作为开发者,我们也可以加载自己的配置文件,只需要将配置文件的文件名作为 mono_config_parse 方法的参数即可。

Mono 运行时的初始化工作到此完成。接下来需要加载程序集并且运行它。

这里需要用到 MonoAssembly 和 mono_domain_assembly_open 方法。

复制代码
const char* managed_binary_path = "./ManagedLibrary.dll";
MonoAssembly *assembly;
assembly = mono_domain_assembly_open (domain, managed_binary_path);
if (!assembly)
error ();

上面的代码会将当前目录下的 ManagedLibrary.dll 文件中的内容加载进已经创建好的 domain 中。此时需要注意的是 Mono 运行时仅仅是加载代码而没有立刻执行这些代码。

如果要执行这些代码,则需要调用被加载的程序集中的方法。或者当你有一个静态的主方法时(也就是程序入口),可以通过 mono_jit_exec 方法调用这个静态入口。

下面我将举一个将 Mono 运行时嵌入 C/C++ 程序的例子,这个例子的主要流程是加载一个由 C#文件编译成的 DLL 文件,之后调用一个 C#的方法输出 Hello World。

首先,我们完成 C#部分的代码。

复制代码
namespace ManagedLibrary
{
public static class MainTest
{
public static void Main()
{
System.Console.WriteLine("Hello World");
}
}
}

在这个文件中,我们实现了输出 Hello World 的功能。之后我们将它编译为 DLL 文件。这里我也直接使用了 Mono 的编译器——mcs。在终端命令行使用 mcs 编译该 cs 文件。同时为了生成 DLL 文件,还需要加上 -t:library 选项。

复制代码
mcs ManagedLibrary.cs -t:library

这样便得到了 cs 文件编译之后的 DLL 文件,叫做 ManagedLibrary.dll。

接下来,完成 C++ 部分的代码。嵌入 Mono 的运行时,同时加载刚刚生成 ManagedLibrary.dll 文件,并且执行其中的 Main 方法输出 Hello World。

复制代码
#include <mono/jit/jit.h>
#include <mono/metadata/assembly.h>
#include <mono/metadata/class.h>
#include <mono/metadata/debug-helpers.h>
#include <mono/metadata/mono-config.h>
MonoDomain *domain;
int main()
{
const char* managed_binary_path = "./ManagedLibrary.dll";
// 获取应用域
domain = mono_jit_init (managed_binary_path);
//mono 运行时的配置
mono_set_dirs("/Library/Frameworks/Mono.framework/Home/lib", "/Library/Frameworks/Mono.framework/Home/etc");
mono_config_parse(NULL);
// 加载程序集 ManagedLibrary.dll
MonoAssembly* assembly = mono_domain_assembly_open(domain, managed_binary_path);
MonoImage* image = mono_assembly_get_image(assembly);
// 获取 MonoClass
MonoClass* main_class = mono_class_from_name(image, "ManagedLibrary", "MainTest");
// 获取要调用的 MonoMethodDesc
MonoMethodDesc* entry_point_method_desc = mono_method_desc_new("ManagedLibrary.MainTest:Main()", true);
MonoMethod* entry_point_method = mono_method_desc_search_in_class(entry_point_method_desc, main_class);
mono_method_desc_free(entry_point_method_desc);
// 调用方法
mono_runtime_invoke(entry_point_method, NULL, NULL, NULL);
// 释放应用域
mono_jit_cleanup(domain);
return 0;
}

之后编译运行,可以看到屏幕上输出的 Hello World。

既然要提供脚本功能,将 Mono 运行时嵌入 C/C++ 程序之后,只在 C/C++ 程序中调用 C#中定义的方法显然还是不够的。脚本机制的最终目的还是希望能够在脚本语言中使用原生的代码,所以下面我将站在 Unity3D 游戏引擎开发者的角度,继续探索一下如何在 C#文件(脚本文件)中调用 C/C++ 程序中的代码(游戏引擎)。

How? 如何模拟 Unity3D 中的脚本机制

首先,假设我们要实现的是 Unity3D 的组件系统。为了方便游戏开发者能够在脚本中使用组件,首先需要在 C#文件中定义一个 Component 类。

复制代码
// 脚本中的组件 Component
public class Component
{
public int ID { get; }
private IntPtr native_handle;
}

与此同时,在 Unity3D 游戏引擎(C/C++)中,则必然有和脚本中的 Component 相对应的结构。

复制代码
// 游戏引擎中的组件 Component
struct Component
{
int id;
}

托管代码(C#)中的接口

可以看到此时组件类 Component 只有一个属性,即 ID。我们再为组件类增加一个属性,Tag。

之后,为了使托管代码能够和非托管代码交互,需要在 C#文件中引入命名空间 System.Runtime.CompilerServices,同时提供一个 IntPtr 类型的句柄以便于托管代码和非托管代码之间引用数据。(IntPtr 类型被设计成整数,其大小适用于特定平台。即是说,此类型的实例在 32 位硬件和操作系统中将是 32 位,在 64 位硬件和操作系统上将是 64 位。IntPtr 对象常可用于保持句柄。 例如,IntPtr 的实例广泛地用在 System.IO.FileStream 类中,以便保持文件句柄。)

最后,我们将 Component 对象的构建工作由托管代码 C#移交给非托管代码 C/C++,这样游戏开发者只需要专注于游戏脚本即可,无需关注 C/C++ 层面即游戏引擎层面的具体实现逻辑了,我在此提供两个方法即用来创建 Component 实例的方法:GetComponents,以及获取 ID 的 get_id_Internal 方法。

这样在 C#端,我们定义了一个 Component 类,主要目的是为游戏脚本提供相应的接口,而非具体逻辑的实现。下面便是在 C#代码中定义的 Component 类。

复制代码
using System;
using System.Runtime.CompilerServices;
namespace ManagedLibrary
{
public class Component
{
// 字段
private IntPtr native_handle = (IntPtr)0;
// 方法
[MethodImpl(MethodImplOptions.InternalCall)]
public extern static Component[] GetComponents();
[MethodImpl(MethodImplOptions.InternalCall)]
public extern static int get_id_Internal(IntPtr native_handle);
// 属性
public int ID
{
get
{
return get_id_Internal(this.native_handle);
}
}
public int Tag {
[MethodImpl(MethodImplOptions.InternalCall)]
get;
}
}
}

之后,我们还需要创建这个类的实例并且访问它的两个属性,所以还要再定义另一个类 Main,来完成这项工作。

Main 的实现如下:

复制代码
// Main.cs
namespace ManagedLibrary
{
public static class Main
{
public static void TestComponent ()
{
Component[] components = Component.GetComponents();
foreach(Component com in components)
{
Console.WriteLine("component id is " + com.ID);
Console.WriteLine("component tag is " + com.Tag);
}
}
}
}

非托管代码(C/C++)的逻辑实现

完成了 C#部分的代码之后,我们需要将具体的逻辑在非托管代码端实现。而我上文之所以要在 Component 类中定义两个属性:ID 和 Tag,是为了使用两种不同的方式访问这两个属性,其中之一就是直接将句柄作为参数传入到 C/C++ 中,例如上文我提供的 get_id_Internal 这个方法,它的参数便是句柄。第二种方法则是在 C/C++ 代码中通过 Mono 提供的 mono_field_get_value 方法直接获取对应的组件类型的实例。

获取组件 Component 类中的属性有两种不同的方法:

复制代码
// 获取属性
int ManagedLibrary_Component_get_id_Internal(const Component* component)
{
return component->id;
}
int ManagedLibrary_Component_get_tag(MonoObject* this_ptr)
{
Component* component;
mono_field_get_value(this_ptr, native_handle_field, reinterpret_cast<void*>(&Component));
return component->tag;
}

之后,由于我在 C#代码中基本只提供接口,而不提供具体逻辑实现。所以还需要在 C/C++ 代码中实现获取 Component 组件的具体逻辑,之后再以在 C/C++ 代码中创建的实例为样本,调用 Mono 提供的方法在托管环境中创建相同的类型实例并且初始化。

由于 C#中的 GetComponents 方法返回的是一个数组,所以对应的需要使用 MonoArray 从 C/C++ 中返回一个数组。C#代码中 GetComponents 方法在 C/C++ 中对应的具体逻辑如下:

复制代码
MonoArray* ManagedLibrary_Component_GetComponents()
{
MonoArray* array = mono_array_new(domain, Component_class, num_Components);
for(uint32_t i = 0; i < num_Components; ++i)
{
MonoObject* obj = mono_object_new(domain, Component_class);
mono_runtime_object_init(obj);
void* native_handle_value = &Components[i];
mono_field_set_value(obj, native_handle_field, &native_handle_value);
mono_array_set(array, MonoObject*, i, obj);
}
return array;
}

其中 num_Components 是 uint32_t 类型的字段,用来表示数组中组件的数量,下面我会为它赋值为 5。之后通过 Mono 提供的 mono_object_new 方法创建 MonoObject 的实例。而需要注意的是代码中的 Components[i],Components 便是在 C/C++ 代码中创建的 Component 实例,这里用来给 MonoObject 的实例初始化赋值。

创建 Component 实例的过程如下:

复制代码
num_Components = 5;
Components = new Component[5];
for(uint32_t i = 0; i < num_Components; ++i)
{
Components[i].id = i;
Components[i].tag = i * 4;
}

C/C++ 代码中创建的 Component 的实例的 id 为 i,tag 为 i * 4。

最后将 C#中的接口和 C/C++ 中的具体实现关联起来。即通过 Mono 的 mono_add_internal_call 方法来实现,也即在 Mono 的运行时中注册刚刚用 C/C++ 实现的具体逻辑,以便将托管代码(C#)和非托管代码(C/C++)绑定。

复制代码
// get_id_Internal
mono_add_internal_call("ManagedLibrary.Component::get_id_Internal", reinterpret_cast<void*>(ManagedLibrary_Component_get_id_Internal));
//Tag get
mono_add_internal_call("ManagedLibrary.Component::get_Tag", reinterpret_cast<void*>(ManagedLibrary_Component_get_tag));
//GetComponents
mono_add_internal_call("ManagedLibrary.Component::GetComponents", reinterpret_cast<void*>(ManagedLibrary_Component_GetComponents));

这样,便使用非托管代码(C/C++)实现了获取组件、创建和初始化组件的具体功能,接下来为了验证是否成功地模拟了将 Mono 运行时嵌入“Unity3D 游戏引擎”中,我们需要编译代码并且查看输出是否正确。

首先将 C#代码编译为 DLL 文件。在终端直接使用 Mono 的 mcs 编译器来完成这个工作。

运行后生成了 ManagedLibrary.dll 文件。

之后将 unity.cpp 和 Mono 运行时链接、编译,会生成一个 a.out 文件(在 Mac 上)。执行 a.out,可以看到在终端上输出了创建的组件的 ID 和 Tag 的信息。

后记

通过本文,我们可以看到游戏脚本语言出现的必然性。同时也应该了解 Unity3D 是 C/C++ 实现的,但是它通过 Mono 提供了一套脚本机制,在方便游戏开发者快速开发游戏的同时也降低了游戏开发的门槛。


感谢丁晓昀对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群)。

2015 年 7 月 27 日 00:423548

评论

发布
暂无评论
发现更多内容

命题架构设计 - 食堂就餐卡系统

知识乞丐

极客大学架构师训练营

脑子不够怎么学架构

紫极

闲谈 极客大学架构师训练营

架构师训练营第一周作业

fenix

常见的几种广告形式以及 OTT 广告与在线广告区别

子悠

计算广告 互联网广告

Week01 食堂就餐卡系统设计

极客大学架构师训练营

第一周学习总结

Vincent

极客时间

【极客大学】【架构师训练营】【第一周】 食堂就餐卡系统设计

NieXY

极客大学 极客大学架构师训练营

第一周学习总结

Jeremy

食堂就餐卡设计

吴吴

架构师训练营第一周总结作业

兔狲

极客大学架构师训练营

第一周作业一:食堂就餐卡系统设计

iHai

架构是训练营

「架构师训练营」架构方法:架构师如何做架构-总结

隆隆

食堂就餐卡系统设计

atlasman

【Week01】架构师如何做架构

Aldaron

成为一名架构师

谭焜鹏

【架构师训练营 - week1 -1】食堂就餐卡系统设计

早睡早起

食堂就餐卡系统设计

哼哼

架构师训练Week1 - 食堂就餐卡系统架构设计

伊利是个圈

架构设计 极客大学架构师训练营 UML 作业

架构师如何做架构

atlasman

架构总结

高高

食堂就餐系统架构设计

K先生

作业二:根据当周学习情况,完成一篇学习总结

叶荣添CANADA

第一周作业二:学习总结

iHai

极客大学架构师训练营

食堂就餐卡系统设计

Vincent

极客时间

小白学软件架构

鸠摩智

架构 UML

餐卡管理系统关键设计图

lei Shi

架构师训练营第1周-心得体会

Larry

架构师训练营(第1周作业)

李德政

极客大学架构师训练营

第一周作业

lwy

架构学习第一周总结

云峰

架构设计心得

吴吴

2021年全国大学生计算机系统能力大赛操作系统设计赛 技术报告会

2021年全国大学生计算机系统能力大赛操作系统设计赛 技术报告会

从游戏脚本语言说起,剖析Mono搭建的脚本基础-InfoQ