|
本文为《Unity Shader入门精要》第五章内容的笔记。 本文相关代码,详见:
原书代码,详见原作者github:
1. 顶点/片元着色器
1.1 基本结构
Unity Shader的基本结构包含了Shader、Properties、SubShader、Fallback等语义块,结合顶点/片元着色器的代码,结构如下:
Shader "MyShaderName"
{
// 属性
Properties
{
}
// 针对显卡A的SubShader
SubShader
{
// 设置SubShader的渲染状态和标签
Pass
{
// 设置渲染状态和标签
// 开始CG代码片段
CGPROGRAM
// 该代码片段的编译指令,例如:
#pragma vertex vert
#pragma fragment frag
// CG代码写在这里
// 结束CG代码片段
ENDCG
// 其他设置
}
// 其他的Pass
Pass
{
}
}
// 针对显卡B的SubShader
SubShader
{
}
// 上述SubShader都失效后,用于回调的Unity Shader
Fallback "VertexLit"
}
上述代码,需要着重关注Pass语义块,绝大多数的顶点/片元着色器的代码都写在Pass里的CGPROGRAM、ENDCG之间。
1.2 案例常用操作说明
后续所有的案例代码编写都会涉及创建场景、创建Shader、创建材质、关闭天空盒等操作,在此做一下说明,后续将不再赘述。
为统一规范,我们在新建项目的Assets根目录下创建如下几个文件夹:
- Senes 存放场景
- Shaders 存放着色器代码
- Materials 存放材质
- Textures 存放贴图
- Models 存放案例需要用到的模型
- Scripts 存放案例需要编写的C#代码
- Prefabs 存放预制体
1.2.1 创建场景
选中Scenes文件夹,右键单击-Create-Scene,并重命名,即可在Scenes文件夹下创建一个新的场景。
1.2.2 创建Shader文件
选中Shaders文件夹,右键单击-Create-Shader-Unlit Shader,并重命名,即可在Shaders文件夹下创建一个新的Shader文件。
前面的笔记提到过,Unity为我们封装了很多Shader模板文件,其中Unlit Shader包含了最基本的顶点、片元着色器,以及雾效(暂时可忽视),是与我们学习内容比较接近的Shader模板,所以后续创建Shader文件,我们都是选择Unlit Shader进行创建。
编写Shader代码时,我们一般会将里面所有内容删除,从第一行代码开始写起。
1.2.3 创建材质
选中Materials文件件,右键单击-Create-Material,并重命名,即可创建材质文件。创建完后,选中这个材质文件,可在Inspector窗口中看到材质面板中的各个属性操作,以及最下方该材质的预览区域:
点击材质Inspector窗口中的Shader,会出现各种Shader选项的下拉框,选择不同的Shader。
选择不同的Shader,材质面板会呈现不同的样式和选项,我们可以修改Shader代码里的Properties来自定义材质面板的显示。
1.2.4 关闭天空盒
为了更清楚地看到Shader实现的效果,我们一般选择关闭天空盒,防止天空盒的颜色对渲染的物体产生影响。
选中对应场景后,在单击菜单栏-Window-Rendering-Lighting Settings(笔者使用的Unity版本是2019.4.10),不同的Unity版本打开Lighting面板的方式可能存在差异,具体可以网上查阅。在Lighting面板的Scene页签下,展开Environment后,有一个Skybox Material,将其选择为None即可:
可以看到,关闭天空盒后的场景周围变黑了:
1.2.5 往场景中添加物体
在场景的Hierarchy中,单击右键选择-3D Object-Sphere,即可在创建中创建一个球体:
读者们亦可选择其他物体进行创建。
1.2.6 更换物体材质
选中前面创建的球体,在Inspector窗口中,可看到Mesh Renderer组件,在该组件中,有一个Materials,里面有当前物体用到的所有材质,默认只定义了1个材质。点击Element 0那一行最右侧的小圆点,可更换物体的材质。
1.3 顶点/片元着色器案例
1.3.1 创建最简单的顶点/片元着色器
按照如下的步骤,做好编写着色器代码前的准备:
- 新建一个场景,命名为Scene_5_2,并关闭天空盒;
- 新建一个Unity Shader,命名为Chapter5-SimpleShader;
- 新建一个材质,命名为SimpleShaderMat,并选择步骤2创建的Shader。
- 新建一个球体,并更换材质为刚刚创建的SimpleShaderMat;
接下来开始编辑新创建的Unity Shader,一般在Unity中双击Chapter5-SimpleShader文件即可在Unity默认的文件编辑器中打开该文件,删掉里面原来的代码,替换成下面的代码:
// 第一行,通过Shader语义,定义这个Shader的名字,名字中使用'/'可定义该Shader的路径(或分组)
// 回到之前创建的材质,选择Shader,可以看到多了一个Unity Shaders Book目录,下面的子目录Chapter 5里面就有我们目前的Shader
Shader "Unity Shaders Book/Chapter 5/Simple Shader"
{
// Properties语义并不是必需的
SubShader
{
// SubShader中没有进行任何渲染设置和标签设置,
// 所以该SubShader将使用默认的设置
Pass
{
// Pass中没有进行任何渲染设置和标签设置,
// 所以该Pass将使用默认的设置
// 由 CGPROGRAM 和 ENDCG 包围 CG 代码片段
CGPROGRAM
// 告诉Unity,顶点着色器的代码在 vert 函数中 (格式:#pragma vertex [name])
#pragma vertex vert
// 告诉Unity,片元着色器的代码在 frag 函数中 (格式:#pragma fragment [name])
#pragma fragment frag
// 顶点着色器代码
// 通过 POSITION 语义,告诉顶点着色器,输入v是这个顶点的位置
// SV_POSITION 语义表示返回的float4类型的变量,是当前顶点在裁剪空间中的位置
// 注意:这两个语义是不能省略的,着色器通过语义来辨识各个变量是用来做什么的,并用在不同的底层处理中
float4 vert(float4 v : POSITION) : SV_POSITION
{
return UnityObjectToClipPos(v);
}
// 片元着色器代码
// 通过SV_Target语义,告诉渲染器,把用户的输出颜色存储到一个渲染目标中(比如:帧缓存中)
// 颜色的RGBA每个分量范围在[0, 1],所以使用fixed4类型
// (0, 0, 0)表示黑色,(1, 1, 1)表示白色
fixed4 frag() : SV_Target
{
return fixed4(1.0, 1.0, 1.0, 1.0);
}
ENDCG
}
}
}
笔者个人习惯将书中提到的代码说明写到代码注释中,一是原书中一大段说明文字容易让人产生倦意;二是出于程序员的本能,看代码逻辑比看需求文档更直接亲切,注释起到了补充说明的作用(也就是:我可以忽略它,但是当我看不懂代码逻辑时,它又能让我很好地理解逻辑)。后续笔记中,也将保持该习惯,望读者们理解。(建议将代码拷贝到编辑器中,阅读体验更佳。)
保存代码并回到Unity中可看到效果如下:
1.3.2 结构体
在顶点着色器或片元着色器的代码中,比如:顶点着色器函数的参数,除了需要当前顶点的坐标外,还需要诸如:法线、切线、纹理坐标等信息时,通常会定义一个结构体,在结构体声明多个变量,并赋予其语义,可将更多的顶点信息传入到顶点着色器中。
之前写的代码改成如下:
Shader "Unity Shaders Book/Chapter 5/Simple Shader Struct"
{
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// 使用结构体作为顶点着色器的输入,可以包含更多顶点信息
// a2v 是当前结构体的名字,可自行定义(写法:struct [StructName])
// 这里 a2v 表示 application to vertex ,意思是:把数据从应用阶段传递到顶点着色器中
struct a2v
{
// 模型空间的顶点坐标,相当于之前顶点着色器的输入v
float4 vertex : POSITION;
// 模型空间中,该顶点的法线方向,使用 NORMAL 语义
float3 normal : NORMAL;
// 该模型的第一套纹理坐标(模型可以有多套纹理坐标),第n+1套纹理坐标,用语义 TEXCOORDn
float4 texcoord : TEXCOORD0;
// 结构体里变量的书写格式:
// Type Name : Semantic;
};
// Unity支持的语义有:
// POSITION 、 NORMAL 、 TANGENT 、 TEXCOORD0 、 TEXCOORD1 、 TEXCOORD2 、 TEXCOORD3 、 COLOR 等
// 使用结构体作为输入参数,不需要写语义,因为语义在结构体里已经声明了
float4 vert(a2v v) : SV_POSITION
{
// 从结构体中取当前顶点的模型空间坐标,将其转为裁剪空间下的坐标
return UnityObjectToClipPos(v.vertex);
}
fixed4 frag() : SV_Target
{
return fixed4(1.0, 1.0, 1.0, 1.0);
}
ENDCG
}
}
}
1.3.3 着色器的数据从哪里来
在Unity中,POSITION(坐标)、TANGENT(切线)、NORMAL(法线)等语义所包含的数据由使用该材质的Mesh Render组件提供。每帧调用Draw Call的时候,Mesh Render组件会把它负责渲染的模型数据发送给Unity Shader。一个模型包含一组三角面片,每个三角面片由3个顶点构成,每个顶点包含一些数据,就是语义对应的数据,它们会被传入顶点着色器中。
1.3.4 顶点着色器和片元着色器的通信
前面提到,应用程序向顶点着色器传数据需要结构体,而顶点着色器向片元着色器的通信一样需要用到结构体。
参考代码如下:
Shader "Unity Shaders Book/Chapter 5/Simple Shader Struct V2F"
{
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
// 使用该结构体定义顶点着色器的输出
struct v2f
{
// SV_POSITION语义表示:pos存储了顶点在裁剪空间中的位置信息
float4 pos : SV_POSITION;
// COLOR0语义用于存储颜色信息,当需要存储更多颜色时,可继续用 COLOR1 、 COLOR2等
fixed3 color : COLOR0;
};
v2f vert(a2v v)
{
// 声明输出结构
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
// 将法线的值转变成颜色值,呈现到模型上(这里没有必然的法线和颜色的转换关系,仅作案例演示,无需纠结此段代码)
// 因为法线方向,各分量范围是[-1, 1],为了让其转变到颜色的范围[0, 1],故做如下运算:
o.color = v.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
return o;
}
// 将顶点输出的结构体传入片元着色器中
fixed4 frag(v2f i) : SV_Target
{
// 结构体里定义的color是fixed3类型(我们也可以将其定义为fixed4类型)
// 输出的颜色为fixed4类型,所以需要补上第四个分量
return fixed4(i.color, 1.0);
}
ENDCG
}
}
}保存代码后,回到Unity的Scene窗口,可以看到一个五彩斑斓的球体。
它可间接地反映每个顶点法线的方向变化。有时候,这种将法线转为颜色输出也可以作为Shader可视化调试的一种思路。
我们知道:
顶点着色器是逐顶点调用的,片元着色器是逐片元(像素)调用的,这意味着顶点着色器的调用次数远远小于片元着色器。为了让每个片元着色器都能有一个结构体的输入,Unity会将非顶点像素的片元着色器的输入结构体的数据由顶点着色器的输出经过插值后得到结果。
通俗理解就是:
三角形面片中某个像素点的数据是由面片上三个顶点根据其到该像素点的距离,按照各顶点距离比例取其部分数值累加后得到的结果(实际应该更复杂,大概是这个意思)。
1.3.4 属性的使用
一个Shader通常会被多个材质使用,不同的材质会对Shader进行不同的设置,而属性则是材质和Shader之间的通道,允许材质传入不同的值给Shader,以实现不同的视觉效果。
基于之前的Shader代码,给其添加一个颜色的属性:
Shader "Unity Shaders Book/Chapter 5/Simple Shader Property"
{
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)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.color = v.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
fixed3 color = i.color;
// 使用_Color属性来影响法线转换成的颜色效果
color *= _Color.rgb;
return fixed4(color, 1.0);
}
ENDCG
}
}
}保存代码并回到Unity,可以看到使用该Shader的材质,多了一个Color Tint属性的颜色选择器,我们修改Color Tint颜色,会发现原来五彩斑斓的球体出现了不同的颜色:
Shader代码中除了定义了属性外,还需要在CG代码声明属性的变量,这里附上ShaderLab属性类型和CG变量类型的匹配关系:
关于uniform关键字,例如:
uniform fixed4 _Color;uniform关键词是CG语言中修饰变量和参数的一种修饰词,仅用于提供一些该变量的初始值是如何指定和存储的相关信息,在Unity Shader中可以省略。(后续的编码中,基本也看不到uniform的身影)
2. Unity的内置文件和变量
2.1 内置的包含文件
Untiy内置的Shader文件,是类似C++中的头文件,以.cginc作为后缀名,使用#include指令引入,在CGPROGRAM和ENDCG之间编写。例如:
CGPROGRAM
//...
#include "UnityCG.cginc"
//...
ENDCG内置文件一般在Unity安装路径下的/Data/CGIncludes中可以找到:
常用的内置文件有4个:
包含了最常使用的辅助函数、宏和结构体;
- UnityShaderVariables.cginc
包含了许多内置的全局变量,如:UNITY_MATRIX_MVP等,在编译Unity Shader时,会被自动包含进来,所以我们写Shader无需显式地引入进来(#include UnityShaderVariables.cginc);
包含了各种内置的光照模型,若我们编写的是Surface Shader,它被自动包含进来;
声明了许多跨平台编译的宏和定义,在编译Unity Shader时,会被自动包含进来;
UnityCG.cginc是后续学习中经常被使用的内置文件,里面预先定义了一些结构体,其中使用频率较高的有:
UnityCG.cginc也预先定义了一些辅助函数,使用频率较高的有:
建议阅读源代码熟悉这些结构体和函数,后续我们会经常用到它们。
2.2 内置的变量
Unity提供了用于访问时间、光照、雾效和环境光等相关数据的变量,他们大多位于UnityShaderVariables.cginc中,与光照相关的变量位于Lighting.cginc、AutoLight.cginc中,这里我们先留个印象,以便后续用到的时候不至于摸不着头脑。
3. Unity提供的CG/HLSL语义
3.1 什么是语义
前面的Shader代码中,我们有看到POSITION、SV_POSITION、COLOR0等语义。通俗地讲,语义就是让Shader知道当前定义的变量,其数据是从哪里读取的。
比如:应用阶段会将模型的顶点坐标存到某个地方,顶点着色器传入的参数变量中声明了float4 v : POSITION,那么Shader就会根据POSITOIN这个语义,从应用阶段存储顶点坐标的地方获取坐标数据并赋值给v变量,这样后续Shader代码就会根据这个变量做一些渲染逻辑。
顶点着色器的输出也是类似:顶点着色器会将计算结果存储到当前变量声明的语义对应的地方,片元着色器的输入参数就会根据变量的语义去从顶点着色器存储数据的地方去读数据。
3.2 系统数值语义
DirectX 10以后出现了一种新的语义类型,就是系统数值语义(system-value semantics),以SV开头,这些语义通常用于渲染流水线的特定流程中,所以不能随便赋值,比如:SV_POSITION是用于光栅化后显示在屏幕上的语义,它在绝大多数平台上与POSITION是等价的,但是在某些平台上(比如PS4)上必须使用SV_POSITION来修饰顶点着色器的输出。
3.3 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。
从顶点着色器到片元着色器阶段的常用语义:
除SV_POSITION外,上述其他语义,我们可以存储任意值到这些语义描述的变量中,这里先留一个印象,后续学习中会遇到。
片元着色器输出常用的语义:
注意:
一个语义可以使用的寄存器只能处理4个浮点值(float),也就是:我们可以定义float4、float4x4类型,但是不能定义float5、float4x5类型。如果有需要,通常是将这个值拆分成多个变量来存储,比如:需要存储5个浮点值,可以分别存到float4和float这两个变量中。
4. Shader的调试
Unity虽然可以帮助我们发现Shader代码的编译错误,但是Shader代码逻辑错误导致的效果不对的问题,我们没办法像C++这样很方便得使用单步断点调试来找到。这里简单介绍几种可以调试的方法:
4.1 使用假彩色图像
这种方法前面我们有接触过,就是:将法线的数值由[-1, 1]的范围转变为[0, 1]的范围,并作为最终的颜色输出到屏幕上。说白了就是将一些数据映射到[0, 1]的范围输出到屏幕,将不可见的数据以颜色的形式可视化地呈现,然后利用“第六感”去发现问题。这里讲个大概,感兴趣可查看原书5.5.1章节的内容。
4.2 Visual Studio插件
Visual Studio有一个针对Unity Shader的插件——Graphics Debugger,可以通过它查看每个像素的最终颜色、位置等信息,也可以对顶点着色器、片元着色器进行单步调试。
4.3 Unity内置:帧调试器
Unity内置的帧调试器(Frame Debugger)可方便快捷地为我们呈现模型的每一帧渲染情况,单击菜单-Window-Analysis-FrameDebuger(不同版本打开方式可能不同),可打开帧调试器窗口。
单击Frame Debug窗口的左上角的Enable按钮,可看到渲染情况。
5. 渲染平台的差异
Unity具有跨平台的特性,大多数的平台差异Unity底层都帮我们处理了,但是依然有些差异需要我们自己处理。
5.1 渲染纹理的坐标差异
OpenGl和DirectX使用了不同的屏幕空间坐标:
在使用渲染到纹理技术时,这种平台差异导致的Y轴上下翻转的问题,Unity底层会为我们处理。但是在一种特殊情况下,Unity不会处理这个翻转,就是:开启抗锯齿的情况下。
在同时处理多张纹理条件下,如果开启抗锯齿,Unity首先渲染得到屏幕图像,再由硬件进行抗锯齿处理后,得到一张渲染纹理来供我们进行后续处理。此时在DirectX平台下,我们得到的输入屏幕图像并不会被Unity翻转。
这时候,我们需要在顶点着色器中翻转某些渲染纹理(例如:深度纹理或其他由脚本传递过来的纹理)的纵坐标,使之符合DirectX平台的规则。代码如下:
// 使用 UNITY_UV_STARTS_AT_TOP 来判断当前平台是否是 DirectX 类型的平台
#if UNITY_UV_STARTS_AT_TOP
// 开启抗锯齿后,主纹理的纹素大小在竖直方向上会变成负值,故使用该值来判断是否开启了抗锯齿
if (_MainTex_TexelSize.y < 0)
// 竖直竖直方向上进行翻转
uv.y = 1 - uv.y;
#endif
5.2 Shader的语法差异
有时候一个Shader在Mac平台(OpenGL)运行良好,但是在Windows平台(DirectX)却出现类似一些报错,比如:
incorrect number of arguments to numeric-type constructor (compiling for d3dll)或
output parameter &#39;o&#39; not completely initialized (compiling for the d3dll)这是因为DirectX 9/11对Shader语法更加严格。以下面的写法为例:
// v是float4类型,但在它的构造器中仅提供一个参数
// 等同于float4 v = float4(0.0, 0.0, 0.0, 0.0);
float4 v = float4(0.0);
这样的写法在OpenGL是合法的,但在DirectX 11平台上却会报错。
又例如Unity内置的tex2D函数(对纹理进行采样),在DirectX 9/11是不支持在顶点着色器中调用的,因为顶点着色器阶段Shader无法得到UV偏导,而tex2D函数需要这样的偏导信息。
5.3 Shader的语义差异
在一些平台上,SV_POSITION和POSITION是完全等价的,但是在另一些平台上两者是不等价的;
而对于SV_Target,有些平台可使用COLOR或COLOR0来等价代替,但是在索尼PS4上,两者却是不等价的。
6. Shader整洁之道
6.1 float、half、fixed精度
CG/HLSL中三种精度的数值类型:
不同的平台和GPU上,三者实际的精度可能和上面描述的不一致:
- 大多数现代桌面GPU会把所有计算按最高的浮点精度计算,即:float、half、fixed在这些平台上是等价的,都等同于float;
- 在移动平台GPU上,它们的确有不同的精度范围,不同的精度浮点运算速度会有差异;
- fixed精度在现代大多数GPU上,被当做和half同等精度对待;
但是在书写上,我们还是要保证尽可能使用精度较低的类型,以此来优化Shader的性能,尤其在移动平台上。
fixed用来存储颜色和单位矢量;half存储更大范围的数据;最差情况下再选择使用float。
6.2 规范语法
书写规范的语法,防止偏门的写法导致某些平台无法运行。比如:
// 规范的写法
float4 v = float4(0.0, 0.0, 0.0, 0.0);
// 不规范的写法
float4 v = float4(0.0);
6.3 避免不必要的计算
过多的运算需要更多的临时寄存器或指令,若寄存器数目或指令数目超过可支持的上限,会导致Shader无法正常运行。
如果不得已需要很多的寄存器或指令,可指定更高等级的Shader Target:
Shader Model:微软提出的一套规范,决定了Shader的各个特性的能力,比如:运算指令数目、寄存器个数等。
所有类似OpenGL平台,被当成是支持到Shader Model 3.0;而WP8/WinRT平台,只支持到Shader Model 2.0。
6.4 慎用分支和循环语句
对于分支和循环语句,GPU的实现和CPU是不同的,GPU处理这些语句会消耗更多的性能,降低并行处理的速度。因此Shader中使用分支/循环语句,建议:
- 能少用则少用;
- 每个分支包含的操作指令数尽可能少;
- 分支的嵌套层数尽可能少;
6.5 不要除以0
错误案例代码:
fixed4 frag(v2f i) : SV_Target
{
return fixed4(0.0 / 0.0, 0.0 / 0.0, 0.0 / 0.0, 1.0);
}对于除以0的情况,有些平台会直接报错,而有些平台不会,但是会得到不可预测的结果。
不得已情况下,可以考虑用if语句判断除数是否等于0,来做不同的分支处理。
写在最后
本文内容会同步发在笔者的公众号上,欢迎大家关注交流!
公众号:程序员叨叨叨(ID:i_coder) |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|