本系列为作者学习UnityShader入门精要而作的笔记,内容将包括:
总之适用于同样开始学习Shader的同学们进行有取舍的参考。
(该系列笔记中大多数都会复习前文的知识,特别是前文知识非常重要的时候,这是为了巩固记忆,诸位可以直接通过目录跳转)
上一节我们学习了Unity Shader的定义、使用方法和基本结构。让我们回忆一下。
我们在学习渲染流水线的时候了解到,Shader是用于着色器编程的。我们首先要区分,Unity中的Shader和我们在渲染流水线中的Shader不是同一个东西,Unity中的Shader统称为Unity Shader。如果开发者需要配置渲染流水线,就需要进行诸多配置以及着色器代码编写,其中涉及的配置和文件实在太多。
Unity为这些工作集成了一个方便的抽象层,也就是Unity Shader 。我们仅需使用ShaderLab来编写Unity Shader就可以完成上述一系列工作。因此ShaderLab定义了一个材质所需的所有东西,而不仅仅是着色器代码。
我们在为3D网格渲染的时候,离不开两个东西:材质(material)和Shader,一个网格的渲染设置需要经过下面的步骤:
Material本质上是一个数据集,它包含了Shader的渲染设置,以及Shader需要读取的那些数据(例如Texture纹理数据和Map贴图)。
Unity现在提供了五种Shader模板用于创建:
Standard Surface Shader会产生一个包含标准光照模型的表面着色器模板。
Unlit Shader会产生一个不包含光照(但包含雾效)的基本的顶点/片元着色器。
Image Effect Shader为我们实现各种屏幕的后处理效果提供一个基本的模板。
Compute Shader 会产生一种特殊的Shader 文件,这类Shader旨在利用GPU的并行性来进行一些与常规渲染流水线无关的计算。
高版本还提供了Ray Tracing Shader,一种用于利用RTX显卡实现光线追踪的Shader。
通过在Shader的Import面板上可以看到Shader的一些基本信息,例如Import Setting面板上能看到那些会显示在材质上的属性。Import Object面板则能够看到该Shader的Tags以及定义的Properties。
最后我们学习了Shader的代码结构:
Shader "ShaderName"
{
Properties
{
//属性
Name ("displayName",PropertyType) = DefaultValue
}
SubShader
{
//显卡A使用的子着色器
[Tag]
[RenderSetup]
pass{
[Name]
[Tag]
[RenderSetup]
}
}
SubShader
{
//显卡B使用的子着色器
}
Fallback "VertexLit"
}
上述代码是一个最简单的Shader代码结构,主要可以分为三个代码块:Properties
、SubShader
、pass
。以ShaderName为开头,以Fallback收尾。
我们自上而下来解析ShaderLab的代码结构:
首先第一行是Shader "ShaderName"
,这行指定了Shader的名称以及路径。格式是Shader "一级目录/二级目录/ShaderName"
(层级可以更多)。
接着是Properties
,定义了该Shader的属性,这些属性就是一些可调整可访问的变量。以该格式定义:
Name ("displayName",PropertyType) = DefaultValue
其中Name代表了属性名(如果想用C#代码调用则需要按照这个字符串访问,通常Name的起名是下划线加大驼峰,例如_MainTex
),displayname代表了该属性在面板上显示的名称,PropertyType定义了该属性的类型,DefaultValue则是该Shader首次被赋值给材质后的初始值。
属性类型 | 默认值的定义语法 | 例子 |
---|---|---|
Int | number | _Int(“Int”,Int) = 2 |
Float | number | _Float(“Float”,Float) = 1.5 |
Range(min,max) | number | _Range(“Range”,Range(0.0,5.0)) = 3.0 |
Color | (number,number,number,number) | _Color(“Color”,Color) = (1,1,1,1) |
Vector | (number,number,number,number) | _Vector(“Vector”,Vector) = (2,3,6,1) |
2D | “defaulttexture”{} | _2D(“2D”,2D) = “” {} |
Cube | “defaulttexture”{} | _Cube(“Cube”,Cube) = “white” {} |
3D | “defaulttexture”{} | _3D(“3D”,3D) = “black” {} |
上表展示了一些属性类型以及使用样例
SubShader是Shader的主要部分,其内部定义了一系列Pass 以及各类可选的设置([Tag],[RenderSetup]):
状态名称 | 设置指令 | 解释 |
---|---|---|
Cull | Cull Back | Front | Off | 设置剔除模式:剔除背面/正面/关闭剔除 |
ZTest | ZTest Less Greater |LEqual |GEqual | Equal | NotEqual | Always | 设置深度测试时使用的函数 |
ZWrite | ZWrite On | Off | 开启/关闭深度写入 |
Blend | Blend SrcFactor DstFactor | 开启并设置混合模式 |
Tag是一个键值对(Key/Value Pair),其键和值都是字符串类型的。这些键值对是SubShader和渲染引擎之间的沟通桥梁,它们告诉Unity的渲染引擎我们希望怎样以及何时渲染这个对象:
标签的结构如下:
// 不同标签用空格隔开
Tags {"TagNamel" = "Valuel " "TagName2" = "Value2" }
标签类型 | 说明 | 例子 |
---|---|---|
Queue | 控制渲染顺序,指定该物体属于哪一个渲染队列,通过这种方式可以保证所有的透明物体可以在所有不透明物体后面被渲染。我们也可以自定义使用的渲染队列来控制物体的渲染顺序 | Tags {“Queue” = “Transparent“} |
RenderType | 对着色器进行分类,例如这是一个不透明的着色器,或是一个透明的着色器。这可以被用于着色器替换(Shader Replacement)功能。 | Tags {“RenderType” = “Opaque“} |
DisableBatching | 一些SubShader在使用Unity的批处理功能会出现问题,例如使用了模型空间下的坐标进行顶点动画。这时可以通过该标签来直接指明是否对该SubShader使用批处理 | Tags {“DisableBatching" = “True”} |
ForceNoShadowCasting | 控制使用该SubShader的物体是否会投射阴影 | Tags {“ForceNoShadowCasting" = “True”} |
IgnoreProjector | 如果该标签值为“True”,那么使用该SubShader的物体将不会收到Projector(projector指Unity中的投影仪组件)的影响。通常用于半透明物体 | Tags {“IgnoreProjector" = “True”} |
CanUseSpriteAtlas | 当该SubShader是用于Sprite时,将该标签设为“False” | Tags {“CanUseSpriteAtlas" = “False”} |
PreViewType | 指明材质面板将如何预览该材质。默认情况下,材质将显示为一个球,我们可以通过把该标签的值设为"Plane""SkyBox"来改变预览类型 | Tags {“PreViewType" = “Plane”} |
在SubShader下的RenderSetup会影响其内部的所有Pass的渲染设置。而Pass中的RenderSetup则只会影响自身。就像全局变量与局部变量的关系。
每个Pass块都定义了一次完整的渲染流程,一个SubShader块中可以有多个Pass块,意味着该Shader每帧渲染都需要经过多次Pass块的渲染。所以为了渲染效率考虑,我们还是尽量少定义Pass块。除非真的有必要使用多个Pass。
我们可以为每个Pass定义其Name ,Tag和RenderSetup。我们可以用下列语句定义Pass的名称:
Name "MyPassName"
通过定义Pass名称, 我们可以使用UsePass 命令来直接使用其他Unity Shader中的Pass。
UsePass "MyShader/MYPASSNAME"
使用方法是UsePass加上使用的Shader名和其内部定义的Pass名,且Pass名需要转为全拼大写。
标签类型 | 说明 | 例子 |
---|---|---|
LightMode | 定义该Pass在Unity的渲染流水线中的角色 | Tags {“LightMode” = “ForwardBase”} |
RequireOptions | 用于指定当满足某些条件时才渲染该Pass,它的值是一个由空格分隔的字符串,目前Unity支持的选项有:SoftVegetation。在后续版本可能增加更多的选项(喜报,根据Unity官方文档,直至2023.2版本依然只有这一个选项) | Tags {“RequireOptions” = “SoftVegetation”} |
同样的,Pass也可以设置标签(注意,Pass设置的标签和SubShader的标签不同,注意区分二者)
Unity Shader还支持一些特殊的Pass,以进行代码复用或实现更复杂的效果:
在Unity Shader中,我们也可以定义多个SubShader块。在加载Shader时,Unity会扫描所有的SubShader块,然后选择第一个能在平台上运行的SubShader块。
这是由于不同显卡对于操作指令的支持能力不同,高级的显卡能进行更复杂的着色器计算,而低级显卡可能不支持。因此我们想要程序支持多个显卡,就需要由高到低定义多个SubShader块。
如果所有的SubShader块都不支持渲染,那么会执行Fallback语句。该语句会在所有的SubShader块都失败后执行指定的Shader,语法如下:
Fallback "Name"
// 或者
Fallback Off
// 最常用的Fallback
Fallback "VertexLit"
我们可以指定一个Shader的Name,也可以Fallback Off关闭Fallback功能。
尽管Unity Shader可以做的事情非常多(例如设置渲染状态等),但最重要的任务还是指定各种着色器所需的代码。这些着色器代码可以写在SubShader语句块中(表面着色器),也可以写在Pass语句块中(顶点/片元着色器和固定函数着色器)
表面着色器(Surface Shader) 是Unity自己创作的一种着色器代码类型,它的本质和顶点/片元着色器(VF) 是一样的。我们定义了一个表面着色器,Unity做的工作仍然是将其转换为顶点/片元着色器再处理。因此表面着色器可以看作Unity对顶点/片元着色器的更高一层抽象。使用表面着色器,Unity帮我们处理了很多光照细节,我们就不必要去操心哪些烦人的事情。
一个简单的表面着色器示例如下:
Shader "Custom/Simple Surface Shader"{
SubShader{
Tags{ "RenderType" = "Opaque"}
// 以CGPROGRAM开头代表下列代码使用了CG编写
CGPROGRAM
// #pragma为预处理指令,声明surface表面着色器将使用函数surf作为表面函数,Lambetr作为光照函数(其中surf是自定义的,Lambert是Unity自带的光照函数)
#pragma surface surf Lambetr
// 定义结构体Input
struct Input{
float4 color: COLOR;
};
//定义Input类型的形参IN,以及SurfaceOutput类型的形态o,
// 其中inout是一个关键字
void surf (Input IN, inout SurfaceOutput o){
o.Albedo = 1;
}
// 结束CG
ENDCG
}
Fallback "Diffuse"
}
上述表面着色器定义在了SubShader语句块 (而非Pass语句块中)中的CGPROGRAM和ENDCG之间。当我们使用表面着色器,就不需要关心使用多少个Pass,这些Unity会帮我们处理好,我们要做的只是定义好使用的表面函数和光照函数即可。
CGPROGRAM和ENDCG中的代码是使用CG/HLSL编写的。语法上和CG/HLSL的语法基本一致。
既然上述代码中使用到了inout,我们可以简单讲一下这个关键字。其实类比C#中的in,out关键字以及C++的指针就方便理解了。
void surf (Input IN, inout SurfaceOutput o){
o.Albedo = 1;
}
我们定义了一个函数,这个函数有两个形参,分别是IN和o。其中IN我们没有指定关键字(限定符),而o指定了inout关键字。在CG/HLSL/GLSL中,函数形参可以通过关键字来定义:
限定符 | 说明 |
---|---|
in | 如果不使用限定符时,默认形参的限定符就是in ,传入函数的形参实际上是实参的复制值。in代表了对实参可读不可写(形参由实参的复制值得到,且修改形参不影响实参) |
out | out代表了对实参可写不可读(相当于指针,out修饰的形参指向这个实参本身,修改时会影响实参的值) |
inout | 对实参可读且可写,传入形参的是实参的复制值,但是函数结束后会根据这个值对应地修改实参 |
在Unity中我们可以使用CG/HLSL来编写顶点/片元着色器(Vertex/Fragment Shader,简称VF) 。它们更加复杂,但灵活度也更高。
下面是一个顶点/片元着色器的示例代码:
Shader "Custom/Simple VertexFragment Shader"{
SubShader{
Pass{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
float4 vert(float4 v : POSITION) : SV_POSITION {
return mul (UNITY_MATRIX_MVP, v);
}
fixed4 frag() : SV_Target {
return fixed4(1.0 , 0.0, 0.0 , 1.0);
}
ENDCG
}
}
}
与表面着色器相似,顶点/片元着色器代码也是定义在CGPROGRAM和ENDCG区块之间的。原因是,我们需要自己定义每个Pass需要使用的Shader代码,虽然我们可能需要编写更多的代码,但带来的好处是灵活性更高,更重要的是,我们可以控制渲染的实现细节。
表面着色器和顶点/片元着色器都使用了可编程的渲染管线。对于那些不支持可编程渲染管线的老式设备,我们只能使用固定函数着色器(Fixed Function Shader) 来完成渲染了,这些着色器只能完成一些简单的效果。
Shader "Tutorial/Basic" {
Properties {
_Color ("Main Color",Color) = (1, 0.5, 0.5, 1)
}
SubShader {
Pass {
Material {
Diffuse {_Color}
}
Lighting On
}
}
}
固定函数着色器是完全使用ShaderLab语法的。我们只能在Pass语句块中定义,这些代码相当于Pass中的一些渲染设置。(实际上这些固定函数着色器也会被Unity编译成对应的顶点/片元着色器)
那么我们究竟应该选择哪一种着色器进行Unity Shader的编写?
最重要的一点,不要单纯地去记忆,怎么使用,在何时使用,去理解,去掌握,知其然然后知其所以然。