法线贴图(NormalMap)可以在不添加多边形的前提下,为模型添加细节。常见的使用场景是为低多边形模型改善外观、添加细节、增强立体感。法线贴图一般根据高多边形模型或高度贴图生成。
左右两边分别对应的是无法线贴图和有法线贴图的效果。很明显右边(有法线贴图)的黑叔叔脸部细节更丰富、刀疤更清晰,脖子上的肌肉细条相对于左图也要更清晰和立体一些。
法线贴图(NormalMap)存储的是表面的法线方向,即向量n = (x, y, z)。而方向是相对于坐标空间而言的。通常法线有两种坐标空间:Tangent Space(切线空间)、Object Space(对象空间或模型空间),如下:
Tangent Space法线贴图看上去通常大部分是浅蓝色,Object Space法线贴图则五颜六色。法线存储在哪个坐标系中都是可以的。
Object Space的优点:
(1)实现简单,更加直观。我们甚至都不需要模型原始的法线和切线等信息,也就是说,计算更少。
生成它也非常简单,而如果要生成Tangent Space Normal Map,由于模型的切线一般是和uv方向相同,因此要得到效果比较好的法线效果就要求纹理映射也是连续的。
(2)在纹理坐标的缝合处和尖锐的边角部分,可见的突变(缝隙)较少,即可以提供平滑的边界。
这是因为Object Space Normal Map存储的同一坐标系下的法线信息,因此在边界外通过插值得到法线可以平滑变换。
而Tanget Space Normal Map中法线信息是依靠纹理坐标uv的方向来得到结果,可能在边缘处或尖锐的部分造成更多可见的缝合迹象。
Tangent Space有更多优点:
(1)自由度很高。Tangent-Space Normal Map记录的是相对法线信息,这意味着,即便把该纹理应用到一个完全不同的网格上,也可以得到一个合理的结果。
(2)可进行UV动画。如:我们可以移动一个纹理的UV坐标来实现一个凹凸移动的效果,这种UV动画在水或者火山熔岩这种类型的物体会用到。
(3)可重用。如:一个砖块,我们可以仅使用一张Normal Map就可以用到所有的六个面上。
(4)可压缩。由于Tangent-Space Normal Map中法线的Z方向总是正方向的,因此我们可以仅存储XY方向,而推导得到Z方向。
而Object Space Normal Map由于每个方向都是可能的,因此必须存储3个方向XYZ的值,不可压缩。
法线存储在哪个坐标系中都是可以的。通常游戏中使用Tangent Space(切线空间)来存放法线贴图。
在Tangent Space中,坐标原点就是顶点的位置,其中z轴是该顶点本身的法线方向(N),另外两个坐标轴在与法向方向相垂直的切平面上。
这样的切线在切平面上有无数条,可以按照模型顶点的位置坐标随纹理坐标(u, v)的变化作为切线空间,来定义切线(Tangent,T)和副切线(Bitangent,Binormal,B)。
但这样做T(u方向)和B(v方向)并不一定是互相垂直的,所以一般会用:
注:T为u方向,N为u和v的叉乘,B为N和T的叉乘
其中,原点对应顶点坐标,x轴是切线方向(T),y轴是副切线方向(B),z轴是法线方向(N)
UE4中StaticMesh的Normals、Tangents、Binormals:
注:模型表面的黄线是点击顶点附近选中的边,为了是看清三角形的情况
Tangent Space存储的是法线的扰动方向。如果一个顶点的法线方向不变,Normal值就是z轴方向(0, 0, 1)。由于z方向只会朝外,因此,x, y取值范围为[-1, 1],z取值范围为[0, 1]。
通常情况下我们存储在贴图中的值为正,一般通过一个简单的变换pixel = (normal + 1) / 2来完成。
经过该变换后,之前的法线值(0, 0, 1)实际上对应了法线纹理中RGB的值为(0.5, 0.5, 1),转换成RGB为(128, 128, 255)。
而这个颜色也就是法线纹理中那大片的蓝色。这些蓝色实际上说明顶点的大部分法线是和模型本身法线一样的,不需要改变。
相应地,在ps中对法线纹理采样后,要进行一次反映射,以得到原先的法线方向。为上面变换的逆函数:normal = pixel * 2 - 1
计算光照
计算光照需要在统一坐标空间下进行,我们通常有两种选择:
(1) 在切线空间下进行光照计算,此时我们需要把光照方向、视角方向变换到切线空间下。
(2) 在世界空间下进行光照计算,此时我们需要把采样得到的法线方向变换到世界空间下,再和世界空间下的光照方向和视角方向进行计算。
从效率上来说,第一种方法往往要优于第二种方法,因为我们可以在vs中就完成对光照方向和视角方向的变换,而第二种方法由于要先对法线纹理进行采样,所以变换过程必须在ps中实现,这意味着我们需要在ps中进行一次矩阵操作。
但从通用性角度来说,第二种方法要优于第一种方法,因为有时我们需要在世界空间下进行一些计算,例如在使用Cubemap进行环境映射时,我们需要使用世界空间下的反射方向对Cubemap进行采样。
如果同时需要进行法线映射,我们就需要把法线方向变换到世界空间下。
在切线空间下计算的具体过程:
① 在vs中计算从模型空间到切线空间的旋转矩阵(该矩阵为TBN的逆矩阵),并用该矩阵将viewDir、lightDir从模型空间变换到切线空间
② 然后在ps对法线纹理采样,并执行反映射
③ 最后在切线空间中进行光照计算
unity中的实现如下(Chapter7-NormalMapTangentSpace.shader):
Shader "Unity Shaders Book/Chapter 7/Normal Map In Tangent Space" { Properties { _Color ("Color Tint", Color) = (1, 1, 1, 1) // diffuse light颜色 _MainTex ("Main Tex", 2D) = "white" {} // 颜色贴图 _BumpMap ("Normal Map", 2D) = "bump" {} // 法线贴图 _BumpScale ("Bump Scale", Float) = 1.0 // 控制凹凸程度,为0时意味着法线纹理不会对光照产生任何影响 _Specular ("Specular", Color) = (1, 1, 1, 1) // specular light颜色 _Gloss ("Gloss", Range(8.0, 256)) = 20 // 控制specular light的强度 } SubShader { Pass { Tags { "LightMode"="ForwardBase" } // pass为前向渲染路径 CGPROGRAM // 与后面的ENDCG来包围住shader代码,以定义vs和fs #pragma vertex vert // 当前shader的vs的函数名为vert #pragma fragment frag // 当前shader的fs的函数名为frag #include "Lighting.cginc" // 为了使用unity内置的一些变量,如_LightColor0
// 定义变量,与Properties语义块中属性建立联系 fixed4 _Color; sampler2D _MainTex; float4 _MainTex_ST; // 颜色贴图的属性(xy为平铺系数,zw为偏移系数) sampler2D _BumpMap; float4 _BumpMap_ST; // 法线贴图的属性(xy为平铺系数,zw为偏移系数) float _BumpScale; fixed4 _Specular; float _Gloss;
// vs的输入结构 struct a2v { float4 vertex : POSITION; // 顶点Position(模型空间) float3 normal : NORMAL; // xyz为法线向量(模型空间),即Tangent Space的z轴方向 float4 tangent : TANGENT; // xyz为tanget切线向量,w分量决定Tangent Space中的第三个坐标轴:副切线的方向 float4 texcoord : TEXCOORD0; // 由于颜色贴图和法线贴图用的是同一套uv,只用了xy分量来存放uv };
// vs的输出结构,fs的输入结构 struct v2f { float4 pos : SV_POSITION; float4 uv : TEXCOORD0; float3 lightDir: TEXCOORD1; // Tangent Space中的光照方向 float3 viewDir : TEXCOORD2; // Tanget Space中的视角方向 }; // Unity doesn't support the 'inverse' function in native shader // so we write one by our own // Note: this function is just a demonstration, not too confident on the math or the speed // Reference: http://answers.unity3d.com/questions/218333/shader-inversefloat4x4-function.html float4x4 inverse(float4x4 input) { #define minor(a,b,c) determinant(float3x3(input.a, input.b, input.c)) float4x4 cofactors = float4x4( minor(_22_23_24, _32_33_34, _42_43_44), -minor(_21_23_24, _31_33_34, _41_43_44), minor(_21_22_24, _31_32_34, _41_42_44), -minor(_21_22_23, _31_32_33, _41_42_43), -minor(_12_13_14, _32_33_34, _42_43_44), minor(_11_13_14, _31_33_34, _41_43_44), -minor(_11_12_14, _31_32_34, _41_42_44), minor(_11_12_13, _31_32_33, _41_42_43), minor(_12_13_14, _22_23_24, _42_43_44), -minor(_11_13_14, _21_23_24, _41_43_44), minor(_11_12_14, _21_22_24, _41_42_44), -minor(_11_12_13, _21_22_23, _41_42_43), -minor(_12_13_14, _22_23_24, _32_33_34), minor(_11_13_14, _21_23_24, _31_33_34), -minor(_11_12_14, _21_22_24, _31_32_34), minor(_11_12_13, _21_22_23, _31_32_33) ); #undef minor return transpose(cofactors) / determinant(input); } // vs着色器入口函数 v2f vert(a2v v) { v2f o; o.pos = mul(UNITY_MATRIX_MVP, v.vertex); // 乘以MVP矩阵,输出投影空间下的顶点Position o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw; // 乘以平铺系数,然后加上偏移系数,得到颜色贴图上的uv坐标 o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw; // 乘以平铺参数,然后加上偏移参数,得到法线贴图上的uv坐标
/*********************************************方式①************************************************/ /// /// Note that the code below can handle both uniform and non-uniform scales /// // Construct a matrix that transforms a point/vector from tangent space to world space fixed3 worldNormal = UnityObjectToWorldNormal(v.normal); // 世界空间的N向量 fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz); // 世界空间的T向量 fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; // 世界空间的B向量 /* float4x4 tangentToWorld = float4x4(worldTangent.x, worldBinormal.x, worldNormal.x, 0.0, worldTangent.y, worldBinormal.y, worldNormal.y, 0.0, worldTangent.z, worldBinormal.z, worldNormal.z, 0.0, 0.0, 0.0, 0.0, 1.0); // The matrix that transforms from world space to tangent space is inverse of tangentToWorld float3x3 worldToTangent = inverse(tangentToWorld); */ //wToT = the inverse of tToW = the transpose of tToW as long as tToW is an orthogonal matrix. float3x3 worldToTangent = float3x3(worldTangent, worldBinormal, worldNormal); // TBN矩阵 // Transform the light and view dir from world space to tangent space o.lightDir = mul(worldToTangent, WorldSpaceLightDir(v.vertex)); // 将光照方向从世界空间转换到切线空间 o.viewDir = mul(worldToTangent, WorldSpaceViewDir(v.vertex)); // 将视线方向从世界空间转换到切线空间
/***************************************************************************************************/
/*********************************************方式②************************************************/ /// /// Note that the code below can only handle uniform scales, not including non-uniform scales /// // 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, normalize(ObjSpaceLightDir(v.vertex))).xyz; // // Transform the view direction from object space to tangent space // o.viewDir = mul(rotation, normalize(ObjSpaceViewDir(v.vertex))).xyz;
/***************************************************************************************************/
return o; } // fs着色器入口函数 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 注:标记为Normal map类型,unity会对贴图进行压缩 tangentNormal = UnpackNormal(packedNormal); tangentNormal.xy *= _BumpScale;
// tangentNormal.z = sqrt(1.0 - (x2+y2)) 注:sqrt为开根号 tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy))); // 注:saturate(x)的作用是如果x取值小于0,则返回值为0。如果x取值大于1,则返回值为1。若x在0到1之间,则直接返回x的值 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" // 为该shader设置合适的Fallback
在世界空间下计算的具体过程:
① 在vs中计算从切线空间到世界空间的变换矩阵,并把该矩阵传递到ps中
② 然后在ps对法线纹理采样,并执行反映射
③ 接着还需要利用变换矩阵将normal从切线空间转换到世界空间
④ 最后在世界空间中进行光照计算
unity中的实现如下(Chapter7-NormalMapWorldSpace.shader):
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;
// vs的输入结构 struct a2v { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; float4 texcoord : TEXCOORD0; };
// vs的输出结构,fs的输入结构 struct v2f { float4 pos : SV_POSITION; float4 uv : TEXCOORD0; float4 TtoW0 : TEXCOORD1; // 从切线空间到世界空间的变换矩阵第一行 float4 TtoW1 : TEXCOORD2; // 从切线空间到世界空间的变换矩阵第二行 float4 TtoW2 : TEXCOORD3; // 从切线空间到世界空间的变换矩阵第三行 }; // vs着色器入口函数 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; // 世界空间下的顶点Position fixed3 worldNormal = UnityObjectToWorldNormal(v.normal); // 世界空间N向量 fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz); // 世界空间T向量 fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; // 世界空间B向量 // 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; } // ps着色器入口函数 fixed4 frag(v2f i) : SV_Target { // Get the position in world space float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w); // 世界空间Position // 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" }
注:unity shader的矩阵是按照列优先顺序来存储(列主序)
上文unity相关的一些截图:
(1)使用法线纹理
(2)使用Bump Scale属性来调整模型的凹凸程度
(3)当使用UnpackNormal函数计算法线纹理中的法线方向时,需要把纹理类型标识为Normal map
参考
Lesson 6bis: tangent space normal mapping
为什么要有切线空间(Tangent Space),它的作用是什么?