2010年的Unity 3 中,Surface Shader 出现了。

表面着色器的一个例子。

我们先做如下准备工作。

1)新建一个场景,去掉天空盒子

2)新建一个材质,新建一个Shader,赋给材质。

3)场景中创建一个胶囊体,上步材质赋给它

然后我们修改Shader代码:


Shader "Unity Shaders Book/Chapter 17/Bumped Diffuse" {
	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"
}


我们在场景中添加一个点光源和聚光灯,效果如下图所示。

unity shader改变shadow强度 unity shader 颜色范围_光照模型

从上面的例子可以看出,相比之前所学的顶点/片元着色器技术,表面着色器的代码量很少。而且我们可以非常轻松地实现常见的光照效果,甚至不需要和任何光照变量打交道,Unity就帮我们处理好了每个光源的光照结果。

和顶点/片元着色器不同的是,表面着色器的CG代码是直接而且也必须写在SubShader中,Unity会在背后为我们生成多个Pass。当然,可以在SubShader一开始处使用Tags来设置该表面着色器使用的标签。

一个表面着色器中最重要的部分是两个结构体以及它的编译指令。其中两个结构体是表面着色器中不同函数之间信息的传递的桥梁,而编译指令是我们和Unity沟通的重要手段。


编译指令

编译指令最重要的作用是指明该表面着色器使用的表面函数和光照函数,并设置一些可选参数。表面着色器的CG块中的第一句代码往往就是它的编译指令。一般格式如下:


#pragma surface surfaceFunction lightModel [optionalparams]


其中,#pragma surface 用于指明该编译指令是用于定义表面着色器的,在它的后面需要指明使用的表面函数和光照模型,同时,还可以使用一些可选参数来控制表面着色器的一些行为。

与之前遇到的顶点/片元抽象层不同,一个对象的表面属性定义了它的反射率、光滑度、透明度等值。而编译指令中的surfaceFunction 就用于定义这些表面属性。surfaceFunction 通常就是名为surf 的函数(函数名可以是任意的)。它的函数格式是固定的:


void surf (Input IN,inout SurfaceOutput o)
void surf (Input IN,inout SurfaceOutputStandard o)
void surf (Input IN,inout SurfaceOutputStandardSpecular o)


其中,后两个是Unity 5 中由于引入了基于物理的渲染而新添加的两种结构体。SurfaceOutput、SurfaceOutputStandard 和 SurfaceOutputStandardSpecular 都是Unity 内置的结构体,它们需要配合不同的光照模型使用。

在表面函数中,会使用输入结构体Input IN 来设置各种表面属性,并把这些属性存储在输出结构体SurfaceOutput、SurfaceOutputStandard 或 SurfaceOutputStandardSpecular 中,再传递给光照函数计算光照结果。

除了表面函数,我们还需要指定另一个非常重要的函数——光照函数。光照函数会使用表面函数中设置的各种表面属性,来应该某些光照模型,进而模拟物体表面的光照效果。Unity 内置了基于物理的光照模型Standard 和 StandardSpecular ,以及简单的非基于物理的光照模型函数Lambert 和 BlinnPhong。

当然,我们也可以自定义自己的光照函数。例如,可以使用下面的函数来定义用于前向渲染中的光照函数:


//用于不依赖视角的光照模型,例如漫反射
half4 Lighting<Name> (SurfaceOutput s, half3 lightDir, half atten);
//用于依赖视角的光照模型,例如高光反射
half4 Lighting<Name> (SurfaceOutput s, half3 lightDir, half3 viewDir,half atten);


在编译指令的最后,我们还可以设置一些可选参数。这些可选参数包含了很多非常有用的指令类型,例如,开启/关闭透明度混合/测试,指明自定义的顶点和颜色修改函数,控制生成的代码等。

1)自定义的修改函数。除了表面函数和光照模型外,表面着色器还可以支持其他两种自定义的函数:顶点修改函数和最后的颜色修改函数。顶点修改函数允许我们自定义一些顶点属性,例如,把顶点颜色传递给表面函数,或是修改顶点位置,实现某些顶点动画等。最后的颜色修改函数则可以在颜色绘制到屏幕前,最后一次修改颜色值,例如实现自定义的雾效等。

2)阴影。我们可以通过一些指令来控制和阴影相关的代码。例如,addshaow 参数会为表面着色器生成一个阴影投射的Pass。通常情况下,Unity可以直接在Fallback中找到通用的光照模型为ShadowCaster的Pass,从而将物体正确地渲染到深度和阴影纹理中。但对于一些进行了顶点动画、透明度测试的物体,我们就需要对阴影的投射进行特殊处理,来为它们产生正确的阴影。fullforwardshadows 参数则可以在前向渲染路径中支持所有光源类型的阴影。默认情况下,Unity只支持最重要的平行光的阴影效果。如果我们需要让点光源或聚光灯在前向渲染中也可以由阴影,就可以添加这个参数。相反地,如果我们不想对使用这个Shader 的 物体进行任何阴影计算,就可以使用noshadow 参数来禁用阴影。

3)透明度混合和透明度测试,我们可以通过alpha 和 alphatest 指令来控制透明度混合和透明测试。例如alphatest:VariableName 指令会使用名为VarialName 的变量来剔除不满足条件的片元。此时,我们还可能需要使用上面提到的addshadow参数来生成正确的阴影投射的Pass。

4)光照。一些指令可以控制光照对物体的影响,例如noambient 参数可以告诉Unity不要应该任何环境光照或光照探针。novertexlights 参数告诉Unity 不要应用任何逐顶点光照。noforwardadd会去掉前向渲染中的额外的Pass。也就是说,这个Shader只会支持一个逐像素的平行光,而其他的光源会按照逐顶点或SH的方法来计算光照影响。这个参数通常会用于移动平台版本的表面着色器中。还有一些用于控制光照烘焙、雾效模拟的参数,如nolightmap、nofog等。

5)控制代码的生成。一些指令还可以控制由表面着色器自动生成的代码,默认情况下,Unity 会为一个表面着色器生成相应的前向渲染路劲、延迟渲染路径使用的Pass,这会导致生成的Shader 文件比较大。如果我们确定该表面着色器只会在某些渲染路径中使用,就可以exclude_path:deferred、exclude_path:forward 和 exclude_path:prepass来告诉Unity不需要为某些渲染路径生成代码。

两个结构体
Input结构体 包含了许多表面属性的数据来源,因此,它会作为表面函数的输入结构体。Input支持很多内置的变量名,通过这些变量名,我们告诉Unity需要使用的数据信息。下表给出了Input结构体中内置的变量。

unity shader改变shadow强度 unity shader 颜色范围_游戏_02


需要注意的是,我们并不需要自己计算上述的各个变量,而只需要在Input结构体中按上述名称严格声明这些变量即可,Unity会在背后为我们准备好这些数据,而我们只需要在表面函数中直接使用它们即可。一个例外的情况是,我们自定义了顶点修改函数,并需要向表面函数中传递一些自定义的数据。例如,为了自定义雾效,我们可能需要在顶点修改函数中根据顶点在视角空间下的位置信息计算雾效混合系数,这样我们就可以在Input结构体中定义一个名为half fog 的变量,把计算结果存储在该变量后进行输出。

有了Input结构体来提供所需要的数据后,我们就可以据此计算各种表面属性。因此,另一个结构体就是用于存储这些表面属性的结构体,即SurfaceOutput、SurfaceOutputStandard 和 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;		// base (diffuse or specular) color
	fixed3 Normal;		// tangent space normal, if written
	half3 Emission;
	half Metallic;		// 0=non-metal, 1=metal
	half Smoothness;	// 0=rough, 1=smooth
	half Occlusion;		// occlusion (default 1)
	fixed Alpha;		// alpha for transparencies
};
struct SurfaceOutputStandardSpecular
{
	fixed3 Albedo;		// diffuse color
	fixed3 Specular;	// specular color
	fixed3 Normal;		// tangent space normal, if written
	half3 Emission;
	half Smoothness;	// 0=rough, 1=smooth
	half Occlusion;		// occlusion (default 1)
	fixed Alpha;		// alpha for transparencies
};

在一个表面着色器中,只需要选择上述三者之一即可,这取决于我们选择使用的光照模型。Unity内置的光照模型有两种,一种是Unity5 之前的、简单的、非基于物理的光照模型,包含了Lambert 和 BlinnPhong;另一种是Unity5 添加的、基于物理的光照模型,包括Standard 和 StandardSpecular ,这种模型会更加符合物理规律,但计算也会复杂很多。如果使用了非基于物理的光照模型,就使用SurfaceOutputStad,否则分别使用SurfaceOutputStandard 和SurfaceOutputStandardSpecular 。其中,SurfaceOutputStandard 结构体用于默认的金属工作流程,对应了Standard 光照函数;而SurfaceOutputStandardSpecular 结构体用于高光工作流程,对应了StandardSpecular 光照函数。

在SurfaceOutput结构体中,部分表面属性有:

1)fixed3 Albedo 对光源的反射率。通常由纹理采样和颜色属性的乘积计算而得。

2)fixed3 Normal 表面法线方向

3)fixed3 Emission 自发光。Unity 通常会在片元着色器最后输出前,使用类似下面的语句进行简单的颜色相加。

c.rgb += o.Emission;

4)half Specular 高光反射中的指数部分的系数,影响高光反射的计算。例如,如果使用了内置的BlinnPhong 光照函数,它会使用如下语句计算高光反射的强度:

float spec = pow(nh,s.Specular*128.0)*s.Gloss;

5) fixed Gloss 高光反射中的强度系数。一般在包含了高光反射的光照模型里使用

6) fixed Alpha 透明通道


Unity 背后做了什么

我们之前说过,Unity在背后会根据表面着色器生成一个包含了很多Pass的顶点/片元着色器。这些Pass有些是为了针对不同的渲染路径,例如,默认情况下Unity 会为前向渲染路径生成LightMode 为 ForwardBase 和 ForwardAdd 的Pass,为Unity 5 之前的延迟渲染路径生成LightMode 为PrePassBase 和 PrePassFinal 的Pass,为Unity5之后的延迟渲染路径生成LightMode 为 Deferred 的Pass。还有一些Pass 是用于产生额外的信息。例如,为了给光照映射和动态全局光照提取表面信息,Unity 会生成一个LightMode 为 Meta 的Pass。有些表面着色器由于修改了顶点位置,因此,我们可以利用adddshaow 编译指令为它生成相应的LightMode 为 ShadowCaster 的阴影投射Pass。这些Pass 的生成都是基于我们再表面着色器中的编译指令和自定义的函数,这是由规律可循的。Unity 提供了一个功能,让我们可以对表面着色器自动生成的代码一探究竟:在每个编译完成的表面着色器的面板上,有一个“Show generated code” 按钮,如下图所示。我们只需要单击一下就可以看到Unity为这个表面着色器生成的所有顶点/片元着色器。

unity shader改变shadow强度 unity shader 颜色范围_光照模型_03


通过查看这些代码,我们就可以了解到Unity到底是如何根据表面着色器生成各个的Pass的。以Unity生成的LightMode 为ForwardBase 的Pass为例,它的渲染流水线如下图所示。

unity shader改变shadow强度 unity shader 颜色范围_自定义_04

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

1)直接将表面着色器中CGPROGRAM 和 ENDCG 之间的代码复制过来,这些代码包括了我们对Input 结构体、表面函数、光照函数等变量和函数的定义。这些函数和变量会在之后的处理过程中被当成正常的结构体和函数进行调用。

2)Unity会分析上述代码,并据此生成顶点着色器的输出——v2f_surf 结构体,用于在顶点着色器和片元着色器之间进行数据传递。Unity会分析我们再定义函数中所使用的变量,例如,纹理坐标、视角方向、反射方向等。如果需要,它就会在v2f_surf 中生成相应的变量。而且,即便有时我们在Input 中定义了某些变量,但Unity在分析代码时发现我们并没有使用这些变量,那么这些变量实际上是不会在v2f_surf 中生成的。也就是说,Unity 做了一些优化。v2f_surf 中还包含了一些其他需要的变量,例如阴影纹理坐标、光照纹理坐标、逐顶点光照等。

3)接着,生成顶点着色器。如果我们自定义了顶点修改函数,Unity会首先调用顶点修改函数来修改顶点数据,或填充自定义的Input结构体中的变量。然后,Unity 会分析顶点修改函数中修改的数据,在需要时通过Input 结构体将修改结果存储到v2f_surf 相应的变量中。然后计算v2f_surf 中其他生成的变量值。这主要包括了顶点位置、纹理坐标、法线方向、逐顶点光照、光赵文丽的采样坐标等。当然,我们可以通过编译指令来控制某些变量是否需要计算。最后将v2f_surf传递给接下来的片元着色器。

4)生成片元着色器。使用v2f_surf 中对应变量填充Input结构体,例如,纹理坐标、视角方向等。然后调用我们自定义的表面函数填充SurfaceOutput结构体。接着调用光照函数得到初始的颜色值。如果使用的是内置的Lambert 或 blinnPhong 光照函数,Unity 还会计算动态全局光照,并添加到光照模型的计算中。再而进行其他颜色的叠加。例如,没有使用光照烘焙,还会添加逐顶点光照的影响。最后,如果自定义了最后的颜色修改函数,Unity就会调用它进行最后的颜色修改。