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

0x00 前言

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

0x01 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++写的)的束缚,这其实是变相的降低了游戏开发的门槛,吸引了很多独立开发者和游戏制作爱好者。

0x02 What?Mono提供的脚本机制

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

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

所以把它作为跨平台的方案是像Unity3D这种开发跨平台游戏的游戏引擎的一个不错的选择。但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运行时嵌入我们的应用,可以分为三个步骤:

  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 
#include 
#include 
#include 
#include 

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 
#include 
#include 
#include 
#include 
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++程序中的代码(游戏引擎)。

0x03 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(&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(ManagedLibrary_Component_get_id_Internal));
//Tag get
mono_add_internal_call("ManagedLibrary.Component::get_Tag", reinterpret_cast(ManagedLibrary_Component_get_tag));
//GetComponents
mono_add_internal_call("ManagedLibrary.Component::GetComponents", reinterpret_cast(ManagedLibrary_Component_GetComponents));

这样,我们便使用非托管代码(C/C++)实现了获取组件、创建和初始化组件的具体功能,接下来为了验证我们是否成功的模拟了将Mono运行时嵌入“Unity3D游戏引擎”中,我们需要将代码编译并且查看输出是否正确。
首先将C#代码编译为DLL文件。我们在终端直接使用Mono的mcs编译器来完成这个工作。
运行后生成了ManagedLibrary.dll文件。
之后将unity.cpp和Mono运行时链接、编译,会生成一个a.out文件(在Mac上)。执行a.out,可以看到在终端上输出了创建出的组件的ID和Tag的信息。

0x04 后记

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

你可能感兴趣的:(c#,游戏开发,mono,unity3d)