在平常的工做中,我偶尔能遇到这样的问题:“为什么游戏脚本在如今的游戏开发中变得不可或缺?”。那么这周我就写篇文章从游戏脚本聊起,分析一下游戏脚本因何出现,而mono又能提供怎样的脚本基础。最后会经过模拟Unity3D游戏引擎中的脚本功能,将Mono运行时嵌入到一个非托管(C/C++)程序中,实现脚本语言和“引擎”之间的分离。javascript
首先聊聊为什么如今的游戏开发须要使用游戏脚本这个话题。html
为什么须要有脚本系统呢?脚本系统又是因何而出现的呢?其实游戏脚本并不是一个新的名词或者技术,早在暴雪的《魔兽世界》开始火爆的年代,人们便熟知了一个叫作Lua的脚本语言。而当时其实有不少网游都不约而同的使用了Lua做为脚本语言,好比网易的大话西游系列。
可是在单机游戏流行的年代,咱们却不多据说有什么单机游戏使用了脚本技术。这又是为何呢?由于当时的硬件水平不高,因此须要使用C/C++这样的语言来尽可能压榨硬件的性能,同时,单机游戏的更新换代并不如网游那么迅速,因此开发时间、版本迭代速度并不是其考虑的第一要素,于是可使用C/C++这样开发效率不高的语言来开发游戏。java
可是随着时间的推移,硬件水平逐年水涨船高,压榨硬件性能的需求已经再也不迫切。相反,此时网游的兴起却对开发速度、版本更迭提出了更高的要求。因此开发效率并不高效,且投资巨大风险很高的C/C++便再也不适应市场的需求了。而更加现实的问题是,随着java、.net甚至是javascript等语言的流行,程序员能够选择的语言愈来愈多,这更加致使了优秀的C/C++程序员所占比例愈来愈小。而网游市场的不断扩大,这种对人才的需求也一样愈来愈大,这就形成了大量的人才空缺,也就反过来提升了使用C/C++开发游戏的成本。而因为C/C++是门入门容易进阶难的语言,其高级特性和高度灵活性带来的高风险也是每一个项目使用C/C++进行开发时,所不得不考虑的问题。程序员
而一个能够解决这种困境的举措即是在游戏中使用脚本。能够说游戏脚本的出现,不只解决了因为C/C++难以精通而带来的开发效率问题,并且还下降了使用C/C++进行开发的项目风险和成本。今后,脚本与游戏开发相得益彰,互相促进,逐渐成为了游戏开发中不可或缺的一个部分。编程
而到了现在手游兴起的年代,市场的需求变得更加庞大且变化更加频繁。这就更加要求须要有脚本语言来提升项目的开发效率、下降项目的成本。
而做为游戏脚本,它具体的优点都包括哪些呢?数组
所以,包括Unity3D在内的众多游戏引擎,都提供了脚本接口,让开发者在开发项目时可以摆脱C/C++(注:Unity3D自己是用C/C++写的)的束缚,这实际上是变相的下降了游戏开发的门槛,吸引了不少独立开发者和游戏制做爱好者。安全
首先一个问题:Mono是什么?架构
Mono是一个由Xamarin公司所赞助的开源项目。它基于通用语言架构(Common Language Infrastructure ,缩写为CLI)和C#的ECMA 标准(Ecma-33五、Ecam-334),提供了微软的.Net框架的另外一种实现。与微软的.Net框架不一样的是,Mono具有了跨平台的能力,也就是说它不只能运行在Windows系统上,并且还能够运行在Mac OSX、Linux甚至是一些游戏平台上。框架
因此把它做为跨平台的方案是像Unity3D这种开发跨平台游戏的游戏引擎的一个不错的选择。但Mono又是如何提供这种脚本的功能的呢?dom
若是须要利用Mono为应用开发提供脚本功能,那么其中一个前提就是须要将Mono的运行时嵌入到应用中,由于只有这样才有可能使得托管代码和脚本可以在原生应用中使用。因此,咱们能够发现,将Mono运行时嵌入应用中是多么的重要。但在讨论如何将Mono运行时嵌入原生应用中去以前,咱们首先要搞清楚Mono是如何提供脚本功能的,以及Mono提供的究竟是怎样的脚本机制。
本小节将会讨论如何利用Mono来提升咱们的开发效率以及拓展性而无需将已经写好的C/C++代码从新用C#写一遍,也就是Mono是如何提供脚本功能的。
经常使用一种编程语言开发游戏是比较常见的一种状况。于是游戏开发者每每须要在高效率的低级语言和低效率的高级语言之间抉择。例如一个用C/C++开发的应用的结构以下图:
能够看到低级语言和硬件打交道的方式更加直接,因此其效率更高。
能够看到高级语言并无和硬件直接打交道,因此其效率较低。
若是以速度做为衡量语言的标准,那么语言从低级到高级的大致排名以下:
开发者在选择适合本身的开发语言时,的确面临着不少现实的问题。
高级语言对开发者而言效率更高,也更加容易掌握,但高级语言也并不具有低级语言的那种运行速度、甚至对硬件的要求更高,这在某种程度上的确也决定了一个项目究竟是成功仍是失败。
所以,如何平衡二者,或者说如何融合二者的优势,便变得十分重要和迫切。脚本机制便在此时应运而生。游戏引擎由富有经验的开发人员使用C/C++开发,而一些具体项目中功能的实现,例如UI、交互等等则使用高级语言开发。
经过使用高级脚本语言,开发者便融合了低级语言和高级语言的优势。同时提升了开发效率,如同第一节中所讲的,引入脚本机制以后开发效率提高了,能够快速的开发原型,而没必要把大量的时间浪费在C/C++上。
脚本语言同时提供了安全的开发沙盒模式,也就是说开发者无需担忧C/C++开发的引擎中的具体实现细节,也无需关注例如资源管理和内存管理这些事情的细节,这在很大程度上简化了应用的开发流程。
而Mono则提供了这种脚本机制实现的可能性。即容许开发者使用JIT编译的代码做为脚本语言为他们的应用提供拓展。
目前不少脚本语言的选择趋向于解释型语言,例如cocos2d-js使用的javascript。所以效率没法与原生代码相比。而Mono则提供了一种将脚本语言经过JIT编译为原生代码的方式,提升了脚本语言的效率。例如,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运行时嵌入咱们的应用,能够分为三个步骤:
让咱们一步一步的进行。首先咱们须要将C++程序进行编译并连接Mono运行时。此时咱们会用到pkg-config工具。在Mac上使用homebrew来进行安装,在终端中输入命令“brew install pkgconfig”,能够看到终端会有以下的输出内容:
==> Downloading https://homebrew.bintray.com/bottles/pkg-config-0.28.mavericks.bottle.2.tar.gz ######################################################################## 100.0% ==> Pouring pkg-config-0.28.mavericks.bottle.2.tar.gz 🍺 /usr/local/Cellar/pkg-config/0.28: 10 files, 604K
结束以后,证实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++程序中的代码(游戏引擎)。
首先,假设咱们要实现的是Unity3D的组件系统。为了方便游戏开发者可以在脚本中使用组件,那么咱们首先要在C#文件中定义一个Component类。
//脚本中的组件Component public class Component { public int ID { get; } private IntPtr native_handle; }
与此同时,在Unity3D游戏引擎(C/C++)中,则必然有和脚本中的Component相对应的结构。
//游戏引擎中的组件Component struct Component { int id; }
能够看到此时组件类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#部分的代码以后,咱们须要将具体的逻辑在非托管代码端实现。而我上文之因此要在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++)实现了获取组件、建立和初始化组件的具体功能,完整的代码以下。
#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> struct Component { int id; int tag; }; Component* Components; uint32_t num_Components; MonoClassField* native_handle_field; MonoDomain* domain; MonoClass* Component_class; //获取属性 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; } //获取组件 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; } int main(int argc, const char * argv[]) { mono_set_dirs("/Library/Frameworks/Mono.framework/Versions/3.12.0/lib/", "/Library/Frameworks/Mono.framework/Home/etc"); mono_config_parse(NULL); const char* managed_binary_path = "./ManagedLibrary.dll"; domain = mono_jit_init(managed_binary_path); MonoAssembly* assembly = mono_domain_assembly_open(domain, managed_binary_path); MonoImage* image = mono_assembly_get_image(assembly); mono_add_internal_call("ManagedLibrary.Component::get_id_Internal", reinterpret_cast<void*>(ManagedLibrary_Component_get_id_Internal)); mono_add_internal_call("ManagedLibrary.Component::get_Tag", reinterpret_cast<void*>(ManagedLibrary_Component_get_tag)); mono_add_internal_call("ManagedLibrary.Component::GetComponents", reinterpret_cast<void*>(ManagedLibrary_Component_GetComponents)); Component_class = mono_class_from_name(image, "ManagedLibrary", "Component"); native_handle_field = mono_class_get_field_from_name(Component_class, "native_handle"); 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; } MonoClass* main_class = mono_class_from_name(image, "ManagedLibrary", "Main"); const bool include_namespace = true; MonoMethodDesc* managed_method_desc = mono_method_desc_new("ManagedLibrary.Main:TestComponent()", include_namespace); MonoMethod* managed_method = mono_method_desc_search_in_class(managed_method_desc, main_class); mono_method_desc_free(managed_method_desc); mono_runtime_invoke(managed_method, NULL, NULL, NULL); mono_jit_cleanup(domain); delete[] Components; return 0; }
接下来为了验证咱们是否成功的模拟了将Mono运行时嵌入“Unity3D游戏引擎”中,咱们须要将代码编译而且查看输出是否正确。
首先将C#代码编译为DLL文件。咱们在终端直接使用Mono的mcs编译器来完成这个工做。
运行后生成了ManagedLibrary.dll文件。
以后将unity.cpp和Mono运行时连接、编译,会生成一个a.out文件(在Mac上)。执行a.out,能够看到在终端上输出了建立出的组件的ID和Tag的信息。
经过本文,咱们能够看到游戏脚本语言出现的必然性。同时也应该了解Unity3D的底层是C/C++实现的,可是它经过Mono提供了一套脚本机制,以方便游戏开发者快速的开发游戏同时也下降了游戏开发的门槛。