图形 3.3 曲面细分与几何着色器_大规模草渲染

细分后的顶点曲面细分与几何着色器 大规模草渲染

B站视频:图形 3.3 曲面细分与几何着色器 大规模草渲染

文章目录

着色器执行顺序

曲面细分着色器

概述

曲面细分阶段(tessellation stages)是利用镶嵌化处理技术对网格中三角形进行细分(subdivide),以此来增加物体表面上的三角形数量。再将这些新的三角形偏移到适当的位置,使网格表现出更加细腻的细节。

曲面细分阶段是可选阶段,使用它能带来如下的好处:

  • 实现细节层次(level-of-detail, LOD)机制;
  • 在内存中仅维护低模(low-poly)网格,再根据需求为它动态地增添额外的三角形,以此节省内存资源;
  • 处理动画和物理模拟时采用简单的低模网格,而仅在渲染过程中使用经镶嵌化处理地高模(high-poly)网格。

曲线和曲面

计算机图形学:曲线与曲面 - 知乎

相关概念

  • 控制点(control point) :控制点是定义曲面形状的关键元素。它们是二维或三维空间中的点,用于确定曲面的控制网格。一般来说,曲面通过关于控制点的方程计算生成。
  • 面片(patch) :对于一个参数曲面 p ( u , v ) p(u,v) p(u,v) ,如果定义域 ( u , v ) (u,v) (u,v) 为矩形,则称该曲面为patch。
  • 镶嵌(tessellation):在实时渲染中,我们需要计算并创建(多个)三角形对真实曲面进行拟合,这个过程称为镶嵌。在运行时,表面可以被镶嵌为多个小三角形。

输入与输出

在一般情况下,顶点着色器向下一个阶段输出三角形顶点数据。然而,在曲面细分着色器开启后,可以理解为 输入装配阶段(IA) 向顶点着色器提交的是具有若干 控制点(control point)面片(patch) ,而顶点着色器传递给曲面细分着色器的为这些控制点的数据。在曲面细分着色器中,一个patch通常会被 镶嵌化(tessellation) 为更多的子patch。这个细分过程中使用子patch的顶点带入控制点的曲面方程,以生成曲面上的顶点,进而拟合真实曲面。

也就是说,当开启曲面细分后,顶点着色器就变成了"处理控制点的着色器"。正因如此,我们还能在曲面细分开展之前,对控制点进行一些调整。一般来讲,动画与物理模拟的计算工作都会在对几何体进行镶嵌化处理之前的顶点着色器中以较低的频次进行。

总体流程

  • Hull Shader
    • 决定细分的数量(设定细分因子 以及内部细分因子
    • 对输入的Patch参数进行改变(如果需要)
  • Tessellation Stage
    • 进行细分操作
  • Domain Shader
    • 对细分后的点进行处理,从重心空间(Barycentric coordinate system)转换到屏幕空间

Hull Shader

外壳着色器(Hull Shader,细分控制着色器)接收顶点着色器传递的控制点数据,向镶嵌器(tessllator)输出常量细分因子,向域着色器传递经过变换和增删后的控制点数据。

外壳着色器实际上由两种着色器组成:常量外壳着色器(constant hull shader)控制点外壳着色器(control point hull shader) 。注意,这两个phase在硬件上并行(parallel) 执行。

常量外壳着色器

常量外壳着色器(constant hull shader) 逐patch 调用,即每处理一个patch就被调用一次。

输入:顶点着色器传递的该patch的控制点数据;

输出 :向镶嵌器(tessellator)输出曲面细分因子(tessellation factor) 。曲面细分因子指示在曲面细分阶段中将patch镶嵌处理后的份数

下面是一个具有3个控制点的三角形面片示例,我们将它从各个方面均匀地镶嵌细分为3份。

c 复制代码
struct PatchTess{
    float edgeTess[3] : SV_TessFactor;
    float insideTess : SV_InsideTessFactor

    // 可以在下面为每个控制点附加所需的额外信息 例如额外的控制点等
};

PatchTess ConstantHS(InputPatch<VertexOut, 3> patch,// 处理3个控制点的面片
    uint patchID : SV_PrimitiveID){
    PatchTess pt;

    // 将该面片从各方面均匀地镶嵌处理为3等份
    pt.edgeTess[0] = 3;
    pt.edgeTess[1] = 3;
    pt.edgeTess[2] = 3;

    pt.insideTess = 3;// 三角形内部细分的份数

    return pt;
}

常量外壳着色器以面片的所有控制点作为输入,在此用InputPatch<VertexOut, 3>对此进行定义。常量外壳着色器接受的是顶点着色器传递的控制点 ,因此它们的类型由顶点着色器输出类型VertexOut来确定。在此例中,我们的三角形面片拥有3个控制点,所以就将InputPatch模板的第二个参数指定为3。系统还通过SV_PrimitiveID语义提供了面片的ID,此ID值唯一地标识了绘制调用过程中的各个面片。

常量外壳着色器必须输出曲面细分因子 ,该因子取决于面片的拓扑结构 。常量外壳着色器阶段对所有输入和输出控制点具有只读访问权限。另外,还可以给输出结构体添加额外的信息,例如可以指定额外的控制点等。

三角形面片(triangle patch) 执行镶嵌化处理的过程分为两部分:

  • 3个边缘曲面细分因子控制对应边上镶嵌后的份数
  • 一个内部曲面细分因子指示着三角形面片内部的镶嵌份数

四边形面片(quad patch) 进行镶嵌化处理的过程同样分为两部分:

  • 4个边缘曲面细分因子控制着对应边缘镶嵌后的份数
  • 两个内部曲面细分因子指示如何来对该四边形面片内部进行镶嵌化处理(一个指示横向维度,一个指示纵向维度)

一般硬件支持的最大曲面细分因子为64。若指定的曲面细分因子为0,则该patch将被剔除,不会送到后面的阶段。这使得我们可以在常量外壳着色器执行 视锥体剔除背面剔除 等优化手段。

另外,常量着色器中定义的曲面细分因子可以是动态 的,以下是一些确定镶嵌次数的常用衡量标准:根据与摄像机之间的距离 ;根据占用屏幕的范围 ;根据三角形的朝向 ;根据粗糙程度

控制点外壳着色器

控制点外壳着色器(control point hull shader) 逐控制点 调用,即顶点着色器每输出一个控制点,此着色器就会被调用一次 。它用来对控制点进行进一步转换,并且可以增加或减少控制点的数量。例如在 PN三角形 中,可以利用控制点外壳着色器将一个3个控制点的三角形网格转换为具有10个控制点的贝塞尔曲面片。

下面是一个控制点外壳着色器仅仅充当一个简单的 传递着色器(pass-through shader) 的例子,它不会对控制点进行任何的修改。

c 复制代码
struct HullOut{
    float3 positionOS : TEXCOORD0;
}

[domain("tri")]
[partitioning("interger")]
[outputtopology("triangle_cw")]
[outputcontrolpoints(4)]
[patchconstantfunc("ConstantHS")]
[maxtessfactor(64.0)]
HullOut HS(InputPatch<VertexOut, 3> input, // 处理3个控制点的面片
    uint controlPointId : SV_OutputControlPointID, uint patchId : SV_PrimitiveID){
    HullOut output;

    output.positionOS = input[i].positionOS;

    return output;
}

通过InputPatch参数可以将patch所有控制点都传至外壳着色器之中。系统值SV_OutputControlPointID索引的正是在被外壳着色器所处理的输出控制点。输入控制点和输出控制点数量未必相同。例如,可以输入4个控制点,输出16个控制点(话虽这么说,我并没找到如何在HS中真正意义上的衍生控制点,一般都是塞进uniform数据和顶点数据了)。

一个控制点外壳着色器还需要定义如下属性:

参数 说明
domain patch的类型。可选用的参数有tri (三角形面片)、quad (四边形面片)、isoline(等值线)
partitioning 曲面细分的模式:integerfractional_evenfractional_odd
outputpology 通过细分所创的三角形的绕序:triangle_cw (顺时针方向绕序)、triangle_ccw(逆时针方向的绕序)、line(针对线段曲面细分)
outputcontrolpoints 外壳着色器执行的次数,每次执行都输出1个控制点。系统值SV_OutputControlPointID给出的索引标明当前正在工作的外壳着色器所输出的控制点
patchconstantfunc 指定常量外壳着色器函数名称的字符串
maxtessfactor 告知驱动程序 ,用户在着色器所用的曲面细分因子的最大值。一般硬件最大值为64

细分因子

细分因子(Tessellation factor)。决定将一条边分成几部分:

  • integer

    细分级别被截断到范围[1, max],并四舍五入到最接近的整数 n,接着相应的边被划分为n段。例如:3.75 的级别将被四舍五入到 4,边缘将被分割成 4 个相同的部分。

  • fractional_even

    细分级被截断到范围[2, max],向上取最近的偶数,周长会被划分为n-2的等长部分,以及两个位于两端的部分(可能比中间部分更短)。具体长度与小数部分有关,为了获取更平滑的细分。例如:3.75级将四舍五入到4边缘将被划分为 4 段。

  • fractional_odd

    细分级别被截断到范围[1, max - 1],向上取最近的奇数。例如:3.75 的级别将四舍五入到 5,边缘将被划分为 5 段。

内部细分因子

内部细分因子(Inner Tessellation Factor),决定了内部的三角形(又或是矩形)该如何画出来。

Inner Tess = 3为例:

从左下角的端点开始,分别找其最近的两个切分点 ,做该点的垂线 ,两条垂线的交点 就是内部新三角形的一个端点。新三角形的其余顶点以此类推。

又以Inner Tess = 6为例:

Tessellator Stage

镶嵌器阶段(Tessellator Stage),在实时渲染中,我们需要计算并创建(多个)三角形对真实曲面进行拟合,这个过程称为镶嵌。在运行时,表面可以被镶嵌为多个小三角形。

镶嵌器是一个固定功能阶段, 这意味我们无法对这一阶段进行任何控制,它全权交由给硬件处理。镶嵌器阶段接收常量外壳着色器输出的曲面细分因子,对面片进行镶嵌化处理。然后,它将镶嵌后生成的顶点传递给域着色器。

对于三角形面片,常量着色器的边缘细分因子分别指示右/下/左边的段数,内部细分因子指示三角形各边中线的段数。

对于四边形面片,常量着色器的边缘细分因子分别指示左/上/右/下边的段数,内部细分因子分别指示横向和纵向中线的段数。

Domain Shader

域着色器(Domain Shader)接收镶嵌器阶段输出的所有顶点与三角形和控制点外壳着色器输出的经过变换后的控制点。在镶嵌器阶段中创建的顶点 ,都会逐一调用域着色器进行后续处理。随着曲面细分功能的开启,顶点着色器便化为"处理每个控制点的顶点着色器 ",而域着色器的本质实为"针对已经过镶嵌化的面片进行处理的顶点着色器"。特别的是,我们可以在此将经镶嵌化处理的面片顶点变换到齐次裁剪空间(如果打开几何着色器,则在几何着色器进行)。

对于三角形面片来讲,域着色器以曲面细分因子控制点外壳着色器所输出的所有面片控制点 以及 镶嵌化处理后的顶点位置重心插值坐标(u, v, w) 作为输入。 是否利用这些参数坐标以及控制点来求取真正的3D顶点位置,完全取决于用户自己。 下面代码中,根据控制点外壳着色器输出patch的控制点和镶嵌器提供的重心插值坐标计算当前镶嵌器阶段创建顶点的真正3D坐标,并变换到齐次裁剪空间中,最后将数据传输给顶点着色器。

c 复制代码
struct DomainOut {
    float4  positionCS : SV_POSITION;
};

[domain("tri")]      
DomainOut DS (PatchTess patchTess, float3 bary : SV_DomainLocation, 
const OutputPatch<HullOut, 3> patch) {// 处理3个控制点的三角面片
    DomainOut output;

    float3 positionOS = patch[0].positionOS * bary.x + patch[1].positionOS * bary.y + patch[2].positionOS * bary.z; 
    output.positionCS = TransformObjectToHClip(positionOS);

    return output; 
}

对于四边形面片,不同地是用镶嵌化处理后的顶点位置参数坐标(u, v)作为输入。它的用法类似于纹理线性过滤的双线性插值。

c 复制代码
struct DomainOut {
    float4  positionCS : SV_POSITION;
};

[domain("quad")]      
DomainOut DS (PatchTess patchTess, float3 bary : SV_DomainLocation, 
const OutputPatch<HullOut, 4> patch) {// 处理4个控制点的四边形面片
    DomainOut output;

    float3 v1 = lerp(patch[0].positionOS, patch[1].positionOS, uv.x);
    float3 v2 = lerp(patch[2].positionOS, patch[3].positionOS, uv.x);
    float positionOS = lerp(v1, v2, uv.y);
    output.positionCS = TransformObjectToHClip(positionOS);

    return output; 
}

总结

曲面细分着色器分为三个子阶段:hull shadertessellator stagedomain shader

  • 顶点着色器将控制点输出给hull shader,它逐控制点将变换后的控制点传递给domain shader,逐patch将细分因子传递给tessellator;
  • domain shader接收hull shader传递的控制点和细分因子,以及tessellator生成的顶点。tessellator每生成一个顶点,domain shader调用一次;
  • domain shader对顶点进行最终的变换,并输出给下一个阶段。

一个简单的曲面细分Shader

Shader

c 复制代码
Shader "TA100/3_3_TessShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Tessellation ("Tessellation", Range(1, 64)) = 1
    }
    SubShader
    {
        Tags
        {
            "RenderType"="Opaque"
        }
        Pass
        {
            CGPROGRAM
            #pragma hull hs
            #pragma domain ds
            #pragma vertex tessVert
            #pragma fragment frag

            #include "UnityCG.cginc"
            #include "Tessellation.cginc"

            #pragma target 5.0

            sampler2D _MainTex;
            float4 _MainTex_ST;

            struct domainOut
            {
                float4 vertex : SV_POSITION;
                float3 normal : NORMAL;
                float4 tangent : TANGENT;
                float4 uv : TEXCOORD0;
            };

            struct hullOut
            {
                float4 vertex : INTERNALTESSPOS;
                float3 normal : NORMAL;
                float4 tangent : TANGENT;
                float4 uv : TEXCOORD0;
            };

            hullOut tessVert(appdata_tan v)
            {
                hullOut o;
                o.vertex = v.vertex;
                o.normal = v.normal;
                o.tangent = v.tangent;
                o.uv = v.texcoord;
            #ifndef UNITY_CAN_COMPILE_TESSELLATION
                o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
            #endif
                return o;
            }

        #ifdef UNITY_CAN_COMPILE_TESSELLATION

            //不同的图元,该结构体会有所不同
            struct patchTess
            {
                float edges[3] : SV_TESSFACTOR;
                float inside : SV_INSIDETESSFACTOR;
                // 可以在下面为每个控制点附加所需的额外信息 例如额外的控制点等
            };

            float _Tessellation;

            patchTess constantHS(InputPatch<hullOut, 3> patch, uint patchID : SV_PrimitiveID)
            {
                patchTess o;
                // 将该面片从各方面均匀地镶嵌处理为_Tessellation等份
                o.edges[0] = _Tessellation;
                o.edges[1] = _Tessellation;
                o.edges[2] = _Tessellation;
                // 三角形内部细分的份数
                o.inside = _Tessellation;
                return o;
            }

            //patch的类型。可选用的参数有tri(三角形面片)、quad(四边形面片)、isoline(等值线)
            [UNITY_domain("tri")]
            //曲面细分的模式:integer、fractional_even、fractional_odd
            [UNITY_partitioning("integer")]
            //输出拓扑类型:point、line、triangle_cw、triangle_ccw
            [UNITY_outputtopology("triangle_cw")]
            //指定常量外壳着色器函数名称的字符串
            [UNITY_patchconstantfunc("constantHS")]
            //外壳着色器执行的次数,每次执行都输出1个控制点。系统值`SV_OutputControlPointID`给出的索引标明当前正在工作的外壳着色器所输出的控制点
            [UNITY_outputcontrolpoints(3)]
            hullOut hs(InputPatch<hullOut, 3> input, uint controlPointId : SV_OutputControlPointID, uint patchId : SV_PrimitiveID)
            {
                return input[controlPointId];
            }

            //domain阶段的顶点着色器
            domainOut domainVert(appdata_tan v)
            {
                domainOut o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.normal = v.normal;
                o.tangent = v.tangent;
                o.uv = v.texcoord;
                o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
                return o;
            }

            //bary:三角形内插参数
            [UNITY_domain("tri")]
            domainOut ds(patchTess patchTess, float3 bary : SV_DomainLocation, const OutputPatch<hullOut, 3> patch)
            {
                appdata_tan v;
                v.vertex = patch[0].vertex * bary.x + patch[1].vertex * bary.y + patch[2].vertex * bary.z;
                v.tangent = patch[0].tangent * bary.x + patch[1].tangent * bary.y + patch[2].tangent * bary.z;
                v.normal = patch[0].normal * bary.x + patch[1].normal * bary.y + patch[2].normal * bary.z;
                v.texcoord = patch[0].uv * bary.x + patch[1].uv * bary.y + patch[2].uv * bary.z;
                domainOut o = domainVert(v);
                return o;
            }

        #endif

            fixed4 frag(domainOut i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv.xy);
                return col;
            }
            ENDCG
        }
    }
}

效果

Tessellation = 1 时:

Tessellation = 5 时:

应用

  • 海浪、雪地等;

    左图示例就是将一条黑色直线按照蓝色曲线不断细分,并逐渐靠近曲线形状的过程。

  • 与置换贴图的结合

几何着色器

概述

在顶点着色器和片元着色器之间有一个可选的几何着色器(geometry shader) 阶段。 几何着色器可以将输入图元转换为其他图元,这是曲面细分等阶段无法做到的。几何着色器在输入的几何图元 (如点、线段、三角形)级别上进行操作,并根据需要生成零个、一个或多个输出的新的几何图元。这使得在几何着色器阶段可以实现诸如几何图元的细分、几何的放大缩小、草地生成、粒子系统等各种复杂的效果。

几何着色器的输入是完整的图元,输出是新的图元。

几何着色器保证按照输入的相同顺序输出图元的结果,这很影响性能,因为结果必须保存和排序。同时,它又是完全可编程的。所以通常很少使用,因为它不能很好体现GPU优势。在移动设备上,甚至不会被考虑使用。

渲染管线

将渲染管线按功能性阶段划分,几何着色器处于几何处理阶段的可选顶点处理阶段。

将渲染管线按GPU逻辑管线划分,几何着色器在顶点着色器(和曲面细分着色器)阶段之后,光栅化阶段之前。

顶点着色器以顶点数据作为输入数据,而几何着色器的输入是上一个阶段生成的顶点组成的图元。与顶点着色器不同,几何着色器可以创建或销毁几何图元 。然后,几何着色器将新的几何图元传递给光栅化阶段。经过光栅化处理后,最终会生成像素片段,供片元着色器阶段进行处理。

几何着色器所输出的图元由顶点列表定义而成,必须将顶点的位置变换到齐次裁剪空间。换言之,经过几何着色器阶段的处理后,我们就得到了位于齐次裁剪空间中由一系列顶点所定义的多个图元。

几何着色器的输出图元类型不一定与输入图元的类型相同。例如,我们可以将顶点着色器传入的一个点扩展为一个四边形。

图元拓扑类型

在应用阶段,CPU需要向GPU提交一系列数据和命令供其渲染。

应用阶段最重要的任务是输入装配(input assembler) 。输入装配阶段会从显存中读取几何数据(顶点和索引),再将它装配为几何图元(geometry primitive)

可是,单凭顶点和索引数据,GPU无法知道顶点究竟如何组成几何图元。例如,我们应将顶点2个一组解释成线段,还是3个一组解释为三角形呢?对此,我们需要通过指定图元拓扑(primitive topology) 来告诉GPU如何利用顶点数据来表示几何图元。

在DirectX中,基础图元拓扑类型有以下五种:

点列表(point list)线条带(line strip)线列表(line list)三角形带(triangle strip)三角形列表(triangle list)

DirectX 10 以后的版本中,加入了一种邻接图元(primitive adjacency) 类型。任何基础图元类型都可以扩展成邻接图元版本。

各种图元拓扑类型如下图,可以先只关注实线顶点,并且注意所有三角类型的顶点都是相同的。

上图中各符号意义如下表所示。

根据图元的类型,可以将其分为点、线和三角形。而根据连接方式,我们可以将其分为列表(list)和带(strip)。

列表类型

列表类型指的是孤立图元类型的列表。对于三角形列表而言,我们可以将输入顶点序列每三个一组组成一个三角形。如下表:

输入顶点序列 输出图元(三角形)
0 1 2 3 4 5 [0, 1, 2] [3, 4, 5]

线列表的处理方式类似。

带类型

带类型 指的是一系列相连的图元类型。对于三角形带而言,当我们输入一个三角形后,在绘制完第一个三角形后,每个后续顶点都将会与上一个三角形的边相连,生成另一个三角形。我们可以使用一个长度为3的窗口在输入顶点序列中滑动,观察每个生成的三角形。如下表:

输入顶点序列 输出图元(三角形)
0 1 2 3 4 5 [0, 1, 2] 3 4 5; 0 [1, 2, 3] 4 5; 0 1 [2, 3, 4] 5; 0 1 2 [3, 4, 5]。

线条带的处理方式类似。

三角形绕序

大多数场景中物体的图元拓扑类型都是三角形带,因为N个顶点序列就可以表示N-2个三角形。可以用来指定复杂的物体,并且有效地利用内存和处理时间。

在几何处理阶段,光栅化阶段之前,三角形的顶点会执行背面剔除,忽略那些看不见的三角形面片。背面剔除算法通常使用三角形的顶点顺序和观察者的视点方向来确定面片的朝向。也就是说,三角形的绕序(winding order) 也是拓扑类型的一个重要因素。

以微软DirectX(Unity也是)文档中配图可以看出,三角形列表的绕序为顺时针。但三角形带无法保证每个三角形的绕序相同。经过观察发现,三角形带中,奇次序的三角形绕序为顺时针,偶次序三角形的绕序为逆时针。为了解决这个问题,GPU内部会对偶数三角形的后两个顶点顺序进行调换 ,以此使它们与奇数三角形的绕序都保持顺时针

以上图为例,GPU实际处理的绕序如下表:。

原始顶点顺序 1 2 3 4 5 6 7

当前顶点索引 输出图元(三角形),顺时针顺序
1 [1, 2, 3]
2 [2, 4, 3]
3 [3, 4, 5]
4 [4, 6, 5]
5 [5, 6, 7]

邻接图元可以在任何基础拓扑类型衍生出来。注意,邻接图元的顶点只能用作几何着色器的输入数据,却并不会被绘制出来。即便程序没有用到几何着色器,但依旧不会绘制邻接图元。

注:以上的讨论都是针对应用阶段输入装配阶段,CPU告诉GPU如何将顶点组成图元类型的图元拓扑类型,而并非向几何着色器输入的图元类型。

编写几何着色器

几何着色器的一般编写格式如下:

c 复制代码
[maxcertexcount(N)]
void ShaderName (PrimitiveType InputVertexType InputName[NumElements], inout StreamOutputObjectVertexType) OutputName){
    // 几何着色器具体实现
}

最大顶点数量

[maxvertexcount(N)]用来指定几何着色器单词调用所输出的顶点数量最大值。其中,N是几何着色器单次调用所输出的顶点数量最大值。几何着色器每次输出的顶点个数都可能不同,但是这个数量却不能超过之前定义的最大值。

出于对性能方面的考虑,应当令maxvertexcount的值尽可能小。线管资料显示,GS每次输出的标量数量在1-20时,它将发挥出最佳的性能;而当27-40时,它的性能将下降到峰值性能的50%。

每次调用几何着色器所输出的标量个数为:maxvertexcount与输出顶点类型结构体中标量 个数的乘积 。例如,如果顶点结构体定义了float3 pos : POSITIONfloat2 tex : TEXCOORD0,即顶点元素中含有5个标量。假设此时将maxvertexcount设置为4,则几何着色器每次输出20个标量,以峰值性能执行。

输入输出

几何着色器的输入参数必须是一个定义有特定图元的顶点数组,图元拓扑类型应输入的顶点数量如下所示:

图元拓扑类型 输入的顶点数量
1
线条列表/带 2
三角形列表/带 3
线及邻接图元 4
三角形及其邻接图元 6

输入参数以图元类型作为前缀,用以描述输入到几何着色器的具体图元类型。并且注意,输入图元类型必须对应输入装配阶段的图元拓扑类型,否则会出现顶点不匹配的现象。该前缀可以是下列类型之一:

前缀 描述
point 输入图元拓扑类型为点列表
line 输入图元拓扑类型为线列表或线条带
triangle 输入的图元拓扑类型为三角形列表或三角形带
lineadj 输入的图元拓扑类型为线条列表/带及其邻接图元
triangleadj 输入的图元为三角形列表/带及其邻接图元

几何着色器的输出参数是标有inout修饰符的流类型(stream type) 。流类型存有一系列顶点,它们定义了几何着色器输出的几何图形。

流类型的本质是一种模板类型(template type),其模板参数用以指定输出顶点的具体类型。流类型有如下3种:

流类型 描述
PointStream 一系列顶点所定义的点列表
LineStream 一系列顶点所定义的线条带
TriangleStream 一系列顶点所定义的三角形带

几何着色器输出的多个顶点会够成图元,图元的输出类型由流类型来指定。对于线条与三角形来说,几何着色器输出的对应图元拓扑类型必须是线条带与三角形带 。而线条列表与三角形列表可以借助内置函数RestarStrip输出。

由于大多数模型图元拓扑类型都是三角形带,所以其实输入一般都是triangle

Append

Append函数用来将几何着色器的输出数据追加到一个现有的流中。

c 复制代码
[maxcertexcount(N)]
void ShaderName (PrimitiveType InputVertexType InputName[NumElements], inout StreamOutputObjectVertexType OutputName){
    // 几何着色器具体实现
    StreamOutputObjectVertexType gout;
    OutputName.Append(gout);
}

RestartStrip

RestartStrip函数用来结束当前的基元条带,开始一个新的条带。如果当前的条带没有足够的顶点被追加出来以填满基元拓扑结构,那么末端的不完整基元将被丢弃。

前文提到,几何着色器输出的图元拓扑类型只能是线条带或三角形带,但总有带状结构无法表示的情况(或者说过载了想不出来),这时候就可以用RestartStrip来重置输出流,采用类似三角形列表的方式追加几个三角形。

c 复制代码
[maxcertexcount(N)]
void ShaderName (PrimitiveType InputVertexType InputName[NumElements],
                inout StreamOutputObjectVertexType) OutputName){
    // 几何着色器具体实现
    StreamOutputObjectVertexType gout;
    OutputName.Append(gout);
    OutputName.RestartStrip();
    OutputName.Append(gout);
}

应用

  • 几何动画

  • 草地等(与曲面细分着色器结合)

草地渲染

本案例项目引用了代码咖啡/GrassShader中的部分素材,请先将此项目下载至本地。

开始

在开始编写Unity Shader代码之前,需要完成以下准备工作:

  • 新建一个Unity项目;
  • 新加一个场景;
  • 在场景中创建一个名为GrassGround的Plane;
  • GrassGround作为父节点,创建一个名为BottomGround的Plane(如下图);
  • 创建一个名为GrassBottomMat的材质,并使用内置的Unlit/ColorShader,颜色选择浅绿色(如下图);

  • 将上一步创建的材质赋给BottomGround

  • 在创建一个Unlit Shader,命名为Grass,删除里面的所有代码,并粘贴复制如下代码:

    c 复制代码
    Shader "TA100/3_3_Grass"
    {
        Properties
        {
        }
    
        // 为了让代码可以跨Pass通用,将代码写在SubShader外面,用CGINCLUDE、ENDCG包起来
        CGINCLUDE
    	#include "UnityCG.cginc"
            
        float4 vert(float4 vertex : POSITION) : SV_POSITION
        {
            return vertex;
        }
    
        ENDCG
    
        SubShader
        {
            // 为了让草两面都能看到,禁用剔除
            Cull Off
    
            Pass
            {
                Tags
                {
                    "RenderType" = "Opaque"
                    "LightMode" = "ForwardBase"
                }
    
                CGPROGRAM
    
                #pragma vertex vert
                #pragma fragment frag
    
                float4 frag(float4 vertex : SV_POSITION) : SV_Target
                {
                    return float4(1, 1, 1, 1);
                }
    
                ENDCG
            }
        }
    }
  • 创建名为GrassMat的材质,将GrassShader赋给它,并将它赋给场景里的GrassGroundPlane;

  • 保存场景;

几何着色器

对3_3_Grass.shader进行编辑。

c 复制代码
// 在CGINCLUDE代码块中添加
struct geometryOutput
{
    float4 pos : SV_POSITION;
};

// 在CGINCLUDE代码块中添加
[maxvertexcount(3)]
void geo(triangle float4 IN[3] : SV_POSITION,
         inout TriangleStream<geometryOutput> triStream)
{
}

// 在SubShader的ForwardBase Pass中添加,添加在"#pragma fragment frag"后面
#pragma geometry geo
#pragma target 4.6
  • 与顶点着色器、片元着色器类似,需要在Pass中指定几何着色器的方法名让几何着色器生效;
  • 定义几何着色器的输出结构geometryOutput,它会在几何着色器中被创建出来,并传递到片元着色器;
  • geo方法的第一个参数triangle float4 IN[3] : SV_POSITION接受一个三角形图元的3个顶点作为输入;
  • geo方法的第二个参数是一个TriangleStream类型,里面包含三角形图元的顶点信息,这里的顶点信息就是刚刚声明的geometryOutput结构,它会被传递到片元着色器中,后续我们会在片元着色器中对geometryOutput进行处理;
  • 在geo方法的前面,我们用中括号定义了最大顶点数量[maxvertexcount(3)],它会告诉GPU,这个geo方法最多会输出3个顶点信息(实际可以不只3个);
  • 几何着色器是Shader Model 4正式引入的着色器,所以需要指定target。

然后往几何着色器的方法里添加需要输出的3个顶点的信息:

c 复制代码
geometryOutput o;

o.pos = float4(0.5, 0, 0, 1);
triStream.Append(o);

o.pos = float4(-0.5, 0, 0, 1);
triStream.Append(o);

o.pos = float4(0, 1, 0, 1);
triStream.Append(o);

保存代码回到场景,可以看到一个三角形已经绘制到场景里了,但是有一个问题:不论我们调整视角还是移动GrassGround,三角形始终跟着当前摄像头移动或旋转。

出现这个问题,是因为几何着色器输出的坐标是在裁剪空间下的,我们的代码写死了裁剪空间下的坐标值,所以不管视角如何改变、物体如何移动,都没有对裁剪空间下的坐标产生任何影响。

而我们本意是期望三角形的三个顶点在基于模型空间下的坐标值,所以需要修改一下代码,使用UnityObjectToClipPos内置方法将模型空间下的坐标转换到裁剪空间下:

c 复制代码
// 对geo方法中o.pos的赋值代码进行修改
o.pos = UnityObjectToClipPos(float4(0.5, 0, 0, 1));
...
o.pos = UnityObjectToClipPos(float4(-0.5, 0, 0, 1));
...
o.pos = UnityObjectToClipPos(float4(0, 1, 0, 1));
...

如此,得到了Plane中心上立着的一个三角形:

我们点开Plane的网格,可以看到,Plane是由很多个三角形图元组成的:

但目前在Plane上面,只看到了一个立起来的三角形,照理说应该每一个三角形图元都有一个对应的三角形 才对。这是因为geo方法里,我们实现的输出三角形顶点位置是基于模型坐标的,并没有根据三角形图元位置做偏移,所以所有的三角形都被绘制到了模型的中心,看起来就只有一个三角形了。

修改一下代码:

c 复制代码
// 修改CGINCLUDE代码块中的vert方法的返回值
return vertex;

// 添加到geo方法内的最前面
float3 pos = IN[0];

// 修改o.pos的赋值代码
o.pos = UnityObjectToClipPos(pos + float3(0.5, 0, 0));
...
o.pos = UnityObjectToClipPos(pos + float3(-0.5, 0, 0));
...
o.pos = UnityObjectToClipPos(pos + float3(0, 1, 0));
...
  • 因为几何着色器中需要传入的坐标位置需要是模型空间的,所以顶点着色器中不再需要将顶点转为裁剪空间下的坐标;

  • geo方法中,我们拿到了三角形图元的第一个顶点,以此作为偏移量,实现了每一个三角形图元上都有一个立起来的三角形:

虽然三角形已经看起来正确地立起来了,但其实它的朝向是有问题的,我们在场景里创建一个球体,把GrassMat材质赋给它,可以看到三角形并没有垂直于球面:

接下来需要基于切线空间和模型空间的坐标转换,让三角形垂直于球面。

切线空间

我们知道,根据切线、副切线、法线,可以模型空间和切线空间的转换矩阵,而副切线又可以通过法线和切线叉乘得到,所以需要将顶点着色器的输入参数中的法线和切线信息传到几何着色器中:

c 复制代码
// 将顶点着色器的输入结构和输出结构加到CGINCLUDE代码块中
struct vertexInput
{
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 tangent : TANGENT;
};

struct vertexOutput
{
    float4 vertex : SV_POSITION;
    float3 normal : NORMAL;
    float4 tangent : TANGENT;
};

// 修改CGINCLUDE代码块中的顶点着色器代码
vertexOutput vert(vertexInput v)
{
    vertexOutput o;
    o.vertex = v.vertex;
    o.normal = v.normal;
    o.tangent = v.tangent;
    return o;
}

在顶点着色器的结构中,我们看到法线使用float3类型,而切线使用float4类型。切线之所以多了一个w分量,是因为垂直于法线和切线的副切线有两个方向,w分量就是决定这个副切线的方向的

接下来修改几何着色器的输入参数类型(由三维坐标变成了结构体),以及里面获取顶点坐标的逻辑:

c 复制代码
// 将第一个参数的float4类型改为vertexOutput类型
void geo(triangle vertexOutput IN[3],
         inout TriangleStream<geometryOutput> triStream)
    
// 修改顶点坐标的获取
float3 pos = IN[0].vertex;

此外在几何着色器中计算出副切线的矢量,并构建出切线空间到模型空间的转换矩阵:

c 复制代码
// 在几何着色器float3 pos的声明下面添加
float3 vNormal = IN[0].normal;
float4 vTangent = IN[0].tangent;
float3 vBinormal = cross(vNormal, vTangent) * vTangent.w;

float3x3 tangentToLocal = float3x3(
    vTangent.x, vBinormal.x, vNormal.x,
    vTangent.y, vBinormal.y, vNormal.y,
    vTangent.z, vBinormal.z, vNormal.z
);

将顶点着色器输出参数转变成几何着色器输出参数的代码逻辑抽象成一个方法,放到几何着色器方法的上面:

c 复制代码
// 放到geo方法的前面
geometryOutput VertexOutput(float3 pos)
{
    geometryOutput o;
    o.pos = UnityObjectToClipPos(pos);
    return o;
}

// 对应的geo方法中模型空间转裁剪空间的代码需要修改
triStream.Append(VertexOutput(pos + float3(0.5, 0, 0)));
triStream.Append(VertexOutput(pos + float3(-0.5, 0, 0)));
triStream.Append(VertexOutput(pos + float3(0, 1, 0)));

// 删除之前的代码
/*
geometryOutput o;

o.pos = UnityObjectToClipPos(pos + float4(0.5, 0, 0, 1));
triStream.Append(o);

o.pos = UnityObjectToClipPos(pos + float4(-0.5, 0, 0, 1));
triStream.Append(o);

o.pos = UnityObjectToClipPos(pos + float4(0, 1, 0, 1));
triStream.Append(o);
*/

最后,将三角形面片的顶点位置经过切线空间转模型空间的矩阵进行转换:

c 复制代码
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0.5, 0, 0))));
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(-0.5, 0, 0))));
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 1, 0))));

可以看出,之前写的3个float3类型的三角形顶点坐标,不再是模型空间下的,而是切线空间的。它经过转换矩阵变换后才变成模型空间的,再与模型空间的顶点坐标进行相加得到最终三角形顶点在模型空间下的位置。

三角形面片虽然看起来比之前的效果好多了,但是细看依然没有垂直于球面,这是因为三角形的第三个坐标是(0, 1, 0),Y分量是1,之前基于Plane实现的这个坐标是模型空间下的,但是经过刚刚的修改,这个坐标空间已经变成了切线空间了,而法线方向是Z轴,所以需要将第三个坐标的1由Y分量改成Z分量。

c 复制代码
// 将第三个顶点坐标的1由Y分量改成Z分量
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 0, 1))));

由此,三角形面片已全部变成垂直于球面:

让三角形更像草

接下来,我们对三角形增加些颜色,让它的朝向更随机一些,让它看起来更像一颗颗草。

颜色渐变

给草添加由根部到头部的颜色渐变,首先添加两个颜色属性:

c 复制代码
// 添加在属性语义块中
_TopColor("Top Color", Color) = (1,1,1,1)
_BottomColor("Bottom Color", Color) = (1,1,1,1)
    
// 添加在ForwardBase Pass的CGPROGRAM代码块中#pragma的下面
fixed4 _TopColor;
fixed4 _BottomColor;

颜色渐变需要在片元着色器中用到lerp方法,而lerp方法需要基于uv坐标,所以几何着色器的输出结构中定义uv变量,然后手动传入三角形面片三个顶点的uv值:

c 复制代码
// 在geometryOutput结构体中添加
float2 uv : TEXCOORD0;

// VertexOutput方法中添加第二个参数uv值
geometryOutput VertexOutput(float3 pos, float2 uv)

// 在VertexOutput方法内,将uv值赋给geometryOutput结构体
o.uv = uv;

// 修改VertexOutput方法调用处,手动传入三角形面片的uv值
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0.5, 0, 0)), float2(0, 0)));
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(-0.5, 0, 0)), float2(1, 0)));
triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 0, 1)), float2(0.5, 1)));

三角形面片uv值对应图:

修改片元着色器代码,让颜色值生效:

c 复制代码
// 修改ForwardBase Pass中片元着色器的参数,由坐标位置改为几何着色器的输出结构体
float4 frag (geometryOutput i) : SV_Target

// 片元着色器中的return代码改为lerp方法采样,通过uv值的Y分量,采样得到输出的颜色值
return lerp(_BottomColor, _TopColor, i.uv.y);

保存代码后,设置材质中的两个颜色值:

回到场景,可以看到渐变的颜色效果:

随机朝向

为了让三角形的排布看起来更自然些,需要让三角形绕竖直方向旋转随机的角度。已经封装好了两个方法:

c 复制代码
// 添加到CGINCLUDE代码块geo方法的前面

// Simple noise function, sourced from http://answers.unity.com/answers/624136/view.html
// Extended discussion on this function can be found at the following link:
// https://forum.unity.com/threads/am-i-over-complicating-this-random-function.454887/#post-2949326
// Returns a number in the 0...1 range.
float rand(float3 co)
{
    return frac(sin(dot(co.xyz, float3(12.9898, 78.233, 53.539))) * 43758.5453);
}

float3x3 AngleAxis3x3(float angle, float3 axis)
{
    float c, s;
    sincos(angle, s, c);

    float t = 1 - c;
    float x = axis.x;
    float y = axis.y;
    float z = axis.z;

    return float3x3(
        t * x * x + c, t * x * y - s * z, t * x * z + s * y,
        t * x * y + s * z, t * y * y + c, t * y * z - s * x,
        t * x * z - s * y, t * y * z + s * x, t * z * z + c
    );
}
  • rand方法会根据传入的三维坐标生成一个随机数,这个随机数的范围在0~1之间;
  • AngleAxis3x3方法会返回绕着传入的方向轴旋转既定的角度的旋转矩阵。

让三角形的顶点在切线空间下,绕Z轴旋转随机的角度,因为rand返回值在0~1,所以需要乘以 2 π 2\pi 2π的弧度,让其在360度范围内随机旋转:

c 复制代码
// 在几何着色器tangentToLocal的声明下面添加
float3x3 facingRotationMatrix = AngleAxis3x3(rand(pos) * UNITY_TWO_PI, float3(0, 0, 1));

// 为了使用内置变量UNITY_TWO_PI,在CGINCLUDE里的最前面引入Lighting.cginc
#include "Lighting.cginc"

因为添加了旋转矩阵,我们不能直接调用tangentToLocal矩阵对顶点直接进行转换,而是先对顶点旋转,再进行空间转换,所以需要声明新的变换矩阵,用它对顶点进行转换:

c 复制代码
// 在几何着色器facingRotationMatrix的声明下面添加
float3x3 transformationMatrix = mul(tangentToLocal, facingRotationMatrix);

// 把顶点转换由tangentToLoca矩阵换为新的transformationMatrix矩阵
triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0.5, 0, 0)), float2(0, 0)));
triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(-0.5, 0, 0)), float2(1, 0)));
triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0, 0, 1)), float2(0.5, 1)));

保存代码回到场景,可以看到三角形的随机旋转效果:

让小草随机前倾后仰

为了让小草实现前倾后仰的效果,我们让小草三角形绕X轴旋转随机的角度,这时需要引入另外一个转换矩阵;同时为了控制小草倾斜的程度,还需要在属性语义块中添加一个属性:

c 复制代码
// 添加新的属性
_BendRotationRandom("Bend Rotation Random", Range(0, 1)) = 0.2
    
// 在CGINCLUDE代码块中声明属性变量
float _BendRotationRandom;

// 在几何着色器的facingRotationMatrix声明下面添加
// 将小草的倾斜角度控制在90度内
float3x3 bendRotationMatrix = AngleAxis3x3(rand(pos.zzx) * _BendRotationRandom * UNITY_PI * 0.5, float3(-1, 0, 0));

// 将之前声明的transformationMatrix矩阵结合上小草倾斜矩阵
float3x3 transformationMatrix = mul(mul(tangentToLocal, facingRotationMatrix), bendRotationMatrix);

保存代码,调整材质面板Bend Rotation Random属性值,可以看到不同倾斜效果的小草:

调整小草的高度和宽度

为了让三角形面片在形状上看起来更像小草,我们添加小草宽高的属性,同时增加宽高的随机因子,让每颗小草的宽高有差异:

c 复制代码
// 添加新的属性
_BladeWidth("Blade Width", Float) = 0.05
_BladeWidthRandom("Blade Width Random", Float) = 0.02
_BladeHeight("Blade Height", Float) = 0.5
_BladeHeightRandom("Blade Height Random", Float) = 0.3

// 在CGINCLUDE代码块中声明属性变量
float _BladeHeight;
float _BladeHeightRandom;	
float _BladeWidth;
float _BladeWidthRandom;

// 在几何着色器里的triStream.Append调用前添加
// 随机参数一个传zyx,一个传xzy,可能是为了增加随机多样性吧,原作者没有交代
float height = (rand(pos.zyx) * 2 - 1) * _BladeHeightRandom + _BladeHeight;
float width = (rand(pos.xzy) * 2 - 1) * _BladeWidthRandom + _BladeWidth;

// 修改三个顶点的坐标值,使用动态宽高
triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(width, 0, 0)), float2(0, 0)));
triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(-width, 0, 0)), float2(1, 0)));
triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0, 0, height)), float2(0.5, 1)));

通过调整可以看到小草的形状已逐步接近真实:

但是小草之间的间距变大了,而我们需要的是密草丛生的效果。这里有两种解决方法:

  • 使用面片数量更多的网格;
  • 使用曲面细分;

曲面细分

接下来我们使用曲面细分,让小草变得更稠密:

c 复制代码
// 添加新的属性
_TessellationUniform("Tessellation Uniform", Range(1, 64)) = 1

// 在ForwardBase Pass里面#pragma声明的下面添加
#pragma hull hull
#pragma domain domain
    
// 在CGINCLUDE代码块中声明属性变量
float _TessellationUniform;

// 在CGINCLUDE代码块的vertexOutput下添加
struct TessellationFactors 
{
    float edge[3] : SV_TessFactor;
    float inside : SV_InsideTessFactor;
};
    
// 在CGINCLUDE代码块的vert下添加
vertexOutput tessVert(vertexInput v)
{
    vertexOutput o;
    o.vertex = v.vertex;
    o.normal = v.normal;
    o.tangent = v.tangent;
    return o;
}

//在geo方法前添加
TessellationFactors patchConstantFunction (InputPatch<vertexInput, 3> patch)
{
    TessellationFactors f;
    f.edge[0] = _TessellationUniform;
    f.edge[1] = _TessellationUniform;
    f.edge[2] = _TessellationUniform;
    f.inside = _TessellationUniform;
    return f;
}

[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("integer")]
[UNITY_patchconstantfunc("patchConstantFunction")]
vertexInput hull (InputPatch<vertexInput, 3> patch, uint id : SV_OutputControlPointID)
{
    return patch[id];
}

[UNITY_domain("tri")]
vertexOutput domain(TessellationFactors factors, OutputPatch<vertexInput, 3> patch, float3 barycentricCoordinates : SV_DomainLocation)
{
    vertexInput v;

    #define MY_DOMAIN_PROGRAM_INTERPOLATE(fieldName) v.fieldName = \
        patch[0].fieldName * barycentricCoordinates.x + \
        patch[1].fieldName * barycentricCoordinates.y + \
        patch[2].fieldName * barycentricCoordinates.z;

    MY_DOMAIN_PROGRAM_INTERPOLATE(vertex)
    MY_DOMAIN_PROGRAM_INTERPOLATE(normal)
    MY_DOMAIN_PROGRAM_INTERPOLATE(tangent)

    return tessVert(v);
}

保存代码回到场景,调整材质面板的Tessellation Uniform属性,可以随着曲面细分参数调大,小草越来越密:

添加风力

为了让小草看起来更加生动,我们使用形变纹理+时间参数让小草动起来。形变纹理在项目仓库的Assets/Textures/Wind.png目录下,样式如下:

形变纹理与法线纹理类似,都是通过图片上像素的颜色值作为信息存储的载体。不同的是,这里的形变纹理只使用了颜色值的两个通道存储风在X和Y分量上的方向。

c 复制代码
// 添加新的属性
// 形变纹理
_WindDistortionMap("Wind Distortion Map", 2D) = "white" {}
// 风的频率(可以理解为风向改变的频率)
_WindFrequency("Wind Frequency", Vector) = (0.05, 0.05, 0, 0)
    
// 在CGINCLUDE代码块中添加属性变量
sampler2D _WindDistortionMap;
float4 _WindDistortionMap_ST;

float2 _WindFrequency;

// 在几何着色器中transformationMatrix声明的上一行添加
// 基于当前图元的顶点,利用纹理缩放+平移+时间*频率,得到纹理采用需要用到的uv
float2 uv = pos.xz * _WindDistortionMap_ST.xy + _WindDistortionMap_ST.zw + _WindFrequency * _Time.y;

接下来添加风力大小控制属性,本质是乘以采样后得到的纹理颜色,实现对风力大小的控制:

c 复制代码
// 添加属性
_WindStrength("Wind Strength", Float) = 1
    
// 在CGINCLUDE代码块中声明属性
float _WindStrength;

// 在几何着色器中uv的声明下面添加
float2 windSample = (tex2Dlod(_WindDistortionMap, float4(uv, 0, 0)).xy * 2 - 1) * _WindStrength;

需要注意的是:采样后,我们将采样结果的范围由[0, 1]变成了[-1, 1],这是为了防止小草向一面倒的情况。接下来根据采样得到的矢量进行归一化后,计算出风力让小草旋转的矩阵,并加入到最终的变换矩阵中:

c 复制代码
// 在windSample的声明后面添加
// 对风向采样进行归一化
float3 wind = normalize(float3(windSample.x, windSample.y, 0));

// 得到风力旋转矩阵
float3x3 windRotation = AngleAxis3x3(UNITY_PI * windSample, wind);

// 修改最终变换矩阵的计算,添加风力旋转矩阵
float3x3 transformationMatrix = mul(mul(mul(tangentToLocal, windRotation), facingRotationMatrix), bendRotationMatrix);

保存代码,配置好材质参数就可以在场景中看到风吹草地的效果了:


至此,我们的草地效果看起来基本完成了,但是细看还是存在问题的,每一片小草靠近Plane的两个顶点在随风飘动的时候存在着一个顶点在Plane下方,另一个在Plane上方的问题:

这个问题的出现是风力旋转矩阵和前倾后仰的倾斜矩阵综合导致的。其实从生活经验上来看,草的根部受风力的影响可以忽略不计,且根部的位置也不应该受到倾斜的影响,所以我们声明一个新的矩阵,只包含空间转换矩阵和朝向矩阵,用来对草的根部顶点进行变换:

c 复制代码
// 在transformationMatrix声明的下一行添加
float3x3 transformationMatrixFacing = mul(tangentToLocal, facingRotationMatrix);

// 修过三角形底部两个顶点的变换矩阵,由transformationMatrix改用transformationMatrixFacing
triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(width, 0, 0)), float2(0, 0)));
triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(-width, 0, 0)), float2(1, 0)));

让小草看起来有柔韧性

到目前为止,小草叶片始终是由一个三角形组成的,意味着这片小草只是硬生生的一块板,其本身并没有弯曲的可能,这会让小草看起来缺少柔韧性。为此我们需要将这个三角形拆分成多个三角形,不同的三角形之间做一下折叠,让整颗草看起来有曲线。

想要实现这样的效果,就需要在几何着色器上下文章了。几何着色器支持输出多个顶点信息,目前我们输出的是3个顶点,只要输出更多的顶点,让这些顶点按照一定的规则组成如上图所示的位置排布就好。

由上图可知,除去最上方的小三角形,其他的三角形两两组成一个梯形,那么几何着色器就可以通过for循环从下到上的顺序,每次往输出中添加梯形底边上的两个点,在for循环结束后,只要加上叶片最顶端的顶点即可。

那么几何着色器怎么知道输出的哪些顶点应该互连形成三角形呢?TriangleStream中包含的前3个顶点连接起来会形成一个三角形,第4个顶点会和第2、3个顶点形成三角形,第5个顶点会和第3、4个顶点形成三角形,以此类推。

基于此,我们使用宏BLADE_SEGMENTS来定义每个叶片上梯形的数量,那么叶片上顶点的数量就变成了BLADE_SEGMENTS * 2 + 1,接下来修改代码:

c 复制代码
// 在CGINCLUDE代码块中添加宏的定义
#define BLADE_SEGMENTS 3

// 修改几何着色器输出的最大顶点数
[maxvertexcount(BLADE_SEGMENTS * 2 + 1)]

为了方便后续代码逻辑的编写,先对目前的代码进行抽象,基于VertexOutput方法在外面再包一个层:

c 复制代码
// 在CGINCLUDE代码块中添加方法定义(注意:代码声明需要写在代码调用之前)
geometryOutput GenerateGrassVertex(float3 vertexPosition, float width, float height, float2 uv, float3x3 transformMatrix)
{
    float3 tangentPoint = float3(width, 0, height);
    float3 localPosition = vertexPosition + mul(transformMatrix, tangentPoint);
    return VertexOutput(localPosition, uv);
}

然后对VertexOutput方法调用的地方进行修改:

c 复制代码
triStream.Append(GenerateGrassVertex(pos, width, 0, float2(0, 0), transformationMatrixFacing));
triStream.Append(GenerateGrassVertex(pos, -width, 0, float2(1, 0), transformationMatrixFacing));
triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix));

接下来开始for循环的编写:

c 复制代码
// 在几何着色器width声明的下一行添加
for (int i = 0; i < BLADE_SEGMENTS; i++)
{
    // t表示遍历的进度,值在0~1,用于计算梯形底边的宽以及底边所处的高度
    float t = i / (float)BLADE_SEGMENTS;
    
    // 计算宽高
    float segmentHeight = height * t;
    float segmentWidth = width * (1 - t);
    
    // 最底部的顶点使用transformationMatrixFacing变换矩阵
    float3x3 transformMatrix = (i == 0 ? transformationMatrixFacing : transformationMatrix);
    
    // 添加顶点
    triStream.Append(GenerateGrassVertex(pos, segmentWidth, segmentHeight, float2(0, t), transformMatrix));
    triStream.Append(GenerateGrassVertex(pos, -segmentWidth, segmentHeight, float2(1, t), transformMatrix));
}

// for循环结束后需要将小草叶片最顶部的顶点加入输出流中
triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1),
                                     transformationMatrix));

// 删除不用的代码
/*
triStream.Append(GenerateGrassVertex(pos, width, 0, float2(0, 0), transformationMatrixFacing));
triStream.Append(GenerateGrassVertex(pos, -width, 0, float2(1, 0), transformationMatrixFacing));
triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix));
*/

通过对切线空间的顶点Y轴方向进行偏移,来实现小草叶片中不同三角形的弯曲程度:

c 复制代码
// GenerateGrassVertex方法添加一个参数forward,用于控制顶点Y轴方向的偏移程度
geometryOutput GenerateGrassVertex(float3 vertexPosition, float width, float height, float forward, float2 uv, float3x3 transformMatrix)

// GenerateGrassVertex方法中,将新加的参数传入到tangentPoint的Y分量中
float3 tangentPoint = float3(width, forward, height);

顶点的偏移程度受for循环中的变量t影响,通过对t进行乘方再乘一个随机的偏移量得到最终的偏移,随机的偏移量基数和乘方的指数放到了属性中方便控制(这里的计算是一个经验公式,了解即可):

c 复制代码
// 添加属性
_BladeForward("Blade Forward Amount", Float) = 0.38
_BladeCurve("Blade Curvature Amount", Range(1, 4)) = 2

// CGINCLUDE代码块中添加属性变量的声明
float _BladeForward;
float _BladeCurve;

// 在几何着色器中width的声明下一行添加
// 计算随机的偏移总量
float forward = rand(pos.yyz) * _BladeForward;

// 在几何着色器的for循环segmentWidth的下一行添加
// 计算当前梯形底边的偏移量
float segmentForward = pow(t, _BladeCurve) * forward;

// 将当前偏移量传到GenerateGrassVertex方法中
triStream.Append(GenerateGrassVertex(pos, segmentWidth, segmentHeight, segmentForward, float2(0, t), transformMatrix));
triStream.Append(GenerateGrassVertex(pos, -segmentWidth, segmentHeight, segmentForward, float2(1, t), transformMatrix));

// 将总偏移量传到最顶部顶点的GenerateGrassVertex方法中
triStream.Append(GenerateGrassVertex(pos, 0, height, forward, float2(0.5, 1), transformationMatrix));

至此,小草的柔韧性问题解决:

光照和阴影

接下来到了最后一步,让草地可以投射和接受阴影。

投射阴影

阴影投射的原理是,将摄像机放到光源的位置,然后渲染场景里的物体,记录场景中没有被遮挡的物体距离光源的距离(深度),形成一张阴影纹理;

然后将相机放回原来的位置再一次渲染,通过将场景里物体到光源的距离阴影纹理中记录的深度 进行比较,如果它的距离比阴影纹理记录的深度值大,则说明物体被其他物体遮挡,接受不到光照,因而产生阴影。

整个过程一般会用到两个Pass,一个是阴影投射Pass,用于阴影纹理生成的阶段;另一个是ForwardBase Pass,用于实际的渲染,所以为了让草地可以投射阴影,我们需要添加一个阴影投射Pass。

c 复制代码
// 在SubShader语义块中添加一个新的Pass
Pass
{
    Tags
    {
        "LightMode" = "ShadowCaster"
    }

    CGPROGRAM
    #pragma vertex vert
    #pragma geometry geo
    #pragma fragment frag
    #pragma hull hull
    #pragma domain domain
    #pragma target 4.6
    #pragma multi_compile_shadowcaster

    float4 frag(geometryOutput i) : SV_Target
    {
        SHADOW_CASTER_FRAGMENT(i)
    }

    ENDCG
}
  • 当前Pass仅用于投射阴影,所以"LightMode"设置为"ShadowCaster";
  • vertgeohulldomain方法都在CGINCLUDE里定义过了,所以直接使用就好;
  • multi_compile_shadowcaster指令保证阴影投射中用到的变量可以正常使用;
  • 调用内置的SHADOW_CASTER_FRAGMENT宏方法实现阴影投射。

保存代码,在场景中创建一个立方体,打开场景的光照,可以在立方体上看到小草的阴影:

接受阴影

接受其他物体投射的阴影,需要计算顶点在屏幕空间下的位置,再传到片元着色器:

c 复制代码
// 在几何着色器输出结构体中添加顶点在屏幕空间下的坐标
// 该变量的命名不可轻易改动,
// 因为内置的阴影衰减方法里会以这个命名来取结构体中存储的屏幕空间下的顶点坐标
unityShadowCoord4 _ShadowCoord : TEXCOORD1;

// 在VertexOutput方法中添加
o._ShadowCoord = ComputeScreenPos(o.pos);

// ForwardBase Pass中添加
#include "AutoLight.cginc"

然后在ForwardBase Pass的片元着色器中暂时注掉之前计算渐变色的lerp方法,使用宏方法来计算阴影衰减:

c 复制代码
return SHADOW_ATTENUATION(i);
// return lerp(_BottomColor, _TopColor, i.uv.y);

最后在ForwardBase Pass的CGPROGRAM代码块中添加指令:

c 复制代码
#pragma multi_compile_fwdbase

保存代码回到场景,可以看到草地接受到了来自立方体投射的阴影

当近距离看场景时,草的叶片上会出现伪影(Artifacts,指与我们预期不一样的结果),原作者的解释是叶片和叶片之间互相阴影投射导致的:

修正的方法是应用线性偏差(applying linear bias)或将各顶点在裁剪空间里的坐标移到屏幕外(translating the clip space positions of the vertices slightly away from the screen)。而前者是Unity是支持的,我们可以根据宏进行判断,仅在阴影投射的Pass中应用线性阴影偏差来修正Artifacts:

c 复制代码
// 在VertexOutput方法中return返回前一行添加
#if UNITY_PASS_SHADOWCASTER
	// Applying the bias prevents artifacts from appearing on the surface.
	o.pos = UnityApplyLinearShadowBias(o.pos);
#endif

可看到修正后的Artifacts情况好了很多:

光照

接下来实现草地的光照计算,这里不考虑高光反射,只使用 I = N ⋅ L I=N \cdot L I=N⋅L简单公式计算漫反射光照计算。

实现思路是将切线空间中的法线转换到世界空间中,与世界空间下归一化后的平行光方向进行点乘,再结合环境光、草本身的渐变颜色,通过lerp方法得到最终的颜色值。

先将切线空间中的法线方向转换到模型空间中:

c 复制代码
// 在GenerateGrassVertex方法中tangentPoint声明的下一行添加
float3 tangentNormal = float3(0, -1, 0);
float3 localNormal = mul(transformMatrix, tangentNormal);

将模型空间中的法线方向传给VertexOutput方法,转换成世界空间坐标后,保存到geometryOutput结构体中:

c 复制代码
// 修改GenerateGrassVertex方法中VertexOutput的调用,传入模型空间下的法线方向
return VertexOutput(localPosition, uv, localNormal);

// geometryOutput结构体中增加法线的存储变量
float3 normal : NORMAL;

// 修改VertexOutput的方法定义,增加法线变量的传参
geometryOutput VertexOutput(float3 pos, float2 uv, float3 normal)
    
// 将法线方向由模型空间转到世界空间后,存储到geometryOutput结构体
o.normal = UnityObjectToWorldNormal(normal);

为了让法线的数值可视化,我们在ForwardBase Pass的片元着色器中将法线方向由[-1, 1]的范围转为[0, 1]的范围后,作为颜色值输出到屏幕:

c 复制代码
// 为ForwardBase Pass片元着色器增加第二个参数:facing,用于判断当前片元是否朝着相机
float4 frag(geometryOutput i, fixed facing : VFACE) : SV_Target

// 在ForwardBase Pass片元着色器添加

// 因为当前的Pass关闭了背面剔除,为了确保法线朝向正确的方向,需要根据是否为背面调整法线的方向
// facing大于0表示正对视角,小于0表示背对视角
float3 normal = facing > 0 ? i.normal : -i.normal;

// 返回限制数值范围后的法线作为输出颜色值
return float4(normal * 0.5 + 0.5, 1);

// 删除原来阴影的返回调用
// return SHADOW_ATTENUATION(i);

可以看到五彩缤纷的草地颜色效果,不同的颜色反映了不同法线方向的数值:

前文提到,小草叶片的每一个顶点(除最底部的两个)都有前倾后仰的位于切线空间下Z分量的偏移 ,而对于叶片最顶端顶点,它的法线方向并不是规规矩矩的float3(0, -1, 0),所以我们需要加上它在Z分量上的偏移来修正法线方向:

c 复制代码
// 修改GenerateGrassVertex方法中切线空间下法线的声明,加上forward分量
float3 tangentNormal = normalize(float3(0, -1, forward));

最后再进行光照模型的计算:

c 复制代码
// 添加新属性Translucent Gain,用于控制法线和平行光点乘结果后的补光
_TranslucentGain("Translucent Gain", Range(0, 1)) = 0.5

// 在ForwardBase Pass中添加属性的声明
float _TranslucentGain;

// 在ForwardBase Pass中片元着色器normal变量的声明下一行添加

// 得到阴影纹理中的值
float shadow = SHADOW_ATTENUATION(i);
// 结合阴影计算法线方向和世界空间中平行光方向的点乘结果
float NdotL = saturate(saturate(dot(normal, _WorldSpaceLightPos0)) + _TranslucentGain)
                       * shadow;

// 内置方法计算环境光
float3 ambient = ShadeSH9(float4(normal, 1));
// 漫反射+环境光,得到光照强度
float4 lightIntensity = NdotL * _LightColor0 + float4(ambient, 1);
// 将光照反射与小草叶片顶部颜色结合,再与底部的颜色通过lerp得到最终颜色
return lerp(_BottomColor, _TopColor * lightIntensity, i.uv.y);

// 删除原来法线数值可视化的调试代码
// return float4(normal * 0.5 + 0.5, 1);

最终得到的效果如下:

参考资料

Unity 曲面细分着色器详解 - 知乎

Unity几何着色器详解 - 知乎

Grass Shader实现风吹草地的效果 - 知乎

相关推荐
Moweiii13 天前
Godot Tween 补间动画探索
游戏引擎·godot·技术美术
超龄魔法少女1 个月前
[Unity] ShaderGraph动态修改Keyword Enum,实现不同效果一键切换
unity·技术美术·shadergraph
小春熙子2 个月前
Unity图形学之CubeMap立方体贴图
unity·游戏引擎·贴图·技术美术
小春熙子2 个月前
Unity图形学之法线贴图原理
unity·游戏引擎·贴图·技术美术
杳戢2 个月前
技术美术百人计划 | 《2.1 色彩空间介绍》笔记
笔记·unity·游戏引擎·图形渲染·技术美术
小春熙子2 个月前
Unity图形学之灯光的原理
unity·游戏引擎·技术美术
小春熙子2 个月前
Unity图形学之着色器之间传递参数
unity·游戏引擎·技术美术·着色器
杳戢2 个月前
凹凸/高度贴图、法线贴图、视差贴图、置换贴图异同
unity·图形渲染·贴图·技术美术
小春熙子2 个月前
Unity图形学之Shader结构
unity·游戏引擎·技术美术