本篇文章转自CYAN编写的《Writing Shader Code for the Universal RP》,文中以一个完整的PBR Shader为案例讲解了URP管线的一些机制以及编写Shader时的注意事项。可以帮助我们少踩一些坑。
Shaderlab
Unity中的着色器文件中,使用Shaderlab语义来定义着色器的Properties、SubShader以及Pass,Pass中实际的着色器代码使用HLSL编写。
Shaderlab中的大多数内容相比过去的内置管线并没有改变太多,所以作者会以一个案例来拆解分析,而不会涉及太多细节的东西,可以通过官方文档查看更详细的内容。URP管线下需要注意的区别是“RenderPipeline”和“LightMode”的标签。
着色器块如下方的形式:
1 | Shader "Custom/UnlitShaderExample" |
在其内部,我们需要一个Properties语义块和SubShader语义块(其中含有Pass语义块):
1 | Properties |
“Properties”语义块是用来暴露需要显示在材质面板上的参数,这样同着色器生成的不同材质可以使用不同的贴图或颜色等等。
如果打算使用C#脚本来修改材质中的属性(如material.SetColor/SetFloat/SetVector等),则不需要在Properties语义块中定义。然而如果需要每个物体拒用不同的参数值,则需要在Properties中定义,否则SRP Batcher试图使用未暴露的参数对对象进行批处理时会出现渲染BUG。如果不是每个对象使用不同的参数值,那么使用Shader.SetGlobalColor/Float/Vector则更加方便。
1 | SubShader |
Unity会使用当前设备GPU支持的第一个SubShader语义块的内容,由于我们的标签设置为“RenderPipeline” = “UniversalRenderPipeline”,所以在内置管线和HDRP管线下不会执行此SubShader,而会尝试着色器中余下的SubShader。如果没有可支持的SubShader,那么则会显示为品红色的错误提示着色器。
“RenderType”标签在内置管线中实现Replacement Shader(URP不支持)时可以使用到。“Queue”标签指定物体渲染的顺序,可以用来指定半透材质物体的排序或用于模板(Stencil)相关的操作。可以在此处查看这些标签的信息。
可以在SubShader中定义多个Pass语义块,但是每个Pass的”LightMode”标签必须指定特定的类型。URP使用single-pass的前向渲染方式,只有第一个标签为”UniversalForward”的Pass(当前GPU支持的)会用被来渲染物体,所以不能渲染多个同类型标签的Pass。如果使用无标签的Pass,会破坏SRP Batcher的批处理。所以建议分开使用着色器或材质,用于不同的MeshRenderers或在前向渲染器中使用RenderObjects特性在特定的层使用一个overrideMaterial重新绘制物体。
Pass中还有一个”Name”,可以配合UsePass命令使用。其他着色器中存在一个你想使用的Pass时,使用这个方法就不需要再重复编写一次了。例如:
1 | UsePass "Custom/UnlitShaderExample/EXAMPLEPASS" |
这个Pass就会被包含在你的着色器中。但是为了与SRP Batcher兼容,所有的pass都要共享相同的UnityPerMaterial CBUFFER,使用UsePass时如果CBUFFER数据不同则会出现问题(未来可能会修复)。下一小节会介绍CBUFFER。
在Pass语义块中,也会常常看到Cull, ZWrite和ZTest。它们的默认值分别是Cull Back, ZWrite On和ZTest LEqual。半透队列中的着色器,还可以使用Blend(混合)操作。模板(Stencil)操作也可以在Pass语义块中定义。
完整的Shaderlab语义块如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30Shader "Custom/UnlitShaderExample" {
Properties
{
_BaseMap ("Example Texture", 2D) = "white" {}
_BaseColor ("Example Colour", Color) = (0, 0.66, 0.73, 1)
//_ExampleDir ("Example Vector", Vector) = (0, 1, 0, 0)
//_ExampleFloat ("Example Float (Vector1)", Float) = 0.5
}
SubShader
{
Tags {
"RenderType"="Opaque"
"Queue"="Geometry"
"RenderPipeline"="UniversalRenderPipeline" }
HLSLINCLUDE
...
ENDHLSL
Pass
{
Name "Example"
Tags { "LightMode"="UniversalForward" }
HLSLPROGRAM
...
ENDHLSL
}
}
}
HLSL
实际的着色器代码是在ShaderLab的各个Pass中使用HLSL(high level shading language)编写的。
内置管线的着色器基本都是使用CG语言编写的,但未来版本推荐使用HLSL,并且HDRP和URP管线的着色器都是基于HLSL的,可见未来将弃用CG语言。曾经使用的CGPROGRAM/CGINCLUDE和ENDCG在URP的着色器中要替换为HLSLPROGRAM/HLSLINCLUDE和ENDHLSL。因为CGPROGRAM等标签会自动包含一些内置的函数,会与URP中的一些函数产生重复定义的冲突。
标量类型变量
HLSL中通常包含下述标量数据类型:
- bool——true或false。
- float——32位浮点数。一般用于表示世界空间坐标和纹理坐标的单个元素的值;或者用于复杂的标量计算,如三角函数、幂函数和指数函数运算。
- half——16位浮点数,一般用于表示较短的向量、方向、模型空间坐标和颜色的单个元素的值。
- double——64位浮点数,不能用于输入或输出。
- fixed——只在内置管线着色器中使用,URP不支持,使用half代替。
- real——默认为half,如果定义了“#define PREFER_HALF 0”,那么则为float。
- int——32位整数
- uint——32位无符号整数(GLES2不支持。自动转为int类型)
向量类型变量
- float2/3/4——每个元素都为float类型的二维/三维/四维向量。
- half2/3/4——每个元素都为half类型的二维/三维/四维向量。
- int2/3/4——每个元素都为int类型的二维/三维/四维向量。
可以使用.x/.y/.z/.w(或者.r/.g/.b/.a)获取向量的各个元素。并且也可以利用这种写法重新排布向量的构成。
1 | float3 vector = float3(1, 2, 3); |
矩阵
矩阵可以用标量类型接数字X数字的形式表示,如float4x3。第一个数字4为矩阵的行数(row),第二个数字3为矩阵的列数(column)。
- float4x4——4 rows, 4 columns
- int4x3——4 rows, 3 columns
- half2x1——2 rows, 1 column
- float1x4——1 row, 4 colomns
可以提取矩阵的元素形成向量:1
2
3
4
5
6
7
8
9float3x3 matrix = {0,1,2,
3,4,5,
6,7,8};
float3 row0 = matrix[0]; // (0, 1, 2)
float3 row1 = matrix[1]; // (3, 4, 5)
float3 row2 = matrix[2]; // (6, 7, 8)
float row1column2 = matrix[1][2]; // 5
// Note we could also do
float row1column2 = matrix[1].z;
矩阵经常用于不同坐标系的变换,所以常常会用到矩阵的乘法。矩阵与向量的乘法不能用*而要用mul(matrix, vector)来实现。1
2mul(GetObjectToWorldMatrix(), float4(positionOS, 1.0)).xyz;
// GetObjectToWorldMatrix() 返回 "UNITY_MATRIX_M",是Unity传入的模型矩阵
上方的方法其实就是TransformObjectToWorld()函数。一定要注意mul(x,y)的输入顺序,如果第一个输入为向量,那么会将其定义为行向量(row vector, 1 row n column),放在第二个位置则认为它是列向量(column vector, n rows 1 column)。例如,float3向量放在x处,那么相当于float1x3的矩阵,放在y处则为float3x1的矩阵。
相乘的矩阵,第一个矩阵的列数要与第二个矩阵的行数相同。计算结果的行数与前者相同,列数与后者相同。例如,mul(float4x4, float4)的结果为float4x1,也就是float4。
数组
着色器中是可以声明数组的,但是Shaderlab的属性和材质面板不支持显示,需要通过C#脚本来调整。数组的大小需要在着色器中声明,保证为常量来规避内存问题。如果还不确定数组需要的大小,可以设置一个最大值,或者通过一个float值来表示数组的长度。1
2
3float _Array[10]; // Float array
float4 _Array[10]; // Vector array
float4x4 _Array[10]; // Matrix array
可以在脚本中通过material.SetFloatArray或Shader.SetGlobalFloatArray来设置数组。另外还有SetVectorArray和SetMatrixArray以及设置全局的版本。
其他类型
HLSL还包含一些类型如Texture和Sampler,可以在URP中通过宏定义:1
2TEXTURE2D(textureName);
SAMPLER(sampler_textureName);
还有缓冲Buffer类型,可以在脚本中通过material.SetBuffer或Shader.SetGlobalBuffer设置。1
2
3
4
StructuredBuffer<float3> buffer;
// 查看 https://docs.unity3d.com/Manual/SL-ShaderCompileTargets.html
流程控制的方法如if/for/while等与C#相同。
函数
在HLSL中声明函数与C#相似,例如:1
2
3float3 example(float3 a, float3 b){
return a * b;
}
float3是返回类型,example是函数名,()中是输入参数,{}是函数体。 空返回类型使用void;可以使用”out”关键字定义输出参数,或者”inout”表示其为输入参数并对其进行修改后输出。
可能会见到一些”inline”函数(内联函数),表示编译器每次调用该内联函数处都会复制一份该函数,对于简短的函数,可以有效降低调用函数产生的开销。
可能还会看到一些如下的函数形式:1
这是macro(宏),宏会在编前将调用宏的地方替换成宏指向的原意。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17float f = EXAMPLE(3, 5);
float3 a = float3(1,1,1);
float3 f2 = EXAMPLE(a, float3(0,1,0));
// 变为:
float f = ((3) * (5));
float a = float(1,1,1);
float3 f2 = ((a) * (float3(0,1,0)));
// 然后编译着色器
// 注意宏中的x和y都加了括号
// 那么:
float b = EXAMPLE(1+2, 3+4);
// 变为:
float b = ((1+2) * (3+4)); // 3 * 7, so 21
// 如果不加括号,那么就会是如下的结果:
float b = (1+2*3+4)
宏还有函数不好实现的用法,例如:1
2
3
4
5
6
7
// 使用:
OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex)
// 变为:
OUT.uv = (IN.uv.xy * _MainTex_ST.xy + _MainTex_ST.zw);
“##”标识符在宏中获取名称及_ST部分,生成_MainTex_ST。当然,_MainTex_ST仍然需要定义。
开始编写着色器
着色器通常包含两个阶段,顶点着色器(vertex shader)和片元着色器(fragment shader)。模型的每个顶点都会运行顶点着色器,屏幕将会显示的每个像素都会运行片元着色器。一些片元可能会被丢弃掉(如alpha裁切和模板着色器),所以不会成为最终的像素(因此有人不喜欢称片元着色器为像素着色器)。另外还有壳/域着色器、几何着色器和计算着色器,暂不讨论。这些着色器在URP和内置管线的工作方式是一样的。
在着色器中,使用HLSLINCLUDE包含的代码会在SubShader中的每个Pass被自动包含进来。不是必须的,但使用SRP Batcher时,如UnityPerMaterial CBUFFER这样每pass相同的内容使用HLSLINCLUDE则非常合适。CBUFFER需要包含暴露的所有属性(与Shaderlab属性语义块中定义的一致),不可以包含没有暴露的属性,纹理不需要被包含在CBUFFER中。
使用material.SetColor/SetFloat/SetVector等等函数可以在C#脚本中实现对未暴露参数的修改。但是使用SRP Batcher对多个带有不同数值的材质示例进行批处理时会产生问题。使用Shader.SetGlobalColor/Float/Vector等函数可以规避此问题,但如果一定需要每个材质示例带有不同数值,那么必须要暴露属性并在CBUFFER中定义。1
2
3
4
5
6
7
8
9
10HLSLINCLUDE
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
float4 _BaseColor;
//float4 _ExampleDir;
//float _ExampleFloat;
CBUFFER_END
ENDHLSL
上方代码包含了URP ShaderLibrary中的Core.hlsl,类似于内置管线的UnityCG.cginc。此文件中也自动包含了一些其他的库文件,其中包含大量有用和常用的函数与宏。在HLSLPROGRAM中首先要做的就是声明顶点着色器和片元着色器。通常用”vert”和”frag”作为两者的名字,当然可以随意定义。
1 | HLSLPROGRAM |
在定义这俩函数之前,经常需要定义两个结构体。会在下个小节介绍。
结构体
在定义顶点着色器和片元着色器之前,需要定义一些用于传输数据的结构体。在内置管线中,这些结构体往往命名为“appdata”和“v2f”(vertex to fragment的简写),在URP中,官方使用的是“Attributes”和“Varyings”。这些名称可以随意命名,但为了协作方便,往往遵循统一的命名规则。
URP的ShaderLibrary中也使用一些结构体来组织函数中需要的数据。例如光照和着色计算中需要的InputData和SurfaceData,将会在光照部分讨论这些。因为第一个案例是Unlit着色器,所以结构体比较简单。1
2
3
4
5struct Attributes {
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};
Attributes结构体包含了传入顶点着色器的数据。将模型每个顶点的数据传入顶点着色器,使用大写的宏来实现。通常包括顶点位置(POSITION),顶点颜色(COLOR),纹理坐标——UV(TEXCOORD)。一个模型拥有8个不同的UV通道,通过TEXCOORD0到TEXCOORD7读取。
Mesh.uv是TEXCOORD0,没有Mesh.uv1,下一个通道是Mesh.uv2,即TEXCOORD1。因此Mesh.uv8对应的是TEXCOORD7。也可以通过NORMAL读取顶点的法线,通过TANGENT读取切线。在Unlit着色器中很少用到。1
2
3
4
5struct Varyings {
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};
Varyings结构体包含顶点着色器输出的数据,并且作为片元着色器的输入数据的结构体(假设中间不存在几何着色器,否则需要额外的结构体,本篇不会涉及到)。定义好结构体后,一般还会定义需要用的纹理和纹理采样器(属性语义块中纹理之外的属性在CBUFFER中定义)。1
2TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
顶点着色器
顶点着色器一个重要任务是将模型顶点位置从模型空间转换到剪裁空间。这样才可以正确渲染将要显示在屏幕上的片元/像素。在内置管线中使用UnityObjectToClipPos函数可以实现该操作,在URP中使用TransformObjectToHClip函数来代替(SpaceTransforms.hlsl)。另外也可以使用下属方式来获取剪裁空间的位置。1
2
3
4
5
6
7
8
9
10
11
12Varyings vert(Attributes IN) {
Varyings OUT;
VertexPositionInputs positionInputs = GetVertexPositionInputs(IN.positionOS.xyz);
OUT.positionCS = positionInputs.positionCS;
// 或者:
//OUT.positionCS = TransformObjectToHClip(IN.positionOS.xyz);
OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap);
OUT.color = IN.color;
return OUT;
}
使用函数GetVertexPositionInputs可以获取各种空间的位置信息。在URP管线的ShaderVariablesFunctions.hlsl中可以查看该函数的实现方式。包含Core.hlsl会自动包含该文件。输入Attributes中的模型空间坐标,得到VertexPositionInputs结构体,它包含了下述位置信息:
- positionWS,世界空间坐标
- positionVS,观察空间坐标
- positionCS,剪裁空间坐标
- positionNDC,归一化设备坐标系中的坐标
Unlit着色器中不需要其他那些坐标信息,因此在编译时它们不会被编译,因此不会产生额外的计算开销。
顶点着色器的其他任务还包括将顶点数据传入到片元着色器,如顶点色OUT.color = IN.color。如果需要采样纹理,那么还需要传输模型的UV。使用OUT.uv = IN.uv来实现(假设都是float2)。经常会使用TRANSFORM_TEX宏来实现在材质面板控制纹理采样器的UV偏移和平铺。如_BaseMap_ST,S是scale;T是translate。在内置管线和URP中都可以使用该宏(core/ShaderLibrary/Macros.hlsl)。这个宏的作用其实是IN.uv.xy * _BaseMap_ST.xy + _BaseMap_ST.zw。但要注意的是TextureName_ST这个float4需要在CBUFFER中定义。
获取法线的函数与获取位置的函数类似:1
VertexNormalInputs normalInputs = GetVertexNormalInputs(IN.normalOS, IN.tangentOS);
GetVertexNormalInputs函数可以将模型空间的法线和切线转换到世界空间。VertexNormalInputs包含了normalWS, tangentWS和bitangentWS三个向量。另外可以使用TransformObjectToWorldNormal(IN.normalOS)函数将模型空间法线转换到世界空间。
片元着色器
片元着色器主要负责决定像素的颜色(包括alpha)。对于Unlit着色器来说,可以是简单的纯色,也可以是采样纹理后的颜色。对于Lit着色器,会稍微复杂一些。但URP管线提供了一些方便的函数,会在光照小节讨论。三角面上的片元/像素的数据由组成该三角面的三个顶点在Varyings中的数据进行线性插值来决定。因此,如果三个顶点从顶点着色器输出的颜色分别是(1, 0, 0), (0, 1, 0)和(0, 0, 1),那么片元着色器得到的颜色则如下图:
如果对顶点法线进行线性插值(用于光照和着色计算),那么插值后的法线很可能不是单位向量。结果类似于重心坐标系统,中心(0.33, 0.33, 0.33)的长度是0.577左右,而不是长度为1的单位向量。因此片元着色器获取插值后的法线需要先进行标准化。当然很多时候插值后的法向量的长度接近1,如果想简化计算,可以不进行标准化。那么现在Unlit材质的像素着色器如下:1
2
3
4
5half4 frag(Varyings IN) : SV_Target {
half4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv);
return baseMap * _BaseColor * IN.color;
}
该着色器输出类型为half4,颜色由纹理颜色、基础色和顶点色共同决定。SV_Target语义表示结果作为片元着色器最终的输出颜色。另外还有类型为float的SV_Depth,用于重写每像素的Z缓冲值。一些GPU为了优化考虑,深度缓冲默认是关闭的。片元着色器中使用SAMPLE_TEXTURE2D宏对纹理进行采样,该宏由URP的ShaderLibrary提供,输入参数是纹理、纹理采样器和UV。另外也可以将像素alpha值低于某特定阈值的像素丢弃掉,那么那部分的模型就不可见。例如四边面片制作的草和树叶。丢弃像素的过程在不透明材质和半透材质中都可以实现,也是常说的alpha剪裁/剔除(clip/cutoff)。材质中可以使用_Cutoff属性可以控制该阈值,不仅要在属性语义块中定义,还要在CBUFFER中定义。
1 | if (_BaseMap.a < _Cutoff){ |
那么目前为止无光照的着色器的完整代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68// Example Shader for Universal RP
// Written by @Cyanilux
// https://cyangamedev.wordpress.com/urp-shader-code/
Shader "Custom/UnlitShaderExample" {
Properties {
_BaseMap ("Example Texture", 2D) = "white" {}
_BaseColor ("Example Colour", Color) = (0, 0.66, 0.73, 1)
//_ExampleDir ("Example Vector", Vector) = (0, 1, 0, 0)
//_ExampleFloat ("Example Float (Vector1)", Float) = 0.5
}
SubShader {
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalRenderPipeline" }
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
float4 _BaseColor;
//float4 _ExampleDir;
//float _ExampleFloat;
CBUFFER_END
ENDHLSL
Pass {
Name "Example"
Tags { "LightMode"="UniversalForward" }
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
struct Attributes {
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};
struct Varyings {
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
Varyings vert(Attributes IN) {
Varyings OUT;
VertexPositionInputs positionInputs = GetVertexPositionInputs(IN.positionOS.xyz);
OUT.positionCS = positionInputs.positionCS;
// Or this :
//OUT.positionCS = TransformObjectToHClip(IN.positionOS.xyz);
OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap);
OUT.color = IN.color;
return OUT;
}
half4 frag(Varyings IN) : SV_Target {
half4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv);
return baseMap * _BaseColor * IN.color;
}
ENDHLSL
}
}
}
关键字和着色器变体
讨论光照之前,先谈谈着色器中的关键字(keyword)和变体(variant),在URP ShaderLibrary中使用的非常多,所以知道关键字和变体的机制十分有意义,这样才能正确处理光照的函数。
在着色器中,可以声明很多#pragma指令,带有multi-compile和shader_feature指令可以控制关键字是否开启,控制着色器某些部分生效与否。这样着色器会编译出多个版本,也就是着色器的变体。
multi_compile
1 |
这个示例中,我们会产生三种变体,_A,_B和_C是关键字。
那么在着色器的代码中,用法如下:
1 |
|
shader_feature
1 |
与multi_compile的机制一样,但区别是最终的打包版本不包含没有使用的变体。因此,在运行时开启或关闭这些关键字是不合理的,因为有些变体代码没有被编译进最终的版本。如果要在运行时处理某些关键字,那么要使用multi_compile来代替。
着色器变体
每增加一个multi_compile和shader_feature,都会增加更多的着色器变体。下方的例子中:1
2
3
第一行存在三种可能性,但是第二行又有两种可能性。因此会出现6种不同的组合方式:
A & D, A & E, B & D, B & E, C & D and C & E
第三行又有两种可能性。因此现在一共有12种不同的组合。但因为使用的是shader_feature,因此有些变体不会存在与最终的打包版本中。
每增加一个带有两个变体的multi_compile都会使最终的组合翻倍。10个这样的multi_compile就会产生1024个着色器变体组合。每个组合都会出现在最终的包体中,那么编译时间也会增加,包体大小也会增加。
如果想查看一个着色器存在多少种变体,可以点击材质面板的”Compile and Show Code”按钮,便会看到变体的数量。如果点击”skip unused shader_feature”可以切换是否查看全部的变体。
上述的指令也有针对顶点着色器和片元着色器的版本,这样可以有效减少最终变体组和的数量,优化包体和编译时间。例如:1
2
3
4
关键字的上限
每个工程关键字的数量最多为256,所以最好遵循通用的关键字便于协作。另外有时会看到multi_compile和shader_feature后面会接”_”,这并不会产生额外的关键字。1
2
3
4
5
6
7
8
9
10
11
12
// 其实就是下面的简写
// 如果想知道关键字是否禁用,可以使用:
// 或者 #if !defined(_KEYWORD)
// 或者 #ifdef _KEYWORD #else
// 代码
为了防止超过关键字的最大上限,可以使用multi_compile和shader_feature的局部版本。这些关键字只会在当前着色器中有效,每个着色器的局部关键字的上限是64。1
2
3
4
5
6
// 当然局部关键字也有vertex和fragment版本
光照
在内置管线中,需要处理光照和着色的着色器为Surface Shader。可以选择不同的光照模型,如physically-based Standard和StandardSpecular或者Lambert(diffuse)和BlinnPhong(specular)模型。也可以编写自定义的光照模型,例如卡通着色。
URP管线不支持surface shader,但ShaderLibrary提供了一些函数来辅助处理很多常用的光照计算。这些函数包含在Lighting.hlsl文件中,需要自己在代码中包含。 UniversalFragmentPBR函数可以处理基于物理的光照着色,下一节介绍。现在仅讨论主平行光的简单光照和阴影。
在Lighting.hlsl中的函数GetMainLight()可以获取主要平行光的数据,那么需要先包含Lighting.hlsl,并且在HLSLPROGRAM后添加几个multi_compile指令来提供一些控制接受阴影的关键字。
1 |
下面,我们需要顶点法线来处理光照和着色,所以在结构体中添加该属性,并且更新顶点着色器。下面的代码是新增的部分:
1 | struct Attributes { |
在片元着色器,可以获取世界空间的法向量,并且使用世界空间的位置来计算阴影坐标(当然可以在顶点着色器中计算阴影坐标然后传入片元着色器,但仅在shadow cascades关键字禁用时有效)。现在就暂且保持简单的方式。
1 | half4 frag(Varyings IN) : SV_Target { |
同时,我们的着色器也需要接受从其他着色器而来的阴影,但现在没有ShadowCaster pass,所以不会对自身和其他物体产生阴影,见下一节。如果仅需要阴影而不需要漫反射,那么可以移除漫反射光照计算,只使用light.shadowAttenuation。如果想进一步扩展,可以包含环境光或烘焙的全局光照以及其他额外的灯光,可将Lighting.hlsl中的UniversalFragmentBlinnPhong函数作为参考示例。也可以直接使用这个函数,需要InputData结构体作为输入,也是PBR着色案例中需要使用的结构体。
PRB光照
基于物理的渲染Physically Based Rendering(PBR)是Unity的”Standard”着色器使用的着色模型,也就是URP的Lit着色器以及Shader Graph中的 PBR Master节点。
前一节提过,内置管线的光照通过Surface Shader处理,”Standard”选项就是一个PBR模型。使用一个surface函数输出Albedo, Normal, Emission, Smoothness, AO, Alpha和Metallic(使用StandardSpecular流程则是Specular)。Unity会使用这些数据生成一个顶点着色器和片元着色器,处理PBR的光照计算和阴影计算。
URP管线不支持surface着色器,然而ShaderLibrary提供了帮助计算光照的函数。Lighting.hlsl中与PBR着色相关的函数有:
1 | half4 UniversalFragmentPBR(InputData inputData, half3 albedo, |
首先定义PBR着色需要的属性,暂且不引入metallic/specular和occlusion贴图,因为URP提供的函数对这两种贴图的处理不是很好。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26Properties {
_BaseMap ("Base Texture", 2D) = "white" {}
_BaseColor ("Example Colour", Color) = (0, 0.66, 0.73, 1)
_Smoothness ("Smoothness", Float) = 0.5
[Toggle(_ALPHATEST_ON)] _EnableAlphaTest("Enable Alpha Cutoff", Float) = 0.0
_Cutoff ("Alpha Cutoff", Float) = 0.5
[Toggle(_NORMALMAP)] _EnableBumpMap("Enable Normal/Bump Map", Float) = 0.0
_BumpMap ("Normal/Bump Texture", 2D) = "bump" {}
_BumpScale ("Bump Scale", Float) = 1
[Toggle(_EMISSION)] _EnableEmission("Enable Emission", Float) = 0.0
_EmissionMap ("Emission Texture", 2D) = "white" {}
_EmissionColor ("Emission Colour", Color) = (0, 0, 0, 0)
}
...
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
float4 _BaseColor;
float _BumpScale;
float4 _EmissionColor;
float _Smoothness;
float _Cutoff;
CBUFFER_END
需要添加需要的multi_compile, shader_feature,调整Attributes和Varyings结构体。属性语义块中定义的TOGGLE属性允许材质编辑器对shader_feature进行开启或禁用。另外可以编写自定义的材质面板UI。如果想要支持构建光照贴图,还需要传入光照贴图UV。另外作者引入了ShaderLibrary中的SurfaceInput.hlsl和SurfaceData.hlsl,其中的SurfaceData结构体可以作为PBR着色需要的数据的载体,并且有一些对不同贴图采样的函数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
//#pragma shader_feature _METALLICSPECGLOSSMAP
//#pragma shader_feature _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
//#pragma shader_feature _OCCLUSIONMAP
//#pragma shader_feature _SPECULARHIGHLIGHTS_OFF
//#pragma shader_feature _ENVIRONMENTREFLECTIONS_OFF
//#pragma shader_feature _SPECULAR_SETUP
// URP 关键字
// Unity 定义的关键字
struct Attributes {
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float4 tangentOS : TANGENT;
float4 color : COLOR;
float2 uv : TEXCOORD0;
float2 lightmapUV : TEXCOORD1;
};
struct Varyings {
float4 positionCS : SV_POSITION;
float4 color : COLOR;
float2 uv : TEXCOORD0;
DECLARE_LIGHTMAP_OR_SH(lightmapUV, vertexSH, 1);
float3 positionWS : TEXCOORD2;
float3 normalWS : TEXCOORD3;
float4 tangentWS : TEXCOORD4;
float3 viewDirWS : TEXCOORD5;
half4 fogFactorAndVertexLight : TEXCOORD6;
// x: fogFactor, yzw: vertex light
float4 shadowCoord : TEXCOORD7;
};
//TEXTURE2D(_BaseMap);
//SAMPLER(sampler_BaseMap);
// 移除,因为SurfaceInput.hlsl定义了_BaseMap
那么顶点着色器也需要更新:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// This function was added in URP v9.x.x versions
// If we want to support URP versions before, we need to handle it instead.
// Computes the world space view direction (pointing towards the viewer).
float3 GetWorldSpaceViewDir(float3 positionWS) {
if (unity_OrthoParams.w == 0) {
// Perspective
return _WorldSpaceCameraPos - positionWS;
} else {
// Orthographic
float4x4 viewMat = GetWorldToViewMatrix();
return viewMat[2].xyz;
}
}
Varyings vert(Attributes IN) {
Varyings OUT;
// 顶点位置
VertexPositionInputs positionInputs = GetVertexPositionInputs(IN.positionOS.xyz);
OUT.positionCS = positionInputs.positionCS;
OUT.positionWS = positionInputs.positionWS;
// UVs & 顶点色
OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap);
OUT.color = IN.color;
// 观察方向
OUT.viewDirWS = GetWorldSpaceViewDir(positionInputs.positionWS);
// 法线和切线
VertexNormalInputs normalInputs = GetVertexNormalInputs(IN.normalOS, IN.tangentOS);
OUT.normalWS = normalInputs.normalWS;
real sign = IN.tangentOS.w * GetOddNegativeScale();
OUT.tangentWS = half4(normalInputs.tangentWS.xyz, sign);
// 顶点光照 & 雾
half3 vertexLight = VertexLighting(positionInputs.positionWS, normalInputs.normalWS);
half fogFactor = ComputeFogFactor(positionInputs.positionCS.z);
OUT.fogFactorAndVertexLight = half4(fogFactor, vertexLight);
// 烘焙光照 & 球谐函数(没有烘焙灯光情况下的环境光照)
OUTPUT_LIGHTMAP_UV(IN.lightmapUV, unity_LightmapST, OUT.lightmapUV);
OUTPUT_SH(OUT.normalWS.xyz, OUT.vertexSH);
// 阴影坐标
OUT.shadowCoord = GetShadowCoord(positionInputs);
return OUT;
}
下面更新片元着色器,使用UniversalFragmentPBR函数,需要InputData结构体传入数据,我们不在片元着色器中创建和设置相关数据,而是封装另外一个函数,也会为贴图处理封装另外一个函数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58InputData InitializeInputData(Varyings IN, half3 normalTS){
InputData inputData = (InputData)0;
inputData.positionWS = IN.positionWS;
half3 viewDirWS = SafeNormalize(IN.viewDirWS);
float sgn = IN.tangentWS.w; // should be either +1 or -1
float3 bitangent = sgn * cross(IN.normalWS.xyz, IN.tangentWS.xyz);
inputData.normalWS = TransformTangentToWorld(normalTS, half3x3(IN.tangentWS.xyz, bitangent.xyz, IN.normalWS.xyz));
inputData.normalWS = IN.normalWS;
inputData.normalWS = NormalizeNormalPerPixel(inputData.normalWS);
inputData.viewDirectionWS = viewDirWS;
inputData.shadowCoord = IN.shadowCoord;
inputData.shadowCoord = TransformWorldToShadowCoord(inputData.positionWS);
inputData.shadowCoord = float4(0, 0, 0, 0);
inputData.fogCoord = IN.fogFactorAndVertexLight.x;
inputData.vertexLighting = IN.fogFactorAndVertexLight.yzw;
inputData.bakedGI = SAMPLE_GI(IN.lightmapUV, IN.vertexSH, inputData.normalWS);
return inputData;
}
SurfaceData InitializeSurfaceData(Varyings IN){
SurfaceData surfaceData = (SurfaceData)0;
// 数字0会自动初始化结构体数据为0。
half4 albedoAlpha = SampleAlbedoAlpha(IN.uv, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap));
surfaceData.alpha = Alpha(albedoAlpha.a, _BaseColor, _Cutoff);
surfaceData.albedo = albedoAlpha.rgb * _BaseColor.rgb * IN.color.rgb;
surfaceData.smoothness = _Smoothness;
surfaceData.normalTS = SampleNormal(IN.uv, TEXTURE2D_ARGS(_BumpMap, sampler_BumpMap), _BumpScale);
surfaceData.emission = SampleEmission(IN.uv, _EmissionColor.rgb, TEXTURE2D_ARGS(_EmissionMap, sampler_EmissionMap));
surfaceData.occlusion = 1;
return surfaceData;
}
half4 frag(Varyings IN) : SV_Target {
SurfaceData surfaceData = InitializeSurfaceData(IN);
InputData inputData = InitializeInputData(IN, surfaceData.normalTS);
half4 color = UniversalFragmentPBR(inputData, surfaceData);
color.rgb = MixFog(color.rgb, inputData.fogCoord);
color.a = saturate(color.a);
return color;
}
现在我们的着色器可以接收阴影,但因为没有ShadowCaster pass,所以不会对自己及其他物体产生投影,将在下一小节讨论。
ShadowCaster和DepthOnly Pass
ShadowCaster
如果希望着色器投影,需要一个标签为”LightMode”=”ShadowCaster”的pass。在Unlit和Lit着色器中都可以使用,但这仅仅是投影功能,如果需要接收阴影,那么就是上文中UniversalForward pass中的做法。
并且着色器中还需要一个标签为”LightMode”=”DepthOnly”的pass。这个pass与ShadowCaster十分类似,但是不带阴影偏移。这个pass可能是自定义的Render Feature会用到的深度pass。着色器中所有的pass都共享同一个UnityPerMaterial CBUFFER使得SRP Batcher起作用。在前面的章节,我们将此缓冲放在HLSLINCLUDE中,所以会自动被包含在着色器的各个pass中。
如果使用UsePass调取其他着色器中的ShadowCaster或者DepthOnly pass,可能会因为CBUFFER数据不统一导致SRP Batcher做批处理时出现一些问题。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26Pass {
Name "ShadowCaster"
Tags { "LightMode"="ShadowCaster" }
ZWrite On
ZTest LEqual
HLSLPROGRAM
#pragma prefer_hlslcc gles
#pragma exclude_renderers d3d11_9x gles
#pragma shader_feature _ALPHATEST_ON
#pragma shader_feature _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
#pragma multi_compile_instancing
#pragma multi_compile _ DOTS_INSTANCING_ON
#pragma vertex ShadowPassVertex
#pragma fragment ShadowPassFragment
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/SurfaceInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
ENDHLSL
}
包含URP管线中的ShadowCasterPass.hlsl,需要定义_BaseMao, _BaseColor和_Cutoff属性,CBUFFER中也需要定义。
在片元着色器中,ShadowCaster会在有阴影的地方返回0,并且丢弃没有任何阴影信息的像素(剪裁只会在_ALPHATEST_ON关键字开启的情况下发生),可以查看com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl中的代码。
如果着色器的pass中做了顶点的置换偏移操作,那么也需要将此操作实现于ShadowCaster的pass中,这样会将偏移后的顶点的正确投影计算出来。为了实现这个操作,可以复制ShadowCasterPass的代码到我们的pass,也可以定义一个新的顶点函数并且替换#pragma vertex ShadowPassVertex,例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
// 从ShadowCasterPass复制函数并稍作修改
Varyings vert(Attributes input) {
Varyings output;
UNITY_SETUP_INSTANCE_ID(input);
// 置换的示例
input.positionOS += float4(0, _SinTime.y, 0, 0);
output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);
output.positionCS = GetShadowPositionHClip(input);
return output;
}
DepthOnly
可以用类似的方法处理DepthOnly pass,只有一点点区别:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26Pass {
Name "DepthOnly"
Tags { "LightMode"="DepthOnly" }
ZWrite On
ColorMask 0
HLSLPROGRAM
#pragma prefer_hlslcc gles
#pragma exclude_renderers d3d11_9x gles
#pragma shader_feature _ALPHATEST_ON
#pragma shader_feature _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
#pragma multi_compile_instancing
#pragma multi_compile _ DOTS_INSTANCING_ON
#pragma vertex DepthOnlyVertex
#pragma fragment DepthOnlyFragment
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/SurfaceInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl"
ENDHLSL
}
DepthOnlyPass.hlsl由URP管线提供,有顶点偏移的操作,则需要复制DepthOnlyVertex函数到我们的着色器中,然后重命名为vert,添加偏移的代码到其中即可。
内置与URP管线差异总结
下面总结一下URP管线相比于内置管线在编写着色器时的代码区别,可能会有遗漏。
- 在Subshader语义块中使用”RenderPipeline”=”UniversalRenderPipeline”
- URP使用以下”LightMode”标签:
- UniversalForward - 使用前向渲染器渲染对象
- ShadowCaster - 用来投影
- DepthOPnly - 用来渲染scene view的深度纹理,在自定义的render feature中可以调用
- Meta - 仅用于烘焙光照贴图
- Universal2D - 开启2D 渲染器,取代前向渲染器
- UniversalGBuffer - 与延迟渲染有关,还在开发中。
- URP使用单pass的前向渲染,只有第一个标签为”UniversalForward”的Pass(当前GPU支持的)会用被来渲染物体,所以不能渲染多个同类型标签的Pass。</font>如果使用无标签的Pass,会破坏SRP Batcher的批处理。所以建议分开使用着色器或材质,用于不同的MeshRenderers或在前向渲染器中使用RenderObjects特性在特定的层使用一个overrideMaterial重新绘制物体。
- RenderObjects前向渲染器特性可以用于使用overrideMaterial重绘制对象到指定的层,单属性的值不会被保留,除非使用一个材质属性语义块,但也会破坏SRP的批处理。也可以覆盖stencil和ztest的值。
- 可以编写自定义的现象渲染特性,例如Blit方法可以实现自定义的后期处理效果。URP后处理目前不包含自定义的效果。
- 使用HLSLPROGRAM/HLSLINCLUDE和ENHLSL,不使用CG的版本,否则会与URP的ShaderLibrary产生冲突。
- 包含URP的ShaderLibrary核心库而不是UnityCG.cginc
1
- 内置管线中与顶点着色器和片元着色器相关的结构体一般命名为appdata和v2f,但URP中的使用习惯是命名为Attributes和Varyings。
为了兼容和支持SRP Batcher,着色器中需要有UnityPerMaterial CBUFFER,并且每个Pass都共享同样的CBUFFER,因此使用HLSLINCLUDE在Subshader中包含该CBUFFER。CBUFFER中包含属性语义块中的所有非纹理参数。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13Properties {
_BaseMap ("Example Texture", 2D) = "white" {}
_BaseColor ("Example Colour", Color) = (0, 0.66, 0.73, 1)
}
...
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
float4 _BaseColor;
CBUFFER_END
ENDHLSL如果包含SurfaceInput.hlsl并使用其中的函数,那么需要使用_BaseMap代表albedo贴图,而不是使用_MainTex。但后期处理材质还是会使用_MainTex作为颜色输出进行Blit操作。
- 定义纹理和采样器,使用以下宏:
1
2TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap); - 使用SAMPLE_TEXTURE2D()采样贴图:
1
half4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv);
- TRANSFORM_TEX宏也存在于URP管线中。
UnityObjectToClpPos由TransformObjectToHClip取代。当然也可以使用GetVertexPositionInputs获取顶点在各个空间中的坐标,没有被使用到的坐标系不会参与编译。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16struct Attributes {
float4 positionOS : POSITION;
};
struct Varyings {
float3 positionCS : SV_POSITION;
float3 positionWS : TEXCOORD2;
};
Varyings vert(Attributes IN) {
Varyings OUT;
VertexPositionInputs positionInputs = GetVertexPositionInputs(IN.positionOS.xyz);
OUT.positionCS = positionInputs.positionCS;
OUT.positionWS = positionInputs.positionWS;
return OUT;
}与顶点位置类似,使用GetVertexNormalInputs可以获取世界空间的Normal, Tangent和Bitangent向量。如果仅需要世界空间法向量,那么可以使用TransformObjectToWorldNormal()获取。
1
2
3VertexNormalInputs normalInputs = GetVertexNormalInputs(IN.normalOS, IN.tangentOS);
// 仅需要Normal时使用:
OUT.normalWS = TransformObjectToWorldNormal(IN.normalOS)URP不支持Surface Shader,所以需要自己编写vertex/fragment着色器。如果需要支持灯光交互,可以包含Lighting.hlsl,其中含有很多计算光照的函数。
1
如果包含了Lighting.hlsl计算光影,那么下面的一些关键字可能需要被定义,如果某些关键字没有被定义,那么ShaderLibrary会跳过相关的计算步骤:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// 主光源阴影
// 额外灯光和其阴影
// 柔和的阴影
// 其他(混合光照,烘焙光照贴图,雾)
// 支持阴影则须将世界空间的顶点位置信息和ShadowCoord传入片元着色器使用ComputeFogFactor和MixFog函数处理雾:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Varyings {
...
half fogFactor : TEXCOORD5;
// 或者其他没有被使用的texcoord
// 如果都被占用了,则将其与一个half3合并在一起
}
...
// 顶点着色器:
half fogFactor = ComputeFogFactor(positionInputs.positionCS.z);
// 片元着色器:
color.rgb = MixFog(color.rgb, IN.fogFactor);