GeometryShader这个概念,已经出现很久了,但由于性能不佳,所以使用的并不多。甚至移动平台根本就不支持。移动平台的硬件更新速度也是越来越快,GS的应用普及应该不会太远。就现阶段而言,GS来做一些辅助效果也是有一定用武之地的。就像本文要提到的这个线框渲染的效果(如下图)。在Unity编辑模式中,偶尔有时候希望能有这种效果, 我在AssetStore里找到了一个叫UCLA Wireframe Shader的资源,里面有Shader源码。发现它是利用GS来实现的,本文就以它的源码为例来说明一下它是如何利用GeometryShader来实现这种线框渲染效果(WireFrame)的。
概念解释
在具体看代码之前,需要先对几何着色器阶段有个初步了解,大概需要知道需要下面几两个概念:
1.图元(graphics primitive)
几乎所有的图形渲染入门书籍里,都要提到这个概念,我们知道,所有的几何模型都是有点,线,三角形等基本单元组成的(这里以三角形为例),每个图元又是由若干个顶点构成。在渲染管线的开始,GPU处理的是每一个顶点,但是GPU是知道每一个顶点是属于哪个三角形的。所有顶点经过顶点着色器处理后输出的结果会经过一个图元装配(Primitive Assembly)的阶段,这个阶段就是把这些处理后的顶点组装成成一个个三角形。为什么这么做呢?因为之后的无论是光栅化和顶点信息插值过程,以及视椎体的裁剪,都是以图元为单位进行的(如果你对这个过程不是非常了解,可以查查资料,或者去看一下刘鹏翻译的《计算机图形学—基于3D图形开发技术》),经过上述的这些阶段后再到达我们熟悉的片元着色阶段,也就离最终渲染结果不远了。
2.几何着色器(Geometry Shader)
对于VS,FS我们都比较熟悉,那GS出现在哪呢?从下面这种图中我们可以看到GS是位于VS和FS之间的。并且是虚线连接,即是可选的。GeometryShader所接收的实际是对VS输出的图元进行添加,删除,或修改,然后输出新的图元信息。再之后的流程就和之前的一样了。
进行线框渲染,一个比较困扰的地方就是我们不知道一个顶点是属于哪一个图元的。但是有了GS的参与之后,这一切就迎刃而解了。后面解释代码时会具体说。
代码解释
Shader "UCLA Game Lab/Wireframe/Single-Sided"
{
Properties
{
_Color ("Line Color", Color) = (,,,)
_MainTex ("Main Texture", 2D) = "white" {}
_Thickness ("Thickness", Float) =
} SubShader
{
Pass
{
Tags { "RenderType"="Transparent" "Queue"="Transparent" } Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off
LOD CGPROGRAM
#pragma target 5.0
#include "UnityCG.cginc"
#include "UCLA GameLab Wireframe Functions.cginc"
#pragma vertex vert
#pragma fragment frag
#pragma geometry geom // Vertex Shader
UCLAGL_v2g vert(appdata_base v)
{
return UCLAGL_vert(v);
} // Geometry Shader
[maxvertexcount()]
void geom(triangle UCLAGL_v2g p[], inout TriangleStream<UCLAGL_g2f> triStream)
{
UCLAGL_geom( p, triStream);
} // Fragment Shader
float4 frag(UCLAGL_g2f input) : COLOR
{
return UCLAGL_frag(input);
} ENDCG
}
}
}
可以看到这个文件里调用了很多定义在"UCLA GameLab Wireframe Functions.cginc"这个文件中的函数。后面用到时候再看。
第26行:指定了用于几何着色器执行的函数。与vs,fs一样。另外21行中指定了要是用的ShaderModel(SM定义了着色器代码的一些规范和能力),这里必须是4.0以上的版本。
29~32行:vs的代码没有什么特殊的,下面贴出的UCLAGL_vert和UCLAGL_v2g的代码也没啥不一样的。唯一值得提一下就是UCLAGL_v2g中pos后面的语意是POSITION而非SV_POSITION,我试了一下这样没啥问题。可以看出vs的代码和以前的意义,进行完顶点处理之后,GPU会进行图元装备,接下来就进入到GS阶段了。
// DATA STRUCTURES //
// Vertex to Geometry
struct UCLAGL_v2g
{
float4 pos : POSITION; // vertex position
float2 uv : TEXCOORD0; // vertex uv coordinate
};
// Vertex Shader
UCLAGL_v2g UCLAGL_vert(appdata_base v)
{
UCLAGL_v2g output;
output.pos = mul(UNITY_MATRIX_MVP, v.vertex);
output.uv = TRANSFORM_TEX (v.texcoord, _MainTex);//v.texcoord; return output;
}
35~39行:这段就是GS的执行函数了,其中第35行[maxvertexcount(3)]是用来限制GS输出的最大顶点数,这里必须理解清楚,前面说过GS可以对输入的图元进行删除,添加,修改,也就是进来一个图元,可能输出0~n个图元,不论图元是以何种形式组织的,它都是由顶点构成的,这个maxvertexcount就是用来限定这个顶点数量的,记住它只是限定最大数量,也就是你提供小于等于这个数量的顶点就可以。
36行:估计你第一次看到这行代码的时候应该和我一样感到奇怪。这怎么还有点模板的意思,还有刚才的那个[maxvertexcount],这个语法看上去有点C#的Attribute的意思啊。平常写的UnityShader不是说CG语言(C for Graphics),这可和C不太一样啊。其实Unity的Shader代码是基于自己的ShaderLab结构的,他用的CG也并不是和Nvidia的CG一模一样。我查了下OpenGL和官方CG的GS用法,大家的意思都是差不多,但是语法细节上是不一样的。无论怎么样最终Unity会负责对ShaderLab进行编译,转化成对于平台的GLSL或者HLSL语言。
回到这个函数声明,第一个参数triangle UCLAGL_v2g p[3],GS接收的是图元,那这个图元是以什么样的形式传递进来的呢?就是以顶点结构数组的形式,比如一个三角形图元由三个顶点构成,那么数组大小就是3,相应的点和线就是1和2。这个参数最前面的triangle就是用来表示这个的,还有两个就是point和line。要记住这个标识符必须和后面数组大小相配。还有一点就是你填写的图元标识符类型和Unity原始模型资源的顶点组织方式不要求一定匹配,比如Unity默认组织模型资源是三角图元的,你在这里可以用point接收,但这样的结果就是本来这个图元有三个顶点,但是你只能接收到第一个了。
第二个参数inout TriangleStream<UCLAGL_g2f> triStream,这里的inout就和C#的inout是一样的,CG本身就有这个关键字。而TriangleStream决定了输出的图元是三角形图元。对应的还有LineStream和PointStream。UCLAGL_g2f的内容如下:
// Geometry to UCLAGL_fragment
struct UCLAGL_g2f
{
float4 pos : POSITION; // fragment position
float2 uv : TEXCOORD0; // fragment uv coordinate
float3 dist : TEXCOORD1; // distance to each edge of the triangle
};
这个结构定义了构成GS输出图元的顶点结构。这里有个dist,后面再解释。可以想象,GS就是把第一个参数的信息拿过来经过处理后把结果填充到第二个参数中去。需要额外说明一下,这里的Stream类型和上面的maxvertexcount是有一些关联的,上面代码输出图元类型是三角形,构成三角形最少需要三个顶点,如果我们最终在GS中像triStream提供了小于三个顶点,则GS将放弃这个片元,当然你也可以提供多余三个顶点,但是超过了maxvertexcount的部分也会被抛弃掉。总之他们两个是有联系的,这里只是提一下,使用时候要注意。
现在来看一下UCLAGL_geom函数,也是这个Shader的核心部分。
// Geometry Shader
[maxvertexcount()]
void UCLAGL_geom(triangle UCLAGL_v2g p[], inout TriangleStream<UCLAGL_g2f> triStream)
{
//points in screen space
float2 p0 = _ScreenParams.xy * p[].pos.xy / p[].pos.w;
float2 p1 = _ScreenParams.xy * p[].pos.xy / p[].pos.w;
float2 p2 = _ScreenParams.xy * p[].pos.xy / p[].pos.w; //edge vectors
float2 v0 = p2 - p1;
float2 v1 = p2 - p0;
float2 v2 = p1 - p0; //area of the triangle
float area = abs(v1.x*v2.y - v1.y * v2.x); //values based on distance to the edges
float dist0 = area / length(v0);
float dist1 = area / length(v1);
float dist2 = area / length(v2); UCLAGL_g2f pIn; //add the first point
pIn.pos = p[].pos;
pIn.uv = p[].uv;
pIn.dist = float3(dist0,,);
triStream.Append(pIn); //add the second point
pIn.pos = p[].pos;
pIn.uv = p[].uv;
pIn.dist = float3(,dist1,);
triStream.Append(pIn); //add the third point
pIn.pos = p[].pos;
pIn.uv = p[].uv;
pIn.dist = float3(,,dist2);
triStream.Append(pIn);
}
先说一下开头处,p0,p1,p2处的计算,因为GS接收的顶点信息都是VS处理过的,在VS中顶点输出的pos信息已经是其在投影空间下的坐标了。现在p[x].pos.xy/p[x].ps.w实际就是手动进行透视除法,得到视口坐标。_ScreenParams.xy是一个Unity为我们提供的内置变量,表示屏幕的分辨率。那么这两者相乘就得到了p[x]在屏幕空间的坐标(单位像素)。这里分别对当前三角图元的三个顶点进行了计算。
再分别讲顶点两两相减,得到三角形的三个边向量。之后利用叉积的几何意义得到三角形的面积,这里实际是平行四边形面积,注意这里用的是v1和v2,思考一下为什么。得到面积后,利用四边形面积公式(底边长X高)来得到当前顶点的对角边的距离。
计算出这三个距离之后就可以进行输出了。前面我们提到UCLAGL_g2f结构中有一个dist,现在可以解释一下了。他是个float3类型,他的xyz分量分别代表了当前顶点到达三角图元三边的距离,你可能会奇怪为什么要用float3,明明一个顶点到其中两条边的距离都是0,之后另外一条边才不是0。要知道我们GS最后输出的依然是图元,还没有进行光栅化插值。最终我们要进行渲染的是片元,这些片元可不一定是正好在图元的三个顶点上。所以用float3是为了能够正确的插值,将来光栅化时候能得到片元距离图元三边的距离。进行输出时候先定义了一个UCLAGL_g2f类型的pIn变量。可以看到后面赋值就没什么说的,注意dist的赋值。每当构造完一个UCLAGL_g2f变量以后,就调用Append方法把它添加到输出结构中。
可见这里的GS并没有删除或者添加图元,它只是对输入图元进行修改再输出。进来的是三角片元的三个顶点,出去的还是三角片元三个顶点。但是经过GS处理,现在每个顶点都知道他距离他所在的三角图元三条边的距离了。正式这个值让我们在fs中能完成线框渲染的效果。
第42~45行:fs我们主要看UCLAGL_frag这个函数,代码如下:
// Fragment Shader
float4 UCLAGL_frag(UCLAGL_g2f input) : COLOR
{
//find the smallest distance
float val = min( input.dist.x, min( input.dist.y, input.dist.z)); //calculate power to 2 to thin the line
val = exp2( -/_Thickness * val * val ); //blend between the lines and the negative space to give illusion of anti aliasing
float4 targetColor = _Color * tex2D( _MainTex, input.uv);
float4 transCol = _Color * tex2D( _MainTex, input.uv);
transCol.a = ;
return val * targetColor+ ( - val ) * transCol;
}
要知道现在input参数里的所有信息,都是经过插值了,它代表的是片元,而不是顶点了。先选出该片元到其所在三角图元三边的最短距离val.看最后的几行代码,可以知道是利用一个混合因子来进行blend,来达到距离图元边越近的片元,可见程度越高。那么混合因子值的计算就决定了后面这个融合步骤的好坏。如果混合因子选取的不好,最终线框渲染的效果就不好。
最后来看看混合因子的计算:val = exp2( -1/_Thickness * val * val );这行代码可以从最外层开始理解,exp2是CG的内置函数,代表2位底的指数函数。你去看看指数函数的图像就知道,当x小于0的时候y值是在0~1区间内的。而且不会为负。而我们的混合因子也是要在0~1区间的。上式括号中的-1就决定了exp2的参数一定为负,因为_Thickness是大于等于0,val的平方也是大于等于0的。那为什么要用val的平方的。看一下y=x^2的函数图像。val是距离,他是大于等于0的,那么随着x的增大,y的增大幅度越来越大(y的导函数是个上升函数),也就是说随着val的增大val*val的取值跨度越来越大。你会问,这又有什么用呢?你现在把两个函数图像结合起来,先不看_Thickness,得到下图3.
从图像上看出当x在0~2范围内,函数曲线急剧下降。并在之后无限趋近于0。这样计算后的混合因子大概只在片元距离三角图元边线0~2个像素的距离内,可见性才明显一些,这样就能画出比较明显的边线效果。 之所以又加上了_Thickness,可以看到把曲线的最终结果除以一个正数,这个数越大,那么就会越明显的减缓图像三曲线的下降效果,也就是图像不会太快的趋近于0,那么新的混合因子在表现的时候边线显得更宽了。
总结
这个方法只是视觉上达到了WireFrame的效果,实际上还是以三角图元的方式来渲染的,只是利用透明度的混合来达到效果。如果真正的想要按照线框模式来渲染,应该修改GS让他输出的图元是LineStream。也就完全不需要像这种方法计算这么多中间变量了。如果感兴趣,不妨写写试试。只是想透过这个例子来说明一下Unity中GeometryShader的基本原理和语法。
貌似Unity的GS好像还只能在DX11API上用,OpenGL上还不行。OpenGL ES目前还不支持GeometryShader。
利用GS确实能做出很多以前做不出或者很难做出的效果,其中一个关键点就是顶点能知道它所在图元的一些信息,这是以前的VS-FS结构做不到的。