17 Unity的表面着色器探秘
09 年时,Unity 的渲染工程师 Aras 连续发表了 3 篇名为《Shaders must die》的博客。在这些博客里,Aras 认为,把渲染流程分为顶点和像素的抽象层面是错误的,是一种不易理解的抽象。目前,这种在顶点/几何/片元着色器上的操作时对硬件友好的一种方式,但不符合我们人类的思考方式。相反,他认为,应该划分成表面着色器、光照模型和光照着色器这样的层面。其中,表面着色器定义了模型表面的反射率、法线和高光等,光照模型则选择是使用兰伯特还是 Blinn-Phong 等模型。而光照着色器负责计算光照衰减、阴影等。这样,绝大部分时间我们只需要和表面着色器打交道,例如,混合纹理和颜色等。光照模型可以是提前定义好的,我们只需要选择哪种预定义的光照模型即可。而光照着色器一旦由系统实现后,更不会轻易改动,从而大大减轻了 Shader 编写者的工作量。有了这样的想法,Aras 在随后的文章中开始尝试把表面着色器整合到 Unity 中。最终,在 2010 年的 Unity3 中,Surface Shader 被加入到 Unity 的大家族中了。
虽然 Unity 换了一个新的“马甲”,但**表面着色器(Surface Shader)**实际上就是在顶点/片元着色器之上又添加了一层抽象。按 Aras 的话来解释就是,顶点/几何/片元着色器是硬件能“理解”的渲染方式,而开发者应该使用一种更容易理解的方式。很多时候,使用表面着色器,我们只需要告诉 Shader:“嘿,使用这些纹理去填充颜色,使用这个法线纹理去填充表面法线,使用兰伯特光照模型,其他的就不要来烦我了”我们不需要考虑是使用前向渲染路径还是延迟渲染路径,场景中有多少光源,它们的类型是什么,怎么处理这些光源,每个 Pass 需要处理多少个光源等问题(正是因为这些事情,人们总会抱怨写一个 Shader 是多么的麻烦)。这时,Unity 说:“不要急,我来干”
1 表面着色器的一个例子
新建一个 BumpedDiffuse.shader,将 BumpedDiffuseMat 的 Shader 设置为 BumpedDiffuse.shader
Shader "MyShader/17-SurfaceShader/BumpedDiffuse"
{
Properties
{
_Color ("Main Color", Color) = (1, 1, 1, 1)
_MainTex ("Base (RGB)", 2D) = "white" {}
_BumpMap ("Normalmap", 2D) = "bump" {}
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 300
CGPROGRAM
#pragma surface surf Lambert
#pragma target 3.0
sampler2D _MainTex;
sampler2D _BumpMap;
fixed4 _Color;
struct Input
{
float2 uv_MainTex;
float2 uv_BumpMap;
};
void surf(Input IN, inout SurfaceOutput o)
{
fixed4 tex = tex2D(_MainTex, IN.uv_MainTex);
o.Albedo = tex.rgb * _Color.rgb;
o.Alpha = tex.a * _Color.a;
o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));
}
ENDCG
}
FallBack "Legacy Shaders/Diffuse"
}
为 BumpedDiffuseMat 的 _MainTex 和 _BumpMap 赋值,就可以得到效果,还可以在场景中添加一些点光源和聚光灯,并改变他们的颜色,可以发现模型可以随着光照的变化而变化。
从上面的例子可以看出,相比之前所学的顶点/片元着色器技术,表面着色器的代码量很少(只需要三十多行),如果我们使用顶点/片元着色器来实现上述的功能,大概需要 150 行代码。而且,我们可以非常轻松地实现常见的光照模型,甚至不需要和任何光照变量打交道,Unity 就帮我们处理好了每个光源的光照结果。
读者可以在 Unity 官方手册的表面着色器的例子一文中找到更多的示例程序。
和顶点/片元着色器包含到一个特定的 Pass 块中不同,表面着色器的 CG 代码是直接而且也必须写在 SubShader 块中,Unity 会在背后为我们生成多个 Pass。当然,可以在 SubShader 一开始处是哦那个 Tags 来设置该表面着色器使用的标签。我们还使用 LOD 命令设置了该表面着色器的 LOD 值。然后,我们使用 CGPROGRAM 和 ENDCG 定义了表面着色器的具体代码。
一个表面着色器中最重要的部分是两个结构体以及它的编译指令。其中,两个结构体是表面着色器中不同函数之间传递信息的桥梁,而编译指令是我们和 Unity 沟通的重要手段。
2 编译指令
我们首先来看一下表面着色器的编译指令。编译指令是我们和 Unity 沟通的重要方式,通过它可以告诉 Unity:“嘿,用这个表面函数设置表面属性,用着光照模型模拟光照,我不要阴影和环境光,不要雾效!”只需要一句代码,我们就可以完成这么多事情。
编译指令最重要的作用是指明该表面着色器使用的表面函数和光照函数,并设置一些可选参数。表面着色器的 CG 块中第一句代码往往就是它的编译指令。编译指令的一般格式如下:
#pragma surface surfaceFunction lightModel [optionalparams]
其中,#param surface 用于指明该编译指令是用于定义表面着色器的,在它的后面需要指明使用的表面函数(surfaceFunction)和光照模型(lightModel),同时,还可以使用一些可选参数来控制表面着色器的一些行为。
2.1 表面函数
我们之前说过,表面着色器的优点在于抽象出了“表面”这一概念。在之前遇到的顶点/片元抽象层不同,一个对象的表面属性定义了它的反射率、光滑度、透明度等值。而编译指令中的 surfaceFunction 就用于定义这些表面属性。surfaceFunction 通常就是名为 surf 的函数(函数名可以是任意的),它的函数格式是固定的:
void surf(Input IN, inout SurfaceOutput o)
void surf(Input IN, inout SurfaceOutputStandard o)
void surf(Input IN, inout SurfaceOutputStandardSpecular o)
其中,后两个是 Unity5 中由于引入了基于物体的渲染而新添加的两种结构体。SurfaceOutput、SurfaceOutputStandard 和 SurfaceOutputStandardSpecular 都是 Unity 内置的结构体,它们需要配合不同的光照模型使用,我们会在下一节进行更详细地解释。
在表面函数中,会使用输入结构体 Input IN 来设置各种表面属性,并把这些属性存储在输出结构体 SurfaceOutput、SurfaceOutputStandard、SurfaceOutputStandardSpecular 中,再传递给光照函数计算光照结果。可以在 Unity 手册中的表面着色器的例子一文中找到更多的示例表面函数。
2.2 光照函数
除了表面函数,我们还需要指定另一个非常重要的函数——光照函数。光照函数会使用表面函数中设置的各种表面属性,来应用某些光照模型,进而模拟物体表面的光照效果。Unity 内置了基于物理的光照函数模型 Standard 和 StandardSpecular(在 UnityPBSLighting.cginc 文件中被定义),以及简单的非基于物体的光照模型函数 Lambert 和 BlinnPhong(在 Lighting.cginc 文件中被定义)。例如,在 Chapter17-BumpedDiffuse 中,我们就指定了使用 Lambert 光照函数。
当然,我们也可以定义自己的光照函数。例如,可以使用下面的函数来定义用于前向渲染中的光照函数:
// 用于不依赖视角的光照模型,例如漫反射
half4 Lighting<Name>(SurfaceOutput s, half3 lightDir, half atten);
// 用于依赖视角的光照模型,例如高光反射
half4 Lighting<Name>(SurfaceOutput s, half3 lightDir, half3 viewDir, half atten);
可以在 Unity 手册的表面着色器中的自定义光照模型一文中找到更全面的自定义光照模型的介绍。而一些例子可以参见手册中的表面着色器的光照例子一文,这篇文档展示了如何使用表面着色器来自定义常见的漫反射、高光反射、基于光照纹理等常用的光照模型。
2.3 其他可选参数
在编译指令的最后,我们还可以设置一些可选参数(optionalparams)。这些可选参数包含了很多非常有用的指令类型,例如,开启/设置透明度混合/透明度测试,指明自定义的顶点和颜色修改函数,控制生成代码等。下面,我们选取了一些比较重要和常用的参数进行更深入地说明。可以在 Unity 官方手册的编写表面着色器一文中找到更加详细的参数和设置说明。
- 自定义修改函数。除了表面函数和光照模型外,表面着色器还可以支持其他两种自定义的函数:顶点修改函数(vertex: VetexFunction)和最后的颜色修改函数(finalColor: ColorFunction)。顶点修改函数允许我们自定义一些顶点属性,例如,把顶点颜色传递给表面函数,或是修改顶点位置,实现某些顶点动画等。最后的颜色修改函数则可以在颜色绘制到屏幕前,最后一次修改颜色值,例如实现自定义雾效等。
- 阴影。我们可以通过一些指令来控制和阴影相关的代码。例如,addshadow 参数会为表面着色器生成一个阴影投射的 Pass。通常情况下,Unity 可以直接在 FallBack 中找到通用的光照模式为 ShadowCaster 的 Pass,从而将物体正确地渲染到深度和阴影纹理中。但对于一些进行顶点动画、透明度测试的物体,我们就需要对阴影的投射进行特殊处理,来为它们产生正确的阴影。正如我们在 11.3.3 节中看到的一样。fullforwardshadows 参数则可以在前向渲染路径中支持所有光源类型的阴影。默认情况下,Unity 只支持最重要的平行光的阴影效果。如果我们需要让点光源或聚光灯在前向渲染中也可以有阴影,就可以添加这个参数。相反地,如果我们不想对使用这个 Shader 的物体进行任何阴影计算,就可以使用 noshadow 参数来禁用阴影。
- 透明度混合和透明度测试。我们可以通过 alpha 和 alphatest 指令开控制透明度混合和透明度测试。例如,alphatest: VariableName 指令会使用名为 VariableName 的变量来剔除不满足条件的片元。此时,我们可能还需要使用上面提到的 addshadow 参数来生成正确的阴影投射 Pass。
- 光照。一些指令可以控制光照对物体的影响,例如,noambient 参数会告诉 Unity 不要应用任何环境光照或光照探针(light probe)。novertexlights 参数告诉 Unity 不要应用任何逐顶点光照。noforwardadd 会去掉所有前向渲染中的额外的 Pass。也就是说,这个 Shader 只会支持一个逐像素的平行光,而其他的光源会按照逐顶点或 SH 的方法来计算光照影响。这个参数通常会用于移动平台版本的表面着色器中。还有一些用于控制光照烘培、雾效模拟的参数,如 nlightmap、nofog 等。
- 控制代码的生成。一些指令还可以控制表面着色器自动生成代码,默认情况下,Unity 会为一个表面着色器生成相应的前向渲染路径、延迟渲染路径使用的 Pass,这会导致生成的 Shader 文件比较大。如果我们确定该表面着色器只会在某些渲染路径中使用,就可以 exclude_path: defrred、exclude_path: forward 和 exclude_path: prepass 来告诉 Unity不需要为某些渲染路径生成代码。
从上述可以看出,表面着色器支持的编译指令参数很多,为我们编写表面着色器提供了很大的方便。之前在顶点/片元着色器中需要耗费大量代码来完成的工作,在表面着色器中可能只需要一个参数亏可以了。当然,相比于顶点/片元着色器,表面着色器也有自身的限制,我们会在 17.6 节中对比它们的优缺点。
3 两个结构体
在上一节我们讲过,表面着色器支持最多自定义 4 种关键的函数:
- 表面函数(用于设置各种表面性质,如反射率、法线等)
- 光照函数(定义表面使用的光照模型)
- 顶点修改函数(修改或传递顶点属性)
- 最后的颜色修改函数(对最后的颜色进行修改)
那么,这些函数之间的信息传递是怎么实现的呢?例如,我们想把顶点颜色传递给表面函数,添加到表面反射率的计算中,要怎么做呢?这就是两个结构体的工作。
一个表面着色器需要使用两个结构体:表面函数的输入结构体 Input,以及存储了表面属性的结构体 SurfaceOutput(Unity 5 新引入了另外两个同种的结构体 SurfaceOutputStandard 和 SurfaceOutputStandardSpecular)
3.1 数据来源:Input结构体
Input 结构体包含了许多表面属性的数据来源,因此,它会作为表面函数的输入结构体(如果自定义了顶点修改函数,它还会是顶点修改函数的输出结构体)。Input 支持很多内置的变量名,通过这些变量名,我们告诉 Unity 需要使用的数据信息。例如,在上文提到的BumpedDiffuse 中,Input 结构体中包含了主纹理和法线纹理的采样坐标 uv_MainTex 和 uv_BumpMap。这些采样坐标必须以“uv”为前缀(实际上也可以用“uv2”为前缀,表明使用次纹理坐标集合),后面紧跟纹理名称。以主纹理 _MainTex 为例,如果需要使用它的采样坐标,就需要在 Input 结构体中声明 float2 uv_MainTex 来对应它的采样坐标。下表列出了 Input 结构体中的其他变量。
变量 | 描述 |
---|---|
float3 viewDir | 包含了视角方向,可用于计算边缘光照等 |
使用 COLOR 语义定义的 float4 变量 | 包含了插值后的逐顶点颜色 |
float4 screenPos | 包含了屏幕空间的坐标,可以用于反射或屏幕特效 |
float3 worldPos | 包含了世界空间下的位置 |
float3 worldRefl | 包含了世界空间下的反射方向前提是没有修改表面法线 o.Normal |
float3 worldRefl: INTERNAL_DATA | 如果修改了表面法线 o.Normal,需要使用该变量告诉 Unity 要基于修改后的法线计算世界空间下的反射方向。在表面函数中,我们需要使用 WorldReflectionVector(IN, o.Normal)来得到世界空间下的反射方向。 |
float3 worldNormal | 包含了世界空间的法线方向。前提是没有修改表面法线 o.Normal |
float3 worldNormal: INTERNAL_DATA | 如果修改了表面法线 o.Normal,需要使用该变量告诉 Unity 要基于修改后的法线计算世界空间下的法线方向。在表面函数中,我们需要使用 WorldNormalVector(IN, o.Normal)来得到世界空间下的法线方向 |
需要注意的是,我们并不需要自己计算上述的各个变量,而只需要在 Input 结构体中按上述名称严格声明这些变量即可,Unity 会在背后为我们准备好这些数据,而我们只需要在表面函数中直接使用它们即可。一个例外情况是,我们自定义了顶点修改函数,并需要向表面函数中传递一些自定义的数据。例如,为了自定义雾效,我们可能需要在顶点修改函数中根据顶点在视角空间下的位置信息计算雾效混合系数,这样我们就可以在 Input 结构体中定义一个名为 half fog 的变量,把计算结果存储在该变量后进行输出。
3.2 表面属性:SurfaceOutput 结构体
有了 Input 结构体来提供所需要的数据后,我们就可以据此计算各种表面属性。因此,另一个结构体就是用于存储这些表面属性的结构体,即 SurfaceOutput、SurfaceOutputStadard 和 SurfaceOutputStandardSpecular,它会作为表面函数的输出,随后会作为光照函数的输入来进行各种光照计算。相比于 Input 结构体的自由性,这个结构体里面的变量是提前就声明好的,不可以增加也不会减少(如果没有对某些变量赋值,就会使用默认值)。SurfaceOutput 的声明可以在 Lighting.cginc 文件中找到;
struct SurfaceOutput
{
fixed3 Albedo;
fixed3 Normal;
fixed3 Emission;
half Specular;
fixed GLoss;
fixed Alpha;
};
而 SurfaceOutputStandard 和 SurfaceOutputStandardSpecular 的声明可以在 UnityPBSLighting.cginc 中找到:
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值
}
在一个表面着色器中,只需要选择上述三者中的其一即可,这取决于我们选择使用的光照模型。Unity 内置的光照模型有两种:
- Unity5 之前的、简单的、非基于物理的光照模型,包括 Lambert 和 BlinnPhong。
- 使用结构体:SurfaceOutput
- Unity5 添加的,基于物理的光照模型,包括 Standard 和 StandardSpecular。
- 使用结构体:SurfaceOutputStandard 或 SurfaceOutputStandardSpecular
基于物理的光照模型会更加符合物理规律,但计算也会复杂很多。其中,SurfaceOutputStandard 结构体用于默认的金属工作流程(Metallic Workflow),对应了 Standard 光照函数;而 SurfaceOutputStandardSpecular 结构体用于高光工作流程(Specular Workflow),对应了 StandardSpecular 光照函数。更多基于物理的渲染内容,会在之后讲到。
本节中,我们着重介绍一下 SurfaceOutput 结构体中的变量和含义。在表面函数中,我们需要根据 Input 结构体传递的各个变量计算表面属性。在 SurfaceOutput 结构体,这些表面属性包括了:
- fixed3 Albedo: 对光源的反射率。通常由纹理采样和颜色属性的乘积计算而得。
- fxied3 Normal: 表面法线方向。
- fixed3 Emission: 自发光。Unity通常会在片元着色器最后输出前(并在最后的顶点函数被调用前,如果定义了的话),使用类似下面的语句进行简单的颜色叠加:
c.rgb += o.Emission;
- half Specular: 高光反射中的指数部分的系数,影响高光反射的计算。例如,如果使用了内置的 BlinnPhong 光照函数,它会是使用如下语句计算高光反射的强度:
float spec = pow(nh, s,Specular * 128.0) * s.Gloss
- fixed Gloss: 高光反射中的强度系数。和上面的 Specular 类似,计算公式见上面的代码。一般在包含了高光反射的光照模型里使用。
- fixed Alpha: 透明通道。如果开启了透明度的话,会使用该值进行颜色混合。
尽管表面着色器极大的减少了我们的工作量,但它带来的一个问题是,我们经常不知道为什么会得到这样的渲染结果。初学者常常会提出这样的问题:“为什么我的场景里没有灯光,但物体不是全黑的呢?为什么我把光源的颜色调成黑色,物体还是有一些渲染颜色呢?”这些问题都源于表面着色器对我们隐藏了实现细节。而想要更加得心应手地使用表面着色器,我们就需要学习它的工作流水线,并了解 Unity 是如何为一个表面着色器生成对应的顶点/片元着色器的(表面着色器本质上就是包含了很多 Pass 的顶点/片元着色器)。
4 Unity背后做了什么
在前面的内容中,我们已经了解到如何利用编译指令、自定义函数(表面函数、光照函数,以及可选的顶点修改函数和最后的颜色修改函数)和两个结构体来实现一个表面着色器。我们一直强调,Unity 实际会在背后为表面着色器生成真正的顶点/片元着色器。那么,表面着色器中的各个函数、编译指令和结构体于顶点/片元着色器之间有什么关系呢?这正是本节要学习的内容。
我们之前说过,Unity 在背后会根据表面着色器生成一个包含了很多 Pass 的顶点/片元着色器。这些 Pass 有些是为了针对不同的渲染路径,例如,默认情况下 Unity 会为前向渲染路径生成 LightMode 为 ForwardBase 和 ForwardAdd 的 Pass,为 Unity 5 之前的延迟渲染路径生成 LightMode 为 PrePassBase 和 PrePassFinal 的 Pass,为 Unity 5 之后的延迟渲染路径生成 LightMode 为 Deferred 的 Pass。还有一些 Pass 是用于产生额外的信息,例如,为了给光照映射和动态全局光照提取表面信息,Unity 会生成一个 LightMode 为 Meta 的 Pass。有些表面着色器由于修改了顶点位置,因此,我们可以利用 addshadow 编译指令为它生成相应的 LightMode 为 ShadowCaster 的阴影投射 Pass。这些 Pass 的生成都是基于我们在表面着色器中的编译指令和自定义的函数,这是由规律可循的。Unity 提供了一个功能,在每个编译完成的表面着色器面板上,都有一个“Show generated code”的按钮,我们只需要单击一下它就可以看到 Unity 为这个表面着色器生成的所有顶点/片元着色器。
通过查看这些代码,我们就可以了解到 Unity 到底是如何根据表面着色器生成各个 Pass 的。以 Unity 生成的 lightMode 为 ForwardBase 的 Pass(用于前向渲染)为例,它的渲染计算流水线如下图所示,我们可以看出 4 个允许自定义的函数在流水线中的位置。
Unity 对该 Pass 的自动生成过程大致如下:
- 直接将表面着色器中 CGPROGRAM 和 ENDCG 之间的代码复制过来,这些代码包括了我们对 Input 结构体、表面函数、光照函数(如果自定义了的话)等变量和函数的定义。这些函数和变量会在之后的处理过程中被当成正常的结构体和函数进行调用。
- Unity 会分析上述代码,并据此生成顶点着色器的输出——v2f_surf 结构体,用于在顶点着色器和片元着色器之间进行数据传递。Unity 会分析我们在自定义函数中所使用的变量,例如,纹理坐标、视角方向、反射方向等。如果需要,他就会在 v2f_surf 中生成相应的变量。而且,即便有时我们在 Input 中定义了某些变量(如某些纹理坐标),但 Unity 在分析后续代码时发现我们并没有使用这些变量,那么这些变量实际上是不会在 v2f_surf 中生成的。这也就是说,Unity 做了一些优化。v2f_surf 中还包含了一些其他需要的变量,例如阴影我呢里坐标、光照纹理坐标、逐顶点光照等。
- 接着,生成顶点着色器
- 如果我们自定义了顶点修改函数,Unity 会首先调用顶点修改函数来修改顶点数据,或填充自定义的 Input 结构体中的变量。然后,Unity 会分析顶点修改函数中修改的数据,在需要时通过 Input 结构将修改结果存储到 v2f_surf 相应的变量中。
- 计算 v2f_surf 中其他生成的变量值。这主要包括了顶点位置、纹理坐标、法线方向、逐顶点光照、光照纹理的采样坐标等。当然,我们可以通过编译指令来控制某些变量是否需要计算。
- 最后,将 v2f_surf 传递给接下来的片元着色器。
- 生成片元着色器
- 使用 v2f_surf 中的对应变量填充 Input 结构体,例如,纹理坐标、视角方向等。
- 调用我们自定义的表面函数填充 SurfaceOutput 结构体。
- 调用光照函数得到初始的颜色值。如果使用的是内置的 Lambert 或 BlinnPhong 光照函数,Unity 还会计算动态全局光照,并添加到光照模型的计算中。
- 进行其他的颜色叠加。例如,如果没有使用光照烘培,还会添加逐顶点光照的影响。
- 最后,如果自定义了最后的颜色修改函数,Unity就会调用它进行最后的颜色修改。其他 Pass 的生成过程和上面类似,在此不再赘述。
5 表面着色器实例分析
为了更加深入地理解表面着色器背后的原理,本节以一个表面着色器为例,分析 Unity 为它生成的代码。
我们以以下Shader为例,实现的效果是对模型进行膨胀,这种效果的实现非常简单,就是在顶点修改函数中沿着顶点法线方向扩张顶点位置。为例分析表面着色器中 4 个可自定义函数(顶点修改函数、表面函数、光照函数和最后的颜色修改函数)的原理,在本例中我们对这 4 个函数全部采用了自定义的实现。
Shader "MyShader/17-SurfaceShader/NormalExtrusion"
{
Properties
{
_ColorTint ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Base (RGB)",2D) = "white" {}
_BumpMap ("Normalmap", 2D) = "bump" {}
_Amount ("Extrusion Amount", Range(-0.5, 0.5)) = 0.1
}
SubShader
{
Tags {"RenderType" = "Opaque"}
LOD 300
CGPROGRAM
#pragma surface surf CustomLambert vertex:myvert finalcolor:mycolor addshadow exclude_path:deferred exclude_path:prepass nometa
// surf - 表面函数
// CustomLambert - 光照函数
// vertex:myvert - 顶点修改函数
// finalcolor:mycolor - 颜色修改函数
// addshadow - 由于我们修改了顶点位置,因此要对其他物体产生正确的阴影效果并不能直接依赖FallBack中找到的阴影投射Pass,addshadow参数可以告诉Unity要生成一个该表面着色器对应的阴影投射Pass
// 默认情况下,Unity会为所有支持的渲染路径生成相应的Pass,为了缩小自动生成的代码量,做了以下操作
// exclude_path:deferred/exclude_path:prepas - 告诉Unity不要为延迟渲染路径生成相应的Pass
// nometa - 取消对提取元数据的Pass的生成
#pragma target 3.0
fixed4 _ColorTint;
sampler2D _MainTex;
sampler2D _BumpMap;
half _Amount;
struct Input
{
float2 uv_MainTeX;
float2 uv_BumpMap;
};
// 顶点修改函数:使用顶点法线对顶点位置进行膨胀
void myvert(inout appdata_full v)
{
v.vertex.xyz += v.normal * _Amount;
}
// 表面函数
void surf(Input IN, inout SurfaceOutput o)
{
fixed4 tex = tex2D(_MainTex, IN.uv_MainTeX);
o.Albedo = tex.rgb; // 使用主纹理设置表面属性中的反射率
o.Alpha = tex.a; // 使用法线纹理设置表面法线方向
o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));
}
// 光照函数:实现简单的兰伯特漫反射光照模型
half4 LightingCustomLambert(SurfaceOutput s, half3 lightDir, half atten)
{
half NdotL = dot(s.Normal, lightDir);
half4 c;
c.rgb = s.Albedo * _LightColor0.rgb * (NdotL * atten);
c.a = s.Alpha;
return c;
}
// 颜色修改函数:使用颜色参数对输出颜色进行调整
void mycolor(Input IN, SurfaceOutput o, inout fixed4 color)
{
color *= _ColorTint;
}
ENDCG
}
FallBack "Legacy Shaders/Diffuse"
}
当在该表面着色器的导入面板中单击“Show generated code”按钮后,我们就可以看到 Unity 生成的顶点/片元着色器了。
Unity 一共为该表面着色器生成了 3 个 Pass,它们的 LightMode 分别是 ForwardBase、ForwardAdd 和 ShadowCaster,分别对应了前向渲染路径中的处理逐像素平行光的 Pass、处理其他逐像素光的 Pass、处理阴影投射的 Pass。这些 Pass 的原理可以回顾 9.1.1 节和 9.4 节中的相关内容。读者可以在这些代码中看到大量的 #ifdef 和 #if 语句,这些语句可以用于判断一些渲染条件,例如,是否使用动态光照纹理、是否使用了逐顶点光照、是否使用了屏幕空间的阴影等,Unity 会根据这些条件来进行不同的光照计算,这正是表面着色器的魅力之一——把这些烦人的光照计算交给 Unity 来做。
需要注意的是,不同的 Unity 版本可能生成的代码有少许不同。下面,我们来分析 Unity 生成的 ForwardBase Pass
-
指明了一些编译指令。
Pass { Name "FORWARD" Tags { "LightMode" = "ForwardBase" } CGPROGRAM // compile directives #pragma vertex vert_surf #pragma fragment frag_surf #pragma target 3.0 #pragma multi_compile_instancing #pragma multi_compile_fwdbase #include "HLSLSupport.cginc" #define UNITY_INSTANCED_LOD_FADE #define UNITY_INSTANCED_SH #define UNITY_INSTANCED_LIGHTMAPSTS #include "UnityShaderVariables.cginc" #include "UnityShaderUtilities.cginc"
顶点着色器 vert_surf 和片元着色器 frag_surf 都是自动生成的。
-
自动生成注释。
// Surface shader code generated based on: // vertex modifier: 'myvert' // writes to per-pixel normal: YES // writes to emission: no // writes to occlusion: no // needs world space reflection vector: no // needs world space normal vector: no // needs screen space position: no // needs world space position: no // needs view direction: no // needs world space view direction: no // needs world space position for lighting: no // needs world space view direction for lighting: no // needs world space view direction for lightmaps: no // needs vertex color: no // needs VFACE: no // passes tangent-to-world matrix to pixel shader: YES // reads from normal: no // 2 texcoords actually used // float2 _MainTeX // float2 _BumpMap
尽管这些对渲染结果没有影响,但我们可以从这些注释中理解到 Unity 的分析过程和它的分析结果。
-
定义一些宏来辅助计算。
#define INTERNAL_DATA half3 internalSurfaceTtoW0; half3 internalSurfaceTtoW1; half3 internalSurfaceTtoW2; #define WorldReflectionVector(data,normal) reflect (data.worldRefl, half3(dot(data.internalSurfaceTtoW0,normal), dot(data.internalSurfaceTtoW1,normal), dot(data.internalSurfaceTtoW2,normal))) #define WorldNormalVector(data,normal) fixed3(dot(data.internalSurfaceTtoW0,normal), dot(data.internalSurfaceTtoW1,normal), dot(data.internalSurfaceTtoW2,normal))
实际上,在本例中上述宏并没有被用到。这些宏是为了在修改了表面法线的情况下,辅助计算得到世界空间下的反射方向和法线方向,预制对应的是 Input 结构体中的一些变量
-
复制表面着色器中编写的 CG 代码,作为 Pass 的一部分,以便后续调用。
-
定义顶点着色器到片元着色器的插值结构体(即顶点着色器的输出结构体)v2f_surf。在定义前,Unity 使用#ifdef 语句来判断是否使用了光照纹理,并为不同的情况生成不同的结构体。主要区别是,如果没有使用光照纹理,就需要定义一个存储逐顶点的 SH 光照的变量。
// vertex-to-fragment interpolation data // no lightmaps: #ifndef LIGHTMAP_ON // half-precision fragment shader registers: #ifdef UNITY_HALF_PRECISION_FRAGMENT_SHADER_REGISTERS struct v2f_surf { UNITY_POSITION(pos); float4 pack0 : TEXCOORD0; // _MainTeX _BumpMap float4 tSpace0 : TEXCOORD1; float4 tSpace1 : TEXCOORD2; float4 tSpace2 : TEXCOORD3; fixed3 vlight : TEXCOORD4; // ambient/SH/vertexlights UNITY_LIGHTING_COORDS(5,6) #if SHADER_TARGET >= 30 float4 lmap : TEXCOORD7; #endif UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO }; #endif // high-precision fragment shader registers: #ifndef UNITY_HALF_PRECISION_FRAGMENT_SHADER_REGISTERS struct v2f_surf { UNITY_POSITION(pos); float4 pack0 : TEXCOORD0; // _MainTeX _BumpMap float4 tSpace0 : TEXCOORD1; float4 tSpace1 : TEXCOORD2; float4 tSpace2 : TEXCOORD3; fixed3 vlight : TEXCOORD4; // ambient/SH/vertexlights UNITY_SHADOW_COORDS(5) #if SHADER_TARGET >= 30 float4 lmap : TEXCOORD6; #endif UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO }; #endif
上面很多变量名看起来很陌生,但实际上大部分变量的含义我们在之前都碰到过,只是这里使用了不同的名称而已。例如,在下面我们会看到,pack0 中实际上存储的就是主纹理和法线纹理的采样坐标,而 tSpace0、tSpace1 和 tSpace2 存储了从切线空间到世界空间的变换矩阵。一个比较模式的变量是 vlight,Unity 会把逐顶点和 SH 光照的结果存储到该变量里,并在片元着色器中和原光照结果进行叠加(如果需要的话)
-
定义真正的顶点着色器,首先会调用我们自定义的顶点修改函数来修改一些顶点属性。
// vertex shader v2f_surf vert_surf (appdata_full v) { UNITY_SETUP_INSTANCE_ID(v); v2f_surf o; UNITY_INITIALIZE_OUTPUT(v2f_surf,o); UNITY_TRANSFER_INSTANCE_ID(v,o); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); myvert (v);
在我们的实现中,只对顶点坐标进行了修改,而不需要向 Input 结构体中添加并存储新的变量。也可以使用另一个版本的函数声明来把顶点修改函数中的某些计算结果存储到 Input 结构体中
void vert(inout appdata_full v, out Input o);
之后的代码是用于计算 v2f_surf 中各个变量的值:
- 计算经过 MVP 矩阵变换后的顶点坐标;
- 使用 TRANSFORM_TEX 内置宏计算两个纹理的采样坐标,并分别存储在 o.pack0 的 xy 分量和 zw 分量中;
- 计算从切线空间到世界空间的变换矩阵,并把矩阵的每一行分别存储在 o.tSpace0、o.tSpace1 和 o.tSpace2 变量中;
- 判断是否使用了光照映射和动态光照映射,并在需要时把两种光照纹理的采样坐标计算结果存储在 o.lmap.xy 和 o.lmap.ze 分量中;
- 判断是否使用了光照映射,如果没有的话就计算该顶点的 SH 光照(一种快速计算光照的方式),把结果存储到 o.vlight 中;
- 判断是否开启了逐顶点光照,如果是就计算最重要的 4 个逐顶点光照的光照结果,把结果叠加到 o.vlight 中。
最后,计算阴影坐标并传递给片元着色器
UNITY_TRANSFER_LIGHTING(o,v.texcoord1.xy); // pass shadow and, possibly, light cookie coordinates to pixel shader return o; }
-
在 Pass 的最后,定义了真正的片元着色器。Unity 首先利用插值后的结构体 v2f_surf 来初始化 Input 结构体中的变量:
// fragment shader fixed4 frag_surf (v2f_surf IN) : SV_Target { UNITY_SETUP_INSTANCE_ID(IN); // prepare and unpack data Input surfIN; #ifdef FOG_COMBINED_WITH_TSPACE UNITY_RECONSTRUCT_TBN(IN); #else UNITY_EXTRACT_TBN(IN); #endif UNITY_INITIALIZE_OUTPUT(Input,surfIN); surfIN.uv_MainTeX.x = 1.0; surfIN.uv_BumpMap.x = 1.0; surfIN.uv_MainTeX = IN.pack0.xy; surfIN.uv_BumpMap = IN.pack0.zw;
随后,Unity 声明了一个 SurfaceOutput 结构体的变量,并对其中的表面属性进行了初始化,再调用了表面函数:
#ifdef UNITY_COMPILER_HLSL SurfaceOutput o = (SurfaceOutput)0; #else SurfaceOutput o; #endif o.Albedo = 0.0; o.Emission = 0.0; o.Specular = 0.0; o.Alpha = 0.0; o.Gloss = 0.0; fixed3 normalWorldVertex = fixed3(0,0,1); o.Normal = fixed3(0,0,1); // call surface function surf (surfIN, o);
在上面的代码中,Unity 还使用了 #ifdef 语句判断当前的编译语言类型是否是 HLSL,如果是就使用更严格的声明方式来声明 SurfaceOutput 结构体(因为 DirectX 平台往往有更加严格的语义要求)。当对各个表面属性进行初始化后,Unity 调用了表面函数 surf 来填充这些表面属性。
之后,Unity 进行真正的光照计算。首先,计算得到了光照衰减和时间空间下的法线方向:
// compute lighting & shadowing factor UNITY_LIGHT_ATTENUATION(atten, IN, worldPos) fixed4 c = 0; float3 worldN; worldN.x = dot(_unity_tbn_0, o.Normal); worldN.y = dot(_unity_tbn_1, o.Normal); worldN.z = dot(_unity_tbn_2, o.Normal); worldN = normalize(worldN); o.Normal = worldN;
其中,变量 c 用于存储最终的输出颜色,此时被初始化为 0.随后,Unity 判断是否关闭了光照映射,如果关闭了,就把逐顶点的光照解雇叠加到输出颜色中
#ifndef LIGHTMAP_ON c.rgb += o.Albedo * IN.vlight; #endif // !LIGHTMAP_ON
如果需要使用光照映射,Unity 就会使用之前计算的光照纹理采样坐标,对光照纹理进行采样并解码,得到光照纹理中的光照结果。这部分代码可以在生成的代码中找到。
如果没有使用光照映射,意味着我们需要使用自定义的光照模型计算光照结果
#ifndef LIGHTMAP_ON c += LightingCustomLambert (o, lightDir, atten); #else c.a = o.Alpha; #endif
而如果使用了光照映射的话,Unity 会根据之前由光照纹理得到的结果得到颜色值,并叠加到输出颜色 c 中。如果还开启了动态光照映射,Unity 还会计算对动态光照纹理的采样结果,同样把结果叠加到输出颜色 c 中。这部分代码读者可以在生成的代码中找到。
最后,Unity 调用自定义的颜色修改函数,对输出颜色 c 进行最后的修改:
mycolor (surfIN, o, c); UNITY_OPAQUE_ALPHA(c.a); return c; }
在上面的代码中,Unity 还使用了内置宏 UNITY_OPAQUE_ALPHA(在 UnityCG.cginc 里被定义)来重置片元的透明通道。在默认情况下,所有不透明类型的表面着色器的透明通道都会被重置为 1.0,而不管我们是否在光照函数中改变了他,如上所示。如果我们想要保留它的透明通道的话,可以在表面着色器的编译指令中添加 keepalpha 参数。
至此,ForwardBase Pass 就结束了。接下来的 ForwardAdd Pass 和上面的 ForwardBase Pass 基本类似,只是代码更加简单了,Unity 去掉了对逐顶点光照和各种判断是否使用了光照映射的代码,因为这些额外的 Pass 不需要考虑这些。
最后一个重要的 Pass 是 ShadowCaster Pass。相比于之前的两个 Pass,它的代码比较简单短小。它的生成原理很简单,就是通过调用自定义的顶点修改函数来保证计算阴影时使用的是和之前一致的顶点坐标。正如我们在 11.3.3 节和 15.1 节中看到的一样,这个自定义的阴影投射 Pass 同样使用了内置的 V2F_SHADOW_CASTER、TRANSFER_SHADOW_CASTER_NORMALOFFSET 和 SHADOW_CASTER_FRAGMENT 来计算阴影投射。
6 Surface Shader 的缺点
从上面的内容中我们可以看出,表面着色器给我们带来了很大的便利。那么,我们之前为什么还要花那么久的时间学习顶点/片元着色器?直接写表面着色器就好了嘛。
正如我们一直强调的那样,表面着色器只是 Unity 在顶点/片元着色器上面提供的一种封装,是一种更高层的抽象。但任何在表面着色器中完成的事情,我们都可以在顶点/片元着色器中重现,但不幸的时,这句话反过来并不成立。
表面着色器虽然可以快速实现各种光照效果,但我们失去了对各种优化和各种特效实现的控制。因此,使用表面着色器往往会对性能造成一定的影响,而内置的 Shader,例如 Diffuse、Bumped Specular 等都是使用表面着色器编写的。尽管 Unity 提供了移动平台的相应版本,例如 Mobile/Diffuse 和 Mobile/Bumped Specular 等,但这些版本的 Shader 往往只是去掉了额外的逐像素 Pass、不计算全局光照和其他一些光照计算上的优化。但要想进行更多深层的优化,表面着色器就不能满足我们的需求了。
除了性能比较差以外,表面着色器还无法完成一些自定义的渲染效果,例如 10.2.2 节中透明玻璃的效果。表面着色器的这些缺点让很多人更愿意使用更自由的顶点/片元着色器来实现各种效果,尽管处理光照时这可能难度更大些。
- 如果你需要和各种光源打交道,尤其是想要使用 Unity 中的全局光照的话,选择表面着色器,但要时刻小心它的性能;
- 如果需要处理的光源数目非常少,例如只有一个平行光,那么使用顶点/片元着色器是一个更好的选择
- 最重要的时,如果你有很多自定义的渲染效果,选择顶点/片元着色器。