赞
踩
摘自知乎《Inside UE4》:https://zhuanlan.zhihu.com/insideue4
UObject提供的元数据、反射生成、GC垃圾回收、序列化、编辑器可见,Class Default Object等功能,
而Actor也有一些功能:Replication(网络复制),Spawn(生死),Tick(有了心跳)。
经过了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和Component的关系:
Actor继承UObject,对于这个Actor想要有哪些属性,通过挂载Component来完成,TSet<UActorComponent*> OwnedComponents 保存着这个Actor所拥有的所有Component,位置属性是通过挂载SceneComponent,一般来说Actor中会有一个SceneComponent作为RootComponent。
TArray<UActorComponent*> InstanceComponents 保存着实例化的Components。实例化是个什么意思呢,就是你在蓝图里Details定义的Component,当这个Actor被实例化的时候,这些附属的Component也会被实例化。
而Component继承关系如下:
ActorComponent下面SceneComponent提供了两大能力:一是Transform,二是SceneComponent的互相嵌套。
思考:为何ActorComponent不能互相嵌套?而在SceneComponent一级才提供嵌套?
老实说,如果让我来设计Entity-Component模式,我很可能会为了通用性而在ActorComponent这一级直接提供嵌套,这样所有的Component就与生俱来拥有了组合其他Component的能力,灵活性大大提高。但游戏引擎的设计必然也经过了各种权衡,虽然说架构上显得并不那么的统一干净,但其实也大大减少了被误用的机会。实体组件模式推崇的“组合优于继承”的概念确实很强大,但其实同时也带来了一些问题,如Component之间如何互相依赖,如何互相通信,嵌套过深导致的接口便利损失和性能损耗,真正一个让你随便嵌套的组件模式可能会在使用上更容易出问题。
从功能上来说,UE更倾向于编写功能单一的Component(如UMovementComponent),而不是一个整合了其他Component的大管家Component(当然如果你偏要这么干,那UE也阻止不了你)。
而从游戏逻辑的实现来说,UE也是不推荐把游戏逻辑写在Component里面,所以你其实也没什么机会去写一个很复杂的Component.
在UE里,Actor之间的父子关系却是通过Component确定的。UE里是通过Child:AttachToActor或Child:AttachToComponent来创建父子连接的。
蓝图中:(摘自https://www.cnblogs.com/hoowall/p/6367806.html)
Keep Relative:
将actor在当前场景中的transform移到parent(Socket)中。(当actor在world中处于原始transform时,它最终效果就是在Socket的预览中的效果。
Keep World:
保持actor在当前场景中的transform,对socket设置transform没有意义。但如果socket移动旋转缩放,会跟随相应移动旋转缩放。
Snap to Target:(最常使用)
使用Socket设置的transform,最终效果与actor在当前场景中的transform无关,最终效果等于socket中预览看到的效果。
DetachFromActor要注意,只有Keep Relative和Keep World选项。
如果使用 Snap to Target 进行attach,那么Keep World让Detach之后对保持之前在Socket调整后的最终尺寸到新的场景中(建议使用该方法);如果使用Keep Relative,则会让尺寸使用物体原始尺寸。
c++中:
- 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);
- }
- }
一个Actor可是可以带有多个SceneComponent的,这意味着一个Actor是可以带有多个Transform“锚点”的。创建父子时,你到底是要把当前Actor当作对方哪个SceneComponent的子?再进一步,如果你想更细控制到Attach到某个Mesh的某个Socket(关于Socket Slot,目前可以简单理解为一个虚拟插槽,提供变换锚点),你就更需要去寻找到特定的变换锚点,然后Attach的过程分别在Location,Roator,Scale上应用Rule来计算最后的位置。
所以Actor父子之间的“关系”其实隐含了许多数据,而这些数据都是在Component上提供的。Actor其实更像是一个容器,只提供了基本的创建销毁,网络复制,事件触发等一些逻辑性的功能,而把父子的关系维护都交给了具体的Component,所以更准确的说,其实是不同Actor的SceneComponent之间有父子关系,而Actor本身其实并不太关心。
聊一聊ChildActorComponent
同作为最常用到的Component之一,ChildActorComponent担负着Actor之间互相组合的胶水。
- 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();
- }
Level和World
Unity觉得世界是由Scene组成的,一个Application来扮演上帝来LoadLevel,后来换成了SceneManager。其他的,有的会称为关卡(Level)或地图(map)等等。而UE中把这种拆分叫做关卡(Level),由一个或多个Level组成一个World。
Level默认带了一个ALevelScriptActor,允许我们在关卡里编写脚本,UE给每一个Level也都默认配了一个书记官(Info),记录着本Level的各种规则属性,而Level中的World Settings继承于AInfo,当Level被加入到World中后,这个Level的Settings如果是主PersistentLevel,那它就会被当作整个World的WorldSettings。
注意,Actors里也保存着AWorldSettings和ALevelScriptActor的指针,所以Actors实际上确实是保存了所有Actor。
思考:为何AWorldSettings要放进在Actors[0]的位置?而ALevelScriptActor却不用?
从下面代码可以看出在ULevel中有两个TArray,一个保存所有网络可复制Actor,一个保存非网络Actor,非网络Actor中第一个元素是WorldSetting,AWorldSettings因为都是静态的数据提供者,在游戏运行过程中也不会改变,不需要网络复制,所以也就可以一直放在前列,然后加一个起始索引标记iFirstNetRelevantActor,相当于为网络Actor划分了一个缓存,从而加速了网络复制时的检测速度。ALevelScriptActor因为是代表关卡蓝图,是允许携带“复制”变量函数的,所以也有可能被排序到后列。
- void ULevel::SortActorList()
- {
- //[...]
- TArray<AActor*> NewActors;
- TArray<AActor*> NewNetActors;
- NewActors.Reserve(Actors.Num());
- NewNetActors.Reserve(Actors.Num());
- // The WorldSettings tries to stay at index 0
- NewActors.Add(WorldSettings);
- // Add non-net actors to the NewActors immediately, cache off the net actors to Append after
- for (AActor* Actor : Actors)
- {
- if (Actor != nullptr && Actor != WorldSettings && !Actor->IsPendingKill())
- {
- if (IsNetActor(Actor))
- {
- NewNetActors.Add(Actor);
- }
- else
- {
- NewActors.Add(Actor);
- }
- }
- }
- iFirstNetRelevantActor = NewActors.Num();
- NewActors.Append(MoveTemp(NewNetActors));
- Actors = MoveTemp(NewActors); // Replace with sorted list.
- // Add all network actors to the owning world
- //[...]
- }
思考:既然ALevelScriptActor也继承于AActor,为何关卡蓝图不设计能添加Component?
观察到,平常我们在创建Actor的时候,我们蓝图界面是可以创建Component的。
那为什么在关卡蓝图里,却不能这么做(没有提供该界面功能)? 通过源码发现,其实UE自己也是在C++里往ALevelScriptActor添加UInputComponent来实现关卡蓝图可以响应事件。
- void ALevelScriptActor::PreInitializeComponents()
- {
- if (UInputDelegateBinding::SupportsInputDelegate(GetClass()))
- {
- // create an InputComponent object so that the level script actor can bind key events
- InputComponent = NewObject<UInputComponent>(this);
- InputComponent->RegisterComponent();
- UInputDelegateBinding::BindInputDelegates(GetClass(), InputComponent);
- }
- Super::PreInitializeComponents();
- }
其实既然ALevelScriptActor是个Actor,那意味着我们当然可以为它添加组件,实际上也确实可以这么做。比如你可以在关卡蓝图里这么干
在此,我也只能进行一番猜测,ALevelScriptActor作为一个特化的Actor,却把Components列表界面给隐藏了,说明UE其实是不希望我们去复杂化关卡构成的。假设说UE开放了关卡Component,那么我们在创建组件时就必然要考虑一个问题:哪些是ActorComponent,哪些是LevelComponent,所以用户就会多一些心智负担,可能混淆。而如果像这样不开放,大家的思路就都转向先创建个Actor,然后再往之上添加component,思路会比较统一清晰。 所以游戏引擎也并不是说最大化的暴露一切功能给你就是最好的,有时候选择太多了反而容易出错。在这一点上,我觉得UE很好的保持了克制,为我们提供了一个优秀的清晰的不易出错的框架,同时也对高阶用户保留了灵活性。
终于,到了把Level拼装起来的时候了。可以用SubLevel的方式:
也支持WorldComposition的方式自动把项目里的所有Level都组合起来,并设置摆放位置:
一个World里有多个Level,这些Level在什么位置,是在一开始就加载进来,还是Streaming运行时加载。
UE里每个World支持一个PersistentLevel和多个其他Level:
Persistent的意思是一开始就加载进World,Streaming是后续动态加载的意思。Levels里保存有所有的当前已经加载的Level,StreamingLevels保存整个World的Levels配置列表。PersistentLevel和CurrentLevel只是个快速引用。在编辑器里编辑的时候,CurrentLevel可以指向其他Level,但运行时CurrentLevel只能是指向PersistentLevel。
思考:为何要有主PersistentLevel?
World至少得有一个Level,这块玩家出生的大陆就是主Level了。也可以同时配置别的Level一开始就加载进来,其实跟PersistentLevel是差不多等价的,另一问题:Levels拼接进World一起之后,各自有各自的worldsetting,那整个World的配置应该以谁的为主?
- AWorldSettings* UWorld::GetWorldSettings( bool bCheckStreamingPesistent, bool bChecked ) const
- {
- checkSlow(IsInGameThread());
- AWorldSettings* WorldSettings = nullptr;
- if (PersistentLevel)
- {
- WorldSettings = PersistentLevel->GetWorldSettings(bChecked);
- if( bCheckStreamingPesistent )
- {
- if( StreamingLevels.Num() > 0 &&
- StreamingLevels[0] &&
- StreamingLevels[0]->IsA<ULevelStreamingPersistent>())
- {
- ULevel* Level = StreamingLevels[0]->GetLoadedLevel();
- if (Level != nullptr)
- {
- WorldSettings = Level->GetWorldSettings();
- }
- }
- }
- }
- return WorldSettings;
- }
World的Settings也是以PersistentLevel为主的,但这也并不意味其他Level的Settings就完全没有作用了
思考:Levels们的Actors和World有直接关系吗?
当别的Level被添加进当前World之后,我们能直接在WorldOutliner里看到其他Level的Actor们。
但这并不代表着World直接引用了Level里的Actor们。TActorIteratorBase(World的Actor迭代器)内部的实现也只是在遍历Levels来获得所有Actor。当然World为了更快速的操作Controllers和Pawn也都保存了引用。但Levels却共享着World的一个PhysicsScene,这也意味着Levels里的Actors的物理实体其实都是在World里的,这也好理解,毕竟物理的碰撞之类的当然要是全局的了。再说到导航,World在拼接Level的时候,也是会同时把两个Level的导航网格给“拼接”起来的。当然目前还不是深入细节的时候,现在只要从大局上明白World-Level-Actor的关系。
思考:为什么要在Level里保存Actors,而不是把所有Map的Actors配置都生成在World一个总Actors里?
这肯定也是一种实现方式,好处是把整个World看成一个整体,所有的actors都从属于world,这样就不存在Level边界,可以更整体的处理Actors的作用范围和判定问题,实现上也少了拼接导航等步骤。当然坏处也是模糊了Level边界,这样在加载进一个Level之后,之后再动态释放,就需要再重新再从整体中抽离出部分来释放,这个筛选过程也会产生比较大的损耗。试着去理解UE的权衡,应该是尽量的把损耗平摊(这里是把Level加载释放的损耗尽量减小),才不会产生比较大的帧率波动,让玩家感觉到卡帧。
UE的游戏世界由Object->Actor+Component->Level->World->WorldContext->GameInstance->Engine层层构建。
从Actor中再派生出了APawn,并定义了3块基本的模板方法接口:
1. 可被Controller控制
2. PhysicsCollision表示
3. MovementInput的基本响应接口
UE知道我们大家都很懒,所以提供了一个默认的Pawn:DefaultPawn,默认带了一个DefaultPawnMovementComponent、spherical CollisionComponent和StaticMeshComponent。也是上述Pawn阐述过的三件套,只不过都是默认套餐。
观战的玩家们只要给他们一些摄像机“漫游”的能力。派生于DefaultPawn的SpectatorPawn提供了USpectatorPawnMovement(不带重力漫游),并关闭了StaticMesh的显示,碰撞也设置到了“Spectator”通道。
UE就为我们直接提供了一个人形的Pawn来让我们操纵。
像人一样行走的CharacterMovementComponent, 尽量贴合的CapsuleComponent,再加上骨骼上蒙皮的网格。同样的三件套,不一样的配方。
有人困惑应该选择Pawn还是Character,Character只不过是Pawn的加强版本。如果角色是人形的带骨骼,选择Character。如果是VR中的一双手(假设只有一双手),因为移动模式和显示都算不太上人形,用Pawn方便。后期如果你想加上人形模型和IK了,那么再把Mesh替换成SkeletalMesh也就行了。Pawn因为是基础款,所以提供了最大的灵活性。
一个Pawn自身上也可以配置策略:
- namespace EAutoReceiveInput
- {
- enum Type
- {
- Disabled,
- Player0,
- Player1,
- Player2,
- Player3,
- Player4,
- Player5,
- Player6,
- Player7,
- };
- }
- TEnumAsByte<EAutoReceiveInput::Type> AutoPossessPlayer;
- enum class EAutoPossessAI : uint8
- {
- /** Feature is disabled (do not automatically possess AI). */
- Disabled,
- /** Only possess by an AI Controller if Pawn is placed in the world. */
- PlacedInWorld,
- /** Only possess by an AI Controller if Pawn is spawned after the world has loaded. */
- Spawned,
- /** Pawn is automatically possessed by an AI Controller whenever it is created. */
- PlacedInWorldOrSpawned, //这里注意,做AI的时候忘了选成这个选项
- };
- EAutoPossessAI AutoPossessAI;
- TSubclassOf<AController> AIControllerClass;
Controller里只是保存了一个Pawn指针,而不是数组,对于RTS这种需要一下子控制多个单位的游戏来说,这种1v1的关系确实比较僵硬,就需要在Controller里自己实现扩展一下,额外保存多个Pawn,然后自己实现一些需要的控制实现,当前1:1的时候,我们的脑袋逻辑很清晰,我们可以在Controller里直接GetPawn,也可以在Pawn中GetController
一些Pawn本身固有的能力逻辑,如前进后退、播放动画、碰撞检测之类的就完全可以在Pawn内实现;一些可替换的逻辑,或者智能决策的,就应该归Controller管辖,如果一个逻辑只属于某一类Pawn,那么其实你放进Pawn内也挺好。而如果一个逻辑可以应用于多个Pawn,那么放进Controller就可以组合应用了.从存在性来说,Controller的生命期比Pawn要长一些,Pawn死亡后,这个Pawn就被Destroy了,就算之后再Respawn创建出来一个新的,但是Pawn身上保存的变量状态都已经被重置了。所以对于那些需要在Pawn之外还要持续存在的逻辑和状态,放进Controller中是更好的选择。
Controller如果可以动态存取玩家的状态就会大为方便了。因此我们会在Controller中见到:
- /** PlayerState containing replicated information about the player using this controller (only exists for players, not NPCs). */
- UPROPERTY(replicatedUsing=OnRep_PlayerState, BlueprintReadOnly, Category="Controller")
- class APlayerState* PlayerState;
而APlayerState的继承体系是:
APlayerState是从AActor派生的AInfo继承下来。无非贪图AActor本身的那些特性以网络复制等。而AInfo们正是这种不爱表现的纯数据大本营。PlayerState通过在GameMode中配置的PlayerStateClass来自动生成。
APlayerState也生成在Level中的,跟Pawn和Controller是平级的关系,Controller保存了一个指针引用。当前游戏有多少个真正的玩家,才会有多少个PlayerState,AI控制的NPC不是真正玩家,不需要创建PlayerState。但是UE把PlayerState的引用变量放在了Controller而不是PlayerController中,说明了其实AIController也是可以设置读取该变量的。一个AI智能能够读取玩家的比分等状态,有了更多的信息来作决策,想来也没有什么不对嘛。
把PlayerState独立构成一个Actor还有一个好处,当玩家偶尔因网络波动断线,因为连接不在所以该Controller也失效了,服务器可以把该PlayerState暂存起来,等玩家重连上了用该PlayerState重新挂接上Controller,以此提供一个比较顺畅无缝的体验。至于AIController,因为都是运行在Server上的,Client上并没有,所以也就无所谓了。
思考:哪些数据应该放在PlayerState中?
PlayerState表示的是玩家的游玩数据,所以那些关卡内的其他游戏数据就不应该放进来(GameState是个好选择),另外Controller本身运行需要的临时数据也不应该归PlayerState管理。而玩家在切换关卡的时候,APlayerState也会被释放掉,所有PlayerState实际上表达的是当前关卡的玩家得分等数据。这样,那些跨关卡的统计数据等就也不应该放进PlayerState里了,应该放在外面的GameInstance,然后用SaveGame保存起来。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。