利用GPU实现无尽草地的实时渲染

利用GPU实现无尽草地的实时渲染

陈嘉栋陈嘉栋

0x00 前言

在游戏中展现一个写实的田园场景时,草地的渲染是必不可少的,而一提到高效率的渲染草地,很多人都会想起GPU Gems第七章
《Chapter 7. Rendering Countless Blades of Waving Grass》[1]中所提到的方案。

现在国内很多号称“次世代”的手游甚至是一些端游仍或多或少的采用了这种方案。但是本文不会为这个方案着墨过多,相反,接下来的大部分内容是关于如何利用Geometry Shader在GPU生成新的独立草体的。

0x01 一个简单的星型

传统的方式,即将模型数据从CPU传递给GPU,GPU再根据这些数据进行渲染的方式在渲染大规模的草体时,往往会忽略单个草体的模型细节。因为单个草体的建模如果过于细致,则渲染大片的草地就需要传递很多多边形,从而造成性能的下降。

因此,一个渲染大片草地的方案往往需要满足以下条件:

  • 单个草的多边形不能过多,最好一棵草只用一个quad来表示
  • 从不同的角度观察,草都必须显得密集
  • 草的排布不能过于规则,否则会不自然


综上,渲染草体时的经典结构——星形就出现了。




这样,简单的星形结构既满足了单棵草的面数很低同时也兼顾了从不同角度观察也能够显得密集。 而让草随风而动也很简单,只需要根据顶点的uv信息找出上面的几个顶点,按照自己规则让顶点移动就可以了。

if (o.uv.y > 0.5)
{
    float4 translationPos =
        float4(sin(_Time.x * _TimeFactor * Pi ), 0, sin(_Time.y * _TimeFactor * Pi ), 0);
    v.vertex += translationPos * _StrengthFactor;
}

现在很多游戏在渲染草地时仍然使用了这种结构。

(图片来自:九州天空城3D)

(图片来自:剑网3)

但是,各位也都看到了,这种方式虽然简单,但是却并不自然,从上方俯视的时候各个面片也能看到清清楚楚,因此这种方式并不是我想要的。

0x02 更真实的草叶

我想要的效果是能够大规模实时渲染,并且每一颗草的叶片都能够随风摇曳的更真实自然的效果。在这方面,业内早有一些探索,例如Siggraph2006上的《Rendering Grass Terrains inReal-Time with Dynamic Lighting》[2],以及Edward Lee的论文《REALISTIC REAL-TIME GRASS RENDERING》[3]。

本文主要按照Edward Lee的论文中描述的方式在Unity中实现GPU生成无尽草地随风摇曳的效果。

这里,我主要用到了Direct3D 10之后新引入的Geometry Shader[4]来实现在GPU上创建单独草体叶片的逻辑。每个叶片根据LOD有3种组成方式,分别需要1个quad、3个quad以及5个quad。


(图片来自:Edward Lee)

而每颗草的位置则由CPU来随机决定,由于GS的输入是一个图元(point、line或triangle)而非顶点,所以我们在CPU中需要根据随机的位置创建point类型的图元作为这棵草的根位置。

ok,接下来就在GPU上通过一个根位置来制作草的叶子。

[maxvertexcount(30)]
    void geom(point v2g points[1], inout TriangleStream<g2f> triStream)
    {

        float4 root = points[0].pos;

虽然位置是随机的,但是我们显然也希望叶子本身的高度和宽度也存在一些随机。

 float random = sin(UNITY_HALF_PI * frac(root.x) + UNITY_HALF_PI * frac(root.z));
        _Width = _Width + (random / 50);
        _Height = _Height +(random / 5);

设置好叶子的属性之后,我们就可以根据这些属性来创建新的顶点模拟叶子的样子了。


画一个简图各位可以看到,组成一颗草的叶子需要12个不同的顶点,但是由于这里没有用index,所以最后总共要输出30个顶点来组成5个quad。

而根据这幅简图,我们还可以很方便的根据根的位置计算各个顶点的位置

同时,还能发现偶数顶点对应的uv坐标是(0,v),而奇数顶点对应的uv坐标都是(1,v)——这里的v是uv坐标中的v——因此,我们又能很轻松的计算出各个顶点对应的uv坐标了。

最后,如果我们要计算实时光,则还需要获取顶点的法线信息,这里简单起见统一为(0, 0, 1)。

 for (uint i = 0; i < vertexCount; i++)
        {
            v[i].norm = float3(0, 0, 1);

            if (fmod(i , 2) == 0)
            { 
                v[i].pos = float4(root.x - _Width , root.y + currentVertexHeight, root.z, 1);
                v[i].uv = float2(0, currentV);
            }
            else
            { 
                v[i].pos = float4(root.x + _Width , root.y + currentVertexHeight, root.z, 1);
                v[i].uv = float2(1, currentV);

                currentV += offsetV;
                currentVertexHeight = currentV * _Height;
            }

            v[i].pos = UnityObjectToClipPos(v[i].pos);

        }

这样,一个叶片的网格就在GPU上创建完成了。


接下来,我们需要处理一下草叶的纹理来渲染出符合我们预期的叶片。这里我用到了GPU Gem那篇文章中的草丛纹理的处理方法:



即叶片的颜色可以只用一个张单独表示叶片颜色的纹理来处理,比如我用的这张纹理:


而草体的具体轮廓则靠另一张纹理提供。但是这里没有使用alpha blend,而是使用了alpha to coverage,因为在处理重重叠叠的草叶时blend会有一些显示顺序上的问题,至于如何使用alpha to coverage各位可以参考SL-Blend

 SubShader
            Tags{ "Queue" = "AlphaTest" "RenderType" = "TransparentCutout" "IgnoreProjector" = "True" }

            Pass
                AlphaToMask On


所以,现在我们只需要在fs内简单的取样输出就可以了。

half4 frag(g2f IN) : COLOR
    {
        fixed4 color = tex2D(_MainTex, IN.uv);
        fixed4 alpha = tex2D(_AlphaTex, (IN.uv));

        return float4(color.rgb, alpha.g);
    }


0x03 生成覆盖地面的无尽草地

有了叶子之后,我们就可以考虑如何生成地形以及地面上覆盖的草了。为了地面的起伏轮廓自然真实,我们可以根据一张高度图来动态创建地面的网格。

由于Unity的网格顶点上限是65000,因此我决定让地面网格的尺寸为250 * 250:

for (int i = 0; i < 250; i++)
    {
        for (int j = 0; j < 250; j++)
        {
            verts.Add(new Vector3(i, heightMap.GetPixel(i, j).grayscale * 5 , j));
            if (i == 0 || j == 0) continue;
            tris.Add(250 * i + j); 
            tris.Add(250 * i + j - 1);
            tris.Add(250 * (i - 1) + j - 1);
            tris.Add(250 * (i - 1) + j - 1);
            tris.Add(250 * (i - 1) + j);
            tris.Add(250 * i + j);
        }
    }        
    ...
    Mesh m = new Mesh();
    m.vertices = verts.ToArray(); 
    m.uv = uvs;
    m.triangles = tris.ToArray();

这样,一个自然而真实地面网格就创建好了。


之后就来生铺草吧。所谓的铺草无非就是我们需要生成一些顶点,作为草叶的根位置传入之前完成的GS。需要说明的是,由于草的密度要足够大,因此不止需要一个草地的mesh,例如我们要种200,000棵草的话就需要3个草地mesh。

另外还要说明的一点,也是要吐槽Unity的地方就在于Unity的mesh实现默认是triangle,而非point(参考Invoking Geometry Shader for every vertex of a mesh)。因此创建记录草根位置的mesh的方法和之前创建地面稍有不同。

 m.vertices = verts.ToArray();
        m.SetIndices(indices,MeshTopology.Points, 0);

        grassLayer = new GameObject("grassLayer"); 
        mf = grassLayer.AddComponent<MeshFilter>();
        grassLayer.AddComponent<MeshRenderer>();

创建好之后,可以看到草根的位置随机的分布在地面上,数量有上百万个。


把我们的shader应用于记录草根位置的mesh上。

wow,我们的草地出现了。



0x03 风的模拟

呆立的草虽然看上去比之前的纸片草好看了很多,但是静止而整齐的叶子毕竟还是很不自然。因此,我们要让草动起来也就是模拟风的效果。

思路仍然是利用三角函数来让草叶摇摆起来,同时根据草的根位置为三角函数提供初始相位然后再增加一些随机性在里面让效果更自然。

 ...伪代码
        wind.x += sin(_Time.x + root.x);
        wind *= random;
        ...

但是针对目前每一颗草都有独立的叶片网格,为了更加逼真的模拟风的效果,显然不同的叶片的不同部位受到风的影响是不同的。

距离叶子的顶端越近,则受到风的影响就越大。



因此在GS生成新顶点的逻辑中,增加风对顶点位置的影响,越高的顶点被影响的程度越大,这样一个更真实的无尽草地效果就实现了。

https://www.zhihu.com/video/895413910969155584

这个demo的简化版代码各位可以在这里获取:

chenjd/Realistic-Real-Time-Grass-Rendering-With-Unity

当然,这不是手机上使用的技术,并且作为一个演示demo我并没有做过多的优化(不过在我的本子上跑起来还是很流畅)。

而且和我文章中的演示相比,要简化一些。

ref:

【1】《Chapter 7. Rendering Countless Blades of Waving Grass
【2】《Rendering Grass Terrains inReal-Time with Dynamic Lighting
【3】《REALISTIC REAL-TIME GRASS RENDERING
【4】《Programming Guide for Direct3D 11

-EOF-

最后打个广告,欢迎支持我的书《Unity 3D脚本编程》



欢迎大家关注我的公众号慕容的游戏编程:chenjd01

「真诚赞赏,手留余香」
3 人赞赏
狂徒
张绍源
饭米
文章被以下专栏收录
27 条评论
推荐阅读