专栏名称: 游戏开发技术教程
网易十年码农,教程、内推、解惑。游戏开发技术、技巧、教程和资源下载,答疑解惑,内推面试。Unity3D、UnrealEngine(UE4、UE5)引擎,C#、C++等语法,图形渲染、物理动画、原理机制、源码剖析等及面试笔试题、职业规划。
目录
相关文章推荐
Python爱好者社区  ·  董事长。。。刺死。。。技术总监。。。已被捕。。。 ·  2 天前  
Python爱好者社区  ·  数学全书 ·  昨天  
Python爱好者社区  ·  北京大学第四弹:DeepSeek私有化部署和 ... ·  昨天  
Python爱好者社区  ·  DeepSeek全攻略 ... ·  5 天前  
Python爱好者社区  ·  中国最难入的IT公司。 ·  5 天前  
51好读  ›  专栏  ›  游戏开发技术教程

程序化草地在Unity中的实现

游戏开发技术教程  · 公众号  ·  · 2024-06-04 13:57

正文

前言

这里的程序化草地是基于《对马岛之魂》在GDC上的分享做的,其中我的实现方法和GDC的方法可能会有一些出入,但是大体上的思路是一致的。

正文

程序框架的搭建

使用绘制参数直接在GPU上直接进行绘制是提升性能最关键的步骤,在Unity中我们可以调用 Graphics.DrawProceduralIndirect 实现这项功能.

     public static void DrawProceduralIndirect(
Material material, //草所使用的材质
Bounds bounds, //所渲染的草地的包围盒
MeshTopology topology,//这个参数可以指定五项,分别是Triangles、Quads、Lines、LineStrip、Points
ComputeBuffer bufferWithArgs,//绘制参数
int argsOffset = 0, //对于bufferWithArgs参数的偏移
Camera camera = null, //为null就是所有相机都会执行绘制,否则就执行指定相机的绘制
MaterialPropertyBlock properties = null,//想要单独设置的材质参数
ShadowCastingMode castShadows = ShadowCastingMode.On,
bool receiveShadows = true,
int layer = 0 //指定绘制对象所属的层级
)

对于 bufferWithArgs 这个参数需要着重的说明一下, 在创建这个传递这个参数的 ComputeBuffer 实例时,必须要指定它为 ComputeBufferType.IndirectArguments 。它一般有四个参数(五个的时候是用的 DrawProceduralIndirect 的另一个重载),分别是每个实例的顶点数、实例数、起始顶点位置和起始实例位置,后面的两个参数一般指定为0,顶点数也容易搞定,主要是实例数的确定可能有一些困难。但是幸好有 AppendStructuredBuffer 的出现,它自带计数器,我们可以在compute shader中将数据处理完之后放到 AppendStructuredBuffer 中,它会记录其中所存放的数据的数量, 然后我们利用 ComputeBuffer.CopyCount 可以将实例数复制给 bufferWithArgs 的第二个参数。需要注意的是在每次调用compute shader时需要将 count 置为0。

_grassPropertiesBuffer.SetCounterValue(0);

GrassComputeShader.Dispatch(0,groupSize,groupSize,1);

ComputeBuffer.CopyCount(_grassPropertiesBuffer,_argsBuffer,sizeof(int));

Graphics.DrawProceduralIndirect(GrassMaterial,_grassInstanceBounds,MeshTopology.Triangles,_argsBuffer,
0,null,null,ShadowCastingMode.Off,true,gameObject.layer);

由于使用参数直接进行绘制,不存在实际的网格,所以在shader编写时需要注意传入参数的绑定。我们提前将需要的数据都传入StructuredBuffer中,然后根据对应的ID可以获取到其中的数据。

StructuredBuffer<GrassPropertiesStruct>GrassProperties;
StructuredBuffer<int> Triangles;
StructuredBuffer<float4> Colors;
StructuredBuffer<float2> Uvs;

//这里第一参数对应之前设置的顶点数,第二个参数对应实例的数
Varyings vert(uint vertex_id: SV_VertexID, uint instance_id: SV_InstanceID)
{
Varyings OUT;
GrassPropertiesStruct grassProperties=GrassProperties[instance_id];

int index=Triangles[vertex_id];
float4 color=Colors[index];
OUT.uv=Uvs[index];

//....
}

这样我们就完成了基本框架的搭建,这个框架就是C#脚本执行初始化-->掉用compute shader执行草的属性计算-->c#获取需要绘制的草的数量并将必要的数据填写到相应的buffer-->C#调用API执行间接绘制。这样就完成了一帧的绘制工作。通过这样一种方式我们只需往buffer中写入数据,而无须回读,所有的数据都在GPU的内存中运算参与compute shader和渲染部分shader的工作,只需执行极少量的draw call就能绘制出大面积的草

模型准备和形态控制参数

草的模型其实也不是刚需,因为我们是通过贝塞尔曲线来控制草的顶点位置,草的宽和高都是需要可调节的,所以我们就不需要每个顶点的顶点位置了,只需要草的索引、颜色、uv等数据就可以了。这里要重点说明一下为什么要颜色数据,它其实传递的并不是真正的颜色星信息,在传入后它会被解析为顶点的相对位置,这也就是不传入顶点绝对位置的原因。为了便于直观展现,可以在Maya或Blender中自行绘制出草的样子,并计算好color中储存的信息。


草模型


有了草的数据之后我们就可以构思一个能够控制草形态的结构体参数了,可以看到《对马岛之魂》中使用到的参数是非常多的,我将最主要的保留了下来,像 Grass Type Side Curve 这种我都没用到, Clump 参数我使用在了另外一组参数中,于是就得到了下列的参数结构体:


参数


struct GrassPropertiesStruct
{
float3 position;
float angle;
float height;
float width;
float tilt;
float bend;
float hash;
float windForce;
float3 surfaceNorm;
float charaDistPower;
};

通过上面的参数我们基本就可以灵活地控制草的形态了。

参数的填写

position是最重要的一个参数,我需要草地生长在unity的terrain上,借助terrain的尺寸数据和高度图我们推算出草需要生长的位置。那么我们要如何确定每一株草的位置呢?这就需要用到 SV_DispatchThreadID 数据了, 因为terrain是正方形的,所以为了形成对应的映射关系compute shader中对 numthreads threadGroups 的设置都应该保持X和Y相等,这样就能使用 SV_DispatchThreadID 对每一株草进行映射,让所有的草都均匀分布在地图上。为了让草看起来有一点杂乱的效果,可以使用SV_DispatchThreadID作为种子进行随机扰动,并使用变量控制扰动强度。

    float hash=rand(id.yxy);
float2 jitter = ((hash*2)-1 ) * _jitterStrength;
positionWS.xz+=jitter;

为了指定草的生长范围,我在terrain中选取了Aplat Alpha贴图中的特定通道作为草的生长条件,只有通道中的值达到要求是才会生成草。


选取深绿色区域作为指定区域


angle height width tilt bend 这几个参数直接指定就好,为了随机性可以hash一下。

windForce 可以直接读取一张贴图,让贴图随时间进行移动,对马岛中有一套风系统,同时也有perlin noise的参与。这里我就简单使用从SD中导出的的Cloud noise,值得注意的是分辨率不用太高,过高的分辨率会导致相隔很近的草的风力变化很大,推荐512*512以下。


Cloud Noise


surfaceNorm 是表示地形表面的法线,在需要时可以对草的朝向做一定的调整。可以找出草xy方向上的相邻点,并构建向量进行叉乘,注意一下方向就能的出地形的法线。

    float2 biTangentSampleUV=heightSampleUV+float2(2,0)/_terrainSize.xz;
float3 biTangentWS=GetPosWithHeight(biTangentSampleUV);

float2 tangentSampleUV=heightSampleUV+float2(0,2)/_terrainSize.xz;
float3 tangentWS=GetPosWithHeight(tangentSampleUV);

float3 bitangentVec=biTangentWS-positionWS;
float3 tangentVec=tangentWS-positionWS;
OUT.surfaceNorm=normalize(mul(tangentVec,bitangentVec));

charaDistPower 参数是做人物在接近草时会有压倒草地的动作所准备的,它记录的时草和人距离的平方。

参数的进一步调整

经过以上参数的填写草地生成的大部分工作已经完成了,剩下的就是进行一些效果上的调整了。在对马岛中为了模拟真实的草地,他们提出了Clump的概念,就是一定范围内的草的朝向等属性是基本相同的,它们有统一的中心点,可以向中心点偏移来与其他Clump拉开距离。


对马岛中草的Clump


所以使用Voronoi噪声能比较好的表现这种效果,噪声中需要使用三个通道来存储我们所需要的数据,第一个是该Clump的参数ID一个通道,第二个是Clump的中心位置两个通道。这里的Clump参数结构体用于控制一个Clump内草的行为。

struct ClumpArgsStruct {
float pullToCentre;
float pointInSameDirection;
float baseHeight;
float heightRandom;
float baseWidth;
float widthRandom;
float baseTilt;
float tiltRandom;
float baseBend;
float bendRandom;
};

上述参数在C#端传输给compute shader,创建一个list存储参数数组,然后通过Voronoi噪声中第一个通道的值进行选择,并干预上一步的参数填写。

            half4 frag (Varyings IN) : SV_Target
{
half2 extendUV=IN.uv*_CellNum;
half2 uv=frac(extendUV);
half2 id=floor(extendUV);
half clumpId=0;

float minDist=1000;
half2 clumpCenter=half2(0,0);

for(float y=-1;y<=1;y++)
{
for(float x=-1;x<=1;x++)
{
half2 offset=float2(x,y);
float2 currentId=id+offset;
half2 random=N22(currentId);
half2 p=offset+sin(random*_Disorder)*0.3;
half2 temp=uv-p;
half dist=length(temp);
if(dist {
minDist=dist;
clumpCenter=IN.uv-temp/_CellNum;
clumpId=fmod((int)(clumpCenter.x+clumpCenter.y)*10,_ClumpNum);






请到「今天看啥」查看全文