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

前言

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

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

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

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

但是隨著時間的推移,硬體水平逐年提升,壓榨硬體性能的需求已經不再迫切。相反,此時網遊的興起卻對開發速度、版本更迭提出了更高的要求。所以開發n效率並不高效,且投資、巨大風險很高的C/C++便不再適應市場的需求了。更加現實的問題是,隨著Java、.Net甚至是JavaScript等語言的n流行,程序員可以選擇的語言越來越多,這更加導致了優秀的C/C++程序員所佔比例越來越小。而網遊市場的不斷擴大,對人才的需求也越來越大,這就造成了n大量的人才空缺,也就反過來提高了使用C/C++開發遊戲的成本。而由於C/C++是門入門容易進階難的語言,其高級特性和高度靈活性帶來的高風險也是每n個項目使用C/C++進行開發時所不得不考慮的問題。

一個可以解決這種困境的舉措便是在遊戲中使用腳本。可以說遊戲腳本的出現,不僅解決了由於C/C++難以精通而帶來的開發效率問題,而且還降低了使用C/C++進行開發的項目風險和成本。從此,腳本與遊戲開發相得益彰,互相促進,逐漸成為了遊戲開發中不可或缺的一個部分。

而到了如今手遊興起的年代,市場的需求變得更加龐大且變化更加頻繁。這就更加需要用腳本語言來提高項目的開發效率、降低項目的成本。

n而作為遊戲腳本,它具體的優勢都包括哪些呢?

  1. 易於學習,代碼方便維護。適合快速開發。
  2. 開發成本低。因為易於學習,所以可以啟用新人,同時開發速度快,這些都是降低成本的方法。

因此,包括Unity3D在內的眾多遊戲引擎,都提供了腳本介面,讓開發者在開發項目時能夠擺脫C/C++(註:Unity3D本身是用C/C++寫的)的束縛,這其實是變相降低了遊戲開發的門檻,吸引了很多獨立開發者和遊戲製作愛好者。

What? Mono提供的腳本機制

首先一個問題:Mono是什麼?

Mono是一個由Xamarin公司贊助的開源項目。它基於通用語言架構(Common Language Infrastructure n,縮寫為CLI)和C#的ECMA n標準(Ecma-335、Ecam-334),提供了微軟的.Net框架的另一種實現。與微軟的.Net框架不同的是,Mono具備了跨平台的能力,也就n是說它不僅能運行在Windows系統上,而且還可以運行在Mac OSX、Linux甚至是一些遊戲平台上。

所以把Mono作為跨平台的方案是一個不錯的選擇。但Mono又是如何提供這種腳本功能的呢?

如果需要利用Mono為應用開發提供腳本功能,那麼其中一個前提就是需要將Mono的運行時嵌入到應用中,因為只有這樣才有可能使託管代碼和腳本能n夠在原生應用中使用。我們可以發現,將Mono運行時嵌入應用中是多麼的重要。但在討論如何將Mono運行時嵌入原生應用中去之前,我們首先要搞清楚nMono是如何提供腳本功能的,以及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則提供了一n種將腳本語言通過JIT編譯為原生代碼的方式,提高了腳本語言的效率。例如,Mono提供了一個原生代碼生成器,可以提高應用的運行效率。它同時提供了很n多方便的調用原生代碼的介面。

在為一個應用提供腳本機制時,往往需要和低級語言交互。這便不得不提到將Mono的運行時嵌入到應用中的必要性了。那麼接下來,我將會討論一下如何將Mono運行時嵌入到應用中。

Mono運行時的嵌入

既然我們明確了Mono運行時嵌入應用的重要性,那麼如何將它嵌入應用中就成為了下一個值得討論的話題。

這個小節我會為大家分析一下Mono運行時究竟是如何被嵌入到應用中的,以及如何在原生代碼中調用託管方法,以及如何在託管代碼中調用原生方法。而n眾所周知的一點是,Unity3D遊戲引擎本身是用C/C++寫成的,所以本節就以Unity3D遊戲引擎為例,假設此時我們已經有了一個用C/C++寫n好的應用(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」即可。

n待pkg-config安裝完畢之後,我們新建一個C++文件,命名為unity.cpp,作為原生代碼部分。我們需要將這個C++文件進行編譯,並和Mono運行時鏈接。

在終端輸入:

g++ unity.cpp -framework CoreFoundation -lobjc -liconv `pkg-config --cflags --libs mono-2`n

此時,經過編譯和鏈接之後,unity.cpp和Mono運行時被編譯成了可執行文件。

到此,我們需要將Mono的運行時初始化。所以再重新回到剛剛新建的unity.cpp文件中,我們要在C++文件中來進行運行時的初始化工作,即調用mono_jit_init方法。代碼如下:

#include <mono/jit/jit.h>n#include <mono/metadata/assembly.h>n#include <mono/metadata/class.h>n#include <mono/metadata/debug-helpers.h>n#include <mono/metadata/mono-config.h>nnMonoDomain* domain;nndomain = mono_jit_init(managed_binary_path);n

mono_jit_init這個方法會返回一個MonoDomain,用來作為盛放託管代碼的容器。其中的參數nmanaged_binary_path,即應用運行域的名字。除了返回MonoDomain之外,這個方法還會初始化默認框架版本,即2.0或4.0,n這個主要由使用的Mono版本決定。當然,我們也可以手動指定版本。只需要調用下面的方法即可:

domain = mono_jit_init_version ("unity", ""v2.0.50727);n

這樣就獲取應用域——domain。但是當Mono運行時被嵌入一個原生應用時,它顯然需要一種方法來確定自己所需要的運行時程序集以及配置文件。默認情況下它會使用系統中定義的位置。

如圖,可以看到,在一台電腦上可以存在很多不同版本的Mono,如果我們的應用需要特定的運行時,就也需要指定其程序集和配置文件的位置。

為了選擇所需Mono版本,可以使用mono_set_dirs方法:

mono_set_dirs("/Library/Frameworks/Mono.framework/Home/lib", "/Library/Frameworks/Mono.framework/Home/etc");n

這樣,我們就設置了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";nMonoAssembly *assembly; nassembly = mono_domain_assembly_open (domain, managed_binary_path); nif (!assembly) n error ();n

上面的代碼會將當前目錄下的ManagedLibrary.dll文件中的內容載入進已經創建好的domain中。此時需要注意的是Mono運行時僅僅是載入代碼而沒有立刻執行這些代碼。

如果要執行這些代碼,則需要調用被載入的程序集中的方法。或者當你有一個靜態的主方法時(也就是程序入口),可以通過mono_jit_exec方法調用這個靜態入口。

下面我將舉一個將Mono運行時嵌入C/C++程序的例子,這個例子的主要流程是載入一個由C#文件編譯成的DLL文件,之後調用一個C#的方法輸出Hello World。

首先,我們完成C#部分的代碼。

namespace ManagedLibraryn{n public static class MainTestn {n public static void Main()n {n System.Console.WriteLine("Hello World");n }n }n}n

在這個文件中,我們實現了輸出Hello World的功能。之後我們將它編譯為DLL文件。這裡我也直接使用了Mono的編譯器——mcs。在終端命令行使用mcs編譯該cs文件。同時為了生成DLL文件,還需要加上-t:library選項。

mcs ManagedLibrary.cs -t:libraryn

這樣便得到了cs文件編譯之後的DLL文件,叫做ManagedLibrary.dll。

接下來,完成C++部分的代碼。嵌入Mono的運行時,同時載入剛剛生成ManagedLibrary.dll文件,並且執行其中的Main方法輸出Hello World。

#include <mono/jit/jit.h>n#include <mono/metadata/assembly.h>n#include <mono/metadata/class.h>n#include <mono/metadata/debug-helpers.h>n#include <mono/metadata/mono-config.h>nMonoDomain *domain;nint main()n{n const char* managed_binary_path = "./ManagedLibrary.dll";n //獲取應用域n domain = mono_jit_init (managed_binary_path);n //mono運行時的配置n mono_set_dirs("/Library/Frameworks/Mono.framework/Home/lib", "/Library/Frameworks/Mono.framework/Home/etc");n mono_config_parse(NULL);n //載入程序集ManagedLibrary.dlln MonoAssembly* assembly = mono_domain_assembly_open(domain, managed_binary_path);n MonoImage* image = mono_assembly_get_image(assembly);n //獲取MonoClassn MonoClass* main_class = mono_class_from_name(image, "ManagedLibrary", "MainTest");n //獲取要調用的MonoMethodDescn MonoMethodDesc* entry_point_method_desc = mono_method_desc_new("ManagedLibrary.MainTest:Main()", true);n MonoMethod* entry_point_method = mono_method_desc_search_in_class(entry_point_method_desc, main_class);n mono_method_desc_free(entry_point_method_desc);n //調用方法n mono_runtime_invoke(entry_point_method, NULL, NULL, NULL);n //釋放應用域n mono_jit_cleanup(domain);n return 0;n}n

之後編譯運行,可以看到屏幕上輸出的Hello World。

既然要提供腳本功能,將Mono運行時嵌入C/C++程序之後,只在C/C++程序中調用C#中定義的方法顯然還是不夠的。腳本機制的最終目的還是n希望能夠在腳本語言中使用原生的代碼,所以下面我將站在Unity3D遊戲引擎開發者的角度,繼續探索一下如何在C#文件(腳本文件)中調用C/C++程n序中的代碼(遊戲引擎)。

How? 如何模擬Unity3D中的腳本機制

首先,假設我們要實現的是Unity3D的組件系統。為了方便遊戲開發者能夠在腳本中使用組件,首先需要在C#文件中定義一個Component類。

//腳本中的組件Componentnpublic class Componentn{n public int ID { get; }n private IntPtr native_handle;n}n

與此同時,在Unity3D遊戲引擎(C/C++)中,則必然有和腳本中的Component相對應的結構。

//遊戲引擎中的組件Componentnstruct Componentn{n int id;n}n

託管代碼(C#)中的介面

可以看到此時組件類Component只有一個屬性,即ID。我們再為組件類增加一個屬性,Tag。

之後,為了使託管代碼能夠和非託管代碼交互,需要在C#文件中引入命名空間System.Runtime.CompilerServices,同時n提供一個IntPtr類型的句柄以便於託管代碼和非託管代碼之間引用數據。(IntPtr類型被設計成整數,其大小適用於特定平台。即是說,此類型的實例n在32位硬體和操作系統中將是32位,在64位硬體和操作系統上將是64位。IntPtr 對象常可用於保持句柄。 例如,IntPtr的實例廣泛地用在n System.IO.FileStream類中,以便保持文件句柄。)

最後,我們將Component對象的構建工作由託管代碼C#移交給非託管代碼C/C++,這樣遊戲開發者只需要專註於遊戲腳本即可,無需關注Cn/C++層面即遊戲引擎層面的具體實現邏輯了,我在此提供兩個方法即用來創建Component實例的方法:GetComponents,以及獲取ID的nget_id_Internal方法。

這樣在C#端,我們定義了一個Component類,主要目的是為遊戲腳本提供相應的介面,而非具體邏輯的實現。下面便是在C#代碼中定義的Component類。

using System;nusing System.Runtime.CompilerServices;nnamespace ManagedLibraryn{n public class Componentn {n //欄位n private IntPtr native_handle = (IntPtr)0;n //方法n [MethodImpl(MethodImplOptions.InternalCall)]n public extern static Component[] GetComponents();n [MethodImpl(MethodImplOptions.InternalCall)]n public extern static int get_id_Internal(IntPtr native_handle);n //屬性n public int IDn {n get n {n return get_id_Internal(this.native_handle);n }n }n public int Tag {n [MethodImpl(MethodImplOptions.InternalCall)]n get;n }n }n}n

之後,我們還需要創建這個類的實例並且訪問它的兩個屬性,所以還要再定義另一個類Main,來完成這項工作。

Main的實現如下:

// Main.csnnamespace ManagedLibraryn{n public static class Mainn {n public static void TestComponent ()n {n Component[] components = Component.GetComponents();n foreach(Component com in components)n {n Console.WriteLine("component id is " + com.ID);n Console.WriteLine("component tag is " + com.Tag);n }n }n }n}n

非託管代碼(C/C++)的邏輯實現

完成了C#部分的代碼之後,我們需要將具體的邏輯在非託管代碼端實現。而我上文之所以要在Component類中定義兩個屬性:ID和Tag,是為n了使用兩種不同的方式訪問這兩個屬性,其中之一就是直接將句柄作為參數傳入到C/C++中,例如上文我提供的get_id_Internal這個方法,它n的參數便是句柄。第二種方法則是在C/C++代碼中通過Mono提供的mono_field_get_value方法直接獲取對應的組件類型的實例。

獲取組件Component類中的屬性有兩種不同的方法:

//獲取屬性nint ManagedLibrary_Component_get_id_Internal(const Component* component)n{n return component->id;n}nnint ManagedLibrary_Component_get_tag(MonoObject* this_ptr)n{n Component* component;n mono_field_get_value(this_ptr, native_handle_field, reinterpret_cast<void*>(&Component));n return component->tag;n}n

之後,由於我在C#代碼中基本只提供介面,而不提供具體邏輯實現。所以還需要在C/C++代碼中實現獲取Component組件的具體邏輯,之後再以在C/C++代碼中創建的實例為樣本,調用Mono提供的方法在託管環境中創建相同的類型實例並且初始化。

由於C#中的GetComponents方法返回的是一個數組,所以對應的需要使用MonoArray從C/C++中返回一個數組。C#代碼中GetComponents方法在C/C++中對應的具體邏輯如下:

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

其中num_Components是uint32_t類型的欄位,用來表示數組中組件的數量,下面我會為它賦值為5。之後通過Mono提供的nmono_object_new方法創建MonoObject的實例。而需要注意的是代碼中的Components[i],Components便是在nC/C++代碼中創建的Component實例,這裡用來給MonoObject的實例初始化賦值。

創建Component實例的過程如下:

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

C/C++代碼中創建的Component的實例的id為i,tag為i * 4。

最後將C#中的介面和C/C++中的具體實現關聯起來。即通過Mono的mono_add_internal_call方法來實現,也即在Mono的運行時中註冊剛剛用C/C++實現的具體邏輯,以便將託管代碼(C#)和非託管代碼(C/C++)綁定。

// get_id_Internalnmono_add_internal_call("ManagedLibrary.Component::get_id_Internal", reinterpret_cast<void*>(ManagedLibrary_Component_get_id_Internal));n//Tag getnmono_add_internal_call("ManagedLibrary.Component::get_Tag", reinterpret_cast<void*>(ManagedLibrary_Component_get_tag));n//GetComponentsnmono_add_internal_call("ManagedLibrary.Component::GetComponents", reinterpret_cast<void*>(ManagedLibrary_Component_GetComponents));n

這樣,便使用非託管代碼(C/C++)實現了獲取組件、創建和初始化組件的具體功能,接下來為了驗證是否成功地模擬了將Mono運行時嵌入「Unity3D遊戲引擎」中,我們需要編譯代碼並且查看輸出是否正確。

首先將C#代碼編譯為DLL文件。在終端直接使用Mono的mcs編譯器來完成這個工作。

運行後生成了ManagedLibrary.dll文件。

之後將unity.cpp和Mono運行時鏈接、編譯,會生成一個a.out文件(在Mac上)。執行a.out,可以看到在終端上輸出了創建的組件的ID和Tag的信息。

後記

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

推薦閱讀:

Visual Studio 開發體驗究竟牛到什麼程度?真的只是拖拖控制項就能完成中小型項目開發?
【譯】介紹 .NET Standard
妥協與取捨,解構C#中的小數運算
怎樣考察有八年經驗程序猿的水平(C#)?
如何評價 Unity 2018?

TAG:Unity游戏引擎 | 游戏开发 | C# |