Banner

大世界渲染:从四叉树剔除到 GPU-Driven Terrain

一、背景与摘要

在现代 3D 游戏引擎(如虚幻 5 的 Nanite 早期思想、各种开放世界引擎)中,庞大世界的渲染 是一个绕不开的核心挑战。一个 10km × 10km 量级的开放世界,若简单地把所有地块网格交给 CPU 遍历并提交 DrawCall,CPU 端的剔除遍历开销GPU 端的顶点处理开销 会双双爆炸,性能瓶颈几乎是必然。

破局思路并不是一蹴而就的。本文将沿着 “从基础概念到进阶演化”的脉络,分三步推进:

  1. 理论打底:先建立”四叉树/八叉树空间剔除”的基本认知,理解为什么空间划分能加速剔除;
  2. CPU 原型:用 Unity 的 Graphics.DrawMeshInstancedGeometryUtility 实现一个最小可用的四叉树剔除原型,看清它的能力边界;
  3. GPU 进化:当原型在大世界规模下力不从心时,自然过渡到 GPU-Driven Terrain 架构——将四叉树遍历、LOD 评估、剔除全部搬到 Compute Shader 中。

尽管图形渲染的脏活累活交给了 GPU,但游戏玩法层面的逻辑(如角色高度查询、物理碰撞、地表材质判定)依然在 CPU 端运行。因此,系统需要在 CPU 端保留一份独立的、专门用于游戏玩法查询的地形数据结构。

本文 GPU 部分主要参考孤岛惊魂5地形渲染的设计思路,并结合项目实现,逐步介绍 GPU Driven Terrain 的核心知识。


二、理论基础:四叉树与空间剔除

1. 什么是四叉树 / 八叉树?

QuadTree
QuadTree

四叉树的构建过程非常直观:

  1. 取一个空间区域,将该区域分配给根节点
  2. 把该空间分为4 个相等的部分,作为根节点的子节点
  3. 持续此过程——每个空间继续四等分,分配给下一层子节点;
  4. 直到达到设定的深度或停止条件,没有子节点的节点称为叶节点
Subdivide
Subdivide

用三维建模的术语来说,这就像对网格进行细分(Subdivide)

八叉树(OcTree)是同样的概念,但它在Y 轴上也会进行划分——每个空间被切成 8 个相等的部分,而不是 4 个。当数据在垂直方向上变化剧烈时(例如装满方块的立方体空间),八叉树能给出更紧凑的边界。

2. 四叉树在 Culling(剔除)中的作用

想象我们有大量想要渲染的模型。最朴素的做法是:

遍历所有模型 → 检查每个是否在相机视图中 → 渲染。

对于成千上万的实例,这个流程会非常缓慢——因为我们对”那些根本不可能可见的整片区域”也付出了 O(N) 的检查代价

利用四叉树,我们可以将大列表拆成更小的列表,并跳过其中一些:

具体步骤:

  1. 用所有模型位置的总边界,确定四叉树需要覆盖的空间大小;
  2. 持续细分,直到每个子空间只包含少量模型;
  3. 把每个模型 Bounds.Contains 检查后,分配到对应的叶节点列表;
  4. 渲染时,从根节点开始向下检查视锥体相交:

在树的下一层,往往有一半的节点不在视野内,我们直接跳过整个分支及其所有子节点——这就是空间剔除的核心收益:用 O(log N) 替代 O(N)。


三、原型实现:基于 CPU 的四叉树实例化渲染

理论清楚后,先做一个最小原型:在 Unity 中用 Graphics.DrawMeshInstanced 批量绘制实例,并用 CPU 端四叉树/八叉树进行视锥剔除。

参考实现:实例网格基础四叉形/八叉形剔除Drawing Thousands of Meshes with DrawMeshInstanced / Indirect in Unity

1. 生成网格与基础绘制

我们先用 Graphics.DrawMeshInstanced 这个 API 来批量绘制大量实例。该函数至少需要:

参数 说明
Mesh 要绘制的网格
Submesh 子网格编号,单网格传 0
Material 必须使用支持 Instancing 的着色器
Matrix4x4[] 包含每个实例 TRS(位置/旋转/缩放)的矩阵列表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 一开始随机填充一个 Matrix4x4 列表,对应想要的实例数
List<Matrix4x4> matricesAll = new List<Matrix4x4>();
for (int i = 0; i < instances; i++)
{
Vector3 position = new Vector3(
Random.Range(-range, range), Random.Range(-range, range), Random.Range(-range, range));
Quaternion rotation = Quaternion.Euler(
Random.Range(-180, 180), Random.Range(-180, 180), Random.Range(-180, 180));
Vector3 scale = new Vector3(
Random.Range(0.5f, 1.5f), Random.Range(0.5f, 1.5f), Random.Range(0.5f, 1.5f));
matricesAll.Add(Matrix4x4.TRS(position, rotation, scale));
}

// Update 中一次性提交批量绘制
void Update()
{
Graphics.DrawMeshInstanced(mesh, 0, material, matricesAll);
}

如果实例数设到 100,000 级别,帧率会立刻肉眼可见地下降——批处理虽然帮了忙,但顶点压力依旧巨大,而且所有实例无论是否在视野内都被绘制。这正是引入空间剔除的动机。

2. 构建 CPU 四叉树(QuadTreeNode.cs

2.1 计算根包围盒

1
2
3
4
5
6
7
void SetBounds()
{
bounds = new Bounds(matricesAll[0].GetPosition(), Vector3.one);
for (int i = 0; i < matricesAll.Count; i++)
bounds.Encapsulate(matricesAll[i].GetPosition());
bounds.Expand(10); // 边缘留点 padding
}

2.2 递归构建子节点

四叉树只在 X/Z 轴划分(Y 轴保留全长),从父节点中心向 4 个角偏移 size/4,子节点尺寸为父节点的一半:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void BuildQuadTree(int depth)
{
Vector3 size = m_bounds.size / 4.0f; // 偏移量
Vector3 childSize = m_bounds.size / 2.0f; // 子节点尺寸
childSize.y = m_bounds.size.y; // Y 轴不划分
Vector3 center = m_bounds.center;

Bounds topLeft = new Bounds(new Vector3(center.x - size.x, center.y, center.z - size.z), childSize);
Bounds bottomRight = new Bounds(new Vector3(center.x + size.x, center.y, center.z + size.z), childSize);
Bounds topRight = new Bounds(new Vector3(center.x - size.x, center.y, center.z + size.z), childSize);
Bounds bottomLeft = new Bounds(new Vector3(center.x + size.x, center.y, center.z - size.z), childSize);

children.Add(new QuadTreeNode(topLeft, depth - 1, Octree));
children.Add(new QuadTreeNode(bottomRight, depth - 1, Octree));
children.Add(new QuadTreeNode(topRight, depth - 1, Octree));
children.Add(new QuadTreeNode(bottomLeft, depth - 1, Octree));
}

从四叉树到八叉树:只需在 Y 轴也偏移一层,原本 4 个子节点变成 2×4 = 8 个,构造逻辑完全对称。当数据在垂直方向上变化剧烈(如装满立方体空间的实例),八叉树能给出更紧的剔除边界。

2.3 把矩阵分配到叶节点

1
2
3
4
5
6
7
8
9
10
public void FindLeafForPoint(Matrix4x4 point)
{
if (m_bounds.Contains(point.GetColumn(3))) // GetColumn(3) 即 TRS 中的位置
{
if (children.Count == 0)
positionsHeld.Add(point); // 叶子节点:直接存入
else
foreach (var child in children) child.FindLeafForPoint(point);
}
}

构建完成后再调一次 ClearEmpty() 修剪空叶子,保持树结构紧凑。

3. 基于摄像机的视锥体剔除

剔除流程的三步走:

  1. 提取视锥平面GeometryUtility.CalculateFrustumPlanes(Camera.main) 拿到 6 个裁剪平面;
  2. AABB-视锥相交测试GeometryUtility.TestPlanesAABB(planes, bounds)
  3. 递归收集可见叶子的实例矩阵
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void RetrieveLeaves(Plane[] frustum, List<Bounds> boundsList, List<Matrix4x4> visibleMatrixList)
{
if (GeometryUtility.TestPlanesAABB(frustum, m_bounds))
{
if (children.Count == 0)
{
visibleMatrixList.AddRange(positionsHeld); // 叶子:吐出所有矩阵
boundsList.Add(m_bounds);
}
else
foreach (var child in children)
child.RetrieveLeaves(frustum, boundsList, visibleMatrixList);
}
}

主循环里只在相机变换发生变化时重算可见集合,再交给 DrawMeshInstanced

1
2
3
4
5
6
7
8
9
void Update()
{
if (cachedPos != Camera.main.transform.localToWorldMatrix)
{
GetFrustomData(); // 重新调用 quadTree.RetrieveLeaves(...)
cachedPos = Camera.main.transform.localToWorldMatrix;
}
Graphics.DrawMeshInstanced(mesh, 0, material, matricesVisible);
}

4. 原型成果

完整原型代码如下:

CullingInstancedDemo.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
using System;
using UnityEngine;
using System.Collections.Generic;
using Random = UnityEngine.Random;

/// <summary>
/// Demonstrates how to perform frustum culling with a quadtree/octree
/// and draw a large number of mesh instances efficiently using
/// Graphics.DrawMeshInstanced.
/// The component populates a spatial partition structure with randomly
/// positioned transforms and then only submits visible leaf nodes for
/// rendering each frame.
/// </summary>
// 说明:该组件演示如何使用四叉树/八叉树进行视锥体裁剪,
// 并通过 Graphics.DrawMeshInstanced 高效绘制大量实例。
// 组件会将随机生成的实例放入空间分区结构,每帧只提交可见的叶子节点进行绘制。
public class CullingInstancedDemo : MonoBehaviour
{
// How many meshes to draw.
// 要绘制的实例数量
public int instances;
// Range to draw meshes within.
// 实例生成的范围(在 -range 到 +range 之间)
public float range;
// Material to use for drawing the meshes.
// 绘制实例时使用的材质
public Material material;
// mesh to draw
// 要实例化绘制的 Mesh
public Mesh mesh;
// turn culling on and off to see difference
// 启用或禁用裁剪以进行对比测试
public bool cull = true;
// show the bounds of the quad/octtree leaves with cubes
// 显示四叉/八叉树叶子节点的包围盒(用于调试)
public bool drawBounds;
// subdivsion of quad/octtree
// 四叉/八叉树的最大递归深度(划分层数)
public int depth = 3;
// swap between octree and quadtree
// 切换为八叉树(true)或四叉树(false)
public bool Octree = true;
// max draw distance for meshes
// 实例允许的最大绘制距离(用于临时调整相机 farPlane)
public float maxDrawDistance = 125;

// quadtreedata ----------------------------------------------------------------------
QuadTreeNode quadTree;
// culling
private Plane[] cameraFrustumPlanes;
float cameraOriginalFarPlane;
// matrices
List<Matrix4x4> matricesVisible = new List<Matrix4x4>();
List<Matrix4x4> matricesAll = new List<Matrix4x4>();
// cached position for camera
Matrix4x4 cachedPos;
// mesh bounds
Bounds bounds;
// just for debug/visual reference
List<Bounds> boundsListVisible = new List<Bounds>();
GUIStyle style = new GUIStyle();

private void Start()
{
Setup();
}

private void OnEnable()
{
// Setup();
}

/// <summary>
/// Initializes the demo by generating random instance transforms,
/// computing the encompassing bounds, and constructing the quadtree.
/// This is called from Start() and can be invoked manually when
/// parameters change.
/// </summary>
// 初始化:生成随机实例矩阵,计算整体包围盒,并构建四叉/八叉树
// 可在 Start() 被调用,也可在参数变化后手动调用以重建结构
private void Setup()
{
// allocate array for six frustum planes (used during culling)
// 分配六个平面数组以保存相机视锥体的六个裁剪平面
cameraFrustumPlanes = new Plane[6];
// build a list of random matrices for every instance
for (int i = 0; i < instances; i++)
{
Vector3 position = new Vector3(Random.Range(-range, range), Random.Range(-range, range), Random.Range(-range, range));
Quaternion rotation = Quaternion.Euler(Random.Range(-180, 180), Random.Range(-180, 180), Random.Range(-180, 180));
Vector3 scale = new Vector3(Random.Range(0.5f, 1.5f), Random.Range(0.5f, 1.5f), Random.Range(0.5f, 1.5f));

Matrix4x4 mat = Matrix4x4.TRS(position, rotation, scale);
matricesAll.Add(mat);
}
SetBounds();
SetupQuadTree();
}

// debug / visual helper to display statistics on screen
// 调试 / 可视化:在屏幕上显示实例总数与本帧绘制数量
void OnGUI()
{
style.fontSize = 30;
GUI.Label(new Rect(10, 10, 300, 100),
"All Meshes: " + matricesAll.Count + "\n" +
"Meshes Drawn: " + matricesVisible.Count, style);
}
// draw gizmos for debugging: visible leaf bounds in green,
// and the overall root bounds in blue when drawBounds is true
// 绘制调试 Gizmos:绿色显示可见叶子包围盒,蓝色显示根包围盒
void OnDrawGizmos()
{
if (drawBounds)
{
Gizmos.color = new Color(0, 1, 0, 0.3f);
for (int i = 0; i < boundsListVisible.Count; i++)
{
Gizmos.DrawWireCube(boundsListVisible[i].center, boundsListVisible[i].size);
}
Gizmos.color = new Color(0, 0, 1, 0.3f);
Gizmos.DrawWireCube(bounds.center, bounds.size);
}
}

/// <summary>
/// Recomputes which instances are potentially visible by
/// extracting the camera frustum, optionally adjusting the
/// far clip to respect maxDrawDistance, and querying the
/// quadtree/octree. Results populate matricesVisible and
/// boundsListVisible for debugging.
/// </summary>
// 获取视锥体数据并查询四叉/八叉树,以更新可见实例与调试包围盒列表
// 为了限制绘制距离,本函数会临时修改相机的 farClipPlane
void GetFrustomData()
{
// temporarily shorten the camera far plane so the frustum
// culling honors the maximum draw distance configured by user.
// 临时缩短相机远裁面以使视锥裁剪遵循 maxDrawDistance
cameraOriginalFarPlane = Camera.main.farClipPlane;
Camera.main.farClipPlane = maxDrawDistance;
Plane[] planes = GeometryUtility.CalculateFrustumPlanes(Camera.main);
// restore the original far plane so we don't affect other
// rendering logic elsewhere in the scene.
// 恢复原始远裁面,避免影响场景中其他渲染逻辑
Camera.main.farClipPlane = cameraOriginalFarPlane;
// prepare lists for a fresh culling pass
// 清空上一帧数据,准备本次裁剪
boundsListVisible.Clear();
matricesVisible.Clear();
// ask the quadtree/octree for leaves that intersect the frustum
if (cull)
{
// 有裁剪时,检索与视锥相交的叶子并收集可见矩阵
quadTree.RetrieveLeaves(planes, boundsListVisible, matricesVisible);
}
else
{
// 未启用裁剪时,收集所有叶子的包围盒用于调试/可视化
quadTree.RetrieveAllLeaves(boundsListVisible);
}
}

/// <summary>
/// Computes a bounding box that contains every instance
/// transform. This bound is passed to the QuadTreeNode
/// constructor as the root region.
/// </summary>
// 计算包含所有实例的包围盒,并作为四叉/八叉树根节点的区域
void SetBounds()
{
// start with the first point to avoid creating an empty bounds
// 从第一个点初始化包围盒以避免创建空包围盒
bounds = new Bounds(matricesAll[0].GetPosition(), Vector3.one);
for (int i = 0; i < matricesAll.Count; i++)
{
bounds.Encapsulate(matricesAll[i].GetPosition());
}
// give a little padding so objects right on the edge still
// fall into the root node
// 适当扩展包围盒以包含边界上的对象
bounds.Expand(10);
}

/// <summary>
/// Builds the quadtree or octree using the previously calculated
/// bounds and depth. Each instance matrix is inserted, which
/// causes the tree to subdivide as necessary. Empty branches
/// are pruned afterwards.
/// </summary>
// 构建四叉树/八叉树:将每个实例插入树中,必要时发生细分,最后修剪空分支
void SetupQuadTree()
{
// create a new quadtree (or octree when Octree flag is true)
quadTree = new QuadTreeNode(bounds, depth, Octree);

// insert every matrix's position into the tree
for (int i = 0; i < matricesAll.Count; i++)
{
quadTree.FindLeafForPoint(matricesAll[i]);
}
// remove leaves that contain no points to keep the structure tight
// 修剪空叶子以保持树结构紧凑
quadTree.ClearEmpty();
}

private void Update()
{
// recompute visibility only when the camera moves to save work
// 仅在相机移动时重新计算可见性以节省计算
if (cachedPos != Camera.main.transform.localToWorldMatrix)
{
GetFrustomData();
cachedPos = Camera.main.transform.localToWorldMatrix;
}

// finally, issue the draw calls. we can either draw just the
// visible set returned by the tree or the complete set when
// culling is disabled, which is useful for performance comparisons.
// 最后发出绘制调用:根据 cull 标志绘制可见集合或全部实例
if (cull)
{
Graphics.DrawMeshInstanced(mesh, 0, material, matricesVisible);
}
else
{
Graphics.DrawMeshInstanced(mesh, 0, material, matricesAll);
}
}
}
QuadTreeNode.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
using UnityEngine;
using System.Collections.Generic;

// QuadTreeNode: 管理四叉树/八叉树节点及其数据
// QuadTreeNode: handles quadtree/octree node structure and storage
public class QuadTreeNode
{
public Bounds m_bounds;
List<QuadTreeNode> children = new List<QuadTreeNode>();
bool Octree;
List<Matrix4x4> positionsHeld = new List<Matrix4x4>();
// 存放该叶子节点中包含的实例矩阵(位置/旋转/缩放)
// Holds the matrices (position/rotation/scale) that belong to this leaf

public QuadTreeNode(Bounds bounds, int depth, bool octree)
{
m_bounds = bounds;
Octree = octree;
children.Clear();
// only build a new node is the depth is over 0
// 仅当 depth 大于 0 时才继续构建子节点(控制递归深度)
if (depth > 0)
{
// examples for octree or quadtree
// 根据 Octree 标志选择构建八叉树或四叉树
if (Octree)
{
BuildOcTree(depth);
}
else
{
BuildQuadTree(depth);
}
}
}


public void RetrieveAllLeaves(List<Bounds> list)
{
// find all leaf (childless) nodes for debug visuals
// 查找所有叶子(无子节点)用于调试可视化
if (children.Count == 0)
{
list.Add(m_bounds);
}
else
{
foreach (QuadTreeNode child in children)
{
child.RetrieveAllLeaves(list);
}
}
}


void BuildQuadTree(int depth)
{
// build a quadtree
// 构建四叉树
// size/4 for offsetting positions for childrens bounds centers
// size/4 用于偏移子节点包围盒中心的位置
Vector3 size = m_bounds.size;
size /= 4.0f;
// size/2 for childrens bounds size
// 子节点包围盒大小为父节点的一半
Vector3 childSize = m_bounds.size / 2.0f;
// y size is total size because we dont subdivide in that axis
// 在 Y 轴不做划分,所以保持 Y 尺寸与父节点一致
childSize.y = m_bounds.size.y;
Vector3 center = m_bounds.center;

// create bounds for every corner, only in x and z
// 为每个角创建包围盒(仅在 X 和 Z 平面划分)
Bounds topLeft = new Bounds(new Vector3(center.x - size.x, center.y, center.z - size.z), childSize);
Bounds bottomRight = new Bounds(new Vector3(center.x + size.x, center.y, center.z + size.z), childSize);
Bounds topRight = new Bounds(new Vector3(center.x - size.x, center.y, center.z + size.z), childSize);
Bounds bottomLeft = new Bounds(new Vector3(center.x + size.x, center.y, center.z - size.z), childSize);

// add children by creating a new node
// 通过创建新节点来添加子节点
children.Add(new QuadTreeNode(topLeft, depth - 1, Octree));
children.Add(new QuadTreeNode(bottomRight, depth - 1, Octree));
children.Add(new QuadTreeNode(topRight, depth - 1, Octree));
children.Add(new QuadTreeNode(bottomLeft, depth - 1, Octree));
}

void BuildOcTree(int depth)
{
// build an octree
// 构建八叉树
// size/4 for offsetting positions for childrens bounds centers
// size/4 用于偏移子节点包围盒中心的位置
Vector3 size = m_bounds.size;
size /= 4.0f;
// size/2 for childrens bounds size
// 子节点包围盒大小为父节点的一半
Vector3 childSize = m_bounds.size / 2.0f;
Vector3 center = m_bounds.center;

// layer 1, negative y axis offset
// 第一层(y 轴负方向偏移)
Bounds topLeft = new Bounds(new Vector3(center.x - size.x, center.y - size.y, center.z - size.z), childSize);
Bounds bottomRight = new Bounds(new Vector3(center.x + size.x, center.y - size.y, center.z + size.z), childSize);
Bounds topRight = new Bounds(new Vector3(center.x - size.x, center.y - size.y, center.z + size.z), childSize);
Bounds bottomLeft = new Bounds(new Vector3(center.x + size.x, center.y - size.y, center.z - size.z), childSize);

// layer 2, positive y axis offset
// 第二层(y 轴正方向偏移)
Bounds topLeft2 = new Bounds(new Vector3(center.x - size.x, center.y + size.y, center.z - size.z), childSize);
Bounds bottomRight2 = new Bounds(new Vector3(center.x + size.x, center.y + size.y, center.z + size.z), childSize);
Bounds topRight2 = new Bounds(new Vector3(center.x - size.x, center.y + size.y, center.z + size.z), childSize);
Bounds bottomLeft2 = new Bounds(new Vector3(center.x + size.x, center.y + size.y, center.z - size.z), childSize);

// add both layers of children by creating a new node for each
// 为每个计算出的子包围盒创建子节点并加入 children 列表
children.Add(new QuadTreeNode(topLeft, depth - 1, Octree));
children.Add(new QuadTreeNode(bottomRight, depth - 1, Octree));
children.Add(new QuadTreeNode(topRight, depth - 1, Octree));
children.Add(new QuadTreeNode(bottomLeft, depth - 1, Octree));

children.Add(new QuadTreeNode(topLeft2, depth - 1, Octree));
children.Add(new QuadTreeNode(bottomRight2, depth - 1, Octree));
children.Add(new QuadTreeNode(topRight2, depth - 1, Octree));
children.Add(new QuadTreeNode(bottomLeft2, depth - 1, Octree));
}

public void RetrieveLeaves(Plane[] frustum, List<Bounds> boundsList, List<Matrix4x4> visibleMatrixList)
{
// check if frustum is overlapping with bounds
// 检查视锥体是否与当前节点包围盒相交
if (GeometryUtility.TestPlanesAABB(frustum, m_bounds))
{
// if we are a leaf node, add the list of matrices to the combining list in culllinginstanceddemo
// 若为叶子节点,则将此叶子持有的所有矩阵添加到可见矩阵列表
if (children.Count == 0)
{
visibleMatrixList.AddRange(positionsHeld);
boundsList.Add(m_bounds);
}
// if we have children, check those
// 否则继续递归检查子节点
else
{
foreach (QuadTreeNode child in children)
{
child.RetrieveLeaves(frustum, boundsList, visibleMatrixList);
}
}
}
}

public void FindLeafForPoint(Matrix4x4 point)
{
// check if matrix4x4 point is inside of the bounds of this node
// 检查矩阵表示的位置是否位于当前节点的包围盒内
if (m_bounds.Contains(point.GetColumn(3)))
{
// if we are a leaf node, add the point to the list this leaf holds
// 若为叶子节点,则将该矩阵加入此叶子的持有集合
if (children.Count == 0)
{
positionsHeld.Add(point);
}
// if we have children, check those
// 否则继续递归分配到子节点
else
{
foreach (QuadTreeNode child in children)
{
child.FindLeafForPoint(point);
}
}
}
}

public bool ClearEmpty()
{
// if the node is empty, we can safely delete it
// 如果节点为空且没有子节点,则该节点可被移除以节省空间
bool delete = false;
if (children.Count > 0)
{
// dont delete things from a list when iterating forward, because you can miss items, so we are iterating backwards here
// 删除列表项时不要正向遍历以避免跳过元素,故在此反向遍历
int i = children.Count;
while (i > 0)
{
i--;
if (children[i].ClearEmpty())
{
children.RemoveAt(i);
}
}
}
// if its empty and a leaf node, return true
// 如果既没有持有的矩阵也没有子节点,则返回 true,表示该节点可被删除
if (positionsHeld.Count == 0 && children.Count == 0)
{
delete = true;
}
return delete;
}

}

在 100,000 实例规模下,开启四叉树剔除后:

  • 绘制数量:100,000 → ~34,000(视野相关)
  • 帧率:33 FPS → 250+ FPS(参考数据,因机器而异)
  • 顶点压力:从 89.7M 降到 33.9M

至此我们得到一个能跑、效率不错的 CPU 端原型。但当我们把目光转向真正的”大世界”时,问题来了。


四、瓶颈与进化:为什么我们需要 GPU-Driven?

1. CPU 方案的局限

我们把场景规模从”装满几万实例的立方体”放大到”10240m × 10240m 的开放世界地形”——

  • 若以 8m 一个 Patch 铺满整个世界,总共需要 1280 × 1280 = 1,638,400 个地块;
  • 即使引入 LOD 四叉树,CPU 端每帧仍要做:节点遍历、LOD 评估、视锥剔除、Matrix4x4 列表组装、传给 DrawMeshInstanced
  • 数据流向是反的:CPU 计算出实例列表 → 拷贝到 GPU 显存 → GPU 才开始绘制。每帧的 CPU↔GPU 数据搬运成为新的瓶颈;
  • Graphics.DrawMeshInstanced 还有单批最多 1023 实例的硬限制。

更深层的问题是:地形渲染天然容易遭遇 Vertex Bound(顶点性能瓶颈)。Patch 网格密集,但视野中真正需要高精度的只有近处一小撮——CPU 没有任何”按像素细颗粒度调整 LOD”的能力。

2. 转移到 GPU 的四大核心优势

将整个剔除/LOD 管线搬到 GPU(即 GPU-Driven Terrain),可以归结为 “打破性能瓶颈”与”建立高效的渲染生态”。具体有以下四大核心优势:

  • 数据流向高度契合:渲染相关的数据最终仅由 GPU 来消耗,因此直接在 GPU 上进行处理是最自然的选择,减少了 CPU↔GPU 带宽传输开销
  • 解放 CPU:转移工作负载可以直接消除 CPU 在地形顶点处理和剔除上的计算开销。
  • 榨干 GPU 性能:这看似矛盾,但其实是该方案的精髓。利用 GPU 上的可用数据,可以实现更精确的 LOD 选择和最大程度的顶点剔除(Vertex Culling)。因为地形渲染极易遭遇顶点性能瓶颈(Vertex Bound),这种深度的剔除优化使得该方案不仅没有增加 GPU 的负担,反而”物超所值”。
  • 系统间的协同效应:当地形信息直接驻留在 GPU 的数据结构中时,其他基于 GPU 的渲染系统(例如负责生成树木、岩石、草地等的 Compute Shader)可以直接访问并利用这些基础数据,大幅提升整体场景的渲染效率。

下面进入正题——GPU-Driven Terrain 的整体架构。


五、GPU-Driven Terrain 核心架构

GPU Terrain Implementation

系统基于 GPU Driven + 四叉树(QuadTree)+ 实例化渲染(Instancing) 的思想构建。在整个渲染管线中,CPU 端 TerrainManager 仅负责分发 Dispatch 指令并维护 Compute Buffers,真正的视锥剔除、遮挡剔除、LOD 评估以及最终绘制数据的生成都在 GPU(Compute Shader)中完成

1. 地形分块

可以建立以下对地形分块的概念:

  • World 大小为 10240m × 10240m
  • QuadTree 有 6 层,从上往下分别代表 LOD5 ~ LOD0
  • LOD 层级:LOD5 有 5×5 = 25 个节点,往下依次 ×2,直到 LOD0 有 160×160 个 Node;在最粗糙的 LOD 下,整个世界由 个大区块(Node)组成
  • 单个 Node 的覆盖范围从 LOD5 ~ 0 依次为 [2048m, 1024m, 512m, 256m, 128m, 64m],所有 LOD 层级的节点 ID 连续编号,上限 MAX_NODE_ID = 34124(等比数列求和)
  • 基础网格(Patch):地形被划分为多个 Patch。每个 Patch 是一个 网格的 Plane
  • 我们称 64m × 64m 为 Sector,即 LOD0 的 Node 大小
  • 在实际渲染的时候,我们会将 Node 打散成 8×8 共 64 个 Patch 作为基础单位提交给 GPU 进行 Instance 渲染

假如不使用 LOD,用 Patch 铺满整个世界,那么 的大世界,总共要渲染 1280×1280 = 1,638,400 个地块——数量巨大,性能不可接受。所以引入 LOD 四叉树:远处地块用低分辨率网格(放大 Patch 实现),近处用高分辨率网格。

2. 数据结构

系统在 GPU 端主要维护了以下几个关键结构:

  • NodeDescriptor:记录节点是否被继续细分(b_divide
  • PatchDescriptor:描述最终需要渲染的区块属性,包括世界坐标(position)、高度区间(minMaxHeight)、当前 LOD 级别(lod)以及用于处理接缝的压缩 LOD 过渡信息(lodTransPacked

3. 资源管理(TerrainAsset / TerrainHelper

  • MinMaxHeightMap:RG32 格式,带 Mipmap,每个 Mip 对应一个 LOD 层级的高度范围,用于 AABB 构建和节点评价的 Y 坐标
  • LOD Map:每帧动态生成,R8 格式,160×160,enableRandomWrite = trueFilterMode.Point(不需要插值)
  • Patch Mesh:16×16 网格、边长 8m 的平面网格,居中生成(顶点偏移 -totalSize * 0.5f),在 TerrainHelper.CreateTerrainPlaneMesh 中静态创建并缓存

所有 ComputeBufferDispose 中统一释放,ShaderIDs 内部类预缓存所有属性 ID,避免每帧字符串 Hash 开销。

4. GPU 渲染管线总览

TerrainManager.csUpdate 中,每帧按顺序执行以下 4 个核心 Pass:

GPU 驱动地形系统 每帧渲染管线

Pass 1 TraverseQuadTree(四叉树遍历)

从顶层 LOD5 的 25 个节点自顶向下遍历,对每个节点调用 EvaluateNode:计算节点中心到相机的平方距离,与 nodeSize × distanceEvaluation 的平方比较。距离足够近且 LOD > 0 则细分,将子节点(nodeIndex * 2 的四个方向)推入 ProduceBuffer;否则写入 FinalNodeList

  • Ping-Pong Buffer:每一 Pass 交换 Consume/Produce Buffer,通过 DispatchCompute(..., _indirectArgsBuffer, 0) 实现 GPU 驱动的间接调度,无需 CPU 回读节点数量。
  • 节点 ID 计算:使用等比数列求和公式来计算节点在显存中的全局偏移量:,并通过位运算 (1u << (2u * m)) 加速计算。

Pass 2 BuildLodMap(构建 LOD 贴图)

构建地形 LOD 贴图是为了后续处理地形接缝。

  • 对 160×160 的 Sector 空间(每个 Sector 是世界最小单元),从 LOD 5 向 0 遍历,找到该 Sector 所属的第一个未细分节点,将其 LOD 值写入 _LodMap(R8 格式 RenderTexture)。
  • _LodMap 记录了地形上每一个基础 Sector 对应的最终 LOD 级别,方便相邻 Patch 快速查询邻居的 LOD 状态,是 Pass 3 接缝计算的依据。

Pass 3 CullPatches(剔除与生成 Patch)

这部分是 GPU Driven 性能优化的核心所在。剔除分两层:

  • 视锥体剔除(Frustum Cull):对 Patch 的 AABB(利用 minMaxHeightMap 获取高度范围)做六平面测试,使用 “AABB 投影半径” 方法,一次点积完成整个包围盒测试。
  • Hi-Z 遮挡剔除(Hi-Z Occlusion Cull)
    • 将世界空间的 AABB 投影到屏幕的 UV 和深度空间(GetBoundsUVD)。
    • 根据 AABB 在屏幕上的大小,计算出需要采样的 Hi-Z Map 对应的 Mipmap 层级。
    • 采样该层级的 4 个极值像素深度,如果物体自身的最浅深度(考虑反转 Z)依然被遮挡物覆盖,则将其剔除。

LOD 过渡信息录入:对于存活的 Patch,通过采样 _LodMap 判断其上下左右四个方向的邻居 LOD 是否比自己更粗糙,将差值打包存入 lodTransPacked。随后追加进 VisiblePatches(AppendBuffer)中。

1
2
3
4
5
6
7
8
9
10
11
12
[numthreads(8,8,1)]
void CullPatches(uint3 id : SV_DispatchThreadID, uint3 groupId : SV_GroupID, uint3 groupThreadId : SV_GroupThreadID)
{
uint3 nodeIndex = FinalNodeList[groupId.x];
uint2 patchOffset = groupThreadId.xy;
// 生成 Patch
PatchDescriptor patch = CreatePatch(nodeIndex, patchOffset);
Bounds bounds = GetPatchBounds(patch);
if (Cull(bounds)) return;
SetLodTrans(patch, nodeIndex, patchOffset);
VisiblePatches.Append(patch);
}

Pass 4 DrawMeshInstancedIndirect(间接绘制)

  • CPU 端无需回读(Readback),每个实例的 Vertex Shader 通过 SV_InstanceID 索引 _VisiblePatchList 读取 PatchDescriptor,完成:缩放(scale = 1 << lod)、位移(世界坐标)、高度图采样(置换顶点 Y)、法线图采样(计算简单漫反射光照)。
  • 调用 Graphics.DrawMeshInstancedIndirect 一次性完成所有可见地形块的绘制。

六、核心改造:GPU 上的四叉树 LOD 计算

四叉树 LOD 是整个 GPU 驱动地形中最核心、也最考验底层逻辑设计的部分。这里我们对比第三章的 CPU 树结构,凸显 GPU 化改造的关键点。

CPU vs GPU:核心思路对比

维度 CPU 原型(第三章) GPU-Driven(本章)
节点存储 嵌套 class 对象,childrenList<QuadTreeNode> 一维 ComputeBuffer<NodeDescriptor>,按等比数列偏移寻址
遍历方式 递归 RetrieveLeaves(深度优先) Ping-Pong 双缓冲 + 逐层 LOD Pass(广度优先)
剔除测试 GeometryUtility.TestPlanesAABB(C# API) Compute Shader 中的 AABB 投影半径 + Hi-Z
可见集合输出 List<Matrix4x4> 拷贝回 CPU AppendBuffer 直接驻留 GPU
绘制 API Graphics.DrawMeshInstanced Graphics.DrawMeshInstancedIndirect(无 CPU 回读)
LOD 邻居查询 不支持 光栅化 _LodMap,O(1) 查询

1. 四叉树的空间架构定义

在进入计算之前,系统对世界进行了宏观的网格划分:

  • 初始状态(最粗糙级):整个地形在最高 LOD 层级(MAX_TERRAIN_LOD = 5)下,并不是一个单一的根节点,而是由 MAX_LOD_NODE_COUNT = 5)个大区块组成。
  • 满树状态(最精细级):如果全部细分到 LOD 0,最大节点总数为 个(MAX_NODE_ID = 34124)。
  • 节点描述(NodeDescriptor:显存中维护了一个结构体数组,每个节点仅包含一个 b_divide 字段,用于标记该节点当前帧是否被一分为四。

2. 核心运行机制:自顶向下的 Ping-Pong 迭代

四叉树的遍历在 TraverseQuadTree Kernel 中完成,采用的是逐层降维的方式:

  • 双缓冲交替(Ping-Pong Buffers):系统在 C# 端维护了两个临时 Compute Buffer(_tempANodeListBuffer_tempBNodeListBuffer)。
  • 迭代过程
    1. 循环从最粗糙的 LOD 5 开始,降至 LOD 0
    2. 每一层循环中,GPU 从 ConsumeNodeList 中取出当前层的节点进行评估。
    3. 如果节点需要细分,则计算出 4 个子节点的索引(乘以 2 并加上偏移 (0,0)、(1,0)、(0,1)、(1,1)),塞入 ProduceNodeList 中供下一层级使用,并将当前节点的 b_divide 设为 true
    4. 如果不需要细分(或已经到底),则将其塞入 AppendFinalNodeList 作为最终需要渲染的节点,并将 b_divide 设为 false
    5. 下一次循环时,交换读写 Buffer(Ping-Pong 交换)

对照 CPU 版的 RetrieveLeaves 递归——递归调用栈、堆上对象、List.AddRange 全没了,全部数据扁平地流过 GPU 缓冲。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 遍历四叉树,进行节点评价,生成 AppendFinalNodeList 和 NodeDescriptors
[numthreads(1,1,1)]
void TraverseQuadTree(uint3 id : SV_DispatchThreadID)
{
uint2 nodeIndex = ConsumeNodeList.Consume();
uint nodeId = GetNodeId(nodeIndex, _PassLOD);
NodeDescriptor desc = NodeDescriptors[nodeId];
if (_PassLOD > 0 && EvaluateNode(nodeIndex, _PassLOD))
{
// divide
ProduceNodeList.Append(nodeIndex * 2);
ProduceNodeList.Append(nodeIndex * 2 + uint2(1, 0));
ProduceNodeList.Append(nodeIndex * 2 + uint2(0, 1));
ProduceNodeList.Append(nodeIndex * 2 + uint2(1, 1));
desc.b_divide = true;
}
else
{
AppendFinalNodeList.Append(uint3(nodeIndex, _PassLOD));
desc.b_divide = false;
}
NodeDescriptors[nodeId] = desc;
}

3. LOD 评价函数(EvaluateNode

决定一个节点是否需要”分裂”的逻辑非常直接,包括:

  • 与摄像机的距离
  • 高度变换剧烈程度
  • etc.

本项目只使用 距离与尺寸的比例关系

评价标准:根据外部传入的评价系数 _NodeEvaluationC.x 乘以 nodeSize 得到阈值。如果距离平方小于阈值平方(即离相机足够近),则返回 true,触发细分。

1
2
3
4
5
6
7
8
9
10
11
12
bool EvaluateNode(uint2 nodeIndex, uint lod)
{
float3 positionWS = GetNodePositionWS(nodeIndex, lod);
float nodeSize = GetNodeSize(lod); // 当前 LOD 节点边长
float3 offset = _CameraPosWS - positionWS;
float sqrDistance = dot(offset, offset); // 相机到节点中心向量的距离平方

float threshold = nodeSize * _NodeEvaluationC.x;
float sqrThreshold = threshold * threshold;

return sqrDistance < sqrThreshold;
}

4. 显存管理优化:一维索引(GetNodeId

四叉树本质上是层级结构,但 GPU 的 Buffer 是一维数组。为了不产生读写冲突并快速定位节点数据,代码中使用了数学公式来计算节点在 1D 数组中的偏移量:

  • 等比数列求和:计算当前 LOD 距离最粗糙 LOD 细分了多少次(设为 )。因为每细分一次,节点数是上一层的 4 倍,这是一个公比为 4 的等比数列。
  • 核心公式(其中 是初始的 )。
  • 位运算加速:代码中使用了 1u << (2u * m) 来极速替代 的计算,极大压榨了 GPU 算力。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
uint GetNodeIdOffset(uint lod)
{
uint m = MAX_TERRAIN_LOD - lod; // 当前 LOD 距离最粗糙 LOD 细分的次数
uint baseCountSq = MAX_LOD_NODE_COUNT * MAX_LOD_NODE_COUNT; // N^2
// 核心数学公式:等比数列求和 N^2 * (4^m - 1) / 3
// 1u << (2u * m) 完全等价于 4^m,且位运算速度极快
return baseCountSq * (((1u << (2u * m)) - 1u) / 3u);
}

uint GetNodeId(uint3 nodeId)
{
uint nodeCount = MAX_LOD_NODE_COUNT << (MAX_TERRAIN_LOD - nodeId.z);
uint offset = GetNodeIdOffset(nodeId.z);
return offset + nodeId.y * nodeCount + nodeId.x;
}

uint GetNodeId(uint2 nodeIndex, uint lod)
{
return GetNodeId(uint3(nodeIndex, lod));
}

5. 生成 LOD Map

四叉树遍历完成后,最终的 LOD 信息会被光栅化到一张全局的二维纹理 _LodMap 中(通过 BuildLodMap Kernel):

  • 该 Pass 遍历每一个最小粒度的网格(Sector)。
  • 自顶向下(从最粗糙到最精细)查询当前空间对应的四叉树节点状态。
  • 一旦遇到 b_divide == false 的节点,说明该处停止了细分,便将当前的 LOD 值写入 _LodMap 纹理并直接 return
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[numthreads(8,8,1)]
void BuildLodMap(uint3 id : SV_DispatchThreadID)
{
uint2 sectorIndex = id.xy;
[unroll]
for (uint lod = MAX_TERRAIN_LOD; lod >= 0; lod--)
{
uint sectorCount = GetSectorCountPerNode(lod);
uint2 nodeIndex = sectorIndex / sectorCount;
uint nodeId = GetNodeId(nodeIndex, lod);
NodeDescriptor desc = NodeDescriptors[nodeId];
if (!desc.b_divide)
{
_LodMap[sectorIndex] = lod * rcp(MAX_TERRAIN_LOD);
return;
}
}
_LodMap[sectorIndex] = 0;
}

这张 Map 脱离了树状结构,使得后续生成 Patch 时,任意地形块都可以通过简单的 UV 采样,以 的时间复杂度瞬间知道自己周围邻居的 LOD 级别,从而完美解决地形接缝问题。


七、GPU 剔除与管线绘制

继续与 CPU 原型对照——上一章我们用 GeometryUtility.TestPlanesAABB,本章把整套剔除搬到 Compute Shader 中,并把 Graphics.DrawMeshInstanced 升级为 Graphics.DrawMeshInstancedIndirect

1. GPU 端的视锥体剔除

不再依赖 C# 的 GeometryUtility——在 Compute Shader 中直接对 Patch 的 AABB(高度范围来自 MinMaxHeightMap)做六平面测试,使用 AABB 投影半径 方法:一次点积完成整个包围盒测试,比逐点测试快得多。

2. Hi-Z 遮挡剔除

CPU 原型完全没有遮挡剔除能力——你站在山后,山前的所有地块依旧会被绘制。GPU 端则可以做性价比极高的遮挡剔除

  • 将世界空间的 AABB 投影到屏幕的 UV 和深度空间(GetBoundsUVD)。
  • 根据 AABB 在屏幕上的大小,计算出需要采样的 Hi-Z Map 对应的 Mipmap 层级。
  • 采样该层级的 4 个极值像素深度,如果物体自身的最浅深度(考虑反转 Z)依然被遮挡物覆盖,则剔除。

详细原理参见:

3. 绘制指令升级:从 DrawMeshInstancedDrawMeshInstancedIndirect

维度 Graphics.DrawMeshInstanced Graphics.DrawMeshInstancedIndirect
实例上限 单批 1023 仅受显存限制
数据来源 CPU 端 Matrix4x4[] GPU ComputeBuffer_VisiblePatchList
CPU 回读 必须(要把可见列表传过去) 不需要(间接参数也在 GPU)
适配 GPU-Driven 不适配 完美适配

整个调用链变成:Compute Shader 把可见 Patch 直接 Append_VisiblePatchListGraphics.DrawMeshInstancedIndirect 通过间接参数缓冲一次性绘制。CPU 完全不需要知道这一帧到底要画多少个 Patch,这是与第三章 CPU 原型最本质的区别。


八、渲染细节:地形材质与 LOD 接缝修复

地形的表面渲染在 terrain.shader 中完成,当前实现比较基础。

1. 高度场采样

在顶点着色器中,根据基础顶点坐标和 _HeightMap 采样出对应的高度信息,并应用高度缩放和偏移量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// uniform float3 _WorldSize;   // 地形尺寸
// Texture2D _HeightMap;

/// <summary>
/// 根据世界空间的 XZ 坐标,采样高度图并返回实际的世界高度(Y 轴)
/// </summary>
float SampleTerrainHeight(float2 positionXZ)
{
// 1. 将物理世界的 XZ 坐标映射到 0~1 的 UV 坐标
// (+0.5 与 +1 用于处理半像素偏移,防止边缘越界)
float2 heightUV = (positionXZ + (_WorldSize.xz * 0.5) + 0.5) / (_WorldSize.xz + 1);

// 2. 在顶点着色器中显式采样第 0 级 Mipmap 的高度(范围 0~1)
float rawHeight = tex2Dlod(_HeightMap, float4(heightUV, 0.0, 0.0)).r;

// 3. 乘以世界的最大高度,还原为真实的物理高度
return rawHeight * _WorldSize.y;
}

2. LOD 接缝处理(Seam Fix)

针对相邻地形 Patch 之间由于 LOD 层级不同而产生的网格接缝问题(LOD Seam)。核心处理思路是将高精度边缘的顶点”吸附(Snap)”到低精度边缘的顶点位置上

具体步骤(FixLODConnectSeam):

  1. lodTransPacked 解包出四条边(左/下/右/上)各自的 LOD 差值;
  2. 用位运算批量计算退化步长:mask = (1 << lodTrans) - 1,即模数掩码;
  3. 判断当前顶点是否处于对应边缘(vertexIndex.x == 0 等);
  4. 对处于边缘的顶点计算偏移量并修正 XZ 坐标和 UV,一次赋值完成。

整个算法没有 if-else 分支,全用向量乘法和掩码实现,GPU 友好。

FixLODConnectSeam
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 修复 LOD 接缝(消除网格裂缝)
// w
// ---------
// | |
//x | | z
// ---------
// y
// =========== 修复 LOD 接缝 ==== Optimization
void FixLODConnectSeam(inout float4 vertex, inout float2 uv, RenderPatch patch)
{
uint4 lodTrans = patch.lodTrans;

// 保留全局早退:如果整个 Patch 都没有接缝处理需求,直接跳过
if (all(lodTrans == 0)) return;

uint2 vertexIndex = (uint2)floor((vertex.xz + PATCH_MESH_SIZE * 0.5 + 0.01) / PATCH_MESH_GRID_SIZE);
float uvGridStrip = 1.0 / PATCH_MESH_GRID_COUNT;

// 1. 批量算出 4 个方向的取模掩码(Mask)
uint4 mask = (uint4(1u, 1u, 1u, 1u) << lodTrans) - 1u;

// 2. 批量计算 4 个方向的取模结果(modIndex)
// 左(x)依赖 y,下(y)依赖 x,右(z)依赖 y,上(w)依赖 x
uint4 modIndex = uint4(vertexIndex.y, vertexIndex.x, vertexIndex.y, vertexIndex.x) & mask;

// 3. 构造边缘判断遮罩(Edge Mask):在边缘则为 1.0,不在则为 0.0
float4 onEdge = float4(
vertexIndex.x == 0 ? 1.0 : 0.0,
vertexIndex.y == 0 ? 1.0 : 0.0,
vertexIndex.x == PATCH_MESH_GRID_COUNT ? 1.0 : 0.0,
vertexIndex.y == PATCH_MESH_GRID_COUNT ? 1.0 : 0.0
);

// 4. 计算右边缘(z)和上边缘(w)特有的反向偏移量
uint offsetZ = ((1u << lodTrans.z) - modIndex.z) * (modIndex.z > 0 ? 1u : 0u);
uint offsetW = ((1u << lodTrans.w) - modIndex.w) * (modIndex.w > 0 ? 1u : 0u);

// 5. 合并最终的位移系数(只有处在对应边缘上的顶点位移才不为 0)
// X 轴位移:受上边缘(w)正向影响,受下边缘(y)反向影响
float finalOffsetX = (float)offsetW * onEdge.w - (float)modIndex.y * onEdge.y;
// Z 轴位移:受右边缘(z)正向影响,受左边缘(x)反向影响
float finalOffsetZ = (float)offsetZ * onEdge.z - (float)modIndex.x * onEdge.x;

// 6. 统一执行位移(从头到尾只有这一次赋值操作)
vertex.x += finalOffsetX * PATCH_MESH_GRID_SIZE;
vertex.z += finalOffsetZ * PATCH_MESH_GRID_SIZE;

uv.x += finalOffsetX * uvGridStrip;
uv.y += finalOffsetZ * uvGridStrip;
}

九、结语与 Future Work

回顾全文,我们沿着”理论 → CPU 原型 → GPU 进化“的脉络,搭建出了一套完整的 GPU-Driven 地形渲染框架:

  • 用四叉树空间剔除把 O(N) 的暴力遍历降到 O(log N);
  • 用 Ping-Pong 缓冲把递归遍历改写成 GPU 友好的逐层迭代;
  • _LodMap 把树状邻居查询降到 O(1);
  • 用 Hi-Z + 视锥双重剔除把可见集合压到最小;
  • DrawMeshInstancedIndirect 彻底斩断 CPU 回读链路。

受限于时间关系,本项目主要跑通并复刻了 GPU-Driven 的核心基础原型(四叉树分割、视锥/Hi-Z 剔除、LOD 接缝处理等)。目前尚未包含复杂的地形材质混合系统,但在后续的迭代中,可以结合 RVT(运行时虚拟纹理)等技术,将其拼装成一个完整、工业级的地形渲染模块。

🔗 项目开源地址

📦
GitHub - zerls/TerrainDemo
TerrainRender

后续进阶与拓展方向(Future Work)

  • 阴影剔除分离(Shadow Culling Separation)
    • 问题:目前的 VisiblePatches 完全依赖主相机的视锥体剔除。如果一座山坡正好在玩家身后(被剔除),但太阳光正好从玩家背后照过来,这座山就不会投射阴影到玩家视野内,导致严重的阴影穿帮(Shadow Popping)
    • 解决思路
      • 在 C# 端将 Buffer 一分为二,创建 _mainCameraVisibleBuffer_shadowCasterVisibleBuffer
      • Dual DispatchCullPatches 核函数每帧 Dispatch 两次。一次传入主相机矩阵进行精准剔除;一次传入光源相机矩阵(或稍微放宽的灯光包围盒范围)进行阴影剔除。
      • Shader 分离:在 terrain.shader 中,主渲染 Pass 读取 Main Buffer,而 ShadowCaster Pass 专门读取 Shadow Buffer。
  • 超大纹理与多地表混合(SVT / RVT / Texture Array)
    • 问题:现阶段仅使用了一套 _AlbedoMap_NormalMap。面对动辄 10km × 10km 的大世界,单张贴图精度远远不够(地表极其模糊);但如果将贴图切碎按材质赋予,材质球数量暴增,又会破坏 GPU Instancing 的初衷。
    • 解决思路
      • 方案 A(Texture Array):将草地、泥土、岩石等多套贴图打包成一个 Texture2DArray。在 Compute Shader 生成 Patch 时,额外采样控制图,计算出该 Patch 占主导地位的地表材质 Index 存入 PatchDescriptor,传给 Fragment Shader 进行动态采样与混合。
      • 方案 B(RVT - Runtime Virtual Texturing):直接利用 URP 的 RVT 系统。底色可以直接烘焙在一张极低分辨率的 Global Map 上;在摄像机周围区域,利用高度图和材质权重图实时在内存中光栅化生成一张高精度 RVT,从而实现无限细节的无缝地表。
  • CPU 与 GPU 的物理碰撞同步(Physics Collision)
    • 问题:地形完全由 GPU 生成,甚至在 Vertex Shader 中发生了高度位移。CPU 端的物理引擎(如 MeshCollider)对地面的真实起伏一无所知,导致角色会掉下虚空或无法行走。
    • 解决思路
      • 角色贴地:放弃在 CPU 生成数百万顶点的 MeshCollider。直接在 CPU 端维护一份二维高度图数据 float[,]。每次角色判定脚底高度时,在 CPU 端利用坐标换算,双线性插值采样该数组即可,速度极快。
      • 复杂物理碰撞(载具/刚体):采用按需加载(Streaming) 的思路。只在玩家周围的 9 宫格(Node)范围内,利用 CPU 高度图动态生成少量低精度 MeshCollider,挂载到隐藏的 GameObject 上。随着玩家移动,这些 Collider 的顶点数据被循环复用和更新。
  • 植被与细节网格联动(GPU Foliage / Grass System)
    • 问题:地表有了,但缺少海量的草地和树木。传统的 CPU 种草方案 DrawCall 极高且难以和动态高度图完美贴合。
    • 解决思路
      • 既然地形的高度、法线以及权重分布(_ControlMap)都在 GPU 显存中,可以直接利用这些数据构建 GPU 驱动的植被系统
      • 新开一个 Compute Shader,根据 _ControlMap 的权重(决定哪里长草、长什么草),在对应的地形 Patch 范围内随机生成数百万棵草的 TRS 矩阵。
      • 在 GPU 端读取地形的真实高度调整草根的 Y 轴坐标,最后同样利用 DrawMeshInstancedIndirect 一次性渲染海量植被。

🦜
GPUDrivenTerrain 学习笔记
语雀链接