ARTICLE · 1182759
两个布尔值、八个状态:UE5 关卡流送源码级拆解
“2853 行”这是 UE5.8 里 LevelStreaming.cpp 的代码长度——一整条把几十 GB 的开放世界按需塞进 8GB 内存的系统。我把它从头翻到尾,发现真正的核心,只有两个布尔变量和一张 8 个状态的转换表。
是不是有点反直觉?一个号称"解决大世界加载"的系统,核心居然简单到这种程度。但恰恰是这两个 bool 加上一张状态机,撑起了从《堡垒之夜》到你们项目里每一张大地图的加载逻辑。今天咱们把这事讲透。
先说那两个 bool 是谁。打开 LevelStreaming.h,你会看到 ULevelStreaming 这个类里躺着两个位域:
// LevelStreaming.h — 核心只有两个布尔标志UPROPERTY(Category=LevelStreaming, BlueprintSetter=SetShouldBeVisible)uint8 bShouldBeVisible:1;UPROPERTY(Category=LevelStreaming, BlueprintSetter=SetShouldBeLoaded, BlueprintGetter=ShouldBeLoaded)uint8 bShouldBeLoaded:1;一个是"该不该加载到内存",一个是"该不该显示出来"。注意,这俩是两码事——加载不等于可见。一个子关卡完全可以加载进内存、Actor 全部生成好,但整个关卡被隐藏着不渲染,等玩家走近门口的那一瞬间再切到可见。这个"加载但不可见"的中间态,是整个系统里最容易被忽略、也最出性能问题的地方。
位域 :1 的意思是这俩变量各占 1 个 bit。ULevelStreaming 身上塞了几十个这样的单 bit 标志,用最小内存表达最多状态。咱们做游戏的对这种抠法应该不陌生。
有了这两个 bool,引擎每一帧要做的事就是一句话:拿"当前状态"和"这两个 bool 算出来的目标状态"做比较,不一样就推动状态迁移。这套状态机长这样:
// LevelStreaming.h — 8 个运行时状态enum classELevelStreamingState : uint8{ Removed, // 已移除 Unloaded, // 未加载 FailedToLoad, // 加载失败 Loading, // 加载中 LoadedNotVisible, // 已加载未显示(关键中间态) MakingVisible, // 正在显示 LoadedVisible, // 已加载且显示 MakingInvisible // 正在隐藏};目标状态则只有 4 个,单独一个枚举:
// LevelStreaming.h — 4 个目标状态enum classELevelStreamingTargetState : uint8{ Unloaded, // 目标:卸载 UnloadedAndRemoved,// 目标:卸载并移除对象本身 LoadedNotVisible, // 目标:加载但隐藏 LoadedVisible, // 目标:加载且显示};一个负责描述"现在在哪",一个负责描述"该去哪"。中间用 DetermineTargetState() 这个函数把两个 bool 翻译成目标状态。这段代码我完整读过,逻辑不复杂,但处处是 Epic 踩过坑后的取舍。挑最核心的一段看:
// LevelStreaming.cpp — LoadedNotVisible 状态下的决策case ELevelStreamingState::LoadedNotVisible:if (bIsRequestingUnloadAndRemoval || World->GetShouldForceUnloadStreamingLevels()) {return ELevelStreamingTargetState::Unloaded; }elseif (World->IsGameWorld() && !ShouldBeLoaded()) {return ELevelStreamingTargetState::Unloaded; }elseif (!IsDesiredLevelLoaded()) {return ELevelStreamingTargetState::LoadedNotVisible; }elseif (ShouldBeVisible()) {return ELevelStreamingTargetState::LoadedVisible; }else {return ELevelStreamingTargetState::LoadedNotVisible; }翻译成人话:一个已经加载但隐藏的关卡,如果引擎要求强卸、或者游戏里 ShouldBeLoaded 变成 false,就卸载;如果没真正加载完,就继续留在"已加载未显示";如果加载完了而且该显示,就切到"加载且显示";否则原地待着。
你发现没有,这里没有一个神秘的黑盒,就是几个 if-else。但顺序极其讲究——"强卸"的判断永远排最前,优先级最高。为什么?因为内存压力来的时候,管你什么可见性,先卸载再说。这层优先级顺序,才是这套状态机的精髓。
那 ShouldBeVisible 到底怎么算的?别看它是个 bool,背后有逻辑:
// LevelStreaming.cpp — 可见性不等于标志位本身boolULevelStreaming::ShouldBeVisible()const{if (GetWorld()->IsGameWorld()) {// 游戏/运行中:必须同时满足 该显示 且 该加载return bShouldBeVisible && ShouldBeLoaded(); }#if WITH_EDITORONLY_DATA// 编辑器:只看编辑器可见性return bShouldBeVisibleInEditor;#elsereturnfalse;#endif}有意思的地方在 bShouldBeVisible && ShouldBeLoaded() 这个与运算:一个关卡再想显示,只要不该加载,它就不可见。这俩标志被设计成"可见性依赖加载性"的层级关系,而不是两个互不相关的开关。很多人调 Level Streaming 时以为改 bShouldBeVisible 就完事,结果加载都加载不出来——根子就在这个 && 上。
状态机讲完了,那"加载"这个最耗时的动作,引擎是怎么做的?答案是异步加载。ULevelStreaming 在需要加载时调 LoadPackageAsync,而不是同步 LoadObject。看这段:
// LevelStreaming.cpp — 带优先级的异步加载int32 OutAsyncLoadPriority;if (!FMath::AddAndCheckForOverflow( LevelStreamingCVars::GLevelStreamingPriorityBias,GetPriority(), OutAsyncLoadPriority)){ OutAsyncLoadPriority = MAX_int32;}AsyncRequestIDs.Add(LoadPackageAsync( PackagePath, DesiredPackageName, FLoadPackageAsyncDelegate::CreateUObject(this, &ULevelStreaming::AsyncLevelLoadComplete), PackageFlags, PIEInstanceID, OutAsyncLoadPriority, InstancingContextPtr));两个细节值得拎出来。第一,加载优先级 = 关卡自己的 priority 加上一个全局 bias。这个 bias 就是 CVar LevelStreaming.LevelStreamingPriorityBias,默认 0。你可以在运行时把某个关卡的优先级顶上去,让它的包在异步加载队列里插队。第二,加载完成后走 AsyncLevelLoadComplete 回调,而不是阻塞游戏线程——这保证了玩家冲进新区域时,帧率不会因为一次同步加载直接砸到个位数。
顺带说三个控制异步加载时间片的 CVar,调试时很有用:
s.AsyncLoadingTimeLimit:每一帧游戏线程愿意花在异步加载收尾工作上的毫秒数,默认很小(个位数毫秒级)。s.PriorityAsyncLoadingExtraTime:高优先级加载额外多给的时间。s.UseBackgroundLevelStreaming:是否启用后台关卡流送。
这三兄弟决定了"加载到底要卡多少帧"。我们调大地图加载卡顿,十有八九就是在这里和 PSO 预编译、Actor 生成之间找平衡。
好,到这里单引擎的部分讲清楚了。但只讲 Level Streaming 不讲 World Partition,等于讲了一半。这俩经常被混为一谈,其实关系很明确:
关键一句话:World Partition 不是替代品,它是套在本套状态机上面的一层自动化外壳。它生成的每个 runtime cell,本质上还是一个 ULevelStreaming 的子类对象(WorldPartitionRuntimeLevelStreamingCell),照样走这 8 个状态。所以你花时间搞懂今天这套状态机,World Partition 那层你就已经看懂一半了——它只是把"该加载哪个"的判断,从你手动调用,换成了引擎用距离去算。
这一层才是真正的分水岭。传统 Level Streaming 里,决定加载什么的是你——美术摆好了 Streaming Volume,程序写好了触发逻辑。World Partition 里,这个决定权被收回了引擎。好处是省事,坏处是你丢了那点"精确控制"。所以 Epic 自己都在文档里说:小地图、线性流程、要精确控制,用传统 Level Streaming 反而更合适。
到这里,如果你只想掌握一句话,记住这个就够:关卡流送 = 两个 bool(该加载/该显示)驱动一张 8 状态机,加载动作走异步、带优先级,World Partition 只是套在它外面的自动化壳。
那动手层面,咱们给一段能直接跑的最小复现。C++ 里动态加载一个子关卡:
// 运行中动态加载并显示一个子关卡voidAMyLoader::LoadSubLevel(const FSoftObjectPath& LevelPath){// 转成关卡包名,这里用路径的关卡名 ULevelStreamingDynamic* Dyn = ULevelStreamingDynamic::LoadLevelInstance(this, LevelPath, FVector::ZeroVector, FRotator::ZeroRotator);if (Dyn) {// 先加载,再显示——顺序对应状态机里的两段迁移 Dyn->SetShouldBeLoaded(true); Dyn->SetShouldBeVisible(true); }}卸载就反过来,先把可见性关掉,让关卡回到"已加载未显示",再关加载让它走完卸载:
voidAMyLoader::UnloadSubLevel(ULevelStreamingDynamic* Dyn){if (!Dyn) return; Dyn->SetShouldBeVisible(false); // 先隐藏 Dyn->SetShouldBeLoaded(false); // 再卸载}顺序很重要。如果你先 SetShouldBeLoaded(false) 而不碰可见性,引擎会先经历 MakingInvisible 再走卸载——多绕一圈,还可能闪一帧黑。Blueprint 里对应的是 Load Stream Level / Unload Stream Level 两个节点,以及每个关卡自身的 bShouldBlockOnLoad(是否阻塞式加载)开关。赶时间想强切关卡,就在切之前设成阻塞式,代价是那几帧会卡。
延伸出去,这一套还能串起一大片知识:异步加载本身在 IoStore 时代被重写成基于 chunk 的按需读取(.utoc/.ucas 容器 + 完美哈希);加载优先级又和 UObject 的软引用、Asset Manager 的 Primary Asset 体系勾连;而"加载但隐藏"这个中间态,正是很多优化技巧(预加载、暖机、镜头切换缓冲)的落脚点。你想深入,这几条线随便拎一条都能再拆一篇。
那么问题来了。这套状态机干净利落,但它没解决一个更要命的问题:当数据源本身在运行时变了——比如一张大地图,玩家走到一半,某个子关卡的资源被热更替换掉了——引擎怎么知道"我手上这个已加载的关卡,和磁盘上最新的那个,是不是同一个"?
这已经不是加载不加载的问题了。这是缓存一致性问题,被硬塞进了游戏引擎的运行时。Level Streaming 用两个 bool 把"该不该加载"讲清楚了,但"加载的那个是不是对的",它没碰。这指向的下一个技术挑战,比今天这套状态机要棘手得多。
我们这些天天和加载卡顿、关卡切换黑屏搏斗的人,其实都在跟这套状态机打交道,只是平时隔着蓝图节点没看见它。把它看穿,很多玄学问题瞬间就落地了。
你是站传统 Level Streaming 的精确控制,还是 World Partition 的全自动?你们项目现在用哪套?评论区聊聊。
这个号只干一件事:把游戏引擎的硬核系统,用写代码的人看得懂的方式拆开讲。每周一篇源码级拆解,不整虚的。觉得这篇帮你省了翻源码的时间,点个"在看",关注不走丢。















































































