在平常的工做中,我偶爾能遇到這樣的問題:「爲什麼遊戲腳本在如今的遊戲開發中變得不可或缺?」。那麼這周我就寫篇文章從遊戲腳本聊起,分析一下游戲腳本因何出現,而mono又能提供怎樣的腳本基礎。最後會經過模擬Unity3D遊戲引擎中的腳本功能,將Mono運行時嵌入到一個非託管(C/C++)程序中,實現腳本語言和「引擎」之間的分離。javascript
首先聊聊爲什麼如今的遊戲開發須要使用遊戲腳本這個話題。java
爲什麼須要有腳本系統呢?腳本系統又是因何而出現的呢?其實遊戲腳本並不是一個新的名詞或者技術,早在暴雪的《魔獸世界》開始火爆的年代,人們便熟知了一個叫作Lua的腳本語言。而當時其實有不少網遊都不約而同的使用了Lua做爲腳本語言,好比網易的大話西遊系列。
可是在單機遊戲流行的年代,咱們卻不多據說有什麼單機遊戲使用了腳本技術。這又是爲何呢?由於當時的硬件水平不高,因此須要使用C/C++這樣的語言來儘可能壓榨硬件的性能,同時,單機遊戲的更新換代並不如網遊那麼迅速,因此開發時間、版本迭代速度並不是其考慮的第一要素,於是可使用C/C++這樣開發效率不高的語言來開發遊戲。程序員
可是隨着時間的推移,硬件水平逐年水漲船高,壓榨硬件性能的需求已經再也不迫切。相反,此時網遊的興起卻對開發速度、版本更迭提出了更高的要求。因此開發效率並不高效,且投資巨大風險很高的C/C++便再也不適應市場的需求了。而更加現實的問題是,隨着java、.net甚至是javascript等語言的流行,程序員能夠選擇的語言愈來愈多,這更加致使了優秀的C/C++程序員所佔比例愈來愈小。而網遊市場的不斷擴大,這種對人才的需求也一樣愈來愈大,這就形成了大量的人才空缺,也就反過來提升了使用C/C++開發遊戲的成本。而因爲C/C++是門入門容易進階難的語言,其高級特性和高度靈活性帶來的高風險也是每一個項目使用C/C++進行開發時,所不得不考慮的問題。編程
而一個能夠解決這種困境的舉措即是在遊戲中使用腳本。能夠說遊戲腳本的出現,不只解決了因爲C/C++難以精通而帶來的開發效率問題,並且還下降了使用C/C++進行開發的項目風險和成本。今後,腳本與遊戲開發相得益彰,互相促進,逐漸成爲了遊戲開發中不可或缺的一個部分。數組
而到了現在手遊興起的年代,市場的需求變得更加龐大且變化更加頻繁。這就更加要求須要有腳本語言來提升項目的開發效率、下降項目的成本。
而做爲遊戲腳本,它具體的優點都包括哪些呢?安全
易於學習,代碼方便維護。適合快速開發。架構
開發成本低。因爲上述第一點,由於易於學習,因此能夠啓用新人,同時開發速度快,這些都是下降成本的方法。框架
所以,包括Unity3D在內的衆多遊戲引擎,都提供了腳本接口,讓開發者在開發項目時可以擺脫C/C++(注:Unity3D自己是用C/C++寫的)的束縛,這實際上是變相的下降了遊戲開發的門檻,吸引了不少獨立開發者和遊戲製做愛好者。dom
首先一個問題:Mono是什麼?編程語言
Mono是一個由Xamarin公司所贊助的開源項目。它基於通用語言架構(Common Language Infrastructure ,縮寫爲CLI)和C#的ECMA 標準(Ecma-33五、Ecam-334),提供了微軟的.Net框架的另外一種實現。與微軟的.Net框架不一樣的是,Mono具有了跨平臺的能力,也就是說它不只能運行在Windows系統上,並且還能夠運行在Mac OSX、Linux甚至是一些遊戲平臺上。
因此把它做爲跨平臺的方案是像Unity3D這種開發跨平臺遊戲的遊戲引擎的一個不錯的選擇。但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運行時到底是如何被嵌入到應用中的,以及如何在原生代碼中調用託管方法,相應的,如何在託管代碼中調用原生方法。而衆所周知的一點是,Unity3D遊戲引擎自己是用C/C++寫成的,因此本節就以Unity3D遊戲引擎爲例,假設此時咱們已經有了一個用C/C++寫好的應用(Unity3D)。
將你的Mono運行時嵌入到這個應用以後,咱們的應用就獲取了一個完整的虛擬機運行環境。而這一步須要將「libmono」和應用連接,一旦連接完成,你的C++應用的地址空間就會像下圖通常:
而在C/C++代碼中,咱們須要將Mono運行時初始化,一旦Mono運行時初始化成功,那麼下一步最重要的就是將CIL/.NET代碼加載進來。加載以後的地址空間將會以下圖所示:
那些C/C++代碼,咱們一般稱之爲非託管代碼,而經過CIL編譯器生成CIL代碼咱們一般稱之爲託管代碼。
因此,將Mono運行時嵌入咱們的應用,能夠分爲三個步驟:
編譯C++程序和連接Mono運行時
初始化Mono運行時
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++程序中的代碼(遊戲引擎)。
首先,假設咱們要實現的是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++)實現了獲取組件、建立和初始化組件的具體功能,接下來爲了驗證咱們是否成功的模擬了將Mono運行時嵌入「Unity3D遊戲引擎」中,咱們須要將代碼編譯而且查看輸出是否正確。
首先將C#代碼編譯爲DLL文件。咱們在終端直接使用Mono的mcs編譯器來完成這個工做。
運行後生成了ManagedLibrary.dll文件。
以後將unity.cpp和Mono運行時連接、編譯,會生成一個a.out文件(在Mac上)。執行a.out,能夠看到在終端上輸出了建立出的組件的ID和Tag的信息。
經過本文,咱們能夠看到遊戲腳本語言出現的必然性。同時也應該瞭解Unity3D的底層是C/C++實現的,可是它經過Mono提供了一套腳本機制,以方便遊戲開發者快速的開發遊戲同時也下降了遊戲開發的門檻。