InsideUE4之GamePlayer架构
前言
本文为大钊老师的学习UE4GamePlay架构的笔记以及阅读UE5.6的笔记,以下为自己的阅读笔记,本人熟悉unity引擎,以unity引擎角度去阅读这篇文章。
写给Unity开发者的Unreal Engine开发指南 (扫盲) - 知乎
(99+ 封私信 / 80 条消息) Unity和UE5对比,以及踩坑记录 - 知乎
(注:本人现在是UE入门初学者,第一遍阅读粗略看基础架构)
开篇
这边有一些准备链接
- 官方文档
- 视频平台
- Github地址
注意:因为UnrealEngine只是公开源码,但不是开源项目,依然是个私有项目。访问该Github地址,需要先链接你的Github到EpicGames的会员权限里,这个文档Linking your Github account说明了步骤。
基础概念
文件结构
目录名称 | 描述 |
---|---|
Binaries | 存放编译生成的结果二进制文件。该目录可加入 .gitignore (每次都会重新生成)。 |
Config | 配置文件。 |
Content | 最常用目录,存放所有资源和蓝图等。 |
DerivedDataCache | 存储引擎针对平台特化后的资源版本(DDC)。例如同一个图片可为不同平台生成适配格式,无需修改原始 .uasset 文件。可加入 .gitignore 。 |
Intermediate | 中间文件(可加入 .gitignore ),存放临时生成文件:- Build 中间文件(如 .obj 和预编译头)- UHT 预处理生成的 .generated.h/.cpp - 由 .uproject 生成的 VS .vcxproj 项目文件- 编译生成的 Shader 文件 |
AssetRegistryCache | Asset Registry 系统的缓存文件,索引所有 .uasset 资源的头信息(如 CachedAssetRegistry.bin )。 |
Saved | 存储自动保存文件、配置文件、日志、引擎崩溃日志、硬件信息及烘焙数据等。可加入 .gitignore 。 |
Source | 存放代码文件。 |
编译类型
很多人在使用UE4的时候,往往只是依照默认的DevelopmentEditor,但实际上编译选项是非常重要的。
UE4本身包含网络模式和编辑器,这意味着你的工程在部署的时候将包含Server和Client,而在开发的时候,也将有Editor和Stand-alone之分;同时你也可以单独选择是否为Engine和Game生成调试信息,接着你还可以选择是否在游戏里内嵌控制台等。
所以为了我们的调试代码方便,我们选择DebugEditor来加载游戏项目,当需要最简化流程的时候用Debug来运行独立版本。
基础概念
和其他的3D引擎一样,UE4也有其特有的描述游戏世界的概念。在UE4中,几乎所有的对象都继承于UObject(跟Java,C#一样),UObject为它们提供了基础的垃圾回收,反射,元数据,序列化等,相应的,就有各种”UClass”的派生们定义了属性和行为的数据。
跟Unity(GameObject-Component)有些像的是,UE4也采用了组件式的架构,但细品起来却又有些不一样。在UE中,3D世界是由Actors构建起来的,而Actor又拥有各种Component,之后又有各种Controller可以控制Actor(Pawn)的行为。Unity中的Prefab,在UE4中变成了BlueprintClass,其实Class的概念确实更加贴近C++的底层一些。
Unity中,你可以为一个GameObject添加一个ScriptComponent,然后继承MonoBehaviour来编写游戏逻辑。在UE4中,你也可以为一个Actor添加一个蓝图或者C++ Component,然后实现它来直接组织逻辑。 UE4也支持各种插件。
- 一些常用的Unity概念和它在Unreal中的对应部分。
Actor和Component
UObject
藉着UObject提供的元数据、反射生成、GC垃圾回收、序列化、编辑器可见,Class Default Object等,UE可以构建一个Object运行的世界。(后续会有一个大长篇深挖UObject)
Actor
脱胎自Object的Actor也多了一些本事:Replication(网络复制),Spawn(生生死死),Tick(有了心跳)。
Actor无疑是UE中最重要的角色之一,组织庞大,最常见的有StaticMeshActor, CameraActor和 PlayerStartActor等。Actor之间还可以互相“嵌套”,拥有相对的“父子”关系。
思考:为何Actor不像GameObject一样自带Transform?
原文是这样说的:
我们知道,如果一个对象需要在3D世界中表示,那么它必然要携带一个Transform matrix来表示其位置。关键在于,在UE看来,Actor并不只是3D中的“表示”,一些不在世界里展示的“不可见对象”也可以是Actor,如AInfo(派生类AWorldSetting,AGameMode,AGameSession,APlayerState,AGameState等),AHUD,APlayerCameraManager等,代表了这个世界的某种信息、状态、规则。你可以把这些看作都是一个个默默工作的灵体Actor。所以,Actor的概念在UE里其实不是某种具象化的3D世界里的对象,而是世界里的种种元素,用更泛化抽象的概念来看,小到一个个地上的石头,大到整个世界的运行规则,都是Actor.
当然,你也可以说即使带着Transform,把坐标设置为原点,然后不可见不就行了?这样其实当然也是可以,不过可能因为UE跟贴近C++一些的缘故,所以设计哲学上就更偏向于C++的哲学“不为你不需要的东西付代价”。一个Transform再加上附带的逆矩阵之类的表示,内存占用上其实也是挺可观的。要知道UE可是会抠门到连bool变量都要写成uint bPending:1;位域来节省一个字节的内存的。
换一个角度讲,如果把带Transform也当成一个Actor的额外能力可以自由装卸的话,那其实也可以自圆其说。经过了UE的权衡和考虑,把Transform封装进了SceneComponent,当作RootComponent。但在权衡到使用的便利性的时候,大部分Actor其实是有Transform的,我们会经常获取设置它的坐标,如果总是得先获取一下SceneComponent,然后再调用相应接口的话,那也太繁琐了。所以UE也为了我们直接提供了一些便利性的Actor方法,如(Get/Set)ActorLocation等,其实内部都是转发到RootComponent。
简述:
Unity的强制绑定
每个GameObject必须携带Transform组件(不可移除),即使对象仅用于逻辑管理(如全局计时器)UE的灵活分离
Transform能力由
SceneComponent
提供,作为可选组件。若Actor无需位置信息(如 APlayerState ),可不附加任何组件;若需要位置,则附加SceneComponent 作为根组件RootComponent
设计优点:
性能优先: 避免为无需Transform的对象支付内存/计算代价(尤其在大规模场景中)。
类型安全: 通过组件分离明确标识对象能力(如带SceneComponent=有位置,反之=纯逻辑)。
C++文化影响: 遵循“最小代价”原则,类似bool用位域(uint bPending:1)节省内存
Component
UActorComponent
看见UActorComponent的U前缀,是不是想起了什么?没错,UActorComponent也是基础于UObject的一个子类,这意味着其实Component也是有UObject的那些通用功能的。(关于Actor和Component之间Tick的传递后续再细讨论)
SceneComponent
ActorComponent下面最重要的一个Component就非SceneComponent莫属了。SceneComponent提供了两大能力:一是Transform,二是SceneComponent的互相嵌套。
思考:为何ActorComponent不能互相嵌套?而在SceneComponent一级才提供嵌套?
思考:Actor的SceneComponent哲学
思考:Actor之间的父子关系是怎么确定的?
你应该已经注意到了Actor里面的TArray<AActor*> Children字段,所以你可能会期望看到Actor:AddChild之类的方法,很遗憾。在UE里,Actor之间的父子关系却是通过Component确定的。同一般的Parent:AddChild操作原语不同,UE里是通过Child:AttachToActor或Child:AttachToComponent来创建父子连接的。
ChildActorComponent
同作为最常用到的Component之一,ChildActorComponent担负着Actor之间互相组合的胶水。这货在蓝图里静态存在的时候其实并不真正的创建Actor,而是在之后Component实例化的时候才真正创建。
Level和World
引言
不同的游戏引擎们,看待这个过程的角度和理念也不一样。
- Cocos2dx
会认为游戏世界是由Scene组成的,Scene再由一个个Layer层叠表现,然后再有一个Director来导演整个游戏。
- Unity
觉得世界也是由Scene组成的,然后一个Application来扮演上帝来LoadLevel,后来换成了SceneManager。
- 其他的
有的会称为关卡(Level)或地图(map)等等。
- UE中
把这种拆分叫做关卡(Level),由一个或多个Level组成一个World。
不要觉得这种划分好像很随意,只是个名字不同而已。实际上一个游戏引擎的“世界观”关系到了一整串后续的内容组织,玩家的管理,世界的生成,变换和毁灭。游戏引擎内部的资源的加载释放也往往都是和这种划分(Level)绑定在一起的。
Level
ALevelScriptActor,允许我们在关卡里编写脚本
AWorldSettings
思考:为何AWorldSettings要放进在Actors[0]的位置?而ALevelScriptActor却不用?
思考:既然ALevelScriptActor也继承于AActor,为何关卡蓝图不设计能添加Component?
World
终于,到了把大陆们(Level)拼装起来的时候了。可以用SubLevel的方式:
也支持WorldComposition的方式自动把项目里的所有Level都组合起来,并设置摆放位置:
思考:为何要有主PersistentLevel?
思考:Levels们的Actors和World有直接关系吗?
思考:为什么要在Level里保存Actors,而不是把所有Map的Actors配置都生成在World一个总Actors里?
WorldContext,GameInstance,Engine
WorldContext
首先World就不是只有一种类型,比如编辑器本身就也是一个World,里面显示的游戏场景也是一个World,这两个World互相协作构成了我们的编辑体验。然后点播放的时候,引擎又可以生成新的类型World来让我们测试。简单来说,UE其实是一个平行宇宙世界观。
以下是一些世界类型:
1 | namespace EWorldType |
而UE用来管理和跟踪这些World的工具就是WorldContext:
思考:为何Level的切换信息不放在World里?
思考:为何World和Level的切换要放在下一帧再执行?
GameInstance
那么这些WorldContexts又是保存在哪里的呢?追根溯源:
GameInstance里会保存着当前的WorldConext和其他整个游戏的信息。明白了GameInstance是比World更高的层次之后,我们也就能明白为何那些独立于Level的逻辑或数据要在GameInstance中存储了。
这一点其实也很好理解,大凡游戏引擎都会有一个Game的概念,不管是叫Application还是Director,它都是玩家能直接接触到的最根源的操作类。而UE的GameInstance因为继承于UObject,所以就拥有了动态创建的能力,所以我们可以通过指定GameInstanceClass来让UE创建使用我们自定义的GameInstance子类。所以不论是C++还是BP,我们通常会继承于GameInstance,然后在里面编写应用于整个游戏范围的逻辑。
因为经常有初学者会问到:我的Level切换了,变量数据就丟了,我应该把那些数据放在哪?再清晰直白一点,GameInstance就是你不管Level怎么切换,还是会一直存在的那个对象!
类似于可持续化单例GameManager
Engine
Engine
此处UEngine分化出了两个子类:UGameEngine和UEditorEngine。众所周知,UE的编辑器也是UE用自己的引擎渲染出来的,采用的也是Slate那套UI框架。好处有很多,比如跨平台比较统一,UI框架可以复用一套控件库,Dogfood等等,此处不再细讲。所以本质上来说,UE的编辑器其实也是个游戏!我们是在编辑器这个游戏里面创造我们自己的另一个游戏。话虽如此,但比较编辑器和游戏还是有一定差别的,所以UE会在不同模式下根据编译环境而采用不同的具体Engine类,而在基类UEngine里通过一个WorldList保存了所有的World。
- Standalone Game:会使用UGameEngine来创建出唯一的一个GameWorld,因为也只有一个,所以为了方便起见,就直接保存了GameInstance指针。
- 而对于编辑器来说,EditorWorld其实只是用来预览,所以并不拥有OwningGameInstance,而PlayWorld里的OwningGameInstance才是间接保存了GameInstance.
目前来说,因为UE还不支持同时运行多个World(当前只能一个,但可以切换),所以GameInstance其实也是唯一的。提前说些题外话,虽然目前网络部分还没涉及到,但是当我们在Editor里进行MultiplePlayer的测试时,每一个Player Window里都是一个World。如果是DedicateServer模式,那DedicateServer也会是一个World。
GamePlayStatics
既然我们在引擎内部C++层次已经有了访问World操作Level的能力,那么在暴露出的蓝图系统里,UE为了我们的使用方便,也在Engine层次为我们提供了便利操作蓝图函数库。
1 | UCLASS () |
我们在蓝图里见到的GetPlayerController、SpawActor和OpenLevel等都是来至于这个类的接口。这个类比较简单,相当于一个C++的静态类,只为蓝图暴露提供了一些静态方法。在想借鉴或者是查询某个功能的实现时,此处往往会是一个入口。
Pawn
在上一篇的内容里,我们谈到了UE的3D游戏世界是由Object->Actor+Component->Level->World->WorldContext->GameInstance->Engine来逐渐层层构建而成的。那么从这下半章节开始,我们就将要开始逐一分析,UE是如何在每一个对象层次上表达游戏逻辑的。和分析对象节点树一样,我们也将采用自底向上的方法,从最原始简单的对象开始。
代码逻辑
其实所有的游戏引擎在构建完节点树之后,都会面临这么一个问题,我的游戏逻辑写在哪里?
有的原始的如Cocos2dx懒得想那么多,干脆就直接码在Node里面得了,所以你翻看Cocos2dx的源码你就会经常发现它的逻辑和表现往往是交杂在一起的,简单直接暴力美学,面向对象继承玩得溜。而面向组合阵营的领军Unity则干脆就把Component思想再应用极致一点,我的逻辑为什么不能也是一个组件?所以Unity里的ScriptComponent也是这种组合思想的体现,模型统一架构优雅,MonoBehavior立大功了!但是在一个Component(ScriptComponent)里去操作管理其他的Components,本身却其实并不是那么优雅,因为有些Component之上的协调管理的事务,从层次上来说,应该放在更高的一个概念上实现。UE在思考这个问题时,却是感觉有些理想主义,颇有些C++的理念,力求不为你不需要的东西付代价,宁愿有时候折衷,也想保住最优性能。UE的架构中也大量应用了各种继承,有些继承链也能拉得很长,同时一方面也吸纳了组合的优点,我们也能见到UE的源码中类的成员变量也是组合了好多其他对象。所以接下来的该介绍的就是UE综合应用这两种思想的设计产物。面向对象派生下来的Pawn和Character,支持组合的Controller们。
Pawn
能够被添加逻辑的Actor
思考:为何Actor也能接受Input事件?
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来让我们操纵。
Controller
如上文所述,UE从Actor中分化了一些专门可供玩家“控制”的Pawn,那我们这篇就专门来谈谈该怎么个控制法!
所谓的控制,本质指的就是我们游戏的业务逻辑。比如说玩家按A键,角色自动找一个最近的敌人并攻击,这个自动寻找目标并攻击的逻辑过程,就是我们所谈的控制。
Note1:重申一下,Controller特别是PlayerController,跟网络,AI和Input的关系都非常的紧密,目前都暂且不讨论,留待各自模块章节再叙述。
AController
AController是继承自AActor的一个子类
,问自己一个问题:如果我想实现一种机制去控制游戏里的Actor,该怎么设计?
巧妇难为无米之炊,咱们先来看看当前手上都有些什么:
- UObject,反射序列化等机制
- UActorComponent,功能的载体,一定程度的嵌套组装能力(SceneComponent)
- AActor,基础的游戏对象,Component的容器
- APawn,分化出来的AActor,物理表示和基本的移动能力,当前正翘首以待。
- 没了,在控制Actor这个层级,我们还暂时不需要去考虑Level等更高层次的对象
思考:Controller和Pawn必须1:1吗?
思考:为何Controller不能像Actor层级嵌套?
思考:Controller可以显示吗?
思考:Controller的位置有什么意义?
思考:哪些逻辑应该写在Controller中?
APlayerState
在游戏里,如果要评劳模,那Controller们无疑是最兢兢业业的,虽然有时候蛮横霸道了一些,但是经常工作在第一线,下面的Pawn们常常智商太低,上面的Level,GameMode们又有点高高在上,让他们直接管理数量繁多的Pawn们又有点太折腾,于是事无巨细的真正干那些脏活累活的还得靠Controller们。本文虽然没有在网络一块留太多笔墨,但是Controller也是同时作为联机环境中最重要的沟通渠道,身兼要职。
回顾总结一下本文要点,UE在Pawn这个层级演化构成了一个最基本和非常完善的Component-Actor-Pawn-Controller的结构:
PlayerController和AIController
APlayerController
思考:哪些逻辑应该放在PlayerController中?
AAIController
同PlayerController对比,少了Camera、Input、UPlayer关联,HUD显示,Voice、Level切换接口,但也增加了一些AI需要的组件:
- Navigation,用于智能根据导航寻路,其中我们常用的MoveTo接口就是做这件事情的。而在移动的过程中,因为少了玩家控制的来转向,所以多了一个SetFocus来控制当前的Pawn视角朝向哪个位置。
- AI组件,运行启动行为树,使用黑板数据,探索周围环境,以后如果有别的AI算法方法实现成组件,也应该在本组件内组合启动。
- Task系统,让AI去完成一些任务,也是实现GameplayAbilities系统的一个接口。目前简单来说GameplayAbilities是为Actor添加额外能力属性集合的一个模块,比如HP,MP等。其中的GamePlayEffect也是用来实现Buffer的工具。另外GamePlayTags也是用来给Actor添加标签标记来表明状态的一种机制。目前来说该两个模块似乎都是由Epic的Game Team在维护,所以完成度不是非常的高,用的时候也往往需要根据自己情况去重构调整
思考:哪些逻辑应该放在AIController中?
GameMode和GameState
GameMode
作用
既然勇敢的承担了游戏逻辑的职责,说他是AInfo家族里的扛把子也不为过,因此GameMode身为一场游戏的唯一逻辑操纵者身兼重任,在功能实现上有许多的接口,但主要可以分为以下几大块:
- Class登记,GameMode里登记了游戏里基本需要的类型信息,在需要的时候通过UClass的反射可以自动Spawn出相应的对象来添加进关卡中。前文说过的Controller的类型登记也是在此,GameMode就是比Controller更高一级的领导
- 游戏内实体的Spawn,不光登记,GameMode既然作为一场游戏的主要负责人,那么游戏的加载释放过程中涉及到的实体的产生,包括玩家Pawn和PlayerController,AIController也都是由GameMode负责。最主要的SpawnDefaultPawnFor、SpawnPlayerController、ShouldSpawnAtStartSpot这一系列函数都是在接管玩家实体的生成和释放,玩家进入该游戏的过程叫做Login(和服务器统一),也控制进来后在什么位置,等等这些实体管理的工作。GameMode也控制着本场游戏支持的玩家、旁观者和AI实体的数目。
- 游戏的进度,一个游戏支不支持暂停,怎么重启等这些涉及到游戏内状态的操作也都是GameMode的工作之一,SetPause、ResartPlayer等函数可以控制相应逻辑。
- Level的切换,或者说World的切换更加合适,GameMode也决定了刚进入一场游戏的时候是否应该开始播放开场动画(cinematic),也决定了当要切换到下一个关卡时是否要bUseSeamlessTravel,一旦开启后,你可以重载GameMode和PlayerController的GetSeamlessTravelActorList方法和GetSeamlessTravelActorList来指定哪些Actors不被释放而进入下一个World的Level。
- 多人游戏的步调同步,在多人游戏的时候,我们常常需要等所有加入的玩家连上之后,载入地图完毕后才能一起开始逻辑。因此UE提供了一个MatchState来指定一场游戏运行的状态,意义看名称也是不言自明的,就是用了一个状态机来标记开始和结束的状态,并触发各种回调。
思考:多个Level配置不同的GameMode时采用的是哪一个GameMode?
思考:Level迁移时GameMode是否保持一致?
思考:哪些逻辑应该写在GameMode里?哪些应该写在Level Blueprint里?
AGameModeBase 生命周期
AGameModeBase 在UE5中作为之前UE4的AGameMode的基类
所有 Game Mode 均为 AGameModeBase
的子类。而 AGameModeBase
包含大量可覆盖的基础功能。部分常见函数包括:
函数/事件 | 目的 |
---|---|
InitGame |
InitGame 事件在其他脚本之前调用(包括 PreInitializeComponents ),由 AGameModeBase 使用,初始化参数并生成其助手类。它在任意 Actor 运行 PreInitializeComponents 前调用(包括 Game Mode 实例自身)。 |
PreLogin |
接受或拒绝尝试加入服务器的玩家。如它将 ErrorMessage 设为一个非空字符串,会导致 Login 函数失败。PreLogin 在 Login 前调用,Login 调用前可能需要大量时间,加入的玩家需要下载游戏内容时尤其如此。 |
PostLogin |
成功登录后调用。这是首个在 PlayerController 上安全调用复制函数之处。OnPostLogin 可在蓝图中实现,以添加额外的逻辑。 |
HandleStartingNewPlayer |
在 PostLogin 后或无缝游历后调用,可在蓝图中覆盖,修改新玩家身上发生的事件。它将默认创建一个玩家 pawn。 |
RestartPlayer |
调用开始生成一个玩家 pawn。如需要指定 Pawn 生成的地点,还可使用 RestartPlayerAtPlayerStart 和 RestartPlayerAtTransform 函数。OnRestartPlayer 可在蓝图中实现,在此函数完成后添加逻辑。 |
SpawnDefaultPawnAtTransform |
这实际生成玩家 Pawn,可在蓝图中覆盖。 |
Logout |
玩家离开游戏或被摧毁时调用。可实现 OnLogout 执行蓝图逻辑。 |
GameState
UE5.6 文档是这么介绍的
Game State 负责启用客户端监控游戏状态。从概念上而言,Game State 应该管理所有已连接客户端已知的信息(特定于 Game Mode 但不特定于任何个体玩家)。它能够追踪游戏层面的属性,如已连接玩家的列表、夺旗游戏中的团队得分、开放世界游戏中已完成的任务,等等。
Game State 并非追踪玩家特有内容(如夺旗比赛中特定玩家为团队获得的分数)的最佳之处,因为它们由 Player State 更清晰地处理。整体而言,GameState 应该追踪游戏进程中变化的属性。这些属性与所有人皆相关,且所有人可见。Game mode 只存在于服务器上,而 Game State 存在于服务器上且会被复制到所有客户端,保持所有已连接机器的游戏进程更新。
AGameStateBase
AGameStateBase
是基础实现,其部分默认功能包括:
函数或变量 | 使用 |
---|---|
GetServerWorldTimeSeconds |
这是 UWorld 函数 GetTimeSeconds 的服务器版本,将在客户端和服务器上同步,因此该时间可用于复制,十分可靠。 |
PlayerArray |
这是所有 APlayerState 对象的阵列,对游戏中所有玩家执行操作时十分实用。 |
HasBegunPlay |
如 BeginPlay 函数在游戏中的 actor 上调用,则返回 true。 |
GameSession
是在网络联机游戏中针对Session使用的一个方便的管理类,并不存储数据,本文重点也不在网络,故不做过多解释,可暂时忽略,留待网络章节再讨论。在单机游戏中,也存在该类对象用来LoginPlayer,不过因为只是作为辅助类,那也可看作GameMode本身的功能,所以不做过多讨论。
UE5.6文档
虚幻引擎中的 Game Mode 和 Game State | 虚幻引擎 5.6 文档 | Epic Developer Community
俩者区别
这俩可以都理解为unity中的GameManager,主要体现的是服务器权威
特性 | GameMode | GameState |
---|---|---|
存在范围 | 仅服务器 | 服务器 + 所有客户端(同步) |
数据权限 | 可读写 | 服务器可写,客户端只读 |
核心职责 | 规则执行 | 状态同步(如分数、时间、玩家列表) |
网络复制 | 不复制 | 自动同步至客户端 |
Player
UPlayer
让我们假装自己是UE,开始编写Player类吧。为了利用上UObject的那些现有特性,所以肯定是得从UObject继承了。那能否是AActor呢?Actor是必须在World中才能存在的,而Player却是比World更高一级的对象。玩游戏的过程中,LevelWorld在不停的切换,但是玩家的模式却是脱离不变的。另外,Player也不需要被摆放在Level中,也不需要各种Component组装,所以从AActor继承并不合适。那还是保持简单吧:
ULocalPlayer
然后是本地玩家,从Player中派生下来LocalPlayer类。对本地环境中,一个本地玩家关联着输入,也一般需要关联着输出(无输出的玩家毕竟还是非常少见)。玩家对象的上层就是引擎了,所以会在GameInstance里保存有LocalPlayer列表。
思考:为何不在LocalPlayer里编写逻辑?
UNetConnection
非常耐人寻味的是,在UE里,一个网络连接也是个Player:
包含Socket的IpConnection也是玩家,甚至对于一些平台的特定实现如OculusNet的连接也可以当作玩家,因为对于玩家,只要能提供输入信号,就可以当作一个玩家。
追根溯源,UNetConnection的列表保存在UNetDriver,再到FWorldContext,最后也依然是UGameInstance,所以和LocalPlayer的列表一样,是在World上层的对象。
本篇先前瞻一下结构,对于网络部分不再细述。
GameInstance
可持续化单例GameManager
一人之下,万人之上
UE提供的方案是一以贯之的,为我们提供了一个GameInstance类。为了受益于UObject的反射创建能力,直接继承于UObject,这样就可以依据一个Class直接动态创建出来具体的GameInstance子类。
我并不想罗列所有的接口,UGameInstance里的接口大概有4类:
- 引擎的初始化加载,Init和ShutDown等(在引擎流程章节会详细叙述)
- Player的创建,如CreateLocalPlayer,GetLocalPlayers之类的。
- GameMode的重载修改,这是从4.14新增加进来改进,本来你只能为特定的某个Map配置好GameModeClass,但是现在GameInstance允许你重载它的PreloadContentForURL、CreateGameModeForURL和OverrideGameModeClass方法来hook改变这一流程。
- OnlineSession的管理,这部分逻辑跟网络的机制有关(到时候再详细介绍),目前可以简单理解为有一个网络会话的管理辅助控制类。
思考:GameInstance只有一个吗?
思考:哪些逻辑应该放在GameInstance?
SaveGame
得益于UObject的序列化机制,现在你只需要继承于USaveGame,并添加你想要的那些属性字段,然后这个结构就可以序列化保存下来的。
架构总结
通过对前九篇的介绍,至此我们已经了解了UE里的游戏世界组织方式和游戏业务逻辑的控制。行百里者半九十,前述的篇章里我们的目光往往专注在于特定一个类或者对象,一方面固然可以让内容更有针对性,但另一方面也有了身在山中不见山的困惑。本文作为GamePlay章节的最终章,就是要回顾我们之前探讨过的内容,以一个更高层总览的眼光,把之前的所有内容有机组织起来,思考整体的结构和数据及逻辑的流向。
游戏世界
如我们在最初篇所问的,如果让你来制作一款3D游戏引擎,你会怎么设计其结构?已经知道,在UE的眼里,游戏世界的万物皆Actor,Actor再通过Component组装功能。Actor又通过UChildActorComponent实现Actor之间的父子嵌套。(GamePlay架构(一)Actor和Component)
众多的各种Actor子类又组装成了Level(GamePlay架构(二)Level和World):
如此每一个Level就拥有了一座Actor的森林,你可以根据自己的需要定制化Level,比如有些Level是临时Loading场景,有些只是保存光照,有些只是一块静态场景。UE用Level这种细一些粒度的对象为你的想象力提供了极大的自由度,同时也能方便团队内的平行协作。
一个个的Level,又进一步组装成了World:
就像地球上的大陆板块一样,World允许多个Level静态的通过位置摆放在游戏世界中,也允许运行时动态的加载关卡。
而World之间的切换,UE用了一个WorldContext来保存切换的过程信息。玩家在切换PersistentLevel的时候,实际上就相当于切换了一个World。而再往上,就是整个游戏唯一的GameInstance,由Engine对象管理着。(GamePlay架构(三)WorldContext,GameInstance,Engine)
到了World这一层,整个游戏的渲染对象就齐全了。但是游戏引擎并不只是渲染,因此为了让玩家也各种方式接入World中开始游戏。GameInstance下不光保存着World,同时也存储着Player,有着LocalPlayer用于表示本地的玩家,也有NetConnection当作远端的连接。(GamePlay架构(八)Player):
玩家利用Player对象接入World之后,就可以开始控制Pawn和PlayerController的生成,有了附身的对象和摄像的眼睛。最后在Engine的Tick心跳脉搏驱动下开始一帧帧的逻辑更新和渲染。
数据和逻辑
说完了游戏世界的表现组成,那么对于一个GamePlay框架而言自然需要与其配套的业务逻辑架构。GamePlay架构的后半部分就自底向上的逐一分析了各个层次的逻辑载体,按照MVC的思想,我们可以把整个游戏的GamePlay分为三大部分:表现(View)、逻辑(Controller)、数据(Model)。一图胜千言:
(请点击看大图)
最左侧的是我们已经讨论过的游戏世界表现部分,从最最根源的UObject和Actor,一直到UGameEngine,不断的组合起来,形成丰富的游戏世界的各种对象。
- 从UObject派生下来的AActor,拥有了UObject的反射序列化网络同步等功能,同时又通过各种Component来组装不同组件。UE在AActor身上同时利用了继承和组合的各自优点,同时也规避了彼此的一些缺点,我不得不说,UE在这一方面度把握得非常的平衡优雅,既不像cocos2dx那样继承爆炸,也不像Unity那样走极端全部组件组合。
- AActor中一些需要逻辑控制的成员分化出了APawn。Pawn就像是棋盘上的棋子,或者是战场中的兵卒。有3个基本的功能:可被Controller控制、PhysicsCollision表示和MovementInput的基本响应接口。代表了基本的逻辑控制物理表示和行走功能。根据这3个功能的定制化不同,可以派生出不同功能的的DefaultPawn、SpectatorPawn和Character。(GamePlay架构(四)Pawn)
- AController是用来控制APawn的一个特殊的AActor。同属于AActor的设计,可以让Controller享受到AActor的基本福利,而和APawn分离又可以通过组合来提供更大的灵活性,把表示和逻辑分开,独立变化。(GamePlay架构(五)Controller)。而AController又根据用法和适用对象的不同,分化出了APlayerController来充当本地玩家的控制器,而AAIController就充当了NPC们的AI智能。(GamePlay架构(六)PlayerController和AIController)。而数据配套的就是APlayerState,可以充当AController的可网络复制的状态。
- 到了Level这一层,UE为我们提供了ALevelScriptActor(关卡蓝图)当作关卡静态性的逻辑载体。而对于一场游戏或世界的规则,UE提供的AGameMode就只是一个虚拟的逻辑载体,可以通过PersistentLevel上的AWorldSettings上的配置创建出我们具体的AGameMode子类。AGameMode同时也是负责在具体的Level中创建出其他的Pawn和PlayerController的负责人,在Level的切换的时候AGameMode也负责协调Actor的迁移。配套的数据对象是AGameState。(GamePlay架构(七)GameMode和GameState)
- World构建好了,该派玩家进来了。但游戏的方式多样,玩家的接入方式也多样。UE为了支持各种不同的玩家模式,抽象出了UPlayer实体来实际上控制游戏中的玩家PlayerController的生成数量和方式。(GamePlay架构(八)Player)
- 所有的表示和逻辑汇集到一起,形成了全局唯一的UGameInstance对象,代表着整个游戏的开始和结束。同时为了方便开发者进行玩家存档,提供了USaveGame进行全局的数据配套。(GamePlay架构(九)GameInstance)
UE为我们提供了这些GamePlay的对象,说多其实也不多,而且其实也是这么优雅有机的结合在一起。但是仍然会把一些朋友给迷惑住了,常常就会问哪些逻辑该写在哪里,哪些数据该放在哪里,这么多个对象,好像哪个都可以。比如Pawn,有些人就会说我就是直接在Pawn里写逻辑和数据,游戏也运行的好好的,也没什么不对。
如果你是一个已经对设计架构了然于心,也预见到了游戏未来发展变化,那么这么直接干也确实比较快速方便。但是这么做其实隐含了两个前提,一是这个Pawn的逻辑足够简单,把MVC的三者混合在一起依然不超过你的心智负担;二是已经断绝了逻辑和数据的分离,如果以后本地想复用一些逻辑创建另一个Pawn就会很麻烦,而且未来联机多玩家的状态复制也不支持。但说回来,人类的一个最常见的问题就是自大,对自己能力的过度自信,对未来变化的虚假掌控感。程序员在自己的编程世界里,呼风唤雨操作内存设备惯了,这种强大的掌控感非常容易地就外延到其他方面去了。你现在写的代码,过几个月后再回头看,是不是经常觉得非常糟糕?那奇怪了,当初写的时候怎么就感觉信心满满呢?所以踩坑多了的人就会自然的保守一些。另一方面,作为团队里的技术高手或老人,我个人觉得也有支持同行和提携后辈的责任,对自己而言只是多花一点点力气,却为别人树立一个清晰的程序结构典范,也传播了设计思想。程序员何苦为难程序员。
但还有一些人喜欢那么硬怼着干的原因要嘛是对未来的可预见性不足(经验不足),要嘛是对程序设计的基本原则不够了解(程序能力不够),比如最简单的“单一职责”。在新手期,面对着UE的程序世界,虽然在已经懂的人眼里就那么几个对象,但是在新手眼里,往往就感觉复杂无比,面对未知,我们本能的反应是逃避,往往就倾向于哪些看起来这么用能工作,就像玩游戏一样,形成了你的“专属套路”。跟穷人忙于工作而没力气提高自己是一个道理。相信我,所有的高手都是从小白过来的,我敢保证,他出生的时候脑袋也肯定是一片空白!区别是有些人后来不怕麻烦的勤能补拙,他努力的去理解这种设计模式的优劣,不局限于自己已经掌握的一片舒适区内,努力去设想未来的各种变化和应对之法,最终形成自己的独立思考。高手只是比新手懂得更多想得更多一些而已。
闲话说完。在分析UE这么一个GamePlay系统的时候,就像UML有各种图一样,我们也应该从各个切面去分析它的构成。这里有两大基本原则:单一职责和变化隔离,但也可以说只有一个。所有的程序设计模式都只是在抽象变化,把变化都抽离开了,剩下的不就是单一职责了嘛。所以UE里对MVC的实践其实也只是在不断抽离出各个对象的变化部分,把Pawn的逻辑抽出来是Controller,把数据抽出来是PlayerState。把World的Level静态逻辑抽出来是关卡蓝图,把动态的游戏玩法抽离出来是GameMode,把游戏数据抽离出来是GameState。具体的每个层次的数据和逻辑的关系前文已经一一详细说过了,此处就不再赘述了。但也再次着重探讨一些分析方法:
- 从竖直的角度来看,左侧是表示,中间是逻辑,右侧是数据。
- 当我们谈到表示的时候,脑袋里想的应该是一个单纯的展示对象,就像一个基本的网络物体,它可以带一些基本的动画,再多一些功能,也顶多只能像一个木偶,有着一些非常机械原始的行为。我们让他前进,他可以知道左腿右腿交替着迈,但他是无知觉的。所以左侧的那一串对象,你应该尽量得让他们保持简单。
- 实现中间的逻辑的时候,你应该专注于逻辑本身,尽量的忘记两旁的表示和数据。去思考哪些逻辑是表示固有的还是比较智能判断的。哪些Controller或Mode我们应该尽量的让它们通用,哪些就让它们特定的负责某一块,有些也不能强求,自己把握好度。
- 右侧的数据,同样的保持简单。我们把它们分离出来的目的就是为了独立变化和在网络间同步,注意一下别走回头路了就好。我们应该只在此放置纯数据。
- 从水平的切面上看,依次自底向上,记住一个原则,哪个层次的应该尽量只负责哪个层次的东西,不要对上层或下层的细节知道得太多,也尽量不要逾矩越权去指手画脚别的对象里的内务事。大家通力协作,注重隐私,保持安全距离,不就社会和谐了嘛。
- 最底层的Component,应该只是实现一些与游戏逻辑无关的功能。理解这个“无关”是关键。换个游戏,你这些Component依然可以用,就是所谓的游戏无关。
- Actor层,通过Pawn、Controller和PlayerState的合作,根据需要旗下再派生出特定的Character,或PlayerController,AIController,但它们的合作模式,三大家族的长老们已经定下了,后辈们应该尽量遵守。这一层,关键的地方在于分清楚哪些是操作Actor的,别向下把Actor内部的功能给抽了出来,也别大包大揽把整个游戏的玩法也管了过来。脑袋保持清醒,这一层所做的事,就是为了让Actor们显得更加的智能。换句话说,这些智能的Actor组合,理论上是可以在随便哪个Level里用的。
- Level和World层,分清楚静态的关卡蓝图和动态可组合GameMode。静态的意思是这个场景本身的运作机制,动态的指的是可以像切换比赛方式一样切换一场游戏的目的。在这一层上,你得有总览游戏大局的自觉了,咱们都是干大事的人,眼光就不要局限在那些一兵一卒那些小事了。制定好游戏规则,赋予这一场游戏以意义,是GameMode最重要的职责。注意两点,一是脑袋里有跟弦,一旦开始联机环境了,GameMode就升职到Server里去了,Client就没有了,所以千万要小心别在GameMode做些客户端的小事;二是GameState是表示一场游戏的数据的,而PlayerState是表示Controller的数据,对象和范围都不同,不能混了。
- GameInstance层,一般来说Player不需要你做太多事情,UE已经帮你处理好了。虽说力量越大,责任就越大,但领导日理万机累坏了也不行是吧。所以GameInstance作为全局的唯一逻辑对象,我们如果能不打扰他就尽量少把事推给他,否则你很快就会看着GameInstance里堆着一山东西。GameInstance身在高层,应该只尽量做一些Level之间的协调工作。而SaveGame也应该尽量只保存游戏持久的数据。
自始至终,回顾一下每个类的本身的职责,该是他的就是他的,别人的不要抢。读者朋友们,如果到此觉得似乎懂了一些,但还是觉得不够深刻理解的话,也没关系,凡事不能一蹴而就,在开发过程中多想多琢磨自然而然就会慢慢领悟了。
整体类图
从类的继承层次上,咱们再加深一下理解。下图只列出了GamePlay架构里一些相关的重要的类:
(请点击看大图)
由此也可以看出来,UE基于UObject的机制出发,构建出了纷繁复杂的游戏世界,几乎所有的重要的类都直接或间接的继承于UObject,都能充分利用到UObject的反射等功能,大大加强了整体框架的灵活度和表达能力。比如GamePlay中最常用到根据某个Class配置在运行时创建出特定的对象的行为就是利用了反射功能;而网络里的属性同步也是利用了UObject的网络同步RPC调用;一个Level想保存成uasset文件,或者USaveGame想存档,也都是利用了UObject的序列化;而利用了UObject的CDO(Class Default Object),在保存时候也大大节省了内存;这么多Actor对象能在编辑器里方便的编辑,也得益于UObject的属性编辑器集成;对象互相引用的从属关系有了UObject的垃圾回收之后我们就不用担心会释放问题了。想象一下如果一开始没有设计出UObject,那么这个GamePlay框架肯定是另一番模样了。
总结
对于GamePlay我们从构建游戏世界开始,再到一层层的逻辑控制,本篇也从各个切面上总结归纳了整体架构。希望读者们好好领会UE的GamePlay架构思想,别贪快,整体上慢慢琢磨以上的架构图,细节上可以回顾过往的单篇来了解。
对于这一套UE提供的GamePlay框架,我们既然选择了用UE引擎,那么自然就应该想着怎么充分利用好它。框架就是你如果在它的规则下办事,那它就是事半功倍的助力器,你会常常发现UE怎么连这个也帮你做完了;而如果你在不了解的情况下想逆着它行事,就常常感受到怎么哪里都受到束缚。我们对于框架的理念应该就像是对待一辆汽车一般,我们关心的是怎么驾驶它到达想要的目的地,而不是折腾着怪它四个轮子不能按照你的心意朝不同方向乱转。对比隔壁的Cocos2dx、或Unity、或CryEngine,UE能够提供这么一个完善的GamePlay框架,对我们开发者而言,是一件幸福的事,不是吗?
结束语
完结撒花!GamePlay大章节也终于结束了,最开始是本着怎么尽早尽大的能帮助到读者朋友们,所以选择了GamePlay作为起始章节。相信GamePlay也是开发者们日常开发过程中接触最多,也是有可能混淆最多,概念不清,很容易用错的一块主题。在介绍GamePlay的时候,更多的重点是在于介绍各对象的职责和关联,所以更多是用类图来描述结构,反而对源码进行剖析的机会不多,但读者们可以自己去阅读验证。希望GamePlay架构的一系列十篇文章能切实地帮助到你们。
Subsystems
专业术语
- Subsystems:指的是这整套“子系统”框架,包含了定义的类以及运作机制。
- SubsystemType/SubsystemClass:指向的是Subsystem的类型,比如TSubclassOf
。 - Subsystem对象:指的是真正创建生成实例化出来的Subsystem对象。
- UMyXXXSubsystem: 用户定义的类,我会以My为前缀来区分。
- 5类Outer对象:Subsystem对象依存属于的5类Outer对象。
Subsystems是什么?
一句话:Subsystems是一套可以定义自动实例化和释放的类的框架。这个框架允许你从5类里选择一个来定义子类(只能在C++定义):
- 自动实例化
这些的UMyXXXSubsystem类,会在合适的时机被创建出对象,然后在合适的时机释放,这个过程是自动化的。不需要自己手写创建代码。也不需要自己显式的定义变量,Subsystems已经定义好方便友好的访问接口了。
- 托管生命周期
根据你选择的父类不同,引擎会为创建出来的Subsystem实现出不同的生命周期。因此官方文档里会称这5个父类为5个不同的生命周期。根据你选择的生命周期不同,Initialize()和Deinitialize()会自动的在合适的时机被调用。一个Subystem类型也有可能根据需要被自动的被创建出多个实例。这些里面的繁琐逻辑自己都不用操心。