第十六章 Unity的表面着色器探秘(1)


表面着色器(Surface Shader)实际上就是在顶点/片元着色器之上又添加了一层抽象。按Aras的话来解释就是,顶点/几何/片元着色器是硬件能理解的渲染方式,而开发者应该使用一种更容易理解的方式。很多时候使用表面着色器,我们只需要告诉Shader:“嘿,使用这些纹理去填充颜色,使用这个法线纹理去填充表面法线,使用兰伯特光照模型,其它的就不要来烦我了”。我们不需要考虑是使用前向渲染路径还是延迟渲染路径,场景中有多少光源,它们的类型是什么,怎样处理这些光源,每个Pass需要处理多少个光源等问题。(正是因为有这些事情,人们总会抱怨写一个Shader是多么麻烦…)这时。Unity会说:“不要着急,我来干!”

1.表面着色器的一个例子

在学习原理之前,我们首先来看一下一个表面着色器长什么样子。
我们将使用表面着色器来实现一个使用了法线纹理的漫反射效果。这可以参考Unity内置的“Legacy Shaders/Bumped Diffuse”的代码实现(可以在官方网站的内置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中查看。在BumpedDiffuseMat的面板上。我们将两张.tif文件分别拖拽到_MainTex和_BumpMap属性上,就可以得到类似下图左图的结果。
第十六章 Unity的表面着色器探秘(1)
我们还可以向场景中添加一些点光源和聚光灯,并改变它们的颜色,就得到类似上图中右图的结果。注意,在这个过程中,我们不需要对代码进行任何改动。
从上面的例子可以看出,相比之前所学的顶点/片元着色器技术,表面着色器的代码量很少(只需要三十多行),如果我们使用顶点/片元着色器,大概需要150多行代码。而且我们可以轻松的实现常见的光照模型,甚至不要可以和任何光照变量打交道,Unity就帮我们处理好了每个光源的光照结果。
读者可以在Unity官方手册的表面着色器的例子一文(http://docs.unity3d.com/Manual/SL-SurfaceShaderExamples.html)
中找到更多的示例程序。下面我们具体来学习表面着色器的特点和工作原理。
和顶点/片元着色器需要包含到一个特定的Pass中不同,表面着色器的CG代码是直接而且也必须写在SubShader块中,Unity会在背后为我们生成多个Pass。当然可以在SubShader一开始处使用Tags来设置该表面着色器使用的标签。然后我们使用CGPROGRAM和ENDCG定义了表面着色器的具体代码。
一个表面着色器中最重要的部分是两个结构体以及它们的编译指令。其中两个结构体是表面着色器中不同函数之间信息传递的桥梁,而编译指令是我们个Unity沟通的重要手段。

2.编译指令

我们首先来看一下表面着色器的编译指令。编译指令是我们和Unity沟通的重要方式,通过它可以告诉Unity:“嘿,用这个表面函数设置表面属性,用这个光照模型模拟光照,我不需要阴影和环境光,不需要雾效”,只需要一句代码,我们就可以完成这么多事情!
编译指令最重要的作用是指明该表面着色器使用的表面函数和光照函数,并设置一些可选参数。表面着色器的CG块中的第一句代码往往就是它的编译指令。编译指令的一般格式如下:

#pragma surface surfaceFunction lightModel [optionalparams]

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

表面函数

我们之前说过,表面着色器的优点在于抽象出了“表面”这一概念。与之前遇到的顶点/片元着抽象层不同,一个对象的表面属性定义了它的反射率、光滑度、透明度等值。而编译指令中的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手册中的表面着色器的例子一文(http://docs.unity3d.com/Manual/SL-SurfaceShaderExamples.html)
中找到更多的示例表面函数。

光照函数

除了表面函数,我们还需要指定另一个非常重要的函数——光照函数。光照函数会使用表面函数中设置的各种表面属性,来应用某些光照模型,进而模拟物体表面的光照效果。Unity内置的基于物理的光照模型函数Standard和StandardSpecular(在UnityPBSLLighting.cginc文件中被定义),以及简单的非基于物理的光照模型函数Lambert和BlinnPhong(在Lighting.cginc文件中被定义)。
当然,我们也可以定义自己的光照函数。例如,可以使用下面的函数来定义用于前向渲染中的光照函数:

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

读者可以在Unity手册的表面着色器中的自定义光照模型一文(
http://docs.unity3d.com/Manual/SL-SurfaceShaderLighting.html)
中找到更全面的自定义光照模型的介绍。而一些例子可以参照手册中的表面着色器的光照例子一文
http://docs.unity3d.com/Manual/SL-SurfaceShaderLightingExamples.html)
,这篇文档展示了如何使用表面着色器来自定义常见的漫反射、高光反射、基于光照纹理等常用的光照模型。

其它可选参数

在编译指令的最后,我们还设置了一些可选参数(optionalparams),这些可选参数包含了很多非常有用的指令类型,例如开启/设置透明度混合/透明度测试,指明自定义的顶点和颜色修改函数,控制生成的代码等。下面我们选取了一些比较重要和常用的参数进行更深入的说明。读者可以在Unity官方手册的编写表面着色器一文(http://docs.unity3d.com/Manual/SL-SurfaceShaders.html)
中找到更加详细的参数个设置说明。
●自定义修改的函数。除了表面函数和光照模型外,表面着色器还可以支持其它两种自定义的函数:顶点修改函数(vertex:VertexFunction)和最后的颜色修改函数(finalcolor:ColorFunction)。顶点修改函数允许我们自定义一些顶点属性。例如把顶点颜色传递给表面函数,或是修改顶点位置,实现顶点动画等。最后的颜色修改函数则可以在颜色绘制到屏幕前,最后一次修改颜色值,例如实现自定义的雾效等。
●阴影。我们可以通过一些指令来控制和阴影相关的代码。例如addshadow参数会为表面着色器生成一个阴影投射的Pass。通常情况下,Unity可以直接在FallBack中找到通用的光照模式为ShadowCaster的Pass,从而将物体正确的渲染到深度和阴影纹理中。但对于一些进行了顶点动画。透明度测试的物体,我们就需要对阴影的投射进行特殊处理,来为它们产生正确的阴影,正如我们在前面看到的一样。fullforwardshadows参数则可以在前向渲染路径中支持所有光源类型的阴影。默认情况下,Unity只支持最重要的平行光的阴影效果。如果我们需要让点光源或聚光灯在前向渲染中也可以有阴影,就可以添加这个参数。相反的,如果我们不想对使用了这个Shader的物体进行任何阴影计算,就可以使用noshadow参数来禁用阴影。
●透明度混合和透明度测试。我们可以通过alpha和alphatest指令来控制透明度混合和透明度测试。例如:alphatest;VariableName指令会使用名为VariableName的变量来剔除不满足条件的片元。此时,我们可能还需要使用上面提到的addshadow参数来生成正确的阴影投射的Pass
●光照。一些指令可以控制光照对物体的影响,例如,noambient参数会告诉Unity不要应用任何环境光照或光照探针(light probe)。novertexlights参数告诉Unity不要应用任何逐顶点光照。noforwardadd会去掉所有前向渲染中的额外的Pass。也就是说,这个Shader只会支持一个逐像素的平行光,而其他光源会按照逐顶点或SH的方法来计算光照影响。这个参数通常会用于移动平台版本的表面着色器中。还有一些用于控制光照烘焙、雾效模拟的参数,如nolightmap、nofog等。
●控制代码的生成。一些指令还可以控制由表面着色器自动生成的代码,默认情况下,Unity会为一个表面着色器生成相应的前向渲染路径、延迟渲染路径使用的Pass,这会导致生成的Shader文件比较大。如果我们确定该表面着色器只会在某些渲染路径中使用,就可以exclude_path:deferred、exclude_path:forward和exclude_path:prepass来告诉Unity不需要为某些渲染路径生成代码。
从上述可以看出,表面着色器支持的编译指令参数很多,为我们编写表面着色器提供了很大的方便。之前在顶点/片元着色器中需要消耗大量代码来完成的工作,在表面着色器中可能只需要一个参数就可以了。当然,相比顶点/片元着色器,表面着色器也有他自身的限制,我们会在后面对比他们的优缺点。