ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity URP程序化草地:Compute Shader与GPU间接绘制全解析

Unity URP程序化草地:Compute Shader与GPU间接绘制全解析 并不是所有人都需要知道“草为什么好看”但做 3D 场景渲染的同学大概率都经历过这样一个瞬间美术在场景里刷了一万个草型 GameObject一拉镜头帧率直接掉到 20你打开 Profiler 一看Draw Call 一万多CPU 端 Transform 更新占了几毫秒而 GPU 可能只用了不到一半。这种时候大部分人的第一反应是“换一个更快的电脑”。但在引擎渲染工程里真正的问题往往不是硬件不够而是数据根本没有喂到 GPU 上。这篇文章要聊的就是彻底绕开 CPU 瓶颈的程序化草地生成方案在 Unity URP 下用 Compute Shader 负责草叶实例的生成、剔除和数据更新再通过 GPU 间接绘制一次性把所有草叶画出来。整套流程不会把一个真实项目里的几百根草变成好看的大草原但可以把“每帧几万根草还能稳定跑”的骨架搭起来。先说结论如果你已经准备用 GameObject Instantiate 去铺一大片草或者把每根草建模成单独 Mesh 再挂 MeshRenderer那可以停一下。这套方案的性能上限很低哪怕在本地测试场景里只有 5 万根草都会让你感受到 Transform 同步和 Draw Call 带来的双重压力。正确做法是把草叶当成“GPU 一侧的粒子”每根草只是一个结构体存位置、尺寸、颜色、随机器生成和动画全部丢给 Compute Shader渲染时用一次 Indirect Draw 配合自定义 Shader 画出来。文章后面会把这条链路拆成 7 个实战环节每个环节都给出可直接对照的代码与配置属于“知其所以然并照着能落地”的类型。1. 第1讲整体数据流与性能上限分析1.1 为什么 GameObject 方案会先撞墙在开始写 Compute Shader 之前先建立一个非常简单的数量模型。假设一个 100 x 100 米的地块上要铺 10 万根草如果用 GameObject 实现每一根草至少要占据一个 Transform 组件也就是一个 C 对象加一个场景节点美术如果还要每根草有轻微缩放旋转差异那 CPU 每帧必须更新 10 万个 Transform 的本地矩阵。即使不做任何动画只是让相机移动时草叶跟着被裁剪掉Unity 的 Culling 系统也要先遍历这 10 万个对象再逐个判断包围盒。这一层的 CPU 开销往往比实际渲染还要贵。如果换成 MeshRenderer GPU Instancing比如把地面按 4x4 米分成很多个小区域每个区域放一片草实例CPU 仍然要维护“哪些区域可见、每个区域有多少实例、实例矩阵有没有变化”。只要草地密度提高内存和 CPU 更新压力同样线性增长。真正适合大规模植被的路线是让 CPU 只负责“给 GPU 分配工作”而所有“生成哪根草、这根草能不能被看到、风怎么吹它”这些事情都在 GPU 上完成。1.2 一条完整的 GPU 草地渲染数据流从工程角度看程序化草地的每帧流程可以归纳为五段阶段发生位置每帧是否执行主要工作实例数据生成Compute Shader初始化或低频根据地形范围随机生成草叶位置、高度、宽度、颜色因子视锥剔除与密度裁剪Compute Shader每帧判断草叶是否在相机视锥内是否超过最大距离并写入紧凑缓冲间接参数写入Compute Shader每帧把实际可见数量写进 Indirect Args供绘制指令读取顶点动画GPU Vertex Shader每帧根据草叶实例数据和风场参数进行弯曲、摆动绘制Indirect Draw / DrawMeshInstancedIndirect每帧使用一个 Mesh 和材质一次调用绘制所有可见实例从全局来看整个系统只有两个明显的“CPU→GPU”边界一个是初始化时把 ComputeBuffer 创建好另一个是每帧上传相机视锥平面和风场变量。草叶自己不会每帧从 GPU 读回 CPU所以不会出现 Readback 和 Stall。这正是引擎渲染实战里最重要的思维转变让 CPU 退到“调度者”的位置而不是“搬运工”。1.3 性能上限在哪里那么这套方案能支撑多少草叶上限往往不来自 GPU 像素填充或顶点数而是来自三处Compute Shader 的 Dispatch 次数和线程浪费。如果草叶总数有 100 万而线程组大小是 64那么 Dispatch 的线程组数量约 15625 个只要 Shader 内部逻辑不复杂这在一个现代 GPU 上根本不是瓶颈。间接绘制时 Vertex Shader 的执行次数。草叶 Mesh 如果是 6 个三角形的小十字片每根草约 8 个顶点100 万草叶意味着每帧要处理 800 万顶点这个量级在 PC 上可以接受在移动端则需要配合 LOD 和密度缩放到 10 万级别。Buffer 带宽。每根草实例如果存 3 个 float4stride 是 48 字节100 万根就是 48MB。每帧视锥剔除时读一遍光照和动画时再读一遍这部分内存带宽是真正需要关注的。所以实际项目通常会限制单块密度并在远处直接不生成草。明确这个模型之后再进入代码层面就不会觉得 Compute Shader 很神秘。它本质上就是让 GPU 把一段并行逻辑跑在成千上万个线程上用大量线程换 CPU 的串行效率。2. 第2讲URP 环境下 Shader 与 Compute Buffer 基础2.1 URP 与内置渲染管线的差异URPUniversal Render Pipeline已经是 Unity 当前主流的渲染管线。相比 Built-in Render PipelineURP 的 Shader 写法更倾向于 HLSL并且大量使用 SRP Batcher 来减少 SetPass Call。写草地 Shader 时最容易踩的坑是把 Built-in 管线的 CGPROGRAM 习惯直接搬过来。从工程角度看最值得关注的差异是三个URP 的标准光照函数位于Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl而不是内置的UnityCG.cginc。如果要兼容 SRP Batcher材质属性需要放进CBUFFER_START(UnityPerMaterial)。自定义顶点数据读取实例信息时需要在 Shader 里增加StructuredBuffer声明并通过SV_InstanceID索引这种方式和 URP 的 instancing 宏并不冲突。2.2 ComputeBuffer 与 Shader 之间的数据契约Compute Shader 最容易被轻视的地方是内存布局。GPU 没有 C# 对象它看到的只是一段连续的内存。你想让 CPU 写入的 struct 和 GPU 读到的 struct 完全一致就必须保证两边的 stride 一致并且字节对齐。例如一个典型草叶实例结构体// C# 侧 [System.Runtime.InteropServices.StructLayout(LayoutKind.Sequential)] public struct GrassInstance { public Vector4 positionAndRandom; // xyz 位置w 随机种子 public Vector4 sizeAndColor; // x 高度y 宽度zw 颜色因子 public Vector4 windAndBend; // x 相位y 弯曲值zw 预留 }对应的 HLSL 结构struct GrassInstance { float4 positionAndRandom; float4 sizeAndColor; float4 windAndBend; };这两个结构的大小都是 48 字节且内部都是 float4 对齐。不要为了省内存把float3连续排列因为 HLSL 中float3的对齐规则在不同平台并不稳妥再叠加 StructuredBuffer 之后很容易出现数据错位。工程上一个保守做法是只要写入 ComputeBuffer 的 C# 结构里出现了Vector3就自动补齐成Vector4或拆成多个 float。2.3 Shader 中读取实例数据的两种方式在 Shader 中读取实例数据常见有两条路依赖 Unity 的 Instancing 宏也就是UNITY_ACCESS_INSTANCED_PROP配合MaterialPropertyBlock设置实例化数组。这种方式适合实例数量较少、字段也较少的场景。依赖自定义StructuredBufferGrassInstance。渲染时把 ComputeBuffer 绑定到材质上顶点着色器里用uint instanceID : SV_InstanceID做索引。程序化草地场景下我们关心的是大量实例的连续动态更新因此后面代码采用第二种方式。使用 StructuredBuffer 时有一个隐蔽细节如果同一材质同时被普通 MeshRenderer 和 Indirect Draw 使用普通 MeshRenderer 的实例读取会导致越界。实际操作中最好单独隔离一份草材质或者用一个统一的_InstanceCount做边界判断避免普通球体、Cube 同时出现在这块材质上。3. 第3讲草叶对象与 URP 草 Shader 搭建3.1 草叶 Mesh 的选择草叶的建模不需要真的去美术软件里做一棵精细植物。程序化草地通常采用两种简化几何体十字交叉片两个 Quad 垂直交叉顶点少光照轮廓简单适合远处的草。多片扇叶从中心点向外延展 3 到 5 个三角形条能更好模拟一丛草从根部向上自然展开的形态。为了把重点放在 Compute Shader 上本文使用一个最简单的 Blade Mesh一个三角形条组成的单片草叶包含 3 个顶点。实际项目中可以在初始化阶段用 C# 生成这个 Mesh也可以准备一个美术建模好的低模草片。3.2 URP 草 Shader 的完整结构下面的 Shader 重点演示三件事读取实例数据、让草从根部到顶部轻微弯曲、输出基础颜色。它不是完整的 PBR 材质但足以跑通 Indirect Draw 流程。Shader Custom/Grass { Properties { _BaseColor (Base Color, Color) (0.2, 0.6, 0.1, 1) _TopColor (Top Color, Color) (0.8, 1.0, 0.4, 1) _BendStrength (Bend Strength, Range(0, 1.5)) 0.3 } SubShader { Tags { RenderPipeline UniversalPipeline RenderType Opaque Queue Geometry } HLSLINCLUDE #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl CBUFFER_START(UnityPerMaterial) float4 _BaseColor; float4 _TopColor; float _BendStrength; CBUFFER_END struct GrassInstance { float4 positionAndRandom; float4 sizeAndColor; float4 windAndBend; }; StructuredBufferGrassInstance _GrassInstances; float _WindTime; ENDHLSL Pass { Name ForwardLit Tags { LightMode UniversalForward } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE #pragma target 4.5 struct Attributes { float3 positionOS : POSITION; uint instanceID : SV_InstanceID; }; struct Varyings { float4 positionCS : SV_POSITION; float3 color : TEXCOORD0; }; Varyings vert(Attributes input) { Varyings output; GrassInstance inst _GrassInstances[input.instanceID]; float3 worldPos inst.positionAndRandom.xyz; float height inst.sizeAndColor.x; float width inst.sizeAndColor.y; float seed inst.positionAndRandom.w; float3 bladePos input.positionOS * float3(width, height, width); float bendFactor bladePos.y / max(height, 0.001); bladePos.x sin(_WindTime * 2.0 seed) * bendFactor * _BendStrength * 0.1; bladePos.z cos(_WindTime * 1.7 seed) * bendFactor * _BendStrength * 0.1; float3 worldVertex worldPos bladePos; output.positionCS TransformWorldToHClip(worldVertex); float blend saturate(bladePos.y / max(height, 0.001)); output.color lerp(_BaseColor.rgb, _TopColor.rgb, blend).rgb; return output; } half4 frag(Varyings input) : SV_Target { return half4(input.color, 1.0); } ENDHLSL } } }这段 Shader 有几个实战细节值得解释在HLSLINCLUDE里定义GrassInstance这样后续多个 Pass 可以共用结构体。顶点动画是根据草叶局部positionOS.y比例计算弯曲量而不是整根草平移。这样呈现出来的是“草叶随风摇摆”而不是“整根草跳来跳去”。光照没有写全UniversalFragmentLit先返回简单的颜色插值目的是让第一个版本尽快跑通。如果读者希望草叶看起来更立体可以引入法线信息并在 frag 中用UniversalFragmentPBR或SimpleLit做光照。一般来说大规模草地为了性能不会使用完整的 PBR 光照模型一个 Lambert 环境光 阴影贴图采样已经足够。3.3 用 C# 生成 Blade Mesh不必依赖外部 FBX下面的代码可以生成一个三角形条状草叶 Mesh// 文件路径Assets/Editor/GrassMeshBuilder.cs using UnityEngine; public static class GrassMeshBuilder { public static Mesh BuildBladeMesh() { Vector3[] vertices { new Vector3(-0.05f, 0.0f, 0.0f), new Vector3( 0.05f, 0.0f, 0.0f), new Vector3( 0.0f, 1.0f, 0.0f) }; int[] triangles { 0, 1, 2 }; Vector2[] uv { new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 1f) }; Mesh mesh new Mesh { name BladeMesh }; mesh.vertices vertices; mesh.triangles triangles; mesh.uv uv; mesh.RecalculateBounds(); mesh.RecalculateNormals(); return mesh; } }这个 Mesh 只有 3 个顶点。一帧如果画 10 万根草等于 30 万个顶点压力很小。后面如果要把草做得更饱满可以用多个不同角度的 Blade Mesh 组合成一个实例 Mesh也会让整体密度观感提升不少。4. 第4讲Compute Shader 生成草叶实例数据4.1 为什么生成不在 CPU 上做很多想上 Compute Shader 的开发者第一步仍然是在 C# 里用ListGrassInstance生成 10 万条数据然后SetData到 ComputeBuffer。这种做法在初始化阶段没有太大问题但如果每次改变密度、需要热更新地块时CPU 串行生成和上传就会卡顿。程序化草地的价值不只是“跑得动”还包括“随时可以用参数重新生成一大片草”。所以更推荐把随机位置、朝向、大小和颜色等全部丢给 Compute Shader。CPU 只需要告诉它草地中心在哪、范围多大、总共生成多少根、随机种子是多少。4.2 生成用的 Compute Shader先定义一个用于生成实例数据的 Compute Shader。// 文件路径Assets/Shaders/GrassGenerate.compute #pragma kernel GenerateGrass struct GrassInstance { float4 positionAndRandom; float4 sizeAndColor; float4 windAndBend; }; RWStructuredBufferGrassInstance _GrassInstances; float4 _AreaParams; // x: 中心 X, y: 中心 Z, z: 宽度, w: 长度 float4 _ScaleParams; // x: 最小高度, y: 最大高度, z: 最小宽度, w: 最大宽度 float4 _ColorParams; // xyzw: 颜色随机权重保留 uint _InstanceCount; float _RandomSeed; float Hash11(float p) { p frac(p * 0.1031); p * p 33.33; p * p p; return frac(p); } [numthreads(64, 1, 1)] void GenerateGrass(uint3 dispatchId : SV_DispatchThreadID) { uint index dispatchId.x; if (index _InstanceCount) return; float halfW _AreaParams.z * 0.5f; float halfL _AreaParams.w * 0.5f; float randX Hash11(index * 0.71f _RandomSeed * 13.1f); float randZ Hash11(index * 1.37f _RandomSeed * 7.7f); float posX _AreaParams.x (randX - 0.5f) * _AreaParams.z; float posZ _AreaParams.y (randZ - 0.5f) * _AreaParams.w; float height lerp(_ScaleParams.x, _ScaleParams.y, Hash11(index _RandomSeed)); float width lerp(_ScaleParams.z, _ScaleParams.w, Hash11(index * 3.1f _RandomSeed)); GrassInstance instance; instance.positionAndRandom float4(posX, 0.0f, posZ, randX * 6.28f); instance.sizeAndColor float4(height, width, randX, randZ); instance.windAndBend float4(randX * 6.28f, 0.0f, 0.0f, 0.0f); _GrassInstances[index] instance; }这个 Kernel 里最关键的是Hash11。直接用sin或frac做随机虽然可以看但容易产生可见的网格规律。上面这种基于行列号的散列函数能生成比较均匀的伪随机数且不需要额外传入一个大数组。4.3 初始化并在 C# 中调用下面脚本负责初始化和每帧调用剔除// 文件路径Assets/Scripts/GrassRenderer.cs using System.Runtime.InteropServices; using UnityEngine; using UnityEngine.Rendering; [StructLayout(LayoutKind.Sequential)] public struct GrassInstance { public Vector4 positionAndRandom; public Vector4 sizeAndColor; public Vector4 windAndBend; } [RequireComponent(typeof(Camera))] public class GrassRenderer : MonoBehaviour { public Mesh grassMesh; public Material grassMaterial; public ComputeShader generateShader; public ComputeShader cullShader; [Header(草地分布)] public Vector2 center Vector2.zero; public Vector2 areaSize new Vector2(60f, 60f); public int instanceCount 100000; public float minHeight 0.5f; public float maxHeight 1.4f; private ComputeBuffer _grassInstanceBuffer; private ComputeBuffer _visibleBuffer; private ComputeBuffer _argsBuffer; private ComputeBuffer _counterBuffer; private void Start() { InitBuffers(); GenerateGrassInstances(); } private void InitBuffers() { int stride Marshal.SizeOfGrassInstance(); // 全部实例数据生成阶段会写入 _grassInstanceBuffer new ComputeBuffer(instanceCount, stride, ComputeBufferType.Structured); // 可见实例数据剔除完毕后写入 _visibleBuffer new ComputeBuffer(instanceCount, stride, ComputeBufferType.Structured); // 间接绘制参数顶点数、实例数、起始顶点、起始实例、保留位 int argsCount 5; uint[] args new uint[argsCount]; args[0] grassMesh.GetIndexCount(0); args[1] 0; _argsBuffer new ComputeBuffer(argsCount, sizeof(uint), ComputeBufferType.IndirectArguments); _argsBuffer.SetData(args); _counterBuffer new ComputeBuffer(1, sizeof(uint), ComputeBufferType.Structured); } private void GenerateGrassInstances() { int kernel generateShader.FindKernel(GenerateGrass); generateShader.SetBuffer(kernel, _GrassInstances, _grassInstanceBuffer); generateShader.SetVector(_AreaParams, new Vector4(center.x, center.y, areaSize.x, areaSize.y)); generateShader.SetVector(_ScaleParams, new Vector4(minHeight, maxHeight, 0.04f, 0.12f)); generateShader.SetInt(_InstanceCount, instanceCount); generateShader.SetFloat(_RandomSeed, Random.value * 100f); int threadGroups Mathf.CeilToInt(instanceCount / 64.0f); generateShader.Dispatch(kernel, threadGroups, 1, 1); } }注意这里生成的草位置高度直接设成 0前提是把草地放在 Y0 平面上。实际地形高低起伏时应该从高度图或地形 Collider 上采样y值再写进positionAndRandom.y。更进阶的方案是传入一张地形高度纹理在每个线程里做一次纹理采样然后直接得到准确高度。4.4 生成阶段的关键检查点如果运行后地面草的位置完全不对优先检查以下几点_AreaParams的 x/y 是否等于草地中心世界坐标而不是相对坐标。_GrassInstances是否成功绑定到 ComputeBuffer。Shader 中RWStructuredBuffer的变量名和 C# 端字符串必须完全一致大小写也要一致。instanceCount能否被 64 整除不重要因为 Shader 里已经用if (index _InstanceCount) return做了保护。但如果instanceCount小于线程组数会有大量线程空转初始化时不明显后续剔除阶段如果频繁 Dispatch 就会造成浪费。5. 第5讲GPU 视锥剔除与 Indirect Draw5.1 为什么需要 GPU 剔除如果只是把所有草叶实例直接丢给 DrawMeshInstancedIndirect性能已经比 GameObject 方案好很多但有一个
返回列表