《Inside UE4》读书总结一

摘自知乎《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。

/*~
 * 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很好的保持了克制,为我们提供了一个优秀的清晰的不易出错的框架,同时也对高阶用户保留了灵活性。

 

 

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的配置应该以谁的为主?

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

 

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自身上也可以配置策略:

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中是更好的选择

 

APlayerState

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保存起来。

 

 

  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值