《Inside UE4》-2-GamePlay架構(一)Actor和Component
《Inside UE4》-2-GamePlay架構(一)Actor和Component
InsideUE4
UE4深入學習QQ群: 456247757
引言
如果讓你來制作一款3D游戲引擎,你會怎么設計其結構?
盡管游戲的類型有很多種,市面上也有眾多的3D游戲引擎,但絕大部分游戲引擎都得解決一個基本問題:抽象模擬一個3D游戲世界。根據基本的圖形學知識,我們知道,為了展示這個世界,我們需要一個個帶着“變換”的“游戲對象”,接着讓它們父子嵌套以表現更復雜的結構。本質上,其他的物理模擬,游戲邏輯等功能組件,最終目的也只是為了操作這些“游戲對象”。
這件事,在Unity那里就直接成了“GameObject”和“Component”;在Cocos2dx那里是一個個的“CCNode”,操縱部分直接內嵌在了CCNode里面;在Medusa里是一個個“INode”和“IComponent”。
那么在UE4的眼中,它是怎么看待游戲的3D世界的?
創世記
UE創世,萬物皆UObject,接着有Actor。
UObject:
起初,UE創世,有感於天地間C++原始之氣一片混沌虛無,便擷取凝實一團C++之氣,降下無邊魔力,灑下秩序之光,便為這個世界生成了堅實的土壤UObject,並用UClass一一為此命名。
藉着UObject提供的元數據、反射生成、GC垃圾回收、序列化、編輯器可見,Class Default Object等,UE可以構建一個Object運行的世界。(后續會有一個大長篇深挖UObject)
Actor:
世界有了土壤之后,但還少了一些生動色彩,如果女媧造人一般,UE取一些UObject的泥巴,派生出了Actor。在UE眼中,整個世界從此了有了一個個生動的“演員”,眾多的“演員”們,一起齊心協力為觀眾上演一場精彩的游戲。
脫胎自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 FVectorGetActorLocation(const T*RootComponent)
{
return(RootComponent!=nullptr)?RootComponent->GetComponentLocation():FVector(0.f,0.f,0.f);
}
boolAActor::SetActorLocation(constFVector&NewLocation,bool bSweep,FHitResult*OutSweepHitResult,ETeleportTypeTeleport)
{
if(RootComponent)
{
constFVectorDelta=NewLocation-GetActorLocation();
returnRootComponent->MoveComponent(Delta,GetActorQuat(), bSweep,OutSweepHitResult, MOVECOMP_NoFlags,Teleport);
}
elseif(OutSweepHitResult)
{
*OutSweepHitResult=FHitResult();
}
returnfalse;
}
同理,Actor能接收處理Input事件的能力,其實也是轉發到內部的UInputComponent* InputComponent;同樣也提供了便利方法。
Component
世界紛繁復雜,光有一種Actor可不夠,自然就需要有各種不同技能的Actor各司其職。在早期的遠古時代,每個Actor擁有的技能都是與生俱有,只能父傳子一代代的傳下去。隨着游戲世界的越來越絢麗,需要的技能變得越來越多和頻繁改變,這樣一組合,唯出身論的Actor數量們就開始爆炸了,而且一個個也越來越胖,最后連UE這樣的神也管理不了了。終於,到了第4個紀元,UE窺得一絲隔壁平行宇宙Unity的天機。下定決心,讓Actor們輕裝上陣,只提供一些通用的基本生存能力,而把眾多的“技能”抽象成了一個個“Component”並提供組裝的接口,讓Actor隨用隨組裝,把自己武裝成一個個專業能手。
看見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下面的家族(為了闡明概念,只列出了最常見的):
ActorComponent下面最重要的一個Component就非SceneComponent莫屬了。SceneComponent提供了兩大能力:一是Transform,二是SceneComponent的互相嵌套。
思考:為何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來創建父子連接的。
voidAActor::AttachToActor(AActor*ParentActor,constFAttachmentTransformRules&AttachmentRules,FNameSocketName)
{
if(RootComponent&&ParentActor)
{
USceneComponent*ParentDefaultAttachComponent=ParentActor->GetDefaultAttachComponent();
if(ParentDefaultAttachComponent)
{
RootComponent->AttachToComponent(ParentDefaultAttachComponent,AttachmentRules,SocketName);
}
}
}
voidAActor::AttachToComponent(USceneComponent*Parent,constFAttachmentTransformRules&AttachmentRules,FNameSocketName)
{
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()
enumclassEAttachmentRule: 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實例化的時候才真正創建。
voidUChildActorComponent::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());
}
}
elseif(ChildActorClass)
{
CreateChildActor();
}
}
voidUChildActorComponent::OnComponentCreated()
{
Super::OnComponentCreated();
CreateChildActor();
}
這就導致了一個問題,當你把一個ActorClass拖進Level后,這個Actor實際是已經實例化了,你可以直接調整這個Actor的屬性。但是你把它拖到另一個Actor Class里,它只會給你空空白白的ChildActorComponent的DetailsPanel,你想調整Actor的屬性,就只能等生成了之后,用藍圖或代碼去修改。這一點來說,其實還是挺不方便的,我個人覺得應該是還有優化的空間。也或許是我還沒有體會到此設計的妙處,等以后懂了再回來填坑。
后記
花了這么多篇幅,才剛剛講到Actor和Component這兩個最基本的整體設計,而關於Actor,Component生命周期,Tick,事件傳遞等機制性的問題,還都沒有展開。UE作為從1代至今4代,久經磨練的一款成熟引擎,GamePlay框架部分其實也就不到十個類,而這些類之間怎么組織,為啥這么設計,有什么權衡和考慮,我相信這里面其實是非常有講究的。如果是UE的總架構師來講解的話,肯定能有非常多的心得體會故事。而我們作為學習者,也應該盡量去體會琢磨它的用心,一方面磨練我們自己的架構設計能力,一方面也讓我們更能掌握這個游戲的引擎。
從此篇開始,會循序漸進的探討各個部分的結構設計,最后再從整體的框架上討論該結構的優劣點。
下一篇預告:GamePlay框架(二)
UE4深入學習QQ群: 456247757
引用
個人原創,未經授權,謝絕轉載,否則將追究法律責任!