本文继续对《UnityShader入门精要》——冯乐乐 第五章第三节进行学习
一、强大的援手:Unity提供的内置文件和变量
上面,我们讲述了如何在Unity中编写一个基本的顶点/片元着色器的过程。顶点/片元着色器的复杂之处在于,很多事情都要我们“亲力亲为”,例如我们需要自己转换法线方向,自己处理光照、阴影等。为了方便开发者的编码过程,Unity提供了很多内置文件,这些文件包含了很多提前预定义的函数、变量和宏等。如果读者在学习其它人编写的UnityShader代码时,遇到了一些从未见到的变量、函数,而又无法找到对应的声明和定义,那么很有可能就是这些代码使用了Unity内置文件提供的函数和变量。
1.内置的包含文件
包含文件(include file),是类似于c++中头文件的一种文件。在Unity中,它们的文件后缀是.cginc。在编写Shader时,我们可以使用#include指令把这些文件包含进来,这样我们就可以使用Unity为我们提供的一些非常有用的变量和帮助函数,例如:
CGPROGRAM
//......
#include "UnityCG.cgnic"
//......
ENDCG
按照书上所说,这些文件可以在http://unity3d.com/cn/get-unity/download/archive进行下载,不过已经打不开了……现在可以去https://unity.cn/releases根据版本进行下载:
解压之后是这样的:
- CGIncludes文件夹中包含了所有的内置文件;
- DefaultResources文件夹中包含了一些内置组件或功能所需要的Unity Shader,例如一些GUI元素使用的Shader;
- DefaultResourcesExtra则包含了所有Unity中内置的Unity Shader;
- Editor文件夹目前只包含了一个脚本文件,它用于定义Unity5引入的StandardShader所使用的的材质面板。
我们也可以从Unity的应用程序中直接找到CGIncludes文件夹。它的位置是:Unity的安装路径/Data/CGIncludes。
这些文件都是非常好的参考资料,在我们想要学习内置着色器的实现或是寻找内置函数的实现时,都可以在这里找到内部实现。
2.CGIncludes文件夹中的主要文件
- UnityCG.cginc 包含最常用的帮助函数、宏和结构体
- UnityShaderVariables.cginc 在编译Shader时,会被自动包含进来,包含了许多内置的全局变量,如UNITY_MATRIX_MVP
- Ligghting.cginc 包含了各种内置光照模型,如果编写SurfaceShader的话,会被自动包含进来
- HLSLSurport.cginc 在编译Shader时,会被自动包含进来,声明了很多跨平台编译的宏和定义
3.UnityCG.cginc常用结构体
- appdata_base 用于顶点着色器输入 顶点位置、顶点法线、第一组纹理坐标
- appdata_tan 用于顶点着色器输入 顶点位置、顶点切线、顶点法线、第一组纹理坐标
- appdata_full 用于顶点着色器输入 顶点位置、顶点切线、顶点法线、四组(或更多)纹理坐标
- appdata_img 用于顶点着色器输入 顶点位置、第一组纹理坐标
- v2f_img 用于顶点着色器输出 裁剪空间中的位置、纹理坐标
struct appdata_base {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct appdata_tan {
float4 vertex : POSITION;
float4 tangent : TANGENT;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct appdata_full {
float4 vertex : POSITION;
float4 tangent : TANGENT;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
float4 texcoord1 : TEXCOORD1;
float4 texcoord2 : TEXCOORD2;
float4 texcoord3 : TEXCOORD3;
fixed4 color : COLOR;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct appdata_img
{
float4 vertex : POSITION;
half2 texcoord : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct v2f_img
{
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
3.UnityCG.cginc常用帮助函数
- float3 WorldSpaceViewDir(float4 v) 输入一个模型顶点坐标,得到世界空间中从该点到摄像机的观察方向
- float3 ObjSpaceViewDir(float4 v) 输入一个模型顶点坐标,得到模型空间中从该点到摄像机的观察方向
- float3 WorldSpaceLightDir(float4 v) 输入一个模型顶点坐标,得到世界空间中从该点到光源的光照方向(方向没有归一化,且只可用于前向渲染)
- float3 ObjSpaceLightDir(float4 v) 输入一个模型顶点坐标,得到模型空间中从该点到光源的光照方向(方向没有归一化,且只可用于前向渲染)
- float3 UnityObjectToWorldNormal(float3 norm) 将法线从模型空间转换到世界空间
- float3 UnityObjectToWorldDir(in float3 dir) 把方向矢量从模型空间转换到世界空间
- float3 UnityWorldToObjectDir(float3 dir) 把方向矢量从世界空间转换到模型空间
4.UnityShaderVariables.cginc
这一部分位于书中第四章4.8节,介绍了关于坐标变换和摄像机参数的内置变量。
- UNITY_MATRIX_MVP 当前的模型观察投影矩阵,用于将顶点/方向矢量从模型空间变换到裁剪空间
- UNITY_MATRIX_MV 当前的模型观察矩阵,用于将顶点/方向矢量从模型空间变换到观察空间
- UNITY_MATRIX_V 当前的观察矩阵,用于将顶点/方向矢量从世界空间变换到观察空间
- UNITY_MATRIX_P 当前的投影矩阵,用于将顶点/方向矢量从观察空间变换到裁剪空间
- UNITY_MATRIX_VP 当前的观察投影矩阵,用于将顶点/方向矢量从世界空间变换到裁剪空间
- UNITY_MATRIX_T_MV UNITY_MATRIX_MV的转置矩阵
- UNITY_MATRIX_IT_MV UNITY_MATRIX_MV的逆转置矩阵,用于将法线从模型空间变换到观察空间,也可用于得到UNITY_MATRIX_MV的逆矩阵
- _Object2World 当前的模型矩阵,用于将顶点/方向矢量从模型空间变换到世界空间
- _World2Object _Object2World的逆矩阵,用于将顶点/方向矢量从世界空间变换到模型空间
二、Cg中的矢量和矩阵类型
在看4.8节和4.7节之前,我觉得要先对4.9.2节仔细研究一下。
1.行优先还是列优先
在Cg中,对float4×4等类型的变量是按行优先的方式进行填充的。什么意思呢?
我们知道要填充一个矩阵需要给定一串数字,例如要声明一个3×4矩阵,我们需要提供12个数字。那么这一串矩阵是一行一行地填充矩阵还是一列一列地填充矩阵呢?这两种方式得到的矩阵是不同的。
例如我们使用(1,2,3,4,5,6,7,8,9)去填充一个3×3矩阵,如果是按行优先的方式,得到的矩阵是:
如果是按照列优先的方式,得到的矩阵是:
Cg使用的是行优先的方法,既是一行一行的填充矩阵的。因此,如果读者需要自己定义一个矩阵时(例如,自己构建用于空间变换的矩阵),就要注意这里的初始化方式。
类似地,当我们在Cg中访问一个矩阵中的元素时,也是按行来索引的。例如:
// 按行优先的方式初始化矩阵M
float3×3 M = float3×3(1.0,2.0,3.0,
4.0,5.0,6.0,
7.0,8.0,9.0);
//得到M的第一行,即(1.0,2.0,3.0)
float3 row = M[0];
// 得到M的第二行第一列的元素,即4.0
float ele = M[1][0]
之所以Unity Shader中的矩阵满足上述规则,是因为使用的是Cg语言。换句话说,上面的特性都是Cg的规定。
如果读者熟悉Unity的API,可能知道Unity在脚本中提供了一种矩阵类型——Matrix4×4。脚本中这个矩阵的类型则是采用列优先的方式。这与Unity Shader中的规定不一样,希望读者在遇到时不会困惑。
在看冯乐乐书中关于multi部分时,我有疑惑。所以我从网上找到了别的资料,以下为部分内容:
DirectX11--HLSL中矩阵的内存布局和mul函数探讨
HLSL中的MUL指令深层剖析
不过我只能看懂前面的,后面关于HLSL的解释,看不懂
2.矩阵左乘与右乘
由于矩阵乘法不满足交换律,则需要区分当前矩阵位于乘号的左边还是右边。有时候经常都会听到左乘和右乘这两个概念,下面是有关它们的含义:
- 左乘指的是该矩阵位于乘号的左边,例如:行向量 左乘 矩阵,即行向量在乘号的左边
- 右乘指的是该矩阵位于乘号的右边,例如:列向量 右乘 矩阵,即列向量在乘号的右边
ps. 向量也是矩阵
行向量v和矩阵M满足下面的关系:
3.HLSL中的mul函数
微软的官方文档是这么描述mul函数的(微软官方文档链接),这里进行个人翻译:
使用矩阵数学来进行矩阵x左乘矩阵y的运算,要求矩阵x的列数与矩阵y的行数相等。
如果x是一个向量,那么它将被解释为行向量。
如果y是一个向量,那么它将被解释为列向量。
表面上看起来很美满,很智能,但稍有不慎就要在这里踩大坑了。
4.dp4指令
dp4是一个汇编指令(微软官方文档链接),使用方法如下:
dp4 dst, src0, src1
其中 src0和src1是一个向量,计算它们的点乘并将结果传给dst。
5.冯乐乐写的没看懂的部分,但是我可以直接记住结论拿去用啊……
在Cg中,矩阵类型是由float3×3、float4×4等关键词进行声明和定义的。而对于float3、float4等类型的变量,我们既可以把它当成一个矢量,也可以把它当成是一个1×n的行矩阵或一个n×1的列矩阵。这取决于运算的种类和它们在运算中的位置。例如当我们进行点积操作时,两个操作数就被当成矢量类型,如下:
float4 a = float4(1.0,2.0,3.0,4.0)
float4 b = float4(1.0,2.0,3.0,4.0)
//对两个矢量进行点积操作
float result = dot(a,b)
在进行矩阵乘法时,参数的位置将决定是按列矩阵还是行矩阵进行乘法。在Cg中,矩阵乘法是通过mul函数实现的。例如:
float4 v = float4(1.0,2.0,3.0,4.0)
float4×4 M = float4×4(1.0,0.0,0.0,0.0,
0.0,2.0,0.0,0.0,
0.0,0.0,3.0,0.0
0.0,0.0,0.0,4.0);
// 把v当成列矩阵和矩阵M进行右乘
float4 column_mul_result = mul(M,v);
//把v当成行矩阵和矩阵M进行左乘
float4 row_mul_result = mul(v,M);
//注意:column_mul_result不等于row_mul_result,而是:
//mul(M,v) == mul(v,tranpose(M))
//mul(v,M) == mul(tranpose(M),v)
因此参数的位置会直接影响结果值。通常在变换顶点时,我们都是使用右乘的方式来按列矩阵进行乘法。这是因为,Unity提供的内置矩阵(如UNITY_MATRIX_MVP等)都是按列存储的,但有时,我们也会使用左乘的方式,这是因为可以省去对矩阵转置的操作。
这里我没看懂的就是为什么mul(M,v) == mul(v,tranpose(M)),谁能给我推导一下呀。我就记住结论先用着吧,大部分情况下,都是把内置的变换矩阵比如UNITY_MATRIX_MVP放mul参数的左边,比如o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
。但是如果有逆矩阵,也可以当作转置矩阵用的时候,就可以放mul参数的右边。在第六章的第一个示例中,就出现了这种写法:fixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
三、法线变换
现在来看4.7节:
假设,我们使用3×3变换矩阵Ma->b来变换顶点(注意,这里涉及到的变换矩阵都是3×3矩阵,不考虑平移变换,这因为切线和法线都是矢量,不会受平移影响),可以由下面的式子直接得到变换后的切线:
其中Ta和Tb分别表示在空间坐标A下和坐标空间B下的切线方向。但如果直接使用Ma->b来变换法线,得到的新的法线可能就不会与表面垂直了,下图给出了这样的一个例子:
那么,我们应该用哪个矩阵来变换法线呢?我们可以由数学约束条件来推出这个矩阵。我们知道一个顶点的切线Ta和法线Na必须满足垂直条件,即Ta·Na=0。给定变换矩阵Ma->b,我们已经知道Tb=Ma->bTa。我们现在想要找到一个矩阵G来变换法线Na,使得变换后的法线依然与切线垂直。即
这个画红线的地方,我是不能理解的。后来搜索到法线变换推导,得到了解答:
看了冯女神的解答就清楚了4.7 法线变换的疑惑 #17
原话:“第一个公式是矢量点乘,可以注意到中间有个黑点。然后为了推导到矩阵里面,因此把矢量运算替换成了等价的矩阵乘法,因为默认矢量可以当成是列矩阵,所以第二个公式就是把它们当成列矩阵进行矩阵乘法。转置是因为矩阵乘法有行列规则,不转置的话这个乘法是没法进行的,不符合矩阵乘法定义。你可以在纸上画一画,第二个那个转置矩阵乘法后也是一个标量,和第一个矢量点乘的结果是相同的。”
理解:点乘得到的是一个数,那么如果矩阵相乘也想得到一个数怎么做?
如果
等式就成立,所以满足以下条件时,变换后的法线仍然与切线垂直:
即使用原变换矩阵的逆转置矩阵来变换法线就可以得到正确的结果。
值得注意的是,如果变换矩阵Ma->b是正交矩阵,那么
因此
也就是说我们可以使用用于变换顶点的变换矩阵来直接变换法线。如果变换只包括旋转变换,那么这个变换矩阵就是正交矩阵。而如果变换只包含旋转和统一缩放,而不包含非统一缩放,我们利用统一缩放系数k来得到变换矩阵Ma->b的逆转置矩阵
这样就可以避免计算逆矩阵的过程。而如果变换中包含了非统一的变换,那么我们就必须要求解逆矩阵来得到变换法线的矩阵。
四、UnityShaderVariables内置矩阵
现在来看4.8节
1.UNITY_MATRIX_T_MV矩阵
UNITY_MATRIX_MV的转置矩阵
很多对数学不了解的读者不理解这个矩阵有什么用处?我们应该知道一种非常吸引人的矩阵类型——正交矩阵。对于正交矩阵来说,它的逆矩阵就是转置矩阵。
因此如果说UNITY_MATRIX_MV是一个正交矩阵的话,那么UNITY_MATRIX_T_MV就是它的逆矩阵,也即是说,我们可以使用UNITY_MATRIX_T_MV把顶点和方向矢量从观察空间变换到模型空间。
从前面的知识来讲,观察空间即以摄像机为原点的空间。这里我是有疑问的,什么时候需要从观察空间变换到模型空间呢?
那么问题是,UNITY_MATRIX_MV什么时候是一个正交矩阵呢?总结一下,如果我们只考虑旋转、平移、缩放这3种类型变换的话,如果一个模型只包括旋转,那么UNITY_MATRIX_MV就是一个正交矩阵。这个条件似乎有些苛刻,我们可以把条件在放宽一些,如果只包括旋转和统一缩放(假设缩放系数是k),那么UNITY_MATRIX_MV就几乎是一个正交矩阵了。为什么是几乎呢?因为统一缩放可能会导致每一行(或每一列)的矢量长度不为1,而是k,这不符合正交矩阵的特性,但我们可以通过除以这个统一缩放系数,来把它变成正交矩阵,在这种情况下,UNITY_MATRIX_MV的逆矩阵就是
而且,如果我们只是对方向矢量进行变换的话,条件可以放的更宽,即不用考虑有没有平移变换,因为平移对方向矢量没有影响。因此我们可以截取UNITY_MATRIX_IT_MV矩阵的前3行前3列来把方向矢量从观察空间变换到模型空间(前提是只存在旋转变换和统一缩放)。对于方向矢量,我们可以在使用前对它们进行归一化处理,来消除统一缩放的影响。
2.UNITY_MATRIX_IT_MV矩阵
UNITY_MATRIX_MV的逆转置矩阵,用于将法线从模型空间变换到观察空间,也可用于得到UNITY_MATRIX_MV的逆矩阵
我们在前面已经知道,法线的变换需要使用原变换矩阵的逆矩阵。
因此UNITY_MATRIX_IT_MV可以把法线从模型空间变换到观察空间。
但只要我们做一点手脚,它也可以用于直接得到UNITY_MATRIX_MV的逆矩阵——我们只需要对它进行转置就可以了。因此为了把顶点或方向矢量从观察空间变换到模型空间,我们可以使用类似下面的代码:
//方法一:使用transpose函数对UNITY_MATRIX_IT_MV进行计算
//得到UNITY_MATRIX_MV的逆矩阵,然后进行列矩阵乘法,
//把观察空间中的点或方向矢量变换到模型空间中
float4 modelPos = mul(transpose(UNITY_MATRIX_IT_MV),viewPos);
//方法二:不直接使用转置函数transpose,而是交换mul参数的位置,使用行矩阵乘法
//本质和方法一是完全一样的
float 4 modelPos = mul(viewPos, UNITY_MATRIX_IT_MV)
上面是冯乐乐原文,下面是我的个人理解。这段代码的目标是把观察空间中的点或方向矢量变换到模型空间。
UNITY_MATRIX_MV: 当前的模型观察矩阵,用于将顶点/方向矢量从模型空间变换到观察空间
UNITY_MATRIX_T_MV: UNITY_MATRIX_MV的转置矩阵
为了这个目标,用UNITY_MATRIX_T_MV它不香嘛?干嘛要动手脚这么费解呢……我只能解释为,呃,她在炫技。。。。
然后UNITY_MATRIX_IT_MV是逆转置矩阵,transpose(UNITY_MATRIX_IT_MV)
就是转置的转置等于没转,所以现在只剩下逆效果了。那viewPos用这个逆效果变换一下,自然得到了modelPos。
方式二,原理就是上面说的mul部分,本来那部分我也没看懂
//mul(M,v) == mul(v,tranpose(M))
//mul(v,M) == mul(tranpose(M),v)
五、UnityShaderVariables内置摄像机和屏幕参数
Unity提供了一些内置变量来让我们访问当前正在渲染的摄像机的参数信息。这些参数对应摄像机上的Camera组件的属性值,下表给出了Unity5.2版本提供的这些变量。
六、4.9.3节 Unity中的屏幕坐标:ComputeScreenPos/VPOS/WPOS
我们在以前讲了屏幕空间的转换细节。在写shader的过程中,我们有时候希望能够获得片元在屏幕上的像素位置。在顶点/片元着色器中,有两种方式来获得片元的屏幕坐标。
1.一种是在片元着色器的输入中声明VPOS或WPOS语义。
VPOS是HLSL中对屏幕坐标的语义,而WPOS是Cg中对屏幕坐标的语义。两者UnityShader中是等价的。我们可以在HLSL/Cg中通过语义的方式来定义顶点/片元着色器的默认输入,而不需要在自己定义输入输出的数据结构。这个在第五章的例子中可以看到:
fixed4 frag(float4 sp: VPOS): SV_Target{
//用屏幕坐标除以屏幕分辨率_ScreenParams.xy,得到视口空间中的坐标
return fixed4(sp.xy/_ScreenParams.xy,0.0,1.0);
}
得到的效果如图:
VPOS/WPOS语义定义的输入是一个float4类型的变量。我们已经知道了它的xy值代表了在屏幕空间中的像素坐标。如果屏幕分辨率是400×300,那么x的范围就是[0.5,400.5],y的范围是[0.5,300.5]。
注意这里的像素坐标并不是整数值,这是因为OpenGL和DirectX10以后的版本认为像素的中心对应的是浮点值中的0.5。那么它的zw分量是什么呢?在Unity中,VPOS/WPOS的z分量范围是[0,1],在摄像机的近裁剪平面处,z的值是0,在远裁剪平面处,z的值是1。对于w分量,我们需要考虑摄像机的投影类型。如果使用的是透视投影,那么w分量的范围是
Near和Far对应了在Camera组件中设置的近裁剪平面和远裁剪平面距离摄像机的远近;如果使用的是正交投影,那么w的值恒为1。这些值是通过对经过投影矩阵变换后的w分量取倒数得到的。在代码的最后,我们把屏幕空间除以分标率来得到视口空间(viewport space)中的坐标。视口坐标很简单,就是把屏幕坐标归一化,这样屏幕左下角就是(0.0,0.0),右上角就是(1,1)。如果已知屏幕坐标的话,我们只需把xy值除以屏幕分辨率即可。
2.另一种方式是通过Unity提供的ComputeScreenPos函数。
这个函数在UnityCG.cginc里被定义。通常的用法需要两个步骤,首先在顶点着色器中将ComputeScreenPos的结果保存在输出结构体中,然后在片元着色器中进行一个齐次除法运算后得到视口空间下的坐标。例如:
struct vertOut{
float4 pos:SV_POSITION;
float4 scrPos:TEXCOORD0;
};
vertOut vert(appdata_base v){
vertOut o;
o.pos = mul (UNITY_MATRIX_MVP, v.vertex);
//第一步:把ComputeScreenPos的结果保存到scrPos中
o.scrPos = ComputeScreenPos(o.pos);
return o;
}
fixed4 frag(vertOut i):SV_Target{
//第二步:用scrPos.xy除以scrPos.w得到视口空间中的坐标
float2 wcoord = (i.scrPos.xy/i.scrPos.w);
return fixed4(wcoord, 0.0,1.0);
}
上面的代码实现效果与上图一样。我们现在来看看这种方式的实现细节。这种方法实际上是手动实现了屏幕映射的过程,而且它得到的坐标直接就是视口空间中的坐标。我们在一起前已经看到了如何将裁剪坐标空间的点映射到屏幕坐标中。据此,我们可以得到视口空间的坐标,公式如下:
上面公式的思想就是,首先对裁剪空间下的坐标进行齐次除法,得到范围在[-1,1]的NDC,然后再将其映射到范围在[0,1]的视口空间下的坐标。那么ComputeScreenPos究竟是如何做到的呢?我们可以在UnityCG.cdnic文件中找到ComputeScreenPos函数的定义,如下:
inline float4 ComputeScreenPos (float4 pos){
float4 o = pos * 0.5f;
# if defined(UNITY_HALF_TEXEL_OFFSET)
o.xy = float(o.x,o.y*_ProjectionParams.x)+o.w*_ScreenParams.zw;
# else
o.xy = float(o.x,o.y*_ProjectionParams.x)+o.w;
# endif
o.zw = pos.zw;
return o;
}
ComputeScreenPos 的输入参数pos是经过MVP矩阵变换后在裁剪空间中的顶点坐标。UNITY_HALF_TEXEL_OFFSET是Unity在某些DirectX*台上使用的宏,在这里我们可以忽略它。这样我们可以只关注#else部分。_ProjectionParams.x在默认情况下是1(如果我们使用了一个反转的投影矩阵的话就是-1,但这种情况很少见)。那么上述代码的过程实际是输出了:
可以看出,这里的xy并不是真正的视口空间下的坐标。因此我们在片元着色器中再进行一步处理,即除以裁剪坐标的w分量。至此,完成整个映射过程。因此虽然ComputeScreenPos的函数名字似乎意味着会直接得到屏幕空间的位置,但并不是这样的,我们仍需在片元着色器中除以它的w分量来得到真正的视口空间的位置。那么为什么Unity不直接在ComputeScreenPos中为我们进行除以w分量这个步骤呢?为什么还需要我们进行这个除法?这是因为,如果Unity在顶点着色器中这么做的话,就会破坏插值结果。我们知道,从顶点着色器到片元着色器的过程实际会有一个插值的过程。如果不在顶点着色器中进行这个除法,保留x,y和w分量,那么它们在插值后再进行这个除法,得到的x/w和y/w就是正确的(我们可以认为是除法抵消了插值的影响)。但是如果我们直接在顶点着色器中进行这个除法,那么就需要对x/w和y/w直接进行插值,这样得到结果就会不准确。原因是,我们不可以在投影空间中进行插值,因为这并不是一个线性空间,而插值往往是线性的。
经过除法操作后,我们就可以得到该片元在视口空间的坐标了,也就是一个xy范围都在[0,1]之间的值。那么它的zw值是什么呢?可以看出,我们在顶点着色器中直接把裁剪空间的zw值存进了输出结构体中,因此片元着色器输入的就是这些插值后的裁剪空间中的zw值。这意味着如果使用的是透视投影,那么z值的范围是[-Near,Far],w值得范围是[Near,Far];如果使用的是正交投影,那么z值得范围是[-1,1],而w值恒为1。
七、Unity提供的Cg/HLSL语义
读者在平时的Shader学习中可能经常看到,在顶点着色器和片元着色器的输入输出变量后还有一个冒号以及一个全部大写的名称,例如SV_POSITION、POSITION、COLOR0。这些大写的名字是什么呢?它们有什么用呢?
1.什么是语义
实际上,这些是Cg/HLSL提供的语义(semantics)。语义实际上就是一个赋给Shader输入和输出的字符串,这个字符串表达了这个参数的含义。通俗地讲,这些语义可以让Shader知道从哪里读取数据,并把数据输出到哪里,它们在Cg/HLSL的Shader流水线中是不可或缺的。需要注意的是,Unity并没有支持所有的语义。
//使用一个结构体来定义顶点着色器的输入
struct a2v {
//POSITION 语义告诉Unity,用模型空间的顶点坐标填充vertex变量
float4 vertex : POSITION;
//NORMAL 语义告诉Unity,用模型空间的法线方向填充normal变量
float3 normal : NORMAL;
//TEXCOORD0 语义告诉Unity,用模型的第一套纹理坐标填充texcoord变量
float4 texcoord : TEXCOORD0;
};
//使用一个结构体来定义顶点着色器的输出
struct v2f {
//SV_POSITION语义告诉Unity,pos里包含了顶点在裁剪空间中的位置信息
float4 pos : SV_POSITION;
//COLOR0语义可以用于存储颜色信息
fixed3 color : COLOR0;
};
v2f vert(a2v v) {
//声明输出结构
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
//v.normal 包含了顶点的法线方向,其分量范围在[-1.0,1.0]
//下面的代码把分量范围映射到了[0.0,1.0]
//并存储到o.color中传递给片元着色器
o.color = v.normal * 0.5 + fixed3(0.5,0.5,0.5);
return o;
}
fixed4 frag(v2f i) : SV_Target{
//return fixed4(i.color,1.0);
fixed3 c = i.color;
c *= _Color.rgb;
//将插值后的i.color 显示在屏幕上
return fixed4(c,1.0);
}
通常情况下,这些输入输出并不需要有特别的意义,也就是说,我们可以自行决定这些变量的用途。例如在上面的代码中,顶点着色器的输出结构体v2f中我们用Color0语义去描述color变量。color变量本身存储了什么,Shader流水线并不关心。
而Unity为了方便对模型数据的传输,对一些语义进行了特别含义的规定。例如,在顶点着色器中的输入结构体a2v用TEXCOORD0来描述texcoord,Unity会识别TEXCOORD0的语义,以把模型的第一组纹理坐标填充到texcoord中。需要注意的是,即便语义的名称一样,如果出现的位置不同,含义也不同。例如TEXCOORD0即可用于描述顶点着色器的输入结构体a2v,也可用于描述输出结构体v2f。但在输入结构体a2v中,TEXCOORD0有特别的含义,即把模型的第一组纹理坐标存储在该变量中,而在输出结构体v2f中,TEXCOORD0修饰的变量含义就可以由我们来决定。
在DirectX 10以后,有了一种新的语义类型,就是系统数值语义(system-value semantics)。这类语义是以SV开头的,SV代表的含义就是系统数值(system-value)。这些语义在渲染流水线中有特殊的含义。例如在上面的代码中,我们使用SV_POSITION语义去修饰顶点着色器的输出变量pos,那么就表示pos包含了可用于光栅化的变换后的顶点坐标(即齐次裁剪空间中的坐标)。用这些语义描述的变量是不可以随便赋值的,因为流水线需要它们来完成特定的目的,例如渲染引擎会把用SV_POSITION修饰的变量经过光栅化后显示在屏幕上。读者有时可能会看到同一个变量在不同的Shader里面使用了不同的语义修饰。例如一些Shader会使用POSITION而非SV_POSITION来修饰顶点着色器的输出。SV_POSITION是DirectX 10中引入的系统数值语义,在绝大多数平台上,它和POSITION语义是等价的,但在某些平台(如索尼PS4)上必须使用SV_POSITION来修饰顶点着色器的输出,否则无法让Shader正常工作。同样的例子还有COLOR和SV_Target。因此为了让我们的Shader有更好的跨平台性,对于这些有特殊含义的变量我们最好使用SV开头的语义进行修饰。
2.Unity支持的语义
下表总结了从应用阶段传递模型数据给顶点着色器时Unity使用的常用语义。这些语义虽然没有使用SV开头,但在Unity内部赋予了它们特殊的含义。
其中TEXCOORDn中的n的数目是和Shader Model有关的,例如一般在Shader Model 2(即Unity默认编译到的Shader Model版本)和Shader Model 3中,n等于8,而在Shader Model 4和Shader Model 5中,n等于16。通常情况下,一个模型的纹理坐标组数一般不超过2,即我们往往只使用TEXCOORD0和TEXCOORD1。在Unity内置的数据结构体appdata_full中,它最多使用6个坐标纹理组。
下表总结了从顶点着色器到片元着色器阶段Unity支持的常用语义。
上面的语义中,除了SV_POSITION有特别含义外,其它语义对变量的含义没有明确要求,也就是说,我们可以存储任意值带这些语义描述变量中。通常,如果我们需要把一些自定义的数据从顶点着色器传递给片元着色器,一般选用TEXCOORD0等。
下表给出了Unity中支持的片元着色器的输出语义。
3.如何定义复杂的变量类型
上面提到的语义绝大部分用于描述标量或矢量类型的变量,例如fixed2、float、float4、fixed4等。下面的代码给出了一个使用语义来修饰不同类型变量的例子。
struct v2f{
float4 pos:SV_POSITION;
fixed3 color0:COLOR0;
fixed4 color1 :COLOR1;
half value0:TEXCOORD0;
float2 value1:TEXCOORD1;
};
关于何时使用哪种类型的变量,我们会在后面给出一些建议,但要注意的是,一个语义可以使用的寄存器只能处理4个浮点值(float)。因此如果我们想要定义矩阵类型,例如float3×4、float4×4等变量就需要使用更多的空间。一种方法是,把这些变量拆分成多个变量,例如对float4×4的矩阵类型,我们可以拆分成4个float4类型的变量,每个变量存储了矩阵的一行数据。
4.TEXCOORD的位置
冯乐乐在书中特意指出TEXCOORD位置不同,含义也不同。这里再转一次,强调一下。
需要注意的是,即便语义的名称一样,如果出现的位置不同,含义也不同。例如TEXCOORD0即可用于描述顶点着色器的输入结构体a2v,也可用于描述输出结构体v2f。但在输入结构体a2v中,TEXCOORD0有特别的含义,即把模型的第一组纹理坐标存储在该变量中,而在输出结构体v2f中,TEXCOORD0修饰的变量含义就可以由我们来决定。
以下参考Unity Shader 语义
Vertex Shader Input Semantics
Semantics | Type | 含义 |
---|---|---|
POSITION | float3/4 | 表示模型的顶点坐标 |
NORMAL | float3 | 表示模型的法线 |
TEXCOORD0~N | float2/3/4 | TEXCOORD0 表示第一套 uv,TEXCOORDN 表示第 N 套 nv |
TANGENT | float4 | 表示模型的切线 |
COLOR | float4 | 表示模型的顶点颜色 |
Vertex Shader Output Semantics / Fragment Shader Input Semantics
Vertex Shader 的最重要目的就是输出顶点在裁剪空间中的坐标。
Semantics | Type | 含义 |
---|---|---|
SV_POSITION | float4 | 表示模型顶点在裁剪空间中的坐标 |
TEXCOORD0~N | 高精度(float4) | 用来保存高精度数据,例如纹理坐标,位置坐标等 |
COLOR0~N | 低精度(fixed4) | 用来表示低精度数据,比如 0~1 之间的颜色值 |
Vertex Shader 中的输出值将按照三角片面进行差值,随后作为 Fragment Shader 的输入。
TEXCOORD 系列语义又叫做插值器(Interpolator),在不同的系统和GPU上,插值器的数量限制也是不同的。
插值器数量 | 限制条件 |
---|---|
8 | OpenGL ES 2.0 (iOS/Android) Direct3D 11 9.x level (Windows Phone) and Direct3 9 shader model 2.0 (old PCs) |
10 | Direct3D 9 shader model 3.0 (#pragma target 3.0) |
16 | OpenGL ES 3.0 (iOS/Android), Metal (iOS) |
32 | Direct3D 10 shader model 4.0 (#pragma target 4.0) |
5.Unity Shader基础篇:浅谈TEXCOORDn
模型中每个顶点保存有uv,可能有一套或者几套,这些uv是指三维模型在2D平面的展开,跟纹理对应上进行插值采样就看到三维里的纹理颜色了。
简单来说texcoord就是存在顶点里的一组数据,我们可以通过这组数据在渲染的时候进行贴图采样,比如我们常用的第一套uv作为基础纹理,通常基础纹理我们可以根据需求进行一些区域的uv重用(比如左右脸贴图一样,可以映射到统一贴图区域),第二套uv经常用于光照贴图,光照贴图要求是uv不可以重复,所以通常不能用第一套uv,第三套uv用于更加奇特的需求,以此类推。。。
八、Shader Debug
调试(debug),大概是所有程序员的噩梦。而不幸的是,对一个Shader进行调试更是噩梦中的噩梦。这也是造成Shader难写的原因之一——如果发现得到的效果不对,我们就可能花非常多的时间来找到问题所在。造成这种现状的原因就是在Shader中可以选择的调试方法非常有限,甚至连简单的输出都不行。
1.使用假彩色图像
假彩色图像指的是用假彩色技术生成的一种图像,与假彩色图像对应的是照片这种真彩色图像。一张假彩色图像可以用于可视化一些数据,如何用它对shader进行调试?
主要思想是我们可以把需要调试的变量映射到[0,1]之间,把他们作为颜色输出到屏幕上,然后通过屏幕上显示的像素颜色来判断这个值是否正确。这种方法得到的调试信息很模糊,能够得到的信息有限,但在很长的一段时间内,这种方法的确是唯一的可选方法。
需要注意的是,由于颜色的分量范围在[0,1],因此我们需要小心处理需要调试的变量的范围,如果我们已知它的值域范围,可以先把它映射到[0,1]之间在进行输出。如果不知道一个变量的范围,只能不停地实验。一个提示是,颜色分量中任何大于1的数值将被设置为1,而任何小于0的数值会被设置为0。因此我们可以尝试使用不同的映射,直到发现颜色发生了变化。(这意味着得到了0到1的值)。
如果要调试一个一维数据,可以选择一个单独的颜色分量(比如R分量)进行输出,其他颜色分量设置为0。如果是多维数据,可以选择对他的每一个分量单独调试,或者选择多个颜色分量进行输出。
书中示例源码中Scene_5_2那个FalseColor示例展示了如何Debug,摄像机上还绑了一个ColorPicker。不过这个效果真是一言难尽啊……
2.Visual Studio Graphics Debugger调试shader
参考
https://docs.unity3d.com/cn/2019.4/Manual/SL-DebuggingD3D11ShadersWithVS.html
Visual Studio图形调试器详细使用教程(基于DirectX11)
Unity Shader 调试
3.帧调试器Frame Debugger
九、渲染平台的差异
1.坐标差异
我们以前提到过OpenGL和DirectX的屏幕空间坐标的差异,如下图所示:
大多数情况下,这样的差异并不会对我们造成任何影响。但当我们要使用渲染到纹理技术,把屏幕图像渲染到一张渲染纹理时,如果不采取任何措施的话,就会出现纹理翻转的情况。幸运的是,Unity在背后为我们处理了这种翻转问题——当在DirectX平台使用渲染到纹理技术时,Unity会为我们翻转屏幕图像纹理,以便在不同平台上达到一致性。
在一种特殊情况下Unity不会为我们进行这个翻转操作,这种情况就是我们开启了抗锯齿。具体细节看原书。
2.语法差异
UNITY_INITIALIZE_OUTPUT
3.语义差异
尽量用SV开头的
十、Shader的整洁之道
1.float、half还是fixed
我们使用Cg/HLSL来编写UnityShader中的代码。而在Cg/HLSL中,有三种精度的数值类型:float,half和fixed。这些精度将决定计算结果的数值范围。下表给出了这3种精度在通常情况下的数值范围。
尽管由上面的不同,但一个基本的建议是,尽可能使用精度较低的类型,是因为这可以优化Shader的性能,这一点在移动平台上尤为重要。
2.避免不必要的计算
尽可能减少Shader中的运算,或者通过预计算的方式来提供更多的数据。
3.慎用分支和循环语句
在最开始,GPU是不支持在顶点着色器和片元着色器中使用流程控制语句的。随着GPU的发展,我们现在已经可以使用if-else、for和while这种流程控制指令了。大体来说,GPU使用了不同于CPU的技术来实现分支语句,在最坏的情况下,我们花在一个分支语句的时间相当于运行了所有分支语句的时间。因此,我们不鼓励在SubShader中使用流程控制语句,因为它们会降低GPU的并行处理操作。
如果我们在Shader中使用了大量的流程控制语句,那么这个Shader的性能会成倍下降。一个解决方法是,我们应该尽量把计算向流水线上方移动,例如把放在片元着色器中的计算放到顶点着色器中去,或者直接在CPU中进行预计算,再把结果传递给Shader。当然,有时我们不可避免的要使用分支语句来进行计算,那么一些建议是:
- 分支判断语句中使用的条件变量最好是常数,即在Shader运行过程中不会发生变化;
- 每个分支中包含的操作指令数尽可能少;
- 分支的嵌套层数尽可能少。