SkillAgentSearch skills...

GPUDrivenTerrainLearn

A Unity Project used for Learning GPU Driven Terrain Render

Install / Use

/learn @wlgys8/GPUDrivenTerrainLearn
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

GPU Driven Terrain 入门

最近看了育碧在GDC上分享的关于孤岛惊魂5地形渲染的PPT。以前我没有接触过大世界地形渲染相关技术,于是就动手实践一下。权且当做一个GPU Driven Terrain 入门。

本项目当前包含:

  • LOD四叉树构建
  • 视锥裁剪
  • Hiz遮挡裁剪
  • LOD接缝处理

本项目当前不包含:

  • 节点资源Stream
  • 材质渲染

所有的计算都由GPU实现,且不存在CPU数据回读。

文章会引用PPT中的一些图片,在实现上大致应该与PPT所述相同,在细节上可能会有些出入,请自行甄别。

先放一个Preiview:

<img src=".imgs/Preview.gif">

注意,这只是一个技术实验项目,请谨慎参考。

1. LOD

这里我们要渲染的世界大小为10240m x 10240m,称之为World

我们手头有一张16x16个格子组成的Mesh,大小为8m x 8m,每个格子分辨率为0.5m。我们称之为Patch

<img src=".imgs/PatchMesh.jpeg">

假如我们用这个Patch铺满整个世界,那么总共要1280 x 1280 = 1638400块。这个数量是巨大的,电脑会直接炸掉。

为了提升性能,于是引入了LOD(Level Of Detail)的概念。即远处的地块采用低分辨率网格(可以通过放大Patch实现),近处采用高分辨率网格。LOD四叉树即是其中一种实现方式。

1.1 LOD四叉树

我们首先将世界在xz平面上分割成相等的5 x 5块相同的区域,每个区域视作一个Node,25个Node构成了四叉树的第一层,记为LOD5。

<img src=".imgs/QuadTreeLOD5.jpeg">

然后遍历这25个节点,根据一定规则进行评价,决定是否对节点进行4叉分割。通常评价的标准有:

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

本项目仅使用与摄像机距离来判定是否分割。这样我们就得到了LOD4的节点.

<img src=".imgs/QuadTreeLOD4.jpeg">

如此重复下去,直到LOD0,我们就得到了一颗LOD四叉树。我们将不同的LOD节点用不同的颜色表示出来,如下图所示:

<img src=".imgs/QuadTreeLOD.jpeg">
  • 对于LOD0的Node,我们使用原分辨率的Patch进行平铺渲染,一个网格分辨率为0.5m
  • 对于LOD1的Node,我们使用Scale为2的Patch平铺渲染,一个网格的分辨率为1m
  • .....
  • 对于LOD5的Node,我们使用Scale为2^5的Patch平铺渲染,一个网格分辨率为16m

易知,每个Node由8x8=64块Patch构成。

根据以上简单的描述,我们可以有以下的尺寸概念:

  • World大小为 10240m x 10240m

  • QuadTree 有6层,从上往下分别代表LOD5~LOD0

  • LOD5有25个Node,往下依次x2,直到LOD0有160x160个Node

  • 单个Node的覆盖范围从LOD5~0依次为[2048m,1024m,512m,256m,128m,64m]

  • 我们称64m x 64m为Sector,即LOD0的Node大小

  • 在实际渲染的时候,我们会将Node打散成8x8共64个Patch作为基础单位提交给GPU进行Instance渲染。

1.2 GPU四叉树分割

在Unity中我们使用ComputeShader来对四叉树进行节点分割计算。我们可以准备以下三个Buffer结构:

uniform uint PassLOD;
ConsumeStructuredBuffer<uint2> ConsumeNodeList;
AppendStructuredBuffer<uint2> AppendNodeList;
AppendStructuredBuffer<uint3> AppendFinalNodeList;

其中:

  • PassLOD为当前执行的LOD级别
  • Consume和Append以uint2作为元素,代表当前LOD下Node的二维索引
  • AppendFinalNodeList以uint3作为元素,其中z表示Node的LOD,xy代表二维索引

具体算法如下:

  1. 将当前PassLOD设为5
  2. 执行Dispatch
  3. 从ConsumeNodeList中获取节点,对节点进行评价,决定是否分割。
  4. 如果决定分割,那么将分割后的4个节点加入AppendNodeList
  5. 否则将当前节点加入AppendFinalNodeList
  6. Dispatch结束
  7. 将当前PassLOD减1,互换ConsumeNodeList和AppendNodeList,回到2执行下一个Pass
<img src=".imgs/BuildNodeList.jpeg">

kernel代码如下:

[numthreads(1,1,1)]
void TraverseQuadTree (uint3 id : SV_DispatchThreadID)
{
    uint2 nodeLoc = ConsumeNodeList.Consume();
    if(PassLOD > 0 && EvaluateNode(nodeLoc,PassLOD)){
        //divide
        AppendNodeList.Append(nodeLoc * 2);
        AppendNodeList.Append(nodeLoc * 2 + uint2(1,0));
        AppendNodeList.Append(nodeLoc * 2 + uint2(0,1));
        AppendNodeList.Append(nodeLoc * 2 + uint2(1,1));
    }else{
        AppendFinalNodeList.Append(uint3(nodeLoc,PassLOD));
    }
}
  • 其中EvaluateNode为节点评价函数

这样每个Dispatch完成一个LOD级别的分割,总共使用6次Dispatch就完成了四叉树的分割。

1.3 节点评价

作为Demo,我们只使用节点中心到摄像机的距离作为评价标准。实际项目中需要根据需求进行编写。

我们的评价公式如下:

f = d / (n * c);

其中d为摄像机到节点中心距离,n为节点边长,c为用户控制系数。

当f < 1时,则对节点进行分割。易知,c越大,节点越容易被分割。

1.4 NodeDescriptor

在实际应用中,我们需要为一个结构来存储节点的一些额外信息,例如:

  • 材质索引
  • LOD Bias
  • etc...

在本Demo中,由于暂不涉及材质处理,我们的NodeDescriptor仅有如下简单的结构:

struct NodeDescriptor{
    uint branch;
};

branch字段代表节点是否进行了分割。在后面生成LOD Map的时候会用到。

但是我们要如何完成Node到Descriptor映射查找呢?

在GPU中没有Map这种结构,我们无法使用uint3去索引NodeDescriptor。因此我们需要建立一个uint3 -> uint的映射,然后将uint作为数组下标去索引NodeDescriptor Buffer。

我们使用一个简单的公式就能完成uint3->uint的映射:

uint nodeIdOffsetLOD[6] = [8525,2125,525,125,25,0];
uint nodeCountLOD[6] = [160,80,40,20,10,5];

.......

uint3 nodeLoc;
uint nodeId = nodeIdOffsetLOD[nodeLoc.z] + nodeLoc.y * nodeCount + nodeLoc.x

这样我们就得到了nodeId。然后我们定义一个NodeDescriptors Buffer

RWStructuredBuffer<NodeDescriptor> NodeDescriptors;

其大小为所有LOD节点个数之和,即:

5x5+10x10+20x20+40x40+80x80+160x160 = 34125

然后使用nodeId对其进行读写即可。

1.4 生成Patch列表

在完成四叉树分割之后,我们得到了FinalNodeList列表。如我们前面所述,LOD0级别的Node,其大小也有64m x 64m。作为最小渲染单位还是太大了。因此我们需要将其打散成更小的单位,称之为Patch。

一个Node打散成8x8=64个Patch作为最小渲染单位。针对单个Node,完整的流程图如下:

<img src=".imgs/BuildPatch.jpeg">

剪裁之类的我们先不管,先把地块渲染出来再说。

首先我们将FinalNodeList的Counter拷贝给IndirectArgs,代表我们要起的线程组数量:

_commandBuffer.CopyCounterValue(_finalNodeListBuffer,_indirectArgsBuffer,0);

即每个Node对应一个ThreadGroup。

然后针对每个ThreadGroup将线程数设为:

[numthreads(8,8,1)]

这样一共64个线程,每个线程对应一个Patch。 然后开始Dispatch.

[numthreads(8,8,1)]
void BuildPatches(uint3 id : SV_DispatchThreadID,uint3 groupId:SV_GroupID,uint3 groupThreadId:SV_GroupThreadID){
    uint3 nodeLoc = FinalNodeList[groupId.x];
    uint2 patchOffset = groupThreadId.xy;
    //生成Patch
    RenderPatch patch = CreatePatch(nodeLoc,patchOffset);
    CulledPatchList.Append(patch);
}

其中:

  • groupThreadId.xy代表了patch在Node局部空间的二维索引.

利用简单的数学知识,我们就能计算出Patch的世界坐标。然后得到如下RenderPatch结构:

struct RenderPatch{
    float2 position; //世界坐标
    uint lod; // lod决定了这个Patch的Scale
};

然后我们可以使用Graphics.DrawMeshInstancedIndirect直接对StructuredBuffer<RenderPatch>进行渲染。

1.5 Patch渲染

编写一个Terrain.shader.基本定义如下:

StructuredBuffer<RenderPatch> PatchList;
struct appdata
{
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
    uint instanceID : SV_InstanceID;
};
  • PatchList是我们在1.4中计算出来的Patch列表
  • instanceID为GPU Instance ID,可以用来索引PatchList

在顶点着色器中,使用以下简单的代码,即可完成Patch的位置偏移和LOD缩放

RenderPatch patch = PatchList[v.instanceID];
uint lod = patch.lod;
float scale = pow(2,lod);
inVertex.xz *= scale;
inVertex.xz += patch.position;
o.vertex = TransformObjectToHClip(inVertex.xyz);

这样就能得到一个初步渲染效果如下:

<img src=".imgs/Wireframe0.jpeg"> <img src=".imgs/Plane0.jpeg">

当摄像机在场景内移动时,我们看一下动态节点分割效果:

<img src=".imgs/QuadTreeAnim.gif">

2. 高度图实现

由于我手头没有合适的资源,就从网上随便下载了一个高度图:

<img src="Assets/GPUDrivenTerrain/Textures/HeightMap.png" width=500>

这张高度图的分辨率为1281x1281,使用R16格式保存。我直接将其应用于整个世界,相当于每个像素对应的范围为8m x 8m,精度是很低的,先凑活用吧。 在实际项目中,应当是按LOD加载对应区域高度图。

高度图的使用很简单,我们根据顶点的xz坐标,利用以下公式计算得到高度图的uv:

float2 heightUV = (inVertex.xz + (_WorldSize.xz * 0.5) + 0.5) / (_WorldSize.xz + 1);

然后去采样高度图得到一个范围为0~1的值。将其乘以预配置的地形最大高度,得到一个世界高度值。然后对顶点进行偏移即可:

float height = tex2Dlod(_HeightMap,float4(heightUV,0,0)).r;
inVertex.y = height * _WorldSize.y;

跑一下后,我们会发现一个问题,原来在平面上实现的四叉树分割失效了:

<img src=".imgs/LODErr.jpeg">

到LOD4就不往下了。

这是因为,在平面上实现的时候,我们计算摄像机与节点中心距离无需考虑高度。但是当地形有了高度之后,我们必须估算出节点的中心高度,才能算得准确的LOD。为了实现这个目标,我们需要建立起Node的包围盒。

2.1 MinMaxHeightMap

Node在xz平面上的最小和最大值我们很容易就可以求得(因为是规则的四叉树格子分割)。因此只需要计算出minHeight和maxHeight,就可以成功构建包围盒了,这个过程可以通过对高度图进行离线预计算得到,我们将其存为MinMaxHeightMap,格式为RG32,R分量为minHeight,G分量为maxHeight。

由于Node是有LOD结构的,因此对应过来,MinMaxHeightMap是具有Mips结构的。

MinMaxHeightMap生成原理如下:

  • 采样HeightMap的相邻4个像素高度值,注意HeightMap中的像素对应的是Vertex的高度.
  • 计算出4个Vertex高度的min和max,分别存入MinMaxHeightMap的RG分量.

因此1281 x 1281的高度图,将生成一张1280 x 1280的 MinMaxHeightMap。

MinMaxHeightMap中的一个像素,代表了一个8m x 8m范围的高度分布。

然后我们对MinMaxHeightMap进行Mips生成,4个像素合并成一个像素,规则为R分量取min,G分量取max。 这样我们就得到Mip级数为9的MinMaxHeightMaps:

<img src=".imgs/MinMaxHeightMaps.jpeg">

其中LOD为0的Node对应的MinMaxHeight Mip应该为3,然后依次往上。

通过MinMaxHeightMap,我们就能轻易构建Node的包围盒,并进行正确的节点评价计算。四叉树分割Debug效果如下:

<img src=".imgs/LODRight.jpeg">

2.2 法线图

到目前位置,我们从摄像机视角去看的渲染效果图如下:

<img src=".imgs/WithHeightMap.jpeg">

由于Demo使用的高度图精度很低,近距离看地面高度过渡会不自然。不过因为只是Demo,我们理解原理就可以了。另一方面现在没有法线效果,看起来很平。我们可以写一个小小的工具,从高度图生成法线图,源代码可以参考项目中的HeightToNormal.compute,这里不贴了。

生成的法线图如下:

<img src="Assets/GPUDrivenTerrain/Textures/TerrainNormal.png" width=500>

法线图我们使用RG32格式,其中R保存x分量,G保存z分量。

有了法线图后,我们使用简单的Diffuse光照模型对地形进行渲染,效果如下:

<img src=".imgs/WithNormal.jpeg">

如前面所述,因为法线图分辨率实在太低了,所以近看还是比较糟糕的,但是远看整个明暗效果还可以,毕竟本文的主旨不在于材质光照着色,我们有一个可以凑活看的效果就可以了。

开启Patch Debug,看一下整个场景的LOD和Patch分布情况:

<img src=".imgs/PatchDebug.jpeg">

如果从GPU中回读PatchIndirectArgsBuffer进行打印,可以发现当前总共进行渲染的Patch数量为5632个。这与我们开头给出的1638400块相比,已经得到了巨大的提升。

但优化空间还是很多的,比如各种裁剪我们都还没有做。

3. 裁剪

在Far Cry 5中总共有三种剪裁:

  • Frustum Cull
  • Hiz Occlusion Cull
  • Back face Cull

这里我们只实现Frustum Cull和Occlusion Cull。

3.1 Patch包围盒

为了完成相关裁剪,我们必须构建Patch的包围盒,原理与前面的Node包围盒相同,但是在实际操作的时候发现由于精度问题会导致包围盒无法完整围住Patch,特别是在LOD0的时候。为了对Patch包围盒有一个可视化的效果供Debug,Demo中额外增加了一个BoundsBuffer进行渲染。Debug效果如下:

<img src=".imgs/BoundsDebug.jpeg">

其中一些灰点即是没有被Bounds包住的露出来的地面。

为了修正这个问题,在Shader中额外增加了一个参数_BoundsHeightRedundance来控制包围盒在高度方向的冗余度。包围盒的Top和Bottom两个平面会向外偏移一定距离以修正精度引起的问题。

<img src=".imgs/BoundsDebug2.jpeg">

通过对包围盒进行可视化绘制,我们确认了包围盒数据得到了正确生成。

3.2 视锥裁剪

接下来要针对包围盒进行视锥裁剪。

视锥裁剪的原理是比较简单的:

  • 摄像机视锥由6个平面围成,平面法线朝内。
  • 一个AABB包围盒由8个顶点构成。
  • 我们依次判定包围盒与每个平面的关系
  • 只要存在一个平面,使得8个顶点都在这个平面的外侧,那么这个物体就在视锥之外.

在C#端,我们可以通过GeometryUtility.CalculateFrustumPlanes(camera,_cameraFrustumPlanes);获取到Plane[6]结构,代表了摄像机视锥的6个平面。

但是GPU中只能使用float4结构,因此需要将Plane转为Vector4.

实际上,一个平面可以有公式Ax + By + Cz + D = 0构成,ABCD正好为4个系数。更进一步,其实(A,B,C)构成了平面的法线,而D代表了原点到平面的距离(有符号,法线为正方向)。

因此我们可以使用以下转换将Plane[6]转为Vector4[6]传给Shader使用:

var v4 = Vector4(plane.normal,plane.distance)

然后在Shader中,我们使用以下函数判定一个点在平面外侧(负法线方向)还是内侧(正法线方向):

//测试点是否在平面的外侧
bool IsOutSidePlane(float4 plane,float3 position){
    return dot(plane.xyz,position) + plane.w < 0; 
}

然后我们依次判定包围盒的8个顶点,就能检测一个包围盒是否完全位于平面外面。最终得到的视锥裁剪函数如下:

//视锥裁剪
bool FrustumCull(float4 planes[6],Bounds bounds){
    return IsBoundsOutSidePlane(planes[0],bounds) || 
    IsBoundsOutSidePlane(planes[1],bounds) || 
    IsBoundsOutSidePlane(planes[2],bounds) || 
    IsBoundsOutSidePlane(planes[3],bounds) || 
    IsBoundsOutSidePlane(planes[4],bounds) || 
    IsBoundsOutSidePlane(planes[5],bounds);
}

视锥裁剪效果动画:

<img src=".imgs/FrustumCullAnim.gif">

3.3 Hiz遮挡裁剪

Hiz指的是Hierarchical depth。其原理是利用上一针的深度图,生成类似Mips的结构。但是Mips的算法与传统的贴图Mips稍有不同: 取4个像素中最远的那个像素写入下一级Mip.

易知:

  • 最高级Mip为1x1的深度图,其代表了整个屏幕中最远距离
  • 次高级Mip为2x2的深度图,4个像素依次代表将屏幕4叉分割后,每块区域各自的最远距离.
  • ....

那么当我们有了所有级别的Hiz Map后,我们在GPU中要如何对一个物体进行遮挡裁剪呢?

  • 首先可以将物体的包围盒8个顶点投影到屏幕空间,得到屏幕空间的8个点。

  • 针对这8个点建立屏幕空间的AABB Box,得到一个屏幕空间矩形.

  • 根据这个矩形的最大边长,我们可以计算出一个合适的Mip。在这个Mip下,矩形的4个顶点恰好满足位于相邻的像素。

  • 依次对4个顶点进行深度测试,如果均未通过,那么就意味着这个物体被完全遮挡。

    <img src=".imgs/Hiz.jpeg">

3.3.1 HizMapPass

实际上Hierarchical depth Buffer除了做遮挡剔除,还有许多其他的用处。在管线中通常可以

View on GitHub
GitHub Stars426
CategoryEducation
Updated5d ago
Forks96

Languages

C#

Security Score

80/100

Audited on Mar 17, 2026

No findings