当前位置:   article > 正文

《Inside UE4》读书总结一_childactorcomponent

childactorcomponent

摘自知乎《Inside UE4》:https://zhuanlan.zhihu.com/insideue4

 

Actor和Component

UObject提供的元数据、反射生成、GC垃圾回收、序列化、编辑器可见,Class Default Object等功能,

而Actor也有一些功能:Replication(网络复制),Spawn(生死),Tick(有了心跳)。 

经过了UE的权衡和考虑,把Transform封装进了SceneComponent,当作RootComponent。但在权衡到使用的便利性的时候,大部分Actor其实是有Transform的,我们会经常获取设置它的坐标,如果总是得先获取一下SceneComponent,然后再调用相应接口的话,那也太繁琐了。所以UE也为了我们直接提供了一些便利性的Actor方法,如(Get/Set)ActorLocation等,其实内部都是转发到RootComponent。

  1. /*~
  2. * Returns location of the RootComponent
  3. * this is a template for no other reason than to delay compilation until USceneComponent is defined
  4. */
  5. template<class T>
  6. static FORCEINLINE FVector GetActorLocation(const T* RootComponent)
  7. {
  8. return (RootComponent != nullptr) ? RootComponent->GetComponentLocation() : FVector(0.f,0.f,0.f);
  9. }
  10. bool AActor::SetActorLocation(const FVector& NewLocation, bool bSweep, FHitResult* OutSweepHitResult, ETeleportType Teleport)
  11. {
  12. if (RootComponent)
  13. {
  14. const FVector Delta = NewLocation - GetActorLocation();
  15. return RootComponent->MoveComponent(Delta, GetActorQuat(), bSweep, OutSweepHitResult, MOVECOMP_NoFlags, Teleport);
  16. }
  17. else if (OutSweepHitResult)
  18. {
  19. *OutSweepHitResult = FHitResult();
  20. }
  21. return false;
  22. }

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++中:

  1. void AActor::AttachToActor(AActor* ParentActor, const FAttachmentTransformRules& AttachmentRules, FName SocketName)
  2. {
  3. if (RootComponent && ParentActor)
  4. {
  5. USceneComponent* ParentDefaultAttachComponent = ParentActor->GetDefaultAttachComponent();
  6. if (ParentDefaultAttachComponent)
  7. {
  8. RootComponent->AttachToComponent(ParentDefaultAttachComponent, AttachmentRules, SocketName);
  9. }
  10. }
  11. }
  12. void AActor::AttachToComponent(USceneComponent* Parent, const FAttachmentTransformRules& AttachmentRules, FName SocketName)
  13. {
  14. if (RootComponent && Parent)
  15. {
  16. RootComponent->AttachToComponent(Parent, AttachmentRules, SocketName);
  17. }
  18. }

一个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之间互相组合的胶水。

  1. void UChildActorComponent::OnRegister()
  2. {
  3. Super::OnRegister();
  4. if (ChildActor)
  5. {
  6. if (ChildActor->GetClass() != ChildActorClass)
  7. {
  8. DestroyChildActor();
  9. CreateChildActor();
  10. }
  11. else
  12. {
  13. ChildActorName = ChildActor->GetFName();
  14. USceneComponent* ChildRoot = ChildActor->GetRootComponent();
  15. if (ChildRoot && ChildRoot->GetAttachParent() != this)
  16. {
  17. // attach new actor to this component
  18. // we can't attach in CreateChildActor since it has intermediate Mobility set up
  19. // causing spam with inconsistent mobility set up
  20. // so moving Attach to happen in Register
  21. ChildRoot->AttachToComponent(this, FAttachmentTransformRules::SnapToTargetNotIncludingScale);
  22. }
  23. // Ensure the components replication is correctly initialized
  24. SetIsReplicated(ChildActor->GetIsReplicated());
  25. }
  26. }
  27. else if (ChildActorClass)
  28. {
  29. CreateChildActor();
  30. }
  31. }
  32. void UChildActorComponent::OnComponentCreated()
  33. {
  34. Super::OnComponentCreated();
  35. CreateChildActor();
  36. }

 

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因为是代表关卡蓝图,是允许携带“复制”变量函数的,所以也有可能被排序到后列。

  1. void ULevel::SortActorList()
  2. {
  3. //[...]
  4. TArray<AActor*> NewActors;
  5. TArray<AActor*> NewNetActors;
  6. NewActors.Reserve(Actors.Num());
  7. NewNetActors.Reserve(Actors.Num());
  8. // The WorldSettings tries to stay at index 0
  9. NewActors.Add(WorldSettings);
  10. // Add non-net actors to the NewActors immediately, cache off the net actors to Append after
  11. for (AActor* Actor : Actors)
  12. {
  13. if (Actor != nullptr && Actor != WorldSettings && !Actor->IsPendingKill())
  14. {
  15. if (IsNetActor(Actor))
  16. {
  17. NewNetActors.Add(Actor);
  18. }
  19. else
  20. {
  21. NewActors.Add(Actor);
  22. }
  23. }
  24. }
  25. iFirstNetRelevantActor = NewActors.Num();
  26. NewActors.Append(MoveTemp(NewNetActors));
  27. Actors = MoveTemp(NewActors); // Replace with sorted list.
  28. // Add all network actors to the owning world
  29. //[...]
  30. }

思考:既然ALevelScriptActor也继承于AActor,为何关卡蓝图不设计能添加Component? 
观察到,平常我们在创建Actor的时候,我们蓝图界面是可以创建Component的。 
那为什么在关卡蓝图里,却不能这么做(没有提供该界面功能)? 通过源码发现,其实UE自己也是在C++里往ALevelScriptActor添加UInputComponent来实现关卡蓝图可以响应事件。

  1. void ALevelScriptActor::PreInitializeComponents()
  2. {
  3. if (UInputDelegateBinding::SupportsInputDelegate(GetClass()))
  4. {
  5. // create an InputComponent object so that the level script actor can bind key events
  6. InputComponent = NewObject<UInputComponent>(this);
  7. InputComponent->RegisterComponent();
  8. UInputDelegateBinding::BindInputDelegates(GetClass(), InputComponent);
  9. }
  10. Super::PreInitializeComponents();
  11. }

其实既然ALevelScriptActor是个Actor,那意味着我们当然可以为它添加组件,实际上也确实可以这么做。比如你可以在关卡蓝图里这么干


在此,我也只能进行一番猜测,ALevelScriptActor作为一个特化的Actor,却把Components列表界面给隐藏了,说明UE其实是不希望我们去复杂化关卡构成的。假设说UE开放了关卡Component,那么我们在创建组件时就必然要考虑一个问题:哪些是ActorComponent,哪些是LevelComponent,所以用户就会多一些心智负担,可能混淆。而如果像这样不开放,大家的思路就都转向先创建个Actor,然后再往之上添加component,思路会比较统一清晰。 所以游戏引擎也并不是说最大化的暴露一切功能给你就是最好的,有时候选择太多了反而容易出错。在这一点上,我觉得UE很好的保持了克制,为我们提供了一个优秀的清晰的不易出错的框架,同时也对高阶用户保留了灵活性。

 

 

World

终于,到了把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的配置应该以谁的为主?

  1. AWorldSettings* UWorld::GetWorldSettings( bool bCheckStreamingPesistent, bool bChecked ) const
  2. {
  3. checkSlow(IsInGameThread());
  4. AWorldSettings* WorldSettings = nullptr;
  5. if (PersistentLevel)
  6. {
  7. WorldSettings = PersistentLevel->GetWorldSettings(bChecked);
  8. if( bCheckStreamingPesistent )
  9. {
  10. if( StreamingLevels.Num() > 0 &&
  11. StreamingLevels[0] &&
  12. StreamingLevels[0]->IsA<ULevelStreamingPersistent>())
  13. {
  14. ULevel* Level = StreamingLevels[0]->GetLoadedLevel();
  15. if (Level != nullptr)
  16. {
  17. WorldSettings = Level->GetWorldSettings();
  18. }
  19. }
  20. }
  21. }
  22. return WorldSettings;
  23. }

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加载释放的损耗尽量减小),才不会产生比较大的帧率波动,让玩家感觉到卡帧。

 

Pawn

UE的游戏世界由Object->Actor+Component->Level->World->WorldContext->GameInstance->Engine层层构建。

从Actor中再派生出了APawn,并定义了3块基本的模板方法接口: 
1. 可被Controller控制 
2. PhysicsCollision表示 
3. MovementInput的基本响应接口

DefaultPawn,SpectatorPawn,Character

DefaultPawn

UE知道我们大家都很懒,所以提供了一个默认的Pawn:DefaultPawn,默认带了一个DefaultPawnMovementComponent、spherical CollisionComponent和StaticMeshComponent。也是上述Pawn阐述过的三件套,只不过都是默认套餐。

SpectatorPawn

观战的玩家们只要给他们一些摄像机“漫游”的能力。派生于DefaultPawn的SpectatorPawn提供了USpectatorPawnMovement(不带重力漫游),并关闭了StaticMesh的显示,碰撞也设置到了“Spectator”通道。

Character

UE就为我们直接提供了一个人形的Pawn来让我们操纵。 

像人一样行走的CharacterMovementComponent, 尽量贴合的CapsuleComponent,再加上骨骼上蒙皮的网格。同样的三件套,不一样的配方。 

有人困惑应该选择Pawn还是Character,Character只不过是Pawn的加强版本。如果角色是人形的带骨骼,选择Character。如果是VR中的一双手(假设只有一双手),因为移动模式和显示都算不太上人形,用Pawn方便。后期如果你想加上人形模型和IK了,那么再把Mesh替换成SkeletalMesh也就行了。Pawn因为是基础款,所以提供了最大的灵活性。

 

AController

一个Pawn自身上也可以配置策略:

  1. namespace EAutoReceiveInput
  2. {
  3. enum Type
  4. {
  5. Disabled,
  6. Player0,
  7. Player1,
  8. Player2,
  9. Player3,
  10. Player4,
  11. Player5,
  12. Player6,
  13. Player7,
  14. };
  15. }
  16. TEnumAsByte<EAutoReceiveInput::Type> AutoPossessPlayer;
  17. enum class EAutoPossessAI : uint8
  18. {
  19. /** Feature is disabled (do not automatically possess AI). */
  20. Disabled,
  21. /** Only possess by an AI Controller if Pawn is placed in the world. */
  22. PlacedInWorld,
  23. /** Only possess by an AI Controller if Pawn is spawned after the world has loaded. */
  24. Spawned,
  25. /** Pawn is automatically possessed by an AI Controller whenever it is created. */
  26. PlacedInWorldOrSpawned, //这里注意,做AI的时候忘了选成这个选项
  27. };
  28. EAutoPossessAI AutoPossessAI;
  29. 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中是更好的选择

 

APlayerState

Controller如果可以动态存取玩家的状态就会大为方便了。因此我们会在Controller中见到:

  1. /** PlayerState containing replicated information about the player using this controller (only exists for players, not NPCs). */
  2. UPROPERTY(replicatedUsing=OnRep_PlayerState, BlueprintReadOnly, Category="Controller")
  3. 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保存起来。

 

 

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/从前慢现在也慢/article/detail/692822
推荐阅读