文/Jerish 专栏:https://zhuanlan.zhihu.com/c_164452593

相关阅读:《Exploring in UE4》移动组件详解[原理分析]
《Exploring in UE4》配置文件详解[原理分析]

目录

一.Level Streaming的使用与注意

1.流关卡的使用与注意

2.世界构成器 World Composition

二.地图切换流程分析

1.ClientTravel

2.ServerTravel

3.Browse

三.无缝地图切换

1.无缝切换流程

2.无缝切换时保存Actor

3.无缝切换时的一些问题与解决方法

一.Level Streaming的使用与注意

流关卡顾名思义,即关卡数据可以以数据流的形式加载到游戏中,这个过程就像加载其他的角色数据一样,非常平稳,对当前的关卡没有影响。具体的表现效果就是,当你在场景A中向场景B行走的时候,B场景会在你事先指定好的地点(或者其他条件)加载进来,而你感觉不到B场景的加载过程,好像原来B场景就存在一样,这样玩家就会觉得仿佛置身于一个大场景一样。

(注意:你可以把流关卡理解成一种“无缝加载”,但是这与UE里面官方文档里面的无缝加载并不是同一个东西,具体的差异在讲无缝加载时再分析)

1.1 流关卡的使用与注意

UE4官方文档关于流关卡的使用介绍的已经很详细,我这里只是就部分重要步骤做描述与讲解。

在UE4里面,每一个World里面至少有一个PersistentLevel以及0-N个StreamingLevels。在编辑器里面通过Windows—Levels窗口即可查看。在下面图1-1我们可以看到当前World里面只存在一个Persistentlevel,这个Persistentlevel就是当前我们打开的level。

093449g3mmkj4ja85cmmmg.jpg图1-1
093451xvk8kkv0vr5kz878.jpg图1-2
通过Levels窗口我们可以开始创建流关卡了,点击Levels按钮,可以创建新的level或者是添加已经存在的level。对于添加进来的流关卡,我们可以设置其是和persistentlevel一起永久加载(alwaysloaded)还是在自定义条件下再加载。如图1-3

093446d4picbdojirglgcj.jpg图1-3
控制流关卡的加载总体上来说有两种方式,一种是通过关卡流体积控制(LevelStreamingVolume),另一种是通过脚本(代码)逻辑控制。简单描述一下两个方法,第一种,LevelStreamingVolume相当于一个定制的触发器,当玩家摄像机(注意是玩家摄像机)进入LevelStreamingVolume体积内的时候,对应的流关卡就会加载(对应关系是通过下图操作设置的 点击levels旁边的按钮打开leveldetails窗口 inspectlevel找到需要加载的流关卡添加对应的LevelStreamingVolume)。第二种,就更随意了,你代码想怎么写就怎么写,简单的方式就是设一个触发器在玩家进入触发器的时候调用LoadStreamLevel,具体的教程的参考官方文档。

093453gk8g0cz31e9g1fio.jpg图1-4
093441g1o9f9q18av2qvmx.jpg图1-5
1.2 世界构成器 World Composition

流关卡给我们提供了一个大世界的解决方案,但是实际操作上由于每个level关卡都可能非常大,我们在编辑器里面一点点调整流关卡里面的Actor位置实在是过于麻烦,所以UE提供了世界构成器功能,简单来说就是帮你把N个关卡用拼图的形式拼接成一个大世界地图。

想使用这个功能,首先你需要在当前的WorldSetting里面勾选 Enable World Composition (这个有个小tips,如果当前你的world里面已经添加了子关卡,那么是无法开启该功能的)。当你开启该功能的时候他会弹出一个界面提示你将会把同一个文件夹内的所有level作为当前persistent level的流关卡,点击OK即可。

093452oonl0opotxypv8i7.jpg图1-6
这时候新加入的流关卡并没有激活,所以是灰色的。右键该地图选择Load选项,就会把NewMap加入当前的level里面,此时在编辑器里面你就可以看到子关卡的物件了。

093444alg8lxtta8anln10.jpg图1-7
这时候有一个问题,你发现无论怎么设置,运行游戏的时候NewMap都会和Persistent Level一同加载到当前的Level里面。这是为什么呢?因为世界构成器默认的加载逻辑就是当玩家距离要加载的关卡满足一定数值时就会加载对应的子关卡。而由于你刚把NewMap加载到当前的World里面,没有设置地图拼图,所以NewMap的默认位置就是当前世界的原点位置,满足默认距离50000(500米),所以一开始运行的时候就会加载进来。

所以接下来,我们要去设置地图拼图。设置完世界构成器之后你会发现level界面多了一个按钮,点击这个按钮就打开了世界构成器的界面。官方文档上长的是这样的(图1-8)

093447assilflbx9333cgg.jpg图1-8
093447z2fm63bsqf99bewc.jpg图1-9
然而当你满怀激动打开后却发现是这样的(如图1-9)???怎么啥都没有,我有场景的啊,这个箭头是什么意思?不要急,首先你可以先滑动鼠标滑轮,滑到最大。相比刚才,你会看到一个的框,没错,这个框就是当前地图的选择框。然后,你去Load刚才新加入的NewMap,这时候你会发现大不相同了(如图1-10)

093448sue6u2ehjl23hepg.jpg图1-10
093440s2qu0kq4n7m3vwuq.jpg图1-11
这个白栏框就是你的NewMap地图。前面说的那个箭头表示你当前摄像机所在的位置,可以看到当前的NewMap的尺寸是1638400*1638400,他的StreamingDistance是50000cm。现在我把NewMap的位置从中心向右侧移动一段距离(超过500米),点击运行游戏。会发现NewMap这回没有加载,然后控制角色向NewMap地图靠近。当满足条件时,NewMap被加载到当前关卡里面。(参考图1-11,1-12)

093452g8izgsi9y9yzsgu8.jpg图1-12 未加载NewMap时
093446a3v3tprlt93e7uxe.jpg图1-13 加载NewMap时(NewMap光照不同,光照有变化,说明NewMap加载了)
解决完上面的问题,新的问题又出现了,我的场景明明很小,为什么在这个WorldComposition界面里面这么大?答案就是因为你的level里面有巨大的天空盒(sky sphere),也就是拼图中的白色部分。所以你可能立刻想到去修改天空盒的大小(修改Scale),这个方法没什么问题。不过一般来说,天空盒是不需要调整的,所以这里有第二个办法,修改WorldComposition的相关属性。

093454s36h6ihjjcjb666x.jpg图1-14
当我们在WorldSettings里面勾选EnableWorldComposition的时候,引擎会帮助我们在World里面创建一个新的Actor,这个Actor的默认名字是LevelBounds(图1-14),通过去掉AutoUpdateBounds属性并重新设置Scale大小,就可以自定义level的大小了。

093444aw8xjsjp78ffqt4w.jpg图1-15
在使用拼图的功能时,我们还看到有一个图层功能,用来给各个关卡分类。点击“+”,可以创建新的图层,可以点自定义Streamingdistance。如果想把一个level添加到一个图层里面,需要右键这个level——AssignToLayer(图1-16)

093445w88qp2m8s4z9gh4z.jpg图1-16
093452qocunoonnkfhnsz4.jpg图1-17
最后提一点,关卡也可以添加Lod信息,但是需要Simplygon软件提供支持。

二.地图切换流程分析

上一章节从使用角度讲解了流关卡在UE里面的应用,虽然他看起来是一种“无缝地图”,但并不是UE官方所指的无缝,UE真正的无缝是指多人游戏时关卡切换客户端不断开与服务器的链接。而在讲解无缝地图切换前有必要先分析一下一般的地图切换流程,在官方文档——多人游戏中的关卡切换这一章节中,由于其讲解的不够详细可能对读者产生一些误导。

(这一章节会涉及到UE底层的一些代码逻辑,如果只是为了了解无缝链接的使用,可以有选择性的泛读一下)

首先,下面是关于地图切换相关类的类图,关键的函数也记录在了类里面。先对涉及到类有一个大致的印象,后面讲解的过程中也可以会头再看看这张类图。(关于WorldContext与World这些类之间的关系,建议先参考大钊先生的文章——《InsideUE4》GamePlay架构(二)Level和World 《InsideUE4》GamePlay架构(三)WorldContext,GameInstance,Engine 这里不会详细介绍。

093445n4717nx4ce0qplc6.jpg图2-1
关于地图切换,仔细分个类的话,无非就是下面几种情况:

1.客户端断开链接自行切换地图,服务器地图不变

2.客户端断开链接加入新的服务器地图,原服务器地图不变

3.服务器切换地图,客户端跟随服务器切换地图

4.客户端,服务器都断开链接,各自切换到自己的新地图

而这几种情况都是通过ClientTravel,ServerTravel,Browse等调用来实现的,下面从各个接口着手分析上面的几种情况。

2.1 ClientTravel

这里的ClientTravel不是专门指接口APlayerController::ClientTravel。而是指UEngine::SetClientTravel。

官方文档上这一点描述有问题,原文是:APlayerController::ClientTravel如果从客户端调用,则转移到新的服务器;如果从服务器调用,则要求特定客户端转移到新地图(但仍然连接到当前服务器)。

而实际上无论是客户端还是服务器调用这个接口,最后的效果都是一样的,都是通过RPC让客户端去调用 ClientTravelInternal_Implementation,让客户端转移到新的服务器的地图上。可以通过下面的方法测试:

1.新建一个第三人称模板的C++项目,在新创建的第三人称的Character里面添加一个BlueprintCallable函数如下。
UFUNCTION(BlueprintCallable, Category = “Level”)
void CharacterClientTavel(const FString URL, enum ETravelType TravelType, bool bSeamless = false);
void ALevelTestCharacter::CharacterClientTavel(const FString URL, ETravelType TravelType, bool bSeamless)
{
if (Controller Cast(Controller))
{
Cast(Controller)->ClientTravel(URL, TravelType, bSeamless);
}
}

2.场景里面放置一个TriggerVolume,然后在第三人称的Character添加一个Overlap事件。

093453n8e3w61syniij3wn.jpg图2-2
按照我的方法测试完之后,大家可以回头再看一下ClientTravel里面的参数。

关于ClientTravel里面的URL以及TravelType参数,其实都很有讲究。简单来说,这个地方可以填写路径,地图名称,IP地址,端口(前面加冒号)等信息。这些信息只要格式正确,就会被识别并放到各个成员变量里面(图2-3)

093453j20ti4ytfz7i8ucc.jpg图2-3
这里我只是简单的添加了一个地图名称,在运行的时候,执行端就会从本地文件夹里面搜索到这个地图并进行加载(并不一定会加载成功)。注意,如果他是一个纯客户端,在执行ClientTravel的时候URL只输入地图而不输入IP,而且TravelType是Relative,他就会加入本地默认的7777端口的服务器,并且服务器会在Welcome的消息里面返回正确的地图信息来纠正客户端。这样客户端可能就是重新加入了一次服务器。在这个过程中,客户端会清空NetDriver,重新生成PendingNetGame。通过TickWorldTravel 执行Browse来与服务器重新建立链接并重新打开地图(流程图见2-4)。如果他在URL里面输入了IP以及端口信息,那么他就会从当前服务器断开并Travel到目标地址的服务器上去,而这个就是ClientTravel负责完成的主要功能。想实现这个功能其实还有两个办法,一是就是在控制台命令里面输入 open 127.0.0.1:7777(假如服务器开在本地),你的客户端也会Travel到目标地址的服务器上。二是调用全局的static接口UGameplayStatics::OpenLevel。不论哪种方式,本质上都是调用引擎的UEngine::SetClientTravel(UPendingNetGame*…)函数。

(注:UE默认端口是7777,多开的服务器进程端口会在7777上面累加,想查看端口占用Windows打开cmd,输入netstat -an即可)

093449okuuo8i203kudu5x.jpg图2-4 ClientTravel流程图(建议结合类图理解)
093442voa4nkogkfziefgv.jpg图2-5 控制台Open命令调用堆栈
上面的流程中,我们提到TravelType需要设置为Relative,这是为什么?我们要知道TravelType表示地图切换的方式,是相对上次的URL切换,还是完全按照当前绝对的URL切换。在执行SetClientTravel的时候会把TravelType赋值给WorldContext 的TravelType 。而这个TravelType会影响URL的创建。如果TravelType是Relative,URL就会设置Protocol,Host为原来的URL里面的对应信息。如果TravelType是Absolute,Host IP端口等信息就完全按照传入的URL设置,可能就是空的(因为我们只传入了一个地图名称)。如果当前Travel的URL没有任何IP信息,引擎就会把这个URL当成本地全局的URL(也就是不受服务器控制),因此客户端就可以自行打开一个地图。这就会造成与上面ClientTravel执行结果完全不同。

不过说实话,这样的操作没什么意义,因为一旦客户端自行加载了一个地图,而服务器没有加载,那就是客户端自己去另一个地图玩了,自己当自己的服务器,断开与原来服务器的链接,NetDriver设置为空,服务器也失去了与客户端的链接管理。下面代码是URL初始化时候根据TravelType的不同而做出不同的操作。
 

  1. if( Type==TRAVEL_Relative )
  2. {
  3. check(Base);
  4. Protocol = Base->Protocol;
  5. Host = Base->Host;
  6. Map = Base->Map;
  7. Portal = Base->Portal;
  8. Port = Base->Port;
  9. }
  10. if( Type==TRAVEL_Relative Type==TRAVEL_Partial )
  11. {
  12. check(Base);
  13. for( int32 i=0; i<Base->Op.Num(); i++ )
  14. {
  15. new(Op)FString(Base->Op[i]);
  16. }
  17. }

 

图3-1无缝切换流程图
093450eq3ue8crs6frae0g.jpg图3-2客户端收到服务器通知执行无缝切换
3.2 无缝切换时保存Actor

前面提到无缝切换时会导致原来地图的Actor被删除,很多前后时候我们不想这样。UE默认会保存一些Actor,不过经过测试有一些与官方文档描述不符或者是理解上容易有歧义,我在下面标记了一下:

1.GameMode (服务器) (实际上默认GameMode并不会传递到新场景)

2.拥有一个有效的 PlayerState 的所有(服务器)(其实还包括PlayerState本身)

3.所有 PlayerControllers (服务器)需要保证你的GameMode继承自GameMode类而且两个地图的PlayerController类型相同才行

4.所有本地 PlayerControllers (服务器和客户端)

如果我们想额外的保存其他Actor有两个函数处理:

1.通过 AGameMode::GetSeamlessTravelActorList 额外添加的任何Actor(服务器)

2.通过 APlayerController::GetSeamlessTravelActorList (在本地PlayerControllers上调用)额外添加的任何Actor(非专有服务器与客户端)

(具体的保存了流程与细节参考函数FSeamlessTravelHandler::Tick)

3.3 无缝切换时的一些问题与解决方法

a.我们知道无缝切换会保持链接,那他是如何保持链接的呢?

答:无缝切换通过一个FSeamlessTravelHandler类Tick操作覆盖了原本的Browse操作,这个过程中不会直接释放地图资源,而是通过一定机制将Map数据通过拷贝进行转移,可以看到在函数FSeamlessTravelHandler::CopyWorldData里面会将当前的World的NetDriver赋值给要加载的World,从而保持了连接不断。当然Map里面数据非常多,迁移要考虑的非常周到,具体细节还要跟随代码仔细查看。

b.在服务器无缝切换到新场景后,新连入的客户端会先跳到原来服务器的场景,再加入到新的场景,这个怎么处理?

答:这是因为游戏运行过程中始终需要一个场景,因为在Game.ini文件里面配置了默认场景。客户端在一开始运行时会先打开默认的场景,然后发送连接到服务器的请求,服务器确认后才能加载新的地图。为了避免这个情况,可以给其设置一个默认的空场景,这个场景只显示加载的过场动画。

c.调试的时候,有时候发现与正常操作不一样?

答:调试的时候,如果中断时间过长可能导致链接超时关闭,所以要仔细阅读服务器日志信息多次测试后再下定结论。如果想控制连接关闭时间或者设置为一直保持连接,可以在Engine/Config文件夹下找到BaseEngine.ini配置文件在里面搜索关键字[/Script/OnlineSubsystemUtils.IpNetDriver](如下图3-3)

自定义ConnectionTimeOut连接断开时间 或者 设置bNoTimeOut为true,不断开连接。

093454chha6fcjf8xx8w9f.jpg图3-3 BaseEngine.ini
d.在APlayerController::GetSeamlessTravelActorList函数里面添加了当前控制的Actor,但是在无缝切换后并没有Travel过去??

答:我测试也是这样,跟了一下代码,发现官方文档描述的确实过于简单了,很多细节都没有交代。首先,你要确认你当前控制的Actor(后面称为MyCharacter)是添加在函数AGameMode::GetSeamlessTravelActorList里还是APlayerController::GetSeamlessTravelActorList里面,对于前者客户端与服务器都可以正常调试,但是对于后者,DedicateServer上是不会执行的。

这里我们假设你把MyCharacter放在了APlayerController的函数里面,当服务器先执行Travel的时候,你会发现他需要遍历一遍场景中所有存在的Actor,如果这个Actor被标记为可以Travel的,那么就会保存,否则就会调用RouteEndPlay将他删除。一旦服务器将这个Actor删除,那么作为执行同步的客户端也就会把他删除(细节可能更复杂一点,可以在SetPawn里面加一个断点调试看看),所以这个MyCharacter并不会Travel到另一个地图,而在Travel过后,GameMode发现当前Controller的Pawn不存在,就给你重新生成了一个默认的Pawn。

进一步来讲,不仅仅是MyCharacter,所有你添加在函数APlayerController::GetSeamlessTravelActorList的Actor都会出现这个问题。如果你的Actor是通过服务器同步过来的,那么这个Actor在Travel之后一定会从客户端上消失。如果这个Actor不是同步的(比如场景中的一些静态模型),那么在Travel之后,这些Actor也只是存在于客户端上面。

093454oddzdbncpenwdd9c.jpg图3-4 服务器清除MyCharacter调用堆栈
093444booodc0zjb1ddqb3.jpg图3-5 客户端接收消息清除MyCharacter调用堆栈
e.在AGameModeBase::GetSeamlessTravelActorList函数里面添加了当前控制的Actor,但是在无缝切换后还是并没有Travel过去??

答:是不是解决了上面的d问题,就可以正常的将MyCharacter传递过去呢?并不是,两个Level在切换的时候还会对Controller有特殊的处理,如果两个关卡的PlayerController的类型不同,就会在Travel的时候生成一个新地图的PlayerController并切换。一旦PlayerController切换,那么原来的PlayerController就会被删除,他所控制的MyCharacter也同样会被删除掉。这个逻辑在AGameMode::HandleSeamlessTravelPlayer处理(注意是GameMode,不是GameModeBase,两个类的逻辑有差异)。

093443m5pzmrnwpc52ppgf.jpg图3-6 无缝切换Controller的切换调用堆栈

Tips:

FPackageName::SearchForPackageOnDisk(FString(URL) + FPackageName::

GetMapPackageExtension(), MapFullName)

可以根据Map名称搜索到带相对路径的文件名字符串

const FString TargetWorldObjectName = FPackageName::GetLongPackageAssetName

(TargetWorldPackageName);

函数可以将带相对路径的文件名字符串转为Map名称

FPaths::GameDir() 游戏项目根目录

 

 


锐亚教育

锐亚教育,游戏开发论坛|游戏制作人|游戏策划|游戏开发|独立游戏|游戏产业|游戏研发|游戏运营| unity|unity3d|unity3d官网|unity3d 教程|金融帝国3|8k8k8k|mcafee8.5i|游戏蛮牛|蛮牛 unity|蛮牛