
程序化草地是很多渲染Demo里最容易出效果、也最容易把工程写崩的方向之一。对象一多CPU侧逐个计算位置、风力、颜色DrawCall和同步开销马上压死帧率单纯用Unity的GPU Instancing做静态草堆又很难处理随机分布、动态风场和地块裁剪。这次我们看的是Unity URP环境下一套完整的7讲实战路线从地块数据建模、Compute Shader生成草点到URP草Shader绑定、动态风力更新、间接绘制与性能观测。这套方案不依赖外置插件核心就是Unity原生Compute Shader、StructuredBuffer、以及URP下的底层Shader编写。它需要你至少接触过Unity中Shader的基本编写方式不要求一开始就懂GPU底层细节但一定要有“数据从CPU传到GPU”的概念。文里所有代码都是可运行的结构示例参数和平台差异我会在对应位置标清楚避免你直接复制后发现效果不对还不知道问题出在哪一章。如果你正在做开放世界地表、大范围植被、或者想搞明白“为什么别人家的草能铺十几万根还能稳60帧”这篇文章值得先收藏再往下看。下面按7讲路线的顺序拆解并把每一讲最需要动手验证的点单独拿出来讲。1. 程序化草地实战核心知识速览维度说明项目技术栈Unity URP Compute Shader StructuredBuffer解决的核心问题大量草地的随机分布、数据生成、动态风场、实例化渲染与剔除流程编程语言C# HLSLGPU能力基础支持Compute Shader的DX11、Vulkan、Metal等平台CPU侧主要动作创建Buffer、Dispatch计算、调用绘制接口GPU侧主要动作生成草点位置、更新风力弯曲、输出变色/宽度等参数渲染接入点URP下的底层Shader按实例ID读取草地数据适合读者Unity客户端开发、技术美术、对GPU程序化生成感兴趣的渲染学习者是否支持批量任务天然支持草地数据本身就是批量Buffer需要提醒的是这套流程不是“搭一个工具然后拖入场景就完事”而是你需要在草密度、三角形数量、阴影开关、风场更新频率之间做取舍。文章后面的性能观察部分会专门给出一套通用调优顺序。2. 实战7讲整体设计很多用户拿到草地生成相关源码第一反应是直接看Shader怎么写的结果被一堆Pass、宏、StructuredBuffer绕晕。这里建议按下面7讲从前往后推进每一讲只解决一类问题。2.1 第1讲确定草地的数据规模与地块拆法先不要碰Shader。草地优化最基本的问题是你打算在一个片区放多少草每根草占多少三角形。一个10x10米的区域放1万根草和100x100米放1万根草密度完全不同。一般先按地块Grid组织每个地块单独保存一份草数据地块内有明确的草根数量上限。数据规模没定后面写出来的ComputeBuffer大小、Dispatch线程组数量、显存占用都没有依据。这一步要注意草地的“根数”和“草片数量”不是一回事。一个草簇可以用一个实例表示每个实例在GPU里扩展成3到5片草叶。决定数量后记下预算值后续所有优化都以这个预算为基准。2.2 第2讲设计GrassData结构与ComputeBuffer跨CPU与GPU传递的Buffer结构越简单越好。个人不建议在Buffer里放大量float3散数据因为C#的Vector3和HLSL的float3在部分底层布局里并不总是对齐。稳妥做法是每个草的数据全部用float4存放一个float4保存位置和高度另一个float4保存颜色和宽度。struct GrassData { public Vector4 posAndHeight; // xyz: 世界坐标, w: 草的高度系数 public Vector4 colorAndWidth; // rgb: 草颜色混合系数, w: 草宽度系数 }HLSL侧保持一致struct GrassData { float4 posAndHeight; float4 colorAndWidth; };这一步虽然简单但很多初学Compute Shader的人会在后续看到Shader输出紫色或画面错乱排查到最后往往是C#结构体和HLSL结构体尺寸不一致。开局就把数据格式固定能省很多电费。2.3 第3讲用Compute Shader生成草点分布这一步是真正的起点。既然叫Compute Shader程序化草地就要让GPU做随机采样而不是用一堆Random.Range在C#里循环。C#循环1万次可能还好循环50万次还会阻塞主线程而GPU一个Dispatch就可以并行处理大量数据。Compute Shader核心结构是这样#pragma kernel InitGrass #define GRASS_GROUP_SIZE 64 RWStructuredBufferGrassData _GrassBuffer; uint _TotalCount; float3 _Center; float2 _AreaSize; float _GroundHeight; float Rand(uint x) { x x * 747796405u 2891336453u; x (x ^ (x 13)) * 1103515245u; x x ^ (x 16); return (float)(x 0x00FFFFFF) / 16777216.0f; } [numthreads(GRASS_GROUP_SIZE, 1, 1)] void InitGrass(uint3 id : SV_DispatchThreadID) { if (id.x _TotalCount) return; GrassData grass; float2 uv float2(Rand(id.x 11u), Rand(id.x * 3u 7u)); float3 p _Center; p.x (uv.x - 0.5f) * _AreaSize.x; p.z (uv.y - 0.5f) * _AreaSize.y; p.y _GroundHeight; grass.posAndHeight float4(p, lerp(0.6f, 1.5f, Rand(id.x 1u))); grass.colorAndWidth float4(1.0f, 0.85f, 0.3f, lerp(0.03f, 0.08f, Rand(id.x 5u))); _GrassBuffer[id.x] grass; }这段代码用线程ID的哈希值生成随机数不依赖Unity随机函数。每个线程只处理一根草线程组大小是64Dispatch的线程组数量由总草数除以64向上取整。int group Mathf.CeilToInt(grassCount / 64f); computeShader.Dispatch(kernel, group, 1, 1);不要改成每线程只处理一个像素再一路狂奔到特大Buffer。先在小数量下确认输出位置和高度正确再加密度。2.4 第4讲CPU侧调度与渲染前准备C#侧需要创建ComputeShader引用、ComputeBuffer、以及一个用于实际绘制草簇的Mesh。把Buffer和计算Shader的kernel绑定后就能执行Dispatch。绘制方面最简单的方式是Graphics.DrawMeshInstancedProcedural。这个接口会根据传入的实例数量调用同一个Mesh然后每次绘制通过顶点Shader里的SV_InstanceID去StructuredBuffer里取草数据。using UnityEngine; public class GrassFieldGenerator : MonoBehaviour { public ComputeShader grassCompute; public Material grassMaterial; public Mesh grassBladeMesh; public int grassCount 10000; public Vector2 size new Vector2(20f, 20f); private ComputeBuffer grassBuffer; private int initKernel; private void Start() { initKernel grassCompute.FindKernel(InitGrass); grassBuffer new ComputeBuffer(grassCount, sizeof(float) * 8); grassCompute.SetBuffer(initKernel, _GrassBuffer, grassBuffer); grassCompute.SetInt(_TotalCount, grassCount); grassCompute.SetVector(_Center, transform.position); grassCompute.SetVector(_AreaSize, size); grassCompute.SetFloat(_GroundHeight, transform.position.y); int group Mathf.CeilToInt(grassCount / 64f); grassCompute.Dispatch(initKernel, group, 1, 1); grassMaterial.SetBuffer(_GrassBuffer, grassBuffer); } private void Update() { Bounds bounds new Bounds(transform.position, new Vector3(size.x, 4f, size.y)); Graphics.DrawMeshInstancedProcedural( grassBladeMesh, 0, grassMaterial, bounds, grassCount); } private void OnDestroy() { grassBuffer?.Release(); } }代码不复杂核心点有三个ComputeBuffer的stride是8个float即32字节对应C#里的两个Vector4。在Dispatch之后、绘制之前要把同一个Buffer设置到Material上。Update里的绘制调用不会触发CPU逐根计算实例数据已经在GPU端Buffer里。如果你的需求是草数量固定且没有裁剪、LOD切换这套GPUInstance方案已经够用。如果需要动态根据相机位置剔除远处的草那就必须考虑第6讲里的间接绘制。2.5 第5讲风场与交互位置更新动态风场是草地程序化中最容易出效果的部分也是最容易把性能做崩的部分。一个不合理的方案是每帧在C#里读取全部草数据修改后再写回Buffer这会直接造成CPU与GPU同步卡顿。正确做法是把玩家位置、风向、风力、时间等极少量参数通过SetVector/SetFloat传到GPU再在Compute Shader里针对Buffer做更新。#pragma kernel UpdateGrass RWStructuredBufferGrassData _GrassBuffer; float3 _PlayerPos; float _WindStrength; float _WindTime; float _DeltaTime; [numthreads(GRASS_GROUP_SIZE, 1, 1)] void UpdateGrass(uint3 id : SV_DispatchThreadID) { if (id.x _TotalCount) return; GrassData grass _GrassBuffer[id.x]; float3 worldPos grass.posAndHeight.xyz; // 用位置和时间做简单正弦风场 float wave sin(worldPos.x * 2.0f _WindTime * 6.0f) cos(worldPos.z * 2.0f _WindTime * 4.0f); // 将弯曲程度编码到posAndHeight.w的额外偏移位实际由Shader去采样 // 这里只示意如果需要保存弯曲可以再加一个float4字段 grass.posAndHeight.w wave * _WindStrength * _DeltaTime; grass.posAndHeight.w saturate(grass.posAndHeight.w); _GrassBuffer[id.x] grass; }实际工程中通常不会把草叶的弯曲量直接存进第6帧后永远累积的高度字段而是一个独立的弯曲Buffer。核心思想是每一帧只做少量GPU端加法或Lerp控制住更新范围。风场全部放在像素阶段做也可以但更推荐在Compute阶段先完成避免单个顶点Shader计算量过大。在C#侧每帧更新风力参数时只需要在Update里调用一次GPU端Dispatchprivate int updateKernel; private float windTime; private float deltaTime; private void Update() { windTime deltaTime; grassCompute.SetFloat(_WindTime, windTime); grassCompute.SetFloat(_DeltaTime, Time.deltaTime); grassCompute.SetVector(_PlayerPos, Camera.main.transform.position); grassCompute.SetFloat(_WindStrength, windStrength); grassCompute.Dispatch(updateKernel, group, 1, 1); }不要把UpdateGrass的频率绑定到渲染主线程上。若草数量很大还可以每隔2到3帧更新一次风场远距离草地完全看不出区别。2.6 第6讲URP侧草Shader的数据读取与渲染URP项目里最容易踩的坑是直接把内置渲染管线的草Shader拿过来然后看到材质变成紫红色。URP下的自定义Shader必须使用HLSL并且引入URP的ShaderLibrary。一个可用的草Shader帧布局应该包含这些核心点Shader Custom/GrassCompute { SubShader { Tags { RenderPipeline UniversalPipeline RenderType TransparentCutout Queue AlphaTest } Pass { HLSLPROGRAM #pragma target 4.5 #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct GrassData { float4 posAndHeight; float4 colorAndWidth; }; StructuredBufferGrassData _GrassBuffer; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_Position; float2 uv : TEXCOORD0; float3 color : TEXCOORD1; }; v2f vert(appdata v, uint instanceID : SV_InstanceID) { v2f o; GrassData grass _GrassBuffer[instanceID]; // 以实例位置为基准把草叶Mesh从原点展开到草地位置 float3 worldPos grass.posAndHeight.xyz; worldPos.xy v.vertex.xy * float2(grass.colorAndWidth.w, grass.posAndHeight.w); // 这里的展开方式只是示意取决于你的草叶Mesh坐标 o.pos TransformWorldToHClip(worldPos); o.uv v.uv; o.color grass.colorAndWidth.rgb; return o; } half4 frag(v2f i) : SV_Target { return half4(i.color, 1.0); } ENDHLSL } } }这段Shader只演示了如何通过SV_InstanceID读取Buffer中的草数据实际草叶展开还需要配合相机朝向或固定轴向防止草在旋转时变成纸片。由于Shader里没有使用额外实例化属性Buffer在Material侧绑定一次即可。注意URP较低版本和较新版本在LOD跨平台处理上不一样。如果你的项目要发布到移动端建议先检查Metal或Vulkan下对StructuredBuffer的读写支持并且避免在Fragment阶段大批量读取Buffer。移动端的GPU架构差异会影响带宽草地Shader最好尽量把计算放在Vertex阶段。2.7 第7讲剔除、LOD与DrawMeshInstancedIndirect当草地开始覆盖成片地形时单纯固定实例数量的DrawMeshInstancedProcedural不够用。因为相机看不到身后区域的草没有必要提交给GPU。此时需要把CPU端的固定count交给GPU端间接参数。核心思路是一个ArgsBuffer保存绘制所需的索引数量、实例数量、起始位置等参数再通过Compute Shader对每个地块做视锥剔除并用InterlockedAdd累加可见实例数。最终绘制时不走CPU固定count而是用Graphics.DrawMeshInstancedIndirect读取ArgsBuffer里的数字。Graphics.DrawMeshInstancedIndirect( grassBladeMesh, 0, grassMaterial, bounds, argsBuffer);间接绘制需要多准备一个ComputeBuffer保存参数并且这个Buffer在每帧需要被Compute Shader更新。这一步对你的项目是一次明显复杂度提升但也是从“几万根草在固定区域做Demo”走向“几十万根草覆盖整个地形”的必经之路。LOD上不建议只依赖Unity的LODGroup因为草是程序化生成的数据每根草没有独立的Transform。推荐在地块粒度上做LOD离相机近的地块保留高密度草中等距离降到低密度最远处只保留地面颜色混合或Billboard片。3. 环境准备与URP项目配置先说明硬性建议按你自己的Unity版本确认。较稳妥的组合是Unity 2021.3 LTS及以上URP包使用与编辑器匹配的版本。项目新建时直接选择Universal 3D模板比先建Built-in工程再切换URP更省事。项目创建完成后有几项容易忽略Project Settings Graphics确认Scriptable Render Pipeline Settings指向URP资产而不是空值。所有草地相关Shader都要放到URP管线下不能在Built-in下直接调试。打开Frame Debugger确认草地Pass是否真的被URP执行。很多Shader看起来写了Pass实际因为Tag不对连渲染队列都没进入。如果要在移动平台跑检查Graphics API。Windows桌面可以用DX11或DX12Android根据设备选择Vulkan或GLES。Compute Shader并不是所有平台默认开启的功能提前筛选目标设备可以减少后患。环境准备阶段不追求一次到位先创建一个空场景在地面上放一个小方块能正常在URP下看到方块颜色再看草地的代码逻辑会比较顺。4. Compute Shader程序化草地部署与启动流程这部分不是Unity里点两下就能跑它是一个“C#组件挂到场景对象、Shader和ComputeShader引用拖进去”的开发流程。下面给出一套标准部署顺序在Project窗口创建一个Compute Shader把InitGrass和UpdateGrass的kernel代码放进去。创建URP草地Shader并加入StructuredBuffer槽位。用ProBuilder或3D软件导出一个草叶Mesh尽量让顶点坐标位于局部坐标原点附近方便实例数据做平移。创建空GameObject挂上GrassFieldGenerator脚本。把Compute Shader、Material、草叶Mesh拖到脚本引用上。运行场景先设置grassCount 1000确认不出错再逐步提高到目标数量。启动运行后你可能会遇到草只在某一块地形上挤在一起或者位置完全随机但根本没有跟随地块中心。经验上大多数问题是_Center没有从C#正确传入Compute Shader。_Center在C#里是一个Vector3的Transform.position但Unity的SetVector实际会传一个Vector4。如果HLSL里声明的是float3要注意不同版本下四分量分量截断是否如预期。更稳妥的做法是在HLSL侧直接声明float3 _Center;C#侧用SetVector传递时Unity会按Vector3语义处理不会补一个默认w。启动过程本身并不是重点重点是它能跑出一个很直观的结果草会密布在你给定的矩形区域里。从这一步开始才能进入资源占用和效果的迭代。5. 资源占用与性能观察方法程序化草地因为涉及GPU线程调度很多消耗不能直接用Unity Profiler CPU段看出来。你至少要做三件事打开Profiler窗口观察Player Loop里PlayerUpdate与Rendering耗时占比。如果Render线程明显升高多半是草片数量和Shader复杂度问题。使用Frame Debugger看每个Pass的DrawCall和三角形数。草地必须确认没有出现几万次单独小DrawCall而是合理的实例化DrawCall。观察ComputeBuffer实际大小。每一根草用两个float4的位置和颜色10万根草就是10万 * 32字节约3.2MB。如果草数据里塞了大量额外的切线、法线、颜色、新旧位置Buffer容量会快速膨胀到几十MB显存占用自然不是一个小数字。关于显存的精准数字要看你自己的草数量、Buffer字段数和目标平台。项目早期建议按“先看Buffer容量再看草片三角形数最后看片元填充率”的顺序定位瓶颈避免盲目降草密度。需要特别强调草地成本并不只在草本身。草投射阴影时Shadow Pass还要被渲染一次。使用Spot Light或平行光时可以尝试让草地不投射阴影只用自阴影烘焙在地表纹理上。如果你坚持要让草投射阴影务必确认Shadow Pass里的Shader是同一个URP Shader并且没有在Shadow Caster Pass里重复读取大Buffer造成性能浪费。6. 常见问题与排查方法问题现象可能原因排查方式解决方案场景里草完全看不见Compute Shader没执行或Buffer未绑定到Material检查C#日志确认Dispatch执行确认Material.SetBuffer调用位置把SetBuffer放在Dispatch之后、第一次Draw之前草材质显示为紫红色或纯白Shader没有被URP正确编译查看Shader编译错误检查是否引入URP ShaderLibrary将Shader写法改为URP HLSL规范确认SubShader Tag草出现在错误坐标或堆积在原点_Center或_AreaSize读取值不对在ComputeShader临时输出调试颜色到Buffer优先检查C#侧SetVector与HLSL侧变量名、类型草的随机分布不均匀随机函数周期短或直接用了sin做分布观察草是否呈明显锯齿或格子条带改用整数哈希生成随机值草数量大了以后CPU卡顿代码里每帧对ComputeBuffer调用GetData回读搜索代码中是否存在ReadInto CPU侧循环不要在CPU回读Buffer必要时把数据留在GPU端Metal平台下Dispatch崩溃ComputeBuffer在Metal上的StructuredBuffer支持限制查看设备日志和Unity Console降低Buffer类型复杂度或切换Vulkan/GLES验证草没有阴影或阴影闪烁Shadow Caster Pass缺少草地数据读取逻辑在Frame Debugger查看ShadowMap Pass为草地单独写Shadow Caster Pass或关掉草阴影草地出现的这些坑看起来各有不同但本质绝大多数都是数据没有在正确的渲染时机被绑定。先明确Shader需要读哪块Buffer再确认C#是否在这个Pass执行之前调用了SetBuffer问题会好查很多。7. 从七讲实战到工程化的最佳实践把七讲全部跑通只是第一步真正要到生产环境还需要做几个工程化动作。第一地块数据要分块管理。永远不要试图在场景中央放一个覆盖全部地形的超大Buffer。把地形拆成若干个Grid每块Grid有自己的Bounds和草数据Buffer。更新风场时只更新相机可见的地块或者与玩家距离小于阈值的地块。第二草的位置最好用高度图或者地形采样做约束而不是全部固定到某个GroundHeight。如果直接把草放在一个平面高度上落地到起伏地形时会看到草插入山坡或者悬空。采样高度图的优点是变低不需要每根草做射线检测。第三批量任务和日出日落变色这类效果适合用Compute Shader处理不适合逐帧CPU循环。把草间褪色、枯黄比例、风力强度全部作为uniform传入GPU利用草的位置作为输入做程序化插值效果会比给每根草单独设置Color更自然。第四版权与资产边界需要提前确认。你自己写的Compute Shader没有版权问题但草叶贴图、草叶片Mesh、第三方开发的URP草地Shader如果是从Asset Store下载的要检查授权范围是否覆盖你准备发布的平台。不要因为“只用在本地测试”就忽视许可边界尤其涉及商业项目时需要留好授权记录。第五用数据驱动的方式组织参数。草密度、区域尺寸、草高度范围、风场强度这些值不要硬编码进C#做成可序列化字段或ScriptableObject配置。这样你可以同时保存“质量低、质量中、质量高”三套配置针对不同平台预处理和跑分。8. 下一步扩展方向跑完一套固定区域内的程序化草地后你其实已经掌握了一个通用能力把自然场景数据从CPU生成任务转成GPU并行任务。这种思路可以继续迁移到石块散布、树木分布、粒子系统流场、大规模实例化建筑物摆放甚至更复杂的GPU GpuDriven管线。最值得先验证的功能是风场交互与间接绘制。风场交互能验证你“是否真正理解了数据在Compute Shader里的更新方式”间接绘制能验证你是否具备“让GPU自己决定画多少”的工程能力。这两个能力只要有一个能用在自己的项目里程序化草地的学习就算真正落地了。最容易踩的坑依然在数据结构和渲染时机。这类玩GPU实例化的方案最难排查的不是画面不够好而是“某个版本的Unity或URP改了底层渲染时机导致同样的SetBuffer调用突然失效”。建议从一开始就给草地代码加独立的Debug开关允许你在场景中只显示草点、不显示草Mesh这样才能快速区分是数据问题还是Shader问题。