切线空间下计算
我们首先来实现第一种方法,即在切线空间下计算光照模型。基本思路是:在片元着色器中通过纹理采样得到切线空间下的法线,然后再与切线空间下的视角方向、光照方向等进行计算,得到最终的光照结果。为此,我们首先需要在顶点着色器中把视角方向和光照方向从模型空间变换到切线空间中。已经知道,如果一个变换中仅存在平移和旋转变换,那么这个变换的逆矩阵就等于它的转置矩阵,而从切线空间到模型空间的变换正是符合这样要求的变换。因此,从模型空间到切线空间的变换矩阵就是从切线空间到模型空间的变换矩阵的转置矩阵。
Shader "Unity Shaders Book/Chapter 7/Normal Map In Tangent Space" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_BumpMap ("Normal Map", 2D) = "bump" {}
_BumpScale ("Bump Scale", Float) = 1.0
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader {
Pass {
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Specular;
float _Gloss;
struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float4 uv : TEXCOORD0;
float3 lightDir: TEXCOORD1;
float3 viewDir : TEXCOORD2;
};
v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
//o.uv.xy = TRANSFORM_TEX(v.uv,_MainTex);
//o.uv.zw = TRANSFORM_TEX(v.uv,_BumpMap);
// Compute the binormal
//float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w;
// Construct a matrix which transform vectors from object space to tangent space
//float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);
// Or just use the built-in macro
TANGENT_SPACE_ROTATION;
// Transform the light direction from object space to tangent space
o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
// Transform the view direction from object space to tangent space
o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz;
return o;
}
fixed4 frag(v2f i) : SV_Target {
fixed3 tangentLightDir = normalize(i.lightDir);
fixed3 tangentViewDir = normalize(i.viewDir);
// Get the texel in the normal map
fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
fixed3 tangentNormal;
// If the texture is not marked as "Normal map"
// tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale;
// tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
// Or mark the texture as "Normal map", and use the built-in funciton
tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(tangentNormal, tangentLightDir));
fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss);
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
FallBack "Specular"
}
我们使用TANGENT语义来描述float4类型的tangent变量,以告诉Unity把顶点的切线方向填充到tangent变量中。需要注意的是,和法线方向normal不同,tangent 的类型是float4, 而非float3,这是因为我们需要使用tangent.w分量来决定切线空间中的第三个坐标轴——副切线的方向性。
由于我们使用了两张纹理,因此需要存储两个纹理坐标。为此,我们把v2f中的uv变量的类型定义为float4类型,其中xy分量存储了MainTex的纹理坐标,而zw分量存储了_BumpMap的纹理坐标(实际上,_MainTex 和_BumpMap 通常会使用同一组纹理坐标,出于减少插值寄存器的使用数目的目的,我们往往只计算和存储一个纹理坐标即可)。
o.uv.xy = TRANSFORM_TEX(v.uv,_MainTex);
o.uv.zw = TRANSFORM_TEX(v.uv,_BumpMap);
然后,我们把模型空间下切线方向、副切线方向和法线方向按行排列来得到从模型空间到切线空间的变换矩阵rotation。需要注意的是,在计算副切线时我们使用v.tangent.w和叉积结果进行相乘,这是因为和切线与法线方向都垂直的方向有两个,而w决定了我们选择其中哪一个方向。Unity 也提供了一个内置宏TANGENT_SPACE_ROTATION (在UnityCG.cginc 中被定义)来帮助我们直接计算得到rotation变换矩阵,它的实现和上述代码完全样。然后,我们使用Unity 的内置函数ObjSpaceLightDir和ObjSpaceViewDir来得到模型空间下的光照和视角方向,再利用变换矩阵rotation把它们从模型空间变换到切线空间中。
o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz;
在上面的代码中,我们首先利用tex2D对法线纹理_ BumpMap 进行采样。法线纹理中存储的是把法线经过映射后得到的像素值,因此我们需要把它们反映射回来。我们通常会把法线纹理的纹理类型标识成Normalmap,Unity会根据平台来选择不同的压缩方法。在这种情况下,我们可以使用Unity的内置函数UnpackNormal来得到正确的法线方向。
tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
法线纹理中存储的就是表面的法线方向。由于法线方向的分量范围在[-1, 1],而像素的分量范围为[0, 1],因此我们需要做一个映射,通常使用的映射就是:
这就要求,我们在Shader中对法线纹理进行纹理采样后,还需要对结果进行一次反映射的过程,以得到原先的法线方向。反映射的过程实际就是使用上面映射函数的逆函数:
对于模型顶点自带的法线,它们是定义在模型空间中的,因此种直接的想法 就是将修改后的模型空间中的表面法线存储在一张纹理中,这种纹理被称为是模型空间的法线纹理。然而,在实际制作中,我们往往会采用另一种坐标空间,即模型顶点的切线空间(tangent space)来存储法线。对于模型的每个顶点,它都有一个属于自己的切线空间,这个切线空间的原点就是该顶点本身,而z轴是顶点的法线方向(n), X轴是顶点的切线方向(t),而y轴可由法线和切线叉积而得,也被称为是副切线( bitangent, b)或副法线,如图7.12所示。这种纹理被称为是切线空间的法线纹理(tangent-space normal map)。
_BumpScale 分别为 -0.8,0,0.8。
世界空间下计算
现在,我们来实现第二种方法,即在世界空间下计算光照模型。我们需要在片元着色器中把法线方向从切线空间变换到世界空间下。这种方法的基本思想是:在顶点着色器中计算从切线空间到世界空间的变换矩阵,并把它传递给片元着色器。变换矩阵的计算可以由顶点的切线、副切线和法线在世界空间下的表示来得到。最后,我们只需要在片元着色器中把法线纹理中的法线方向从切线空间变换到世界空间下即可。尽管这种方法需要更多的计算,但在需要使用Cubemap进行环境映射等情况下,我们就需要使用这种方法。
Shader "Unity Shaders Book/Chapter 7/Normal Map In World Space" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_BumpMap ("Normal Map", 2D) = "bump" {}
_BumpScale ("Bump Scale", Float) = 1.0
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader {
Pass {
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Specular;
float _Gloss;
struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float4 uv : TEXCOORD0;
//存储从切线到世界空间的变换矩阵
float4 TtoW0 : TEXCOORD1;
float4 TtoW1 : TEXCOORD2;
float4 TtoW2 : TEXCOORD3;
};
v2f vert(a2v v) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
float3 worldPos = mul(_Object2World, v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
// Compute the matrix that transform directions from tangent space to world space
// Put the world position in w component for optimization
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
return o;
}
fixed4 frag(v2f i) : SV_Target {
// Get the position in world space
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
// Compute the light and view dir in world space
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
// Get the normal in tangent space
fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));
bump.xy *= _BumpScale;
bump.z = sqrt(1.0 - saturate(dot(bump.xy, bump.xy)));
// Transform the narmal from tangent space to world space
bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));
fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(bump, lightDir));
fixed3 halfDir = normalize(lightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(bump, halfDir)), _Gloss);
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
FallBack "Specular"
}
一个插值寄存器最多只能存储float4大小的变量,对于矩阵这样的变量,我们可以把它们按行拆成多个变量再进行存储。上面代码中的 TtoW0、TtoW1 和TtoW2就依次存储了从切线空间到世界空间的变换矩阵的每一行。 实际上,对方向矢量的变换只需要使用3X3大小的矩阵,也就是说,每--行只需要使用float3类型的变量即可。但为了充分利用插值寄存器的存储空间,我们把世界空间下的顶点位置存储在这些变量的w分量中。
顶点着色器:我们计算了世界空间下的顶点切线、副切线和法线的矢量表示,并把它们按列摆放得到从切线空间到世界空间的变换矩阵。我们把该矩阵的每一行分别存储在TtoW0、TtoW1和TtoW2中,并把世界空间下的顶点位置的xyz分量分别存储在了这些变量的w分量中,以便充分利用插值寄存器的存储空间。
片元着色器:我们首先从TtoW0、TtoW1 和TtoW2的w分量中构建世界空间下的坐标。然后,使用内置的UnityWorldSpaceLightDir和UnityWorldSpaceViewDir函数得到世界空间下的光照和视角方向。接着,我们使用内置的UnpackNormal函数对法线纹理进行采样和解码(需要把法线纹理的格式标识成Normal map),并使用_ BumpScale 对其进行缩放。最后,我们使用TtoW0、TtoW1和TtoW2存储的变换矩阵把法线变换到世界空间下。这是通过使用点乘操作来实现矩阵的每一行和法线相乘来得到的。