《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下面最重要的一个Component就非SceneComponent莫属了。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的3D游戏世界是由Object->Actor+Component->Level->World->WorldContext->GameInstance->Engine来逐渐层层构建而成的。


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

作为GamePlay中至关重要的一个逻辑概念,让我再罗嗦强调一遍应该不为过吧。Pawn实现的是“可被控制”的概念。因为“被控制了”之后经常要被移动(UE对FPS是真爱啊),所以Pawn就索性把移动的接口也定义了一下(当然,为了灵活性,内部转交给MovementComponent再处理),既然能移动了,但也不能随便在地图里乱走吧,所以碰撞(物理表示)看来也是需要的啊,好吧,那就加上,齐活了。

DefaultPawn,SpectatorPawn,Character

DefaultPawn

因为我们每次想自己搞Pawn都得从Pawn派生过来,然后再一个个添加组件。UE知道我们大家都很懒,所以提供了一个默认的Pawn:DefaultPawn,默认带了一个DefaultPawnMovementComponent、spherical CollisionComponent和StaticMeshComponent。也是上述Pawn阐述过的三件套,只不过都是默认套餐。

SpectatorPawn

UE的FPS做的太好了,就会有一些观众想要观战。观战的玩家们虽然也在当前地图里,但是我们并不需要真正的去表示它们,只要给他们一些摄像机“漫游”的能力。所以派生于DefaultPawn的SpectatorPawn提供了一个基本的USpectatorPawnMovement(不带重力漫游),并关闭了StaticMesh的显示,碰撞也设置到了“Spectator”通道。

Character

因为我们是人,所以在游戏中,代入的角色大部分也都是人。大部分游戏中都会有用到人形的角色,既然如此,UE就为我们直接提供了一个人形的Pawn来让我们操纵。 

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

有些人一开始的时候会困惑应该选择Pawn还是Character,其实从继承体系中就可以了解到Character只不过是Pawn的加强特化版本。一般来说,如果你控制的角色是人形的带骨骼的,那就选择Character吧。而如果是VR中的一双手(假设只有一双手),因为移动模式和显示都算不太上人形,顶多只能算是个漂浮的“幽灵”,所以还是用Pawn方便些。后期如果你想加上人形模型和IK了,那么再把Mesh替换成SkeletalMesh也就行了。Pawn因为是基础款,所以提供了最大的灵活性。

AController

因为跟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,
};
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希望也能有一些记忆,保存住一些游戏状态。那么到底应该怎么保存呢?AController自身当然可以添加成员变量来保存,这些变量也可以网络复制,一般来说也够用。但是终究还是遗忘了一个最重要的数据状态。整个游戏世界构建起来就是为了玩家服务的,而玩家在游戏过程中,肯定要存取产生一些状态。而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只为players存在,不为NPC生成,指的是PlayerState是跟UPlayer对应的,换句话说当前游戏有多少个真正的玩家,才会有多少个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保存起来。



猜你喜欢

转载自blog.csdn.net/zhangxiaofan666/article/details/81028094