【UE4】UE4GamePlay框架
这篇博文主要记录一些自己在学习GamePlay的过程中一些心得记录,最开始使用的是UE5源码学习,后来不知道不小心改了啥,UE5源码崩了,就换回了UE4.26所以源码部分可能会有一部分来自UE5有一部分来自UE4,会有点出入。
参考博客:UE4官方文档、大钊、南京周润发、带带大师兄、yblackd、董国政、 Ken_An、张悟基、paprika
一、整体框架
首先来看一下整体框架:
红色部分为主体,从右往左为组合关系,至上而下为派生关系。
在整个UE宇宙的构成中,UEngine就类似化学元素,UObject就类似物质,物质通过演化便衍生出了物体—AActor和UActorComponent,AActor继续演化就出现了生物APawn,人—ACharacter,于是世界便有了信息—AInfo,规则—AGameMode,大量的物体、生物组合在一起便形成了大陆—ULevel,不同的大陆组合在一起便形成了世界—UWorld,世界有着自己的信息—FWorldContext和客观规律—UGameInstance。
而在UE这个宇宙有很多个Word,如编辑时的World,编辑时运行的World,运行时的World等等,查看源码就可知道UE宇宙有五大世界。
1 |
|
首先我们先了解一下这些类的具体作用,然后再细致的了解各个类。
1.UEngine
UEngine类是UE的基础,UEngine提供一些最底层的交互—与操作系统的交互,而根据不同的运行模式UE与操作系统的交互模式又有少许不同,所以UEngine又派生出了UGameEngine和UEditerEngine来负责不同运行模式下的交互模式。
其中有一个很重要的全局指针GEngine,通过GEngine可以访问各种UE的全局资源,同时GEngine还提供多线程访问能力。
关于UEngine的资料实在是太少了,官方文档中对UEngine的描述也就一句话,对UEngine的理解也就止步于此了。
2.UObject
UObject是构成UE世界最基础的物质,所以UObject提供供UE世界运行的最基本的功能:
- Garbage collection:垃圾收集
- Reference updating:引用自动更新
- Reflection:反射
- Serialization:序列化
- Automatic updating of default property changes:自动检测默认变量的更改
- Automatic property initialization:自动变量初始化
- Automatic editor integration:和虚幻引擎编辑器的自动交互
- Type information available at runtime:运行时类型识别
- Network replication:网络复制
在之后再深入浅出的讲解各个功能。
3.AActor
AActor是派生自UObject的一个及其重要的类,AActor在UObject的基础上再进一步提供了:
- Replication:网络复制
- Spawn:动态创建
- Tick:每帧运行
Replicatoin使AActor有了分裂复制的生育能力,Spawn使AActor在UE世界中出生,在UE4世界中死去,Tick使AActor有了心跳,AActor便组成了丰富多彩的UE世界。
AActor拥有一个庞大的子孙族群,ALevelScriptActor、ANavigationObjectBase、APawn、AController、AInfo这些都是AActor的直系后代,而这些后代也都各自拥有自己的庞大分支族群,构成了UE世界中最强大的种族AActor。
ALevelScriptActor
ALevelScriptActor在官方文档中的表述就是ULevelScriptBlueprint生成的类的基类,通过名称我们就很容易联想到关卡蓝图,没错ULevelScriptBlueprint就是我们最常用的关卡蓝图,ULevelScriptBlueprint继承自UObject,所以ULevelScriptBlueprint的子类是一个多继承的虚继承类,而ALevelScriptActor就为其提供AActor的能力。
在官方文档中有提及默认关卡蓝图是可以通过DefualtGame.ini配置文件替换成自定义关卡蓝图的,具体使用方法在后面在探讨。
ANavigationObjectBase
ANavigationObjectBase的资料实在是少的可怜,就连官方文档也是没有一个字的描述,源码也是相当简单,总共就70行,由ANavigationObjectBase是APlayerState的基类,和它继承的接口INavAgentInterface可以猜测ANavigationObjectBase应该和网络复制有关,具体细节留到以后更熟悉UE4了再深入探讨吧。
APlayerStart
APlayerStart的作用就是记录APawn在游戏开始时生成的Position与Rotation信息,UE设计APlayerStart的初忠就是想让游戏的关卡设十师和场景设计师的工作分离开来,也就解耦合。那么,如果Level中不存在APlayerStart ,APawn 会出生在哪是呢?答案是世界原点(0,0,0)
APawn
APawn在AActor的基础上再度添加了:
- 被Controller控制
- PhysicsCollision:物理碰撞
- MovementInput:移动响应接口
等能力,有了MovementInput接口APawn就拥有了可运动的能力,这里UE的逻辑划分十分精妙,UE将一个可运动的物体巧妙地划分成了APwan和AController,APawn重点表现在物体,而这个物体具备运动能力,但是自身不具备运动技巧;而AController这是控制APawn运动地大脑,用来控制APawn如何运动,如果把APawn比作是提线木偶,那么AController就是控制木偶运动地线。
到了APawn这一代,AActor的衍化之旅开始衍化出现于玩家间交互的能力,而这之中的佼佼者便是ACharacter。
ACharacter
ACharacter是APwan的特化加强版,在UE世界中可以称之为“人”,ACharacter是一个专门为人形角色定制的APawn,自带CharacterMovement组件,可以使人形角色像人一样行走。
ADefaultPawn
最初始的APawn使最基本的APawn类,只提供APawn的一些基本能力,而没有提供支持这些能力的组件,而在具体实际使用情况中我们使用的APawn应该还需要组合一些其他的能力,以适应不同的场景,如:我们知道APawn可以运动,但在实际场景中我们是要确定这个APawn是因该直立行走还是爬行,是用轮子行驶还是用翅膀飞行,APawn在玩家眼里应该长什么样子,是人还是蛇,是因该左球形碰撞还是应该做方形碰撞,这些都是APawn不具备的能力,这时ADefaultPawn便出现了,ADefaultPawn自带DefualtPawnMovement、CollisionComponent、StaticMeshCompnent三件套,为ADefaultPawn提供了默认的场景表现。
ASpectatorPawn
在游戏中存在一种特殊的玩家—观战玩家,这类玩家不需要具体表现形式,只需要一些相机的漫游能力,于是ASpectatorPawn出现了,ASpectatorPawn继承自ADefaultPawn,ASpectatorPawn提供了一个基本的USpectatorPawnMovement(不带重力漫游),并关闭了StaticMesh的显示,碰撞也设置到了“Spectator”通道。
AController
AController就是控制APawn运动的大脑了,ACtroller负责处理一些直接与玩家交互的控制逻辑,AController是从AActor派生的与APawn同级的子类,在UE的设计中,在同一时刻一个AController和一个APawn之间是1:1的关系,AController可以在多个APawn之间通过Possess/UnPossess切换。AController有两种控制APawn的方式,一种是AController直接附在APawn的身上控制APawn的移动,如驾驶汽车,一种是以上帝的视角控制APawn的移动,如控制第三人称的角色。
APlayerController
APlayerController是由AController派生出来专门用于负责玩家交互逻辑的AController,APlayerController提供了:
- Camera管理
- Input输入响应
- UPlayer关联
- HUD显示
- Level切换
- Voice音源监听
这些能力。
AAIController
在一个游戏中有玩家控制的角色也可以有NPC,那么NPC的行动逻辑有谁来控制呢?答案就是AAIController,AAIController与APlayerController完全不同,因为一个NPC不要管理Camera,不需要响应玩家的输入,不需要关联UPlayer,不需要显示HUD,不需要监听音源,只有Level切换可能会在少数情况下需要,那么AAIController因该做什么呢?UE为它设计的是这些事:
- Navigation:自动寻路
- AI Component:用于启动运行行为树,使用黑板数据
- Task系统:让AI去完成一些任务
当然一个游戏中是至少需要一个APlayerController的,但是可以没有AAIController。
AInfo
AInfo是一些数据保存类的基类,AInfo不需要运动和碰撞,也不需要物理表现,仅仅只是保存数据,所以UE在AInfo中将这些功能都隐藏了,之所以不直接继承自UObject,而继承自AActor是因为游戏数据是需要具备网络复制的能力的,而UObject不具备这个能力
AWordSettings
AWordSetting继承自AInfo用来配置和保存一些Level配置,主要用于配置Level的GameMode信息,光照信息,导航系统,声音系统,LOD系统,物理加速度等关卡信息。由此可以知道一个Level对应一个AWordSetting,但是一个AWordSetting可以应用在多个Level上。
AGameMode
AGameMode就是用于配置AWorldSetting中的GameMode属性的。
在UE的设计中AGameMode就是游戏世界的逻辑,及整个游戏的玩法规则,而在实际情况中一个游戏既可以只有一个玩法也可以有多种玩法规则,所以AWordSetting与AGameMode的对应关系也是一个AWorldSetting只能对应一个AGameMode,而一个AGameMode可以对应多个AWorldSetting。那么AGameMode应该负责哪些逻辑呢?UE是这么规定的:
- Class登记:记录GameMode中各种类的信息
- Spawn:创建Pawn和PlayerController等
- 游戏进度:游戏暂停重启的逻辑
- 过场动画逻辑
- 多人游戏的步调同步
AGameState
AGameState用于保存游戏数据,如任务进度,游戏活动等。
APlayerState
APlayerState是一个用于存储玩家状态的类,在一个游戏客户端,尤其是网络游戏客户端中是可以存在多个APlayerState对象的,不同的APlayerState保存不同玩家的状态,同时APlayerState也可以存在于服务器中。APlayerState的生命周期为一整个Level的生命周期。
到这是AActor家族下的几个重要成员的基本功能我们便有了一个大概的了解了,这里我们来捋一下这些成员之间的关系和在UE世界中的地位。
4.UActorComponent
UActorComponent是UE向U3D看齐的一个产物,虽然UE世界有了Actor就有了形形色色的物体生物,但是不同的生物拥有不同的技能,而同一个Actor可以会某个技能也可以不会,这种概念使用组合的方式组合到Actor下是最理想的,于是Component便出现了,UActorComponent直接继承自UObject,与AActor同级,Component既可以嵌套在Actor下,也可以嵌套在其他的Component下,但是需要注意的是,UActorComponent这一级是不提供互相嵌套的能力的,只有到其子类USceneComponent一级才提供互相嵌套能力。
USceneComponent
USceneComponent主要提供两大能力,一是Transform,二是SceneComponent的互相嵌套。一般我们直接在Level里创建的Actor都会默认带有一个SceneComponent组件。
UPrimitiveComponent
UPrimitiveComponent主要提供Actor用于物体渲染和碰撞相关的基础能力。
UMeshComponent
UMeshComponent由UPrimitiveComponent派生而来,主要提供具体的渲染显示方面的能力。
UChildActorComponent
从名字就可以窥探其功能一二了,UChildComponent在Actor中主要用于链接Actor与Component,提供Component和Actor的嵌套能力。
5.ULevel
ULevel可以看作是UE世界的大陆,是AActor的容器,前面提到的ALevelScriptActor便是ULevel默认带有的关卡蓝图,在这个关卡蓝图中编写便是这块大陆的逻辑,同时ULevel也默认带有一个AWorldSetting。
6.UWorld
在UE中所有的ULevel互相联系就构成了一个UWorld,ULevel构建UWorld的方式有两种,一种是以SubLevel的形式,像关卡流一样,一个关卡链接下一个关卡,来组成UWorld,一种是每一个ULevel就是这个大地图的UWorld中的一块地图,ULevel之间以相对位置衔接在一起,构成一个大地图来组成这个UWorld。无论是那种构成形式,在一个UWorld中都有一个PersistentLevel,PersistenetLevel就是主Level,是玩家最初始的出生地,这里用的是最初始而不是游戏开始,是因为,现在很多在游戏开始时玩家的出点可能不是PersistentLevel而是上一次玩家离线时的位置。
7.FWorldContext
FWorldContext不对开发者公开,是UE内部用来处理引擎UWorld上下文的类,比如当我们从编辑状态的EditorWorld点击播放切换到PIEWorld即运行状态时,这个过程中EditorWorld到PIEWorld之间的信息交换就是通过FWorldContext实现的。可以说FWorldContext处理的是UWorld级的通信。
8.UGameInstance
UGameInstance可以说是凌驾于所有AActor、UActorComponent、ULevel、UWorld之上的类,通常情况下一个Game中应该只有一个,这里的Game是UEngine中提到的所有World的总和,当然这不是绝对的,对于更高层次的开发者,UE也是提供了多个UGameInstance协同的扩展的。UGameInstance的生命周期就是从游戏进程启动到游戏进程结束。
所以UGameInstance主要处理:
- UWorld、ULevel之间的切换
- UPlayer的创建,这里的UPlayer又和前面的APlayerController有所不同,这一点在后面再介绍。
- 全局配置
- GameMode的切换
9.UNetDriver
从名字就可以略知一二,UNetDriver是UE处理网络同步相关的类,UNetDriver中有两个主要的成员:
1 |
|
ServerConnection是客户端到服务器的连接,ClientConnections数组是服务器到客户端群的连接的数组。而在UNetConnnection中又有一个很重要的成员:
1 |
|
ActorChannels是在服务器与客户端完成连接后用于实现Actor同步的对象。
10.UPlayer
UPlayer即玩家,ULevel可以切换,UWorld可以交替,但是尽管ULevel、UWorld如何变换,玩家还是那个玩家,所以UPlayer是和UGameInstance同一级别的存在,在整个GamePlay架构中UPlayer主要以GameModeBase中的一个属性出现。
在一个单机游戏中UPlayer是唯一的存在,但是在一个网络联级游戏中,表示同一实体的UPlayer即存在于玩家本地的客户端中,同时也存在于其他玩家的多个客户端中,那么玩家的输入就既要作用于本地的APawn上,同时在其他玩家的客户端中的表示这个实体的APawn也要做出响应的反应,于是UE便将UPlayer又派生出了两个子类,ULocalPlayer和UNetConnection。其中ULocalPlayer就是处理本地客户端的输入逻辑的类。
UNetConnection
UNetConnection就是处理其他玩家在本地客户端中的APawn的类,所以UNetConnection也是一个玩家。
11.USaveGame
前面提到了AGameState是一个保存游戏数据的类,这个保存是一个临时保存,所以当游戏程序关闭之后AGameState中数据也就不存在了,而USaveGame就是用来保存存档的类,USaveGame提供游戏数据永久性保存,我们只需要往USaveGame中添加我们要保存的属性字段,就可以直接调用USaveGame的接口直接将游戏数据序列化保存到本地文件中,相当的方便。
花了这么长的篇幅也就简要的介绍了一下GamePlay的整体框架,总共由这11个类组成,说起来不多,但是里面的门道却是相当深奥,这需要在以后的使用中慢慢学习消化。
那么接下来就开始各个类的详细使用学习了。
二、UObject
首先我们来看UObject提供的功能:
- Garbage collection:垃圾收集
- Reference updating:引用自动更新
- Reflection:反射
- Serialization:序列化
- Automatic updating of default property changes:自动检测默认变量的更改
- Automatic property initialization:自动变量初始化
- Automatic editor integration:和虚幻引擎编辑器的自动交互
- Type information available at runtime:运行时类型识别
- Network replication:网络复制
1.垃圾回收
首先我们来研究研究UE4是如何进行垃圾回收的。
可以配合着看。
由于C++不提供GC功能,所有UE自己实现了一套GC功能,使用的也是最经典的标记-清理
垃圾回收方式。
GC的过程
UEGC分为来两个阶段,第一个阶段UE从根集合开始遍历,遍历所有可达对象,于是UE就知道了哪些对象还在被引用,哪些对象已经不可被引用了。第二阶段UE会逐步的清理这些不可达对象,形式为分帧分批清理,为什么要这么做呢?想想我们卸载一次性Level时的感受就知道了,分批处理可以保证我们在使用UE时的顺滑而不卡顿。
UEGC的主要函数是在UObjectGlobals.h头文件中CollectGarbage函数
1 |
|
可以看到GC的整体流程很自然的划分成了三个阶段,获取GC锁、执行CollectGarbageInternal和释放GC锁。使用锁的原因是UEGC是多线程的,为了防止在GC的过程中对象被其他线程访问,以保证异步加载的稳定。而CollectGarbageInternal函数则进行垃圾回收和对象标记与清理,两个参数KeepFlags表示这些被标记的对象无论是否被引用都将被保留,bPerformFullPurge表示GC时进行全清理还是分帧分批清理。
那么GC又是如何进行对象标记的呢?还是看源码
1 |
|
我在PerformReachabilityAnalysis函数处做了标记,GC时UE就是通过这个函数进行对象标记的,PerformReachabilityAnalysis函数会做多线程实时的分析对象的引用关系,然后标记出可达与不可达对象。标记是如何进行的还得深入到PerformReachabilityAnalysis函数,再上源码
1 |
|
首先前面的宏暂时可以忽略掉,
第一步,FGCArrayStruct* ArrayStruct = FGCArrayPool::Get().GetArrayStructFromPool();
UE将UObject的所有的强引用和弱引用都存储大ArrayStruct数据结构中,FGCArrayPool是UEGC的主要执行类
第二步,TArray<UObject*>& ObjectsToSerialize = ArrayStruct->ObjectsToSerialize;
分离UObject的强引用到ObjectsToSerialize 数组中。
这是FGCArrayStruct结构体的源码:
1 |
|
ObjectsToSerialize存储强引用,WeakReferences存储弱引用。
第三步,GObjectCountDuringLastMarkPhase.Reset();
重置对象的引用计数。
第四步,通过一个if判断标记可达对象,于是可达对象与不可达对象就被标记出来了,接下来便是GC清理。
GC的触发
UE的GC发生在游戏线程上,支持多线程GC,和大多数主流语言的GC一样支持自动触发和手动触发。
手动触发
手动触发UE也提供了两种方式,其一是通过C++函数:
1 |
|
这里需要注意的是GEngine
在Engine.h
头文件下。
手动触发的使用场景一般是在卸载某些资源后,手动触发GC回收这些资源在使用过程中的无用对象。
其二是蓝图节点:
手动调用这两个函数,UE会跳过GC算法,在下一次Tick时直接进行GC。
这里有一点需要注意,在大多数情况下,手动GC一般只能回收NewObject函数创建的对象,而UWorld()->SpawnActor函数创建的对象无论如何调用都无法销毁,这是因为,当UE创建一个Actor之后在UWorld中就已经保存了这个Actor的引用,所以无论我们如何释放Actor的引用,这个Actor的引用计数都不会归零,所以要销毁一个Actor还是需要通过Actor->Destroy()函数。
我们可以个一个例子:
1 |
|
1 |
|
1 |
|
OutputLog:
1 |
|
可以看到使用UWorld()->SpawnActor创建的Actor即使手动强制GC也没有被回收,因为这个Actor是可达对象。
自动触发
要想UE自动触发的GC能能够回收我们创建的对象,那么我们创建的对象就必须继承自UObject,至于加不加UPROPERTY()宏似乎不影响GC的回收,如下面的测试结果,还是以上面的例子为例,把BeginPlay函数改为如下:
1 |
|
我们将MyActor2拖入场景中,运行,OutputLog输出,可以找到下面两句:
1 |
|
可以看到,没有使用UPROPERTY()宏的变量a依旧在手动GC时被回收了,这里为了效果明显点使用了手动强制回收,其实使用自动GC也是一样的。
这里有提个疑问:
当我们在一个继承自UObject的类组合一个继承自UObject的对象,如果在这个对象定义前没有使用UPROPERTY()宏,那么在Play后UE会调用一次这个对象的析构函数,但是这个对象依然可以被使用,而如果在定义这个对象前使用了UPROPERTY()宏,那么这对象将和组合类被析构时一起被析构。疑问为什么UE会调用一次被组合对象的析构且析构后依然可以使用这个对象。如:
1 |
|
1 |
|
1 |
|
使用UPROPERTY()宏的输出结果:
1 |
|
不使用UPROPERTY()宏的输出结果:
1 |
|
很明显在Play后UMyObject对象的析构函数被调用了,但是此时如果继续访问UMyObject里的成员依旧可以访问。
TWeakObjectPtr、TWeakPtr(既保存引用又可GC)
有时我们可能需要在一个类里面临时保存一些对象,但是一旦保存了引用,就需要手动释放才能保证这些对象可以被GC自动回收,关于这个方面UE也贴心的为我们提供了 TWeakObjectPtr指针,当然,这也是C++弱指针的UE魔改办罢了,使用这个指针既可以引用对象,但是又不会造成引用计数+1。可以通过一个例子很好的看出来。
1 |
|
1 |
|
1 |
|
OutputLog:
1 |
|
可以看到,AActor1对象依旧被强制回收了。
而TWeakPtr则对于自定义类的弱指针。
注意:弱指针不可以被用来作为TSet或TMap的Key,因为一个对象被GC时无法通知一个容器的Key,但是可以用来作为容器的Value。
TSharedPtr、TSharedRef(自定义类的GC)
自定义类的GC,UE也贴心的提供了 TSharedPtr和TSharedRef对象来为自定义类支持GC,TSharedPtr本质上是一个被封装过的指针,使用形式上依然保留指针的风格。
创建TSharedPtr指针指向一个自定义类时,需要使用MakeShareable()
函数,如:
1 |
|
TSharedPtr和TSharedRef都可以为自定义类提供GC功能,二者的区别只在于TSharedPtr可以为null,而SharedRef不可以。我在网上查询发现有三种方法构建TSharedRef,分别为:
第一种:
1 |
|
第二种:
1 |
|
第三种:
1 |
|
其中第二种方法在编写时没有任何问题但在编译时无法通过,并提示:
1 |
|
使用的编译环境为:UE4.22 + VS2017
FGCObject(在自定义类中控制UObject对象的GC)
当我们在一个自定义类中组合一个UObject对象时,如果不做特殊处理也会出现GC触发中发现的疑问,在自定义类没有被析构时,UObject的对象的析构函数就被调用了,但是对象依然可以被使用。目前没有发现这种情况会导致什么样的后果,但是作为一个合格的UE程序还是应该尽量避免这种情况的发生,那么在一个自定义类中组合一个UObject对象,应该如何控制UObject对象的GC呢?
UE4提供了一个叫做FGCObject的类,位于GCObject.h头文件中,我们需要使自定义类继承自FGCObject类,然后再实现AddReferencedObjects函数,并在函数中通过Collector.AddReferencedObject()函数将所有的UObject对象UE4自动管理即可。
如:
1 |
|
然后,UObject对象就会在FActor对象析构时才被析构。
2.序列化
FObjectWriter和FObjectReader序列化对象到文件和从文件读取
FObjectWriter可以将对象数据序列化为二进制流,然后配合FFileHelper将流写入文件即可实现对象状态存储到文件。
1 |
|
配合FFileHelper将文件中的对象状态读入字节数组,FObjectReader就可以将字节数组中的对象状态写入新的对象中。
1 |
|
看一下运行结果:
可以看到,新创建的USerializationObj对象的状态是被修改过后的状态。
Actor的使用方式和UObject是一样的:
1 |
|
1 |
|
运行结果:
3.反射
在使用UE4的反射时有一个基础概念是必须要清楚的,即UE4的反射系统是建立在一整套的宏的设计上的,也就是说,想要一个类、属性、方法、枚举、结构体等支持UE4的反射,那么类必须加UCLASS宏标识,属性必须加UPROPERTTY宏标识,方法必须加UFUNCTION宏标识,枚举必须加UENUM宏标识,结构体必须加USTRUCT宏标识,如果不加这些宏来标识对应的目标,那么这些目标对于UE4的反射系统来说就是不可见的。
搜索所有的Object
C++本身的反射系统RTTI相当薄弱,所以UE在C++的基础上借助UObject自己实现了一套反射系统,同时借鉴了C#的长处提供了一系列反射用的系统函数。
1 |
|
运行时创建对象
1 |
|
UE4提供FindObject模板函数来搜索指定的类的类型信息,返回的类型元素据通过UClass类型对象存储,UClass对象就是UE4专门用来存储元数据的类型,UClass中提供了大量的方法来操作元数据,UClass,这里使用GetDefaultObject函数调用默认的构造函数创建SerializationObj类型的对象,需要注意的是GetDefaultObject返回的是一个UObject对象,所以需要使用Cast来做类型转换。
遍历对象内所有的属性、函数
1 |
|
USerializationObj头文件内容:
1 |
|
输出:
注意:
- 对于
UClass* uclass = FindObject<UClass>(ANY_PACKAGE, TEXT("SerializationObj"));
需要注意的是UClass不能使用智能指针来装载,如:TSharedPtr<UClass> uclass = MakeShared(FindObject<UClass>(ANY_PACKAGE,TEXT("SerializationObje")))
,使用智能指针在编译阶段和运行阶段都没有问题,但是结束运行时会导致引擎崩溃(直接启动的引擎会崩溃,通过vs启动的引擎会报异常),根据崩溃的提示,原因视乎和GC有关,具体原因未明。 for (TFieldIterator<UProperty> i(obj->GetClass()); i; ++i)
的i的构造参数是UClass类型,而GetClass函数是一个实例函数,所以要取得一个类的UClass数据就不得不提供一个它的实例
此外由于静态变量无法被UPROPERTY宏标识,所以static属性对于UE4的反射系统来说也是不可见的,使用for (TFieldIterator<UProperty> i(obj->GetClass()); i; ++i)
遍历属性是可以发现其中没有静态属性的。
遍历类的继承的所有接口
1 |
|
USerializationObj头文件内容:
1 |
|
输出结果:
遍历枚举
1 |
|
输出结果:
遍历元数据
1 |
|
需要注意的是,一个对象的UMetaData数据不能直接获取,而需要通过GetOutermost函数获取这个对象的UPakage对象再通过UPakage对象的GetMetaData函数来获取,由于UE4使用TMap<FName,FString>
的数据结构来存储元数据,所以我们通过UMetaData对象的GetMapForObject函数获取的元数据需要使用一个TMap<FName,FString>来存储,而TMap的元素又是一个TPair,所以遍历时可以使用一个范围for循环并使用
TPair<FName,FString>结构来存储取出的
TMap<FName,FString>元素。
对于元素据暂时没有深入去研究,总之如果我们只创建一个UObject类并且只往里面添加一些属性和函数,类的元数据都是空的,尝试过向UCLASS和UPROPERTY宏中添加meta内容,元数据依旧是空的,所以在使用TMap<FName,FString>
时最好先判空。
这里有一个坑,就是UE_LOG不能打印FName类型的字符串,FName类型字符串必须通过ToString函数转换成FString才能被UE_LOG打印,更坑的是直接打印FName时,在编写代码时编辑器不会报错,只有在编译时才会报错。
遍历继承关系
1 |
|
输出结果:
将UClass换成UStruct最终效果也是一样的,因为UClass继承自UStruct。
1 |
|
遍历所有的子类
首先为USerializationObj类创建两个子类:
1 |
|
输出结果:
动态操作实例属性
UE4提供了一个通过名字来动态获取属性的方法
1 |
|
SerializationObj类:
1 |
|
运行结果:
UClass::FindPropertyByName()
函数可以通过名字来访问调用对象中的属性,而FindPropertyByName()返回的也不是直接可用的属性,而是包含这个属性信息的UProperty类,然后通过UProperty::ContainerPtrToValuePtr()
函数可以获取这个属性的指针,通过这个指针即可修改属性的值了。这个方法可直接修改实例中的任何属性,在测试修改const属性时发现了一个问题,即被UPROPERTY宏修饰的属性如果加上const那么程序将无法编译通过
除此之外也可通过遍历属性的方法来获取想要的属性,同样支持任何保护级
1 |
|
当然直接通过指针来操作属性在安全性上是不够的,大多数时候我们可能只是需要属性的一份值拷贝就够了,所以UE4针对FString类型的属性提供了两个更安全的操作函数:
1 |
|
输出结果:
UProperty::ExportTextIte
函数返回的是一个FString,而非FString*,所以获取到的是一份FString的拷贝,可以看到我们对outStr做修改是不会影响到到obj中的str的,同时UE4也提供拷贝设值UProperty::ImportText
函数,将inStr的值拷贝赋值到obj的str中,之后obj的str的值就发生了变化。
动态调用实例函数
1 |
|
SerializationObj头文件内容:
1 |
|
输出结果:
事实上真正通过反射调用函数的方法是:ProcessEvent
,而有参函数和无参函数的调用又有所区别,首先需要通过UObject::FindFunctionChecked
函数通过函数名获取函数的元数据信息存储到UFunction类中,无参函数的调用就可以直接通过ProcessEvent(UFunction*,nullptr)
来调用了,第一个参数是存储了指定函数元数据信息的UFunction,由于没有参数所以传入函数参数的第二个参数直接设为nullptr即可。
而对于有参数有返回值的函数调用,则需要提前创建好存储函数参数和返回值的结构体,如上面例子中Fun_Params,名字可以随意取,但是结构体的成员类型、数量和顺序必须和对应的gen.cpp文件中UE4为这个函数创建的存储函数参数信息的结构体一直,我们可以看一下这个结构体的结构,位置在:项目根目录\Intermediate\Build\Win64\UE4Editor\Inc\MyProject\SerializationObj.gen.cpp,我这里类的名字是SerializationObj,所以文件叫SerializationObj.gen.cpp。
1 |
|
然后对应函数原型:
1 |
|
结构体的成员和函数的参数列表类型和顺序一一对应的,最后一个成员固定名字为ReturnValue用于存储函数的返回值。
所以我们在调用有参有返回值的函数时需要创建一个对应这种结构的结构体,使用这个结构体的变量来传递参数和接收返回值,如上面例子中的:pams。
相较于C#中Invoke函数,将参数和返回值直接装箱至object中,UE4却没有办法这么做,因为UE4的UObject系统和原生C++可以算是两套系统,UE4的UObject没办法像C#那样将所有的类型都装箱到UObject中,索性把装箱的操作直接交给开发者做了,所以才有创建存储参数返回值的结构体的步骤。
C++通过反射调用蓝图函数和事件
由于蓝图函数和事件在编译后也是以UFunction的元数据存储的,所以通过反射是可以实现C++调用蓝图函数和事件的。
首先创建一个继承自Actor的蓝图MyBlueprint,并在蓝图中新增函数PrintStr和自定义事件PrintWorld:
然后在C++中增加调用蓝图函数和事件的代码:
1 |
|
我们逐行分析:
for (TActorIterator<AActor> bpActor(GetWorld()); bpActor; ++bpActor)
,遍历Level中所有的Actor,这里有一个坑,就是GetWorld()必须使用Actor自身的GetWorld()函数,不能使用GEngine->GetWorld(),否则运行时会提示资源被占用;
if (bpActor->GetName() == TEXT("MyBlueprint"))
,找到我们需要的蓝图;
for (TFieldIterator<UFunction> bpFun(bpActor->GetClass()); bpFun; ++bpFun)
,遍历蓝图中的所有的函数和事件,蓝图函数和事件在底层元数据都是以UFunction的形式存储的,所以遍历的时候可以同时遍历函数和事件;
if (bpFun->HasAnyFunctionFlags(FUNC_BlueprintEvent) && bpFun->HasAnyFunctionFlags(FUNC_BlueprintCallable) && bpFun->GetName() == TEXT("PrintStr"))
,找到蓝图中名字为PrintStr的函数HasAnyFunctionFlags()函数用于判断当前函数是否拥有某个标记,如:FUNC_BlueprintEvent—函数时蓝图事件,FUNC_BlueprintCallable—函数是蓝图可调用函数即蓝图函数;
UFunction* fun = *bpFun;
获取函数的元素据存储到UFunction中;
uint8* buff = static_cast<uint8*>(FMemory_Alloca(fun->ParmsSize));
,为函数栈申请内存空间,FMemory_Alloca申请自动内存的宏,fun->ParmsSize函数的总变量大小;
FFrame frame = FFrame(*bpActor, fun, buff);
,创建函数栈;
fun->Invoke(*bpActor, frame, buff);
,通过函数栈执行函数
这种方式调用蓝图函数虽然很灵活方便,但是效率实在堪忧,能不用还是尽量别用吧。
C++通过子类重写调用蓝图函数
通过C++父类申明函数,蓝图子类实现函数,C++父类调用函数的方式也可以实现C++调用蓝图函数,虽然这种方式不属于反射的范畴了,不过想起来了还是记录一下吧。
首先对于C++类AOperActor创建一个给蓝图来实现的函数BPPrint
1 |
|
这里需要注意的是,如果需要用蓝图子类来实现父类函数的话,这个函数必须是public权限,且需要标识BlueprintImplementableEvent,这个标识符会告诉UE4这个函数可以在蓝图中被当作事件来使用,并且会对这个函数进行默认实现,也就是实现一个空函数体,这就是为什么即使我们不在子类里实现这个函数直接调用也不会报错的原因。
然后我们创建一个继承自AOperator类的蓝图类并在蓝图类里实现BPPrint函数
这里实现BPPrint函数的方式有两个,一个是直接右键搜索BPPrint就像调用事件一样,直接调出实现,另一个是在Function中重写BPPrint,最终的结果和表现形式是一样的。
然后最关键的一点就是,OperatorActorInherit这个实现了BPPrint函数的蓝图类必须要在场景中函数调用才能生效,我们在AOperatorActor类的BeginPlay函数中调用
1 |
|
结果:
我这里选择使用一个Actor来做C++通过继承调用蓝图函数的例子而不是Object,也正是因为实现函数的蓝图必须在场景里调用才生效,而Object是不能存在于场景中的。
上面说到蓝图VM会为被BlueprintImplementableEvent标识的函数生成默认实现,事实上UE4也提供了函数的自定义默认实现的,即使用BlueprintNativeEvent
标识就可以自定义函数的默认实现了,且必须要实现,否则编译不能通过,更重要的是函数名还有所变化,如:我们要自定义BPPrint的默认实现,那么BPPrint的实现应该如下:
1 |
|
后缀_Implementation
是必须要加的,否则编译无法通过。
此时如果我们不在子类中重写这个函数那么调用是默认调用父类的函数实现,如果我们在子列中重写这个函数的实现那么调用的就是子类的函数实现了。如:
不在子类中重写:
在子类中重写:
这里有一个问题,就是父类的实现会被多调用一次,原因未知。
除了通过重写父类函数然后直接通过调用父类函数的形式在C++中调用子类的蓝图函数的调用方式外,UE4还提供了直接通过函数名字来调用子类的任意函数的接口:
C++通过CallFunctionByNameWithArguments调用蓝图函数
大部分操作和前面的C++通过子类重写调用蓝图函数一样,需要一个继承自父类的蓝图子类,不同的是子类不需要重写父类的函数,父类可以直接通过CallFunctionByNameWithArguments接口使用函数名调用子类蓝图中任意函数。
子类蓝图中的PrintHello函数
注意这个函数是直接由子类创建的。
然后就可以直接在父类里调用了,我这里直接在BeginePlay里调用
1 |
|
输出结果:
这里有几点需要注意,FString::Printf中的字符串使用空格隔开,一个字符串为要调用的函数名,之后的字符串为参数,各个参数之间也是用空格隔开,FOutputDeviceDebug来自头文件OutputDeviceDebug.h
当然CallFunctionByNameWithArguments接口也有通过子类重写来调用蓝图的方式一样需要通过子类来调用蓝图,所以一样需要这个子类蓝图要存在于场景中,否则调用一样无效,所以有一样的局限性,就是只支持Actor类型。
三、AActor
1.Actor网络同步
Actor的网络同步可以参考另一篇博文的第三节
2.GameMode
GameMode的执行过程
这里引用Ken_An大佬总结的一张精髓图片
GameMode只运行在服务器当中,对于单机游戏来说,由于UE4的服务器代码和客户端代码是一体的所以单机游戏本身可以算是自己的服务器,对网络游戏来说,GameMode只存在于服务器当中,在客户端中只拥有GameMode的一些副本,GameMode存在于ULevel中,当游戏切换Level时,当前GameMode会随着Level的切换而被销毁,并在新的Level加载之后产生新的GameMode。
GameMode的创建到Pawn的生成过程:
游戏进程开始运行,此时UE创建GameInstance,GameInstance初始化WorldSetting中设置的GameMode,事实上在UE创建GameInstance时还创建UEngine和UWorld;
UE调用UGameEngine::Start函数,Start函数调用UEngine::Browse函数,再由Browse函数调用UEngine::LoadMap函数,由LoadMap函数来加载Map,创建新的World,并调用AGameInstance::CreateGameModeForURL创建GameMode;
SetGameMode函数主要是确保GameMode只能在Server端创建,并调用AGameInstance::CreateGameModeForURL函数创建GameMode,而CreateGameModeForURL就是实际直接调用SpawnActor创建GameMode的函数了;
CreateGameModeForURL函数会去读取WorldSetting的配置,并配置到新创建GameMode中;
Client发送连接请求:Client通过ClientTravel函数向服务器请求连接;
Server处理Client的请求连接:如果Server接受Client的连接,则发送配置的Server Default Map给Client;
Client加载地图成功之后,Server调用AGameModeBase::PreLogin函数,如果Server不想某个Client接入游戏,可以在PreLogin中拒绝;
如果Server接受Client加入游戏,则调用AGameModeBase::Login,如果不接受则不调用:每当有一个Client加入游戏,Login函数就会创建一个PlayerController并复制一份到对应的Client中替换Client的本地PlayerController,此时Client和Server就通过PlayerController建立起了通信连接,RPC调用就生效了,但是按官方的说法此时调用RPC还是不安全的,应该在AGameModeBase::PostLogin函数执行完之后再调用;
疑问:按照官方的说法,PreLogin在Login之前调用,且源码中也是一个公有的虚函数,我在自定义的GameMode中重写的PreLogin函数在游戏运行时并没有调用而重写的Login和PostLogin会调用,关于这方面的资料实在是太过于匮乏,目前尚不知道原因何在。
PostLogin调用HandleStartingNewPlayer:HandleStartingNewPlayer函数是可以被蓝图重写的;
HandleStartingNewPlayer调用RestartPlayer:RestartPlayer为蓝图可调用函数,但UE不允许RestartPlayer函数被蓝图重写,允许被C++重写;
RestartPlayer函数会通过FindPlayerStart函数为将要Spawn的Pawn选取出生位置,然后调用RestartPlayerAtPlayerStart函数在在指定位置生成Pawn;
RestartPlayerAtPlayerStart则会调用SpawnDefaultPawnFor函数实际生成Pawn并设置生成位置,然后提供InitStartSpot函数在引擎认为完成Pawn的生成之前来调整Pawn的出生位置,InitStartSpot在源码中是一个空函数,可以被蓝图重写,然后RestartPlayerAtPlayerStart会调用FinishRestartPlayer函数来设置Controller的朝向,并通知引擎确认Pawn的生成;
SpawnDefaultPawnFor函数也是一个蓝图可重写函数,会初始化Pawn生成的Transform,然后调用SpawnDefaultPawnAtTransform函数在指定的Transform生成Pawn;
SpawnDefaultPawnAtTransform函数则是实际调用SpawnActor函数来创建Pawn的最底层函数了,SpawnDefaultPawnAtTransform也是一个蓝图可重写函数;
至此从GameMode生成到Pawn的生成过程就结束了。
AGameMode与AGameModeBase
AGameMode继承自AGameModeBase,AGameModeBase提供基础的游戏玩法规则,角色控制链中各种类的注册,游戏进度的暂停与重启,过场动画等,而AGameMode则在AGameModeBase的基础上加上了多人联机匹配的机制,如AGameMode提供了联机时的各种状态(等待加入,等待准备,游戏中等等),当游戏中的玩家断开连接时,AGameMode提供挂起玩家并存储玩家状态,待玩家重返游戏时恢复的机制。根据官方文档中描述AGameMode的产生在AGameModeBase之前,而AGameModeBase是在UE4.14之后才加入的,目的是在AGameMode上面再添加一个层级,以便UE4后续对GameMode的扩展。这些功能都在AGameMode的源码中有所反应,如下面截取的部分源码片段:
1 |
|
4.GameState
按照官方的说法GameState是用来保存游戏全局数据的,如任务进度,NPC状态等,GameState在服务器产生并会备份一份到所有的客户端,并且GameState对所有客户端可见,与PlayerState相对,PlayerState用于保存客户端自身的状态。GameState有GameMode创建。
只有服务器上GameState在状态发生改变时才会自行同步备份到所有的客户端,客户端的GameState副本自身不会自行同步GameState的状态到Server,如一个客户端触发了一个NPC的状态,修改了这个客户端中GameState备份的NPC状态,这个GameState备份不会将修改过的状态同步的服务器和其他的客户端,但如果修改状态的逻辑在服务器中执行,修改的GameState时服务器上的GameState,则这个状态的修改会自行同步到所有的客户端,所以对GameState的修改应该在服务器中进行。
GameState属于GameMode配置的一部分所以会跟随着GameMode的产生而产生,销毁而销毁。
GameState的创建过程
1 |
|
在GameMode构造的时候会初始化GameState的类型为AGameStateBase,GameMode在AGameModeBase::PreInitializeComponents函数中通过SpawnInfo来确定GameMode指定的GameState类型,然后调用SpawnActor创建GameState对象,然后调用InitGameState配置GameState的一些属性。
在AGameModeBase::InitGameState中
1 |
|
GameState获取了当前GameMode对象的引用和当前SpectatorPawn的对象,SpectatorPawn是旁观者类。
5.PlayerState
和GameState相对PlayerState用于保存玩家数据,和GameState一样PlayerState也首先在Server中生成并同步副本到所的Client中,一个Client的当前Level中会保存所有加入这局游戏的玩家的PlayerState。
如,我们有三个玩家加入游戏,那么在运行时Level下就出现了三个PlayerState。
PlayerSate存在于Controller和Pawn中,Controller保存PlayerState的源对象,Pawn保存PlayerState的引用,即PlayerState的生命周期跟着Controller走,这也比较符合PlayerState的定位,PlayerState保存的是玩家数据而不是角色数据,因为一局游戏中一个玩家可以操控多个角色。
和GameState一样,PlayerState也在Server中的PlayerSate状态发生变化时会自动同步状态到所有客户端中对应的PlayerState副本,而Client中PlayerState副本状态发生改变时不会自动同步到Server,所以对PlayerState的修改也应该在服务器中进行。
PlayerState的创建过程
- GameMode在Login函数中调用SpawnPlayerController函数;
- SpawnPlayerController会根据配置情况调用不通的函数来创建PlayerController;
- PlayerController调用PostInitializeComponents函数进行初始化,PostInitializeComponents是APlayerController继承自AActor的函数,在Actor所有组件初始化后初始化自己时调用;
- PostInitializeComponents函数调用InitPlayerState函数创建PlayerState实例,InitPlayerState函数是APlayerController继承自AController的函数;
- InitPlayerState函数通过SpawnInfo确定创建的PlayerState的类型,然后调用SpawnActor创建PlayerState实例。
6.WorldSettings
WorldSettings的资料着实是太少太少了,连官方论坛中都很少提及,官方文档也就了了一句话,WorldSettings主要做的就是对游戏世界的一系列配置,如:大地图的动态加载与卸载,世界光照,声音系统,边界检查,导航系统,AI系统,世界重力模拟等等,具体的一些选项功能可以查看Im-JC的博文。
WorldSettings是蓝图不可见的,如果我们需要动态的获取WorldSettings里的一些配置则需要通过GetActorsWithClass来获取。
默认WorldSettings是可以更换的,在ProjectSettings/Engine/GeneralSetttings/DefualtClass下
可以看到不仅WorldSettings可以配置,GameViewportClient、LocalPlayer、LevelScriptActor,PhysicsCollisionHandler等都可以自定义配置,UE是真的强大,连关卡蓝图、UI显示,物理碰撞等都给予了我们自定义能力。
我们在编辑器中打开的WorldSettings视图并不是WorldSettings,而是由WorldSettings提供一个可视化编辑界面。
WorldSettings的创建过程
在UE源码中好一阵找,发现WorldSettings的创建有四个地方,分别是
UEditorLevelUtils::AddLevelToWorld_Internal
UEditorEngine::CreateTransLevelMoveBuffer
UWorld::RepairWorldSettings
UWorld::InitializeNewWorld
UEditorLevelUtils和UEditorEngine都是和编辑器相关的,不在GamePlay的框架内,这里就不讨论了,我们重点看一下UWorld中的。
InitializeNewWorld函数是实际创建WorldSettings的地方,而RepairWorldSettings按照源码的解释就是用于确保游戏中切实有一个可用的WorldSettings的功能函数,RepairWorldSettings在UWorld::PostLogin时被调用。
- GameInstance在InitializeStandalone中调用UWorld::CreateWorld函数;
- UWorld::CreateWorld函数调用InitializeNewWorld来创建WorldSettings;
- UWorld::InitializeNewWorld函数就是实际创建WorldSettings的函数,InitializeNewWorld会先读取ProjectSettings中的WorlSettings的配置,如果配置了则创建对应的WorldSettings类实例,否则创建默认的WorldSettings实例。
7.ALevelScriptActor
ALevelScriptActor就是我们常说的关卡蓝图,ALevelScriptActor是一个在关卡中的隐藏Actor,在Level列表里是看不到的。
自定义关卡蓝图
既然关卡蓝图也是一个Actor那么理论上关卡蓝图也是可以自定义的,经过一番研究UE4还真提供了自定义关卡蓝图的功能。
ALevelScriptActor不是一个蓝图类,所以我们直接去创建蓝图是找不到一个ALevelScriptActor基类可供继承的,所以我们只能先用C++去创建一个继承自ALevelScriptActor的自定义C++类,然后再修改关卡蓝图的父类为自定义的ALevelScriptActor类。
这里我创建了一个LSPLevelScriptActor类并重写了BeginPlay函数,在BeginPlay函数里只打印一串字符。
1 |
|
1 |
|
然后我们打开想要自定义关卡蓝图的关卡,并打开关卡蓝图,在ClassSettings/ClassOptions/ParentClass设置成为自定义的LSPLevelScriptActor,那么当我们运行BeginPlay事件时,就会在屏幕上打印LSPLevelScriptActor::BeginPlay
8.APlayerController
APlayController与AIController相对,专用于给玩家操作的角色控制器,在AGameMode章节有说过APlayerController是由AGameModeBase::Login函数创建。APlayerController在游戏运行时是不可视的,主要负责接收外部输入,如鼠标键盘、游戏手柄等,并根据输入按一定的逻辑来控制与其绑定的APwan。APlayerController可以通过Possess函数来获取一个APawn的控制权,也可以通过UnPossess函数还放弃一个APawn的控制权。
在UE4的设计里,APwan和APlayerController都是可以接收外部输入的,如InputAxis事件既可以放在APawn里对APwan进行控制,也可以APlayerController里对指定APawn进行控制,那么二者对外部输入的处理有什么不同呢?事实上,APlayerController在逻辑上的层级要高于APawn的,也就是说外部输入要先进入APlayerController再由APlayerController传递给APawn,这就使得APlayController可以对APawn的输入进行拦截。
既然APawn和APlayerController都可以接收外部输入,那么对输入逻辑的处理应该放在APawn里还是放在APlayerController里呢?
个人理解是人应该放在APawn里,为什么呢?因为在一个游戏里,同一个玩家是可以操作多种类型的角色的,如GTA5里面,玩家既可以控制人型角色,也可以开各种车辆,还可以还飞机。各种角色对接收的输入和对输入的处理都是不一样的,如当玩家按下键盘s时,如果APawn是一个人,那么角色应该向后走,如果APawn是一辆车,那么角色应该减速,如果APawn是一个架飞机,那么角色因该下降。这么多中不同的对同一输入的处理不因该由一个APlayerController来出来,而是将之拆分到不同的APawn中处理。
输入顺序
UE4里可以接收输入的有4种类,APlayController、APawn、ALevelScriptActor和普通Actor
Actor只要设置EnableInput或AutoReceiveInput就可以接收输入了
UE4的可接收输入对象的输入优先级:
Actor>APlayerController>ALevelScriptActor>APawn
输入栈
UE4对输入的接收有一个输入栈的概念,在游戏一开始时,UE4会对所有的可接收输入的对象进行入栈处理,UE4通过入栈顺序来对可接收输入对象的输入优先级进行分级,先直接上一段源码。
1 |
|
为了验证输入优先级,我创建了一个具有APawn,APlayerController,具有输入的ALevelScriptActor,具有输入的Actor的关卡。
我在APawn中加入了前后左右滚动的事件InputAxisMoveForward和InputAxisMoveRight,同时在APlayerController,ALevelScriptActor,Actor中分别都加入一个InputAxisMoveRight,且只进行文字打印操作。
先来直接看一下结果
可以看到字符串的输出顺序为Actor Input->Controller Input->ServerMap Input->APawn Input
此时键盘输入依旧会一次从栈顶的Actor一直传递到栈底的APawn,但是APawn的InputAxisMoveRight已经被其上层的InputComponent截断,所以APawn只能进行前后移动而无法左右移动。
输入流程
先上一张从张悟基大佬哪里盗来的流程图。
在UE4LaunchEngineLoop.h文件中有一个专门处理引擎循环的类FEngineLoop,UE4在FEngineLoop::Tick()函数中处理每帧获取设备输入,主要处理逻辑。
1
2
3
4FSlateApplication& SlateApp = FSlateApplication::Get();
{ QUICK_SCOPE_CYCLE_COUNTER(STAT_FEngineLoop_Tick_PollGameDeviceState);
SlateApp.PollGameDeviceState();
}其中使用了大量的宏,以本小菜的水平当前还看不懂😬,只知道逻辑是在这里处理的。
UE4使用FSlateApplication类来将各种各样的硬件输入转化为固定的对应响应事件,如FSlateApplication::OnControllerButtonReleased()函数就是专门处理按键释放的函数,既可以处理手柄按钮释放,也可以处理键盘按键释放。
在UE4中有一个GenericApplication类是专门做平台处理的接口,在FSlateApplication的基类FSlateApplicationBase中就保存了一个GenericApplication的实例指针。GenericApplication中的函数都是虚函数,GenericApplication类会根据不同平台生成对应的子类的实例,FSlateApplication会根据不同的平台去做不同的输入处理,如在PC平台上就会调用在更底层的输入处理中调用FSlateApplication::OnControllerButtonReleased()函数将键盘释放转化为ProcessKeyUpEvent事件,在PS4平台中同样在更底层的输入处理中调用FSlateApplication::OnControllerButtonReleased()函数将按钮释放转化为ProcessKeyUpEvent事件。
关于GenericApplication的资料实在找不到,这里纯是个人理解。
到这一步,UE4就将各种各种各样的平台输入统一到几个输入处理事件中了,这里以键盘按键释放为例,继续往下走,键盘按键释放操作被映射到ProcessKeyUpEvent事件中,ProcessKeyUpEvent事件主要就做一件事,就是将输入优先传入UMG中,判断UMG中是否有对这个按键操作进行了监听,如果监听了,则将输入传入到UMG中,而输入是否继续传入到World中由UMG决定,如果没有监听则直接将输入传递到World中。
这里需要注意的是,UMG不能直接对键盘进行监听也不能对AxisInput进行监听,只能监听ActionInput。
其中Consume参数就决定了输入是否继续往下传递。
如果输入继续往下传递,这时输入会进入到UGameViewportClient中,UGameViewportClient中有对应输入类型的处理函数,通过这些函数将输入传递给PlayerControlelr,Actor、PlayerController、LevelScriptActor、Pawn才能接收到输入,这里输入会按照PlayerController的输入栈来传递输入事件。
这里的资料也是少得可怜,至于输入到底是如何从FSlateApplication传递到UGameViewportClient中的始终未得其解。
1
2
3
4
5
6
7
8//UGameViewportClient.h
virtual bool InputKey(const FInputKeyEventArgs& EventArgs) override;
UE_DEPRECATED(4.21, "Use the new InputKey(const FInputKeyEventArgs& EventArgs) function.")
virtual bool InputKey(FViewport* InViewport, int32 ControllerId, FKey Key, EInputEvent Event, float AmountDepressed = 1.f, bool bGamepad = false) override final { return false; }
virtual bool InputAxis(FViewport* Viewport, int32 ControllerId, FKey Key, float Delta, float DeltaTime, int32 NumSamples=1, bool bGamepad=false) override;
virtual bool InputChar(FViewport* Viewport,int32 ControllerId, TCHAR Character) override;
virtual bool InputTouch(FViewport* Viewport, int32 ControllerId, uint32 Handle, ETouchType::Type Type, const FVector2D& TouchLocation, float Force, FDateTime DeviceTimestamp, uint32 TouchpadIndex) override;
virtual bool InputMotion(FViewport* Viewport, int32 ControllerId, const FVector& Tilt, const FVector& RotationRate, const FVector& Gravity, const FVector& Acceleration) override;
在PlayerController中也有处理UGameViewportClient传入的输入类型的函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18//APlayerController.h
/** Handles a key press */
virtual bool InputKey(FKey Key, EInputEvent EventType, float AmountDepressed, bool bGamepad);
/** Handles a touch screen action */
virtual bool InputTouch(uint32 Handle, ETouchType::Type Type, const FVector2D& TouchLocation, float Force, FDateTime DeviceTimestamp, uint32 TouchpadIndex);
UE_DEPRECATED(4.20, "InputTouch now takes a Force")
bool InputTouch(uint32 Handle, ETouchType::Type Type, const FVector2D& TouchLocation, FDateTime DeviceTimestamp, uint32 TouchpadIndex)
{
return InputTouch(Handle, Type, TouchLocation, 1.0f, DeviceTimestamp, TouchpadIndex);
}
/** Handles a controller axis input */
virtual bool InputAxis(FKey Key, float Delta, float DeltaTime, int32 NumSamples, bool bGamepad);
/** Handles motion control */
virtual bool InputMotion(const FVector& Tilt, const FVector& RotationRate, const FVector& Gravity, const FVector& Acceleration);而Actor、ScriptLeveActor、PlayerController、Pawn则创建各自的InputComponent组件压入PalyerController的输入栈来接收PlayerController的输入,收到输入则去执行绑定的响应函数。
Actor在C++中接收输入
1 |
|
和在蓝图中差不多,也需要先Actor接收输入,需要注意的是要获取当前的PlayerController需要使用GWorld,而绑定事件的InputComponent->BindAction只能绑定ActionInput,且必须是在ProjectSettings/Input中注册过的才行。
9.APawn
APawn在整个UPlayer->APlayerController->APawn的控制链中主要负责角色的物理表现和与角色相关的输入响应。
关于APawn好像也没啥可看的,网上关于APawn的资料几乎没有,具体的深入研究等之后在进行吧。
四、UGameInstance
GameInstance可以理解为UE虚拟的游戏进程,双击exe游戏开始,GameInstance创建,结束游戏,杀死进程,GameInstance销毁,所以GameInstance的生命周期就是一个游戏的游玩过程。
GameInstance虽说是单例,但是UE并没有规定一个游戏里只能存在一个GameInstance,只是由UE存储的GameInstance实例只有一个,且必定是ProjectSetting中配置的GameInstance类型。
GameInstance在各个端之间是互相独立且互不通信的,像Server中的GameInstance和Client中的GameInstance是两个互相独立的实例,且二者没有能力直接通信,因为UE就没有给予GameInstance网络通信的能力。
哪些逻辑应该写在GameInstance中
保存跨关卡的全局数据
由于GameState和PlayerState的实例都会随着Level的切换而被销毁,所以一些需要跨关卡存在的数据就需要使用GameInstance来保存了。
一些需要全局使用UI
由于UE的UI是使用Widget来实现的,在使用UI时需要先CreateWidget,然后将widget实例AddToViewport,所以一些需要全局使用的UI如:背包,人物属性等UI的逻辑就可以写在GameInstance中,以避免跳转关卡时重新初始化这些实例。
Level切换
虽然OpenLevel这些关卡切换的实现在UGameEngine中,GameEngine是比GameInstance更高一级的存在,一般而言我们很少使用到GameEngine,除非是针对编辑器的开发,所以将关切切换的逻辑写在GameInstance中更合理。
与服务器的连接,TCP,HTTP等逻辑
因为GameInstance作为一个全局实例,可以很方便的在各个地方获取,这就方便了服务器的重连,和资源下载。
GameInstance的创建过程
其实在GameMode的执行过程中已经有所体现,事实上当我们双击exe运行游戏进程时,第一个创建出来的实例不是GameInstance,而是GameEngine。
游戏进程启动,创建GameEngine;
GameEngine调用Init函数,读取ProjectSetting中配置的GameInstance的类型,
判断是否配置了GameInstance类型,是则创建对应类型的GameInstance实例,否则创建默认的GameInstance实例;
调用GameInstance的初始化函数,创建FWorldContext,通过FWorldContext创建UWorld,再通过UWorld,GameInstance就可以创建GameMode了。
1 |
|
五、UPlayer
UPlayer就是UE虚拟出来的玩家,在一个多人游戏中玩家有两类,自己和别人,所以UPlayer也随之派生出两个子类,ULoaclPlayer和UNetConnection,ULocalPlayer就是自己,UNetConnection就是别人。
UPlayer的绑定
在FSeamlessTravelHandler::Tick()函数中调用了GameMode->PostSeamlessTravel();
PostSeamlessTravel函数主要是创建一个GameSession和调用HandleSeamlessTravelPlayer来为APlayerController设置UPlayer。
HandleSeamlessTravelPlayer()函数调用SwapPlayerControllers()函数来为APlayerController设置UPlayer,在SwapPlayerControllers最终使用APlayerController::SetPlayer()函数设置UPlayer。
六、UEngine
UEngine下有两个主要的子类,UGameEngine和UEditorEngine,UGameEngine是游戏运行时的UEngine实例,UEditorEngine是UE编辑器实例,UEngine在Engine.h中用GEngine全局变量保存。
UEngine是整个游戏开始的最初是的入口,主要负责做一些顶层类的初始化工作,如初始化负责渲染和输入UGameViewportClient,负责记录World信息的FWorldContext,负责管理音频的FAudioDeviceManager,配置默认的UEngine,负责模块管理的FModuleManager,负责资产管理的UAssetManager,对一些配置文件的加载,如Engine.ini。
关于UEngine::Init函数,看了一下源码,UE5相较于UE4修改还挺大的。
自定义引擎类
UE4的一大好处就是极度自由,UE4除了提供自定义GamePlay框架里的各种类,连自定义引擎类的能力都提供了,由于引擎类有两种UGameEngine和UEditorEngine,所以自定引擎类也分两种,继承自UGameEngine的类只能用于Game模式,继承自UEditorEngine的类只能用于Editor模式,由于UEditorEngine在除了自己魔改UE的情况一般不会修改,所以我们只看UGameEngine就行了。
首先我们创建一个继承自UGameEngine的ULSPGameEngine类,我们可以在类里面重载基类函数或新增自定义函数来扩展自己的引擎类,我这里就不进行扩展了,然后打开项目目录下的/Config/DefaultEngine.ini文件,在[/Script/Engine.Engine]栏将GameEngine=/Script/Engine.Engine改成自己的引擎类,GameEngine=/Script/LSPTetrisClient.LSPGameEngine,其中LSPTetrisClient是模块名称,LSPGameEngine是自定义引擎类的名称,如果没有这一行就自己加一行进去。怎么看自己的项目模块名称呢?在.uproject文件下,默认模块的名字就是自己项目的模块名称。
到这一步自定义引擎类就设置完毕了,要然配置文件生效我们要编译一下并重启引擎,让引擎重新去读取DefaultEngine.ini文件。
那么怎么检测自定义引擎类是否生效了呢?
这里我在GameInstance里加了一个函数来检测。
1 |
|
把这个函数暴露给蓝图,然后在关卡蓝图中调用。先看一下PIE模式下的运行结果:
可以看到ULSPGameEngine并未生效,这是因为GameEngine只在Game模式下才起作用,UE会根据不同的运行模式来生成不同UEngine,现在我们打个包出来再看下结果:
可以看到ULSPGameEngine生效了。
七、UWorld
UWorld在UE4源码中的描述是这样的
- World 是代表地图或沙箱的顶级对象,Actor 和组件将在其中存在并被渲染。
- 一个世界可以是一个单一的持久化关卡,带有一个可选的流媒体关卡列表,这些关卡通过体积和蓝图函数加载和卸载或者它可以是一组按世界构成组织的关卡。
- 在独立游戏中,通常只有一个世界存在,除非在目的地和当前世界都存在的无缝区域过渡期间。
- 在编辑器中存在许多世界:正在编辑的关卡、每个 PIE 实例、每个具有交互式渲染视口的编辑器工具等等。
UWorld中存在一个ULevel数组用于保存World下的多个Level。
1 |
|
UWolrld是一个相当庞大的类,它管理相当大数量的世界配置和世界信息,如:关卡列表、角色控制器列表、相机列表、玩家数量、音频管理器列表、世界时间,玩家控制器的数量、世界重力、世界物理模拟属性等等,也提供着大量的世界基础能力,如:加载或卸载关卡、向永久性关卡中流式添加或卸载关卡,世界的暂停与重置,AI系统的管理,添加和移除Pawn等等。
一般来说一个游戏只会存在一个UWorld,并且UWorld如同UEngine一样在UWorld.h中也使用一个全局变量GWorld来存储,在一般的Actor中我们可以同过GetWorld()函数来获取GWorld。
UWorld的创建
首先由UGameInstance::InitializeStandalone函数调用UWorld::CreateWorld函数来创建一个World,同时会将自身的引用传递给UWorld,还会将创建出来UWorld配置给WorldContext;
1
2
3
4
5
6
7
8
9
10
11
12
13void UGameInstance::InitializeStandalone(const FName InPackageName, UPackage* InWorldPackage)
{
// Creates the world context. This should be the only WorldContext that ever gets created for this GameInstance.
WorldContext = &GetEngine()->CreateNewWorldContext(EWorldType::Game);
WorldContext->OwningGameInstance = this;
// In standalone create a dummy world from the beginning to avoid issues of not having a world until LoadMap gets us our real world
UWorld* DummyWorld = UWorld::CreateWorld(EWorldType::Game, false, InPackageName, InWorldPackage);
DummyWorld->SetGameInstance(this);
WorldContext->SetCurrentWorld(DummyWorld);
Init();
}UWorld::CreateWorld函数则是实际调用NewObject创建UWorld的函数,同时会为新创建出来的World配置一系列参数,并将新的World添加进UEngine中存储,最主要的就是调用UWorld::InitializeNewWorld函数来加载项目设置里配置的默认地图;
UWorld::InitializeNewWorld函数会创建PersistentLevel,并将当前UWorld对象设置成PersistentLevel,同时也会对PersistentLevel进行一些列的配置操作,然后调用UWorld::InitWorld函数来初始化世界;
UWorld::InitWorld函数就是UWolrd的初始化函数了,InitWorld会根据WorldSetting初始化World,初始化寻路系统、AI系统、物理碰撞,物理重力,Brush系统,为当前世界配置PersistentLevel。
八、ULevel
ULevel的概念就是关卡,ULevel会保存其所属的UWorld的引用。ULevel的组成与其他的.uasset资源还有点不一样,因为ULevel又其下的所有Actor,ULevel专有LevelScriptActor,同时又有各种光照烘培信息,所以ULevel在UE中使用.umap的格式存储,而我们进行光照烘培后产生的对应的UMapBuildDataRegistry文件则是专门用于存储烘培信息的。
1.初始Level的创建
- 默认地图的加载首先由UEngine::Start函数拉起,UEngine::Start函数只做了一件事,通过UEngine中保存的GameInstance引用调用UGameInstance::StartGameInstance函数;
- UGameInstance::StartGameInstance会去读取默认的地图设置,并获取需要默认加载地图名称,然后将地图名称传递给UEngine::Browse函数;
- UEngine::Browse就是具体调用LoadMap函数加载地图的函数了。
2.使用蓝图加载umap资源
蓝图加载关卡有两种反式,一种是OpenLevel一种是LoadLevelInstance,OpenLevel是关闭当前关卡进入指定关卡,而LoadLevelInstance则是将指定关卡作为SubLevel,当前关卡作为PersistentLevel,将SubLevel挂载到PersistentLevel下,二者均可以直接通过关卡的名字加载关卡。
3.使用C++加载umap资源
这里我就直接在GameInstance中编写逻辑了。
1 |
|
4.使用LevelStreamingVolume加载关卡
除了上面两种关卡手动加载的方式,UE还提供使用LevelStreamingVolume触发器来自动触发关卡加载,LevelStreamingVolume类似一个触发器,LevelStreaming会检测进入其中的PlayerController,如果发现有PlayerController进入则自动加载指定的关卡,当检测到触发区域不存在PlayerController了,则自动卸载对应关卡。
LevelStreamingVolume的关卡加载也是基于PersistentLevel进行的,所有的LevelStreamingVolume都应该存在与PersistentLevel中,需要加载的SubLevel都应该预先挂载到PersistentLevel上,这样我们就可以在Window/Levels/SummonsLevelDetails的LevelDetails面板中在InspectLevel中选择要加载的关卡,我这里叫SubOneMap,然后在LevelStreaming/StreamingVolumes中绑定PersistentLevel中的LevelStreamingVolume,这样一个LevelStreamingVolume就和一个关卡绑定了。这里StreamingVolumes是一个数组,所以
现在我们来部署一个场景试试效果:
首先在主关卡和子关卡中分别布置如下场景。
其中子关卡是可以不需要关照的,因为主关卡中已经存在光照了。
然后按照上面所说的方式,将子关卡挂载到主关卡下,设置好子关卡在主关卡中的位置,并绑定LevelStreaminVolume。运行来看一下效果:
九、FWorldContext
根据官方文档的描述,FWorldContext用于处理World的切换,同时也保存着World的信息,在UE中UWorld的切换流程是先销毁当前World然后加载下一个Wolrd,因此前一个World的信息就可以通过FWolrdContext传递到后一个World,FWorldContext有UEngine统一管理,且不对外公开。而对不同的UEngine,FWorldContext的数量是不定的,在GameEngine中FWorldContext是唯一的,而中EditorEngine一定存在一个管理编辑器World的FWorldContext,同时可能存在多个管理PIEWorld的FWorldContext,在UE的逻辑中编辑器也是一个World。
源码中对于FWorldContext的描述是这样的:
- 在引擎级别处理 UWorlds 的上下文。当引擎带来和破坏世界时,我们需要一种方法来保持世界属于什么。
- WorldContexts 可以被认为是一个轨道。默认情况下,我们有 1 个轨道用于加载和卸载关卡。添加第二个上下文就是添加第二个轨道;世界继续生存的另一条轨道。
- 对于 GameEngine,将有一个 WorldContext,直到我们决定支持多个同步世界。
- 对于 EditorEngine,EditorWorld 可能有一个 WorldContext,PIE World 可能有一个 WorldContext。
- FWorldContext 提供了一种管理“当前 PIE UWorld*”的方法以及连接/旅行到新世界的状态。
- FWorldContext 应该保留在 UEngine 类的内部。外部代码不应保留指针或尝试直接管理 FWorldContext。
- 外部代码仍然可以处理 UWorld,并将 UWorlds 传递给引擎级函数。引擎代码可以查找给定 UWorld* 的相关上下文。
- 为方便起见,FWorldContext 可以维护指向 UWorlds 的外部指针。例如,PIE 可以将 UWorld UEditorEngine::PlayWorld 与 PIE 世界上下文联系起来。如果 PIE UWorld 发生变化,UEditorEngine::PlayWorld 指针将自动更新。这是通过 AddRef() 和 SetCurrentWorld() 完成的。
纯机翻,看看就行。
FWorldContext的创建过程
- 在第四节中已经说明过,FWorldContext是由UGameInstance::InitializeStandalone函数调用UEngine::CreateNewWorldContext函数来创建的;
- 而UEngine::CreateNewWorldContext函数就是直接new FWorldContext对象的地方,FWorldContext是一个C++结构体,所以UE直接new在了堆上。
FWorldContext由UE内部管理,对开发者来说是不需要接触的,所以暂时也不需要过深入的了解,只需知道FWorldContext是个什么,干什么的就可以了,以后有时间再去深入了解吧。
十、USaveGame
前面已经提到过USaveGame是UE封装好的一个用于持久化保存游戏数据的类,在源码中的描述是这样的:
- 此类充当可用于保存游戏状态的保存游戏对象的基类。
- 当您创建自己的保存游戏子类时,您将为要保存的信息添加成员变量。
- 然后当你想保存游戏时,使用 CreateSaveGameObject 创建这个对象的实例,填写数据,并使用 SaveGameToSlot,提供一个插槽名称。
- 要加载游戏,您只需使用 LoadGameFromSlot,然后从结果对象中读取数据。
而且有意思的是USaveGame是一个空类,贴一下源码:
1 |
|
这是因为,USaveGame只负责存储游戏数据,即我们可以通过在USaveGame类中创建变量来保存游戏中的数据,将这些游戏数据保存到USaveGame中,然后通过一下方法就可以对USaveGame中的游戏数据进行持久化保存和读取了:
- UGameplayStatics::CreateSaveGameObject:创建USaveGame类
- UGameplayStatics::SaveGameToSlot:将USaveGame类序列化保存到磁盘中的.sav文件中
- UGameplayStatics::DoesSaveGameExist:判断磁盘中默认保存目录下是否存在对应的指定槽位名称的.sav文件
- UGameplayStatics::LoadGameFromSlot:加载指定槽位名称的.sav文件并返回反序列化后的USaveGame对象
- UGameplayStatics::DeleteGameInSlot:删除指定槽位名称的.sav文件
- UGameplayStatics::AsyncSaveGameToSlot:异步地将USaveGame类序列化保存到磁盘中的.sav文件中
- UGameplayStatics::AsyncLoadGameFromSlot:异步地加载指定槽位名称的.sav文件并返回反序列化后的USaveGame对象
用法也是非常简单,就没什么好说地了。需要了解的是,我们在编辑器中跑游戏的时候,USaveGame会把.sav文件保存到/项目文件夹/Saved/SaveGame/文件夹下,.sav文件的名字就是保存时的槽位名字,一个.sav文件就是一个存档,当游戏被打包后,.sav文件则存放在/项目目录/Saved/SaveGames/文件夹下
十一、UActorCompoent
关于Compoent比较细致一点的文章,网上也是少之又少,只好继续自己撸源码了。
根据官方文档的说法就是,UE将Actor除基本功能以外的其他功能都拆分成了一个个的UActorComponent了,如负责移动的MovementComponent、负责接收输入的InputComponent、负责物理动画的PhysicalAnimationComponent、负责场景坐标的ScneCompnent、负责模型显示的MeshComponent等等,连是时间线也有一个专门TimeLineComponent负责。
Compoent对于Actor来说是一种扩展,且Component是能挂载在Actor下,那么一个Component如何挂载到一个Actor下呢?
1.Compoent挂载
编辑模式挂载与卸载
编辑模式直接手动挂载,UE会自动为我们注册组件。卸载直接删除就好。
蓝图动态挂载与卸载
蓝图提供Add Component by Class
节点来动态的添加组件,同时提供DestroyComponent
节点来卸载组件,需要注意的是DestroyComponent节点是一个UActorComponent的一个虚函数。
已知在4.26中预设了Add Component by Class节点,在4.23中没有。
C++动态挂载与卸载
当我们创建一个C++组件,UE会自动在UClass中添加一个BlueprintSpawnableComponent元数据,这个元数据就是使C++组件可以直接在编辑器里使用,直接在编辑器里添加这个组件。
1 |
|
C++挂载组件有两种方式,一种是在构造函数中挂载,一种是在运行时挂载,在构造函数中挂载和直接在编辑器中挂载是一样的,都属于编辑时挂载。
在构造函时挂载
1 |
|
在运行时挂载
1 |
|
那么二者有什么区别内?
首先构造时挂载的组件我们在Actor的Details面板中是可以看见组件的,而运行时挂载的组件我们是看不到的,这就意味着构造时挂载的组件我们可以在编辑时和运行时编辑组件的暴露给蓝图的内容,而运行时挂载则对开发者完全不透明。
其次在构造时挂载组件我们需要再手动注册组件,因为CreateDefaultSubobject函数已经对组件进行了注册,需要注意的是CreateDefaultSubobject函数只能在构造函数中使用,在构造函数之外使用会直接导致异常中断,这是因为在CreateDefaultSubobject的源码中直接规定了CreateDefaultSubobject只能在构造函数中使用,否者直接中断。至于AddInstanceComponent函数则是将新创建出来的组件添加到Actor组件列表中,如果不添加进去,尽管Actor也会创建一个新的组件,但是在Actor的Details面板中也看不到组件信息了。而在运行时挂载则需要对组件进行注册,即RegisterComponent,否则Actor只是在堆区创建了一个UObject,却没有把组件添加进World也没有挂载到Actor上。
1 |
|
卸载组件
UE提供了UActorComponent::UnregisterComponent函数来取消组件注册,取消注册的同时也会销毁组件。
1 |
|
2.UActorComponent
由于从UActorComponent派生出来的子类数量相当庞大,所以只重点看几个基类,UActorComponent、USeneComponent、UPrimitiveComponent、UChildActorComponent我们一个个的来看。
首先我们来看看它们的UML:
可以看到UActorComponent是直接继承自UObject的Component的基类,而UE预定义的几个Component类都派生自UActorComponent的子类USeneComponent。
UActorComponent主要提供一些通用的接口,如ResgisterComponent,UnresgisterComponent,TickComponent,BingePlay,EndPlay,InitializeComponent,UninitializeComponent,SetActive,GetWorld等等。
3.USceneComponent
USceneComponent组件为Actor提供在World中Transform功能,包括三维坐标,旋转角和缩放,相对坐标能力。
并且由于USceneComponent具有相对坐标的能力所以USceneComponent可以进行组件嵌套,同时USceneComponent提供GetChildrenComponent函数来获取嵌套在USceneComponent下的所有组件,同时提供AttachToComponent函数用于去嵌入某个组件下。
USceneComponent的C++动态动态嵌套
1 |
|
UE提供了AttachToComponent方法来见过一个USceneComponent嵌套进另一个USceneComponent中。需要注意的是,AttachToCpmponent函数无法再构造函数中使用,否则直接编译不过。
事实上UE提供了AttachTo和AttachToComponent两个函数来进行USceneComponent的嵌套,AttachTo时已经过时的方法,在源码的标注的是4.12的时候就已经弃用了。
1 |
|
1 |
|
而AttachToComponent中新增了一个必须输入的参数—FAttachmentTransformRules,这是一个结构体,总共有四个对象:
1 |
|
FAttachmentTransformRules参数描述了子组件嵌入父组件是的坐标与缩放规则:
- KeepRelativeTransform:嵌入的子组件使用其父组件的相对坐标;
- KeepWorldTransform:嵌入的子组件保持自己的世界坐标;
- SnapToTargetNotIncludeingScale:使用(0,0,0)的相对坐标并重置Scaleda到(1,1,1);
- SnapToTargetIncludeingScale:使用(0,0,0)的相对坐标但不重置Scale到(1,1,1);
USceneComponent的蓝图动态嵌套
UE蓝图提供了两个接口来嵌套USceneComponent,AttachActorToComponent和Attach ComponentToComponent。
AttachActorToComponent是直接将子USceneComponent嵌套到Actor的RootComponent下;
Attach ComponentToComponent则是将子USceneComponent嵌套到指定的USceneComponent下。
4.UPrimitiveComponent
UPrimitiveComponent是一系列可视组件的基类,如碰撞体相关的ShepeComponent、BoxComponent等,与渲染相关的StaticMeshComponent、ModelComponent等,工具相关的ArrowComponent、SplineComponent等,与场景相关的BrushComponent、LandscapeComponent等。
UPrimitiveComponent直接继承自USceneComponent所以也拥有坐标相关的能力,在此之上UPrimitiveComponent还提供了物理碰撞和渲染相关的能力,按照源码注释的说法就是:
- PrimitiveComponents 是包含或生成某种几何体的场景组件,通常用于渲染或用作碰撞数据。
- 对于各种类型的几何体,有几个子类,但目前最常见的ShapeComponents(Capsule、Sphere、Box)、StaticMeshComponent 和 SkeletalMeshComponent。
- ShapeComponents 生成用于碰撞检测但不渲染的几何体,而 StaticMeshComponents 和 SkeletalMeshComponents 包含渲染的预构建几何体,但也可用于碰撞检测。
值得注意的是,UPrimitiveComponent默认是不开启Tick的,所以我们如果想要TickComponent函数就需要在构造函数中手动打开PrimaryComponentTick.bCanEverTick = true;
UPrimitiveComponent的基本能力已经不在GamePlay范畴,这里就不继续深入了,以后有时间再深入了解。
5.UChildActorComponent
UChildActorComponent是专门用于Actor嵌套子Actor功能的组件,UChildActorComponent在注册时和其他组件有些不同,因为UChildActorComponent会在注册时自动创建一个Actor,在销毁时也会将Actor一并销毁,所以如果我们在组件注册时未设定UChildActorComponent绑定的Actor类型,UChildActorComponent会创建默认的AActor,只有绑定了Actor类型才会去创建指定类型的Actor。
1 |
|
在运行时创建
1 |
|
在构造时创建
1 |
|
和PtimitiveComponent一样UChildActorComponent也默认不开启Tick。
十三、总结
最后来总结一下整个GamePlay的初始化流程,这张图是基于UE4.26来制作的,所以与前面的UE5源码的流程可能会有点出入:
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!