高度圖轉法線
高度圖中保存的是物體表面的高度信息,可以利用u,v方向上高度變化的斜率,計算出tangent和binormal,然后通過向量叉乘得到normal。我們在fragment shader中計算每個fragment的normal:
void InitializeFragmentNormal(inout Interpolators i) {
// 取兩側點進行采樣
float2 du = float2(_HeightMap_TexelSize.x * 0.5, 0);
float u1 = tex2D(_HeightMap, i.uv - du);
float u2 = tex2D(_HeightMap, i.uv + du);
float2 dv = float2(0, _HeightMap_TexelSize.y * 0.5);
float v1 = tex2D(_HeightMap, i.uv - dv);
float v2 = tex2D(_HeightMap, i.uv + dv);
float3 tangent = float3(_HeightMap_TexelSize.x, u2 - u1, 0);
float3 binormal = float3(0, v2 - v1, _HeightMap_TexelSize.y);
// 注意是B x T
i.normal = cross(binormal, tangent);
i.normal = normalize(i.normal);
}

可以看到,得到的法線非常銳利,這是因為叉乘得到的原始法線為float3(_HeightMap_TexelSize.y * (u1 - u2), _HeightMap_TexelSize.x * _HeightMap_TexelSize.y, _HeightMap_TexelSize.x * (v1 - v2))。原始法線的y分量過小,導致歸一化時x和z方向的值會偏大,從而偏離(0,1,0),而顯得效果十分銳利。這里我們可以特殊處理,將得到的tangent和binormal向量先進行縮放,再進行叉乘計算:
float3 tangent = float3(1, u2 - u1, 0);
float3 binormal = float3(0, v2 - v1, 1);

法線貼圖采樣
在Unity中,高度圖可以直接導入成法線貼圖,只要在導入設置中進行修改即可:

我們可以使用現成的API函數UnpackScaleNormal提取法線貼圖中的normal:
half3 UnpackScaleNormal(half4 packednormal, half bumpScale)
{
return UnpackScaleNormalRGorAG(packednormal, bumpScale);
}
half3 UnpackScaleNormalRGorAG(half4 packednormal, half bumpScale)
{
#if defined(UNITY_NO_DXT5nm)
half3 normal = packednormal.xyz * 2 - 1;
#if (SHADER_TARGET >= 30)
// SM2.0: instruction count limitation
// SM2.0: normal scaler is not supported
normal.xy *= bumpScale;
#endif
return normal;
#else
// This do the trick
packednormal.x *= packednormal.w;
half3 normal;
normal.xy = (packednormal.xy * 2 - 1);
#if (SHADER_TARGET >= 30)
// SM2.0: instruction count limitation
// SM2.0: normal scaler is not supported
normal.xy *= bumpScale;
#endif
normal.z = sqrt(1.0 - saturate(dot(normal.xy, normal.xy)));
return normal;
#endif
}
可以看到,如果引擎編譯shader時發現平台不支持DXT5NM,則會將紋理信息直接按rgb格式解析為法線。bumpScale參數用法就和高度圖的時候類似,用來縮放法線的xy分量來調整凹凸的程度。如果支持DXT5NM,那么法線貼圖里只用了g通道和a通道來儲存法線的y分量和x分量。z分量需要根據向量的歸一化手動計算。另外別忘了,這里得到的法線是基於TBN空間的,如果直接拿來用,還需要手動調換一下y分量和z分量的位置:
i.normal = UnpackScaleNormal(tex2D(_NormalMap, i.uv), _BumpScale);
i.normal = i.normal.xzy;
i.normal = normalize(i.normal);

多張法線貼圖
之前我們提到過detail texture,可以與main texture疊加來豐富紋理細節。類似地,我們可以擁有一張detail normal map,與原來的法線貼圖進行疊加。normal map在unity導入時也可以設置fade range,完全淡出時的效果就跟沒有法線一樣。

那么,怎樣對兩個法線進行疊加呢?顯然,直接加和求平均是不合適的,平均會抵消法線的信息,使得效果變得平整。例如一個法線n1=(0, 1, 0),另外一個法線n2=(0, 0.5, 0.87),平均之后得到的法線n3=(0, 0.75, 0.44),顯然與豎直方向更加接近了,這不是我們想要的。我們希望,當有一個法線的效果是完全平整時,也不會影響另外一個法線產生的效果。
讓我們回到之前的高度圖中來。我們知道,法線其實是反映高度在uv方向高度變化程度的向量。即法線可以寫成這樣的形式:
在TBN空間中,則為:
我們希望法線疊加,就是把uv方向高度變化的量進行疊加。假設從兩張法線貼圖中取出的法線分別為M和D,那么可得到:
那么,最終疊加的法線N為:
可以看出,M和D的xy分量還是會受到各自z分量的影響,那么直接去掉它:
這個就是最終得到的疊加法線。
當然,我們直接可以使用Unity提供的API函數BlendNormals來進行這個操作:
half3 BlendNormals(half3 n1, half3 n2)
{
return normalize(half3(n1.xy + n2.xy, n1.z*n2.z));
}
切線空間
在使用Unity導入模型時,通常使用MikkTSpace算法來計算切線。MikkTSpace約定了計算binormal的方式為:
binormal = cross(normal.xyz, tangent.xyz) * tangent.w;
可以發現tangent向量是4維的,其中w分量的值為+1/-1。那么這個w分量是做什么用的呢?
我們知道,tangent和binormal實際代表了紋理的uv方向。在DirectX和OpenGL平台上,紋理的u方向是一致的,都是從左向右;而v方向卻有差別,DirectX上v方向是自頂向下的,原點在左上方;OpenGL上v方向是自底向上的,原點在右下方。因此,為了保證binormal的方向始終與紋理的v方向保持一致,需要引入一個分量w來控制是否翻轉binormal。
此外,如果是鏡像模型,那么模型的法線和切線應當是對稱的,但binormal應當還是一致的,即模型兩側的TBN空間不是一致的,而是對稱的。這時,兩邊的tangent的w分量就需要不同了。來看一個例子:

圖中是一個鏡像模型,讓我們導入到Unity中,看看它兩邊的TBN長啥樣:

其中,紅色代表tangent,藍色代表binormal,綠色代表normal。讓我們拉近了來看下:

可以看到,兩邊的TBN空間是對稱的,為了實現這一點,需要借助tangent的w分量。
不過在Unity中,我們發現實際計算binormal的方法是這樣的:
float3 CreateBinormal (float3 normal, float3 tangent, float binormalSign) {
return cross(normal, tangent.xyz) *
(binormalSign * unity_WorldTransformParams.w);
}
這里多出了一個變量unity_WorldTransformParams。它的w分量與物體transform的scale有關。如果有奇數個scale的值為負數,那么w取值為-1,否則取值為0。其實就是說,在scale為負數的時候,物體的紋理可能會被翻轉,導致TBN空間不對,這和前面提到的鏡像問題原因類似。來看一個例子:

當scale.x為-1時,原本向上的法線實際上要變得向下,在tangent不變的情況下,需要翻轉binormal:

當scale.x和scale.z都為-1時,原本向上的法線經過兩次翻轉之后依舊向上,就無需翻轉binormal:

如果你覺得我的文章有幫助,歡迎關注我的微信公眾號(大齡社畜的游戲開發之路)-

