尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Unity HDRP大世界地形GPU-Driven渲染:Compute Shader剔除与Indirect Draw实战

发布时间:2026/9/29 18:37:39

资讯中心
01
ARTICLE

Unity HDRP大世界地形GPU-Driven渲染:Compute Shader剔除与Indirect Draw实战

Unity HDRP大世界地形GPU-Driven渲染:Compute Shader剔除与Indirect Draw实战
1. 大世界地形为什么必须走GPU这条路做开放世界或者大场景地图的同行应该都有体会Unity自带的Terrain组件在中小规模场景里够用一旦把视野拉到几平方公里甚至几十平方公里问题就全冒出来了。最典型的就是Draw Call爆炸——每块地形Tile都是一个独立渲染批次几百块拼在一起CPU光提交渲染指令就忙不过来帧率直接掉到二三十。再加上地形LOD切换时的跳变、草皮和树木的剔除开销整套管线在PC上勉强能跑到了移动端基本就是幻灯片。GPUTerrain方案的核心思路是把地形的网格生成、LOD选择、视锥剔除、甚至部分贴图混合逻辑全部搬到GPU侧用Compute Shader来算。CPU只负责下发相机参数和全局配置剩下的活儿让显卡自己并行处理。这样一来原本压在CPU上的几千次剔除判断和网格提交变成了GPU里几毫秒的并行计算Draw Call也能通过Indirect Draw合并到个位数。这套方案适合谁如果你正在做Unity HDRP下的开放世界、数字孪生大场景、或者需要动态加载超大地形的项目并且对帧率有硬性要求那GPUTerrain值得花时间啃。如果只是做个几百米见方的小关卡用原生Terrain反而更省事别为了技术而技术。我下面会从整体架构、核心细节、实操落地、踩坑排查四个维度把这套方案拆开讲清楚。内容基于HDRP管线URP思路类似但部分API要换我会在对应位置标注。2. 方案整体架构与选型逻辑2.1 为什么不用传统Terrain加LOD Group传统做法是给每块地形挂LOD Group靠Unity内置的剔除系统管理。这套机制在场景物体数量可控时没问题但地形Tile数量一上去CPU端的Culling和Sorting就成了瓶颈。我实测过一个16x16共256块地形的场景光是相机剔除每帧就要吃掉4到6毫秒这还没算渲染本身。GPUTerrain换了个思路把地形当作一个整体的高度图数据源网格的细分和裁剪在GPU里按需生成。相机看到哪里就只为哪片区域生成高精度网格视野边缘自动降精度。整个过程不需要CPU逐个Tile判断而是把相机视锥参数传给Compute Shader让GPU并行地对每个地形Patch做可见性测试。选这个方案的理由很直接GPU天生适合做大量重复的并行判断而地形剔除恰好就是这类任务。一块RTX 3060有几千个流处理器同时处理上万个Patch的剔除判断毫无压力换成CPU单线程做同样的事就是灾难。2.2 GPU-Driven的核心组件拆解整套方案由四个关键部分组成缺一不可高度图数据层把地形高度信息存成RenderTexture或者ComputeBuffer让Shader能直接采样。通常用R16或R32格式分辨率根据场景尺度定我一般用每米1到2个采样点。Patch划分与LOD策略把地形切成固定大小的Patch比如64x64米一块每块根据相机距离决定细分等级。LOD层级不是固定的而是根据屏幕空间误差动态算。Compute Shader剔除管线核心中的核心。每个Patch的包围盒和相机视锥做相交测试通过测试的Patch写入AppendBuffer供后续Indirect Draw使用。Indirect Draw渲染用Graphics.DrawProceduralIndirect或者RenderMeshIndirect把剔除结果直接喂给GPU渲染CPU完全不介入。注意HDRP下Indirect Draw的API和内置管线有差异CommandBuffer.DrawProceduralIndirect在HDRP里需要配合HDRenderPipeline的自定义Pass使用不能直接照搬Built-in的写法。2.3 LOD策略的取舍距离驱动还是误差驱动很多人第一反应是按距离分LOD近处高模远处低模。但纯距离驱动有个问题地形起伏大的地方远处低模会出现明显的轮廓失真而平坦区域近处用高模又浪费。我采用的是屏幕空间误差驱动。具体做法是对每个Patch计算它的几何误差通常是Patch内高度变化的最大值投影到屏幕后占多少像素。如果误差小于1个像素就用最低LOD误差在1到4像素之间用中等LOD超过4像素才用最高精度。这样平坦区域自动降精度崎岖区域保持细节视觉质量和性能都更优。误差计算公式大致是screenError geometricError * screenHeight / (2 * distance * tan(fov/2))其中geometricError是Patch的几何误差distance是Patch到相机的距离fov是垂直视场角。这个公式在Compute Shader里每个Patch算一次开销可以忽略。3. 核心细节解析与实操要点3.1 高度图数据的组织与采样高度图我建议用RenderTexture而不是Texture2D原因是RenderTexture支持GPU直接写入后续如果要做地形编辑比如挖洞、堆山可以直接用Compute Shader改高度图不用回读CPU。格式选RFloat或者RHalf。RFloat精度高但显存占用大RHalf在大多数场景够用。一张4096x4096的RHalf高度图占32MB显存RFloat则要64MB。如果地形尺度特别大可以分块存储每块2048x2048按需加载。采样时要注意双线性过滤的问题。地形网格顶点采样高度图时如果用双线性插值相邻顶点的法线计算会出现接缝。我的做法是在Compute Shader里手动做双线性采样然后用法线重建公式算顶点法线保证接缝处平滑。float SampleHeight(float2 uv) { float2 texelSize 1.0 / _HeightMapSize; float2 pixel uv * _HeightMapSize - 0.5; float2 f frac(pixel); int2 i floor(pixel); float h00 _HeightMap[i]; float h10 _HeightMap[i int2(1, 0)]; float h01 _HeightMap[i int2(0, 1)]; float h11 _HeightMap[i int2(1, 1)]; return lerp(lerp(h00, h10, f.x), lerp(h01, h11, f.x), f.y); }3.2 Patch剔除的包围盒计算每个Patch的包围盒不能简单用平面矩形因为地形有高低起伏。我的做法是在生成Patch时预先算好该Patch内的最小和最大高度包围盒的Y轴范围就取这两个值。这样剔除时不会因为地形凸起导致误剔除。包围盒和视锥的相交测试用标准的6平面测试。但要注意HDRP的相机视锥和内置管线略有不同特别是开了物理相机或者自定义投影矩阵时。稳妥的做法是从Camera组件拿到projectionMatrix和worldToCameraMatrix手动提取6个平面而不是依赖GeometryUtility.CalculateFrustumPlanes。bool IsPatchVisible(float3 boundsMin, float3 boundsMax, float4 planes[6]) { for (int i 0; i 6; i) { float3 normal planes[i].xyz; float d planes[i].w; float3 positive float3( normal.x 0 ? boundsMax.x : boundsMin.x, normal.y 0 ? boundsMax.y : boundsMin.y, normal.z 0 ? boundsMax.z : boundsMin.z ); if (dot(normal, positive) d 0) return false; } return true; }3.3 Compute Shader的线程组划分线程组大小直接影响性能。我试过[numthreads(64,1,1)]、[numthreads(128,1,1)]、[numthreads(256,1,1)]几种配置在RTX 3060上128的配置最稳。64太小线程组调度开销占比高256在某些老显卡上会触发寄存器溢出。每个线程处理一个Patch。如果Patch总数是4096那就要dispatch 32个线程组4096/128。Dispatch的组数要动态计算不能写死因为地形块数可能随加载变化。int patchCount terrainPatches.Count; int threadGroups (patchCount 127) / 128; _computeShader.Dispatch(0, threadGroups, 1, 1);提示Dispatch之前一定要确保AppendBuffer已经清空否则上一帧的剔除结果会残留导致渲染出不该渲染的Patch。用ComputeBuffer.SetCounterValue(0)来重置计数器。3.4 Indirect Draw的参数构造Indirect Draw需要一个参数缓冲区里面存着顶点数、实例数、起始索引等。这个缓冲区可以在Compute Shader里直接写入也可以用ComputeBuffer.CopyCount把AppendBuffer的计数拷过来。HDRP下推荐用RenderMeshIndirect它比DrawProceduralIndirect更灵活支持完整的材质属性。参数结构体是IndirectDrawIndexedArgs包含indexCountPerInstance、instanceCount、startIndex、baseVertexIndex、startInstance五个字段。var args new IndirectDrawIndexedArgs { indexCountPerInstance (uint)mesh.GetIndexCount(0), instanceCount 0, // 由Compute Shader填充 startIndex (uint)mesh.GetIndexStart(0), baseVertexIndex (int)mesh.GetBaseVertex(0), startInstance 0 };4. 完整实操流程与关键环节实现4.1 环境准备与项目配置先把HDRP环境搭好。Unity版本建议2022.3 LTS或更新HDRP版本对应14.x以上。新建项目时选HDRP模板或者手动装HDRP包。关键配置项Graphics Settings里把Scriptable Render Pipeline设为HDRP Asset。HDRP Asset里开启Compute Shader支持和Indirect Draw支持。Quality Settings里把Async Compute打开让Compute Shader和渲染管线能并行。如果目标平台是移动端还要在Player Settings里确认Graphics API包含Vulkan或MetalOpenGL ES对Compute Shader支持有限。注意HDRP的Camera组件默认会做自己的剔除如果我们的GPUTerrain已经做了剔除要把相机的Culling Mask设成不包含地形Layer避免重复剔除浪费性能。4.2 高度图生成与Patch数据构建高度图可以从外部导入也可以用代码生成。我一般用Perlin噪声加多层叠加生成一张基础高度图后续再手动雕刻细节。Texture2D GenerateHeightMap(int resolution, float scale, int octaves) { var tex new Texture2D(resolution, resolution, TextureFormat.RFloat, false); var pixels new float[resolution * resolution]; for (int y 0; y resolution; y) { for (int x 0; x resolution; x) { float h 0; float amplitude 1; float frequency 1; for (int o 0; o octaves; o) { h Mathf.PerlinNoise(x * frequency / resolution * scale, y * frequency / resolution * scale) * amplitude; amplitude * 0.5f; frequency * 2; } pixels[y * resolution x] h; } } tex.SetPixels(pixels); tex.Apply(); return tex; }Patch数据构建时每个Patch要记录世界坐标原点、尺寸、包围盒最小最大高度、几何误差。这些数据打包成ComputeBuffer结构体对齐要注意HLSL的float3实际占16字节别用float3直接塞用float4或者手动padding。struct TerrainPatch { public Vector3 origin; public float size; public Vector3 boundsMin; public float geometricError; public Vector3 boundsMax; public float padding; }4.3 Compute Shader剔除管线编写剔除Shader的主逻辑分三步视锥测试、LOD计算、写入AppendBuffer。#pragma kernel CSMain StructuredBufferTerrainPatch _Patches; AppendStructuredBufferuint _VisiblePatches; float4 _FrustumPlanes[6]; float3 _CameraPosition; float _ScreenHeight; float _Fov; [numthreads(128,1,1)] void CSMain(uint3 id : SV_DispatchThreadID) { uint patchIndex id.x; if (patchIndex _PatchCount) return; TerrainPatch patch _Patches[patchIndex]; // 视锥测试 if (!IsPatchVisible(patch.boundsMin, patch.boundsMax, _FrustumPlanes)) return; // 计算屏幕空间误差 float distance length(patch.origin - _CameraPosition); float screenError patch.geometricError * _ScreenHeight / (2 * distance * tan(_Fov * 0.5)); // 误差太小就跳过用更低LOD的Patch代替 if (screenError 1.0) return; _VisiblePatches.Append(patchIndex); }LOD选择我放在渲染阶段做而不是剔除阶段。剔除阶段只判断可见性渲染时根据误差选具体网格。这样剔除和LOD解耦调试更方便。4.4 渲染Pass的接入HDRP下自定义Pass需要继承CustomPass在Execute方法里做Indirect Draw。class GPUTerrainPass : CustomPass { protected override void Execute(CustomPassContext ctx) { // 更新Compute Shader参数 _computeShader.SetBuffer(0, _Patches, _patchBuffer); _computeShader.SetBuffer(0, _VisiblePatches, _visibleBuffer); _computeShader.SetMatrixArray(_FrustumPlanes, _frustumPlanes); _computeShader.SetVector(_CameraPosition, ctx.hdCamera.camera.transform.position); // Dispatch剔除 _computeShader.Dispatch(0, _threadGroups, 1, 1); // 拷贝计数 ComputeBuffer.CopyCount(_visibleBuffer, _argsBuffer, 4); // Indirect Draw ctx.cmd.RenderMeshIndirect(_terrainMesh, _terrainMaterial, _argsBuffer, 1); } }CopyCount的第二个参数是偏移量IndirectDrawIndexedArgs里instanceCount在偏移4字节的位置前4字节是indexCountPerInstance所以填4。4.5 地形材质与法线处理材质用HDRP的LitShader但顶点位置和法线要在Vertex Shader里根据高度图重新计算。因为我们的网格是程序化生成的顶点位置不是固定的。void Vert(inout AttributesMesh input) { float3 worldPos TransformObjectToWorld(input.positionOS); float height SampleHeight(worldPos.xz); worldPos.y height; input.positionOS TransformWorldToObject(worldPos); // 法线重建 float3 normal ReconstructNormal(worldPos.xz); input.normalOS TransformWorldToObjectNormal(normal); }法线重建用中心差分采样当前点上下左右四个点的高度算切线叉乘得法线。采样间距取一个像素对应的世界距离太小会有噪声太大法线会糊。提示HDRP的LitShader默认会做法线贴图采样如果我们的地形不需要法线贴图在材质里把Normal Map关掉省一次采样。5. 常见问题与排查技巧实录5.1 地形接缝与LOD跳变问题表现相邻Patch之间出现裂缝或者LOD切换时地形突然跳一下。原因相邻Patch的LOD等级不同边界顶点高度不一致。LOD跳变是因为误差阈值设置得太敏感。解决在Patch边界处做顶点高度对齐。具体做法是高LOD的Patch在边界处采样低LOD Patch的高度强制对齐。LOD跳变则通过加一个过渡区间来缓解误差在0.8到1.2像素之间时用混合LOD而不是硬切。我踩过的坑是一开始没做边界对齐地形看起来像碎玻璃。后来加了边界采样接缝就没了。但边界采样会增加一点开销大概每帧多0.3毫秒可以接受。5.2 Compute Shader结果为空问题表现地形完全不渲染或者只渲染了一部分。排查步骤检查AppendBuffer的计数是否正常。用ComputeBuffer.GetCounterValue()读一下如果是0说明剔除全被剔掉了。检查视锥平面提取是否正确。把相机参数打印出来手动算一下平面方程和Shader里的对比。检查Patch的包围盒是否合理。如果包围盒Y轴范围是0到0那所有Patch都会被剔除。检查Dispatch的线程组数是否够。如果Patch有4096个线程组只Dispatch了1个那只能处理128个Patch。速查表现象可能原因排查方法完全不渲染AppendBuffer计数为0读计数器检查视锥平面部分渲染线程组数不足确认Dispatch组数ceil(patchCount/128)闪烁包围盒计算错误检查boundsMin/Max是否合理边缘缺失视锥平面提取错误对比手动计算的平面方程5.3 移动端性能不达标问题表现PC上跑得好好的打包到移动端帧率腰斩。原因移动端GPU的Compute Shader性能和PC差距很大特别是线程组调度和内存带宽。另外移动端对Indirect Draw的支持不如PC完善。解决降低线程组大小从128降到64减少寄存器压力。减少Patch数量增大Patch尺寸。PC上64米一块移动端可以128米一块。关闭Async Compute移动端上这个特性有时反而拖后腿。如果目标设备支持Vulkan优先用VulkanMetal次之OpenGL ES最后考虑。我实测过一台骁龙870的设备用128线程组时帧率只有25降到64线程组后回到38再把Patch尺寸翻倍后稳定在50左右。所以移动端一定要做参数降级。5.4 显存占用过高问题表现地形加载后显存暴涨低配设备直接崩。原因高度图分辨率太高或者Patch数据缓冲区太大。解决高度图用RHalf代替RFloat显存直接减半。Patch数据如果每个Patch存了冗余信息精简结构体。另外不可见的Patch数据可以卸载用分块加载的方式管理。提示ComputeBuffer创建时指定ComputeBufferType.Structured不要用DefaultStructured的显存管理更高效。5.5 HDRP版本兼容性问题问题表现升级HDRP版本后Indirect Draw报错或者渲染结果异常。原因HDRP的API在不同版本间有变动特别是CustomPass和RenderMeshIndirect的签名。解决锁定HDRP版本不要随意升级。如果必须升级先查官方Changelog看CustomPass和Indirect Draw相关API有没有Breaking Change。我一般会在项目里把HDRP版本写死在manifest.json里避免自动升级。6. 一些实操心得与扩展思路这套GPUTerrain方案我从原型到落地大概花了三周时间其中一半时间在调剔除和LOD的边界情况。最大的体会是GPU-Driven的东西调试成本比CPU侧高得多。CPU上你可以打断点、打日志GPU上只能靠RenderDoc抓帧或者把中间结果写到RenderTexture里肉眼观察。所以前期一定要把数据可视化做好比如把Patch的LOD等级用颜色画出来把剔除结果画成红绿图这样一眼就能看出问题。另一个心得是不要追求一步到位。我一开始就想把地形编辑、草皮、树木全塞进GPU管线结果复杂度失控。后来拆成三步走先跑通纯地形渲染再加草皮最后加树木。每步都验证性能稳了再往下走。后续扩展的话这套管线可以接GPU Instancing做植被也可以接Virtual Texture做地表贴图混合。思路是一样的把CPU侧的批量判断搬到GPU用并行计算换帧率。但每加一个系统都要重新评估GPU的算力预算别把显卡压垮了。最后分享一个小技巧在Compute Shader里加一个_DebugMode开关可以切换输出剔除结果、LOD等级、包围盒可视化。调试时省事很多发布时把开关关掉不影响性能。这个习惯我从做渲染管线开始就一直保持强烈推荐。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。