自己的第一篇博客,記錄一下在unity中處理法線貼圖的一些技術(shù)點(diǎn)。
首先,什么是法線貼圖(Normal Mapping)呢?維基百科上的解釋是“是一種模擬凹凸處光照效果的技術(shù),是凸凹貼圖的一種實(shí)現(xiàn)(原文:it is a technique used for faking the lighting of bumps and dents – an implementation of bump mapping)。而我一直把它當(dāng)作一種能表現(xiàn)物體凹凸感、真實(shí)感的技術(shù),并且實(shí)現(xiàn)起來并不復(fù)雜(至少網(wǎng)上一搜一大堆,無腦復(fù)制黏貼都行)。但是,真正自己來實(shí)現(xiàn)一遍,還是有些坑踩了,所以在此記錄下。
PS. 不知道凹凸貼圖的小伙伴我把維基百科的解釋貼在這兒,“凹凸貼圖就是讓每個(gè)待渲染的像素在計(jì)算照明之前都要加上一個(gè)從高度圖中找到的擾動,這樣得到的結(jié)果表面表現(xiàn)更加豐富、細(xì)致,更加接近物體在自然界本身的模樣。”
既然要實(shí)現(xiàn)法線貼圖,法線圖是必不可少的,我們需要像這樣的一張圖

嗯,看著就和一般的貼圖不一樣,整體偏向藍(lán)紫色。這是為什么呢?要解釋這個(gè)問題也很簡單,看官方文檔就行了(https://docs.unity3d.com/Manual/StandardShaderMaterialParameterNormalMap.html),不過如果你比較懶(比如我>-<),不想去看文檔的話,就看我在這里的解釋吧(^ - ^)。
圖中rgb通道的實(shí)際上儲存著法線的xyz方向,z分量代表向上(unity通常讓y代表向上,但這里不是),我們知道rgb值是0-1的范圍,而方向的取值范圍是-1到1之間,所以在制作這張法線圖的時(shí)候會有一個(gè)轉(zhuǎn)換,讓-1到1的值變成0-1的值(映射函數(shù)為rgb =(normal+1)/2);大多數(shù)情況下我們不需要讓法線偏很多,或者說法線根本沒變,就是一個(gè)朝上的vector(0,0,1),那么變成rgb值就是(0.5,0.5,1),這個(gè)值看起來就是藍(lán)紫色了。那些看起來不是藍(lán)紫色的地方,說明法線偏的比較厲害,所以變成了其他顏色。
另外,如果美術(shù)給了法線圖,扔進(jìn)unity是需要改一下texture type的,default的話后面會有麻煩,最好改成normal map(如圖所示)

現(xiàn)在可以開始寫代碼來使用這張normal圖了……
等等,再寫代碼使用之前還需要記錄一件事,那就是其實(shí)這個(gè)圖里所記錄的法線方向是在tangent sapce下,我們要用的話需要轉(zhuǎn)換,可以選擇把tangent space下的法線轉(zhuǎn)換到local space再一步步處理,或者干脆把涉及到的東西轉(zhuǎn)換到tangent space下,兩種都可以。
不過首先,什么是tangent space?我們知道local space,world space這些是因?yàn)槎x的原點(diǎn)不同而有了各自的空間表達(dá),那么tangent space的原點(diǎn)就是和他們這些空間的原點(diǎn)都不一樣,這個(gè)空間的原點(diǎn)就是模型的頂點(diǎn),z軸是該點(diǎn)的normal方向,如下圖(unity讓z代表向上,原來如此!)


那么x軸,y軸就應(yīng)該是和該點(diǎn)相切的兩條線,這樣的線本來在空間中是有無數(shù)條的,但模型里會定義好一個(gè)tangent,這個(gè)東西的方向一般就是x軸,而y軸就可以通過cross(x,z)來求得了。


終于,可以開始寫代碼實(shí)現(xiàn)了:)
我們在vertex shader里的代碼是這樣的
v2f vert (appdata_tan v)//一定要用appdata_tan否則取不到變量TANGENT_SPACE_ROTATION就用不了了
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.texNormal = TRANSFORM_TEX(v.texcoord, _NormalTex);
TANGENT_SPACE_ROTATION;
//unity自帶命令,實(shí)際上就是在算下面兩行東西
//float3 binormal = cross(v.tan.xyz,v.normal) * v.tan.w;
//float3x3 rotation = float3x3(v.tan.xyz,binormal,v.normal);
float3 ld = mul(unity_WorldToObject, _WorldSpaceLightPos0.xyz);//將光向量從世界空間轉(zhuǎn)到模型空間
o.lightDirection = mul(rotation, ld);//再從模型空間轉(zhuǎn)到切線空間
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
其中重要的有兩點(diǎn):
一是vertex shader傳入的變量類型要是appdata_tan,否則TANGENT_SPACE_ROTATION用了會報(bào)錯(cuò),因?yàn)檫@個(gè)東西要用模型自帶的tangent計(jì)算東西,創(chuàng)建shader時(shí)自動生成的那個(gè)appdata就可以不用了,免得麻煩;
二是o.lightDirection = mul(rotation, ld);這句代碼,它表達(dá)的意思把光向量從local space轉(zhuǎn)到tangent space,記住它!記住它!記住它?。?!
呃。。。死記硬背不是個(gè)好方法,我們還是來看看為什么吧。首先這個(gè)rotation是個(gè)float3x3類型的矩陣,而在這里這個(gè)矩陣是按行存儲的(我沒有找到確切的文檔說明是按行存儲,但根據(jù)結(jié)果來看一定是),而我們在計(jì)算一個(gè)向量從一個(gè)空間到另一個(gè)空間都是把轉(zhuǎn)換矩陣按列存儲再左乘(這里要安利一個(gè)視頻合集https://www.bilibili.com/video/av6731067,對于線性代數(shù),各種矩陣轉(zhuǎn)換有很直觀的解釋),而這邊的這個(gè)rotation矩陣是按行存儲的,相當(dāng)于是按列存儲的rotation矩陣的轉(zhuǎn)置,這里就很有意思了,因?yàn)檫@個(gè)rotation矩陣是個(gè)單位正交矩陣(這個(gè)不明白還是百度吧),而單位正交矩陣有個(gè)性質(zhì)就是它的轉(zhuǎn)置矩陣等于它的逆矩陣,所以這里相當(dāng)于在左乘rotation矩陣的逆矩陣,原本按列存儲的時(shí)候每個(gè)列向量代表的是tangent space下的向量,所以左乘了是把某個(gè)向量從tangent space轉(zhuǎn)到local space,那么左乘它的逆矩陣就是把某個(gè)向量從local space轉(zhuǎn)到tangent space了。
PS. 這里如果不太明白的話需要去熟悉一下線性代數(shù)的相關(guān)內(nèi)容,上面那個(gè)鏈接是個(gè)很好的入門。
其實(shí)還有隱藏的第三點(diǎn):),如果是自己算的rotation矩陣,這句float3 binormal = cross(v.tan.xyz,v.normal) * v.tan.w;為什么要乘個(gè)v.tan.w?這是因?yàn)檫@個(gè)w分量記錄了方向,可正可負(fù),在OpenGL與DirectX下是不一樣的,所以要乘上。
再來就是fragment shader
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
float3 normal = UnpackNormal(tex2D(_NormalTex, i.texNormal));//獲取圖中法線向量,把normal圖的texture type設(shè)置正確會得到正確結(jié)果
normal.xy *= _NormalScale;//控制凹凸程度,若是直接normal*_NormalScale的話若_NormalScale為0則下面的diffuseLight項(xiàng)為0,只用UNITY_LIGHTMODEL_AMBIENT項(xiàng)會導(dǎo)致cube黑乎乎的,不好看
normal.z = 1 - saturate(sqrt(dot(normal.xy,normal.xy)));//因?yàn)閤y拉伸了所以z要重新算,本來x*x + y*y + z*z = 1,所以z = 1 - sqrt(x*x + y*y),而dot(normal.xy,normal.xy)就是在表達(dá)x*x + y*y
float3 ambientLight = UNITY_LIGHTMODEL_AMBIENT.rgb;
float3 diffuseLight = _LightColor0.rgb * saturate(dot(i.lightDirection.xyz,normal));
float4 finalCol = float4((ambientLight + diffuseLight) * col.rgb,col.a);
// apply fog
UNITY_APPLY_FOG(i.fogCoord, finalCol);
return finalCol;
}
我們利用unity的內(nèi)置函數(shù)UnpackNormal來獲取那張藍(lán)紫色貼圖里的normal,這個(gè)方法的源代碼在UnityCG.cginc中有,是這樣子的
// Unpack normal as DXT5nm (1, y, 1, x) or BC5 (x, y, 0, 1)
// Note neutral texture like "bump" is (0, 0, 1, 1) to work with both plain RGB normal and DXT5nm/BC5
fixed3 UnpackNormalmapRGorAG(fixed4 packednormal)
{
// This do the trick
packednormal.x *= packednormal.w;
fixed3 normal;
normal.xy = packednormal.xy * 2 - 1;
normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
return normal;
}
inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if defined(UNITY_NO_DXT5nm)
return packednormal.xyz * 2 - 1;
#else
return UnpackNormalmapRGorAG(packednormal);
#endif
}
還記得之前說要把美術(shù)給的法線貼圖在unity中設(shè)置好texture type么?設(shè)置了那張貼圖就會以DXT5nm的壓縮格式存儲,這樣就可以調(diào)用這個(gè)內(nèi)置方法來得到正確的normal了,當(dāng)然了不設(shè)也行,從源代碼來看unity幫你做了這個(gè)公式rgb = (normal+1)/2的事情,也可以自己去做。不過設(shè)置了texture type在跨平臺的時(shí)候我們就不用考慮其他事情,無腦調(diào)用方法就好,這個(gè)還是比較方便的,所以強(qiáng)烈建議要設(shè)置!
最后,一個(gè)應(yīng)用了法線貼圖的cube就這樣誕生了!項(xiàng)目地址

2020.05.11更新
上文中我把計(jì)算都放到了tangent space底下做,但我們也能把法線轉(zhuǎn)到world space底下進(jìn)行計(jì)算,所以這次更新來講講如何把法線從tangent space轉(zhuǎn)到world space。
PS. 這么做會有一些性能上的損失,首先我們需要在片元著色器中獲取法線,然后逐片元的進(jìn)行坐標(biāo)轉(zhuǎn)換,而如果把計(jì)算都放在tangent space的話,我們可以在頂點(diǎn)著色器中把光源方向、視線方向都轉(zhuǎn)換到tangent space,然后在片元著色器中進(jìn)行顏色計(jì)算,這是個(gè)逐頂點(diǎn)的過程。我們都認(rèn)同一件事,片元比頂點(diǎn)多,那么逐片元會比逐頂點(diǎn)性能開銷大,所以說要不要這么做,還是要由開發(fā)者自己來判斷了。
首先我們需要一個(gè)矩陣,這個(gè)矩陣可以把tangent space中的物體轉(zhuǎn)換到world space,如何構(gòu)建這樣一個(gè)矩陣呢?我們先要確定x,y,z三根坐標(biāo)軸的方向,這里需要tangent space底下x,y,z軸在world space底下的表達(dá),是這樣的
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal); //z軸方向
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz); //x軸方向
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; //y軸方向
這一步不太清楚的小伙伴可以去看馮樂樂的《Unity Shader入門精要》的4.6.2小節(jié),明確一下數(shù)學(xué)概念。確定好三根軸以后那么矩陣就可以構(gòu)建出來了,注意這里是按列排序的矩陣,最后一列存放worldPos這個(gè)變量,不浪費(fèi)空間。
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);
在vertex shader中做好了這些操作后,接下來我們?nèi)ragment shader中進(jìn)行使用。
float3 normal = UnpackNormal(tex2D(_NormalTex, i.texNormal)); //取出貼圖中的法線,此時(shí)法線在tangent space中
normal.xy *= _NormalScale; //縮放法線
normal.z = 1 - saturate(sqrt(dot(normal.xy,normal.xy))); //計(jì)算縮放后的z
float3 worldNormal = normalize(float3(dot(i.TtoW0.xyz, normal), dot(i.TtoW1.xyz, normal), dot(i.TtoW2.xyz, normal))); //將法線從tangent space轉(zhuǎn)到world space
接下來,就是大家表演的時(shí)間了。有了world space下的法線,無論做Blinn Phong還是Lambert,或者PBR等等,都是可以的。
至于法線貼圖中的法線到底放在tangent space底下好,還是local space、或者world space(可以這么做但比較少見)好,仁者見仁智者見智。
參考
Unity Shader - 表面凹凸技術(shù)匯總 http://www.itdecent.cn/p/fea6c9fc610f
【Unity Shaders】法線紋理(Normal Mapping)的實(shí)現(xiàn)細(xì)節(jié)https://blog.csdn.net/candycat1992/article/details/41605257
【光能蝸牛的圖形學(xué)之旅】Unity切線空間問題和推理思考http://www.itdecent.cn/p/af800402f5db