《InsideUE4》GamePlay架構(一)Actor和Component

大釗發表於2016-12-23

《InsideUE4》GamePlay架構(一)Actor和Component


想要啥Component,Actor你自己拿

引言

如果讓你來製作一款3D遊戲引擎,你會怎麼設計其結構?

儘管遊戲的型別有很多種,市面上也有眾多的3D遊戲引擎,但絕大部分遊戲引擎都得解決一個基本問題:抽象模擬一個3D遊戲世界。根據基本的圖形學知識,我們知道,為了展示這個世界,我們需要一個個帶著“變換”的“遊戲物件”,接著讓它們父子巢狀以表現更復雜的結構。本質上,其他的物理模擬,遊戲邏輯等功能元件,最終目的也只是為了操作這些“遊戲物件”。
這件事,在Unity那裡就直接成了“GameObject”和“Component”;在Cocos2dx那裡是一個個的“CCNode”,操縱部分直接內嵌在了CCNode裡面;在Medusa裡是一個個“INode”和“IComponent”。
那麼在UE4的眼中,它是怎麼看待遊戲的3D世界的?

創世記

UE創世,萬物皆UObject,接著有Actor。

UObject:

起初,UE創世,有感於天地間C++原始之氣一片混沌虛無,便擷取凝實一團C++之氣,降下無邊魔力,灑下秩序之光,便為這個世界生成了堅實的土壤UObject,並用UClass一一為此命名。

《InsideUE4》GamePlay架構(一)Actor和Component
UObject.png-26.8kB

藉著UObject提供的後設資料、反射生成、GC垃圾回收、序列化、編輯器可見,Class Default Object等,UE可以構建一個Object執行的世界。(後續會有一個大長篇深挖UObject)

Actor:

世界有了土壤之後,但還少了一些生動色彩,如果女媧造人一般,UE取一些UObject的泥巴,派生出了Actor。在UE眼中,整個世界從此了有了一個個生動的“演員”,眾多的“演員”們,一起齊心協力為觀眾上演一場精彩的遊戲。

《InsideUE4》GamePlay架構(一)Actor和Component
Actor1.png-38.5kB

脫胎自Object的Actor也多了一些本事:Replication(網路複製),Spawn(生生死死),Tick(有了心跳)。
Actor無疑是UE中最重要的角色之一,組織龐大,最常見的有StaticMeshActor, CameraActor和 PlayerStartActor等。Actor之間還可以互相“巢狀”,擁有相對的“父子”關係。

思考:為何Actor不像GameObject一樣自帶Transform?
我們知道,如果一個物件需要在3D世界中表示,那麼它必然要攜帶一個Transform matrix來表示其位置。關鍵在於,在UE看來,Actor並不只是3D中的“表示”,一些不在世界裡展示的“不可見物件”也可以是Actor,如AInfo(派生類AWorldSetting,AGameMode,AGameSession,APlayerState,AGameState等),AHUD,APlayerCameraManager等,代表了這個世界的某種資訊、狀態、規則。你可以把這些看作都是一個個默默工作的靈體Actor。所以,Actor的概念在UE裡其實不是某種具象化的3D世界裡的物件,而是世界裡的種種元素,用更泛化抽象的概念來看,小到一個個地上的石頭,大到整個世界的執行規則,都是Actor.
當然,你也可以說即使帶著Transform,把座標設定為原點,然後不可見不就行了?這樣其實當然也是可以,不過可能因為UE跟貼近C++一些的緣故,所以設計哲學上就更偏向於C++的哲學“不為你不需要的東西付代價”。一個Transform再加上附帶的逆矩陣之類的表示,記憶體佔用上其實也是挺可觀的。要知道UE可是會摳門到連bool變數都要寫成uint bPending:1;位域來節省一個位元組的記憶體的。
換一個角度講,如果把帶Transform也當成一個Actor的額外能力可以自由裝卸的話,那其實也可以自圓其說。經過了UE的權衡和考慮,把Transform封裝進了SceneComponent,當作RootComponent。但在權衡到使用的便利性的時候,大部分Actor其實是有Transform的,我們會經常獲取設定它的座標,如果總是得先獲取一下SceneComponent,然後再呼叫相應介面的話,那也太繁瑣了。所以UE也為了我們直接提供了一些便利性的Actor方法,如(Get/Set)ActorLocation等,其實內部都是轉發到RootComponent。

/*~
 * Returns location of the RootComponent 
 * this is a template for no other reason than to delay compilation until USceneComponent is defined
 */ 
template<class T>
static FORCEINLINE FVector GetActorLocation(const T* RootComponent)
{
    return (RootComponent != nullptr) ? RootComponent->GetComponentLocation() : FVector(0.f,0.f,0.f);
}

bool AActor::SetActorLocation(const FVector& NewLocation, bool bSweep, FHitResult* OutSweepHitResult, ETeleportType Teleport)
{
    if (RootComponent)
    {
        const FVector Delta = NewLocation - GetActorLocation();
        return RootComponent->MoveComponent(Delta, GetActorQuat(), bSweep, OutSweepHitResult, MOVECOMP_NoFlags, Teleport);
    }
    else if (OutSweepHitResult)
    {
        *OutSweepHitResult = FHitResult();
    }

    return false;
}複製程式碼

同理,Actor能接收處理Input事件的能力,其實也是轉發到內部的UInputComponent* InputComponent;同樣也提供了便利方法。

Component

世界紛繁複雜,光有一種Actor可不夠,自然就需要有各種不同技能的Actor各司其職。在早期的遠古時代,每個Actor擁有的技能都是與生俱有,只能父傳子一代代的傳下去。隨著遊戲世界的越來越絢麗,需要的技能變得越來越多和頻繁改變,這樣一組合,唯出身論的Actor數量們就開始爆炸了,而且一個個也越來越胖,最後連UE這樣的神也管理不了了。終於,到了第4個紀元,UE窺得一絲隔壁平行宇宙Unity的天機。下定決心,讓Actor們輕裝上陣,只提供一些通用的基本生存能力,而把眾多的“技能”抽象成了一個個“Component”並提供組裝的介面,讓Actor隨用隨組裝,把自己武裝成一個個專業能手。

《InsideUE4》GamePlay架構(一)Actor和Component
ActorAndComponent.png-30.4kB

看見UActorComponent的U字首,是不是想起了什麼?沒錯,UActorComponent也是基礎於UObject的一個子類,這意味著其實Component也是有UObject的那些通用功能的。(關於Actor和Component之間Tick的傳遞後續再細討論)

下面我們來細細看一下Actor和Component的關係:
TSet<UActorComponent*> OwnedComponents 儲存著這個Actor所擁有的所有Component,一般其中會有一個SceneComponent作為RootComponent。
TArray<UActorComponent*> InstanceComponents 儲存著例項化的Components。例項化是個什麼意思呢,就是你在藍圖裡Details定義的Component,當這個Actor被例項化的時候,這些附屬的Component也會被例項化。這其實很好理解,就像士兵手上拿著把武器,當我們擁有一隊士兵的時候,自然就一一對應擁有了不同例項化的武器。但OwnedComponents裡總是最全的。ReplicatedComponents,InstanceComponents可以看作一個預先的分類。

一個Actor若想可以被放進Level裡,就必須例項化USceneComponent* RootComponent。但如果你光看程式碼的話,OwnedComponents其實也是可以包容多個不同SceneComponent的,然後你可以動態獲取不同的SceneComponent來當作RootComponent,只不過這種用法確實不太自然,而且也得非常小心維護不同狀態,不推薦如此用。在我們的直覺印象裡,一個封裝過後的Actor應該是一個整體,它能被放進Level中,擁有變換,這一整個整體的概念更加符合自然意識,所以我想,這也是UE為何要在Actor裡一一對應一個RootComponent的原因。

再來說說Component下面的家族(為了闡明概念,只列出了最常見的):

《InsideUE4》GamePlay架構(一)Actor和Component
Components.png-57.4kB

ActorComponent下面最重要的一個Component就非SceneComponent莫屬了。SceneComponent提供了兩大能力:一是Transform,二是SceneComponent的互相巢狀。
《InsideUE4》GamePlay架構(一)Actor和Component
OfficialActorAndComponent.jpg-24.4kB

思考:為何ActorComponent不能互相巢狀?而在SceneComponent一級才提供巢狀?
首先,ActorComponent下面當然不是隻有SceneComponent,一些UMovementComponent,AIComponent,或者是我們自己寫的Component,都是會直接繼承ActorComponent的。但很奇怪的是,ActorComponent卻是不能巢狀的,在UE的觀念裡,好像只有帶Transform的SceneComponent才有資格被巢狀,好像Component的互相巢狀必須和3D裡的transform父子對應起來。
老實說,如果讓我來設計Entity-Component模式,我很可能會為了通用性而在ActorComponent這一級直接提供巢狀,這樣所有的Component就與生俱來擁有了組合其他Component的能力,靈活性大大提高。但遊戲引擎的設計必然也經過了各種權衡,雖然說架構上顯得並不那麼的統一干淨,但其實也大大減少了被誤用的機會。實體元件模式推崇的“組合優於繼承”的概念確實很強大,但其實同時也帶來了一些問題,如Component之間如何互相依賴,如何互相通訊,巢狀過深導致的介面便利損失和效能損耗,真正一個讓你隨便巢狀的元件模式可能會在使用上更容易出問題。
從功能上來說,UE更傾向於編寫功能單一的Component(如UMovementComponent),而不是一個整合了其他Component的大管家Component(當然如果你偏要這麼幹,那UE也阻止不了你)。
而從遊戲邏輯的實現來說,UE也是不推薦把遊戲邏輯寫在Component裡面,所以你其實也沒什麼機會去寫一個很複雜的Component.

思考:Actor的SceneComponent哲學
很多其他遊戲引擎,還有一種設計思路是“萬物皆Node”。Node都帶變換。比如說你要設計一輛汽車,一種方式是車身作為一個Node,4個輪子各為車身的子Node,然後移動父Node來前進。而在UE裡,一種很可能的方式就變成,汽車是一個Actor,車身作為RootComponent,4個輪子都作為RootComponent的子SceneComponent。請讀者們細細體會這二者的區別。兩種方式都可以實現出優秀的遊戲引擎,只是有些理念和側重點不同。
從設計哲學上來說,其實你把萬物看成是Node,或者是Component,並沒有什麼本質上的不同。看作Node的時候,Node你就要設計的比較輕量廉價,這樣才能比較沒有負擔的建立多個,同理Component也是如此。Actor可以帶多個SceneComponent來渲染多個Mesh實體,同樣每個Node帶一份Mesh再組合也可以實現出同樣效果。
個人觀點來說,關鍵的不同是在於你是怎麼劃分要操作的實體的粒度的。當看成是Node時,因為Node身上的一些通用功能(事件處理等),其實我們是期望著我們可以非常靈活的操作到任何一個細小的物件,我們希望整個世界的所有物體都有一些基本的功能(比如說被拾取),這有點完美主義者的思路。而注重現實的人就會覺得,整個遊戲世界裡,有相當大一部分物件其實是不那麼動態的。比如車子,我關心的只是整體,而不是細小到每一個車軲轆。這種理念就會導成另外一種設計思路:把要操作的實體按照功能劃分,而其他的就儘量只是最簡單的表示。所以在UE裡,其實是把5個薄薄的SceneComponent表示再用Actor功能的盒子裝了起來,而在這個盒子內部你可以編寫操作這5個物件的邏輯。換做是Node模式,想編寫操作邏輯的話,一般就來說就會內化到父Node的內部,不免會有邏輯與表現摻雜之嫌,而如果Node要把邏輯再用組合分離開的話,其實也就轉化成了某種ScriptComponent。

思考:Actor之間的父子關係是怎麼確定的?
你應該已經注意到了Actor裡面的TArray<AActor*> Children欄位,所以你可能會期望看到Actor:AddChild之類的方法,很遺憾。在UE裡,Actor之間的父子關係卻是通過Component確定的。同一般的Parent:AddChild操作原語不同,UE裡是通過Child:AttachToActor或Child:AttachToComponent來建立父子連線的。

void AActor::AttachToActor(AActor* ParentActor, const FAttachmentTransformRules& AttachmentRules, FName SocketName)
{
    if (RootComponent && ParentActor)
    {
        USceneComponent* ParentDefaultAttachComponent = ParentActor->GetDefaultAttachComponent();
        if (ParentDefaultAttachComponent)
        {
            RootComponent->AttachToComponent(ParentDefaultAttachComponent, AttachmentRules, SocketName);
        }
    }
}
void AActor::AttachToComponent(USceneComponent* Parent, const FAttachmentTransformRules& AttachmentRules, FName SocketName)
{
    if (RootComponent && Parent)
    {
        RootComponent->AttachToComponent(Parent, AttachmentRules, SocketName);
    }
}複製程式碼

3D世界裡的“父子”關係,我們一般可能會認為就是3D世界裡的變換的座標空間“父子”關係,但如果再度擴充套件一下,如上所述,一個Actor可是可以帶有多個SceneComponent的,這意味著一個Actor是可以帶有多個Transform“錨點”的。建立父子時,你到底是要把當前Actor當作對方哪個SceneComponent的子?再進一步,如果你想更細控制到Attach到某個Mesh的某個Socket(關於Socket Slot,目前可以簡單理解為一個虛擬插槽,提供變換錨點),你就更需要去尋找到特定的變換錨點,然後Attach的過程分別在Location,Roator,Scale上應用Rule來計算最後的位置。

/** Rules for attaching components - needs to be kept synced to EDetachmentRule */
UENUM()
enum class EAttachmentRule : uint8
{
    /** Keeps current relative transform as the relative transform to the new parent. */
    KeepRelative,

    /** Automatically calculates the relative transform such that the attached component maintains the same world transform. */
    KeepWorld,

    /** Snaps transform to the attach point */
    SnapToTarget,
};複製程式碼

所以Actor父子之間的“關係”其實隱含了許多資料,而這些資料都是在Component上提供的。Actor其實更像是一個容器,只提供了基本的建立銷燬,網路複製,事件觸發等一些邏輯性的功能,而把父子的關係維護都交給了具體的Component,所以更準確的說,其實是不同Actor的SceneComponent之間有父子關係,而Actor本身其實並不太關心。

接下來的左側派生鏈依次提供了物理,材質,網格最終合成了一個我們最普通常見的StaticMeshComponent。而右側的ChildActorComponent則是提供了Component之下再疊加Actor的能力。

聊一聊ChildActorComponent
同作為最常用到的Component之一,ChildActorComponent擔負著Actor之間互相組合的膠水。這貨在藍圖裡靜態存在的時候其實並不真正的建立Actor,而是在之後Component例項化的時候才真正建立。


void UChildActorComponent::OnRegister()
{
    Super::OnRegister();

    if (ChildActor)
    {
        if (ChildActor->GetClass() != ChildActorClass)
        {
            DestroyChildActor();
            CreateChildActor();
        }
        else
        {
            ChildActorName = ChildActor->GetFName();

            USceneComponent* ChildRoot = ChildActor->GetRootComponent();
            if (ChildRoot && ChildRoot->GetAttachParent() != this)
            {
                // attach new actor to this component
                // we can't attach in CreateChildActor since it has intermediate Mobility set up
                // causing spam with inconsistent mobility set up
                // so moving Attach to happen in Register
                ChildRoot->AttachToComponent(this, FAttachmentTransformRules::SnapToTargetNotIncludingScale);
            }

            // Ensure the components replication is correctly initialized
            SetIsReplicated(ChildActor->GetIsReplicated());
        }
    }
    else if (ChildActorClass)
    {
        CreateChildActor();
    }
}

void UChildActorComponent::OnComponentCreated()
{
    Super::OnComponentCreated();

    CreateChildActor();
}複製程式碼

這就導致了一個問題,當你把一個ActorClass拖進Level後,這個Actor實際是已經例項化了,你可以直接調整這個Actor的屬性。但是你把它拖到另一個Actor Class裡,它只會給你空空白白的ChildActorComponent的DetailsPanel,你想調整Actor的屬性,就只能等生成了之後,用藍圖或程式碼去修改。這一點來說,其實還是挺不方便的,我個人覺得應該是還有優化的空間。

修訂

4.14 Child Actor Templates

UE終於聽到了人民群眾的呼聲,在4.14裡增加了Child Actor Templates來支援在子ChildActor的DetailsPannel裡檢視和修改屬性。

《InsideUE4》GamePlay架構(一)Actor和Component
ChildActorTemplates.jpg-126.6kB

後記

花了這麼多篇幅,才剛剛講到Actor和Component這兩個最基本的整體設計,而關於Actor,Component生命週期,Tick,事件傳遞等機制性的問題,還都沒有展開。UE作為從1代至今4代,久經磨練的一款成熟引擎,GamePlay框架部分其實也就不到十個類,而這些類之間怎麼組織,為啥這麼設計,有什麼權衡和考慮,我相信這裡面其實是非常有講究的。如果是UE的總架構師來講解的話,肯定能有非常多的心得體會故事。而我們作為學習者,也應該儘量去體會琢磨它的用心,一方面磨練我們自己的架構設計能力,一方面也讓我們更能掌握這個遊戲的引擎。
從此篇開始,會循序漸進的探討各個部分的結構設計,最後再從整體的框架上討論該結構的優劣點。

下一篇預告:GamePlay架構(二)Level和World

引用

  1. Unreal Engine 4 Terminology
  2. Gameplay Framework Quick Reference

知乎專欄:InsideUE4
UE4深入學習QQ群:456247757(非新手入門群,請先學習完官方文件和視訊教程)
微信公眾號:aboutue,關於UE的一切新聞資訊、技巧問答、文章釋出,歡迎關注。
個人原創,未經授權,謝絕轉載!

相關文章