Sep 18 2008
Prototype:物件原型複製者
在物件導向生成設計模式的範疇中,除了之前曾經介紹過的 Facory Method 以及 Abstract Factory 兩項工廠類型的模式以外,本文將繼續介紹另一項在遊戲程式領域中,經常受到廣泛使用的 Prototype 設計模式。
Prototype,原意為原型或雛形,與工廠模式接收到使用者命令之後即時生產物件的方式不同,在 Prototype 模式中,我們需要先打造出物件的原型樣版,待鑄模程序完成以後,接下來就能夠輕易地以此原型複製產生出全新的物件。複製,也可以是一種生成物件的方法。如 UML 圖所示,在 Prototype 設計模式中,定義了三個參與角色:
- Prototype:宣告自我複製的介面。
- ConcretePrototype:具體實作出自我複製的操作。
- Client:叫原型個體自我複製一份,以生出新的物件。
按照上述的責任關係,程式系統的使用者 Client 只需要指涉到基礎的 Prototype 介面,就能夠以此 Prototype 介面所宣告的 Clone() 方法,複製出類別的物件成品;而 ConcretePrototype 們衍生自 Prototype 介面,是 Client 進行 Operation() 方法後將會獲得的實際成品,所以必須一肩扛起實作 Clone() 內容細節的重責大任。
以遊戲中的角色系統架構為例,可以由角色的基底類別 Actor 扮演上述的 Prototype 角色,而分別衍生自 Actor 類別的 Player 與 Monster 類別則是屬於其中的 ConcretePrototype 角色。只要在 Actor 類別中宣告了 Clone() 介面,再經由 Player 與 Monster 類別實作出 Clone() 方法的細節以後,在遊戲程式中就能夠輕鬆地使用 Actor 類別的 Clone() 介面,複製出所需的玩家物件與怪物物件:
// 創建玩家原型 Player* playerPrototype = new Player(); // 以原型複製方法,產生玩家物件 Actor* playerCloned = playerPrototype->Clone(); // 創建怪物原型,並且設定原型的初始數值 Monster* monsterPrototype = new Monster(); monsterPrototype->SetHP(100); monsterPrototype->SetMP(50); monsterPrototype->SetExp(250); // 以原型複製方法,產生怪物物件 Actor* monster1 = monsterPrototype->Clone(); Actor* monster2 = monsterPrototype->Clone(); Actor* monster3 = monsterPrototype->Clone();
Prototype 設計模式的基本結構與應用概念非常易於理解;然而,使用 Prototype 設計模式的困難之處,就在於如何實作複製方法。
首先,先重溫一下創造物件的其他方法。在物件導向程式設計中,若要達到生成物件的目標,除了以建構式傳入相關參數,或是使用預設建構式創造物件後再進行 Initialize() 或 Setup() 等初始程序以外,還有另外兩個常見的方法:使用複製建構式 (Copy Constructor) 或賦值運算子 (Assignment Operator)。
// 以預設建構式建立 player1 物件 Player player1; // 以複製建構式建立 player2 物件 Player player2(player1); // 以賦值運算子將 player2 物件的內容複製給 player1 物件 player1 = player2;
以複製建構式與賦值運算子創建物件的方法,都是藉由已建立完成的物件進行成員資料的複製程序。但是一般來說,由於這兩項方法很容易被使用者誤用而且難以察覺,所以在遊戲程式中,除非有不得不使用的良好理由,否則我們會盡量避免使用複製建構式與賦值運算子的方法,而應該以明確的 Clone() 或者 Copy() 方法清楚標示出複製行為的操作方法,以避免造成誤用的可能性。另外,也可以將類別的複製建構式與賦值運算子宣告為私有成員,就能夠有效禁止這兩項方法的操作行為:
class Player
{
public:
Player();
Player(int hp, int mp);
private:
// 宣告為私有成員,禁止外部程式碼使用
Player(const Player& p);
Player& operator=(const Player& p);
};
在談到複製機制時非提不可的議題,就是著名的淺層複製 (Shallow Copy) 與深層複製 (Deep Copy) 問題。簡單來說,淺層複製只會拷貝物件的參考,而深層複製才會完完整整地拷貝物件的一切內容物。對於遊戲系統的應用面來說,如果只有淺層複製機制通常並不足夠;舉個例子來說,如果在 Monster 類別中,內含了一個 Transform 類別用來處理怪物的位移、旋轉與縮放功能,然後我們使用了淺層複製的方式實作 Monster 類別的 Clone() 方法:
Actor* Monster::Clone()
{
Monster* monster = new Monster();
monster->SetHP(m_MaxHP);
monster->SetMP(m_MaxMP);
monster->SetExp(m_Exp);
// 將物件指標傳入新建立的 monster 物件中
monster->SetTransform(m_Transform);
return monster;
}
如上所示,這樣的複製程序,就等於將所有複製出來的怪物物件與原來的怪物物件,全部指向同一個 Transform 物件,所以只要移動其中一隻怪物的位置,所有的怪物都會跟著位移到相同的位置上。正確的深層複製 Clone() 程序如下:
Actor* Monster::Clone()
{
Monster* monster = new Monster();
monster->SetHP(m_MaxHP);
monster->SetMP(m_MaxMP);
monster->SetExp(m_Exp);
// 創建新的 Transform 物件
Transform* transform = new Transform();
transform->SetPosition(m_Transform.GetPosition());
transform->SetRotation(m_Transform.GetRotation());
transform->SetScale(m_Transform.GetScale());
// 新的 Monster 物件使用新的 Transform 物件
monster->SetTransform(transform);
return monster;
}
只要為類別物件實作出深層複製的 Clone() 方法,就能夠建立出一整組的 Prototype 物件,接下來就可以使用一個 PrototypeManager 類別來管理這些 Prototype 物件。以管理特效原型物件的類別為例:
// @file EffectPrototypeManager.h
#include "Dictionary.h"
class EffectPrototypeManager
{
public:
Effect* CreateEffect(std::string& fileName)
{
Effect* effect = NULL;
// 先在 Prototypes Pool 中尋找該特效的原型是否存在
if (!m_PrototypesPool->GetAt(fileName, effect)) {
// 若不存在,則創建新的特效原型
effect = new Effect(fileName);
// 將原型物件存入 Prototypes Pool 中
m_PrototypesPool->SetAt(fileName, effect);
}
// 以原型物件複製產生新的特效物件
return effect->Clone();
}
private:
Dictionary< std::string, Effect*, true >* m_PrototypesPool;
};
在遊戲引擎以及繪圖引擎中,Prototype 設計模式具有相當重要的地位。舉例來說,假設遊戲中的每個技能特效物件,都儲存在個別的實體檔案中,當遊戲需要產生出 10 個相同檔案名稱的特效物件時,是不是表示系統也需要讀取相同的檔案 10 次之多?如果在程式系統中,能夠先建立起這些特效物件的原型,就不需要重複進行開啟檔案與讀取檔案的程序,只要執行一次 I/O 程序,後續的物件就可以全部交由 CPU 進行複製操作。另外,還能夠藉此機制實作出預先載入遊戲資源的功能。
在 Java 與 C# 語言中,都存在著 ICloneable 介面類別,使用者能夠繼承此介面以實作出合適的可複製類別 (Cloneable Class)。但是在 C++ 語言中,並沒有提供類似 ICloneable 介面的機制,因此以 C++ 語言撰寫的遊戲引擎與繪圖引擎,就必須自行打造出適當的 Clone 架構與機制。以 OGRE 繪圖引擎為例,OGRE 目前並沒有符合 Prototype 設計模式的架構與介面,僅有少部分的類別如 Animation、Entity、Material 與 Mesh 等等擁有複製方法;其中,Entity 類別的 clone() 程序如下所示:
// @file OgreEntity.cpp
Entity* Entity::clone(const String& newName) const
{
Entity* newEnt = mManager->createEntity(newName, getMesh()->getName());
if (mInitialised) {
// Copy material settings
SubEntityList::const_iterator i;
unsigned int n = 0;
for (i = mSubEntityList.begin(); i != mSubEntityList.end(); ++i, ++n) {
newEnt->getSubEntity(n)->setMaterialName((*i)->getMaterialName());
}
if (mAnimationState) {
delete newEnt->mAnimationState;
newEnt->mAnimationState = new AnimationStateSet(*mAnimationState);
}
}
return newEnt;
}
在之前介紹 OGRE 引擎的文章裡,曾經提到關於資源物件名稱唯一性的問題,所以在 OGRE 中複製物件時,必須傳入一個全新的物件名稱做為複製方法的參數值。如上述程式碼所示,OGRE 的 Entity 類別複製方法是先由 SceneManager 建立出全新的 Entity 物件後,再逐一拷貝 Material 與 AnimationState 物件的設定值。雖然 Entity 類別有提供 clone() 方法,但很可惜的是,更為關鍵重要的 Node 與 SceneNode 類別都沒有提供相對應的 clone() 方法,對於以建構大型遊戲系統為目標的程式設計者來說,將會造成許多資源管理層面的困擾。
在如同 OGRE 般使用 Scene Graph 架構的遊戲引擎或者繪圖引擎裡,大多數遊戲中的物件,都是以 Node 與 Entity 物件層層堆疊連結所建立而成的一棵「樹」。所以對於 Scene Graph 架構的引擎來說,如果要達到深層複製的功能,必須要能夠複製某個 Node 物件底下的整個結構樹;也就是說,除了複製物件本體以外,還需要複製物件的連結性。因此,想要實作出完整的 Clone() 方法,往往不是只用 memcpy() 複製某一區塊的記憶體就能夠單純解決的問題,而必須以遞迴的方式層層向下複製物件的結構與連結關係。
Prototype,就是這樣一個概念簡單而實作細節令人費盡思量的設計模式。如果程式設計者能夠善加利用 Prototype 模式,並且實作出合適的物件複製方法,將能夠為遊戲引擎與遊戲系統帶來很大的助益!

在「在遊戲引擎以及繪圖引擎中,Prototype 設計模式具有相當重要的地位。」一段中,我覺得如果 Prototype 的作用只是用來讓一個 Resource 共同使用的話,應該不太算是 Prototype 的功能。我覺得這應該是一般的 Resource Management 的問題,例如需要用一個 key 去取得 Resource,同時處理 Reference Count 等等。
以我記憶,Prototype 的適合時機是: 建立一個新物件花的資源(例如時間),比複制並修改一個物件多。否則直接以 constructor (或 static function) 建立物件、或透過 Factory 用動態型別 (例如一個 enum parameter) 建立物件就可以。在實際應用上,好像很少機會碰到那種時機。如果以這個條件為準,本文的例子就不太適合。
反而,一個 IClonable 介面是有需要的。就是當我們真的要複製某一個物件 (而非只是 Prototype)。例如 一個通用的 Copy and Paste 機制處理不同的型別就必需要 polymorphic 的 Clone()。
關於 Clone,令我想起實作遊戲用的 Containers 時,可以 specialize 去支援一些可以 bitwise copyable 的類。例如 Vector 中,如果 Player 是可以 bitwise copyable,就可以令這個 Vector 在 resize 時直接 memcpy() 而不必續一呼叫 copy constructor。
@Milo:
唔,在「Prototype 設計模式具有相當重要的地位」那個段落中,我的敘述不夠明確,的確容易產生誤解。
如果是像貼圖、模型、文字或音源之類單純的實體檔案資料,使用 Reference Counting 機制處理資源共享是絕對沒問題的。但我在文中所提的「物件」指的其實是「Game Object」,也就是在 physical resource 之上的那層遊戲物件。Game Object 可能是由數個不同的 base class 所架構起來的完整物件。
以 Scene Graph 架構的 OGRE 為例,當美術在 3ds Max 中製作了一段魔法攻擊的炫麗特效後,會匯出 mesh、texture、material 以及管理物件用的 osm 檔案。而遊戲程式要把這個特效檔案載入時,必須先去讀取並且解析 osm 檔案中的定義,才能夠建立起特效的 spatial data,也就是 node 與 node 之間的階層關係。問題在於遊戲如果同時需要顯示好幾份魔法攻擊特效時,雖然 mesh、texture 與 material 檔案能夠獲得資源共享的好處,但是最上層的 osm 物件卻沒有辦法達成這樣的目標。
由 osm 檔案建構出來的遊戲物件,通常是一棵完整的 node-based tree,藉由最頂端的 root node 與遊戲世界相互 attach/detach 而產生作用。在這樣的情況下,如果遊戲引擎中的 node 類別沒有提供深層複製的 Prototype 模式,等同於每次需要 osm 物件時,都必須由使用者自己重新建立出完整的遊戲物件結構(重複之前的讀取與解析 osm 檔案程序)。因此,如果能夠在 osm 物件的層面上實作出 Prototype 模式,就可以為程式設計者減輕不少負擔,也會對遊戲的效能提升產生綜效。
所以,在文章裡所指的 Prototype 應用之處,並不是在於低階的資源管理,而是在於比較高階的物件複製層面上。
我同意,你舉的 OGRE sub-tree copy 例子是一個需要 Prototype 的應用。我覺得其原因在於建立一個新物件花的資源(這個例子是 I/O、Parsing 時間),比複制並修改一個物件多。
@Milo Yip:
沒錯,複製遊戲物件的 sub-tree 結構就是 Prototype 模式最適當的應用之一,也是在大部分的遊戲引擎中比較少見的進階功能。