一、最朴素的场景:把所有 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 个物体 ✓└── ...视锥体剔除时:
- 从 Root 开始,检查八叉树节点的包围盒是否与视锥体相交
- 不相交 → 整个分支跳过(可能一下跳过几千个物体)
- 相交 → 继续向下检查子节点
- 到达叶子节点时,检查节点内的每个物体
最佳场景: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::ComputeViewVisibility → FrustumCull → 用 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-10m | 10000 三角面 |
| LOD1 | 10-30m | 3000 三角面 |
| LOD2 | 30-80m | 800 三角面 |
| LOD3 | 80m+ | 不渲染(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),这是引擎能承载复杂场景的基础。