從游戲腳本語言說起,剖析Mono搭建的腳本基礎

jopen 9年前發布 | 18K 次閱讀 Mono

前言

在日常的工作中,我偶爾能遇到這樣的問題:“為何游戲腳本在現在的游戲開發中變得不可或缺?”。那么這周我就寫篇文章從游戲腳本聊起,分析一下游戲 腳本因何出現,而Mono又能提供怎樣的腳本基礎。最后會通過模擬Unity3D游戲引擎中的腳本功能,將Mono運行時嵌入到一個非托管(C/C++) 程序中,實現腳本語言和“引擎”之間的分離。

Why?從為何需要游戲腳本說起

首先聊聊為何現在的游戲開發需要使用游戲腳本這個話題。

為何需要有腳本系統呢?腳本系統又是因何而出現的呢?其實游戲腳本并非一個新的名詞或者技術,早在暴雪的《魔獸世界》開始火爆的年代,人們便熟知了 一個叫做Lua的腳本語言。而當時其實有很多網游都不約而同地使用了Lua作為腳本語言,比如網易的大話西游系列。但是在單機游戲流行的年代,我們卻很少 聽說有什么單機游戲使用了腳本技術。這又是為什么呢?因為當時的硬件水平不高,所以需要使用C/C++這樣的語言盡量壓榨硬件的性能,同時,單機游戲的更 新換代并不如網游那么迅速,所以開發時間、版本迭代速度并非其考慮的第一要素,因而可以使用C/C++這樣開發效率不高的語言來開發游戲。

 從游戲腳本語言說起,剖析Mono搭建的腳本基礎

但是隨著時間的推移,硬件水平逐年提升,壓榨硬件性能的需求已經不再迫切。相反,此時網游的興起卻對開發速度、版本更迭提出了更高的要求。所以開發 效率并不高效,且投資、巨大風險很高的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搭建的腳本基礎

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++開發的應用的結構如下圖:

 從游戲腳本語言說起,剖析Mono搭建的腳本基礎

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

 從游戲腳本語言說起,剖析Mono搭建的腳本基礎

可以看到高級語言并沒有和硬件直接打交道,所以效率較低。

如果以速度作為衡量語言的標準,那么語言從低級到高級的大體排名如下:

  • 匯編語言;
  • 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搭建的腳本基礎

將Mono運行時嵌入到這個應用之后,應用就獲取了一個完整的虛擬機運行環境。而這一步需要將“libmono”和應用鏈接,鏈接完成后,C++應用的地址空間就會像下圖這樣:

 從游戲腳本語言說起,剖析Mono搭建的腳本基礎

而在C/C++代碼中,我們需要將Mono運行時初始化,一旦Mono運行時初始化成功,那么下一步最重要的就是將CIL/.Net代碼加載進來。加載之后的地址空間將會如下圖所示:

 從游戲腳本語言說起,剖析Mono搭建的腳本基礎

那些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版本,可以使用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的信息。

 從游戲腳本語言說起,剖析Mono搭建的腳本基礎

后記

通過本文,我們可以看到游戲腳本語言出現的必然性。同時也應該了解Unity3D是C/C++實現的,但是它通過Mono提供了一套腳本機制,在方便游戲開發者快速開發游戲的同時也降低了游戲開發的門檻。

來自:http://www.infoq.com/cn/articles/analysis-of-the-script-based-on-mono

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!