UE4流关卡与无缝地图切换总结

一.Level Streaming的使用与注意


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

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



1.流关卡的使用与注意

        UE4官方文档关于流关卡的使用介绍的已经很详细,我这里只是就部分重要步骤做描述与讲解。
        在UE4里面,每一个World里面至少有一个PersistentLevel以及0-N个StreamingLevels。在编辑器里面通过Windows—Levels窗口即可查看。在下面图1-1我们可以看到当前World里面只存在一个Persistentlevel,这个Persistentlevel就是当前我们打开的level。


 图1-1


图1-2


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


图1-3


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


图1-4
 


 图1-5


2.世界构成器 World Composition


        流关卡给我们提供了一个大世界的解决方案,但是实际操作上由于每个level关卡都可能非常大,我们在编辑器里面一点点调整流关卡里面的Actor位置实在是过于麻烦,所以UE提供了世界构成器功能,简单来说就是帮你把N个关卡用拼图的形式拼接成一个大世界地图。
想使用这个功能,首先你需要在当前的WorldSetting里面勾选 Enable World Composition (这个有个小tips,如果当前你的world里面已经添加了子关卡,那么是无法开启该功能的)。当你开启该功能的时候他会弹出一个界面提示你将会把同一个文件夹内的所有level作为当前persistent level的流关卡,点击OK即可。


 图1-6


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


图1-7


        这时候有一个问题,你发现无论怎么设置,运行游戏的时候NewMap都会和Persistent Level一同加载到当前的Level里面。这是为什么呢?因为世界构成器默认的加载逻辑就是当玩家距离要加载的关卡满足一定数值时就会加载对应的子关卡。而由于你刚把NewMap加载到当前的World里面,没有设置地图拼图,所以NewMap的默认位置就是当前世界的原点位置,满足默认距离50000(500米),所以一开始运行的时候就会加载进来。
        所以接下来,我们要去设置地图拼图。设置完世界构成器之后你会发现level界面多了一个按钮,点击这个按钮就打开了世界构成器的界面。官方文档上长的是这样的(图1-8)


图1-8

图1-9


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


图1-10

图1-11


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


图1-12 未加载NewMap时
 


图1-13 加载NewMap时(光照有变化,说明NewMap加载了)



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


                                   图1-14


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


图1-15


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


图1-16

图1-17


        最后提一点,关卡也可以添加Lod信息,但是需要Simplygon软件提供支持。

二.地图切换流程分析


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

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

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


图2-1


        关于地图切换,仔细分个类的话,无非就是下面几种情况:

        客户端断开链接自行切换地图,服务器地图不变
        客户端断开链接加入新的服务器地图,原服务器地图不变
        服务器切换地图,客户端跟随服务器切换地图
        客户端,服务器都断开链接,各自切换到自己的新地图
        而这几种情况都是通过ClientTravel,ServerTravel,Browse等调用来实现的,下面从各个接口着手分析上面的几种情况。

1.ClientTravel

        这里的ClientTravel不是专门指接口APlayerController::ClientTravel。而是指UEngine::SetClientTravel。
        官方文档上这一点描述有问题,原文是:APlayerController::ClientTravel如果从客户端调用,则转移到新的服务器;如果从服务器调用,则要求特定客户端转移到新地图(但仍然连接到当前服务器)。
        而实际上无论是客户端还是服务器调用这个接口,最后的效果都是一样的,都是通过RPC让客户端去调用
        ClientTravelInternal_Implementation,让客户端转移到新的服务器的地图上。可以通过下面的方法测试:

        新建一个第三人称模板的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);
    }
}

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


图2-2


        按照我的方法测试完之后,大家可以回头再看一下ClientTravel里面的参数。
        关于ClientTravel里面的URL以及TravelType参数,其实都很有讲究。URL的意义我在博客(UE4命令行参数解析)里面有讲解,简单来说,这个地方可以填写路径,地图名称,IP地址,端口(前面加冒号)等信息。这些信息只要格式正确,就会被识别并放到各个成员变量里面(图2-3)


图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即可)


 

图2-4 ClientTravel流程图(建议结合类图理解)

图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的不同而做出不同的操作。

   

    if( Type==TRAVEL_Relative )
    {
        check(Base);
        Protocol = Base->Protocol;
        Host     = Base->Host;
        Map      = Base->Map;
        Portal   = Base->Portal;
        Port     = Base->Port;
    }
    if( Type==TRAVEL_Relative || Type==TRAVEL_Partial )
    {
        check(Base);
        for( int32 i=0; i<Base->Op.Num(); i++ )
        {
            new(Op)FString(Base->Op[i]);
        }
    }


        说了这么多,总结一句,ClientTravel的主要目的就是将客户端从一个服务器迁移到另一个服务器(也可以重新加入当前的服务器)。这个过程一定是要断开链接的(关于无缝操作下个章节再去讲)。而官方文档的第二个作用在我这里一直无法得到解释,暂时认为他是有问题的。

2.ServerTravel


        讲解完了ClientTravel,ServerTravel也就相对容易一些了。UEngine::ServerTravel的主要功能就是让服务器去加载新的地图并且通知所有他连接下的客户端都跟着他进入到新的地图去(只能在客户端运行)。同样,ServerTravel也可以设置Relative还是Absolute,不过影响不大了,但是注意URL里面不要填写IP地址了,因为他的功能就是在本地切换地图,所以不需要添加IP地址相关信息(会崩溃)。服务器是首先需要自己加载地图,然后通知客户端执行SetClientTravel跟随服务器切换level,随后读取服务器发送的WelcomMessage消息并正确的加载响应的地图。执行完ServerTravel后,GameMode等所有Actor都应该是重新生成的,旧场景的对象会被在执行LoadMap时被垃圾回收掉。

        在编辑器里面,总有一些表现可能比较奇怪。比如,编辑器下执行ServerTravelURL里面只填写地图名称会发现执行后发现客户端会卡主。其实是因为下面的代码,编辑器的GIsClient属性为true(正常一个DedicateServer一定为false),导致服务器在LoadMap的时候不能正常初始化监听的NetDriver,因此客户端无法与服务器建立连接而一直处于Pending状态。表现上就是客户端角色卡主的效果。

// Listen for clients.
    if (Pending == NULL && (!GIsClient || URL.HasOption(TEXT("Listen"))))
    {
        if (!WorldContext.World()->Listen(URL))
        {
            UE_LOG(LogNet, Error, TEXT("LoadMap: failed to Listen(%s)"), *URL.ToString());
        }
    }


        另外,服务器初始化NetDriver必须通过UWorld::Listen。Listen函数在两个地方都会执行,一个是编辑器里面UGameInstance::StartPIEGameInstance。另一个是在服务器执行UEngine::LoadMap()的时候。
(ServerTravel里面的URL不能带有符号 “%” , “:” , “\” )

3.Browse


        这个函数前面没有重点描述,但其实每次调用ClientTravel以及ServerTravel的时候都一定会用到(前提是不勾选无缝切换)。官方文档给出描述是Browse就像是加载新地图的硬重置,一定会断开客户端与服务器的连接,导致非无缝的切换。因为这里面会重置客户端的NetDriver,创建UPendingNetGame并进行相关初始化。所以,只要我们没有勾选无缝切换地图的选项,就一定会执行该操作。

        最后再回头看一下前面说的几种切换地图的方式,应该就比较清晰了:

                1.客户端断开链接自行切换地图,服务器地图不变
                APlayerController::ClientTravel 未设置IP Absolute

                2.客户端断开链接加入新的服务器地图,原服务器地图不变
                APlayerController::ClientTravel 未设置IP(或者设置了IP) Relative

                3.服务器切换地图,客户端跟随服务器切换地图
                UWorld::ServerTravel 只设置地图信息

               4. 客户端,服务器都断开链接,各自切换到自己的新地图
                先执行情况1,再执行情况3

三.无缝地图切换

        说了这么多,终于讲到无缝切换了。根据上面的讲解,现在大家应该已经了解了无缝切换的真正含义了——在不断开连接的情况下切换地图(注意:相当于切换PersistentLevel,不是加载子关卡)。仔细分析一下,这个定义里面其实是包含隐含条件的,如果客户端想切换地图,那么肯定是服务器先切换的地图,否则客户端无法在一个与服务器不同地图且保持连接的情况下正常游戏。如果是客户端从一个服务器切换到另一个服务器,那就更不用说了,必须重新建立到新服务器的连接。

        所以无缝切换的正常情况只有一种:服务器切换地图,客户端与服务器在保持连接的情况下也跟着切换地图。(也就是上一章节的第三种切换地图的方式+保持连接不断)。无缝加载的使用情景类似于一个房间服务器,玩家们从A场景完成一项任务或者结束一次比赛后重新开始新的任务或比赛。而前面的流关卡更偏向与RPG式的大地图探索。

        首先我们先考虑一种不合理的情况来加深理解:直接调用ClientTravel。可以看到ClientTravel的定义void APlayerController::ClientTravel(const FString& URL, ETravelType TravelType, bool bSeamless, FGuid MapPackageGuid)。这里面有URL,TravelType以及是否无缝bSeamless。看起来参数很齐全啊,是不是直接调用就可以了呢?假如我们编辑器勾选Dedicate,URL为本地的一个新地图NewMap(当前是ThirdPersonExampleMap)勾选bSeamless,TravelType同时为Relative。这时候我们运行游戏并触发ClientTravel函数(参考第二章节的例子),你会发现客户端在不断开连接的情况下好像成功的进入了NewMap,但是有两个很严重的问题,第一个是服务器仍然是原地图,所以你的客户端里面的很多数据(物理数据等)都是不匹配的;第二个问题是,你的原地图的各种Actor很快就会被垃圾回收掉,然后你在新地图里什么也做不了了。

        第二个问题是正常的,因为在无缝连接的进行时没有处理的Actor就会被删除,稍后我们再讲解。但是第一个问题是致命的,你的客户端在保持连接的情况下进入了一个与服务器不一样的地图,那可想而知,玩起来肯定到处是Bug。我举这个例子的原因就是想说——不要直接调用ClientTravel!如果你理解客户端与服务器之间的关系,你就会明白,二者必须要保持一致,所以只在客户端去执行无缝Travel是不合法的操作。正确的方式应该让服务器去调用ServerTravel同时勾选GameMode里面的UseSeamlessTravel(如果在编辑器里面操作,你需要一个继承当前GameMode的蓝图并在这个蓝图里面修改UseSeamlessTravel属性)。另外,需要提示你的是,编辑器模式下不支持SeamlessTravel。启用无缝切换,需要通过 UGameMapsSettings::TransitionMap 属性配置一个过度地图。该属性默认为空,不配置的话就会默认为过渡地图创建一个空地图。

1.无缝切换流程


        无缝切换不会走Browse函数,自然也就不会断开连接,开启无缝后。整套切换流程有很大的变化,值得注意的是travel过程当中有一个过度地图TransitionMap ,所以先要把相关的Actor保存到TransitionMap ,再从TransitionMap 保存到目标场景中去。流程如下图3-1:


图3-1无缝切换流程图

图3-2客户端收到服务器通知执行无缝切换


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.无缝切换时的一些问题与解决方法


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,不断开连接。
如果想连接配置文件的使用细节与原理,可以参考我的另一篇博客 UE4 Config配置文件详解。


图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也只是存在于客户端上面。


图3-4 服务器清除MyCharacter调用堆栈

                                   图3-5 客户端接收消息清除MyCharacter调用堆栈


e.在AGameModeBase::GetSeamlessTravelActorList函数里面添加了当前控制的Actor,但是在无缝切换后还是并没有Travel过去??

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


图3-6 无缝切换Controller的切换调用堆栈


Tips:
FPackageName::SearchForPackageOnDisk(FString(URL) + FPackageName::
GetMapPackageExtension(), &MapFullName)
可以根据Map名称搜索到带相对路径的文件名字符串

const FString TargetWorldObjectName = FPackageName::GetLongPackageAssetName
(TargetWorldPackageName);
函数可以将带相对路径的文件名字符串转为Map名称

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

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
UE4中,关卡接缝是指在多关卡切换时,由于关卡加载的过程中可能会出现短暂的黑屏或者过渡的不畅,导致玩家感觉到关卡之间有一道明显的分界线或接缝。这个问题的解决方法有很多种。一种常用的方法是使用LevelStreaming来实现关卡的加载和切换。LevelStreaming是UE4中的一个原生功能,它可以将关卡切分成多个切片,并在合适的时机进行加载和卸载,从而实现无缝关卡切换效果。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [UE4关卡切换_详细讲解案例.doc](https://download.csdn.net/download/gaofei7439/12299406)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] - *2* [UE4地图关卡无缝地图)](https://blog.csdn.net/weixin_33711641/article/details/92723741)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] - *3* [UE4关卡无缝地图切换总结](https://blog.csdn.net/u012999985/article/details/78484511)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值