Prototype:物件原型複製者

prototype-uml在物件導向生成設計模式的範疇中,除了之前曾經介紹過的 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 模式,並且實作出合適的物件複製方法,將能夠為遊戲引擎與遊戲系統帶來很大的助益!

4 thoughts on “Prototype:物件原型複製者”

  1. 在「在遊戲引擎以及繪圖引擎中,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。

  2. @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 應用之處,並不是在於低階的資源管理,而是在於比較高階的物件複製層面上。

  3. 我同意,你舉的 OGRE sub-tree copy 例子是一個需要 Prototype 的應用。我覺得其原因在於建立一個新物件花的資源(這個例子是 I/O、Parsing 時間),比複制並修改一個物件多。

  4. @Milo Yip:
    沒錯,複製遊戲物件的 sub-tree 結構就是 Prototype 模式最適當的應用之一,也是在大部分的遊戲引擎中比較少見的進階功能。

Leave a Reply