第十六章 Unity的表面着色器探秘(3)
1.表面着色器实例分析
为了帮助读者更加深入的理解表面着色器背后的原理,我们在本节以一个表面着色器为例,分析Unity为它生成的代码。
测试场景如下图所示,它的实现效果是对模型进行膨胀。
这种效果的实现非常简单,就是在顶点修改函数中沿着顶点法线方向扩张顶点的位置。为了分析表面着色器中4个可自定义的函数(顶点修改函数、表面函数。光照函数和最后的颜色修改函数)的原理,在本例中我们对这4个函数全部采用了自定义的实现。代码如下:
Shader "Unity Shaders Book/Chapter 17/Normal Extrusion" {
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
// surf - which surface function.
// CustomLambert - which lighting model to use.
// vertex:myvert - use custom vertex modification function.
// finalcolor:mycolor - use custom final color modification function.
// addshadow - generate a shadow caster pass. Because we modify the vertex position, the shder needs special shadows handling.
// exclude_path:deferred/exclude_path:prepas - do not generate passes for deferred/legacy deferred rendering path.
// nometa - do not generate a “meta” pass (that’s used by lightmapping & dynamic global illumination to extract surface information).
#pragma surface surf CustomLambert vertex:myvert finalcolor:mycolor addshadow exclude_path:deferred exclude_path:prepass nometa
#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"
}
在顶点的修改函数中,我们使用顶点法线对顶点位置进行膨胀;表面函数使用主纹理设置了表面属性中的反射率,并使用法线纹理设置了表面法线方向;光照函数实现了简单的兰伯特漫反射光照模型;在最后的颜色修改函数中,我们简单的使用了颜色参数对输出颜色进行调整。注意,除了4个函数外,我们在#pragma surface的编译指令一行中还指定了一些额外的参数。由于我们修改了顶点位置,因此,要对其它物体产生正确的阴影效果并不能直接依赖Fallback中找到的阴影投射Pass,addshadow参数可以告诉Unity要生成一个该表面着色器对应的阴影投射Pass。默认情况下,Unity会为所有支持的渲染路径生成相应的Pass,为了缩小自动生成的代码量,我们使用exclude_path:deffered和exclude_path:prepass来告诉Unity不要为延迟渲染路径生成相应的Pass。最后,我们使用nometa参数取消对提取元数据的Pass的生成。
当在该表面着色器的导入面板中单击“Show generated code”按钮后,我们就可以看到Unity生成的顶点/片元着色器了。由于代码比较多,为了节省篇幅我们不再把全部代码粘贴到这里。
在这个近600行的代码文件中,Unity一共为该表面着色器生成了3个Pass,它们的LightMode分别是ForwardBase、ForwardAdd和ShadowCaster,分别对应了前向渲染路径中的处理逐像素平行光的Pass、处理其他逐像素光的Pass、处理阴影投射的Pass。读者可以在这些代码中看到大量的#ifdef和#if语句,这些语句可以判断一些渲染条件,例如是否使用了动态光照纹理、是否使用了逐顶点光照、是否使用了屏幕空间的阴影等,Unity会根据这些条件来进行不同的光照计算,这正是表面着色器的魅力之一——这些烦人的光照计算交给Unity来做。需要注意的是,不同的Unity版本可能生成的代码有少许不同。下面我们来分析Unity生成的ForwardBase Pass。
(1)Unity首先指明了一些编译指令
// ---- forward rendering base 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都是自动生成的。
(2)之后出现的是一些自动生成的注释:
// 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的分析过程和它的分析结果
(3)随后,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结构体中的一些变量。
(4)接着,Unity把我们在表面着色器中编写的CG代码复制过来,作为Pass的一部分,以便后续调用。
(5)然后Unity定义了顶点着色器到片元着色器的插值结构体(即顶点着色器的输出结构体)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
上面的变量名看起来很陌生,但实际上大部分变量的含义我们在之前都碰到过,只是这里使用了不同的名称而已。例如在下面我们会看到,pack0实际上存储的就是主纹理和法线纹理的采样坐标,而tSpace0、tSpace1和tSpace2存储了从切线空间到世界空间的变换矩阵。一个比较陌生的变量是vlight,Unity会把逐顶点和SH光照的结果存储到该变量里,并在片元着色器中和原光照结果进行叠加(如果需要的话)。
(6)随后,Unity定义了真正的顶点着色器。顶点着色器会首先调用我们自定义的顶点修改函数来修改一些顶点属性:
// 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_vert中各个变量的值。例如计算经过MVP矩阵变换后的顶点坐标;使用TRANSFORM_TEX内置宏计算两个纹理的采样坐标,并分别存储在o.pack0的xy分量和zw分量中;计算从切线空间到世界空间的变换矩阵,并把矩阵的每一行分别存储在o.tSpace0、o.tSpace1和o.tSpace2变量中;判断是否使用了光照映射和动态光照映射,并在需要时把两种光照纹理的采样坐标计算结果存储在o.lamp.xy和o.lamp.zw分量中;判断是否使用了光照映射,如果没有的话就计算该顶点的SH光照(一种快速计算光照的方法),把结果存储到o.vlight中;判断是否开启了逐顶点光照,如果是就计算最重要的4个逐顶点光照的光照结果,把结果叠加到o.vlight中。
最后计算阴影坐标并传递给片元着色器:
TRANSFER_SHADOW(o);//pass shadow coordinates to pixel shader
return o;
(7)在Pass的最后,Unity定义了真正的片元着色器。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
如果没有使用光照映射,意味着我们需要使用自定义的光照模型计算光照结果:
// realtime lighting: call lighting function
#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参数。
2.Surface Shader的缺点
从上面的例子我们可以看出,表面着色器给我们带来了很大方便。那么我们之前为什么还要花那么久的时间学习顶点/片元着色器?直接写表面着色器不就好了吗?
正如我们一直强调的那样,表面着色器只是Unity在顶点/片元着色器上面提供的一种封装,是一种更高层的抽象。但任何在表面着色器中完成的事情,我们都可以在顶点/片元着色器中实现,但不幸的是,这句话反过来并不成立。
这世上任何事情都是有代价的,如果我们想要得到遍历,就需要以牺牲自由度为代价。表面着色器虽然可以快速实现各种光照效果,但我们失去了对各种优化和各种特效实现的控制。因此使用表面着色器往往会对性能造成一些影响,而内置的Shader,例如Diffuse、BumpedSpecular等都是使用表面着色器编写的。
除了性能比较差以外,表面着色器还无法完成一些自定义的渲染效果。如前面讲到的透明玻璃的效果。表面着色器的这些缺点让很多人更愿意使用自由的顶点/片元着色器来实现更重效果,尽管处理光照时这可能难度更大一些。
因此我们给出以下建议供读者参考:
●如果你需要和各种光源打交道,尤其是想要使用Unity中全局光照的话,你可能更喜欢使用表面着色器,但要时刻小心他的性能。
●如果你需要处理的光源数目非常的少,例如只有一个平行光,那么使用顶点/片元着色器是一个更好的选择。
●更重要的是,如果你有很多自定义的渲染效果,那么请选择顶点/片元着色器。