Banner
GPUDrivenTerrain
🦜
GPUDrivenTerrain 学习笔记
语雀链接

将地形的计算和渲染逻辑从传统 CPU 转移到 GPU(即 GPU-Driven Terrain),是现代 3D 游戏引擎(如虚幻 5 的 Nanite 早期思想、各种开放世界引擎)应对庞大世界渲染的核心趋势 。 其根本原因可以归结为 “打破性能瓶颈”与“建立高效的渲染生态” 。具体有以下四大核心优势

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

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

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

系统宏观架构

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

地形分块

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

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

假如我们不使用 LOD,用Patch铺满整个世界,那么 的大世界,总共要渲染 1280 x 1280 = 1638400 地块。这个数量是巨大的,显然性能是不可接受。

所以我们引入LOD四叉树,远处的地块采用低分辨率网格(可以通过放大Patch实现),近处采用高分辨率网格,从而提升性能。

数据结构

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

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

资源管理

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 开销。


GPU 渲染管线

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

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

Pass1 TraverseQuadTree(四叉树遍历)

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

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

Pass2 BuildLodMap(构建 LOD 贴图)

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

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

Pass3 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);
}

Pass4 DrawMeshInstancedIndirect(间接绘制)

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

四叉树 (QuadTree) LOD

四叉树 (QuadTree) LOD 是整个 GPU 驱动地形中最核心、也最考验底层逻辑设计的部分孤岛惊魂5将传统在 CPU 端进行的“节点遍历与评估”完全搬到了 Compute Shader 中。以下是重点介绍:


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

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

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

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

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

  • 双缓冲交替 (Ping-Pong Buffers): 系统在 C# 端维护了两个临时 Compute Buffer (_tempANodeListBuffer_tempBNodeListBuffer)。
  • 迭代过程: 1. 循环从最粗糙的LOD 5开始,降至LOD 0
    1. 每一层循环中,GPU 从ConsumeNodeList中取出当前层的节点进行评估。
    2. 如果节点需要细分,则计算出 4 个子节点的索引(乘以 2 并加上偏移(0,0), (1,0), (0,1), (1,1)),塞入ProduceNodeList中供下一层级使用,并将当前节点的b_divide设为true
    3. 如果不需要细分(或已经到底),则将其塞入AppendFinalNodeList作为最终需要渲染的节点,并将b_divide设为false
    4. 下一次循环时,交换读写 Buffer(Ping-Pong 交换)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//遍历四叉树,进行节点评价,生成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
13
bool EvaluateNode(uint2 nodeIndex,uint lod){
float3 positionWS = GetNodePositionWS(nodeIndex,lod);
//当前 LOD 级别的节点边长
float nodeSize = GetNodeSize(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; // m 代表当前 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中(通过BuildLodMapKernel):

  • 该 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剔除

项目中主要实现了视锥剔除与 HiZ 遮挡剔除,详细原理可以查看以下文章:

GPU Culling

GPU Frustum Culling 视锥剔除

Hierarchical Z

地形材质

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

高度场采样

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

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

/// <summary>
/// 根据世界空间的 XZ 坐标,采样高度图并返回实际的世界高度 (Y轴)
/// </summary>
/// <param name="positionXZ">顶点的 XZ 平面坐标</param>
/// <returns>计算后的实际世界高度 Y</returns>
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. 在顶点着色器中使用 tex2Dlod 显式采样第 0 级 Mipmap 的高度(范围 0~1)
float rawHeight = tex2Dlod(_HeightMap, float4(heightUV, 0.0, 0.0)).r;

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

LOD 接缝处理

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

具体步骤(FixLODConnectSeam):

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

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

LixLODConnectSeam
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;
}

结语

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

🔗 项目开源地址:

📦
GitHub - zerls/TerrainDemo
TerrainRender

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

  • 阴影剔除分离 (Shadow Culling Separation)
    • 问题: 目前的VisiblePatches完全依赖主相机的视锥体剔除。如果一座山坡正好在玩家身后(被剔除),但太阳光正好从玩家背后照过来,这座山就不会投射阴影到玩家视野内,导致严重的阴影穿帮(Shadow Popping)
    • 解决思路:
      • 在C#端将Buffer一分为二,创建_mainCameraVisibleBuffer_shadowCasterVisibleBuffer
      • DualDispatch:CullPatches核函数每帧 Dispatch 两次。一次传入主相机矩阵进行精准剔除;一次传入光源相机矩阵(或者稍微放宽的灯光包围盒范围)进行阴影剔除。
      • Shader分离:terrain.shader中,主渲染 Pass读取 Main Buffer,而ShadowCasterPass专门读取 Shadow Buffer。
  • 超大纹理与多地表混合 (SVT / RVT / Texture Array)
    • 问题: 现阶段仅使用了一套_AlbedoMap_NormalMap。面对动辄 10km x 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一次性渲染海量植被。