《Unity Shader入门精要》学习笔记一
1、本书的源代码
https://github.com/candycat1992/Unity_Shaders_Book
2、第1章
Shader是面向GPU的工作方式
3、第2章 渲染流水线
Shader:着色器
渲染流水线:目标是渲染一张二维纹理,输入是一个虚拟摄像机、一些光源、一些Shader以及纹理等。
渲染的3个阶段:
1)应用阶段:
由应用主导,由CPU负责实现。
3个任务:
1. 准备好场景数据:摄像机位置、模型、光源。
2.粗粒度剔除工作:把不可见的物体剔除出去。
3.设置好每个模型的渲染状态,材质(漫反射颜色、高光反射颜色)、使用的纹理、使用的Shader灯。
输出:渲染所需的几何信息,即渲染图元(点、线、三角面等)。
2)几何阶段:
GPU上运行。
任务:把顶点坐标变换到屏幕空间中,再交给光栅器进行处理。
输出:屏幕空间的二维顶点坐标、每个顶点对应的深度值、着色等相关信息。
3)光栅化阶段:
GPU上运行。
使用上个阶段传递的数据来产生屏幕上的像素,并渲染出最终的图像。
需要对上一个阶段得到的逐顶点数据(例如纹理坐标、顶点颜色等)进行插值,然后再进行逐像素处理。
渲染状态:定义了场景中的网格是怎样被渲染的。比如使用什么顶点着色器/片元着色器、光源属性、材质等。如果没有更改渲染状态,那么所有的网格都将使用同一种渲染状态。
Draw Call:CPU向GPU发送命令开始进行一个渲染过程。当给定了一个Draw Call时,GPU就会根据渲染状态(例如材质、纹理、着色器等)和所有输入的顶点数据来进行计算,最终输出成屏幕上显示的那些漂亮的像素。
顶点着色器:可编程,实现顶点的空间变换、顶点着色。
曲面细分着色器、几何着色器:可选的着色器。
裁剪:把不在摄像机视野内的顶点裁剪掉,并剔除某些三角图元的面片。不可编程,可配置。
屏幕映射:不可编程。把每个图元的坐标转换到屏幕坐标系中。
片元着色器:可编程的,用于实现逐片元的着色操作。
逐片元操作:执行很多重要的操作,例如修改颜色、深度缓冲、进行混合等。不可编程,可配置。
顶点着色器的主要任务:坐标变换和逐顶点光照。坐标变换:对顶点的坐标(即位置)进行某种变换,比如通过改变顶点位置来模拟水面、布料等。
一个顶点着色器必须完成的一个工作:把顶点坐标从模拟空间转换到齐次裁剪空间。
光栅化的2个重要目标:计算每个图元覆盖了哪些像素,以及为这些像素计算它们的颜色。
三角形设置:几何阶段输出三角网格的顶点,即每条边的两个端点。如果要得到整个三角网格对像素的覆盖情况,就必须计算每条边上的像素坐标。为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式。
三角形遍历:检查每个像素是否被一个三角网格所覆盖。如果被覆盖的话,就会生成一个片元。
三角形遍历阶段会根据上一个阶段的计算结果来判断一个三角网格覆盖了哪些像素,并使用三角网格3个顶点的顶点信息对整个覆盖区域的像素进行插值,这一步的输出是得到一个片元序列。
一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色,这些状态包括了(但不限于)它的屏幕坐标、深度信息,以及其他从几何阶段输出的顶点信息,例如法线、纹理坐标等。
片元着色器:输入是上一个阶段对顶点信息插值得到的结果,而它的输出是一个或者多个颜色值。仅可以影响单个片元。
这里采用的渲染技术是纹理采样。
逐片元操作:输出合并阶段。任务:
1.决定每个片元的可见性。这涉及了很多测试工作,例如深度测试、模板测试等。
2.如果一个片元通过了所有的测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区中的颜色进行混合。
模板测试:GPU会首先读取模板缓冲区中该片元位置的模板值,然后将该值和读取到的参考值进行比较。
深度测试:GPU会把 该片元的深度值和已经存在于深度缓冲区中的深度值进行比较。例如,只想显示出离摄像机最近的物体,而那些被其他物体遮挡的就不需要出现在屏幕上。
为什么要合并?渲染过程是一个物体接着一个物体画到屏幕上的。而每个像素的颜色信息被存储在一个名为颜色缓冲的地方。当我们执行这次渲染时,颜色缓冲中往往已经有了上次渲染之后的颜色结果。合并就是处理两个颜色的逻辑。
对于不透明物体,可以关闭混合,这样片元着色器计算得到的颜色值就会直接进行覆盖了。对于半透明物体,就需要使用混合操作来让这个物体看起来是透明的。
OpenGL和DirectX是图像应用编程接口,这些接口架起了上层应用程序和底层GPU的沟通桥梁。
显卡的组成:图像处理单元GPU、显卡内存称为显存。
CPU和GPU的并行工作:使用命令缓冲区,它包含了一个命令队列,由CPU向其中添加命令,而由GPU从中读取命令,添加和读取的过程是互相独立的。命令缓冲区的命令有很多种类,而Draw Call是其中一种。
Draw Call多了会影响帧率。CPU发送命令有很多准备工作,如果Draw Call的数量太多,CPU就会把大量时间花费在提交Draw Call上,造成CPU的过载。
减少Draw Call的方法:批处理。
CPU在RAM把多个网格合并成一个更大的网格,再发送给GPU,然后在一个Draw Call中渲染它们。但是,使用批处理合并的网格将会使用同一种渲染状态。所以,批处理适合处理静态的物体,比如大地、石头等。
减少Draw Call的开销:
1)避免使用大量很小的网格。当不可避免地需要使用很小地网格结构时,考虑是否可以合并它们。
2)避免使用过多地材质。尽量在不同地网格之间共用同一个材质。
总结:顶点着色器进行顶点变换以及传递数据,片元着色器进行逐像素地渲染。
4、Shader基础
Shader:渲染流水线中的某些特定阶段 ,如顶点着色器阶段、片元着色器阶段。
材质和Unity Shader搭配流程:
1)创建一个材质
2)创建一个Unity Shader,并把它赋给上一步中创建的材质
3)把材质赋给要渲染的对象
4)在材质面板中调整Unity Shader的属性,以得到满意的效果
Unity Shader的4种模板:
1)Standard Surface Shader:包含了标准光照模型的表面着色器模板
2)Unlit Shader:不包含光照的基本的顶点/片元着色器
3)Image Effect Shader:为我们实现各种屏幕后处理效果提供了一个基本模板
4)Compute Shader:利用GPU的并行性来进行一些与常规渲染流水线无关的计算
【ShaderLab】
Unity Shader是Unity为开发者提供的高层级的渲染抽象层,这样可以更加轻松地控制渲染。
如果没有使用Unity Shader,需要和很多文件和设置打交道;而在Unity Shader帮助下,开发者只需要使用ShaderLab来编写Unity Shader文件就可以完成所有的工作。
一个Unity Shader的基础结构:
Unity会根据使用的平台来把这些结构编译成真正的代码和Shader文件,而开发者只需要和Unity Shader打交道即可。
【材质和Unity Shader的桥梁:Properties】
这些属性将出现在材质面板中,开发者能够方便地调整各种材质属性。
为了在Shader中可以访问到这些属性,需要在CG代码片中定义和这些属性类型相匹配的变量。
【SubShader】
一个Unity Shader文件可以包含多个SubShader语义块,最少一个。
当Unity加载这个Unity Shader时,会扫描所有的SubShader语义块,然后选择第一个能够在目标平台上运行的SubShader。如果都不支持,就会使用Fallback语义指定的Unity Shader。
语义块:
RenderSetup:状态
Tags:标签。
每个Pass定义了一次完整的渲染流程,如果Pass数目过多,往往会造成渲染性能的下降。
常见的渲染状态[RenderSetup]设置:
当在SubShader块中设置了上述渲染状态时,将会应用到所有的Pass。也可以在Pass语义块中单独进行上面的设置。
【SubShader标签】
是一个键值对,都是字符串类型。它们是SubShader和渲染引擎之间的沟通桥梁。它们用来告诉Unity引擎:SubShader我希望怎样以及何时渲染这个对象。
标签结构:
SubShader支持的标签类型:
上述标签仅可以在SubShader中声明,而不可以在Pass块中声明。Pass块中的标签不同于SubShader的标签类型。
【Pass语义块】
1)Name说明
Name为该Pass的名称,比如:Name "MyPassName",通过这个名称,可以使用ShaderLab的UsePass命令来直接使用其他Unity Shader中的Pass,比如:
UsePass "MyShader/MYPASSNAME"
这样可以提高代码的复用性。由于Unity内部会把所有Pass的名称转换成大写字母的表示,因此在使用UsePass命令时必须使用大写形式的名字。
2)RenderSetup设置渲染状态
SubShader的状态设置同样适用于Pass。
3)Tags标签
与SubShader的标签不同。
4)特殊的Pass
- UsePass:使用该命令来复用其他Unity Shader中的Pass
- GrabPass:抓取屏幕并将结果存储在一张纹理中,以用于后续的Pass处理。
【Fallback】
作用:告诉Unity,如果上面所有的SubShader在这块显卡上都不运行,那么就使用这个最低级的Shader吧。
可以通过一个字符串来告诉Unity这个最低级的Unity Shader是谁,也可以任性地关闭Fallback功能。
例子:
Fallback影响阴影投射:在渲染阴影纹理时,Unity会在每个Unity Shader中寻找一个阴影投射地Pass。通常情况下,我们不需要专门实现一个Pass,因为Fallback使用的内置Shader中包含了这样一个通用的Pass。
【Unity Shader形式】
【表面着色器Surface Shader】
表面着色器是Unity对顶点/片元着色器的更高一层的抽象。它存在的价值在于,Unity为我们处理了很多光照细节,使得我们不需要再操心这些事情。
例子:
表面着色器被定义在SubShader语义块(而非Pass语义块)中的CGPROGRAM和ENDCG之间。
表面着色器不需要开发者关心使用多少个Pass、每个Pass如何渲染等问题,Unity会在背后为我们做好这些事情。
好比告诉Unity:使用这些纹理去填充颜色,使用这个法线纹理去填充法线,使用Lambert光照模型,其他的不要来烦我。
【顶点/片元着色器Vertex/Fragment Shader】
例子:
顶点/片元着色器的代码也定义在CGPROGRAM和ENDCG之间,但是是写在Pass语义块内,而非SubShader内的。因为我们需要自己定义每个Pass需要使用的Shader代码。
5、数学基础
【笛卡尔坐标系】
xyz轴互相垂直,这些的基矢量被称为正交基 。
正交:相互垂直的意思。
【矢量加法的三角形定则】
【矢量的模】
【矢量的点积公式一】
几何意义就是投影。
【矢量的点积公式二】
【矢量的叉积】
会得到一个同时垂直于这两个矢量的新矢量。
【矩阵】
一个矩阵可以把一个矢量从一个坐标空间转换到另一个坐标空间。
矩阵乘法的性值:
1)不满足交换律:
2)满足结合律:
矩阵串接的转置:
【逆矩阵】
不是所有的矩阵都有逆矩阵,第一个前提就是,该矩阵必须是一个方阵。
如果一个矩阵有对应的逆矩阵,我们就说这个矩阵是可逆的,或者说是非奇异的。
一个矩阵的行列式不为0,那么它就是可逆的。
逆矩阵的性值:
1)逆矩阵的逆矩阵是原矩阵。
2)单位矩阵的逆矩阵是它本身
3)转置矩阵的逆矩阵是逆矩阵的转置。
4)矩阵串接相乘后的逆矩阵等于反向串接各个矩阵的逆矩阵。
一个矩阵可以表示一个变换,而逆矩阵允许我们还原这个变换。
【正交矩阵】
一个方阵M和它的转置矩阵的乘积是单位矩阵的话,这个矩阵就是正交的。
如果一个矩阵是正交的,那么它的转置矩阵和逆矩阵是一样的。
Unity中,常规做法是把矢量放在矩阵的右侧,即把矢量转换成列矩阵来进行运算。
使用列向量的结果是,我们的阅读顺序是从右到左,即先对v使用A进行变换,再使用B进行变换,最后使用C进行变换。
【线性变换】
保留矢量加和标量乘的变换。
主要几何变换:旋转、缩放、错切、镜像、正交投影。
平移变换f(x)= x + (1,2,3)不满足f(x)+f(x)=f(x+x),因此不能用一个3*3矩阵来表示一个平移变换。
【仿射变换】
合并线性变换和平移变换的变换类型,可以用一个4*4的矩阵来表示,此时可以表示平移、旋转和缩放。
需要把矢量扩展到思维空间下,这就是齐次坐标空间。
【基础变换矩阵】
M3*3用于表示旋转和缩放,t3*1用于表示平移,01*3是零矩阵,右下角的元素就是标量1。
1)平移变换
2)缩放变换
3)旋转变换
以下特指绕着x\y\z轴进行变换。
4)复合变换
把平移、旋转和缩放组合起来形成一个复杂的变换过程。
变换公式是:
约定的变换顺序:先缩放、再旋转、最后平移。
【坐标空间变换】
要想定义一个坐标空间,必须指明其原点位置和3个坐标轴的方向。
坐标空间会形成一个层次结构:每个坐标空间都是另一个坐标空间的子空间,反过来说,每个空间都有一个父坐标空间。对坐标空间的变换实际上就是在父空间和子空间之间对点和矢量进行变换。
变换矩阵可以通过坐标空间C在坐标空间P中的原点和坐标轴的矢量表示来构建出来:把3个坐标轴一次放入矩阵的前3列,把原点矢量放到最后一列,再用0和1填充最后一行即可。
因为矢量是没有位置的,所以坐标空间的原点变换是可以忽略的,即我们仅仅平移坐标系的原点是不会对矢量造成任何影响的,所以变换矩阵可以优化为:
通过求解Mc->p的逆矩阵的方式求解出反向变换Mp->c。
如果Mc->p是一个正交矩阵,那么Mc->p的逆矩阵就等于它的转置矩阵。
如果我们知道坐标空间变换矩阵MA->B是一个正交矩阵,那么我们可以提取它的第一列来得到坐标空间A的x轴在坐标空间B下的表示,还可以提取它的第一行来得到坐标空间B的x轴在坐标空间A下的表示。
反过来,如果我们知道坐标空间B的x轴、y轴和z轴(必须是单位矢量,否则构建出来的就不是正交矩阵了)在坐标空间A下的表示,就可以把它们依次放在矩阵的每一行就可以得到从A到B的变换矩阵了。
【模型空间】
也被称为局部空间。
每个模型都有自己独立的坐标空间,当它移动或旋转的时候,模型空间也会跟着它移动和旋转。把我们当成游戏中的模型的话,当我们在办公室里移动时,我们的模型空间也在跟着移动,当我们转身时,我们本身的前后左右方向也在跟着改变。
【世界坐标】
它时一个特殊的坐标系,因为它建立了我们所关心的最大的空间。
如果一个Transform没有任何父节点,那么这个位置就是在世界坐标系中的位置。
顶点变换的第一步,就是将顶点坐标从模型空间变换到世界空间中,这个变换叫做模型变换。
在世界空间中,模型进行了(2,2,2)的缩放,又进行了(0,150,0)的旋转以及(5,0,25)的平移。这里的变换顺序是不能互换的,即先进行缩放,再进行旋转,最后是平移。
第2个矩阵是y轴的旋转矩阵。
【观察空间view space】
也被称为摄像机空间。
观察空间和屏幕空间是不同的。观察空间是一个三维空间,而屏幕空间是一个二维空间。从观察空间到屏幕空间的转换需要经过投影操作。
顶点变换的第二步,就是将顶点坐标从世界空间变换到观察空间中,这个变换叫做观察变换。
摄像机在世界坐标中所作的变换。
求变换矩阵,一种方式是构建从观察空间到世界空间的变换矩阵 然后再求逆。另一种方法是让摄像机原点位于世界坐标的原点,坐标轴与世界空间中的坐标轴重合即可。
摄像机在世界空间中的变换是按(30,0,0)进行旋转,然后按(0,10,-10)进行平移。那么为了把摄像机重新移回到初始状态,我们需要进行逆向变换,即先按(0,-10,10)平移,再按(-30,0,0)进行旋转,以便让坐标轴重合。
由于观察看空间使用的右手坐标系,世界空间是左手坐标系,因此需要对z分量进行取反操作。
【裁剪空间】
顶点接下来要从观察空间转换到裁剪空间(也被称为齐次裁剪空间)。用于变换的矩阵叫做裁剪矩阵,也被称为投影矩阵。
裁剪空间的目标是能够方便地对渲染图元进行裁剪:完全位于这块空间内部的图元将会被保留,完全位于这块空间外部的图元将会被剔除,而与这块空间边界相交的图元就会被裁剪。那么这块空间如何决定的呢?是由视锥体决定的。
视锥体是空间中的一块区域,这块区域决定了摄像机可以看到的空间。视锥体由6个平面包围而成,这些平面被称为裁剪平面。视锥体有两种类型,这涉及两种投影类型:正交投影和透视投影。
在透视投影中网格是近大远小,地板上的平行线不会保持平行。
在正交投影中,所有网格大小都一样,而且平行线一直保持平行。
透视投影模拟了人眼看到世界的方式,而正交投影则完全保留了物体的距离和角度。
视锥体的6个面:
通过Camera组件的Field of View(FOV)属性来改变视锥体竖直方向的张开角度,而Clipping Planes中的Near和Far参数可以控制视锥体的近裁剪平面和远裁剪平面距离摄像机的远近,这样可以求出视锥体近裁剪平面和远裁剪平面的高度。横向的信息可以通过摄像机的横纵比得到。
现在可以根据已知的Near、Far、FOV和Aspect的值来确定透视投影的投影矩阵:
【屏幕空间】
经过投影矩阵的变换后,可以进行裁剪操作了。当完成了所有的裁剪工作后,就需要进行真正的投影了,需要把视锥体投影到屏幕空间。经过这一步变换,就会得到真正的像素位置,而不是虚拟的三维坐标。
屏幕空间是二维空间,因此必须把顶点从裁剪空间投影到屏幕空间中,来生成对应的2D坐标。
在Unity中,从裁剪空间到屏幕空间的转换是由Unity帮我们完成的,顶点着色器只需要把顶点转换到裁剪空间即可。
【顶点的空间变换过程】
顶点着色器的最基本的任务就是把顶点坐标从模拟空间转换到裁剪空间中。
在片元着色器中,可以得到该片元在屏幕空间的像素位置。
Unity中各个坐标空间的旋向性:
【法线】
模型的一个顶点往往会携带额外的信息,而顶点法线就是其中一种信息。
当我们变换一个模型的时候,不仅需要变换它的顶点,还需要变换顶点法线,以便在后续处理中计算光照等。
一般来说,点和绝大部分方向矢量都可以使用同一个4*4或3*3的变矩阵MA->B把其从坐标空间A变换到坐标空间B中。但在变换法线的时候,如果使用同一个变换矩阵,可能就无法确保维持法线的垂直性。
【切线】
切线往往也是模型顶点携带的一种信息,它通常与纹理空间对齐,而且与法线方向垂直。
由于切线是由两个顶点之间的差值计算得到的,因此可以直接使用用于变换顶点的变换矩阵来变换切线。
其中TA/TB分别表示在坐标空间A和B下的切线方向。
如果直接使用MA->B来变换切线,得到的新法线方向可能就不会与表面垂直了。
如果变换只包含旋转变换,那么这个变换矩阵就是正交矩阵。
【齐次坐标】
点坐标:就是3D世界里的门牌号(X,Y,Z)
空间转换:把方向从一个参考系换到另一个
齐次坐标:给点坐标加一个W=1(给方向加w=0),让一个4*4变换矩阵能同时搞定移动、旋转、缩放。平时使用的Vector3,Unity在背后默默都用Vector4和4*4矩阵算好了所有变换。
【内置转换矩阵】
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的逆矩阵,用于将顶点/方向矢量从世界空间变换到模型空间
6、Shader基础编码
(1)创建Shader使用案例
Assets -> Create -> Shader -> Standard Surface Shader
创建完放到Assets -> Shaders目录下,命名为SimpleShader2。
创建材质:Assets -> Create -> Material,
创建完放到Assets -> Material目录下,命名为SimpleShaderMat。
在Material上应用Shader:
在SimpleShaderMat的Inspector的Shader中选择Custom -> SimpleShader2(刚创建的Shader)。
点击Open重写Shader的代码
代码如下:
Shader "Custom/SimpleShader2"
{SubShader{Pass{CGPROGRAM#pragma vertex vert#pragma fragment frag float4 vert(float4 v: POSITION) : SV_POSITION{return mul(UNITY_MATRIX_MVP, v);}float4 frag() : SV_Target{return fixed4(1.0, 1.0, 1.0, 1.0);}ENDCG}}
}
得到的效果如下:
代码解释:
1)Properties语义并不是必需的,所以可以选择不声明任何材质属性。
2)CG代码片段在CGPROGRAM和ENDCG之间
3)以下格式指明了顶点着色器和片段着色器的函数
#pragma vertex name
#pragma fragment name
name是我们指定的函数名,名字不一定是vert和frag。
4)vert(float4 v:POSITION):SV_POSITION是顶点着色器代码,它是逐顶点执行的。
vert函数的输入v包含了这个顶点的位置,这是通过POSITION语义指定的。它的返回值是一个float4类型的变量,它是该顶点在裁剪空间中的位置,POSITION和SV_POSITION都是CG/HLSL中的语义,它们是不可省略的,这些语义将告诉系统用户需要哪些输入值,以及用户的输出是什么。例如:POSITION将告诉Unity,把模型的顶点坐标填充到输入参数中,SV_POSITION将告诉Unity,顶点着色器的输出是裁剪空间中的顶点坐标。
在上面的vert函数中,就是把顶点坐标从模型空间转换到裁剪空间中。
在frag函数中没有任何输入。它的输出是一个fixed4类型的变量,并且使用了SV_Target语义进行限定。SV_Target也是HLSL中的一个系统语义,它等同于告诉渲染器,把用户的输出颜色存储到一个渲染目标(render target)中,这里将输出到默认的帧缓存中。
(2)更复杂案例
得到模型上每个顶点的纹理坐标和法线方向。
使用纹理坐标来访问纹理,而法线可用于计算光照。
代码:
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'Shader "Custom/SimpleShader2"
{SubShader{Pass{CGPROGRAM#pragma vertex vert#pragma fragment frag // 使用一个结构体来定义顶点着色器的输入struct a2v{// POSITION语义告诉Unity,用模型空间的顶点坐标填充vertex变量float4 vertex: POSITION;// NORMAL语义告诉Unity,用模型空间的法线方向填充normal变量float3 normal: NORMAL;// TEXCOORD0语义告诉Unity,用模型的第一套纹理填充texcoord变量float4 texcoord: TEXCOORD0;};float4 vert(a2v v) : SV_POSITION{return UnityObjectToClipPos(v.vertex);}float4 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;......
};
Semantic语义部分是不可以被省略的。
填充到POSITION,TANGENT,NORMAL这些语义中的数据是从哪里来的呢?在Unity中,它们是由使用该材质的Mesh Render组件提供的。在每帧调用Draw Call的时候,Mesh Render组件会把它负责渲染的模型数据发送给Unity Shader。一个模型通常包含了一组三角面片,每个三角面片由3个顶点构成,而每个顶点又包含了一些数据,例如顶点位置、法线、切线、纹理坐标、顶点颜色等。通过上面的方法,我们就可以在顶点着色器中访问顶点的这些模型数据。
【顶点着色器和片元着色器之间的通信】
Shader "Custom/SimpleShader2"
{SubShader{Pass{CGPROGRAM#pragma vertex vert#pragma fragment frag // 使用一个结构体来定义顶点着色器的输入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;// Color语义可以用于存储颜色信息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);}ENDCG}}
}
v2f用于在顶点着色器和片元着色器之间传递信息。
得到的效果:
顶点着色器的输出结构中,必须包含一个变量,它的语义是SV_POSITION。否则,渲染器将无法得到裁剪空间中的顶点坐标,也就无法把顶点渲染到屏幕上。
片元着色器中的输入实际上是把顶点着色器的输出进行插值后得到的结果。
【使用属性】
属性是材质和Shader沟通的参数。
材质提供给我们一个可以方便地调节Unity Shader中参数地方式,通过这些参数,我们可以随时调整材质的效果。而这些参数就需要写在Properties语义块中。
新的代码:
Shader "Custom/SimpleShader2"
{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{// 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;// Color语义可以用于存储颜色信息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{fixed3 c = i.color;// 使用_Color属性来控制输出颜色c *= _Color.rgb;return fixed4(c, 1.0);}ENDCG}}
}
(3)内置文件与变量
文件后缀 .cginc
在编写Shader时,可以使用#include指令把这些文件包含进来,这样就可以使用Unity为我们提供的一些非常有用的变量和帮助函数。
#CGPROGRAM
// ...
#include "UnityCG.cginc"
// ...
ENDCG
windows上的文件位于:D:\programs\unity_editor\2022.3.42f1c1\Editor\Data\CGIncludes
- UnityCG.cginc:包含了最常使用的帮助函数、宏和结构体等
- UnityShaderVariables.cginc:在编译Unity Shader时,会被自动包含进来。包含了许多内置的全局变量,如UNITY_MATRIX_MVP等
- Lighting.cginc:包含了各种内置的光照模型,如果编写的是Surface Shader的话,会自动包含进来
- HLSLSupport.cginc:在编译Unity Shader时,会被自动包含进来。声明了很多跨平台编译的宏和定义。
(4)CG/HLSL语义
顶点着色器、片元着色器的输入输出变量后的一个冒号和一个全部大写的名称,比如SV_POSITION、POSITION、COLOR0等。
SV开头:system-value semantics,SV代表的含义就是系统数值。这些语义在渲染流水线中有特殊的含义。比如:使用SV_POSITION语义去修饰顶点着色器的输出变量pos,那么就表示pos包含了可用于光栅化的变换后的顶点坐标(即齐次裁剪空间中的坐标)。用这些语义描述的变量是不可以随便赋值的,因为流水线需要使用它们来完成特定的目的,例如渲染引擎会把用SV_POSITION修饰的变量经过光栅化后显示在屏幕上。
(5)Unity支持的常用语义
1)从应用阶段传递模型数据给顶点着色器时Unity支持的常用语义
- POSITION:模型空间中的顶点位置,通常时float4类型
- NORMAL:顶点法线,通常是float3类型
- TANGENT:顶点切线,通常是float4类型
- TEXCOORDn:如TEXCOORD0、TEXCOORD1,该顶点的纹理坐标,TEXCOORD0表示第一组纹理坐标,依次类推,通常是float2或float4类型
- COLOR:顶点颜色,通常是fixed4或float4类型
2)从顶点着色器传递数据给片元着色器时Unity使用的常用语义
- SV_POSITION:裁剪空间中的顶点坐标,结构体中必须包含一个用该语义修饰的变量
- COLOR0:通常用于输出第一组顶点颜色,但不是必需的
- COLOR1:通常用于输出第二组顶点颜色,但不是必需的
- TEXCOORD0~TEXCOORD7:通常用于输出纹理坐标,但不是必需的
除了SV_POSITION是有特别含义外,其他语义对变量的含义没有明确要求,也就是说,我们可以存储任意值到这些语义描述变量中。
通常,如果我们需要把一些自定义的数据从顶点着色器传递给片元着色器,一般选用TEXCOORD0等。
3)片元着色器输出时Unity支持的常用语义
SV_Target:输出值将会存储到渲染目标(render target)中。
(6)Debug
【假彩色图像方法】
使用假彩色图像:把需要调试的变量映射到[0,1]之间,把它们作为颜色输出到屏幕上,然后通过屏幕上显示的像素颜色来判断这个值是否正确。
如果要调试的数据是一个一维数据,那么可以选择一个单独的颜色分量(如R分量)进行输出,而把其他颜色分量置为0。如果是多维数据,可以选择对它的每一个分量单独调试,或者选择多个颜色分量进行输出。
参考代码:
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'Shader "Custom/FalseShader"
{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 = UnityObjectToClipPos(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;return o;}fixed4 frag(v2f i): SV_Target{return i.color;}ENDCG}}
}
【帧调试器】
位于Windows -> Analysis -> Frame Debugger。
(7)渲染纹理的坐标差异
(8)Shader整洁之道
1)CG/HLSL中3种精度的数值类型
float:最高精度的浮点值
half:中等精度的浮点值
fixed:最低精度的浮点值
桌面GPU会把所有计算都按最高的浮点精度进行计算,也就是说,float、half、fixed在这些平台上实际是等价的。
但在移动平台的GPU上,它们的确会有不同的精度范围,而且不同精度的浮点值的运算速度也会有所差异。
尽可能使用精度较低的类型,因为这可以优化Shader的性能,这一点在移动平台上尤其重要。
2)Shader Model
由微软提出的一套规范,它们决定了Shader种各个特性的能力。这些特性和能力体现在Shader能使用的运算指令数目、寄存器个数等各个方面。
Shader Model等级越高,Shader的能力就越大。
虽然更高等级的Shader Target可以让我们使用更多的临时寄存器和运算指令,但一个更好的方法是尽可能减少Shader中的运算,或者通过预计算的方式来提供更多的数据。