Unity Shader的基本结构。它包含了Shader、Properties、SubShader、Fallback等语义块。顶点/片元着色器的结构与之大体类似
Shader "MyShaderName" {
Properties {
// 属性
}
SubShader {
// 针对显卡A的SubShader
Pass {
// 设置渲染状态和标签
// 开始CG代码片段
CGPROGRAM
// 该代码片段的编译指令,例如:
#pragma vertex vert
#pragma fragment frag
// CG代码写在这里
ENDCG
// 其他设置
}
// 其他需要的Pass
}
SubShader {
// 针对显卡B的SubShader
}
// 上述SubShader都失败后用于回调的Unity Shader
Fallback "VertexLit"
}
一个简单的代码:
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'
Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
SubShader{
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
float4 vert(float4 v : POSITION) : SV_POSITION {
return UnityObjectToClipPos(v);
}
fixed4 frag() : SV_Target {
return fixed4(1.0, 1.0, 1.0, 1.0);
}
ENDCG
}
}
}
效果:
讲解:
代码的第一行通过Shader语义定义了这个Unity Shader的名字——“Unity Shaders Book/Chapter 5/Simple Shader
Properties语义并不是必需的,我们可以选择不声明任何材质属性
两条编译指令:
#pragma vertex vert
#pragma fragment frag
它们告诉Unity,哪个函数包含了顶点着色器的代码,哪个函数包含了片元着色器的代码
更一般的形式:
#pragma vertex name
#pragma fragment name //其中name 就是我们指定的函数名
float4 vert(float4 v : POSITION) : SV_POSITION {
return mul (UNITY_MATRIX_MVP, v);
}
这就是本例使用的顶点着色器代码,它是逐顶点执行的。vert函数的输入v包含了这个顶点的位置,这是通过POSITION语义指定的。它的返回值是一个float4类型的变量,它是该顶点在裁剪空间中的位置,POSITION和SV_POSITION都是CG/HLSL中的语义(semantics),它们是不可省略的,这些语义将告诉系统用户需要哪些输入值,以及用户的输出是什么
例如这里,POSITION将告诉Unity,把模型的顶点坐标填充到输入参数v中,SV_POSITION将告诉Unity,顶点着色器的输出是裁剪空间中的顶点坐标
return 执行的代码的意思是:把顶点坐标从模型空间转换到裁剪空间中。UNITY_MATRIX_MVP矩阵是Unity内置的模型·观察·投影矩阵
fixed4 frag() : SV_Target {
return fixed4(1.0, 1.0, 1.0, 1.0);
}
在本例中,frag函数没有任何输入。它的输出是一个fixed4类型的变量,并且使用了SV_Target语义进行限定。SV_Target也是HLSL中的一个系统语义,它等同于告诉渲染器,把用户的输出颜色存储到一个渲染目标(render target)中,这里将输出到默认的帧缓存中。片元着色器中的代码很简单,返回了一个表示白色的fixed4类型的变量。片元着色器输出的颜色的每个分量范围在[0, 1],其中(0, 0,0)表示黑色,而(1, 1, 1)表示白色。
为了自建一个自定义的结构体,我们必须使用如下格式来定义它:
struct StructName {
Type Name : Semantic;
Type Name : Semantic;
.......
};
其中,语义是不可以被省略的
我们修改了vert函数的输入参数类型,把它设置为我们新定义的结构体a2v。通过这种自定义结构体的方式,我们就可以在顶点着色器中访问模型数据。
在Unity中,填充到POSITION, TANGENT, NORMAL这些语义中的数据是由使用该材质的Mesh Render组件提供的。在每帧调用Draw Call的时候,Mesh Render组件会把它负责渲染的模型数据发送给Unity Shader
我们知道,一个模型通常包含了一组三角面片,每个三角面片由3个顶点构成,而每个顶点又包含了一些数据,例如顶点位置、法线、切线、纹理坐标、顶点颜色等
顶点着色器是逐顶点调用的,而片元着色器是逐片元调用的。片元着色器中的输入实际上是把顶点着色器的输出进行插值后得到的结果。
Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
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.0);
}
ENDCG
}
}
}
在上面的代码中,我们声明了一个新的结构体v2f。v2f用于在顶点着色器和片元着色器之间传递信息。同样的,v2f中也需要指定每个变量的语义。在本例中,我们使用了SV_POSITION和COLOR0语义。顶点着色器的输出结构中,必须包含一个变量,它的语义是SV_POSITION。否则,渲染器将无法得到裁剪空间中的顶点坐标,也就无法把顶点渲染到屏幕上。COLOR0语义中的数据则可以由用户自行定义,但一般都是存储颜色,例如逐顶点的漫反射颜色或逐顶点的高光反射颜色
Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
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,初始值是(1.0,1.0,1.0,1.0),对应白色。为了在CG代码中可以访问它,我们还需要在CG代码片段中提前定义一个新的变量,这个变量的名称和类型必须与Properties语义块中的属性定义相匹配。
包含文件(include file),是类似于C++中头文件的一种文件。在Unity中,它们的文件后缀是.cginc。在编写Shader时,我们可以使用#include指令把这些文件包含进来,这样我们就可以使用Unity为我们提供的一些非常有用的变量和帮助函数
CGPROGRAM
// ...
#include "UnityCG.cginc"
// ...
ENDCG
我们可以在官方网站(http://unity3d.com/cn/get-unity/download/archive)上选择下载 -> 内置着色器来直接下载这些文件
CGIncludes文件夹中包含了所有的内置包含文件;DefaultResources文件夹中包含了一些内置组件或功能所需要的Unity Shader,例如一些GUI元素使用的Shader; DefaultResourcesExtra则包含了所有Unity中内置的Unity Shader; Editor文件夹目前只包含了一个脚本文件,它用于定义Unity 5引入的Standard Shader(详见第18章)所用的材质面板
我们可以在Unity的应用程序中直接找到 CGIncludes 文件夹
在Windows上,它们的位置是:Unity的安装路径/Data/CGIncludes。
可以在微软的关于 DirectX 的文档中找到关于语义的详细说明页面
这些语义可以让Shader知道从哪里读取数据,并把数据输出到哪里,它们在CG/HLSL的Shader流水线中是不可或缺的
在DirectX 10以后,有了一种新的语义类型,就是系统数值语义(system-value semantics)。这类语义是以SV开头的,SV代表的含义就是系统数值(system-value)。用这些语义描述的变量是不可以随便赋值的,因为流水线需要使用它们来完成特定的目的,例如渲染引擎会把用SV_POSITION修饰的变量经过光栅化后显示在屏幕上。
上面的语义中,除了SV_POSITION是有特别含义外,其他语义对变量的含义没有明确要求,也就是说,我们可以存储任意值到这些语义描述变量中。通常,如果我们需要把一些自定义的数据从顶点着色器传递给片元着色器,一般选用TEXCOORD0等。
需要注意的是,一个语义可以使用的寄存器只能处理4个浮点值(float)。因此,如果我们想要定义矩阵类型,如float3×4、float4×4等变量就需要使用更多的空间。一种方法是,把这些变量拆分成多个变量,例如对于float4×4的矩阵类型,我们可以拆分成4个float4类型的变量,每个变量存储了矩阵中的一行数据。
Unity 中对 UnityShader 的调试方式,主要包含了两种方式
假彩色图像(false-color image)指的是用假彩色技术生成的一种图像。与假彩色图像对应的是照片这种真彩色图像(true-color image)
主要思想是,我们可以把需要调试的变量映射到[0, 1]之间,把它们作为颜色输出到屏幕上,然后通过屏幕上显示的像素颜色来判断这个值是否正确。需要注意的是,由于颜色的分量范围在[0, 1],因此我们需要小心处理需要调试的变量的范围。如果我们已知它的值域范围,可以先把它映射到[0, 1]之间再进行输出。如果我们要调试的数据是一个一维数据,那么可以选择一个单独的颜色分量(如R分量)进行输出,而把其他颜色分量置为0。如果是多维数据,可以选择对它的每一个分量单独调试,或者选择多个颜色分量进行输出。
Shader "Unity Shaders Book/Chapter 5/False Color" {
SubShader {
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct v2f {
float4 pos : SV_POSITION;
fixed4 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 Shader的调试功能——Graphics Debugger
通过Graphics Debugger,我们不仅可以查看每个像素的最终颜色、位置等信息,还可以对顶点着色器和片元着色器进行单步调试
要使用帧调试器,我们首先需要在Window -> Frame Debugger中打开帧调试器窗口
帧调试器可以用于查看渲染该帧时进行的各种渲染事件(event),这些事件包含了Draw Call序列,也包括了类似清空帧缓存等操作。帧调试器窗口大致可分为3个部分:最上面的区域可以开启/关闭(单击Enable按钮)帧调试功能,当开启了帧调试时,通过移动窗口最上方的滑动条(或单击前进和后退按钮),我们可以重放这些渲染事件;左侧的区域显示了所有事件的树状图,在这个树状图中,每个叶子节点就是一个事件,而每个父节点的右侧显示了该节点下的事件数目。我们可以从事件的名字了解这个事件的操作,例如以Draw开头的事件通常就是一个Draw Call;当单击了某个事件时,在右侧的窗口中就会显示出该事件的细节,例如几何图形的细节以及使用了哪个Shader等。同时在Game视图中我们也可以看到它的效果。如果该事件是一个Draw Call并且对应了场景中的一个GameObject,那么这个GameObject也会在Hierarchy视图中被高亮显示出来
如果被选中的Draw Call是对一个渲染纹理(RenderTexture)的渲染操作,那么这个渲染纹理就会显示在Game视图中。而且,此时右侧面板上方的工具栏中也会出现更多的选项,例如在Game视图中单独显示R、G、B和A通道。
上面的精度范围并不是绝对正确的,尤其是在不同平台和GPU上,它们实际的精度可能和上面给出的范围不一致。通常来讲。
如果我们在Shader中使用了大量的流程控制语句,那么这个Shader的性能可能会成倍下降。一个解决方法是,我们应该尽量把计算向流水线上端移动,例如把放在片元着色器中的计算放到顶点着色器中,或者直接在CPU中进行预计算,再把结果传递给Shader。当然,有时我们不可避免地要使用分支语句来进行运算,那么一些建议是: