5开始Unity Shader学习之路
在初级篇中实现的Unity Shader大多不能直接用于真是项目中,因为他们缺少了完整的光照计算,例如阴影、光照衰弱等。仅仅是为了阐述一些实现原理。
顶点/片元着色器的基本结构
Shader "MyShaderName"{
Properties{
//属性
}
SubShader{
//针对显卡a的SubShader
Pass{
//设置渲染状态和标签
//开始CG代码片段
CGPROGRAM
//该代码片段的编译指令,例如:
#pragma vertex vert
#pragma fragment frag
//CG代码写在这里
ENDCG
//其他设置
}
//针对显卡B的SubShader
}
//上述SubShader都失败后用于回调的Unity Shader
Fallback "VertexLit"
}
其中,最重要的部分是Pass语义块,我们绝大部分的代码都是写在这个语义块里面的。
Shader "Custom/Test" {
SubShader{
Pass{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// POSITION 和SV_POSITION都是CG/HLSl中的语义(semantics)他们是不可省略的
//这些语义将告诉系统用户需要哪些输入值,以及用户的输出是什么。
//这里POSITION将告诉Unity,把模型的顶点坐标填充到输入参数v中,
//SV_POSITION将告诉Unity,顶点着色器的输出是裁剪空间中的顶点坐标。
//UNITY_MATRIX_MVP矩阵是Unity内置的模型.观察.投影矩阵(4.8);
float4 vert(float4 v : POSITION) :SV_POSITION{
return mul(UNITY_MATRIX_MVP,v);
}
//本例中frag没有任何输入。它的输出是一个fixed4类型的变量,并且使用了SV_Target语义进行限定。
//SV_Target也是HLSL中的一个系统语义,他等同于告诉渲染器,把用户的输出颜色存储到一个渲染目标中,这里将输出到默认的帧缓存中。
//片元着色器中的代码很简单,返回一个表示白色的fixed4类型的变量。
fixed4 frag() : SV_Target{
return fixed4(1.0,1.0,1.0,0.0);
}
ENDCG
}
}
}
#pragma vertex vert
#pragma fragment frag
它们告诉Unity,哪个函数包含可顶点着色器的代码,哪个函数包含了片元着色器的代码。
在上面的例子中,在顶点着色器中我们使用POSITION语义得到了模型的顶点位置。
现在,我们想要得到模型上每个顶点的纹理坐标和法线方向。我们需要使用纹理坐标来访问纹理,而法线可用于计算光照。因此我们需要为顶点着色器定义一个新的输入参数,这个参数不在是一个简单的数据类型,而是一个结构体。
Shader "Custom/Test" {
SubShader{
Pass{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//使用一个结构体来定义顶点着色器的输入
struct a2v {
//POSITION语义告诉Unity,用模型空间的顶点坐标填充vertex变量
float4 vertex : POSITION;
//NORMAL语义告诉Unity,用模型空间的法线方向填充normal变量
float3 normal:NORMAL;
//TEXCOORDO语义告诉Unity,用模型的第一套纹理坐标填充texcoord变量
float4 texcoord:TEXCOORD0;
};
float4 vert(a2v v) :SV_POSITION{
//使用v.vertex来访问模型空间的顶点坐标
return mul(UNITY_MATRIX_MVP,v.vertex);
}
fixed4 frag() : SV_Target{
return fixed4(1.0,1.0,1.0,1.0);
}
ENDCG
}
}
}
上述代码中,我们声明了一个新的结构体a2v,它包含立顶点着色器需要的模型数据。它包含了顶点着色器需要的模型数据。Unity支持的语义有:POSITION,TANGENT,NORMAL,TEXCOORD0,TEXCOORD1,TEXCOORD2,TEXCOORD3,COLOR等。
为了创建一个自定义的结构体,我们必须使用如下格式来定义它:
struct StructName{
Type Name:Semantic;
Type Name:Semantic;
};
其中,语义是不可以被省略的。在5.4节中,我们将给出这些语义的含义和用法。
a表示应用(application),v表示顶点着色器(vertex shadr),a2v的意思就是把数据从应用阶段传递到顶点着色器中。
那么,填充到POSITION,TANGENT,NORMAL这些语义块中的数据究竟是从哪里来的呢?在Unity中,他们是由使用该材质的Mesh Render组件提供的。在每帧调用Draw Call的时候,Mesh Render组件会把他负责渲染的模型数据发送给Unity Shader。我们知道,一个模型通常包含了一组三角面片,每个三角面片由3个顶点构成,而每个顶点又包含了一些数据,例如顶点位置、法线、切线、纹理坐标、顶点颜色等。通过上面的方法,我们就可以在顶点着色器中访问顶点的这些数据模型。
在实践中,我们往往希望从顶点着色器输出一些数据,例如把模型的法线、纹理坐标等传递给片元着色器。这就涉及顶点着色器和片元着色器之间的通信。
为此,我们需要再定义一个新的结构体。
Shader "Custom/Test" {
SubShader{
Pass{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
struct a2v {
float4 vertex : POSITION;
float3 normal:NORMAL;
float4 texcoord:TEXCOORD0;
};
//使用一个结构体来定义顶点着色器的输出
struct v2f {
//SV_POSITION语义告诉Unity,pos里包含了顶点在裁剪空间中的位置信息
float4 pos : SV_POSITION;
//COLOR0语义可以用于存储颜色信息
fixed3 color : COLOR0;
};
v2f vert(a2v v) : SV_POSITION{
//声明输出结构
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, 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{
//将插值后的i.color显示到屏幕上
return fixed4(i.color,1.0f);
}
ENDCG
}
}
}
在上面的代码中,我们声明了一个新的结构体v2f。v2f用于在顶点着色器和片元着色器之间传递消息。同样,v2f中也需要指定每个变量的语义。在本例中,我们使用了SV_POSITION和COLOR0语义。顶点着色器的输出结构中必须包含一个变量,他的语义是SV_POSITION。否则,渲染器将无法得到裁剪空间中的顶点坐标,也就无法把顶点渲染到屏幕上。COLOR0语义中的数据在可以由用户自定义,但一般都是存储颜色,例如逐顶点的漫反射颜色或逐顶点的高光反射颜色。类似的语义还有COLOR1等(5.4)
至此,我们就完成了顶点着色器和片元着色器之间的通信。需要注意的是,顶点着色器是逐顶调用的,而片元着色器是逐片元调用的。片元着色器中的输入实际上是把顶点着色器的输出进行差值后得到的结果。
使用属性:
材质提供给我们一个可以方便地调用Unity Shader中参数的方式,通过这些参数,我们可以随时调整材质的效果。而这些参数就需要写在Properties语义块中。
Shader "Custom/Test" {
Properties{
//声明一个Color类型的属性
_Color("Color Tint", Color) = (1.0,1.0,1.0,1.0)
}
SubShader{
Pass{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//在CG代码中,我们需要定义一个与属性名称和类型都匹配的变量
fixed4 _Color;
struct a2v {
float4 vertex : POSITION;
float3 normal:NORMAL;
float4 texcoord:TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
fixed3 color : COLOR0;
};
v2f vert(a2v v) : SV_POSITION{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.color = v.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
return o;
}
fixed4 frag(v2f i) : SV_Target{
fixed3 c = i.color;
//使用_Color属性来控制输出颜色
c *= _Color.rgb;
return fixed4(c,1.0);
}
ENDCG
}
}
}
在上面的代码中,我们首先添加了Properties语义块中,并在其中声明了一个属性_Color,它的类型是Color,初始值是(11.0,1.0,1.0,1.0),对应白色。为了在CG代码中可以访问他,我们还需要在CG代码片段中提前定义一个新的变量,这个变量的名称和类型必须与Properties语义块中的属性定义相匹配。
有时,读者会发现在CG变量前会有一个uniform关键字,例如:
uniform fixed4 _Color;
uniform关键词是CG中修饰变量和参数的一种修饰词,它仅仅用于提供一些关于该变量的初始值是如何指定和存储的相关信息(这和其他一些图像编程接口中的uniform关键词的作用不太一样)。在Unity Shader中,uniform关键词是可以省略的。
5.3内置的包含文件
包含文件,是类似于C++中头文件的一种文件。在Unity中,他们的文件后缀是.cginc。在编写Shader时,我们可以使用#include指令把这些文件包含进来,这样我们就可以使用Unity为我们提供的一些非常有用的变量和帮助函数。例如:
CGPROGRAM
//...
#include "UnityCG.cginc"
//...
ENDCG
那么,这些文件在哪里呢?我们可以在官网(http://unity3d.com/cn/get-unity/download/archive)上选择下载>内置着色器来直接下载这些文件。
从图5.3中可以看出,从官网下载的文件中包含了多个文件夹。其中,CGIncludes文件夹中包含了所有的内置包含文件;DefaultResources文件夹中包含了一些内置组件或功能所需要的Unity Shader,例如一些GUI元素使用的Shader:DefaultResourcesExtra则包含了所有Unity中内置的Unity Shader;Editor 文件夹目前只包含了一个脚本文件,它用于定义Unity5引入的Standard Shader(18)所用的材质面板。这些文件都是非常好的参考资料,在我们想要学习内置着色器的实现或是寻找内置函数的实现时,都可以在这里找到内部实现。
我们也可以从Unity的应用程序中直接找到CGIncludes文件夹。在Mac上,他们的位置是:/Applications/Unity/Unity.app/Contents/CGIncludes;在Windows上,他们的位置是:Unity的安装路径/Data/CGIncludes。
5.4 什么是语义
实际上,这些是CG/HLSL提供的语义。读者可以在微软的关于DIrectX的文档(https://msdn.microsoft.com/en-us/library/windows/desktop/bb509647(v=vs.85).aspx#VS)中找到关于语义的详细说明页面。根据文档我们可以知道,语义实际上就是一个赋给Shader输入和输出的字符串,这个字符串表达了这个参数的含义。通俗地讲,这些语义可以让Shader知道从哪里读取数据,并把数据输出到哪里,它们在CG/HLSL的Shader流水线中是不可或缺的。需要注意的是,Unity并没有支持所有的语义。
而Unity为了方便对模型数据的传输,对一些语义进行特别的含义规定。例如,在顶点着色器 的输入结构体a2f用TEXCOORD0来描述texcoord,Unity会识别TEXCOORD0语义,以把模型的第一组纹理坐标填充到texcoord中。需要注意的是,即使语义的名称一样,如果出现的位置不同,含义也不同。例如,TEXCOORD0既可以用于描述顶点着色器的输入结构体a2f,也可用于描述输出结构体v2f。但是输入结构体a2f中,TEXCOORD0有特别的含义,即把模型的第一组纹理坐标存储在该变量中,而在输出结构体v2f中,TEXCOORD0修饰的变量含义就可以由我们来决定。
在DirectX 10以后,有了一种新的语义类型,就是系统数值语义(system-value semantics)这类语义是以SV开头的,SV代表的含义就是系统数值(system-value)。
SV_POSITION 是DirectX 10中引入的系统数值语义,在绝大多数平台上,他和POSITION语义是等价的,但在某些平台(例如索尼PS4)上必须使用 SV_POSITION来修饰顶点着色器的输出,否则无法让Shader正常工作。因此,为了让我们的Shader有更好的跨平台性,对于这些有特殊含义的变量我们最好使用以SV开头的语义进行修饰。
5.5 Debug
假彩色图像(false-color image)指的是用假彩色技术生成的一种图像。与假彩色图像对应的是照片这种真彩色图像(true-color image)。
作为实例,下面我们会使用假彩色图像的方式可视化一些模型数据,如法线、切线、纹理坐标、顶点颜色,以及它们之间的运算结果。
Shader "Custom/Test" {
SubShader{
Pass{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
struct v2f {
float4 pos : SV_POSITION;
fixed3 color : COLOR0;
};
v2f vert(appdata_full v){
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
//可视化法线方向
o.color = fixed4(v.normal * 0.5 + fixed3(0.5,0.5,0.5),1.0);
//可视化切线方向
o.color = fixed4(v.tangent * 0.5 + fixed3(0.5, 0.5, 0.5), 1.0);
//可视化副切线方向
fixed3 binormal = cross(v.normal, v.tangent.xyz) * v.tangent.w;
o.color = fixed4(binormal * 0.5 + fixed3(0.5,0.5,0.5),1.0);
//可视化第一组纹理坐标
o.color = fixed4(v.texcoord.xy, 0.0, 1.0);
//可视化第二组纹理坐标
o.color = fixed4(v.texcoord1.xy, 0.0, 1.0);
//可视化第一组纹理坐标的小数部分
o.color = frac(v.texcoord);
if (any(saturate(v.texcoord) - v.texcoord))
{
o.color.b = 0.5;
}
o.color.a = 1.0;
//可视化第二组纹理坐标的小数部分
o.color = frac(v.texcoord1);
if (any(saturate(v.texcoord1) - v.texcoord1))
{
o.color.b = 0.5;
}
o.color.a = 1.0;
//可视化顶点颜色
//o.color = v.color;
return o;
}
fixed4 frag(v2f i) : SV_Target{
return i.color;
}
ENDCG
}
}
}
在上面的代码中,我们使用了Unity内置的一个结构体——appdata_full。(5.3)我们可以在UnityCG.cginc里找到它的定义:
struct appdata_full {
float4 vertex : POSITION;
float4 tangent : TANGENT;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
float4 texcoord1 : TEXCOORD1;
float4 texcoord2 : TEXCOORD2;
float4 texcoord3 : TEXCOORD3;
#if defined(SHADER_API_XBOX360)
half4 texcoord4 : TEXCOORD4;
half4 texcoord5 : TEXCOORD5;
#endif
fixed4 color : COLOR;
};
可以看出,appdata_full几乎包含了所有的模型数据。
为了可以得到某点的颜色值,我们可以使用类似颜色收拾器的脚本得到屏幕上某点RGBA值,从而推断出该点的调试信息。
Graphics Debugger 利用神器:Visual Studio
通过Graphics Debugger,我们不仅可以查看每个像素的最终颜色、位置等信息,还可以对顶点着色器和片元着色器进行单步调试。具体的安装和使用方法参见Unity官网文档中使用Visual Studio对DirectX 11的Shader进行调试一文(http://docs.unity3d.com.Manual/Manual/SL_DebuggingD3D11ShadersWithVS.html)。
当然,本方法也有一些限制。例如,我们需要保证Unity运行在DirectX 11平台上,而且Graphics Debugger 本身存在一些bug。但这依旧无法掩盖它的优点!
最新利器:帧调试器
尽管Mac无法体验Visual Studio 的强大功能,但幸运的是,Unity5除了带来全新的UI系统外,还给我们带来了一个新的针对渲染的调试器——帧调试器(Frame Debugger)。与其他调试工具的复杂性相比,Unity原生的帧调试器非常简单快捷。我们可以使用它来看到游戏图像的某一帧是如何一步步渲染出来的。
要使用帧调试器,我们首先需要在Wndow FramDebugger中打开帧调试器窗口。
帧调试器可以用于查看渲染该帧时进行的各种渲染事件(event),这些事件包含了Draw Call 序列,也包括了类似清空帧缓存等操作。帧调试器窗口大致分为3个部分:最上面的区域可以开启/关闭(单机Enable按钮)帧调试功能,当开启了帧调试时,通过移动窗口最上方的滑动条(或单击前进和后退按钮),我们可以重放这些渲染事件;左侧的区域显示了所有事件的树状图,在这个树状图中,每个叶子节点就是一个事件,而每个父节点的右侧显示了该节点下的事件数目。我们可以从事件的名字了解这个事件的操作,例如以Draw开头的事件通常就是一个Draw Call ;当单击了某个事件时,在右侧的窗口中就会显示该事件的细节,例如几何图形的细节以及使用了那个Shader等。同时在Game视图中我们也可以看到它的效果。如果该事件是一个Draw Call并且对应了场景中的一个GameObject,那么这个GameObject也会在Hierarchy视图中被高亮显示出来。
如果被选中的Draw Call 是对一个渲染纹理的渲染操作,那么这个渲染纹理就会显示在Game视图中。而且,此时右侧面板上方的工具栏中也会出现更多的选项,例如在Game视图中单独显示R、G、B和A通道。
* 5.6 渲染平台的差异*
因为OpenGL和DirectX的屏幕空间坐标差异的存在,导致我们需要注意,当我们要使用渲染到纹理技术,把屏幕图像渲染到一张渲染纹理中时,如果不采取任何措施的话,就会出现纹理翻转的情况。幸运的是,Unity在背后为我们处理了这种翻转问题——当在DirectX平台上使用渲染到纹理技术时,Unity会为我们翻转屏幕图像纹理,以便在不同平台上达到一致性。
有一种特殊情况下Unity不会为我们进行这个翻转操作,这种情况就是我们开启了抗锯齿(Edit-Project Setting-Quality-Anti Aliasing中开启)并在此时使用了渲染到纹理技术。
如果发现一些Shader在平台A下工作良好,而在平台B下出现了问题,可以去Unity官方文档(http://docs.unity3d.com/Manual/SL_PlatformDifferences.html)中寻找更多的资料。
5.7 Shader 整洁之道
在本书中,我们使用CG/HLSL来编写Unity Shader 中的代码。 而在CG/HLSL中,有3中精度的数值类型:float,half和fixed。
上面的精度范围并不是绝对正确的,尤其是在不同平台和GPU上,他们实际的精度可能和上面给出的范围不一致。通常来讲。
* 大多数现代的桌面GPU会把所有计算都按最高的浮点精度进行计算,也就是说,在这些平台上实际是等价的。这意味着,我们在PC上很难看出因为half和fixed精度而带来的不同。
*但在移动平台的GPU上,他们的确会有不同的精度范围,而且不同精度的浮点值的运算速度也会有所差异。因此,我们应该确保在真正的移动平台上验证我们的Shader。
*fixed精度实际上只在一些较旧的移动平台上有用,在大多数现代的GPU上,他们内部把fixed和half当成同等精度来对待。
尽管有上面的不同,但一个基本建议是,尽可能使用精度较低的类型,因为这可以优化Shader的性能,这一点在移动平台上尤为重要。从它们大体的值域范围来看,我们可以使用fixed类型来存储颜色和单位矢量,如果要存储更大范围的数据可以选择half类型,最差情况下在选择使用float。如果我们的目标平台是移动平台,一定要确保在真是的手机上测试我们的Shader,这一点非常重要。关于移动平台的优化技术(16)。
5.7.2 规范语法 如果要发布到DirectX平台上就需要使用严格的语法。
5.7.3 避免不必要的计算 如果我们毫无节制地在Shader(尤其是片元着色器)中进行了大量计算,那么我们可能很快就会收到Unity的错误提示:
temporary register limit of 8 exceeded
或
Arithmetic instruction limit of 64 exceeded;65 arithmetic instructions needed to compile program
出现这些错误信息大多是因为我们再Shader中进行了过多的计算,是的需要的临时寄存器数目或指令数目超过了当前可支持的数目。通常,不同的Shader Target、不同的着色器阶段,我们可使用的临时存储器和指令数目都是不同的。
通常,我们可以通过指定更高等级的Shader Target来消除这些错误。
需要注意的是,所有类似OpenGL的平台(包括移动平台)被当成是支持到 Shader Model3.0的。而WP8/WinRT平台则只支持到Shader Model2.0.
5.7.4 慎用分支和循环语句
*分支判断语句中使用的条件变量最好是常数,即在Shader运作过程中不会发生变化;
*每个分支中国包含的操作指令尽可能少;
*分支的嵌套层数尽可能少。
5.7.5 不要除以0 解决方案式,对那些除数可能是0的情况,强制截取到非0范围。在一些资料中,可能会看到使用if语句来判断除数是否为0的。
5.8 扩展阅读
读者可以在《GPU 精粹2》中的GPU流程控制一章中更深入地了解为什么流程控制语句在GPU会影响性能。我们提到了Shader中面临期存器数目和运算指令都有限制,实际上Shader Model对顶点着色器和片元着色器中使用的指令数、临时寄存器、常量寄存器、输入/输出寄存器、纹理等都进行了规定。读者可以在Wiki的相关资料和HLSL的手册中找到更多的内容。
《Mark Harris,Ian Buck”GPU fiow-Control Idioms”》In GPU
Geme 2. 中译本:GPU精粹2:高性能图形芯片和通用计算百编程技巧,法尔译,清华大学出版社,
《High-Level Shading Language,Wiki(http://en.weikopedia.org/wiki/High_Level
_Shading_Language)》.
《Shader Models vs Shader Profiles,HLSL手册(http://msdn.microsoft.com/en-us/library/windows/desktop/bb509628(v=vs.85).aspx)》