没啥用的主页
学习杂记
UnityShader 细分着色器与几何着色器
Dec 04 2022

前言

最近开了草地渲染觉得很帅。就决定来学习一下如何制作。发现利用到了细分着色器,故此写篇博客来记录一下。

在Unity种我们可以编程的Shader有四个。分别是

  1. 顶点着色器(Vertex Shader)
  2. 细分着色器(Tessellation Shader)
  3. 几何着色器(Geometry Shader)
  4. 片元着色器(Fragment Shader)
    他们在渲染过程中如下

我们今天的主角便是其中的曲面细分着色器(Tessellation Shader) ,曲面细分着色器在我们使用中分为三个步骤。准备数据——生成顶点和边——显示出来。结构如下图

其中的控制着色器便是用于准备数据。而中间的是无法编程也无法配置的,最后一步便是计算结果的细分着色器。

语法结构

在Unity中使用曲面细分着色器第一步需要在Unity中来声明函数和对应的着色器。跟声明顶点着色器和片元着色器一样,语法如下

#pragma hull hullProgram
#pragma domain ds

hull便是我们的细分控制着色器,domain是我们的细分计算着色器。至于具体的内容我们后面再讲。我们现在需要知道细分着色器他的输入是什么,他干了什么事情,他的输出是什么,这样才能更好的去了解该怎么写代码。

Tess输入是一个集合,它包含了每个顶点的属性,其中属性是每个顶点共享的

Tess会根据我们输入的信息,来对面或者是边进行细分。具体的不同的信息需要不同的配置

Tess的输出也很简单,便是我们处理好了的顶点信息。

既然这个Shader需要输入信息,那么就需要写个结构体来存储,代码如下

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

因为我们是细分一个物体,所以只要把原本的顶点信息原封不动的传进来即可,不需要去做什么改动,需要注意的点也就只有vertex的语义本应该是POSITION,现在变成了INTERNALTESSPOS,这是因为编译器会报语义重用的错。

struct OutputPatchConstant{
    float edgb[3]:SV_TESSFACTOR;
    float inside:SV_INSIDETESSFACTOR;
};

这里还需要声明一个结构体,用于在细分的时候的控制。其中的SV_TESSFACTOR语义是定义了每条边上的细分程度。即指定的边会被分成多少段.而变量数组长度为3是指我们我们的三条边,SV_INSIDETESSFACTOR语义是内部细分度,指定内部再几个维度上被细分。
至此我们便写完了曲面细分着色器的战前准备。接下来我们便要去实现一下他的计算部分。

首先我们需要实现前面指定的曲面细分控制着色器,整个函数如下

[UNITY_domain("tri")] //指输入的图元是三角形
[UNITY_partitioning("fractional_odd")] //拆分edgb的规则
[UNITY_outputtopology("triangle_cw")]//决定了产生的图元的朝向,cw为顺时针,ccw为逆时针
[UNITY_patchconstantfunc("hsconst")]// 指定了计算数值的函数。
[UNITY_outputcontrolpoints(3)] //指hull shader输出的顶点数量
TessVertex hullProgram(InputPatch<TessVertex,3> patch ,uint id:SV_OUTPUTCONTROLPOINTID){
    return patch[id];
}

在这个shader中我们大部分的内容都是在配置,而计算的部分较少,前面的几项配置很好理解,参数选择各位可以去网上搜索,这里不再过多叙述,这里要讲的是第四个配置项:**[UNITY_patchconstantfunc(“hsconst”)]**,它指定了一个方法,用于决定怎么计算细分线段。代码如下

OutputPatchConstant hsconst(){
    OutputPatchConstant o;
    o.edgb[0] = _TessellationUniform;
    o.edgb[1] = _TessellationUniform;
    o.edgb[2] = _TessellationUniform;
    o.inside = _TessellationUniform;
    return o;
}

参数**_TessellationUniform一个float类型的变量,用于控制细分程度。其中可选的参数有两个,分别是InputPatch<TessVertex,3> patch** 和 uint patchID:SV_PrimitiveID,前者是一个Patch,参数TessVertex是我们顶点着色器的输出,3是因为一个三角形有3个顶点,我们可以利用这个变量来控制不同的切分因子,从而实现一些LOD的效果。后者是这个patch的唯一ID。

让我们接着往下看,第六个是**UNITY_outputcontrolpoints(3)**。它是指定了我们每个图元输出的控制点的数量,不一定与输入的数量相同?

最后便是我们的函数主题,它返回值是前面定义好了的顶点数据,而他的输入参数则需要细说虽然我也不会,首先是TessVertex,也就是输入的顶点信息,3则是一个patch,前面说了一个patch可以是三角形,四边形,线段,而我们前面又设置为了三角形,一个三角形有3个顶点嘛,所以这里便写3,而后一个参数uint id:SV_OUTPUTCONTROLPOINTID则是告知我们当前执行的顶点是patch的哪一个,因为该函数一次仅应输出一个顶点。Patch中的每个顶点都会调用一次它。在这里我们也可以写第三个参数 uint patchID:SV_PrimitiveID从而获得patch的id。

接下来我们来实现细分计算着色器的内容

[UNITY_domain("tri")]
VertexOutput ds(OutputPatchConstant trssFactors,const OutputPatch<TessVertex,3> patch,float3 bary:SV_DOMAINLOCATION){
//bary 重心坐标
    VertexInput 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.uv = patch[0].uv*bary.x + patch[1].uv*bary.y + patch[2].uv*bary.z;
        
    VertexOutput o = vert (v);
    return o;
}

首先是第一行设置图元类型,不讲了,然后函数的主题,里面包含了顶点信息。输入的参数有细分参数trssFactors(就前面算过的那个)。第二个是patch不多说了,第三个参数bary则是重心空间(以重心为原点的坐标系)下的系数。用该系数与重心坐标还原到模型空间,相关的信息可以去看初中数学关于重心的解释。总之我们可以通过系数来获得一个顶点在原始三角形的相对位置。
最后我们将所有的顶点都转化到投影空间,vert函数如下

VertexOutput vert(VertexInput i){
    VertexOutput o;
    o.vertex = UnityObjectToClipPos(i.vertex);
    o.uv = i.uv;
    o.normal =i.normal;
    o.tangent = i.tangent;
    return o;
}

最终效果如下,完整代码可以再参考资料第二个中找到

总结:我们首先是在曲面计算着色器中来配置相关的信息。通过输入的原始面片,我们可以计算出在这个面片内所细分出来的顶点的重心坐标,然后在细分着色器中来具体的计算出它的值。曲面细分着色器现在常用于处理LOD,原理则是在不同的情况下选择不同的细分因子,具体可以从Unity的官方例子中和参考资料中找到。

曲面细分参考

Unity渲染基础:URP下曲面细分的实现

曲面细分着色器

Shader学习_曲面细分着色器

曲面细分着色器(翻译版本)

几何着色器介绍

如果说曲面细分着色器是在已有的基础上依靠规则增加顶点,那我们的几何着色器就是无中生有,在已有的定点上来生成新的顶点,而且可以生成不止一个,十分的厉害。

现在让我们来看看一个最简单的几何着色器长什么样

#pragma geometry geom
[maxvertexcount(4)] 
void geom(point v2g input[1],inout PointStream<g2f> outStream){ 
    g2f o=(g2f)0; 
    o.vertex=input[0].vertex; 
    o.uv=input[0].uv;   
    outStream.Append(o); 
}

第一行定义函数,从第二行才是真正的属于几何着色器部分。他定义了我们单次调用的最大可以生成多少个顶点,我们在后面的具体代码中添加到流中的顶点数量不能超过这个。处于性能考虑,这个最大定点数应该尽可能小。第三行是我们的函数主体,他不需要有返回值,而是直接把返回值写入到流中,也就是PointStream。而输入的参数则是v2g类型,在v2g类型定义的前面则是我们输入的图元类型。它可以是点,线或者是面,每一个图元都会调用一次函数的具体内容。我们可以通过变量input来访问到该图元的具体顶点。

与前面的类型相同,我们输出流的类型也是分为了点,线,面。他们的类型分别为PointStream,LineStream,TriangleSteam三种。 我们只需要定义好顶点的位置然后输入到流中即可。需要注意的是,几何着色器不会保留原来的信息,所有的输出都要我们自己来重新定义

上面的代码就是将原本模型都只输出成一个点,如下图

在参考资料中我们可以找到Line版本和Triangle版本。

几何着色器作用

因为几何着色器具有更改显示的原理,因此我们可以利用其来做一些特效,比如一些消散效果,就是通过判断点的坐标值来选择性的将他们变成点或者是面,从而达到了一种自然的消散效果。
其他人的效果图
也可以通过一些相关的信息,比如顶点法线方向,来构建一些我们需要的效果,比如一个会缩放的 “点” 球。这些特效本文也不给出代码了(因为我自己都没写)。

我们也可以通过与曲面细分着色器相结合来生成GPU版本的草地,虽然据说效率不如其他的办法,且适用性比较低(在手机端几何着色器不常用)。但是可定制化十分的高。我们也是通过这个例子来具体讲解几何着色器的使用,代码中关于一些结构体的传递,里面都基本上都说只有uv和vertex数据,因此就不放出来了,只放关键代码

[maxvertexcount(3)]
void geom(point t2g input[1],inout TriangleStream<t2g> outstream){
    float3 pos = input[0].vertex;
    float3x3 facingRotationMatrix = AngleAxis3X3(rand(pos) * UNITY_TWO_PI,float3(0,1,0));
    float3x3 bendRotationMatrix = AngleAxis3X3(rand(pos.zzx) *  _BendRotationRandom* UNITY_PI * 0.5f,float3(0,0.2,0.5));

    float3x3 transform = mul(facingRotationMatrix,bendRotationMatrix);

    float height = (rand(pos.zyx) * 2 - 1) * _BladeHeightRandom + _BladeHeight;
    float width = (rand(pos.xzy) * 2 - 1) * _BladeWidthRandom + _BladeWidth;

    outstream.Append(vertexOutput(pos + mul(transform,float3(width,0,0)),float2(0.0,0.0)));
    outstream.Append(vertexOutput(pos + mul(transform,float3(-width,0,0)),float2(1.0,0.0)));
    outstream.Append(vertexOutput(pos + mul(transform,float3(0,height,0)),float2(0.5,1.0)));           
}

关于AngleAxis3x3,他随机返回一个旋转矩阵用于控制草的弯曲和旋转。其中我们的代码中facingRotationMatrix负责草的旋转,而bendRotationMatrix负责草的弯曲,
后面的代码中我们在面片的第一个顶点上生成了一个三角形,并重新赋予了uv值,达到让每个草的uv独立,而不是整个草地用一套uv值。

然后便是添加流中,这里的添加顺序也要注意,只有顺时针的时候才能被正常显示出来,而我在关闭了剔除,所以这里的顺序不用关心。

一个草地的核心代码也就是如此的简单,我们利用曲面细分来生成更多的顶点,然后用几何着色器在顶点上生成一个三角形。至于代码的融合就交给各位读者来自己尝试一下了。最终生成的草地效果如下

我们的草地还有许多的不足,比如没有与物体的交互,无法接收阴影,弯曲效果太差,没有随风摇晃的效果(等我实现了我会回来补充)。这些效果仍然需要我去慢慢的学习,学习永无止境,我们不能因为短时间没有效果便选择放弃。愿,在以后的学习之路我们可以越走越远。

几何着色器参考

点线面的构建

利用几何着色器和曲面细分着色器实现一个草地

知乎文章《几何着色器(Geometry Shader)的基础介绍(基于DirectX》