2259 字
11 分钟
场景管理与空间数据结构

一、最朴素的场景:把所有 Actor 放在一个数组里#

std::vector<AActor*> AllActors;
void Tick(float dt) {
for (auto* actor : AllActors) {
actor->Tick(dt); // 所有 Actor 挨个 Tick
}
}
void Render() {
for (auto* actor : AllActors) {
if (IsInFrustum(actor->Bounds, Camera)) {
actor->Render(); // 只画视野内的
}
}
}

场景里有 100 个 Actor 时没问题。5000 个时,每帧对 5000 个 Actor 跑视锥体检测变成开销。10000 个时遍历本身就开始卡。而且这里面有很多根本不在视野里的(比如另一个关卡的城市),根本不应该参与遍历。

解决思路:不要暴力遍历,用空间数据结构快速找到”可能相关的”那批对象


二、场景图(Scene Graph):不只是空间关系#

1 层次化组织#

场景图(Scene Graph)是树形结构,每个节点有自己的局部变换,子节点继承父节点的变换:

World Root
├── Level_A (Transform: origin)
│ ├── Building_01
│ │ ├── Door
│ │ └── Window_*
│ └── Tree_*
└── PlayerCharacter
├── Camera
└── Weapon (Attached to hand bone)

父节点移动,子节点自动跟随。这就是你在 UE 里把一个对象拖到另一个对象下面形成父子关系时发生的事情——子节点的世界矩阵变成了 ParentWorldMatrix × LocalMatrix

2 不只是 Transform#

实际引擎的场景图节点(UE 里是 USceneComponent)承载的远不止变换矩阵:

USceneComponent:
├── Transform (位置 / 旋转 / 缩放)
├── Bounds (包围盒,用于剔除)
├── Attachment (父子关系链接)
├── Visibility (是否可见、是否在编辑器中隐藏)
└── Tick 依赖 (哪个 TickGroup、和谁的 Tick 有先后关系)

3 遍历与脏标记#

场景图不是每帧从根出发重新计算所有世界矩阵的。每个节点有一个 脏标记(Dirty Flag)

void USceneComponent::SetRelativeLocation(FVector NewLoc) {
RelativeLocation = NewLoc;
MarkTransformDirty(); // 只标记自己 + 所有子孙
}
void USceneComponent::UpdateComponentToWorld() {
if (!bTransformDirty) return; // 没变就不算
if (AttachParent) {
ComponentToWorld = RelativeTransform * AttachParent->ComponentToWorld;
} else {
ComponentToWorld = RelativeTransform;
}
// 递归更新所有子节点
for (auto* Child : AttachChildren) {
Child->UpdateComponentToWorld();
}
bTransformDirty = false;
}

大部分物体大部分帧是不动的(建筑、地形、静态装饰)。脏标记机制让”不动的东西”几乎零开销。


三、空间数据结构:快速找到”谁在附近”#

场景图解决了父子层次关系,但没有解决任意空间查询:“摄像机前方 100 米内有哪些物体?“

1 八叉树(Octree)#

将空间递归地八等分,直到每个节点包含的物体数量低于阈值:

Root (整个关卡,10000 个物体)
├── 000 (前右下) → 3 个物体 ✓ (叶子节点)
├── 001 (前左下) → 细分为 8 个子节点
│ ├── 001-000 → 2 个物体 ✓
│ ├── 001-001 → 空 ✓
│ └── ...
├── 010 → 5 个物体 ✓
└── ...

视锥体剔除时:

  1. 从 Root 开始,检查八叉树节点的包围盒是否与视锥体相交
  2. 不相交 → 整个分支跳过(可能一下跳过几千个物体)
  3. 相交 → 继续向下检查子节点
  4. 到达叶子节点时,检查节点内的每个物体

最佳场景:3D 空间均匀分布的物体(体素、点云、开放世界)
弱点:物体大量集中在一个区域(城市中心)→ 那一个八叉树节点变得巨大,退化回遍历

2 层次包围盒(BVH, Bounding Volume Hierarchy)#

不按空间对半分,而是按物体数量对半分。自底向上构建:

所有物体按照位置排序 → 两两分组 → 每组计算包围盒 → 递归向上聚合

BVH 是光线追踪的标配数据结构(NVIDIA RTX 的硬件加速就是基于 BVH 遍历),用于碰撞检测同样很常见。

和 Octree 的区别:Octree 是空间固定的(划分线不会动),BVH 是根据物体分布动态调整的。物体移动后 Octree 只需重新分配物体到新节点,BVH 理论上需要重建(实际通过宽泛的包围盒容差来减少重建频率)。

3 均匀网格(Uniform Grid)#

最简单:把世界切成等尺寸的格子。

Grid[3][5] → 包含坐标在这个格内的所有物体

优势:实现简单到令人发指;插入/删除 O(1);非常适合 2D 或平坦 3D 场景。
劣势:网格大小是个取舍——太大退化回遍历,太小物体跨格边界查询变复杂;世界极大时空格数量爆炸。

4 实际引擎的选择:分层方案#

UE 实际上同时用了多种数据结构:

数据结构用在
SceneComponent 层次树组织所有组件,处理父子 Transform
UWorld::Levels关卡组织——Persistent Level + Sub-Levels
FOctree (LightOctree)光照剔除——快速找到影响某区域的所有光源
Streaming Grid (World Partition)确定哪些地图分块应该加载/卸载
FPrimitiveOctree可见性剔除的初筛

一个引擎不只用一种空间结构。不同子系统对空间查询的需求不同,就会用不同结构。


四、可见性剔除(Visibility Culling)#

“不画看不见的东西”是渲染优化的第一步。剔除实际上有多个层级:

1 视锥体剔除(Frustum Culling)#

最基本的:物体的包围球/包围盒和摄像机视锥体做相交测试。落在视锥体外 → 不提交 Draw Call。

UE 的做法FSceneRenderer::ComputeViewVisibilityFrustumCull → 用 FPrimitiveOctree 快速排除。

2 遮挡剔除(Occlusion Culling)#

略复杂:物体在视锥体内,但被前面的物体挡住了

传统方案:用上一帧的深度缓冲做 Occlusion Query。GPU 渲染一个简化的包围盒,看有没有像素通过深度测试。没有 → 被挡住了 → 不画。问题:Query 结果要等 GPU 回读,有延迟,当帧可能赶不上(上一帧的结果用于这一帧的决策)。

UE5 方案OcclusionCull 本身在 GPU 上跑(Compute Shader),用上一帧的 HZB(Hierarchical Z-Buffer)做快速、保守的相交测试。

3 距离剔除(Distance Culling)#

太远的物体不画。美术在每个 Actor 上设置 MaxDrawDistance,超过距离自动跳过。你的竞拍游戏里,远处的收藏馆橱柜可能根本不需要渲染,但这一般不是开放世界才遇到的问题。

4 细节层级(LOD)#

不是不画,是画更便宜的版本:

LOD Level距离面数
LOD0(全精度)0-10m10000 三角面
LOD110-30m3000 三角面
LOD230-80m800 三角面
LOD380m+不渲染(Billboard 替代或直接剔除)

LOD 切换是突然的(Popping),引擎通常做 抖动过渡(Dither Transition)——在过渡区用噪声遮罩渐隐低精度版本渐显高精度版本。


五、UE5 World Partition:关卡组织的新范式#

1 传统关卡管理的问题#

Persistent Level + Sub-Levels 模型在开放世界遇到瓶颈:

  • 哪些 Sub-Level 该加载?手动标记或用 Level Streaming Volume
  • 多个关卡设计师同时编辑一个 Level → 合并冲突地狱
  • 关卡加载/卸载时明显的卡顿

2 World Partition 的理念#

不按”哪个关卡”来组织,而是按空间网格(Grid)自动切分

世界被划分成固定大小的 Grid Cell
每个 Cell 内的 Actor 自动归入对应的 Cell
玩家附近的 Cell 自动加载(由 Streaming Source 触发)
远离的 Cell 自动卸载

对关卡设计师来说,不再有”Level A”、“Level B”这种概念——他们在一个统一的世界里放东西,引擎自动负责分块和流式加载。

核心数据结构UWorldPartitionRuntimeHash 管理 Grid → Cell → Streaming Level 的映射。UWorldPartitionStreamingPolicy 在运行时根据 Streaming Source(通常是玩家位置)计算需要加载的 Cell 集合。

3 对引擎设计者的启示#

World Partition 的设计体现了一个通用原则:“数据应该存在哪”和”数据如何被访问”是两个问题,把它们解耦

传统的 Sub-Level 把二者绑在一起——数据位置 = 加载单元。World Partition 把”数据位置”(Grid Cell)和”加载单元”(Streaming Level)分离,允许加载单元的大小不严格等于网格大小(可以合并相邻的小 Cell 为一个大的加载单元)。

这个思路可以推广到自研引擎的任何大规模数据管理场景。


六、总结#

概念一句话
场景图层次化组织,父子 Transform 传递,脏标记减少计算
八叉树 / BVH空间查询的索引——视锥体剔除、光照、碰撞检测的基础
视锥体剔除抛弃视野外的物体,最便宜的剔除
遮挡剔除抛弃被挡住的物体,需要 GPU 辅助
LOD远的物体用更粗糙的版本,降低 GPU 负担
World Partition按空间网格自动切分世界,自动流式加载

场景管理是渲染之前的那一步——你首先得知道”画什么”,才能谈”怎么画”。好的空间数据结构让”找东西”的时间从 O(N) 降到 O(log N),这是引擎能承载复杂场景的基础。

场景管理与空间数据结构
https://www.m4doka.xyz/posts/engine/engine-6-scene-management/
作者
m4doka
发布于
2026-07-16
许可协议
CC BY-NC-SA 4.0