Home

Milo的遊戲開發

關於把 Mil 開源,徵求大家意見

osi_standard_logo

這兩個星期在老家的悠長假期快結束了。沒有開發環境下,在想一些開發計劃,當中最重要的是關於 Mil 的開源問題。本文記錄了現時的一些想法,並徵求大家旳意見。

開源的原因

  1. 開始寫 Mil 時是為學習和交流用的。
  2. 沒人用的軟件沒有價值;沒做出好遊戲的引擎也沒有價值。
  3. 遊戲使用的技術一日千里,不常更新的引擎也沒有用。有使用者會鼓勵更新。
  4. 必須要有使用者的回饋才能進步。

期望的理想結果

  1. 有個人或公司(包括自己)用它來做遊戲,並取得成功。
  2. 有更多人參與開發。
  3. 有人(包括自己)用它來做研究用的平台,研究一些新的遊戲用技術。(例如有人用 Quake 來做 AI、ray-tracing的實驗)
  4. 出版書籍或文章。
  5. 提供顧問服務,或取得開發資助。

開源的選擇

  1. 使用那種License? 參考了朋友給的意見,暫時認為 MIT 比較適合。目標是有人用就好了。
  2. 使用那種文字語言(document, blog, forum)? 這比較困難,用英文是比較簡單。但是否要考慮用中文呢?可能先英文,再讓有興趣的人譯中文吧。還是有人認為中文更重要呢?
  3. 在那裡 host? 暫時比較想用 google code (SVN)。但最好是有自己 domain 的website吧? 有其他建議的請告訴我。

開源的準備

首先,完成以下的軟件功能:

  1. 完善 MilStudio 的基本 World 編輯功能。
  2. 腳本的基本 callback 函數。
  3. 把一些之前的 examples (如Platform) 在 MilStudio 裡重新建立一遍,存為 Mud 檔。
  4. 轉換 Mud 至 Win32 MilPlayer 的格式。
  5. 使 MilPlayer 能運行 examples。
  6. 支持人物 skeleton、animation 及 skinning (自 COLLADA 滙入、渲染至 Lua API) 。
  7. 基本的渲染引擎 (主要是 Material、Lights、Renderer)。先做一個 single light per pass (只是簡單Blinn/Phong) 的 renderer。(其實自己很想試做 Light Pre-Pass (slides) 的)
  8. 音效功能。API 應該可以很簡單吧。
  9. Linux 及 OpenGL…… 其實不太想做,因為實際用途太少。主要用途只是用來 proof of concept,可以 Linux + OpenGL 證明 PS3、OSX、iPhone 等也沒大問題。這點再考慮。
  10. Unit tests (Lua API、MilStudio)
  11. Code review 及 refactoring (不想之後的 API 改變太大,有改動盡早改)

之後,上傳前要做的事情:

  1. 檢查所有 thirdparty 的 license,並移除不適合的 (例如我現時把 DXSDK 的 header/library 放在 repository 裡,應該是不可以的吧?但要別人安裝某個版本的 DXSDK 總覺得很麻煩)
  2. 編寫文檔,主力是 Lua 的 API。目前的想法是在 C++ header 裡寫 API doc,用 doxygen 輸出 XML 格式,再寫一個程式轉換為 Lua 的 API 樣式。(將來還可以用來做 IDE 裡的 auto completion 和 call tips)
  3. 做win32 binary installer (MilStudio、MilPlayer),給不需要用 source code 的使用者。
  4. 項目 logo、icon、philosophy、tutorial……

以上的事情大概在今年內應該完成不了,希望在農曆新年前完成。

之後的短期路線圖

只有路線圖,沒有時間表。

  1. 因應朋友的需要做 PSP 移植,可能會先用 homebrew 的 SDK
  2. MilStudio 裡的 Lua debugger。
  3. Mud 及 MilStudio 加入 version control。
  4. 用 Entity 的方式來做 GUI,暫不考慮文字輸入。
  5. 做不完的 graphics 功能 (particle、shadow、post-processing……)

結語

開源也意味著將來長時間要做更新、bug fixes、回答問題等等,需要長期的投入。

歡迎你的任何意見,可回 comment 或 e-mail 給我。

半年近況,並談 .NET PropertyGrid 的動態屬性挷定

半年近況

距離上一篇文章也剛好半年了。從那個時候之後,在內地很多網站也被封了,包括了我這個 blog。不過這並不是荒廢 blog 的原因,主要還是因為我上一個項目 《Cloudy with a Chance of Meatballs》 (Xbox360/PS3/Wii/PC) 進入 crunch mode,幾個月都在忙,回家也不想編程序,更別說寫 blog了。

《Cloudy》遊戲終於在九月十五日發行了,電影則在我兒子一歲生日上畫,當天亦是九一八事變的紀念日。雖然沒有甚麼期望,但還是想看一看遊戲網站和玩家的評語。回香港的時候應該會買一兩個平台的版本。如你有興趣玩這個遊戲,推薦兩個人一起玩co-op,會比較有趣。

cloudyxbox360cloudyps3cloudywiicloudypc

八月轉到了新公司,有很多新東西要學習,不過這裡給我發揮的空間也許比較多。有機會,並得到公司的許可,也許在遊戲發行後再說說當中做的一些技術和經驗。

這個月的 Mil Engine 進展

在八月下旬,我開始重拾在家裡編程序的愛好。簡單總結一下:

  1. 跟據原來設計的 API 重新實現 Mil Universe Database (MUD),並成功做到 Transaction。Transaction 可以保持 MUD 的 integrity,例如在匯入 COLLADA 時,會在 MUD 裡創建很多新檔案,但當滙入中間出現錯誤,例如找不到紋理、或一些格式不支持,就能夠 rollback,保証原來的資料不會被破壞,也不會有不完整的資料。
  2. 把 MilStudio 從檔案系統改為 MUD,加入一個視窗瀏覽MUD的內容。這個過程是挻簡單的。而且利用 Key (代替 path) 做 identifier,出現的 bug 會少很多。例如使用者在 MUD 裡的目錄裡搬一筆資料,不會影響它的 Key,不用更改所有參考到這筆資料的資料。做 MUD 的 TreeView 也比較簡單,不用處理一些 path 的操作 (如計算 path 和 relative path 的結合,更改一個 folder 名字要更改所有 subtree 的 path 之類)。
  3. 重構Script模組的API,讓 Lua 定義一個 “class” (其實是 table)後,能取得它的 “fields” 的名稱、預設值、及跟據預設值決定 field 的型別。之後就是生成 “object” 和修改它們的 “fields”。
  4. 新增 Behavior component,加到 Entity 後用來綁定一個 Lua “class”,和設定 object 的 fields 的初始值。下一步就是在 MilStudio 使用 PropertyGrid 介面去給使用者修改這些值。可是這一步花了我許多時間,所以這文章會談一下當中的問題。
  5. 其他小事項還包括在 Mud 中找所有 Lua 文件編譯、把 class 作為一個 (sharing) resource、解決 Lua 裡取得 C++ reference count object的問題、把每個MilStudio的使用者操作從一個大類別的函數 refactor 為 command pattern 等等。
  6. 昨天星期天,早上六時起床開始重構 Gizmo,也把視角操作 (Rotate/Pan/zoom) 改為 3D Studio Max 的鍵位 (滑鼠中按鈕和 Alt)。現時有 translate、rotate 和 (uniform) scale gizmo,支持 local/world space 操作。
  7. milstudio20090921

    先談 Lua 腳本的物件導向編寫方式,接下來說說跟 PropertyGrid 博鬥的經驗吧。

    用 Lua 編寫類別

    我使用以下的方式定義一個 Lua 類別:

    Box = { extent = Math.Vector3(1, 1, 1), i = 123, s = 'Mil', t = true }
    
    function Box:OnInit()
        print('Box:OnInit() name=' .. self.entity:GetName() .. ' extent=' .. tostring(self.extent))
        local mesh = Engine.Mesh_Cast(self.entity:CreateComponent(Engine.MeshComponentType))
        mesh:SetGeometry(geometry)
        mesh:SetMaterial(Graphics.Material_Default())
    end function
    
    -- ...
    
    return Box
    

    這個 Box 類別 (其實引擎裡是拿不到 “Box” 這個名稱,只是一個 table 傳回值) 裡有4個fields,分別有不同的預設值和型別。因為 Lua 是動態型別的語言,必須有非 nil 的值,這個 field 才能存在。

    在讀入這個 Box 類別時,設定 Box.__index = Box。

    要建立一個 Box 的物件,可以先建立一個 table,內裡可以 override Box 裡的fields,再把那物件的 metatable 設為 Box。例如:

    box1 = { extent=Math.Vector(2, 3, 4), s = 'Lua' }
    setmetatable(box1, Box)
    

    當在 box1 找 field (例如 box1.extent),找到就會使用 box1 的值,找不到就會用 metatable 的 __index 去找,即是找 Box 的 field。function 也是這樣從 class 繼承過來的。

    但使用者其實只需要編寫那個 Lua 類別文件,並在 MilStudio 設定物件的 fields 的值就可以了。Box.__index 和 setmetatable 等都是 C++ 調用 Lua API 去執行的。

    我把編輯後的物件,serialize 成一個 Lua 程序片段,例如把一個 world 存成 XML 時,就可能變成:

    
        
            
                return { extent=Math.Vector(2, 3, 4), s = 'Lua' }
                
            
        
        
    
    

    XML中的class=”257″是那個 Lua 文件在 MUD 裡的 Key。由於 behavior 元素包含的是一個合法的 Lua 片段,所以只要能 return 一個 table,將來還可以加入比較複雜的內容。

    現在,假設從 Script 模組裡,取得一個 Lua class 的 fields 的名稱、預設值及型別之後,怎樣把這些 fields 加進 .NET PropertyGrid 裡呢?

    .NET PropertyGrid 簡介

    PropertyGrid 是 .Net 裡一個強大的控件,它利用反射機制,可以用來編輯 .Net 物件的屬性。編輯一般的物件是很簡單的,只需要把物件設至 PropertyGrid.SelectedObject 就可以,加幾個 attributes 還可以做簡單的客制化,下面是一個例子:

    public class Monster
    {
        string name = "Slime";
        int maxHP = 100;
    
        public string Name
        {
            get { return name; }
            set { name = value; }
        }
    
        [DisplayName("Maximum HP")]
        [DefaultValue(100)]
        [Category("Stats")]
        public int MaxHP
        {
            get { return maxHP; }
            set { maxHP = value; }
        }
    }
    
    public partial class Form1 : Form
    {
        Monster monster = new Monster();
    
        public Form1()
        {
            InitializeComponent();
    
            propertyGrid1.SelectedObject = monster;
        }
    }
    

    propertygrid1

    但是,現在的問題是,我們只能在執行期才知道要編輯的資料,其名稱、預設值和型別都是動態更新的。

    用PropertyGrid 的動態挷定

    一個解決方法是編寫一個實現 ICustomTypeDescriptor 介面的類別。這個介面有一個 GetProperties() 函數,它回傳一個 PropertyDescriptorCollection 集合物件,裡面存放該類別的屬性資料。在這個應用中,只要在這個 GetProperties() 函數裡取得 Lua class 的 field 資料(名稱、預設值及型別),建立一個PropertyDescriptorCollection 就可以了。

    每個 PropertyDescriptor 可以重新定義一些函數,包括:

    • Type PropertyType { get; } 傳回這屬性的類型
    • object GetValue(object component) 傳回這屬性的值
    • void SetValue(object component, object value) 設定屬性的值
    • ResetValue(object component) 重置屬性的值

    當中,component 是指擁有這屬性的物件,但並不一定需要它,因為建構一個繼承 PropertyDescriptor 的物件時,可以傳入一些變量。

    以下是源代碼,比較冗長。

    public class BehaviorFieldCollection : ICustomTypeDescriptor
    {
        private Behavior behavior;
    
        public BehaviorFieldCollection(Behavior behavior)
        {
            this.behavior = behavior;
        }
    
        public String GetClassName()
        {
            return TypeDescriptor.GetClassName(this, true);
        }
    
    /*
        // The following are similiar to GetClassName(), just delegates to TypeDescriptor
        public AttributeCollection GetAttributes() { ... }
        public String GetComponentName() { ... }
        public TypeConverter GetConverter() { ... }
        public EventDescriptor GetDefaultEvent() { ... }
        public PropertyDescriptor GetDefaultProperty() { ... }
        public object GetEditor(Type editorBaseType) { ... }
        public EventDescriptorCollection GetEvents(Attribute[] attributes) { ... }
        public EventDescriptorCollection GetEvents() { ... }
    */
    
        public object GetPropertyOwner(PropertyDescriptor pd)
        {
            return this;
        }
    
        public PropertyDescriptorCollection GetProperties(Attribute[] attributes)
        {
            return GetProperties();
        }
    
        public PropertyDescriptorCollection GetProperties()
        {
            Runtime runtime = behavior.GetClass().GetRuntime();
            PropertyDescriptorCollection pds = new PropertyDescriptorCollection(null);
    
            Class cls = behavior.GetClass();
            if (cls != null)
            {
                uint fieldCount = cls.GetFieldCount();
                for (uint fieldIndex = 0; fieldIndex < fieldCount; fieldIndex++)
                {
                    string name = cls.GetFieldName(fieldIndex);
                    cls.GetFieldDefault(name);
                    string scriptType = runtime.GetParameterType(0);
                    object defaultValue = RuntimeType.PopObject(runtime);
                    Attribute[] attrs = new Attribute[] { new DefaultValueAttribute(defaultValue) };
    
                    pds.Add(new BehaviorFieldDescriptor(behavior, name, scriptType, defaultValue, attrs));
                }
            }
    
            return pds;
        }
    
        // If there is no ToString(), it will display the type name (BehaviorFieldCollection) in the value of Fields.
        public override string ToString()
        {
            return string.Empty;
        }
    }
    
    public class BehaviorFieldDescriptor : PropertyDescriptor
    {
        private Behavior behavior;
        private string scriptType;
        private object defaultValue;
    
        public BehaviorFieldDescriptor(Behavior behavior, string name, string scriptType, object defaultValue, Attribute[] attrs)
            : base(name, attrs)
        {
            this.behavior = behavior;
            this.scriptType = scriptType;
            this.defaultValue = defaultValue;
        }
    
        public override bool CanResetValue(object component)
        {
            return true;
        }
    
        public override Type ComponentType
        {
            get { return typeof(BehaviorFieldCollection); }
        }
    
        public override bool IsReadOnly
        {
            get { return false; }
        }
    
        public override Type PropertyType
        {
            get { return RuntimeType.ConvertScriptType(scriptType); }
        }
    
        public override object GetValue(object component)
        {
            behavior.GetClass().GetField(behavior.GetObject(), this.Name);
            return RuntimeType.PopObject(behavior.GetClass().GetRuntime());
        }
    
        public override void SetValue(object component, object value)
        {
            if (RuntimeType.PushObject(behavior.GetClass().GetRuntime(), scriptType, value))
            {
                behavior.GetClass().SetField(behavior.GetObject(), Name);
                behavior.SerializeObject();
                behavior.CreateObject();
            }
        }
    
        public override void ResetValue(object component)
        {
            behavior.GetClass().ResetField(behavior.GetObject(), this.Name);
            behavior.SerializeObject();
            behavior.CreateObject();
        }
    
        public override bool ShouldSerializeValue(object component)
        {
            object value = GetValue(component);
            if (defaultValue == null || value == null)
                return false;
            else
                return !value.Equals(defaultValue);
        }
    }
    
    partial class Behavior
    {
        [TypeConverter(typeof(Mil.Mud.ResourceConverter))]
        [DefaultValue(null)]
        [DisplayName("(Class)")]
        public Class Class {
            get { return GetClass(); }
            set { SetClass(value); }
        }
    
        [TypeConverter(typeof(ExpandableObjectConverter))]
        public BehaviorFieldCollection Fields
        {
            get	{ return new BehaviorFieldCollection(this);	}
        }
    
        public object GetField(string fieldName)
        {
            PropertyDescriptor pd = Fields.GetProperties()[fieldName];
            if (pd == null)
                return null;
    
            return pd.GetValue(null);
        }
    
        public bool SetField(string fieldName, object obj)
        {
            PropertyDescriptor pd = Fields.GetProperties()[fieldName];
            if (pd == null)
                return false;
    
            pd.SetValue(null, obj);
            return true;
        }
    }
    

    每次呼叫 SetValue(),就是使用者改變了 field 的值的時候,都把這個 Lua 物件 serialize,再從新創建,創建時會調用新 object 的 OnInit() 成員函數。這麼做,更改值之後就能即時看到結果了。

    最後的 GetField() 和 SetField() 是方便 C# 中存取 Lua object 的 field。我暫時只用在一些 MilStudio 的測試裡。

    propertygrid2

    如果讀者細心看上面的截圖,會發現每個component 是一個 category (正常一該是一個物件,再按加號展開),而且按動Entity裡的Component開關,應該可以看到新的Component的屬性。對了!這也是動態的!不過這次因為不是一個屬性(是Entity類別),只要把Entity用一個繼承自TypeConverter的類別,在該類別的 GetProperties() 函數輸出 PropertyDescriptorCollection。這就是另一種動態挷定的方法。

    因為 MSDN 對這部份的解說非常有限,開發時遇到很多問題。例如把 Vector3 (一個 reference type) 加入ExpandableObjectConverter,就可以編輯當中的x、y、z屬性。可是在Transform component中的Translate (一個Vector3 的屬性) 修改後卻不起作用! 因為每次它都傳回一個新的 Vector3 物件,而 PropertyGrid 不會調用 Translate 屬性的set。這個問題就花了我大半天的時間,後來找到可以把TypeConverter.GetCreateInstanceSupported()傳回 true,並更改 TypeConverter.CreateInstance() 函數去解決這個問題。後者中創建一個Vector3,PropertyGrid就會用它呼叫Translate屬性的Set。

    我發覺我不太喜歡解決這種 API 上的問題,而且有時候還要花費很多精力,卻只有很少產出。比較想花時間在設計上。

    後記

    這兩個星期每天晚上和周休二日都在編這個東西,進度已快了很多,但離實用還有一大段距離。如果能維持這個速度,也許年末能做點小遊戲。不過要維持這速度可能不容易,最近買的新書都沒時間看。

    我目前還在考慮是否把這小東西變為開源,主要是擔心沒能力提供支援,另外是有參與者的話又會產生問題。不過沒經歷實際做遊戲的工具是不行的。希望實現了一些基本的功能後,可以先讓自己或給朋友做一些遊戲。

    P.S. 我現在是用非常慢的 TOR 翻越長城

    P.P.S. 我這星期六就回香港放半個月假了

從頭開始思考遊戲的資料管理系統(二)

上回談及一些遊戲資料管理的初部分析,但有些思緒還沒有整理好,寫得頗亂。今次就直接談我在幾個月前設計的Mil Universe Database (MUD),從中再闡述當中的一些想法。

基本的資料流程

資料流程

首先,一個項目中的所有引擎會使用的資料,都放進一個完整的資料庫裡。現時我的設計一個資料庫會儲存成一個 .mud檔案。

外部檔案可以滙入資料庫,也可以滙出。但滙出的資料會失去一些資訊(如稍後說的依存關係)。

資料庫最後會轉換為平台特定的格式,如轉換Endianness、轉換模型和紋理格式、轉換XML到二進制格式、編譯腳本等等。這步驟的主要目的是加快遊戲讀資料的速度。此外,也可以把關卡或人物的資料(如三維模型、紋理、動畫等)包裝成一個資料塊(data chunk),優點是進一步加快了讀資料的速度(尤其是光碟,因為seeking慢),缺點是一筆資料可能出現在多個資料塊中,增加了遊戲的容量,而且可能會讀到已載入的資料。

如果多人參與同一項目,該資料庫會分為中央和本地的資料庫,透過版本控制同步及提交更新。這裡描述的是一個可離線編輯的版本控制系統,我也和伙伴討論過其他方式,可見使用Database Server+Source Control Server构建游戏开发用数据文件的可行性分析

功能需求

構想MUD 時考慮了以下的需求。

完整單一資料庫

上回說到一個遊戲項目會有數萬以上筆資料。如果每筆資料以獨立檔案儲存,會產生很多問題。我覺得缺乏完整性 (Integrity)是最大的問題。所以MUD是以單一的資料庫儲(.mud檔案)存整個項目的資料。

Key

使用整數作為每筆資料在永續時的Identifier,稱為 Key。Key只是系統才知道的,製作人員應該從來不需要知道這概念。

層階式檔案系統

每筆資料會有路徑和檔案名稱,但這只是給使用者「看」而已,遊戲引擎始終是使用Key作為Unique Identifier。資料庫轉換成平台格式時,這些路徑檔名會被忽略,不存在於遊戲的執行版本。

依存關係

資料和資料間的依存關係會記綠下來。如一個三維模使用數個材質;每個材質又使用數個紋理。當一筆資料被刪掉時,可提醒使用者該資料被那些資料使用,如果確定刪去,也會通知其他資料刪去那些使用參考。

腳本也是用Key去挷定資料。例如一個腳本會在執行期,在某些情況下改變它的遊戲物件的三維模型,那麼該腳本會定義它需要一個三維模型的(成員)變數。關卡設計師會把腳本挷定在一個遊戲物件上,並設定它需要的三維模型。這遊戲物件永續時儲存了三維模型的Key,而在執行期腳本則拿到了三維模型的物件(指針)。這可以解決把資料(如路徑)直接寫在腳本所產生的問題。

此外,依存關係也可以找出及清除一些孤立的資料(沒有其他資料會依存的資料)。當然,要做到這一點,還要定義一個(或一些)根節點。

版本控制

遊戲的資料通常由很多人參與製作,版本的控制是十分重要的。我未詳細考慮怎樣實現MUD的版本控制。簡單的方案是使用SVN之類的檔案版本控制軟件的API,把Key轉為檔案去存取。但仍要考慮資料以外的東西怎麼作版本處理(檔名、依存關係等等)。

其他擴展功能

  • 資產管理(Asset Management): 除了儲存遊戲編輯工具和遊戲引擎會直接使用的檔案,亦儲存來源檔 (.max、.psd ),並有workflow自動轉換這些檔案,例如紋理的大小和mip-map設定。
  • 壓縮: 加快檔案的讀寫、減少磁碟使用空間。
  • 加密: 加強安全性 (開發時期的泄漏、出版後被反組譯等等)

Schema設計

我之前設計的第一個Mud版本應該是以C++直接實作,但schema用一般database畫出來可能比較容易明白:

Mud Schema

最重要的是Key table,它記錄每個key所指向的資料的大小、類型、和儲存在資料庫中的offset。

File、Folder和Dependency都是制作期的資料庫才有的,這些 tables在最終運行版的資料庫會被刪去。

API 設計

以下是Mud API的第一個版本,當中示有 Retail的函數是遊戲正式版需要的函數:

class MudFile
{
public:
    MudFile(char *mudFileFilename);    // Retail

    // Mud file manipulation
    bool Create();
    bool Open();    // Retail
    bool Close();    // Retail
    bool Check();
    bool Compact();

    // Transaction
    bool BeginTransaction();
    bool Commit();
    bool Rollback();

    // Read/Write
    bool OpenReadFile(Key key);    // Retail
    bool OpenWriteFile(Key key);
    bool CloseFile();    // Retail
    uint32_t ReadFile(void *buffer, uint32_t maxSize);    // Retail
    uint32_t WriteFile(void *buffer, uint32_t size);

    // File manipulations
    Key CreateFile(FolderID folderID, char *filename, FileType type);
    bool DeleteFile(Key key);
    bool RenameFile(Key key, char *filename);
    bool MoveFile(Key key, FolderID folderID);

    // File information
    char *GetFilename(Key key);
    uint32_t GetFileSize(Key key);    // Retail
    FileType GetFileType();    // Retail
    FolderID GetFileFolder(Key key);

    // Folder manipulations
    FolderID CreateFolder(FolderID parentFolderID, char *folderName);
    bool DeleteFolder(FolderID folderID);
    bool RenameFolder(FolderID folderID, char *folderName);
    bool MoveFolder(FolderID folderID, FolderID parentFolderID);

    // Folder information
    char *GetFolderName(FolderID folderID);
    FolderID GetFolderParent(FolderID folderID);
    
    // Path
    string GetPath(Key key);
    Key GetKeyByPath(const string& path);

    // Folder/File Traversal
    FolderID GetFirstChildFolder(FolderID folderID);
    FolderID GetNextSiblingFolder(FolderID folderID);
    Key GetFirstFileInFolder(FolderID folderID);
    Key GetNextFileInFolder(Key key);

    // Dependency
    bool AddDependency(Key source, Key target);
    bool RemoveDependency(Key source, Key target);
    KeyList GetSources(Key target);
    KeyList GetTargets(Key source);
};

我把這組API盡量設計得簡單,減少所需要的類別(如File、Folder類別),使C# (或其他語言)的Binding容易實作。在Retail版中,只需要少量函數,實作多個平台的版本也變得容易。

我原來的設計裡還有 Transaction 的考慮,希望可以加強database的integrity。例如程式在運作中途異常結束,database的內容可以維持consistency (不會有只寫了一半的資料或資料組)。

後記

這一篇文章其實只談及一個解決方案,並沒有很仔細的分析每個決定,及討論其他方案。如果有寫得不太清楚,或有任何意見,歡迎留言討論。

從頭開始思考遊戲的資料管理系統(一)

和一般應用軟件有點不同,大部份遊戲軟件都需要使用大量的遊戲資料 (Game Data)──或稱為資源 (Resource)、資產(Game Asset, 但通常Asset包括資料的原始格式, 不是最終運行遊戲所需的資料)。在製作遊戲時,如何管理這些資料是一個非常重要的問題。曾經看過和使用過不同的方案,現在歸回原點,分析基本的需求,隨筆記錄我選的方案的思路。

遊戲資料管理的重要性

如果從遊戲軟件的產出 (deliverables) 來分析,一個遊戲軟件可以分為三部份:
遊戲引擎: 比較固定的、和遊戲性無直接關係的程式
遊戲唯讀資料: 腳本、圖像、音效、關卡、影像
遊戲讀寫資料: 遊戲存檔、遊戲設定、玩家自製內容

從現今的遊戲實際容量來看,遊戲引擎可能佔1~10MB、遊戲讀寫資料10KB~10MB,其餘以GB為單位的全是唯讀資料。以開發人員來計算,開發引擎可能是數人,但製作那些唯讀資料的隊伍是以百計的,主要是遊戲性程式設計師、人物美工、場境美工、關卡設計師等等。

本文主要談遊戲唯讀資料,所以以下會「資料」或「遊戲資料」皆指「遊戲唯讀資料」。

遊戲資料可以有幾多筆?

接著我隨便估計一個「小」遊戲需要的資料筆數(筆數在下節定義)

20 關卡 x (200關卡貼圖 + 100關卡模型 + 200遊戲物件) = 10000
40 人物 x (3人物貼圖 + 2人物模型 + 20人物動畫) = 1000
200視覺效果x (5效果貼圖 + 5動畫) = 1000
20使用者介面x 25貼圖 = 500
(100物件腳本 + 80人物腳本 + 20使用者介面腳本) x 5原始代碼檔案 = 1000
180音效 + 20音樂 =200
總和 = 13,700

以這個推算遊戲的資料筆數數量級一般在10^4 至 10^6。我覺得極限只是會到10^7,因為大量資料是模型、貼圖、動畫等,平均值不會少於10KB,而10^7 x 10KB 已經是 10GB。

遊戲資料的概念分析

我心目中的遊戲資料是有以下特點

  1. 唯一性 (Uniqueness): 每筆資料有它唯一的Identifier,透過一個identifier可以讀取一筆資料。
  2. 不可分割 (Atomic): 當把一些資料分割成多筆資料是沒意義的,就算是一筆資料。例如假設一張貼圖不會讀取、使用其中一部份,則視為一筆資料。
  3. 依存關係 (Dependency): 一筆資料可能會靠identifiers引用其他資料。這和上不可分割屬性也有關係,因為是用identifier依存某一個資料,而不是該資料內的一部份。我未想到會有循環的依存關係,暫時把依存關係當作一個 Directed Acyclic Graph (DAG)。

一般檔案系統的檔案和這裡的資料有點不同。檔案的路徑可以視為Identifier,但很多檔案的內容是可以再分割的,而且沒有顯性(explicit)的依存關係。

選擇Identifier

我看過和想到的資料的Identifier可以是:

  1. 路徑 (path): 例如 Texture/wall.jpg
  2. URL: 例如 Texture/wall.jpg、http://www.mysite.com/news.jpg
  3. 整數: 例如 0x54AF4C58
  4. GUID: 例如 {3F2504E0-4F89-11D3-9A0C-0305E82C3301}

路徑

檔案系統的路徑可能是最直覺的資料Identifier。我以前做過的引擎,和很多商用引擎也會使用檔案和路徑這個表達方式。路徑通常是相對於某一個目錄、或一個壓縮檔(例如 id 的引擎會把檔案壓縮在一個zip檔裡)。

路徑的好處是和我們日常用的操作系統管理檔案的方式一樣。層階式 (hierarchical)的目錄結構讓使用者可以自行分類管理,你亦可以歷遍(traverse)目錄結構,例如讀取某一目錄的*.lua。它實作簡單,因為操作系統已提供所需功能。也可以使用一些現存的版本管理工具去直接管理這些資料,如SVN、Perforce等。

路徑的壞處包括: 大小寫問題、多國文字問題、儲存效率差、運行效率差。前二者太概都容易明白,不再詳述。儲存效率差是指用字串去記錄每個identifier,包括引用時的identifier都會花費不少空間,而且路徑(不單是檔案名)的長度可以很長(例如可能要256 位元組)。而運行效率差是因為比較兩個路徑慢,計算一個路徑的hash code (例如用路徑identifier做 hash_map或hash_set 的key) 也是很慢而且不是常數速度。在遊戲運行期,這些路徑文字資訊是冗餘的,因為玩家不會看到。

URL

URL 算是路徑的延伸。這延伸的好處是它是一個標準(不會有平台相關、大小寫、多國語言等問題),而且有些XML裡也會用URL作為引用的identifier。另外,可以選擇性支持不同的協定 (protocol),例如透過http存取互聯網上的資源。其他壞處和路徑差不多。

整數

整數是最簡單的identifier。通常關聯式資料庫(relational database)的表(table)都以一個整數欄作為列的primary key。相對前兩種identifier,整數的好處是儲存量小而速度快。

整數identifier的缺點是,整數對使用者而言沒有任何意義,也沒有層階式的管理系統。

另外,多少位元才足夠呢? 從之前的估計,10^7筆資料用32-bit就足夠了。用另一個角度想,如果一個5年的項目有100個成員會添加資料,一周工作7天的話平均每人每日可以增加23534筆資料,嗯……應該足夠吧,除非資料經常大量刪去又創建資料(每次都產生新的identifier)。要澈底杜絕Identifier不夠用,可以重用舊的刪去了的Identifier,或是把現有的所有Identifier重新編號。

整數identifier在多人同時建立資料的時候,還要考慮如何令identifier不重覆。解決辦法之一是連線到一個identifier產生的伺服程式。

GUID

最後,GUID的好處是確保在不連線下,每人也可以得到不會重覆的identifier。缺點是GUID的大小為128-bit,是32-bit的4倍。

之後

這幾個簡單的段落已花了我一整個晚上,我想繼續寫有關這些遊戲資料的使用流程,並最後闡述現在做的引擎在這方面的設計。還是那一句,希望多些交流指導。

《程序員修煉之道》、《代碼之美》、《Real-time Rendering 3rd Edition》、《Physical-based Rendering》

新年過後,Mil的開發進度有點慢,花了斷斷續續一個月才完成了一個簡單的 translate gizmo。要長期維持熱情真的不容易。由於沒甚麼Mil的進展可以看,今天也未想到做那個 story,就在此和大家分享這幾個月看過的書。

程序員修煉之道 (英文注釋版)
The Pragmatic Programmer: From Journeyman to Master

我對這書本來沒有很大的期望,而且它的書齡比較大,只是當作休閒讀物,但閱後喜出望外。除了有不少有用精闢的題示,還真的有不少能激發思考的寓言。分享幾個和程序員心理有關的例子,一個提示為

Tip 4: Don’t Live with Broken Windows

說的不是M記的Windows (笑)。故事是說一些研究人員發現,一個漂亮整潔城區如何開始衰落──當一個窗子破了沒修,就會令人覺得業主不關心那大廈,其他人就開始亂拋垃圾、塗鴉,不斷惡性循環,最終變成腐敗地區。

在我的過去,也有不少類似的項目。有時候不是時間的問題,而是心理上的問題。面對寫得不好的東西,沒心要把它弄好,最終只會是一些被廢棄的代碼。又或是,看到一些小bug沒去查,最後要花更多的時間精神去修正,或又是把它放棄。

每看到程序中有問題的地方,就不要容忍。問題可以是設計上的、編程上的。時刻要想著 refactoring。 不要把系統惡化。

另一個提示

Tip 30: You Can’t Write Perfect Software

這個也許是一個顯淺的提示,對我來說郤是一個反省的機會。從來,我希望可以寫出好的程式、好的代碼,希望可以追求完美。但這是不實際的。從來沒有完美的軟件,將來也不會有。軟件內不完美,使用軟件的人或軟件也不完美。要接受這個現實,並反過來令程式在不完美下完成工作。編寫 defensive coding、unit testing。不要為了避免有bug而寫一些「不對的保護」(例如函式內參數不對就直接返回),如果是有問題的,就讓它 crash (或assert等) 並找出問題來源,比匿藏那些問題好。

Beautiful Code: Leading Programmers Explain How They Think
代碼之美

相反,這本原來比較有期望的著作,閱後(沒有全部讀完)有點失望。

有些文章是有些趣味,有些覺得非常沉悶。總體來說,看不到作者們對「美」的看法。一些簡短程式做到比較複雜的功能 (例如 quick sort、regular expression),看上去是不錯的,雖然也不知道簡單是否是美。一些大系統 (例如 ERP…… 囧) 完全不知道他們從那個角度看到它的「美」。

或許,

Beauty is in the eye of the beholder

代碼需然用文字寫成,但是否和詩詞一樣,應用、或可用「美」這個形容詞去度衡量,是一個問題。

買這本書的人或許要三思。

Real-Time Rendering 3rd Edition

毫無疑問,這是一本非常好的書。1027頁、1416個參考文獻、全彩印刷、幾乎沒有廢話和無用插圖(嗯……第三版是有一些比較大的遊戲截圖)、非常全面的內容。開賣時 amazon還打折比買第二版還平宜,是超值的珍藏。第一次擁有這本經典 (第一版在大學圖書館借過,第二版在以前工作上採購過),第一次由頭到尾去閱讀,每個章節都看到自己不懂的新事物。第三版新加入的內容也對我有吸引力。雖然看過一次,但一次絕對不夠,有時間會再讀,再加深理解。

Physically Based Rendering

我在大學沒讀過計算機圖形學、沒寫過 Offline Renderer,所以想多學一些這方面的知識。而另一方面,也憧憬未來計算機圖形的硬件發展,Real-time Ray-tracing、Radiosity 也許會變成主流。這本書提供了理論基楚,和一個 Offline Renderer 的實作 (pbrt),是少有的理論與實際並重的書。

嘗試從頭到尾看一篇,但是後面關於 Monte Carlo Integration 和 Light Transport Equation 對我來說是很難。有時間會再看。

這本書的一個缺點是異常重 (5.5磅 = 2.5 公斤),在床上看腹部會感到很大「壓力」;相對的優點是可以訓練手部肌肉。

閱讀計劃

由於看過《程序員修煉之道 (英文注釋版)》,想繼續看幾本類似的原文書,包括 Kent Beck 的注釋版:

  • Implementation Patterns
  • Test-Driven Development by Example

注釋版是挻方便的,英文生字也不用查字典。

最近還發現剛剛這兩本 Morgan Kaufman 的大陸版英文原文版:

  • 3D Game Engine Design 2nd Edition
  • The Art of Multiprocessor Programming

第一本是剛2009年出版的,現在還斷貨。這書值得參考,尤其是數學部份。

第二本的原書是上年出版的,大陸版也是上年出版。這新書一直想讀,因為自己在這方面比較弱,面對 multi-core 的年代,一定要多多學習。

英文版除了原汁原味,還可以溫習英語,而且有些大陸版的原文書的人民幣價格比原書美金價格還低。真好。

Freetype2向量文字渲染

上回使用Freetype2的光柵化(Rasterization)功能把文字渲染到 Texture上,再把 Texture 渲染到螢幕上。除了這個方法外,前文也提及可以把字型以向量方式渲染。在朋友的慫恿下,加上未有這方面的經驗,便花了兩三天去實作這個功能。雖然這個功能在遊戲中完全不重要,但實作這個的難度比預期高,也沒找到類似的參考,而且需要用許多計算幾何(Computational Geometry) 的演算法,對我來說,好像是一個學習和練習的功課。我嘗試把實作的過程記錄下來,和大家交流。

繼續閱讀

Freetype2 文字渲染

Freetype2 是一個開源的文字渲染程序庫,支持多種向量和點陣字型格式。以前的項目用過這程序庫,不過不是我親手整合。以前的項目有很多不同的重載函數去提供各樣文字渲染功能。今個星期花了三天整合,花了不少時間想,怎麼可以最有彈性、又最簡單呢?

繼續閱讀

平台遊戲的遊戲性實驗

經歷接近一年,這是本 Blog 第一個可「玩」的小測試。測試程式(包括 Lua 源程序)可在本文章末下載。

由於 MilStudio 等著 MUD 模組才好繼續,就先來做一個平台遊戲的遊戲性實驗,過程非常有趣 (可能是因為很多個月裡只是在做引擎和工具),所以這星期一口氣完成了 10 個 stories。

實驗內容

  1. 控制角色,做出走路和跳躍兩個動作。
  2. 繞著角色迴轉的攝影機,角度會按主角移動而自動迴轉,玩家也可以改變角度。
  3. 加入一些箱子,可與角色作物理互動 (角色能推動箱子,箱子也能影響角色)。
  4. 加入移動平台,它們會在兩點中循環移動。角色站在平台上會受平台的慣性影響。例如角色站在水平均速移動的平台向上跳,他掉下來時在平台的位置應該和跳的時候一樣。

繼續閱讀

2008年回顧和展望

迎接家人的來臨後,慢慢適應了新生活。每天還是找到一點時間,看書、寫引擎、和看動畫。很久沒更新這 Blog,就先說說這個月的引擎進展、今年的工作、和明年的展望吧。

本月引擎進展

自從十月中旬開始使用敏捷方法,45 個 stories 中完成了 26 個,平均每個 story 花上了一、兩個晚上。以下簡介一下完成的部份。

MilStudio

我們花了比較多的時間在 MilStudio 這個工具上。雖然它目前還是非常簡陋,但進展還算不錯。

  • Script Editor: 由 Textbox 作為文字編輯器升級至 ScintillaNET,支持 Lua syntax highlighting、搜尋、取代、跳行等等。ScintillaNET 還支持一些將來做 IDE 很有用的功能,例如在 debug 時加入箭頭和斷點等符號。
  • Game Window: 渲染遊戲內容。現在加入了簡單的 Camera Navigation 和 Picking。
  • Entity Window: 以 TreeView 顯示 Entity 層階,也可以選取 Entity。因為之前在物件刪除時會 crash,Refactor 了一次,使 Mil 的資料和 MilStudio 的顯示同步。
  • Property Window: 顯示和修改目前選取的 Entity 的屬性。

目前可以用 MilStudio 編寫一些簡單的測試,但由於仍欠缺 World 的 Persistency,MilStudio 並沒有實際創建和編輯Entity 的能力。

繼續閱讀

協作,敏捷,繼續做引擎

距離上一篇 Blog 已經是兩個半月了。光陰似箭,兒子也出生了,引擎的開發進度還是很慢。

希望引擎不用等到兒子接手才能完成,回來上海後,最近有了一些新進展。

首先,決定不再獨自閉門做車,找了同事 xlyao 一齊在工餘時間開發。很多獨自煩惱想不到解決科案 (通常是太多科案選不來),兩個人經過討論後就較容易得出結論。

第二,採用所謂敏捷軟件開發方法 (Agile Software Development)。我對 Agile 也許還是一知半解,希望逐步能掌握箇中真諦。

現時的開發過程包括以下數項。

User Story

一個 user story 是一個由 user 提出的明確要求。這些 user story 是要很細小及可以簡單驗証的,例如「使用者可以在地圖編輯器點選一個物件,把它複製。」

我們利用 Google Docs 的 Spreadsheet 輸入 user stories。這些 stories 沒有指明由誰來做,誰喜歡做那個就拿來做,記下開始及完成日期。

User story 的好處是軟件的功能按實際所需而增加,而不是在項目開始時花很多時間寫一份軟件需求規格。因為每個 User story 都比較小,軟件能不停有新的產出 (deliverable),也能讓使用者驗証是否合乎他的要求。傳統的做法有時可能花幾個月時間在一個需求上,但後來又發現和實際所需有所分別。另一個好處是,程序員可能用幾小時或幾天時間便可以完成一個 story,不用預先考慮太多將來未知的需求,心理上也能得到點成就感。不過也要有打算,未來會因為新的 story 而要把舊的代碼重構 (Refactor)。

審核

每個 User Story 完成後,現時是直接 commit 進 SVN 伺服器。我設置了 svnnotify 程序,當有 commit 時會發電郵給所有人,內容包括 log 和 diff。我們需要審核代碼,並應該測試是否符合 story 的要求。

溝通

敏捷很重視溝通。撰寫 Story 可能是一個人提出的,但應該要和其他人解釋。Story 不會寫的很深入 (和傳統的軟件規格有分別),要討論來得知詳細的需求,和如何把它實作。

未來

敏捷方法還包括很多 practice,我們暫時未正式採用,或許將來會慢慢加入吧,例如:

  1. Poker Planning: 透過各自估計 story size point (規模點數),和別人比較和討論後,決定 story 的 size point。
  2. Sprint planning: 每個 sprint (時間單位,例如一週) ,按 story 的優先次序、依賴性及 point 預算 (以之前的 Sprint 能做多少 point 和這個 sprint 的人手時間等決定),去決定做那些 stories。
  3. Stand up meeting: 每天工作開始時,所有隊員站立開會,討論上一天的工作情況及今天的計劃。這個應該沒可能了……
  4. Sprint review: 每個 sprint 結束後討論 sprint 做得好與壞的地方。

雖然開始協作和敏捷方法只有數天,但感覺上項目有了新的動力,希望能在很短的未來能不斷有產出,和大家分享。

P.S. 我把兒子起了英文名字── Lua,真的寫了在出身證名文件裡……

Home

Search
Feeds
Meta

Return to page top