来源:World Partition HLOD 源码通读 + 构建日志与运行时 2D 调试图实测
引擎版本:UE 5.8
面向读者:UE 图形程序员
World Partition 的 HLOD 是一条很长的链路:从一条 commandlet 命令开始,中间经过流送生成、拓扑排序、隔离世界烘焙、六种几何体算法、材质 Bake,最后落到运行时两套 streaming grid 的显隐配合。网上大部分资料只讲配置面板,这篇把代码路径从头到尾串一遍,并把运行时那张 2D 调试图彻底读懂——它是排查 HLOD 问题最直接的工具。
目录
- 总览:一条命令背后发生了什么
- Phase 1 · SetupHLODActors:建立”谁代理谁”的账本
- Phase 2 · GetHLODWorkloads:拓扑排序与分布式切分
- Phase 3 · BuildHLODActors:在隔离世界里烘焙一个 Actor
- 六种 HLODBuilder 算法拆解
- Material Baking:像素是怎么被烤出来的
- 运行时:两套 Grid 如何配合
- 把运行时 2D 调试图读懂
- 排查手册:日志、开关、常见现象
- 一页速记
- 参考链接
一、总览:一条命令背后发生了什么
离线构建 HLOD 的入口是 WorldPartitionBuilderCommandlet:
UnrealEditor-Cmd.exe <Project>.uproject /Game/Maps/<YourMap> -run=WorldPartitionBuilderCommandlet -Builder=WorldPartitionHLODsBuilder -SetupHLODs -BuildHLODs -AllowCommandletRendering -Unattended-AllowCommandletRendering 不是可选项:材质 Bake 阶段要真的把源材质渲染进 RenderTarget,没有渲染设备整个流程会直接失败。
调用链:
UWorldPartitionBuilderCommandlet::Main
└─ RunBuilder(BuilderClass, MapPackageName) // 加载 World,读同名 .ini 配置
└─ UWorldPartitionBuilder::RunBuilder(World)
└─ Run(World, SCCHelper) // LoadDataLayers → 按 LoadingMode 分派
└─ UWorldPartitionHLODsBuilder::RunInternal(...) // HLOD 用 ELoadingMode::Custom
├─ SetupHLODActors() // -SetupHLODs
├─ BuildHLODActors() // -BuildHLODs / -RebuildHLODs
├─ DeleteHLODActors() // -DeleteHLODs
├─ SubmitHLODActors() // -FinalizeHLODs
└─ DumpStats() // -DumpStats
参数到步骤的映射在 WorldPartitionHLODsBuilder.cpp 的 ValidateParams() 里(UE 5.8 为 :278-308)。有个容易忽略的默认行为:
if (BuildOptions == EHLODBuildStep::None)
{
BuildOptions = EHLODBuildStep::HLOD_Setup | EHLODBuildStep::HLOD_Build;
}一个步骤参数都不给时,默认等价于 -SetupHLODs -BuildHLODs。所以只想 dump 统计却忘了写 -DumpStats,实际会跑一遍完整构建。
三个阶段的职责边界很清楚:
| 阶段 | 输入 | 输出 | 有没有几何体 |
|---|---|---|---|
| Setup | 世界里所有 HLOD-relevant Actor 的 ActorDesc | AWorldPartitionHLOD Actor 的元数据(源 Actor 列表、Bounds、RuntimeGrid、层级) | 没有 |
| Workload | HLOD Actor 之间的父子关系 | 保证子先于父的构建顺序,可切成 N 份 | — |
| Build | 逐个 HLOD Actor | 真正的 StaticMesh / ISMC / MaterialInstance | 有 |
Setup 阶段完全不碰几何体,只算”谁代理谁”;Build 阶段才把源 Actor 真正加载进来做合并。这个分离是分布式构建的前提。
二、Phase 1 · SetupHLODActors:建立”谁代理谁”的账本
2.0 先分清:UE 5.8 有两套 RuntimeHash
这是读这块代码最容易踩的坑。UWorldPartition::SetupHLODActors()(WorldPartitionStreamingGeneration.cpp:2701)只是转发,真正的实现有两份:
UWorldPartitionRuntimeSpatialHash(旧) | UWorldPartitionRuntimeHashSet(新) | |
|---|---|---|
| 实现文件 | RuntimeSpatialHash/RuntimeSpatialHashHLOD.cpp:451 | RuntimeHashSet/WorldPartitionRuntimeHashSetHLODGeneration.cpp:266 |
| 组织方式 | 固定金字塔网格(CellSize × 2^L) | 可配置的 RuntimePartition 列表 + HLODSetups |
| HLOD grid 命名 | HLOD{Level}_{CellSize}m_{LoadingRange}m | {MainPartition}:{HLODPartition},如 MainGrid:HLOD_0 |
| 2D 调试绘制 | WorldPartitionRuntimeSpatialHash.cpp 内 | 独立的 WorldPartitionRuntimeHashSetDebugDraw.cpp |
| 调试 CVar 前缀 | wp.Runtime.ShowRuntimeSpatialHash* | wp.Runtime.HashSet.ShowDebugDisplay* |
2.1 FHLODCreationContext:整个 Setup 的账本
struct FHLODCreationContext
{
TMap<FName, FWorldPartitionHandle> HLODActorDescs; // ① 账本
TArray<FWorldPartitionReference> ActorReferences; // ② 短生命周期引用
TMap<FName, TWeakObjectPtr<AWorldPartitionHLOD>> UnsavedHLODActors; // ③ 编辑器未保存的
};① HLODActorDescs 是核心。构建时遍历所有 ActorDescContainerInstance(每个 Content Bundle 一个),把世界里已经存在的所有 AWorldPartitionHLOD 以 Actor 的 FName 为 Key 收进来(WorldPartitionRuntimeHashSetHLODGeneration.cpp:284-292):
BaseContainerInstanceCollection->ForEachActorDescContainerInstance([&HLODCreationContext, &ContentBundleGuids, WorldPartition](const UActorDescContainerInstance* ActorDescContainerInstance)
{
ContentBundleGuids.Add(ActorDescContainerInstance->GetContentBundleGuid());
for (UActorDescContainerInstance::TConstIterator<AWorldPartitionHLOD> HLODIterator(ActorDescContainerInstance); HLODIterator; ++HLODIterator)
{
FWorldPartitionHandle HLODActorHandle(WorldPartition, HLODIterator->GetGuid());
HLODCreationContext.HLODActorDescs.Emplace(HLODIterator->GetActorName(), MoveTemp(HLODActorHandle));
}
});顺带一提,这段遍历同时把每个 container 的 ContentBundleGuid 收进 ContentBundleGuids,后面按 Content Bundle 分组要用。
增量更新的整个机制就建立在这个 Map 上:
CreateHLODActors对每个 Cell 算出”这里应该存在的 HLOD Actor 名字”后,先去账本里RemoveAndCopyValue()。找到 → 复用;找不到 → 新建。所有 Cell 处理完,账本里剩下的就是”世界里存在但已经不该存在”的过时 HLOD Actor,函数末尾(:664)统一删包。
② ActorReferences 防止本轮新建的 HLOD Actor 在 Cell 处理期间被 GC,每个 Cell 处理完立刻 .Empty()(:555)。
③ UnsavedHLODActors 是编辑器交互式构建专用,由 -ConsiderUnsavedHLODActors 控制。
2.2 主循环:逐层向上迭代
HLODLevel = 0,Context = 真实 Actor 的 StreamingGeneration Context
while (Context != null):
① GenerateRuntimePartitionsStreamingDescs(Context) → 按 (RuntimePartition, Cell) 分组
② for each Cell:
收集 ActorInstances → 构建 FHLODCreationParams → CreateHLODActors()
统计 NumNextLayerHLODActors(有 ParentHLODLayer 的)
保存 Dirty Package,释放 ActorReferences
③ if NumNextLayerHLODActors > 0:
把本轮产出的 HLOD Actor 包装成 FHLODStreamingGenerationContext
HLODLevel++,继续
else: 退出
清理账本残留 → DeletePackages
为什么层级能自动向上聚合:下一轮 Context 里每个 HLOD Actor 被包装成一个 FActorSetInstance,它的 RuntimeGrid 来自该 Actor 的 RuntimeGrid,Bounds 是 RuntimeBounds(= 原 Cell 内所有源 Actor 的 Bounds 并集)。高层 Partition 的 LoadingRange 更大 → 格子更大 → 相邻多个低层 Cell 的 HLOD Actor 自然落进同一个高层 Cell:
Level 0: 原始 Actor → Cell A(HLOD-0-A), Cell B(HLOD-0-B), Cell C(HLOD-0-C)
↓ 作为下一层的输入
Level 1: HLOD-0-A/B/C → 重新分配到更大的格子 → Cell X(HLOD-1-X,包含 A/B/C)
Level 2: ...
FHLODCreationParams 里两个字段最关键(:473-485):
HLODCreationParams.CellGuid = CellUniqueId.Guid; // 决定 Actor 名字的 Hash
HLODCreationParams.MinVisibleDistance = RuntimePartition->LoadingRange; // 一路传给 Builder 算 ScreenSizeMinVisibleDistance 会传到 Build 阶段,MeshSimplify、LandscapeHLOD 的 LOD 选择和纹理尺寸全靠它。改 HLOD Partition 的 LoadingRange 会连带改变生成网格的面数和贴图分辨率,不只是加载距离。
2.3 CreateHLODActors:分组、命名、复用
实现在 WorldPartitionHLODUtilities.cpp:193。
第一步,按 (HLODLayer, bIsCustomHLODActor) 分组(FPerHLODLayerKey)。有两个细节:
CustomHLODActor类型的 Layer 会被提升到父 Layer——这类 Actor 本身就是 HLOD 表现,所以它直接作为父层的源;没有父层就整个跳过(:213-225)。AddSourceActor()会递归展开引用链:GetReferences()递归加入,GetEditorReferences()平铺加入。所以一个 HLOD 的源 Actor 数量常常比 Cell 里的 Actor 数多。
第二步,算确定性 Hash(ComputeHLODActorUniqueHash,:153):
HLODActorHash = CityHash64WithSeed(HLODLayerName, ..., HLODActorHash);
HLODActorHash = CityHash64WithSeed(&CellGuid, sizeof(FGuid), HLODActorHash);
if (HLODLayer->GetHLODActorClass() != AWorldPartitionHLOD::StaticClass())
HLODActorHash = CityHash64WithSeed(HLODActorClassPathName, ...);
if (bCustomHLOD)
HLODActorHash = CityHash64WithSeed(TEXT("CustomHLOD"), ...);名字是 FString::Printf(TEXT("%s_%016" UINT64_x_FMT), *HLODLayer->GetName(), HLODActorHash),即 LayerName_XXXXXXXXXXXXXXXX。同样的输入永远得到同样的名字,这是增量构建可复用的基础。
有个 read-only CVar wp.Editor.HLOD.UseLegacy32BitNameHash(默认 false)可以退回 32 位 CRC 命名。
第三步,复用或新建,然后逐字段对比、只有变化才 MarkPackageDirty()。代码里用一个 DirtyReason 指针记录是哪个字段变了(:313-444),日志会打印出来——排查”为什么这个 HLOD 又被标脏了”直接看这行:
Marking existing HLOD actor "lodA/MainGrid_L1_X-3_Y-3" dirty, reason "SourceActors".
被检查的字段包括:SourceActors(先比数量,再比 GetSourceActorsHash)、RuntimeGrid、SpatiallyLoaded、HLODLevel、DoesRequireWarmup、ParentHLODLayer、ActorLabel、FolderPath、HLODBounds、MinVisibleDistance、Standalone。
一个反直觉的字段语义(:390):
UHLODLayer* ParentHLODLayer = bIsSpatiallyLoadedHLOD ? HLODLayer->GetParentLayer() : nullptr;
HLODActor->SetHLODLayer(ParentHLODLayer);HLODActor->GetHLODLayer() 返回的是父层,不是生成它的那一层。生成它的层要从 HLODActor->GetSourceActors()->GetHLODLayer() 拿。读代码时这两个很容易搞混。
2.4 RuntimeGrid 命名:767m 还是 768m?
旧的 SpatialHash 路径用 UHLODLayer::GetRuntimeGridName(HLODLayer.cpp:266):
FName UHLODLayer::GetRuntimeGridName(uint32 InLODLevel, int32 InCellSize, double InLoadingRange)
{
return *FString::Format(TEXT("HLOD{0}_{1}m_{2}m"),
{ InLODLevel, int32(InCellSize * 0.01f), int32(InLoadingRange * 0.01f) });
}于是会看到 HLOD0_256m_767m 这种名字,而调试图上标题写的是 768 m。不是配置错了,是两条路径的浮点精度不同:
- 名字:
InLoadingRange是double,double × 0.01f走双精度。0.01f的精确值是0.00999999977648…,76800 × 它 = 767.99998…,int32()截断 → 767。 - 调试显示(
WorldPartitionRuntimeSpatialHash.cpp:2434):GetLoadingRange()返回 float,float × float在单精度下把767.99998舍入成精确的768.0f,int32()→ 768。
顺带一个工程后果:这个名字会固化进 HLOD Actor 的 RuntimeGrid 字段。以后改 HLODLayer 的 CellSize/LoadingRange,grid 名字就变了,旧 HLOD Actor 会指向一个不存在的 grid → 必须重新 build。名字里带数字本来就是为了让配置变更能被发现。
HashSet 路径没有这个问题,它的 grid 名是纯粹的层名拼接(WorldPartitionRuntimeHashSetHLODGeneration.cpp:471):
return FName(*FString::Printf(TEXT("%s:%s"),
*MainRuntimePartition->Name.ToString(), *HLODRuntimePartition->Name.ToString()));这段之前还有两道提前返回:HLOD partition 找不到、或它是 URuntimePartitionPersistent 时都返回 NAME_None——HLOD 落在 persistent 分区上是不会有 runtime grid 的。
三、Phase 2 · GetHLODWorkloads:拓扑排序与分布式切分
入口 UWorldPartitionHLODsBuilder::GetHLODWorkloads(WorldPartitionHLODsBuilder.cpp:1255)。它解决一个硬约束:子 HLOD 必须先于父 HLOD 构建(父的源就是子的产物),且同一条依赖链必须落在同一个 builder 上。
HLODParenting: Map<ParentGuid → [ChildGuid...]> // 来自 FHLODActorDesc::GetChildHLODActors()
RecursiveAdd(GroupGuid, HLODGuid):
未访问过 → 标记 → Insert(HLODGuid, 0) // 插到头部:子在前,父在后
→ 递归处理它的所有 ChildHLODs
已访问过 → 把 HLODGroups[HLODGuid] 整组 Insert 到 GroupGuid 头部,并删掉原组
// 两条链共享子节点时合并成一组
HLODGroups.ValueSort(按组大小降序)
Round-Robin:Idx % NumWorkloads 分配给 N 个 builder
最后 check(ValidateWorkload(...)) 逐个校验:遍历工作列表,确认每个 HLOD Actor 的 ChildHLODActors 都已经在它之前出现过。
分布式构建(-DistributedBuild)就是把这 N 份 workload 写进 HLODBuildManifest.ini,分发到 N 台机器,各自跑 -BuildHLODs -BuilderIdx=k,最后一台机器做 -FinalizeHLODs 提交。
四、Phase 3 · BuildHLODActors:在隔离世界里烘焙一个 Actor
BuildHLODActors() 拿到排好序的 GUID 列表后逐个调 HLODActor->BuildHLOD(bForceBuild),落到 FWorldPartitionHLODUtilities::BuildHLOD(WorldPartitionHLODUtilities.cpp:831)。
4.1 隔离的 BuildHLODWorld
UPackage* BuildHLODPackage = NewObject<UPackage>(nullptr,
*MakeUniqueObjectName(nullptr, UPackage::StaticClass(), TEXT("/Temp/BuildHLODPackage")).ToString(), RF_Transient);
UWorld* BuildHLODWorld = UWorld::CreateWorld(EWorldType::Editor, /*bInformEngineOfWorld*/false,
TEXT("BuildHLODWorld"), BuildHLODPackage, /*bAddToRoot*/true);为什么要隔离世界:源 Actor 要被真正加载、渲染(材质 Bake 是真渲染),不能污染正在编辑的主世界;而且构建完必须能干净销毁。
一个容易忽略的细节是 RVT 卷需要镜像进去(:853-894)。地形等材质会采样 Runtime Virtual Texture,如果隔离世界里没有 ARuntimeVirtualTextureVolume,Bake 出来的颜色就是错的。做法是把源世界的 RVT Volume 临时 Rename 进一个 UActorContainer,整体 StaticDuplicateObjectEx 到 BuildHLODWorld,再把原件 Rename 回去。
对称地,ON_SCOPE_EXIT 里除了 DestroyWorld,还要把源世界的 RVT 组件 MarkRenderStateDirty()——因为共享的 URuntimeVirtualTexture 资产的 producer 槽位被临时代理占走了,不还回去源世界的 RVT 采样会一直是坏的,直到重载关卡或重编材质。
// 编辑器交互式构建时引擎不会在两个 Actor 之间 tick(commandlet 路径会 FakeEngineTick)。
// 连续处理多个 HLOD Actor 时,render scene 和它们预留的 buffer 会跨 Actor 累积,
// 最终耗尽 GPU 虚拟地址空间。这里主动 GC,保证下一个 Actor 开始前上一个 build world 被完全释放。
if (!IsRunningCommandlet())
{
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
}4.2 LoadSourceActors:四道等待
LoadSourceActors(:745)把源 Actor 流式加载进 BuildHLODWorld,然后必须把所有异步工作全部等干净,否则 Bake 出来的是半成品:
FAssetCompilingManager::Get().FinishAllCompilation(); // ① 资产编译(Nanite/DDC/纹理)
FAssetCompilingManager::Get().ProcessAsyncTasks(); // ② 延迟构造脚本
// ③ 渲染资源流送:
// 外层 StreamAllResources 负责发现待流送资产并发起 I/O;
// 内层 BlockTillAllRequestsFinished 把在途请求排干,且不调 UpdateResourceStreaming
// (那会重新发起请求,永远收敛不了)。内层结束后外层再检查一遍 tick 期间新注册的资产。
while (IStreamingManager::Get().StreamAllResources(0.1f) > 0)
{
do {
FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);
FTSTicker::GetCoreTicker().Tick(FApp::GetDeltaTime());
} while (IStreamingManager::Get().BlockTillAllRequestsFinished(0.1f, true) > 0);
}
// ④ 所有引用到的材质 Shader 编译完成——注意要沿 Parent 链一路 EnsureIsComplete,
// 不完整的 shader map 常常挂在父 MIC 上而不是叶子 MI 上
for (TActorIterator<AActor> It(TargetWorld); It; ++It) { /* ... EnsureIsComplete ... */ }
GShaderCompilingManager->FinishAllCompilation();另外,构建 HLOD Level > 0 时,开头会先 UPackage::WaitForAsyncFileWrites()——因为它的源 Actor 就是上一层刚写盘的 HLOD Actor。
4.3 收集源 Component
static TArray<UActorComponent*> GatherHLODRelevantComponents(UWorld* InWorld, const UWorldPartitionHLODSourceActors& InSourceActors)
{
for (TActorIterator<AActor> It(InWorld); It; ++It)
{
if (!InSourceActors.IsHLODRelevant(*It)) continue;
for (UActorComponent* Comp : It->GetHLODRelevantComponents())
{
// Component 可以返回代理组件,让 HLOD 用代理而不是自己
TArray<UActorComponent*> Proxies = Comp->GetHLODProxyComponents();
if (!Proxies.IsEmpty()) HLODRelevantComponents.Append(Proxies);
else HLODRelevantComponents.Add(Comp);
}
}
}GetHLODProxyComponents() 是一个很实用的扩展点:自定义组件可以在 HLOD 构建时用一个更简单的代理组件顶替自己,而不必去改 HLODBuilder。
4.4 脏检测:HLODRebuildPolicy
FHLODRebuildPolicyDataSet Old = bForceBuild ? FHLODRebuildPolicyDataSet() : HLODActor->GetHLODRebuildPolicyDataSet();
FHLODRebuildPolicyDataSet New = UHLODRebuildPolicy::ComputeDataForRebuildPolicies(HLODActor, HLODRelevantComponents, /*bInForComparison=*/true);
EHLODRebuildPolicyDecision Decision = HLODBuildEvaluatorDelegate.IsBound()
? HLODBuildEvaluatorDelegate.Execute(HLODActor, Old, New)
: UHLODRebuildPolicy::Evaluate(HLODActor, Old, New);
if (Decision != EHLODRebuildPolicyDecision::ApproveRebuild) return false;对源 Component 的几何/材质做哈希比对,一致就跳过。HLODBuildEvaluatorDelegate 允许项目接管这个决策——比如”美术只改了某个不影响远景的参数就别重建了”。
-ReportOnly 走的是 bTestOnly 分支:算到这里就返回 true,只报告”哪些需要重建”,不产资产。
4.5 分派到各个 Builder
UHLODBuilder::Build(HLODBuilder.cpp:324,返回 FHLODBuildResult)是总入口,做两件事:
// ① 先按 HLODBatchingPolicy 把要强制走 instancing 的组件摘出来
for (UActorComponent* SourceComponent : InHLODBuildContext.SourceComponents)
{
if (ShouldBatchComponent(SourceComponent)) // PrimitiveComponent->HLODBatchingPolicy == Instancing
ComponentsToBatch.Add(SourceComponent);
else
InputComponents.Add(SourceComponent);
}
// ② 剩下的按组件自报告的 custom builder 分组
for (UActorComponent* SourceComponent : InputComponents)
{
TSubclassOf<UHLODBuilder> HLODBuilderClass = SourceComponent->GetCustomHLODBuilderClass();
HLODBuildersForComponents.FindOrAdd(HLODBuilderClass).Add(SourceComponent);
}
for (const auto& Pair : HLODBuildersForComponents)
{
// 没有 custom builder 就用当前 builder(由 HLODLayer.LayerType 决定的那个)
const UHLODBuilder* HLODBuilder = Pair.Key ? Pair.Key->GetDefaultObject<UHLODBuilder>() : this;
BuildResult.HLODComponents.Append(HLODBuilder->Build(InHLODBuildContext, Pair.Value));
}
if (!ComponentsToBatch.IsEmpty())
BuildResult.HLODComponents.Append(BatchInstances(ComponentsToBatch));两个关键结论:
HLODBatchingPolicy::Instancing会绕过 Layer 配置。某个 Component 打了这个标记,不管这一层配的是 MeshMerge 还是 MeshApproximate,它都会被单独批成 ISMC。远景发现”某些物件没被合进代理网格”,先查这个。GetCustomHLODBuilderClass()是组件级的注册点。地形的ULandscapeHLODBuilder就是这么接进来的(LandscapeComponent.cpp:97),它不在GetHLODBuilderClass()的 switch 里。找不到地形 HLOD 走的哪条路,就是漏了这个。
4.6 后处理与收尾
Build 出来的 Component 会被统一处理:SetMobility(Static)、关碰撞、always-loaded 的 HLOD 打开 bRayTracingFarField、静态网格按 StaticMesh_LayerName 重命名。然后 GatherOutputStats() 统计 Nanite 三角面数、各通道纹理尺寸、Mesh/Texture 内存,写进 HLOD Actor 的 stat 表(-DumpStats 就是导出这些)。最后把新的 RebuildPolicyDataSet 存回去,供下次比对。
五、六种 HLODBuilder 算法拆解
所有 Builder 实现同一个接口:
TArray<UActorComponent*> Build(const FHLODBuildContext&, const TArray<UActorComponent*>& InSourceComponents) const;输入是加载进隔离世界的源 Component,输出是挂到 AWorldPartitionHLOD 上的新 Component。
1. Instancing —— UHLODBuilderInstancing
适用:大量重复物体(树、石头、栅栏)。最快,零几何损耗。
核心是 UHLODBuilder::BatchInstances()(HLODBuilder.cpp:161):
1. 源组件分成两类:UStaticMeshComponent(含 ISMC)/ UInstancedSkinnedMeshComponent
其余类型会被 Warning 列出来并丢弃 —— 日志里看到 "Excluding N unsupported components" 就是这里
2. 每个 SMC:
ISMComponentDescriptor->InitFrom(SMC, false)
→ 提取 StaticMesh* + 材质覆盖 + LOD 设置 + Nanite 状态
按 Descriptor->GetTypeHash() 分组(同 Mesh + 同材质 = 同组)
ISMComponentBatcher.Add(SMC, FilterFunc) // 把每个原始实例的 Transform 加入批次
3. 每组产出一个 UHLODInstancedStaticMeshComponent,InitComponent 写入所有 Transform
并记录 SourceComponentsToInstancesMap(运行时可反查某个实例来自哪个源组件)
引用了 private / transient / null 网格的组件会被跳过并打 Warning。可选的 FilterFunc 按 Bounds 的 Extent/Area/Volume 剔掉太小的实例。
结果:N 种 Mesh × M 个实例 → N 个 ISMC,运行时 GPU instancing 绘制。
2. MeshMerge —— UHLODBuilderMeshMerge
适用:中等密度、材质多样的建筑群,目标是砍 Draw Call。
if (UseSettings.MaterialSettings.TextureSizingType == TextureSizingType_AutomaticFromMeshDrawDistance)
UseSettings.MaterialSettings.MeshMinDrawDistance = InHLODBuildContext.MinVisibleDistance;
MeshMergeUtilities.MergeComponentsToStaticMesh(
SourcePrimitiveComponents, InHLODBuildContext.BuildWorld, UseSettings, HLODMaterial,
InHLODBuildContext.AssetsOuter->GetPackage(), InHLODBuildContext.AssetsBaseName,
Assets, MergedActorLocation, /*ViewDistance*/0.25f, /*bSilent*/false);内部流程:
1. 收集所有 StaticMesh 的 LOD0 三角形 → 合并成单一 FMeshDescription
(各 Mesh 的 Transform 烘进顶点位置)
2. 可选 bMergeMaterials:
- 把每个源材质渲染到 RenderTarget(Diffuse/Normal/Metallic/Roughness/...)
- 多张纹理打进一张 Atlas
- 生成参数化的 FlattenMaterial 实例
- UV 按 Atlas 重新布局
3. 输出:1 个合并 StaticMesh + 1 个 MaterialInstance → 1 个 StaticMeshComponent
参数影响:TextureSizingType_AutomaticFromMeshDrawDistance 会用 MinVisibleDistance 自动推纹理分辨率(越远越小);bMergeMaterials = false 跳过 Bake,多材质保留,DC 节省会小很多。
3. MeshSimplify —— UHLODBuilderMeshSimplify
适用:远景城市轮廓,大幅削面但保留外形。
和 MeshMerge 的关键区别是先算 ScreenSize 再传进去(HLODBuilderMeshSimplify.cpp:81-104):
static const float ScreenX = 1920, ScreenY = 1080;
static const float HalfFOVRad = FMath::DegreesToRadians(45.0f); // 注意:这是半 FOV
static const FMatrix ProjectionMatrix = FPerspectiveMatrix(HalfFOVRad, ScreenX, ScreenY, 0.01f);
float ScreenSizePercent = ComputeBoundsScreenSize(
FVector::ZeroVector,
GetComponentsBounds().SphereRadius,
FVector(0, 0, InHLODBuildContext.MinVisibleDistance), // 相机放在 MinVisibleDistance 外
ProjectionMatrix);
UseSettings.ScreenSize = FMath::RoundToInt(ScreenSizePercent * ScreenX);Proxy Mesh 内部(引擎 IMeshMergeModule):屏幕空间覆盖近似 → 生成全新的轮廓代理网格(面数由 ScreenSize 驱动)→ 对代理网格重新展 UV → Bake 材质。
与 MeshMerge 的本质区别:MeshMerge 是把原始三角形”拼在一起”(保留细节),MeshSimplify 是生成一个全新的近似代理网格(面数受控)。
4. MeshApproximate —— UHLODBuilderMeshApproximate
适用:超大范围远景(整个街区),需要极度简化并支持 Nanite 输出。
IGeometryProcessing_ApproximateActors* ApproxActorsAPI = GeomProcInterfaces.GetApproximateActorsImplementation();
IGeometryProcessing_ApproximateActors::FOptions Options = ApproxActorsAPI->ConstructOptions(UseSettings);
Options.bUsePackedMRS = true;
ApproxActorsAPI->ApproximateActors(Input, Options, Results);底层是 GeometryProcessing 模块(Geometry4 / MeshModelingToolset):
1. 体素化:所有源 Mesh 光栅化进 3D 体素场
2. 等值面提取:从体素场提取连续封闭网格 —— 内部被遮挡的几何体天然被消除
3. QEM 简化到目标面数(TargetTriCount / ScreenSize 驱动)
4. 自动 UV 展开
5. Material Baking:光线投射把源材质属性采样到新 UV 空间
bUsePackedMRS = true → Metallic/Roughness/Specular 打进一张纹理,省两个采样器
(对应生成材质上的 PackMetallic / PackSpecular / PackRoughness 静态开关)
6. 可选输出 Nanite StaticMesh
7. 资产路径:先写 NEWASSET_ 前缀的临时 Package,成功后 Rename 到正式路径
—— 这样旧资产可以安全移进 Transient 被 GC,避免名字冲突
与 MeshSimplify 的区别:MeshSimplify 是屏幕空间代理,保外形轮廓;MeshApproximate 是体积级近似,适合从各个角度都能看到的大型结构,且天然剔除内部几何。
5. CustomHLODActor —— UHLODBuilderCustomHLODActor
TArray<UActorComponent*> Build(...) const { return {}; }它不产出任何 Component。用途是:这个 Actor 本身就是 HLOD 表现(如 AWorldPartitionCustomHLOD,美术手工做的远景模型)。在 Setup 阶段这类 Actor 会被直接提升到父 Layer 作为输入源使用(见 2.3)。
6. LandscapeHLODBuilder —— ULandscapeHLODBuilder
注册方式:不在 GetHLODBuilderClass() 的 switch 里,而是 ULandscapeComponent::GetCustomHLODBuilderClass() 返回它,在 UHLODBuilder::Build() 的分派里生效。
1. 按 ALandscapeProxy 分组(一个 Proxy = 地形的一个流送块)
2. 选 LOD(ComputeRequiredLandscapeLOD):
AutomaticLOD:同样用硬编码的 1920×1080 / HalfFOV=45° 投影 + MinVisibleDistance
算出屏幕占比,再去 LandscapeProxy.GetLODScreenSizeArray() 里找匹配级别
SpecificLOD :用配置的固定 LOD
LowestDetailLOD:最低细节级别
3. 导出地形网格:LandscapeProxy->ExportToRawMesh(ExportParams, MeshDescription)
按 LOD 降采样高度图 → 四边形网格
Nanite 地形额外加 Skirt(裙边)防止块与块之间漏缝,深度取目标 LOD 下一个格子的世界尺寸
4. Bake 地形材质(不走 MeshMerge 的 Bake 管线):
- 贴图尺寸:AutomaticSize 时用 ComputeRequiredTexelDensityFromDrawDistance 从
MinVisibleDistance 反推,再夹到 [16, ULandscapeSettings::GetHLODMaxTextureSize(), GetMax2DTextureDimension()]
- FMaterialUtilities::ExportLandscapeMaterial() 生成 FFlattenMaterial(专门处理权重图/图层混合)
- OptimizeFlattenMaterial() → CreateFlattenMaterialInstance()
- 生成的纹理强制 AddressX/Y = TA_Clamp,避免地形块边缘 wrap 出接缝
5. 输出组件类型:
Nanite 开 → UStaticMeshComponent(Nanite 自己处理 LOD)
Nanite 关 → ULandscapeMeshProxyComponent
InitializeForLandscape(Proxy, LandscapeLOD)
内部存邻接地形块的混合信息,运行时用来平滑过渡
6. UStaticMesh::BatchBuild(StaticMeshes) 并行编译
❗ 地形的 flatten 材质(
GEngine->DefaultLandscapeFlattenMaterial)烘出来的法线是世界空间的。单层 HLOD 没问题,但当这个地形 HLOD 又作为下一层 HLOD 的源、被 MeshMerge/MeshApproximate 再 bake 一次时,下一层的 bake 管线默认按切线空间理解法线,结果就是远景地形光照发飘。如果你也遇到”HLOD1 之后地形法线不对”,这是第一个该查的地方。
各 Builder 对比
| Builder | 几何体变化 | 材质处理 | 输出 Component | 适用距离 |
|---|---|---|---|---|
| Instancing | 不变(原始 Mesh) | 不变 | ISMC | 近 → 中 |
| MeshMerge | 原始三角形合并 | Bake → Atlas | SMC(合并 Mesh) | 中 |
| MeshSimplify | 屏幕空间代理 Mesh | Bake → Atlas | SMC(代理 Mesh) | 中 → 远 |
| MeshApproximate | 体素化近似 Mesh | 光线投射 Bake + MRS 打包 | SMC(体素 Mesh) | 远 |
| CustomHLODActor | 无(Actor 即 HLOD) | 无 | 无 | 任意 |
| LandscapeHLOD | 高度图降采样 Mesh | ExportLandscapeMaterial | SMC / LandscapeMeshProxy | 按 LOD |
六、Material Baking:像素是怎么被烤出来的
这是 HLOD 里最”图形”的一段,也是耗时和显存问题的重灾区。
6.1 通用路径
FMeshMergeUtilities::MergeComponentsToStaticMesh()
└─ CreateMergedMaterial()
├─ 遍历每个 (Mesh, Material) 对,组装 FMeshData / FMaterialData
│ bUseMeshData = bGloballyRemapUVs
│ || (bUseVertexDataForBakingMaterial && (bRequiresUniqueUVs || DoesMaterialUsesVertexData))
│ → 决定是"按真实网格烘"还是"按一个四边形烘"
├─ 用 CompareMaterialData / CompareMeshData 去重,相同配置复用同一次 bake
└─ IMaterialBakingModule::BakeMaterials(MaterialSettings, MeshSettings, BakeOutputs)
BakeMaterials 本身是个流水线:
// 提前把 render item 准备好,避免卡住 game thread
LaunchAsyncPrepareRenderItems(PipelineDepth);
// 一次性建好所有 material proxy,让 shader 异步开编
CreateMaterialProxies();
for (每个材质)
{
TMap<FRenderItemKey, FMeshMaterialRenderItem*>* RenderItems = GetRenderItems(Index);
for (const auto& [Property, Size] : CurrentMaterialSettings.PropertySizes)
{
FExportMaterialProxy* Proxy = CreateMaterialProxy(&CurrentMaterialSettings, Property);
if (!Proxy->IsCompilationFinished()) Proxy->FinishCompilation();
UTextureRenderTarget2D* RT = GetRenderTarget(Property, Size, CurrentMaterialSettings);
FMeshMaterialRenderItem* RenderItem = RenderItems->FindChecked(FRenderItemKey(CurrentMeshSettings, Size));
BakeMaterialProperty(CurrentMaterialSettings, Property, RenderItem, RT, Proxy, CurrentOutput);
}
// RenderItem 必须在 render thread 上销毁
ENQUEUE_RENDER_COMMAND(DestroyRenderItems)(...);
}FMeshMaterialRenderItem::GenerateRenderData() 决定烘焙的”画布”:
if (MeshSettings->MeshDescription) PopulateWithMeshData(); // 用真实网格在 UV 空间铺开
else PopulateWithQuadData(); // 简单矩形也就是说,材质用到顶点数据(顶点色、WPO 相关的顶点属性)时,必须走 PopulateWithMeshData,否则烘出来的是错的——这就是上面那个 bUseMeshData 判断的意义。
6.2 FlattenMaterial → MaterialInstance
FMaterialUtilities::CreateFlattenMaterialInstance() 把烘焙结果落成参数:
// 非常数 → 存成贴图;常数 → 存成向量参数,省一张贴图
auto SetTextureParamConstVector = [&](EFlattenMaterialProperties FlattenProperty)
{
if (FlattenMaterial.DoesPropertyContainData(FlattenProperty) && !FlattenMaterial.IsPropertyConstant(FlattenProperty))
{
SetTextureParam(FlattenProperty);
}
else
{
const FName ConstName(GetMatchingParamName(FlattenProperty, OutMaterial) + TEXT("Const"));
OutMaterial->SetVectorParameterValueEditorOnly(ConstName, FlattenMaterial.GetPropertySamples(FlattenProperty)[0]);
}
};MRS 打包(bPackTextures)是逐通道位运算塞进一张图:
for (int32 SampleIndex = 0; SampleIndex < NumSamples; ++SampleIndex)
{
MergedTexture[SampleIndex].DWColor() |=
(FColor::Black.DWColor() + ((PropertySamples[SampleIndex].DWColor() & ColorMask) >> Shift[PropertyIndex]));
}
UTexture2D* PackedTexture = CreateTextureFromDefault(PackedTextureName, AssetBasePath + TEXT("T_") + AssetBaseName + TEXT("_MRS"), PackedSize, MergedTexture);看到 T_xxx_MRS 这种贴图就是它。三张单通道图合成一张,采样器从 3 个变 1 个。
6.3 地形专用路径:真的去渲染一遍场景
地形不走上面的管线,走 FMaterialUtilities::ExportLandscapeMaterial():
const FMatrix::FReal ZOffset = UE_OLD_WORLD_MAX;
FMatrix ProjectionMatrix = FReversedZOrthoMatrix(LandscapeExtent.X, LandscapeExtent.Y, 0.5f / ZOffset, ZOffset);
RenderSceneToTextures(Scene, ViewOrigin, ViewRotationMatrix, ProjectionMatrix, ShowOnlyPrimitives, HiddenPrimitives, OutFlattenMaterial);正交俯视投影 + 只显示这个地形 Proxy,然后对每个属性渲染一遍:
// 每个属性对应一个 buffer visualization 模式和一个色彩空间
SupportedProperties.Add(EFlattenMaterialProperties::Diffuse) =
TPair<FName, FCapturePropertyColorSpace>(FName("BaseColor"), GammaSpaceCapture);
// ... Normal / Metallic / Roughness / Specular 各自的 VisModeName单次渲染的关键设置:
RenderTargetTexture->TargetGamma = CaptureColorSpace.TargetGamma;
ViewFamily.EngineShowFlags.SetPostProcessing(true);
ViewFamily.EngineShowFlags.SetVisualizeBuffer(true); // 用 buffer visualization 取出单个 GBuffer 通道
ViewFamily.EngineShowFlags.SetTonemapper(false); // 不要 tonemap
ViewFamily.EngineShowFlags.SetScreenPercentage(false);
ViewFamily.EngineShowFlags.SetMegaLights(false);
FCanvas Canvas(RenderTargetResource, NULL, FGameTime::GetTimeSinceAppStart(), Scene->GetFeatureLevel());
Canvas.Clear(FLinearColor::Transparent);
PerformSceneRender(Canvas, ViewFamily, *NewView, bPerformWarmpup);
FReadSurfaceDataFlags ReadSurfaceDataFlags;
ReadSurfaceDataFlags.SetLinearToGamma(CaptureColorSpace.bEncodeSRGBOnReadback);
RenderTargetResource->ReadPixelsPtr(OutSamples.GetData(), ReadSurfaceDataFlags, FIntRect(0, 0, TargetSize.X, TargetSize.Y));Warmup 帧是这里最实用的一个细节:
static void PerformSceneRender(FCanvas& Canvas, FSceneViewFamily& ViewFamily, FSceneView& View, bool bWithWarmup)
{
if (bWithWarmup)
{
for (int32 i = 0; i < CVarMaterialUtilitiesWarmupFrames.GetValueOnGameThread(); i++)
{
// 预热 Virtual Texture 时需要递增 frame index,抖动 VT feedback 采样位置
View.OverrideFrameIndexValue = i;
GetRendererModule().BeginRenderingViewFamily(&Canvas, &ViewFamily);
}
}
// 最终捕获用确定的 frame index 0,保证结果可复现
View.OverrideFrameIndexValue = 0;
GetRendererModule().BeginRenderingViewFamily(&Canvas, &ViewFamily);
}VT / RVT 的 feedback 是延迟若干帧的,第一帧渲染时 VT 页还没请求回来,直接读回会得到低 mip 或空白。所以要先跑若干预热帧、每帧抖动 feedback 位置把页面请求回来,再用固定 frame index 做最终捕获(保证确定性)。
地形 HLOD 贴图糊 / 局部有块状低分辨率区域,先把 warmup 帧数调大。
七、运行时:两套 Grid 如何配合
Build 完之后,世界里会多出若干个 streaming grid——每个 HLOD 层一个:
距离玩家 0 ─────── 256 m ─────── 512 m ──────►
[MainGrid : 真 actor]
[HLOD_0 : HLOD 代理 ──────────────]
↑ 重叠区 ↑ 只有 HLOD
三条规则:
① HLOD grid 的 LoadingRange 必须大于它所代理的那一层。 否则真 Actor 卸载后没有代理接管,远景直接空掉。
② 重叠区靠显隐互斥。 UWorldPartitionHLODRuntimeSubsystem 监听 cell 的显隐:
void UWorldPartitionHLODRuntimeSubsystem::OnCellShown(const UWorldPartitionRuntimeCell* InCell)
{
// 源 cell 变可见 → 把对应的 HLOD 隐藏
HLODObject->SetVisibility(false);
}
void UWorldPartitionHLODRuntimeSubsystem::OnCellHidden(const UWorldPartitionRuntimeCell* InCell)
{
HLODObject->SetVisibility(UWorldPartitionHLODRuntimeSubsystem::WorldPartitionHLODEnabled);
}所以”HLOD cell 状态是 Loaded Visible,但画面上看不到 HLOD 网格”是完全正常的——它被源 cell 压住了。判断远景 pop 要看的是两个 grid 的状态配合,不是单看某一个。
③ HLOD grid 默认只在客户端加载。
SpatialHash 路径(RuntimeSpatialHashHLOD.cpp:357,372):
HLODGrid.bClientOnlyVisible = true;
// ...
// 有 HLODModifier 的层(例如可破坏 HLOD)需要服务端也有组件
HLODGrid.bClientOnlyVisible &= HLODLayer->GetHLODModifierClass() == nullptr;HashSet 路径(WorldPartitionRuntimeHashSet.cpp:1231):
HLODSetup.PartitionLayer->bClientOnlyVisible = !HLODPartitionMustBeLoadedOnServer(HLODSetup);生效点在 UWorldPartitionRuntimeHash::IsCellRelevantFor:
// Dedicated server & listen server without server streaming won't consider client-only visible cells
if ((NetMode == NM_DedicatedServer) || ((NetMode == NM_ListenServer) && !IsServerStreamingEnabled()))
return false;在 DS 上跑,HLOD 的方块会整个从调试图里消失。这是对的:服务器不渲染,不需要代理网格,省内存省 IO。联机测试时”客户端两个方块、服务端一个”属正常现象。
顺带一个和调试图有关的实现细节:SpatialHash 路径给 HLOD grid 的调试色不是随机的(普通 grid 的 DebugColor 默认 FLinearColor::MakeRandomColor()),而是查 GEngine->HLODColorationColors:
const int32 HLODColorIdx = FMath::Clamp(HLODLevel + 2, 0U, (int32)LastLODColorationColorIdx);
HLODGrid.DebugColor = GEngine->HLODColorationColors[HLODColorIdx];对照 BaseEngine.ini 的默认表(HLODColorationColors,index 2 起):HLOD level 0 = 蓝,level 1 = 黄,level 2 = 紫,level 3 = 青。这套颜色和视口的 HLOD Coloration 可视化模式是同一套,可以互相对照。
八、把运行时 2D 调试图读懂
wp.Runtime.ToggleDrawRuntimeHash2D
8.0 这张图本质是什么
它不是小地图,是把 World Partition 的 runtime streaming grid 沿 Z 投影到 XY 平面后画在 Canvas 上的半透明调试层。背景的 3D 场景是从下面透出来的,不属于调试图。
画布规则(WorldPartitionSubsystem.cpp:1497-1503):最多占屏幕 75%,四周留 10 px,最终强制成正方形(取宽高较小值)。嫌它太大用:
wp.Runtime.DrawRuntimeHash2DScaleFactor 0.5
每个 grid 画一个独立正方形,横向并排。上图里左边是 MainGrid | 256 m,右边是 build HLOD 之后新生成的 HLOD_0 | 512 m。
8.1 视图范围 = 2.2 × LoadingRange,与世界大小无关
这是读图第一件要建立的直觉:视图范围跟着 streaming source 的加载范围自适应,跟世界多大没有关系。
// 累加所有 streaming source 在这个 grid 上的形状包围盒
Source.ForEachShape(StreamingData->GetLoadingRange(), Name, true,
[&GridsShapeBounds](const FSphericalSector& Shape) { GridsShapeBounds += Shape.CalcBounds(); });
// detailed 模式:以 source 包围盒为中心,外扩 10%
GridReferenceWorldPos = FVector2D(GridsShapeBounds.GetCenter());
WorldRegionExtent = FVector2D(GridsShapeBounds.ExpandBy(GridsShapeBounds.GetExtent() * 0.1f).GetExtent().GetMax());
const FVector2D WorldToScreenScale = GridScreenHalfExtent / WorldRegionExtent;所以:正方形边长 = 2 × 1.1 × LoadingRange = 2.2 × LoadingRange,中心 ≈ 玩家位置。
✅ 黄圆直径 / 方框边长 = 2R / 2.2R = 0.909——图上圆几乎顶满方框、四边留一圈,比例正好对得上。
三个容易踩的点:
- 有一帧延迟:
DesiredWorldBounds是这一帧算出来给下一帧用的(Subsystem 在 Draw 之前 union 的是上一帧各 context 的结果)。高速移动时区域跟手慢半拍,正常。 - 改 loading range 图会跟着缩放:
wp.Runtime.OverrideRuntimeLoadingRange一改,整张图比例尺就变了,不同时刻的截图不能直接叠着比。 - 多 grid 时每个方块比例尺不同——见 8.5,这是这张图最大的陷阱。
8.2 格子代表什么
这是两条绘制路径差异最大的地方:
SpatialHash 路径(WorldPartitionRuntimeSpatialHash.cpp:1028-1093)
按 (X, Y, Level) 遍历格子,画格子的世界 AABB。一个格子坐标可以对应多个 cell(不同 DataLayer 组合、不同 ContentBundle 各自生成独立 cell),画的时候把格子在 Y 方向等分成 N 条:
CellBoundsSize.Y /= FilteredCells.Num();所以图上某些格子里的横向条纹 = 同一格里多个独立加载的 cell,状态可以不同(一条绿一条红完全正常)。格子四周的黑边是格子边界,不是 cell 边界。detailed 模式下还会在格子内画:左侧最多 20% 宽 = DataLayer 颜色条,右侧 20% = ContentBundle 颜色条(和右下角 DataLayer 列表前的小方块是同一套色)。
❗ 默认只画第 0 层:wp.Runtime.ShowRuntimeSpatialHashGridLevel = 0、ShowRuntimeSpatialHashGridLevelCount = 1。而实际的 hash 是金字塔:level L 的格子边长 = CellSize × 2^L。跨格的大 Actor 会被 promote 到更高 level 的大格子里,那些格子在默认视图里完全看不见。排查”某个大物件为什么不随距离卸载”必须先把 ShowRuntimeSpatialHashGridLevelCount 调大。
HashSet 路径(WorldPartitionRuntimeHashSetDebugDraw.cpp:143-228)
不按格子坐标遍历,而是直接对 cell 做空间查询,每个 cell 画两层:
// ① 固定 cell 边界(很淡的灰底 + 黑框)
DrawContext.LocalDrawTile(..., Cell->GetCellBounds()..., FLinearColor::Gray.CopyWithNewOpacity(0.05f), ...);
DrawContext.LocalDrawBox (..., Cell->GetCellBounds()..., FLinearColor::Black.CopyWithNewOpacity(0.05f), 1, ...);
// ② streaming 边界(状态色)
DrawContext.LocalDrawTile(..., Cell->GetStreamingBounds()..., CellColor.CopyWithNewOpacity(CellOpacity), ...);
DrawContext.LocalDrawBox (..., Cell->GetStreamingBounds()..., FLinearColor::Black, 1, ...);CellBounds 和 StreamingBounds 是两个不同的框,前者是格子的固定几何范围,后者是这个 cell 里内容的实际范围(可能小得多)。上图里那些散落的彩色小方块就是 StreamingBounds——它们比格子小很多,说明这些 cell 里内容稀疏。这一点非常有用:能一眼看出哪些 cell 是”大格子装小东西”。
多 DataLayer 时是沿 X 方向等分(不是 SpatialHash 的 Y 方向),而且 DataLayer / ContentBundle 是独立的显示模式而不是侧边色条:
wp.Runtime.HashSet.ShowDebugDisplayMode
0 = Level Streaming State(默认)
1 = Data Layers
2 = Content Bundles
3 = Level Streaming State,但只显示没挂 DataLayer / ContentBundle 的 cell
wp.Runtime.HashSet.ShowDebugDisplayLevel // 从第几个 HierarchicalLevel 开始
wp.Runtime.HashSet.ShowDebugDisplayLevelCount // 一次显示几层,默认 1
注意 HashSet 模式下多层叠加时透明度会自动摊薄:0.25f / max(LevelCount, 1)。
8.3 颜色 = streaming status
Cell->GetDebugColor(VisualizeMode).CopyWithNewOpacity(
VisualizeMode == StreamingPriority ? 0.75f : 0.25f)透明度只有 0.25,所以同一个状态叠在亮背景上偏白、暗背景上偏暗——图里的”深绿”和”亮绿”其实是同一个状态,别当成两种。
| 值 | 状态 | 颜色 |
|---|---|---|
| 0 | Unloaded | 红 |
| 1 | Unloaded Still Around | 紫 |
| 2 | Loading | 黄 |
| 3 | Loaded Not Visible | 青 |
| 4 | Making Visible | 蓝 |
| 5 | Loaded Visible | 绿 |
| 6 | Preloading | 品红 |
| 7 | Failed to Load | 栗 |
| 8 | Making Invisible | 橙 |
(LevelStreaming.cpp:2498)
切成优先级热力图(蓝→绿→黄→红,透明度提到 0.75):
wp.Runtime.ShowRuntimeSpatialHashCellStreamingPriority
8.4 大圆 = 加载范围,不是可视范围
- 半径 = 这个 grid 的
GetLoadingRange(),也就是标题栏那个数字。跟相机 FOV、渲染可视距离、cull distance 都没关系。 - 实际类型是
FSphericalSector:360° 画整圆;bIsSector时画扇形并额外画两条边界半径。图里是整圆 → 该 source 没配自定义 shape(或配的就是球)。 - 颜色 =
Source.GetDebugColor()(按 source 名 hash),多个 source 各不相同,和下方 Streaming Sources 列表里的彩色名字一一对应。 - 圆心射出的线有两条,靠长度区分:长度 = 半径 → source 朝向轴(
Axis * Shape.GetRadius());长度 = 半径的一半 → 速度矢量(Velocity.GetSafeNormal() * LoadingRange * 0.5f)。
一个常被误判成 bug 的现象:格子是否被请求,判定的是 cell 的 AABB 与这个球/扇形求交(ForEachIntersectingCells / ForEachIntersectingElement),不是”格子中心到圆心的距离”。所以绿色区域会比圆略微外扩、边缘呈锯齿状。这是对的。
8.5 ❗ 最大的读图陷阱:两个方块比例尺不同
detailed 模式下每个 grid 用自己的范围归一化:
| 方块 | LoadingRange | 视图半边长(×1.1) | 视图全宽 |
|---|---|---|---|
| MainGrid | 256 m | 281.6 m | 563.2 m |
| HLOD_0 | 512 m | 563.2 m | 1126.4 m |
结果是:两个方块里的黄圆看起来一样大(都占 1/1.1 = 90.9%),但左边是 256 m 半径、右边是 512 m 半径。同理右边的一个格子在屏幕上看着不比左边大多少,实际是好几倍。
绝对不能跨方块用视觉大小做比较,只能各自读各自的比例尺。
还有个实现细节值得记一笔:GridsShapeBounds 是在 grid 循环外面声明、循环内 += 累加的,也就是第 N 个方块的 extent 其实是前 N 个 grid 的并集。SpatialHash 路径在画之前按 LoadingRange 升序排过(:2360),所以累加结果恰好等于当前 grid 自己的范围,上表成立。HashSet 路径没有排序(直接遍历 TMap),如果某个大 range 的 grid 排在前面,后面小 range 的方块比例尺就会被撑大。读 HashSet 的图时这点要留意。
8.6 其余元素逐个拆
| 元素 | 含义 |
|---|---|
| 顶部白字 | WorldPartition 所在 package 名 |
顶部黄字 Name | N m | GridName | LoadingRange(米) —— 是加载范围,不是 cell 大小,这行最常被看错。SpatialHash 路径还会追加 | Client Only、| GridLevelFilter N |
| 红色水平线 | 世界 X 轴(Y=0),长度 ±1638400 uu |
| 绿色竖线 | 世界 Y 轴(X=0) |
| 外框 | DrawGridBounds。SpatialHash 用 grid 的 DebugColor(普通 grid 随机色,HLOD grid 见第七节的 HLODColorationColors);HashSet 固定用白色 |
| 黄色矩形(若可见) | grid 的 WorldBounds,即这个 grid 覆盖的整个世界范围(detailed 模式才画)。HLOD grid 的 WorldBounds 通常比主 grid 小得多,只覆盖真正生成了 HLOD Actor 的区域——看到它的一角说明你正站在 HLOD 覆盖区边缘 |
坐标轴很实用:知道自己的 Pos.Y 和视图半边长,就能算出红线(Y=0)应该出现在圆心上方几分之几处,一眼验证读图没读错,也能立刻判断自己在世界哪个象限。
8.7 文字区
Streaming Status for (Client 1): (Idle)—— 可能是(Purging)/(Unhashing)/(AsyncLoading)/(Idle),再加可选的(HighPriorityLoading)。Idle = 当前无 pending 加载/GC。Streaming Performance: Good (Blocking Enabled)—— Good / Slow / Critical,阈值由wp.Runtime.SlowStreamingRatio、wp.Runtime.BlockOnSlowStreamingRatio控制。Blocking Enabled 意味着 Critical 时会阻塞主线程等加载完(防止玩家掉出地形)。做卡顿分析时这是头号嫌疑点。Streaming Sources行:Priority: 128=Normal(Highest=0 / High=64 / Normal=128 / Low=192,数值越小优先级越高)Local/Remote—— 本地玩家 vs 服务器复制过来的 sourceActivated/Loaded—— TargetState。Activated = 加载并显示;Loaded = 只加载不显示(预热用)Blocking—— 该 source 参与 block on slow loadingPos / Rot / Vel—— 速度单位可用wp.Runtime.DebugDisplaySpeedUnit切换
Streaming Status Legend的计数有两个坑(WorldPartitionSubsystem.cpp:1702-1738):0) Unloaded故意不显示数字(代码里if (Status != LEVEL_Unloaded)才追加计数),不是数量为 0。上面那张图里其余 8 项都带(0),唯独它后面什么都没有。Unloaded Still Around取的是FLevelStreamingGCHelper::GetNumLevelsPendingPurge(),是全局待 GC 的 level 数,会混进 Level Instance 等非 WP 来源——引擎自己在旁边挂了@todo_ow说这个值不精确。别拿它做严格统计。
- 右侧 DataLayer 列表 ——
UDataLayerManager::DrawDataLayersStatus,分三组:Loaded Data Layers(青标题)、Active Data Layers(绿标题)、Unloaded Data Layers(银标题灰字)。只有 Unloaded 一组,说明这些 runtime data layer 全部未激活——带这些 DataLayer 的 cell 即使在圆内也不会加载。 - 画不出来的东西:WP 的 2D canvas 只能画 line / box / tile / text(
FWorldPartitionCanvasItems)。画面上任何径向渐变、图标、旋转罗盘都不是 WP debug 画的,是项目自己的 HUD。
九、排查手册:日志、开关、常见现象
9.1 日志三件套
构建时的日志都落在 <Project>/Saved/Logs/WorldPartition/:
① StreamingGeneration-HLOD-<pid>-<时间戳>.log(WorldPartitionStreamingGeneration.cpp:2243)
存放每个 GUID 对应的是什么资源——GUID 反查资产的唯一手段。

② WorldPartitionHLODsBuilder-<pid>-<时间戳>.log
总量统计,最有用的是这行:
LogWorldPartitionHLODsBuilder: Display: #### Building 152 HLOD actors ####
这是所有层加起来的总数。以及逐个 Actor 的进度行,会带上类型和源数量:
[37 / 152] Building HLOD actor lodA/MainGrid_L1_X-3_Y-3 (EHLODLayerType::Instancing, HLODLayer: lodA, SourceActors: 3)...
③ 每一级 HLOD 和哪些 Actor 有关

图里能读到的信息很典型:
[HLOD0] lodA/TestMapOne_MainGrid_L1_X-3_Y-3
|- HLODLayer: lodA (LayerType: EHLODLayerType::Instancing)
|- RuntimeGrid: MainGrid:HLOD_0
|--[+] SourceActors summary: 3 actors, 2 classes
| |- * /Script/Foliage.InstancedFoliageActor x2
| |- * /Script/Landscape.LandscapeStreamingProxy x1
[HLOD1] lodB/TestMapOne_HLOD_0_L1_X-2_Y-2
|- HLODLayer: lodB (LayerType: EHLODLayerType::MeshMerge)
|- RuntimeGrid: MainGrid:HLOD_1
|--[+] SourceActors summary: 1 actors, 1 classes
| |- * /Script/Engine.WorldPartitionHLOD x1
HLOD1 的源就是 HLOD0 的产物(WorldPartitionHLOD x1,GUID 正好等于上面那个 HLOD0 Actor 的 GUID),第二节讲的逐层迭代在这里是可见的。Cell 名字里的 _MainGrid_L1_X-3_Y-3 / _HLOD_0_L1_X-2_Y-2 也直接告诉你这个 HLOD 是从哪个 grid 的第几层、哪个坐标的格子生成的。
9.2 只看某一层:LoadingRange 开关组合
利用第七节的显隐互斥规则,可以用 loading range 精确隔离出任意一层:
wp.Runtime.OverrideRuntimeLoadingRange -grid=[GridName] -range=[Range]
注意有两条命令,参数语义不同:
wp.Runtime.OverrideRuntimeLoadingRange -grid=<名字> -range=<int>wp.Runtime.OverrideRuntimeSpatialHashLoadingRange -grid=<索引> -range=<float>(SpatialHash 专用,按 grid 索引)两者都用
-range=-1清除覆盖、恢复原值。
规律很干净:想看第 N 层,就把前 N 个 grid 设 0,剩下的设成巨大值。
假设 grid 是 MainGrid / HLOD_0 / HLOD_1:
| 想看 | MainGrid | HLOD_0 | HLOD_1 |
|---|---|---|---|
| 原始几何(HLOD 全被压住) | 200000 | 200000 | 200000 |
| 只看 HLOD 第 0 层 | 0 | 200000 | 200000 |
| 只看 HLOD 第 1 层 | 0 | 0 | 200000 |
原理:range 设巨大值等于”全加载”,此时低层的真 Actor 会把高层 HLOD 全部压成不可见;把前 N 个 grid 的 range 设 0 就等于把它们整层拿掉,第 N 层自然浮出来。“全设巨大值”能看到原始几何,靠的正是显隐互斥而不是 HLOD 没加载——理解这一点,这套开关就不需要死记了。
配合 wp.Runtime.HLOD 0(整体关掉 HLOD)可以确认”远景到底是 HLOD 还是真 Actor”。
9.3 常见现象 → 原因
一个现象往往有好几个成因,下表只列最该先看的那个。
| 现象 | 首要怀疑 |
|---|---|
| 圆内出现红/紫格 | cell 被过滤掉了(DataLayer 未激活 / Z-culling / ContentBundle),或加载失败。先看右侧 DataLayer 列表 |
| 成片黄色(Loading)持续不消 | IO / 解压瓶颈,或 loading range 相对硬件设太大。配合 Streaming Performance 看 |
Performance: Critical + Blocking Enabled | 会阻塞主线程,是掉帧的直接原因 |
| 格子内出现条纹且状态不一致 | 同格多 cell(DataLayer / ContentBundle 拆分)。检查是否拆得过碎——cell 数量爆炸 = ULevelStreaming 开销爆炸 |
| 绿区边缘比圆外扩且锯齿 | 正常,AABB 求交所致 |
| 某个大物件不随距离卸载 | ⚠️ 十有八九被 promote 到高 grid level。wp.Runtime.ShowRuntimeSpatialHashGridLevelCount 5 或 wp.Runtime.HashSet.ShowDebugDisplayLevelCount 5 确认 |
| HLOD 方块里一个格子都没有 | 按可能性排:① HLOD Actor 被 promote 到更高 level(默认只画一层);② 玩家在 HLOD grid 的 WorldBounds 之外;③ HLOD Actor 实际没生成(用 wp.Runtime.DumpWorldPartitions 看每层 cell 数);④ HLOD cell 挂了未激活的 DataLayer;⑤ wp.Runtime.HLOD 被关了 |
| HLOD cell 是 Loaded Visible 但看不到网格 | 正常,源 cell 可见时 HLOD 被主动隐藏 |
| 远景闪烁 / 空洞 | 同时看两个方块:主 grid 圆边缘的格子变红时,HLOD 方块对应位置必须已经是绿的。HLOD 那边还是黄(Loading)或红就会漏 |
| HLOD 贴图糊 / 法线平 / 材质是默认灰 | Build 阶段四道等待(4.2)某一环没等到 |
| 地形 HLOD 贴图局部块状低分辨率 | VT/RVT 预热帧不够,调大 CVarMaterialUtilitiesWarmupFrames |
| 某些物件没被合进代理网格 | 查 HLODBatchingPolicy::Instancing,它会绕过 Layer 配置单独批成 ISMC |
| HLOD1 之后地形法线发飘 | 地形 flatten 材质烘的是世界空间法线,下一层 bake 按切线空间理解(见第五节的坑) |
| 改了 HLODLayer 的 CellSize/LoadingRange 之后 HLOD 全没了 | grid 名字变了,旧 HLOD Actor 指向不存在的 grid,必须重新 build |
9.4 命令速查
通用
| 命令 | 作用 |
|---|---|
wp.Runtime.ToggleDrawRuntimeHash2D | 开关 2D 调试图 |
wp.Runtime.ToggleDrawRuntimeHash3D | 在 3D 世界里直接画 cell 盒子,看 Z 分布 / 判断 HLOD Actor 落在哪一级 |
wp.Runtime.DrawRuntimeHash2DScaleFactor 0.5 | 缩小调试图(0.1~1) |
wp.Runtime.DumpWorldPartitions | dump 每个 grid 的 CellSize / LoadingRange / WorldBounds / 各级 cell 数 —— 确认配置的权威手段 |
wp.Runtime.DumpStreamingSources | dump 所有 streaming source |
wp.Runtime.ToggleDrawRuntimeCellsDetails | 列出每个 cell 的明细 |
wp.Runtime.ShowRuntimeSpatialHashCellStreamingPriority | 切成优先级热力图模式 |
wp.Runtime.DebugFilterByRuntimeHashGridName <name> | 只显示某个 grid(独占整个画布,比例尺变大看得清);不带参数清除过滤 |
wp.Runtime.DebugFilterByStreamingStatus | 只高亮某种状态的 cell |
wp.Runtime.DebugFilterByDataLayer / DebugFilterByCellName | 按 DataLayer / cell 名过滤 |
wp.Runtime.DebugDisplaySpeedUnit | 切换速度单位显示 |
wp.Runtime.HLOD 0/1 | 整体关/开 HLOD |
wp.Runtime.SlowStreamingRatio / BlockOnSlowStreamingRatio | 流送性能判定阈值 |
SpatialHash 专用
| 命令 | 作用 |
|---|---|
wp.Runtime.ShowRuntimeSpatialHashGridLevel N | 从第 N 层开始画 |
wp.Runtime.ShowRuntimeSpatialHashGridLevelCount 5 | 一次画 5 层(排查大 actor promote 必开) |
wp.Runtime.OverrideRuntimeSpatialHashLoadingRange -grid=1 -range=100000 | 按索引临时改某个 grid 的加载范围 |
HashSet 专用
| 命令 | 作用 |
|---|---|
wp.Runtime.HashSet.ShowDebugDisplayLevel N | 从第 N 个 HierarchicalLevel 开始显示 |
wp.Runtime.HashSet.ShowDebugDisplayLevelCount 5 | 一次显示 5 层 |
wp.Runtime.HashSet.ShowDebugDisplayMode 1 | 0=流送状态 / 1=DataLayer / 2=ContentBundle / 3=流送状态但排除挂了 DataLayer 和 ContentBundle 的 cell |
Editor / 构建期
| 命令 | 作用 |
|---|---|
wp.Editor.HLOD.UseLegacy32BitNameHash | 退回 32 位 HLOD Actor 命名(read-only,切换会让所有 HLOD 换名重建) |
十、一页速记
构建期
Commandlet → RunInternal → Setup / Build / Delete / Finalize / Stats
Setup : 账本 HLODActorDescs(FName → Handle),逐层迭代,
CityHash64(LayerName + CellGuid[+ActorClass][+CustomHLOD]) → 确定性 Actor 名
→ 找到=复用 / 找不到=新建 / 剩下的=删除
Build : 隔离 World(镜像 RVT)→ 四道等待 → 收集 Component(可被代理替换)
→ RebuildPolicy 脏检测 → BatchingPolicy 分流 → 各 Builder
→ 后处理 / Stats / DestroyWorld + GC
分派 : LayerType 决定默认 builder;Component 可用 GetCustomHLODBuilderClass 抢走
(地形就是这么接的);HLODBatchingPolicy::Instancing 优先级最高
运行时
每层一个 grid,LoadingRange 逐层递增
HLODRuntimeSubsystem:源 cell 可见 → 隐藏 HLOD;源 cell 隐藏 → 显示 HLOD
HLOD grid 默认 client only,DS 上整层不存在
读图
方块 = 一个 grid,边长 = 2.2 × 该 grid 的 LoadingRange
圆 = LoadingRange,不是可视距离
圆心射线:长=半径 → 朝向;长=半径一半 → 速度
跨方块不能比大小
默认只画一层,大物件在高层看不见
十一、参考链接
官方文档
🔗 World Partition - Hierarchical Level of Detail — HLOD Layer 资产与各字段的官方说明,配置面板层面的权威参考
🔗 World Partition — WP 总览:网格划分、流送源、Data Layers、Cook 流程
🔗 World Partition Builder Commandlet Reference — 各 Builder commandlet 的命令行格式,含 WorldPartitionHLODsBuilder
🔗 World Building Guide(Epic 官方知识库) — WP / HLOD / Data Layers 的实践建议与常用调试命令清单
引擎源码索引(UE 5.8,相对路径)
| 主题 | 相对路径 | 关键符号 |
|---|---|---|
| Builder 入口 / 参数解析 / 拓扑排序 | Engine/Source/Editor/UnrealEd/Private/WorldPartition/WorldPartitionHLODsBuilder.cpp | RunInternal · ValidateParams · GetHLODWorkloads |
| Setup 转发 | Engine/Source/Runtime/Engine/Private/WorldPartition/WorldPartitionStreamingGeneration.cpp | UWorldPartition::SetupHLODActors (:2701) |
| Setup 实现(SpatialHash) | Engine/Source/Runtime/Engine/Private/WorldPartition/RuntimeSpatialHash/RuntimeSpatialHashHLOD.cpp | SetupHLODActors (:451) · bClientOnlyVisible (:357,:372) |
| Setup 实现(HashSet) | Engine/Source/Runtime/Engine/Private/WorldPartition/RuntimeHashSet/WorldPartitionRuntimeHashSetHLODGeneration.cpp | SetupHLODActors (:266) · grid 名拼接 (:471) |
| HLOD Actor 创建 / 烘焙 | Engine/Plugins/Editor/WorldPartitionHLODUtilities/Source/Private/WorldPartition/HLOD/Utilities/WorldPartitionHLODUtilities.cpp | ComputeHLODActorUniqueHash (:153) · CreateHLODActors (:193) · LoadSourceActors (:745) · BuildHLOD (:831) |
| Builder 分派 / 实例批处理 | Engine/Source/Runtime/Engine/Private/WorldPartition/HLOD/HLODBuilder.cpp | UHLODBuilder::Build (:324) · BatchInstances (:161) |
| MeshSimplify 的 ScreenSize 推导 | Engine/Plugins/Editor/WorldPartitionHLODUtilities/Source/Private/WorldPartition/HLOD/Builders/HLODBuilderMeshSimplify.cpp | 假想相机 (:82-85) · ComputeBoundsScreenSize (:103-104) |
| 脏检测的图像对比 | Engine/Plugins/Editor/WorldPartitionHLODUtilities/Source/Private/WorldPartition/HLOD/RebuildPolicies/HLODRebuildPolicyImageCompare.cpp | 同一组假想相机 (:99-104) |
| 地形 Builder 注册点 | Engine/Source/Runtime/Landscape/Private/LandscapeComponent.cpp | GetCustomHLODBuilderClass (:97) |
| 材质 Bake / VT 预热 | Engine/Source/Developer/MaterialUtilities/Private/MaterialUtilities.cpp | CVarMaterialUtilitiesWarmupFrames (:68) · PerformSceneRender (:827) |
| 旧路径 grid 命名 | Engine/Source/Runtime/Engine/Private/WorldPartition/HLOD/HLODLayer.cpp | GetRuntimeGridName (:266) |
| 运行时 client-only 判定 | Engine/Source/Runtime/Engine/Private/WorldPartition/RuntimeHashSet/WorldPartitionRuntimeHashSet.cpp | bClientOnlyVisible (:1231) |
| 2D 调试绘制(HashSet) | Engine/Source/Runtime/Engine/Private/WorldPartition/RuntimeHashSet/WorldPartitionRuntimeHashSetDebugDraw.cpp | cell 双层绘制 |
| HLOD 调试色表 | Engine/Config/BaseEngine.ini | HLODColorationColors (:286-292) |