Unity 渲染原理(七)表面着色器与Shader Graph

在内置渲染管线中,表面着色器是编写与光照交互的着色器的一种简化方式。

编写与光照交互的着色器非常复杂。有不同的光源类型,不同的阴影选项,不同的渲染路径(前向和延迟渲染);着色器应该以某种方式应对所有这些复杂性。

表面着色器是一种代码生成方法,与使用低级顶点/像素着色器程序相比,可以更轻松地编写光照着色器。

工作原理

您可以定义一个“表面函数”,它将您需要的所有 UV 或数据作为输入,并填充输出结构 SurfaceOutput。SurfaceOutput 基本上描述了_表面的属性_(反照率颜色、法线、发光、镜面反射等)。需要使用 HLSL 编写此代码

表面着色器编译器随后计算出需要的输入、填充的输出等等,并生成实际的顶点和像素着色器以及渲染通道来处理前向和延迟渲染。

表面着色器的标准输出结构:

1
2
3
4
5
6
7
8
9
struct SurfaceOutput
{
fixed3 Albedo; // 漫射颜色
fixed3 Normal; // 切线空间法线(如果已写入)
fixed3 Emission;
half Specular; // 0..1 范围内的镜面反射能力
fixed Gloss; // 镜面反射强度
fixed Alpha; // 透明度 Alpha
};

在 Unity 5 中,表面着色器还可以使用基于物理的光照模型。内置标准光照模型和标准镜面反射光照模型(见下文)分别使用以下输出结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct SurfaceOutputStandard
{
fixed3 Albedo; // 基础(漫射或镜面反射)颜色
fixed3 Normal; // 切线空间法线(如果已写入)
half3 Emission;
half Metallic; // 0=非金属,1=金属
half Smoothness; // 0=粗糙,1=平滑
half Occlusion; // 遮挡(默认为 1)
fixed Alpha; // 透明度 Alpha
};
struct SurfaceOutputStandardSpecular
{
fixed3 Albedo; // 漫射颜色
fixed3 Specular; // 镜面反射颜色
fixed3 Normal; // 切线空间法线(如果已写入)
half3 Emission;
half Smoothness; // 0=粗糙,1=平滑
half Occlusion; // 遮挡(默认为 1)
fixed Alpha; // 透明度 Alpha
};

表面着色器输入结构

输入结构 Input 通常具有着色器所需的所有纹理坐标。纹理坐标必须命名为“uv”后跟纹理名称的形式(如果要使用第二个纹理坐标集,则以“uv2”开头)。

可以放入输入结构的其他值:

  • float3 viewDir - 包含视图方向,用于计算视差效果、边缘光照等等。
  • 具有 COLOR 语义的 float4 - 包含插值的每顶点颜色。
  • float4 screenPos - 包含反射或屏幕空间效果的屏幕空间位置。请注意,这不适合 GrabPass;您需要使用 ComputeGrabScreenPos 函数自己计算自定义 UV。
  • float3 worldPos - 包含世界空间位置。
  • float3 worldRefl - 在_表面着色器不写入 o.Normal_ 的情况下,包含世界反射矢量。有关示例,请参阅反光漫射 (Reflect-Diffuse) 着色器。
  • float3 worldNormal - 在_表面着色器不写入 o.Normal_ 的情况下,包含世界法线矢量。
  • float3 worldRefl; INTERNAL_DATA - 在_表面着色器写入 o.Normal_ 的情况下,包含世界反射矢量。要获得基于每像素法线贴图的反射矢量,请使用 WorldReflectionVector (IN, o.Normal)。有关示例,请参阅反光凹凸 (Reflect-Bumped) 着色器。
  • float3 worldNormal; INTERNAL_DATA - 在_表面着色器写入 o.Normal_ 的情况下,包含世界法线矢量。要获得基于每像素法线贴图的法线矢量,请使用 WorldNormalVector (IN, o.Normal)。

表面着色器编译指令

就像任何其他着色器一样,表面着色器放置在 CGPROGRAM…ENDCG 代码块内。不同之处在于:

它必须放在 SubShader 代码块内,不能在 Pass 内。表面着色器本身将编译为多个通道
它使用 #pragma surface … 指令来指示自己是表面着色器。
#pragma surface 指令为:

# pragma surface surfaceFunction lightModel [optionalparams]

必需参数

  • surfaceFunction - 具有表面着色器代码的 Cg 函数。该函数的格式应为 void surf (Input IN, inout SurfaceOutput o),其中 Input 是您定义的结构。Input 应包含表面函数所需的任何纹理坐标和额外自动变量。
  • lightModel - 要使用的光照模型。内置光照模型是基于物理的 Standard 和 StandardSpecular,以及简单的非基于物理的 Lambert(漫射)和 BlinnPhong(镜面反射)。请参阅自定义光照模型页面以了解如何编写自己的光照模型。
    • Standard 光照模型使用 SurfaceOutputStandard 输出结构,并与 Unity 中的标准(金属性工作流)着色器匹配。
    • StandardSpecular 光照模型使用 SurfaceOutputStandardSpecular 输出结构,并与 Unity 中的标准(镜面反射设置)着色器匹配。
    • Lambert 和 BlinnPhong 光照模型不是基于物理的(来自 Unity 4.x),但使用这两个光照模型的着色器在低端硬件上可以提高渲染速度。

可选参数

这些可选参数一开始关注下自定义函数修改器和代码生成选项就好

透明度和 Alpha 测试

由 alpha 和 alphatest 指令控制。
透明度通常可以有两种:传统的 Alpha 混合(用于淡出对象)或更符合物理规律的“预乘混合”(允许半透明表面保留适当的镜面反射)。启用半透明度会使生成的表面着色器代码包含混合命令;而启用 Alpha 镂空将根据给定的变量在生成的像素着色器中执行片元废弃。

  • alpha 或 alpha:auto - 对于简单的光照函数,将选择淡化透明度(与 alpha:fade 相同);对于基于物理的光照函数,将选择预乘透明度(与 alpha:premul 相同)。
  • alpha:blend - 启用 Alpha 混合。
  • alpha:fade - 启用传统淡化透明度。
  • alpha:premul - 启用预乘 Alpha 透明度。
  • alphatest:VariableName - 启用 Alpha 镂空透明度。剪切值位于具有 VariableName 的浮点变量中。您可能还想使用 addshadow 指令生成正确的阴影投射物通道。
  • keepalpha - 默认情况下,无论输出结构的 Alpha 输出是什么,或者光照函数返回什么,不透明表面着色器都将 1.0(白色)写入 Alpha 通道。使用此选项可以保持光照函数的 Alpha 值,即使对于不透明的表面着色器也是如此。
  • decal:add - 附加贴花着色器(例如 terrain AddPass)。这适用于位于其他表面之上并使用附加混合的对象。请参阅表面着色器示例
  • decal:blend - 半透明贴花着色器。这适用于位于其他表面之上并使用 Alpha 混合的对象。请参阅表面着色器示例

自定义修改器函数

可用于更改或计算传入的顶点数据,或更改最终计算的片元颜色。

  • vertex:VertexFunction - 自定义顶点修改函数。在生成的顶点着色器的开始处调用此函数,并且此函数可以修改或计算每顶点数据。请参阅表面着色器示例。
  • finalcolor:ColorFunction - 自定义最终颜色修改函数。请参阅表面着色器示例。
  • finalgbuffer:ColorFunction - 用于更改 G 缓冲区内容的自定义延迟路径。
  • finalprepass:ColorFunction - 自定义预通道基本路径。

阴影和曲面细分

可以提供其他指令来控制阴影和曲面细分的处理方式。

  • addshadow - 生成阴影投射物通道。常用于自定义的顶点修改,以便阴影投射也可以获得程序化顶点动画。通常情况下,着色器不需要任何特殊的阴影处理,因为它们可以通过回退机制来使用阴影投射物通道。
  • fullforwardshadows - Support all light shadow types in Forward rendering path. By default shaders only support shadows from one directional light in forward rendering (to save on internal shader variant count). If you need point or Spot Light shadows in forward rendering, use this directive.
  • tessellate:TessFunction - 使用 DX11 GPU 曲面细分;该函数计算曲面细分因子。有关详细信息,请参阅表面着色器曲面细分。

代码生成选项

默认情况下,生成的表面着色器代码会尝试处理所有可能的光照/阴影/光照贴图情况。但是在某些情况下,您知道您不需要其中的一部分,可以调整生成的代码以跳过它们。这样可以减小着色器,从而提高加载速度。

  • exclude_path:deferred、exclude_path:forward 和 exclude_path:prepass - 不为给定的渲染路径(分别对应延迟着色路径、前向路径和旧版延迟路径)生成通道。
  • noshadow - 禁用此着色器中的所有阴影接受支持。
  • noambient - 不应用任何环境光照或光照探针。
  • novertexlights - 在前向渲染中不应用任何光照探针或每顶点光源。
  • nolightmap - 禁用此着色器中的所有光照贴图支持。
  • nodynlightmap - 禁用此着色器中的运行时动态全局光照支持。
  • nodirlightmap - 禁用此着色器中的方向光照贴图支持。
  • nofog - 禁用所有内置雾效支持。
  • nometa - 不生成“Meta”通道(由光照贴图和动态全局光照用于提取表面信息)。
  • noforwardadd - 禁用前向渲染附加通道。这会使着色器支持一个完整方向光,所有其他光源均进行每顶点/SH 计算。也能减小着色器。
  • nolppv - 禁用此着色器中的光照探针代理体支持。
  • noshadowmask - 为此着色器禁用阴影遮罩支持(包括 Shadowmask 和 Distance Shadowmask)。

其他选项

  • softvegetation - 仅在开启 Soft Vegetation 时才渲染表面着色器。
  • interpolateview - 在顶点着色器中计算视图方向并进行插值;而不是在像素着色器中计算。这可以使像素着色器更快,但会额外消耗一个纹理插值器。
  • alfasview - 将半方向矢量传入光照函数而不是视图方向。计算半方向并按每个顶点对其进行标准化。这更快,但并不完全正确。
  • approxview - 在 Unity 5.0 中已删除。请改用 interpolateview。
  • dualforward - 在前向渲染路径中使用双光照贴图。
  • dithercrossfade - 使表面着色器支持抖动效果。然后,可将此着色器应用于使用细节级别组 (LOD Group) 组件(配置为交叉淡入淡出过渡模式)的游戏对象。

表面着色器的渲染路径

在内置渲染管线中,使用表面着色器时,如何应用光照以及使用着色器的哪些通道取决于使用的渲染路径。着色器中的每个通道均通过通道标签来表达其光照类型。

  • 在前向渲染中,将使用 ForwardBase 和 ForwardAdd 通道。
  • 在延迟着色中,将使用 Deferred 通道。
  • 在旧版延迟光照中,将使用 PrepassBase 和 PrepassFinal 通道。
  • 在旧版顶点光照中,将使用 Vertex、VertexLMRGBM 和 VertexLM 通道。
  • 在上述任何情况中,要渲染阴影或深度纹理,都将使用 ShadowCaster 通道。

表面着色器示例

简单的着色器示例

我们将从一个非常简单的着色器 (Shader) 开始,并在此基础上加以丰富。下面的着色器将表面颜色设置为“白色”。它使用内置的兰伯特(漫射)光照模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Shader "Example/Diffuse Simple" {
SubShader {
Tags { "RenderType" = "Opaque" }
CGPROGRAM
#pragma surface surf Lambert
struct Input {
float4 color : COLOR;
};
void surf (Input IN, inout SurfaceOutput o) {
o.Albedo = 1;
}
ENDCG
}
Fallback "Diffuse"
}`

纹理

一个全白的对象很无聊,所以让我们添加一个纹理。我们将向着色器添加 Properties 代码块,这样我们将在材质中看到纹理选择器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Shader "Example/Diffuse Texture" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
}
SubShader {
Tags { "RenderType" = "Opaque" }
CGPROGRAM
#pragma surface surf Lambert
struct Input {
float2 uv_MainTex;
};
sampler2D _MainTex;
void surf (Input IN, inout SurfaceOutput o) {
o.Albedo = tex2D (_MainTex, IN.uv_MainTex).rgb;
}
ENDCG
}
Fallback "Diffuse"
}

要理解这段代码需要明白UV是个什么概念,简单来说,就是模型上的每个点都有一个uv坐标,这个坐标对应的是贴图上的一个点,渲染模型上的这个点的时候,这个点的颜色就是通过这坐标去贴图上获取的。详细解释可以看:https://www.cnblogs.com/cancantrbl/p/14766502.html

surf函数中用到了两个变量,大家可能会觉得有点迷惑,是怎么来的。
首先是_MainTex这个变量,是在HLSL代码里面定义的,找个变量的名字要和Properties中定义的属性名字完全相同才可以。
另一个变量是我们定义的结构体Input中的,叫做uv_MainTex,这个变量编译器会自动帮我们注入该点的uv坐标

输入结构 Input 通常具有着色器所需的所有纹理坐标。纹理坐标必须命名为“uv”后跟纹理名称的形式(如果要使用第二个纹理坐标集,则以“uv2”开头)。

法线贴图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Shader "Example/Diffuse Bump" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
_BumpMap ("Bumpmap", 2D) = "bump" {}
}
SubShader {
Tags { "RenderType" = "Opaque" }
CGPROGRAM
#pragma surface surf Lambert
struct Input {
float2 uv_MainTex;
float2 uv_BumpMap;
};
sampler2D _MainTex;
sampler2D _BumpMap;
void surf (Input IN, inout SurfaceOutput o) {
o.Albedo = tex2D (_MainTex, IN.uv_MainTex).rgb;
o.Normal = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap));
}
ENDCG
}
Fallback "Diffuse"
}

这个Shader中,我们多了一个输入和一个输出,我们多输入了一个法线贴图,同时也给输出的法线变量赋予了有效值。

如果对法线贴图的概念不太清楚,可以看一下法线贴图的官方文档

这里我们讲一下o.Normal = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap));这一行代码

首先是tex2D (_BumpMap, IN.uv_BumpMap) 这一段,我们已经理解了,是根据这个点的uv坐标从法线贴图中获取对应的rgba(或者说xyzw)值,那么我们为什么不能直接把值赋给Normal呢?
首先要明白,Unity中的发现贴图的存储是经过打包的格式DXT5nm, 只有G和A通道是有用的,而我们的发现是一个三维向量,所以我们要把它恢复出来,这个时候我们再看看这个UnpackNormal的定义:

1
2
3
4
5
6
7
8
9
10
11
inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if defined(SHADER_API_GLES) defined(SHADER_API_MOBILE)
return packednormal.xyz * 2 - 1;
#else
fixed3 normal;
normal.xy = packednormal.wy * 2 - 1;
normal.z = sqrt(1 - normal.x*normal.x - normal.y * normal.y);
return normal;
#endif
}

边缘光照

现在,尝试添加一些边缘光照以突出游戏对象的边缘。我们将根据表面法线和视图方向之间的角度添加一些发射光照。为此,我们将使用内置的表面着色器变量 viewDir

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
Shader "Example/Rim" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
_BumpMap ("Bumpmap", 2D) = "bump" {}
_RimColor ("Rim Color", Color) = (0.26,0.19,0.16,0.0)
_RimPower ("Rim Power", Range(0.5,8.0)) = 3.0
}
SubShader {
Tags { "RenderType" = "Opaque" }
CGPROGRAM
#pragma surface surf Lambert
struct Input {
float2 uv_MainTex;
float2 uv_BumpMap;
float3 viewDir;
};
sampler2D _MainTex;
sampler2D _BumpMap;
float4 _RimColor;
float _RimPower;
void surf (Input IN, inout SurfaceOutput o) {
o.Albedo = tex2D (_MainTex, IN.uv_MainTex).rgb;
o.Normal = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap));
half rim = 1.0 - saturate(dot (normalize(IN.viewDir), o.Normal));
o.Emission = _RimColor.rgb * pow (rim, _RimPower);
}
ENDCG
}
Fallback "Diffuse"
}

这段代码又多了两行,首先我们看第一行:half rim = 1.0 - saturate(dot (normalize(IN.viewDir), o.Normal));,我们解读一下这段代码,首先是normalize(IN.viewDir) ,将视角方向单位化,然后dot方法来和法线点乘,saturate函数的作用是,如果结果小于0则返回0,如果大于1,则返回1。那么这一段代码的结果就是,判断视角方向与这一点法线的方向,如果夹角大于90度,点乘结果就小于0,saturate结果就是0,那么rim的结果就是1,其他的大家可以自己自行推倒。

然后是第二行:o.Emission = _RimColor.rgb * pow (rim, _RimPower);,通过pow函数来对rim进行幂,获得该点的自发光强度,然后乘上我们设置给_RimColor的rgb值,最后赋值给Emission也就是该点的自发光属性。

官方文档还有一些其他的示例,这里就不一一展开分析了,有兴趣可以继续看:https://docs.unity3d.com/cn/current/Manual/SL-SurfaceShaderExamples.html

Unity背后做了什么

Unity在背后会根据表面着色器生成一个包含了很多Pass的顶点/片元着色器。

这些Pass有些是为了针对不同的渲染路径,例如,默认情况下Unity 会为前向渲染路径生成LightMode 为 ForwardBase 和 ForwardAdd 的Pass,为Unity 5 之前的延迟渲染路径生成LightMode 为PrePassBase 和 PrePassFinal 的Pass,为Unity5之后的延迟渲染路径生成LightMode 为 Deferred 的Pass。

还有一些Pass 是用于产生额外的信息。例如,为了给光照映射和动态全局光照提取表面信息,Unity 会生成一个LightMode 为 Meta 的Pass。这些Pass 的生成都是基于我们再表面着色器中的编译指令和自定义的函数,这是由规律可循的。Unity 提供了一个功能,让我们可以对表面着色器自动生成的代码一探究竟:在每个编译完成的表面着色器的面板上,有一个“Show generated code” 按钮,如下图所示。我们只需要单击一下就可以看到Unity为这个表面着色器生成的所有顶点/片元着色器。

以Unity生成的LightMode 为ForwardBase 的Pass为例,它的渲染流水线如下图所示:

Unity对该Pass的自动生成过程大致如下:

  1. 将表面着色器中CGPROGRAM和ENDCG之间的代码复制过来。
  2. Unity根据上述代码生成结构体v2f_surf(顶点着色器的输出)。如果Input定义了一些变量但没有使用,生成的结构体也不会包含该变量。还会包含阴影纹理坐标、光照纹理坐标、逐顶点光照等。
  3. 生成顶点着色器。
  • 如果定义了顶点修改函数,会先调用,或填充自定义Input结构体中的变量。Unity会分析该函数修改的数据,通过Input结构体把修改结果存储到v2f_surf相应变量。
  • 计算v2f_surf中其他变量:顶点位置、纹理坐标、法线方向、逐顶点光照、光照纹理等。
  • 把v2f_surf传递给片元着色器。
  1. 生成片元着色器。
  • 将v2f_surf变量(纹理坐标、视角方向)填充到Input结构体。
  • 调用自定义表面函数,填充SurfaceOutput结构体。
  • 调用光照函数得到初始的颜色值。如果使用内置的Lambert或BlinnPhong光照函数,Unity还会计算动态全局光照,并添加到光照模型的计算。
  • 进行其他颜色叠加。例如没有光照烘培,会添加逐顶点光照的影响。
  • 调用最后的颜色修改函数。

Shader Graph

为了简化我们编写顶点着色器和片元着色器的过程,Unity将他们抽象成了表面着色器。
但是这还不够,为了更加简化,Unity又提出了Shader Graph,它可以帮我们可视化的进行Shader的编写。

我们就简单拖拽一个出来:

这个Shader Graph的功能就是从贴图中提取出每个点对应在贴图中的rgb值,然后分别赋值给表面着色器输出的,Albeo和Emission

这个Shader Graph存储时是以一个又一个节点的方式存储的,它会被编译成表面着色器,进而编译成顶点着色器和偏远着色器。

Shader Graph虽然方便,但是也有个问题,就是他默认添加了很多关键字,这会导致最终编译出的Shader变体过多,如果是简单的Shader,其实没必要用到Shader Graph

参考文章:
https://www.jianshu.com/p/3d8a9f3f2430
https://docs.unity3d.com/cn/current/Manual/SL-SurfaceShaders.html