引言
如果让你来制作一款3D游戏引擎,你会怎么设计其结构?
尽管游戏的类型有很多种,市面上也有众多的3D游戏引擎,但绝大部分游戏引擎都得解决一个基本问题:抽象模拟一个3D游戏世界。根据基本的图形学知识,我们知道,为了展示这个世界,我们需要一个个带着“变换”的“游戏对象”,接着让它们父子嵌套以表现更复杂的结构。本质上,其他的物理模拟,游戏逻辑等功能组件,最终目的也只是为了操作这些“游戏对象”。
这件事,在Unity那里就直接成了“GameObject”和“Component”;在Cocos2dx那里是一个个的“CCNode”,操纵部分直接内嵌在了CCNode里面;那么在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 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随用随组装,把自己武装成一个个专业能手。
看见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来创建父子连接的。
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里查看和修改属性。
/** The class of Actor to spawn */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=ChildActorComponent, meta=(OnlyPlaceable, AllowPrivateAccess="true", ForceRebuildProperty="ChildActorTemplate"))
TSubclassOf<AActor> ChildActorClass;
/** The actor that we spawned and own */
UPROPERTY(Replicated, BlueprintReadOnly, Category=ChildActorComponent, TextExportTransient, NonPIEDuplicateTransient, meta=(AllowPrivateAccess="true"))
TObjectPtr<AActor> ChildActor;
/** Property to point to the template child actor for details panel purposes */
UPROPERTY(VisibleDefaultsOnly, DuplicateTransient, Category=ChildActorComponent, meta=(ShowInnerProperties))
TObjectPtr<AActor> ChildActorTemplate;
最新的UE代码里已经提供了ChildActorTemplate这个对象,来为ChildActorClass生成一个Actor模板,方便我们编辑属性,之后在生成ChildActor的时候,可以把ChildActorTemplate身上的属性拷贝给ChildActor,这样运作看起来就像生成了你配置的那个Actor,也比较流畅自然了。
后记
花了这么多篇幅,才刚刚讲到Actor和Component这两个最基本的整体设计,而关于Actor,Component生命周期,Tick,事件传递等机制性的问题,还都没有展开。UE作为从1代至今4代,久经磨练的一款成熟引擎,GamePlay框架部分其实也就不到十个类,而这些类之间怎么组织,为啥这么设计,有什么权衡和考虑,我相信这里面其实是非常有讲究的。如果是UE的总架构师来讲解的话,肯定能有非常多的心得体会故事。而我们作为学习者,也应该尽量去体会琢磨它的用心,一方面磨练我们自己的架构设计能力,一方面也让我们更能掌握这个游戏的引擎。
从此篇开始,会循序渐进的探讨各个部分的结构设计,最后再从整体的框架上讨论该结构的优劣点。