ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity大世界地形性能优化:GPU Terrain与Compute Shader实战

Unity大世界地形性能优化:GPU Terrain与Compute Shader实战 1. 大世界地形渲染的痛点与GPU Terrain的破局思路做Unity大世界项目的朋友大概率都经历过这样的场景场景里铺了几十平方公里的地形美术刷完高度图之后运行起来帧率直接掉到20以下打开Profiler一看Terrain.Render和Culling两项吃掉了大半的CPU时间。更难受的是你想加一点草、石头、树木的细节Draw Call瞬间飙到几千手机端直接烫得能煎鸡蛋。这不是你优化不到位而是Unity内置Terrain这套东西从设计之初就是CPU驱动的——每一块地形的LOD切换、每一批草和树的剔除、每一个Patch的提交全都要CPU挨个算一遍。地形规模一大CPU就成了瓶颈。GPU Terrain也叫GPU-Driven Terrain要解决的就是这个问题。它的核心思路很直接把地形的高度采样、LOD选择、视锥剔除、甚至草和植被的实例化全部搬到Compute Shader里用GPU并行算CPU只负责下发几个参数和最终的绘制命令。这样一来地形规模从几平方公里扩展到几百平方公里CPU开销几乎不变帧率能稳住。这套方案在HDRP管线下配合Compute Shader和Indirect Draw是目前Unity大世界方案里比较成熟的一条路。这篇文章适合谁看如果你正在做开放世界、数字孪生、大场景仿真这类项目被地形性能卡过脖子或者你已经在用Unity的Terrain但发现它撑不住你的野心那这篇内容应该能帮到你。我会从整体设计思路讲起把GPU Terrain的核心细节、实操步骤、参数计算、踩坑经验都摊开说尽量让你看完能自己动手搭一套出来。需要说明的是下面涉及的具体参数和代码结构是基于我实际项目中的常见实践做的合理补全不同项目需要根据实际情况调整。2. 整体方案设计与核心技术选型2.1 为什么不用Unity内置TerrainUnity内置的Terrain组件本质上是一个CPU端的四叉树管理结构。它把地形切成一块块Patch根据摄像机距离决定每块Patch用哪一级LOD然后逐个提交绘制。这个流程在中小场景里没问题但到了大世界级别问题就暴露了CPU剔除开销线性增长地形块数量随面积平方增长每帧遍历所有块做视锥剔除CPU时间直接爆炸。LOD切换粒度粗内置Terrain的LOD是按Patch整体切换的相邻Patch之间容易出现裂缝Crack要么用额外的缝合Pass要么忍受视觉瑕疵。草和植被系统独立Terrain的Detail和Tree是另一套CPU驱动的系统和地形本身的LOD不联动导致远处草还在渲染近处树却已经被剔除的尴尬情况。Draw Call不可控每个Patch至少一个Draw Call加上草和树几千个Draw Call是常态。GPU Terrain的思路是把上面这些全部重构。地形不再是一块块Patch而是一张连续的Heightmap纹理LOD不再按块切换而是按屏幕空间误差Screen Space Error逐顶点或逐Cluster决定剔除不再在CPU做而是把地形切成Cluster用Compute Shader做GPU Frustum Culling和Hi-Z Occlusion Culling最终用Graphics.DrawProceduralIndirect一次性提交所有可见Cluster。草和植被也用同样的Cluster机制管理和地形共享剔除结果。2.2 核心技术栈拆解这套方案依赖几个关键技术点我逐个说清楚它们的作用和选型理由Compute Shader整个方案的算力核心。地形的LOD选择、Cluster剔除、高度采样、法线计算、草实例化全部在Compute Shader里并行执行。选它而不是用传统的Vertex/Fragment Shader做是因为这些计算是“每Cluster一次”或“每顶点一次”的用Compute Shader可以灵活控制线程组大小还能把结果写到StructuredBuffer里给后续Pass用。Indirect DrawGraphics.DrawProceduralIndirect和Graphics.DrawMeshInstancedIndirect。前者用来画地形Cluster后者用来画草和植被。Indirect Draw的好处是绘制参数顶点数、实例数由GPU端的Compute Shader写入CPU不需要回读避免了GPU-CPU同步等待。这是GPU-Driven渲染的关键一环。HDRP管线HDRP提供了更现代的渲染特性支持比如Shader Graph、Custom Pass、以及更好的Compute Shader集成。当然URP也能做GPU Terrain但HDRP在大世界场景下的光照、阴影、后处理方面更成熟。如果你项目已经定了URP核心思路不变只是部分API需要替换。Heightmap纹理与Virtual Texture地形高度数据存在一张或多张Heightmap纹理里。大世界级别的高度图分辨率可能达到16K甚至更高直接加载会爆显存。常见做法是用Virtual Texture虚拟纹理按需流式加载或者把地形分成多个Tile每个Tile一张Heightmap根据摄像机位置动态加载。这里为了简化我们先假设用单张Heightmap后续再讲分Tile的方案。Cluster-based LOD把地形网格预切成固定大小的Cluster比如64x64顶点一个Cluster每个Cluster有自己的包围盒。LOD选择时根据Cluster包围盒在屏幕上的投影大小计算Screen Space Error决定用哪一级LOD。这个计算在Compute Shader里对每个Cluster并行执行。2.3 方案整体数据流我把整个数据流串一遍让你有个全局视角初始化阶段CPU生成地形的Cluster网格数据顶点位置、索引、包围盒上传到GPU的StructuredBuffer。Heightmap纹理也上传到GPU。每帧Compute Pass 1 - LOD选择根据摄像机参数和每个Cluster的包围盒计算Screen Space Error选出合适的LOD级别写入LOD Buffer。每帧Compute Pass 2 - 视锥剔除用摄像机视锥体对Cluster做剔除可见的Cluster写入Visible Cluster Buffer。每帧Compute Pass 3 - 高度采样与顶点变换对可见Cluster的每个顶点从Heightmap采样高度计算世界坐标和法线写入Vertex Buffer。每帧Compute Pass 4 - 草和植被实例化根据地形高度和可见性生成草和植被的实例位置写入Instance Buffer。绘制阶段用DrawProceduralIndirect画地形用DrawMeshInstancedIndirect画草和植被。这个流程里CPU每帧只做几件事更新摄像机参数、Dispatch几个Compute Shader、发起Indirect Draw。没有回读没有逐物体提交CPU开销基本恒定。3. 核心细节解析与实操要点3.1 Cluster网格的生成与组织Cluster是这套方案的基本单位。我一般把地形切成固定大小的网格块比如每个Cluster覆盖64x64个顶点顶点间距根据地形总尺寸和Heightmap分辨率来定。假设地形是4096米x4096米Heightmap是8192x8192那么顶点间距就是0.5米。每个Cluster覆盖64x64顶点就是32米x32米的区域。整个地形就是128x128个Cluster总共16384个Cluster。每个Cluster需要存储的数据包括包围盒用于LOD选择和视锥剔除。包围盒的Min/Max Y需要从Heightmap里采样出来不能简单用0到最大高度否则剔除会不准确。顶点索引范围这个Cluster的顶点在全局Vertex Buffer里的起始索引和数量。LOD级别数每个Cluster支持几级LOD比如4级每级LOD的顶点密度不同。生成Cluster网格的代码大致长这样C#端// 假设地形参数 int heightmapResolution 8192; float terrainSize 4096f; int clusterVertexCount 64; int clusterCountPerAxis (heightmapResolution - 1) / (clusterVertexCount - 1); // clusterCountPerAxis 127 / 63 2? 不对这里要仔细算 // 实际上如果Heightmap是8192顶点数也是8192Cluster顶点数64 // 那么每轴Cluster数 (8192 - 1) / (64 - 1) 8191 / 63 ≈ 130 // 为了整除通常把Heightmap分辨率设为(2^n 1)Cluster顶点数设为(2^m 1)这里有个坑Cluster之间的顶点是共享的否则会出现裂缝。所以每个Cluster的顶点数应该是clusterVertexCount但相邻Cluster之间要重叠一行/一列顶点。计算Cluster数量时要用(totalVertexCount - 1) / (clusterVertexCount - 1)。这个细节不注意地形就会出现明显的接缝。包围盒的Y范围需要从Heightmap采样。我通常会在CPU端预计算每个Cluster的Min/Max高度存到ClusterData里。如果Heightmap是运行时生成的也可以在Compute Shader里做一次Reduce操作算出Min/Max。3.2 LOD选择与Screen Space Error计算LOD选择的核心是计算每个Cluster在屏幕上的投影误差。常用的是Screen Space Error公式SSE (ClusterBoundingSphereRadius * ScreenHeight) / (DistanceToCamera * 2 * tan(FOV/2))如果SSE大于某个阈值就用更高精度的LOD小于阈值就用更低精度的LOD。这个计算在Compute Shader里对每个Cluster并行执行速度很快。但这里有个问题如果每个Cluster独立选择LOD相邻Cluster的LOD级别可能不同导致T-Junction裂缝。解决办法有两种一是用Geometry Clipmap的思路让LOD级别在空间上连续过渡二是用Stitching在LOD边界处生成过渡三角形。我一般推荐第一种因为实现简单视觉瑕疵也少。具体做法是不按Cluster独立选LOD而是按“LOD Ring”来组织。以摄像机为中心向外一圈圈扩展每一圈用一个LOD级别。这样LOD边界是规则的环形裂缝问题容易处理。但这种方式对摄像机的旋转不敏感如果摄像机快速旋转边缘的LOD可能不够。折中方案是用“Quadrant”方式把地形分成四个象限每个象限独立做Ring LOD。在Compute Shader里LOD选择的代码结构大概是[numthreads(64,1,1)] void SelectLOD(uint3 id : SV_DispatchThreadID) { uint clusterIndex id.x; if (clusterIndex _ClusterCount) return; ClusterData cluster _ClusterBuffer[clusterIndex]; float3 clusterCenter (cluster.boundsMin cluster.boundsMax) * 0.5; float distance length(clusterCenter - _CameraPosition); float sse (cluster.boundingSphereRadius * _ScreenHeight) / (distance * 2.0 * tan(_FOV * 0.5)); uint lodLevel 0; for (uint i 0; i _LODCount; i) { if (sse _LODThresholds[i]) { lodLevel i; break; } } _LODBuffer[clusterIndex] lodLevel; }_LODThresholds是一个数组存储每级LOD的SSE阈值。这些阈值需要根据项目实际情况调一般从高到低递减比如[16, 8, 4, 2]。阈值越大LOD切换越保守画质越好但性能越差。3.3 GPU视锥剔除与Hi-Z遮挡剔除视锥剔除相对简单把Cluster的包围盒和摄像机的六个视锥平面做测试如果包围盒完全在某个平面外侧就剔除。在Compute Shader里每个Cluster一个线程计算量很小。Hi-Z遮挡剔除复杂一些但效果显著。思路是先用上一帧的深度Buffer生成Hi-Z金字塔一系列逐级降采样的深度纹理然后在Compute Shader里用Cluster的包围盒投影到屏幕空间查询Hi-Z纹理判断是否被遮挡。如果被遮挡就剔除。Hi-Z的生成可以用Compute Shader做每一级降采样取上一级2x2区域的最大深度值。查询时把Cluster包围盒投影到屏幕空间取包围盒在屏幕上的AABB然后用这个AABB的最大深度和Hi-Z纹理对应位置的最小深度比较。如果AABB的最大深度小于Hi-Z的最小深度说明Cluster完全被遮挡。这里有个细节Hi-Z查询需要处理屏幕空间AABB跨多个Mip Level的情况。常见做法是选择一个合适的Mip Level使得AABB在屏幕上的大小不超过2x2像素然后采样那个Level的深度值。这个Mip Level的计算公式是mipLevel ceil(log2(max(aabbWidth, aabbHeight)))在Compute Shader里可以用log2和ceil函数算。3.4 高度采样与顶点变换地形的高度数据存在Heightmap纹理里。在Compute Shader里对每个可见Cluster的每个顶点从Heightmap采样高度然后计算世界坐标。这里要注意采样方式如果用双线性插值地形表面会比较平滑如果用点采样会有明显的块状感。我一般用双线性插值配合法线计算。法线计算可以用Heightmap的相邻像素差分来做float2 texelSize 1.0 / _HeightmapResolution; float hL _Heightmap.SampleLevel(_LinearClampSampler, uv - float2(texelSize.x, 0), 0).r; float hR _Heightmap.SampleLevel(_LinearClampSampler, uv float2(texelSize.x, 0), 0).r; float hD _Heightmap.SampleLevel(_LinearClampSampler, uv - float2(0, texelSize.y), 0).r; float hU _Heightmap.SampleLevel(_LinearClampSampler, uv float2(0, texelSize.y), 0).r; float3 normal normalize(float3(hL - hR, 2.0 * texelSize.x * _TerrainSize, hD - hU));这个法线是切线空间下的需要转换到世界空间。如果地形没有旋转切线空间和世界空间一致直接用就行。顶点变换后的数据写入Vertex Buffer供后续的Vertex/Fragment Shader使用。这里有个性能优化点如果LOD级别较低顶点数少可以直接在Vertex Shader里采样Heightmap不需要Compute Shader预计算。但LOD级别高时顶点数多Compute Shader并行采样的优势就体现出来了。我一般会根据LOD级别决定是否走Compute Shader路径。3.5 草和植被的GPU实例化草和植被的实例化是GPU Terrain的另一个大头。传统做法是在CPU端生成草的位置然后提交Draw Call。GPU Terrain的做法是在Compute Shader里根据地形高度和可见性生成草的实例位置写入Instance Buffer然后用DrawMeshInstancedIndirect一次性画出来。具体流程草密度图一张纹理存储每个位置草的密度。可以在CPU端生成也可以从美术工具导出。Compute Shader生成实例对每个可见Cluster根据草密度图用随机采样生成草的位置。位置的高度从Heightmap采样。视锥剔除对生成的草实例做视锥剔除剔除掉的实例不写入Instance Buffer。Indirect Draw用DrawMeshInstancedIndirect画草绘制参数由Compute Shader写入。这里有个坑草的实例数量可能非常大比如每平方米10棵草一平方公里就是1000万棵。直接生成这么多实例Instance Buffer会爆。解决办法是分级近处用高密度远处用低密度或者用Impostor替代。我一般会在Compute Shader里根据距离动态调整草密度距离越远密度越低。另一个坑是草的LOD切换。如果草突然消失或出现视觉上很突兀。解决办法是用淡入淡出在Compute Shader里根据距离计算草的Alpha写入Instance Buffer在Shader里用这个Alpha做透明度混合。4. 实操过程与核心环节实现4.1 项目初始化与HDRP配置首先创建一个HDRP项目。在Package Manager里安装HDRP然后通过Assets Create Rendering HDRP Asset创建HDRP配置文件在Project Settings Graphics里指定这个配置文件。Quality Settings里也要指定HDRP的Quality Asset。接下来创建一个空场景删掉默认的Directional Light和Camera自己重新建一个。Directional Light的强度调到1.5左右角度调成斜射这样地形阴影比较明显。Camera的Far Clip Plane调到5000以上大世界场景需要看得远。在HDRP的Global Settings里把Shadow的Max Distance调大比如200米。地形的阴影如果太远性能吃不消200米是个折中值。另外把Volumetric Fog关掉大世界场景里体积雾很吃性能除非你确实需要。4.2 地形数据准备与Cluster生成地形数据我一般用World Machine或Gaea生成Heightmap导出为16位PNG或RAW格式。导入Unity时把Texture Type设为DefaultsRGB关掉Format设为R16或R8。如果Heightmap分辨率超过8192建议分Tile存储每个Tile一张纹理。Cluster生成脚本的核心逻辑public class TerrainClusterGenerator : MonoBehaviour { public Texture2D heightmap; public float terrainSize 4096f; public float terrainHeight 500f; public int clusterVertexCount 64; void GenerateClusters() { int totalVertexCount heightmap.width; int clusterCountPerAxis (totalVertexCount - 1) / (clusterVertexCount - 1); int totalClusterCount clusterCountPerAxis * clusterCountPerAxis; ClusterData[] clusters new ClusterData[totalClusterCount]; float vertexSpacing terrainSize / (totalVertexCount - 1); for (int z 0; z clusterCountPerAxis; z) { for (int x 0; x clusterCountPerAxis; x) { int clusterIndex z * clusterCountPerAxis x; int startVertexX x * (clusterVertexCount - 1); int startVertexZ z * (clusterVertexCount - 1); // 计算包围盒 float minY float.MaxValue; float maxY float.MinValue; for (int vz 0; vz clusterVertexCount; vz) { for (int vx 0; vx clusterVertexCount; vx) { int px startVertexX vx; int pz startVertexZ vz; float height heightmap.GetPixel(px, pz).r * terrainHeight; minY Mathf.Min(minY, height); maxY Mathf.Max(maxY, height); } } float worldX startVertexX * vertexSpacing; float worldZ startVertexZ * vertexSpacing; float clusterWorldSize (clusterVertexCount - 1) * vertexSpacing; clusters[clusterIndex] new ClusterData { boundsMin new Vector3(worldX, minY, worldZ), boundsMax new Vector3(worldX clusterWorldSize, maxY, worldZ clusterWorldSize), startVertexIndex clusterIndex * clusterVertexCount * clusterVertexCount, vertexCount clusterVertexCount * clusterVertexCount }; } } // 上传到GPU ComputeBuffer clusterBuffer new ComputeBuffer(totalClusterCount, ClusterData.SizeInBytes); clusterBuffer.SetData(clusters); // ... 传给Compute Shader } }这里有个性能问题在CPU端逐像素采样Heightmap计算包围盒对于8192x8192的Heightmap来说要采样6700万次太慢了。优化方法是用Compute Shader做Reduce或者直接在生成Heightmap时就输出每个Cluster的Min/Max高度。我一般用后者在World Machine里用Macro输出每个Tile的Min/Max高度导入Unity时直接读。4.3 Compute Shader的编写与调试Compute Shader的编写有几个关键点线程组大小一般用[numthreads(64,1,1)]因为64是Warp Size的倍数GPU利用率高。如果Cluster数量不是64的倍数需要在Shader里做边界检查。Buffer的读写StructuredBuffer用于只读数据RWStructuredBuffer用于读写。注意RWStructuredBuffer的写入是原子的但多个线程写同一个位置会有竞争需要避免。Dispatch参数Dispatch的线程组数量要算对。如果Cluster数量是16384线程组大小是64那么Dispatch的x参数是(16384 63) / 64 256。调试Compute Shader比较麻烦因为不能直接打断点。我一般用RWStructuredBuffer把中间结果写出来然后在C#端用GetData读回来打印。或者用RenderDoc抓帧看Buffer的内容。RenderDoc对Compute Shader的支持很好可以看到每个Buffer的输入输出。4.4 Indirect Draw的配置与调用Indirect Draw需要构造一个ComputeBuffer里面存储绘制参数。对于DrawProceduralIndirect参数结构是struct IndirectDrawArgs { public uint vertexCountPerInstance; public uint instanceCount; public uint startVertex; public uint startInstance; }对于DrawMeshInstancedIndirect参数结构是struct IndirectInstanceArgs { public uint instanceCount; public uint startInstance; public uint startVertex; public uint startIndex; }这些参数由Compute Shader写入。比如在视锥剔除Pass里每发现一个可见Cluster就用InterlockedAdd原子操作增加instanceCount并把Cluster的索引写入Instance Buffer。调用DrawProceduralIndirect的代码Graphics.DrawProceduralIndirect( material, bounds, MeshTopology.Triangles, argsBuffer, 0, null, null, ShadowCastingMode.On, true, gameObject.layer );注意bounds参数要设得足够大覆盖整个地形否则Unity会错误剔除。我一般设成new Bounds(terrainCenter, new Vector3(terrainSize * 2, terrainHeight * 2, terrainSize * 2))。4.5 性能测试与参数调优搭好之后用Unity Profiler和Frame Debugger看性能。重点看几个指标CPU Main Thread应该在5ms以下。如果超过检查是不是有回读操作。GPU ComputeLOD选择和剔除的Compute Pass应该在1ms以下。Draw Call地形应该只有1个Draw Call草和植被各1个。GPU Time整体GPU时间应该在16ms以下60帧。调优参数Cluster大小64x64顶点是个折中值。太小Cluster数量多Compute开销大太大LOD切换粒度粗画质差。LOD阈值根据画质和性能需求调。阈值大画质好性能差阈值小画质差性能好。草密度根据目标平台调。PC端可以密一些移动端要稀疏。5. 常见问题与排查技巧实录5.1 地形裂缝与T-Junction问题现象相邻Cluster之间出现细小的裂缝摄像机移动时裂缝闪烁。原因相邻Cluster的LOD级别不同高LOD的Cluster边缘顶点和低LOD的Cluster边缘顶点不匹配导致T-Junction。解决方法Stitching在LOD边界处生成过渡三角形把高LOD的顶点“拉”到低LOD的边上。实现复杂但效果好。LOD Ring不按Cluster独立选LOD而是按环形区域选LOD保证相邻区域LOD级别连续。实现简单但LOD过渡不够灵活。Skirt在每个Cluster的边缘向下延伸一圈“裙边”遮住裂缝。实现最简单但裙边本身可能穿帮。我一般用LOD Ring Skirt的组合LOD Ring保证大部分区域没有裂缝Skirt兜底处理极端情况。5.2 Compute Shader性能瓶颈现象Compute Shader的Dispatch时间很长GPU利用率低。原因可能是线程组大小不合适或者Buffer的访问模式不友好。排查方法用RenderDoc看Compute Shader的Occupancy如果低于50%说明线程组大小或寄存器使用有问题。检查Buffer的访问是否连续。如果多个线程访问同一个Buffer的相邻位置会有Bank Conflict降低性能。减少不必要的分支。Compute Shader里的if语句会导致Warp Divergence尽量用lerp或step替代。5.3 Indirect Draw不生效现象调用DrawProceduralIndirect后屏幕上什么都没有。原因可能是Indirect Args Buffer没有正确写入或者Bounds设置不对。排查步骤用GetData读回Args Buffer检查instanceCount是否为0。检查Compute Shader里的InterlockedAdd是否正确执行。注意InterlockedAdd需要RWStructuredBuffer不能用StructuredBuffer。检查Bounds是否覆盖了地形。如果Bounds太小Unity会剔除整个Draw Call。检查Material的Shader是否支持Procedural Draw。HDRP的Shader需要手动添加#pragma instancing_options和#pragma multi_compile_instancing。5.4 草和植被的剔除不准确现象远处的草还在渲染近处的草却被剔除了。原因草的剔除用的是上一帧的Hi-Z如果摄像机快速移动Hi-Z不准确。解决方法用当前帧的深度Buffer做Hi-Z但需要额外的Pass性能开销大。用保守剔除把Cluster的包围盒扩大一些减少误剔除。对草单独做一次视锥剔除不用Hi-Z因为草的包围盒小视锥剔除足够。5.5 常见问题速查表问题可能原因解决方法地形裂缝LOD级别不连续用LOD Ring或StitchingCompute Shader慢线程组大小不合适调整为64或128Indirect Draw不生效Args Buffer未写入检查InterlockedAdd和Bounds草剔除不准确Hi-Z延迟用保守剔除或单独视锥剔除地形高度不对Heightmap采样错误检查sRGB和Format设置法线方向错误切线空间转换错误检查法线计算和空间转换性能突然下降Buffer回读检查是否有GetData调用阴影闪烁Shadow Distance太小调大Shadow Distance5.6 独家避坑技巧技巧一Heightmap的sRGB一定要关。Unity默认把Texture当sRGB处理Heightmap是线性数据如果开了sRGB高度值会被Gamma校正地形高度全错。这个坑我踩过排查了一整天。技巧二Compute Shader的Buffer要加[RWStructuredBuffer]才能写。如果只写StructuredBuffer编译不会报错但运行时写入无效数据全是0。这个坑很隐蔽因为Shader编译通过但结果不对。技巧三Indirect Draw的Bounds要设大。Unity会根据Bounds做视锥剔除如果Bounds太小整个Draw Call被剔除屏幕上什么都没有。我一般把Bounds设成地形的两倍大小。技巧四草的实例数量要限制。如果草密度太高Instance Buffer会爆显存。我一般限制每帧最多生成100万个草实例超过的部分用Impostor替代。技巧五用Frame Debugger看Draw Call。如果地形有多个Draw Call说明Indirect Draw没生效或者Cluster被分批提交了。正常情况下地形应该只有1个Draw Call。技巧六Compute Shader的Dispatch要算对。Dispatch的参数是线程组数量不是线程数量。如果Cluster数量是16384线程组大小是64Dispatch的x参数是256不是16384。这个算错要么少算Cluster要么多算浪费。技巧七HDRP的Shader要加Procedural Instancing支持。HDRP的Lit Shader默认不支持Procedural Draw需要手动修改Shader添加#pragma instancing_options procedural:Setup和对应的Setup函数。这个在HDRP文档里有说明但容易忽略。技巧八用AsyncGPUReadback做调试。如果需要读回GPU数据做调试用AsyncGPUReadback而不是GetData前者是异步的不会阻塞CPU。GetData会强制同步导致帧率骤降。技巧九地形LOD切换要加淡入淡出。LOD切换时如果直接切换网格会有明显的跳变。可以在Shader里根据LOD级别混合两个LOD的顶点位置实现平滑过渡。这个技巧在远处地形上效果很明显。技巧十移动端要降级。移动端的GPU性能有限Compute Shader的线程组大小要调小LOD阈值要调大草密度要降低。我一般会在移动端把Cluster大小从64降到32LOD级别从4级降到3级。6. 方案扩展与个人经验分享这套GPU Terrain方案搭好之后还可以往几个方向扩展。一个是加Virtual Texture把Heightmap和Albedo都做成虚拟纹理按需加载支持更大的地形。另一个是加Runtime Sculpting允许玩家在运行时修改地形高度用Compute Shader做高度图的实时更新。还有一个是加GPU Skinning把树木的骨骼动画也搬到GPU进一步降低CPU开销。我个人在实际操作中的体会是GPU Terrain这套东西难点不在单个技术点而在整体流程的串联。Compute Shader、Indirect Draw、HDRP管线每个单独拿出来都不难但要把它们串起来让数据在CPU和GPU之间正确流动需要反复调试。我建议你先搭一个最小可运行版本比如只做LOD选择和视锥剔除把地形画出来然后再逐步加高度采样、草实例化、Hi-Z剔除。每加一个功能都用RenderDoc抓帧验证确保数据正确。另外不要迷信参数。网上很多教程给的LOD阈值、Cluster大小、线程组大小都是针对特定项目的。你的项目地形尺寸、目标平台、画质要求不同参数一定要自己调。我一般会做一个参数调试面板运行时可以实时调整LOD阈值和草密度边调边看效果找到最适合当前项目的参数组合。最后再分享一个小技巧如果地形边缘有接缝检查一下Heightmap的Wrap Mode。如果设成Clamp边缘会拉伸如果设成Repeat边缘会重复。大世界地形一般用Clamp然后在边缘处用Skirt遮住。这个细节很小但很容易忽略。
返回列表