事情是这样的,最近做风格化场景正好需要做到草地,遂在网上寻找相关教程,找到了下面这几篇:

https://zhuanlan.zhihu.com/p/397620652https://indienova.com/indie-game-development/render_infinite_grassland_with_gpu/

URP渲染管线 - GPUInstance绘制草地 - 知乎 (zhihu.com)

这几篇文章各有所长,有些用几何着色器进行优化,有些用gpu instance进行优化

于是就萌生了这样的想法:能不能把这两个方案结合起来呢,于是有了下面的方案

单个草面片

首先是陈嘉栋大佬的方案,基于几何着色器重构顶点,在几何着色器传入一个顶点,然后以这个顶点为中心建立一个面片

                 //位置随机化
                float random_value = points[0].random_v;//sin(PI * frac(root.x) + PI * frac(root.z));
                _GrassWidth = _GrassWidth + random_value/50;
                _GrassHeight = _GrassHeight + random_value/5;

                float vertex_count = 12;

                float current_v = 0;
                float offset = 1/(vertex_count/2 - 1);
                float current_grass_height = 0;
            
                //顶点数组初始化
                g2f v[12] = {
                    creat_g2f(),creat_g2f(),creat_g2f(),creat_g2f(),
                    creat_g2f(),creat_g2f(),creat_g2f(),creat_g2f(),
                    creat_g2f(),creat_g2f(),creat_g2f(),creat_g2f()
                };
                //广告牌效果
                float3 up = float3(0,1,0);
                float3 look = -GetWorldSpaceViewDir(root.xyz);
                look.y = 0;
                look = normalize(look);
                float3 right = cross(up,look);

                
                
                
                //重构顶点位置与uv
                for(uint i = 0;i < vertex_count;i++){    
                    v[i].root = root;             

                    v[i].normal = float3(0,1,0);

                    //法线扰动
                    half3 normal_offset = (0.15 * sin(root.x * 82.32523 + root.z)) * right;

                    v[i].normal = normalize(v[i].normal + normal_offset + look*0.5);


                    if(fmod(i,2) == 0 ){
                        //左边的顶点
                        v[i].pos = float4(root.xyz - _GrassWidth * right + current_grass_height * up,1);
                        v[i].uv = float2(0,current_v);
                    }
                    else{
                        v[i].pos = float4(root.xyz + _GrassWidth * right + current_grass_height * up,1);
                        v[i].uv = float2(1,current_v);
                    
                    }

这里我还加上了一个广告牌效果,不然基于面片的效果容易穿帮,另一种解决方案是插片,详见第二篇文章

面片着色

面片建立完成后,下一步就是对面片进行着色看看效果,这里需要用一张图作为alpha遮罩,这里 我图方便依然是偷的陈嘉栋大佬的图

不过这样的图要做也简单,ps上随便找个笔刷或者图形工具啥的也能做

接着就是给草上色了,由于我做的风格化的效果,所以用ps渐变工具做了一张简单渐变图

上下渐变,这个很好理解

着色代码

half4 main_col = tex2D(_GrassCol, i.uv);
half4 main_shape = tex2D(_AlphaTex,i.uv);

然后在返回值中将alpha值设定为main_shape的r通道即可,同时,想要让alpha通道生效,还需要在pass中做如下设置

        Tags { "RenderType" = "TransparentCutout" "IgnoreProjector" = "True" "Queue" = "AlphaTest" "RenderPipeline" = "UniversalPipeline" }
        LOD 100

        AlphaToMask On
        Cull Off

至此,一棵草的基本渲染就完成了,此时将材质拖放到任意一个网格体上就能看到网格消失,每一个点上都生成了一棵草,因此下面我们就可以利用c#脚本自定义网格体,让材质在这个网格体上的每一个顶点都生成一棵草

网格构建

一般而言在c#中构建网格体需要创建一个带有meshrenderer 和meshfilter组件的游戏对象,然后创建对应的网格数据,并将网格数据赋值给这个网格对象,但在这里不需要,因为这里要配合gpu instance使用,而gpu instance的原理是传入一个mesh和多个实例的差异化信息,在依次drawcall中绘制多个差异化实例,因此这里只需要创建网格即可

  private Mesh grass_layer;
    void Start()
    {   
        grass_layer = new Mesh();
        grass_layer.vertices = vertlist.ToArray();
        grass_layer.uv = uvlist.ToArray();
        grass_layer.SetIndices(indicies.ToArray(), MeshTopology.Points, 0);
}

这里顶点数据我只赋了一个(1,1,1),这样在后面gpu instance就相当于是一棵草的多次实例化

gpu instance

这里gpu instance的api我用的是Graphic的API,前后分别用了Graphics.DrawMeshInstanced 和Graphics.DrawMeshInstancedIndirect 

具体使用网上有很多案例,这里只说一点

在使用 Graphics.DrawMeshInstanced 时,必须要在顶点着色器完成顶点,准确地说是那个position语义的变量,从模型空间转换到裁剪空间的过程,而后面在几何着色器中需要基于这个裁剪空间坐标重构顶点,因此需要将其再转换回模型空间,否则就会出现gpu instance实例化数量大打折扣的情况,大概是500:1

这个问题我查了很多资料,但是依然找不到答案,有知道的大佬也可在评论区说一说

(不过说实话,感觉这个api用处也不大,毕竟只能最多实例化1023个,当时是觉得这个用起来方便点,不过后来还是换了,另一个麻烦是麻烦点但至少没那么多限制)

草片着色

完成了利用gpu instance的草片铺设,接下来就可以对草片的效果进行细化了,首先是着色,那着色就必然离不开法线,可是草片上的点的法线应该怎么设置,Colin大神的代码中给出了一个简单的近似方案:直接将草片法线设置为垂直向上(0,1,0),不过如果所有的草法线相同会带来几乎相同的着色效果,显得整个画面很死板,因此这里给法线加上一点扰动

后面就是简单的bling-phong着色模型了,需要注意的是,一般来说受到光照的都是草尖部分,底部因为遮挡等原因光照效果并不会很明显,所以这里我做了一个从草根到草尖的渐变,回想在几何着色器中构筑顶点时我们为每一个点赋了uv,因此正好可以用uv中的v作为依据进行插值实现渐变效果

另一个需要注意的是,这里我做的是风格化的渲染,因此需要饱和度相对较高的颜色,如果直接套用lambert模型做diffuse的话显得灰灰的,达不到我想要的效果,因此这里将(-1,1)的diffuse值重映射到(0,1)将整体的亮度提高了一个档次

            

            half4 frag (g2f i) : SV_Target
            {   
                //UNITY_SETUP_INSTANCE_ID(i)
                //光照支持(bling-phong模型)
                float3 view_ws = GetWorldSpaceViewDir(i.root);

                float3 light_dir = GetMainLight().direction;

                float3 half_vector = normalize(view_ws + light_dir);
                float specular = max(dot(i.normal,half_vector),0);
                specular = pow(specular,3);
                specular *= i.uv.y;

                float diffuse = dot(i.normal,light_dir) * 0.5 + 0.5;
                //阴影接收

                // sample the texture
                half4 main_col = tex2D(_GrassCol, i.uv);
                half4 main_shape = tex2D(_AlphaTex,i.uv);
                
                return half4(main_col.xyz * diffuse + specular,main_shape.r);
            }

风吹效果

实现草片的风吹效果并不难,本质就是让草片顶点绕中心旋转,主要是旋转的节奏

忘了是在哪里看到的,说风场的波形大致可以用

sin(x)+sin(2x)近似(大概?),然后再基于此进行调整

但只用一个波形控制的话,整体的风吹拂效果会很整齐死板,加入不同的初始相位的话又会显得凌乱,为了实现相对整体,块状的吹拂效果,可以加入一张噪声图作为风力的扰动

加入噪声图就涉及到在几何着色器中采样纹理,不能再像在片元着色器一样使用tex2d,必须使用

tex2dlod,以草片中心的uv坐标作为采样uv(这个用c#传入就行,相关操作可参考:[Unity]大批量物体渲染学习笔记(二) - 简书 (jianshu.com)

考虑到在风场吹拂下,草叶受影响的程度一般是从高到低递减,因此同样用了uv中的v来控制草叶受风场影响的程度

互动性

互动性本质即是向shader传入物体的位置信息,再根据位置信息做出反应

这里位置信息的传入采用了正交摄像机俯拍草坪,摄像机画面写入一张rendertexture上,这只需要设置摄像机的output即可,后面在shader上采样这张RT就能得到物体的位置信息,当然,因为是正交俯拍摄像机,所以忽略了物体的y轴信息,不过这一般来说也不太重要就是了

因为我们只希望在RT中记录物体的信息,因此需要把物体和相机单独设置一个层(就是inspector 那边的layer),否则会出现很多干扰信息传入了也没法处理

值得一提的是,coling大神在此基础上采用了trail renderer组件,不仅省去了写shader的麻烦,而且还可利用trial renderer的渐变消失特性实现草的渐变恢复效果

物体位置信息传入后后面就简单了,设定一个弯曲方向让草片弯曲就行了,其实我一直想实现一个草叶向物体四周倒伏的效果同时保留trial renderer带来的渐变效果,但想了很久没想到太好的方案,遂搁置

                    float4 is_step = tex2Dlod(_TrailTex,float4(points[0].uv,0.0,0.0));

                    //计算压倒后的位置
                    float3 bendDir = normalize(GetWorldSpaceViewDir(root.xyz));
                    bendDir.xz *= 0.5;
                    bendDir.y = min(-0.5,bendDir.y);

                    v[i].pos.xyz = lerp(v[i].pos.xyz + bendDir * v[i].pos.y/-bendDir.y,v[i].pos.xyz,1-(is_step * 0.95 + 0.05));

视锥剔除

至此,草地的渲染基本完成,但因为我们使用了gpu instance渲染物体,因此针对这一部分物体unity将不会为我们做视锥剔除,这个过程需要我们自己完成

视锥剔除的一个简单的方案是对每一个物体计算一个包围盒,然后计算包围盒的八个顶点是否都在视锥体内,如果不在就将其剔除,这里这个过程我用computer shader实现,另外,剔除即代表不渲染,即不在gpu instance中加入对应的实例数据

具体到实现上就是,先把剔除前的数据传入computer shader 对每一个实例计算其包围盒是否在视锥体内,如果在就将其添加进返回结果中,最后将结果返回到c#脚本中,然后c#脚本再以返回的结果进行实例化即可

这里代码我基本上是参考的Unity中使用ComputeShader做视锥剔除(View Frustum Culling) - 知乎 (zhihu.com)

这篇文章讲的非常详细,涵盖了每一步的原理和实现,我这里没说清楚的几乎都能在这里找到答案

碎碎念

这次我做风格化草地的学习步骤基本就是这些,其实一开始只是想找一个好上手的教程先做着,结果做到后面对性能开始有了追求,于是就开始缝(极其痛苦的过程),说实话我还没测试 过这样做到底会在性能上提高多少,但无奈实在是不想让自己做了一部分的工程全部推倒重来,所以就硬着头皮缝下去了,所幸最后成功

后续有机会我应该会测试一下性能上的提升,如果写的有哪里有问题还希望诸位大佬不吝指正

以上

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐