這是我以前在其它地方寫的, 轉到這里來, 這里的排版比較好看.
添加了新的內容, 比如法線貼圖和切空間的概念等(2019.07.04)
----------- 下面首先這是別人寫的切空間的原理, 因為難懂所以我才寫了一個新的版本的在后面 -----------
法線貼圖中的法線向量在切線空間中,法線永遠指着正z方向。切線空間是位於三角形表面之上的空間:法線相對於單個三角形的本地參考框架。它就像法線貼圖向量的本地空間;
它們都被定義為指向正z方向,無論最終變換到什么方向。使用一個特定的矩陣我們就能將本地/切線空寂中的法線向量轉成世界或視圖坐標,使它們轉向到最終的貼圖表面的方向。 我們可以說,上個部分那個朝向正y的法線貼圖錯誤的貼到了表面上。法線貼圖被定義在切線空間中,所以一種解決問題的方式是計算出一種矩陣,把法線從切線空間變換到一個不同的空間,
這樣它們就能和表面法線方向對齊了:法線向量都會指向正y方向。切線空間的一大好處是我們可以為任何類型的表面計算出一個這樣的矩陣,由此我們可以把切線空間的z方向和表面的法線方向對齊。 這種矩陣叫做TBN矩陣這三個字母分別代表tangent、bitangent和normal向量。這是建構這個矩陣所需的向量。要建構這樣一個把切線空間轉變為不同空間的變異矩陣,
我們需要三個相互垂直的向量,它們沿一個表面的法線貼圖對齊於:上、右、前;已知上向量是表面的法線向量。右和前向量是切線(Tagent)和副切線(Bitangent)向量。
下面的圖片展示了一個表面的三個向量
計算出切線和副切線並不像法線向量那么容易。從圖中可以看到法線貼圖的切線和副切線與紋理坐標的兩個方向對齊。我們就是用到這個特性計算每個表面的切線和副切線的。需要用到一些數學才能得到它們;請看下圖:
上圖中我們可以看到邊紋理坐標的不同,是一個三角形的邊,這個三角形的另外兩條邊是和,它們與切線向量和副切線向量方向相同。這樣我們可以把邊和用切線向量和副切線向量的線性組合表示出來(注意和都是單位長度,在平面中所有點的T,B坐標都在0到1之間,
因此可以進行這樣的組合):
我們也可以寫成這樣:
是兩個向量位置的差,和是紋理坐標的差。然后我們得到兩個未知數(切線T和副切線B)和兩個等式。你可能想起你的代數課了,這是讓我們去接和。
上面的方程允許我們把它們寫成另一種格式:矩陣乘法
嘗試會意一下矩陣乘法,它們確實是同一種等式。把等式寫成矩陣形式的好處是,解和會因此變得很容易。兩邊都乘以的逆矩陣等於:
這樣我們就可以解出和了。這需要我們計算出delta紋理坐標矩陣的擬陣。我不打算講解計算逆矩陣的細節,但大致是把它變化為,1除以矩陣的行列式,再乘以它的共軛矩陣。
有了最后這個等式,我們就可以用公式、三角形的兩條邊以及紋理坐標計算出切線向量和副切線。
我們可以用TBN矩陣把所有向量從切線空間轉到世界空間,傳給像素着色器,然后把采樣得到的法線用TBN矩陣從切線空間變換到世界空間;法線就處於和其他光照變量一樣的空間中了。
我們用TBN的逆矩陣把所有世界空間的向量轉換到切線空間,使用這個矩陣將除法線以外的所有相關光照變量轉換到切線空間中;這樣法線也能和其他光照變量處於同一空間之中。
我們來看看第一種情況。我們從法線貼圖重采樣得來的法線向量,是以切線空間表達的,盡管其他光照向量是以世界空間表達的。把TBN傳給像素着色器,我們就能將采樣得來的切線空間的法線乘以這個TBN矩陣,
將法線向量變換到和其他光照向量一樣的參考空間中。這種方式隨后所有光照計算都可以簡單的理解。
以上就是別人寫的攻略, 我表示有看沒有懂, 就自己寫一個吧
-------------------------- 我是分割線 --------------------------
好吧看完我要跪了, 有圖有文, 可是看不懂, 我就來個深入淺出版本吧:
先說明什么是法線貼圖 :
法線貼圖就是提供給模型表面作為其法線的一張貼圖, 或者叫凹凸貼圖, 一般用來提高模型細節, 比如說一個牆壁模型師做成一個簡單Quad, 然后加上凹凸貼圖就能在光照的時候表現出牆面的凹凸效果了.
然后什么是切空間 :
法線貼圖提取出來的向量, 它不是一個本地向量, 是基於該點所在的模型的三角面上的, 也就是基於切空間的, 那么問題來了, 這個法線它的方向是朝向哪里的? 也就是說這個法線的切空間坐標系是哪里來的呢? 我們看下圖 :
圖中可見, 如果要組成一個在這個面上的坐標系, 有無數種, 這些都是切空間坐標系.
我們按照美術制作的流程 (假) 來解釋比較容易明白, 首先美術做了一個面, 在這個面上要添加凹凸法線, 然后如果他選擇了紅色坐標系, 那么這個點的法線的向量可能是(1,2,3), 如果他選擇了綠色的坐標系, 這個法線的向量可能就是(2,3,4)了,
他選擇的坐標系就叫切空間了. 那么我們要怎樣方便快捷地知道切空間呢? 是要美術在模型的每一個面上都附加一個坐標系信息嗎? 還真是, 請看下圖:
一個模型, 它的切空間信息是可以導入的, 也就是說模型是可以附帶切空間信息的. 那么一個切空間信息應該是怎樣的呢? 很簡單, 因為模型的每個面都是有向量的 (當做Y軸), 那么我們再有一個其它的軸 (X軸或Z軸), 然后叉積就能計算出另外一個軸了.
所以導入模型的選項中可以選擇導入Tangents, 這里的X軸一般被稱為Tangent軸. Z軸被叫做Bitangent軸. Y軸就是Normal了.
到這里你可能就慌了, 知道了切空間的各個軸, 也知道了在切空間中的凹凸法線向量, 那么怎樣把凹凸法線變換到世界坐標系中啊??? 很簡單, 先把凹凸法線轉換到本地坐標系, 然后轉換到世界坐標系.
比如:
凹凸法線向量 (r, g, b) 一般使用r對應X軸, g對應Z軸, b對應Y軸, 所以法線貼圖一般偏藍, 就是偏向法線方向.
切空間各個坐標軸向量(切空間相對於本地坐標系) :
X軸 : (x0, y0, z0) -> Tangent
Y軸 : (x1, y1, z1) -> Normal
Z軸 : (x2, y2, z2) -> Bitangent
那么轉換到本地坐標系就是 localNormal = normalize(Tangent * r + Normal * b + Bitangent * g), 數學理論不用說了, 初中生的知識. 再轉到世界坐標系就用 Transform.localToWorldMatrix 計算即可, 非常簡單. 看到這里就不虛了吧.
我們繼續往下看, 原來切空間還能通過計算得出來?
為什么呢? 如果美術同學在導出模型的時候沒有導出切空間信息給我們, 還能通過計算得到? 計算得到的跟美術同學制作時使用的切空間能一樣嗎?
答案是 : 可以計算得到, 計算出來的切空間跟美術制作時使用的是一樣的. 是不是又開始慌了? 不是說一個面上的切空間有無數種嗎? 為什么能逆計算出來呢? 答案就在UV坐標中.
前面的文章是假設了T, B兩個三維向量, 使用差值來計算的, 假設有三個點 :
P1 (x1, y1, z1) 對應UV(u1, v1)
P2 (x2, y2, z2) 對應UV(u2, v2)
P3 (x3, y3, z3) 對應UV(u3, v3)
那么假設T,B向量為正交向量在三角平面上:
P2-P1 = T * (u2-u1) + B * (v2-v1)
P3-P1 = T * (u3-u1) + B * (v3-v1)
根據上面文章的計算, 這個T,B向量是唯一的, 根據現代工程原理, 那么一般來說美術制作所使用的軟件, 它也是根據模型的頂點位置和UV來給出切空間的, 然后美術同學就在給出的切空間去做凹凸貼圖, 而不是由他來自定義切空間.
所以切空間是可以根據逆計算得到的.
下面是從幾何原理來說明切空間:
先從shader怎樣使用凹凸貼圖開始說, 原理很簡單, 首先你想要給一個模型提供法線貼圖, 那么在每一個Fragment階段都要去取NormalMap的rgb當做法線來用, 流程如下:
1. 用uv取出NormalMap相應的rgb作為tangentNormal, 它的rgb的b值是我們通常的法線方向. 見圖一
2. 把這個tangentNormal貼到uv相應的插值點的Local坐標位置(圖二), 因為它表現的是這個點的切空間中的法線方向, 必然要轉換到本地坐標系, 轉換之后它就是這個點的LocalNormal了.
如圖一是tangentNormal的rgb(xyz)方向. 圖二表示這個圖元在模型的一個面上, tangentNormal在轉換后的方向也發生了改變.
3. 把LocalNormal轉到世界就是該插值點的世界法線了WorldNormal. 完畢.
圖一
圖二
通過代碼梳理流程, 以下是某老外寫的, 思路非常清晰 :
1. GetTangentSpaceNormal就是把法線貼圖的向量弄出來
2. 獲取出來的tangentSpaceNormal就是切空間中的向量, 注意這里使用了rgb的b來作為Y軸方向, 這是約定俗成的.
3. i.tangent (X軸), binormal (Z軸), i.normal (Y軸) 代表的就是當前三角面的切空間相對於LocalSpace的坐標系, 這樣跟tangentSpaceNormal的每個值相乘, 就相當於把向量投影到本地坐標系了.
// 把法線貼圖的向量弄出來
float3 GetTangentSpaceNormal (Interpolators i) { float3 normal = float3(0, 0, 1); #if defined(_NORMAL_MAP) normal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale); #endif #if defined(_DETAIL_NORMAL_MAP) float3 detailNormal = UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale); detailNormal = lerp(float3(0, 0, 1), detailNormal, GetDetailMask(i)); normal = BlendNormals(normal, detailNormal); #endif return normal; }
void InitializeFragmentNormal(inout Interpolators i) { float3 tangentSpaceNormal = GetTangentSpaceNormal(i); #if defined(BINORMAL_PER_FRAGMENT) float3 binormal = CreateBinormal(i.normal, i.tangent.xyz, i.tangent.w); #else float3 binormal = i.binormal; #endif i.normal = normalize(tangentSpaceNormal.x * i.tangent + tangentSpaceNormal.z * i.normal + tangentSpaceNormal.y * binormal); }
這里可能有點沒有說清楚, i.tangent, binormal, i.normal其實都是三角形面上基於LocalSpace坐標系的新坐標系(切空間), 而它的法線就是i.normal.
因為NormalMap的b(z)表示的是垂直方向, 所以用tangentSpaceNormal.z * i.normal 來獲得在新坐標系中法線方向的值.
FragmentOutput MyFragmentProgram (Interpolators i) { float alpha = GetAlpha(i); #if defined(_RENDERING_CUTOUT) clip(alpha - _AlphaCutoff); #endif InitializeFragmentNormal(i);
......
}
看, 在Fragment中修改法線方向
在前面的流程梳理中很自然地忽略了一個過程: 怎樣獲得Tangent和Bitangent軸.實際上就是獲得一個在三角形面上的坐標系, 我們將LocalSpace坐標系作為原始坐標系, 而在模型三角形面上的坐標系(切空間)就是LocalSpace坐標系的子坐標系,
它的每個軸的描述是用LocalSpace坐標系作為參照的.所以Tangent的計算可以直接在模型階段就預先計算好, 作為本地數據存儲即可.
那么Tangent和Bitangent軸到底是怎樣計算出來的呢, 以現有數據來看, 我們只知道三角形面的幾個頂點坐標, 以及該面的Normal(法線), 那么在這個三角形上構建的坐標系可以是無窮多的, 只要符合在面上的兩個正交向量+法線即可,
看下圖 :
法線(紅)+藍色 或 法線(紅)+綠色 都能構建一個坐標系. 法線貼圖獲取的向量在不同坐標系里面的方向肯定是不同的. 要怎樣才能構建唯一正確的切空間坐標系呢...回到法線貼圖來,
當把這個貼圖貼在某個模型上時, 比如在下圖中, 噴塗區域貼在了某個三角面上 :
噴塗區域就是對應的三角面, 那么就簡單了, 如果我們把這張2D圖片做成一個3D中的平面的話, 我們通過拉伸, 平移旋轉等各種方法把對應的三角形區域跟模型上的重疊起來的話, 那么該3D平面的兩個邊就成了Tangent和Bitangent軸了,
理解了的話就可以 回去看開篇的數學公式了 往下看了. 下圖我把中國地圖貼在了一個三角形上(假設是在模型的本地坐標系中), 然后還做了一個3D平面掛上貼圖.
我通過各種方法使他們圖片重疊了, 這樣我的3D圖片的兩個邊 ( 當然方向是UV的正方向 ) 就成為了切空間的Tangent和Bitangent了 (當然計算切空間不可能這樣神手動).
希望這個能夠講清楚切空間的邏輯流程.
PS: 模型每個頂點都帶有position, uv, 所以計算Tangent這些數據並不依賴於圖片, 不要被上面我的手動誤導了哈
下來詳細講解數學流程吧...還是用中國地圖來說:
P1,P2,P3 就是模型三角形面的三個點了, 他們帶有位置和UV信息.
P1{X1, Y1, Z1, U1, V1}
P2{X2, Y2, Z2, U2, V2}
P3{X3, Y3, Z3, U3, V3}
E1{P1 - P2} (X, Y, Z)
E2{P3 - P2} (X, Y, Z)
注意, 這是計算用到的中間變量, 與取哪個點的先后無關, 與哪個點的相對位置也無關, 不管怎樣取只要能表現出三角形的任意兩條邊即可.
du1, du2, dv1, dv2 分別表示E1, E2代表的向量在uv上的差值
注意, 這里因為要求得的向量只有T,B所以需要兩個行列式即可, 所以上面的數據只取了三角形的任意兩條邊, 以及他們的增量數據du/dv.
變量就這些, 它已經提供了我們所需的數據了
1. 它有了實際空間中的兩個向量E1, E2
2. 它提供了向量增長的方向的參考數據du1, dv1, du2, dv2, 也就是說E1,E2在T,B坐標系下是如何增長的(因為UV就是沿着T,B增長的), 反過來也就可以求出T,B的向量了.
PS -- 這里可以把T,B坐標系看成是有邊界的坐標系(UV值就是坐標系中的位置所占的百分比), 之后的計算能夠進行全依賴於UV坐標是個歸一化數據, 在任何縮放下都不受影響的功勞.
之后就可以開始寫等式了:





var p1 = new Vector3(1, 3, 5); var p2 = new Vector3(2, 3, 8); var p3 = new Vector3(0, 1, 4); var uv1 = new Vector2(0, 0); var uv2 = new Vector2(0.5f, 0.5f); var uv3 = new Vector2(1, 0); var E1 = p1 - p2; var E2 = p3 - p2; var duv1 = uv1 - uv2; var duv2 = uv3 - uv2; float multi = 1.0f / (duv1.x * duv2.y - duv2.x * duv1.y); var matrixUV = new Matrix4x4(); matrixUV[0, 0] = duv2.y; matrixUV[0, 1] = -duv1.y; matrixUV[1, 0] = -duv2.x; matrixUV[1, 1] = duv1.x; var matrixE = new Matrix4x4(); matrixE.SetRow(0, new Vector4(E1.x, E1.y, E1.z, 0)); matrixE.SetRow(1, new Vector4(E2.x, E2.y, E2.z, 0)); var finalMatrix = (matrixUV * matrixE); var tangent = new Vector3(finalMatrix[0, 0], finalMatrix[0, 1], finalMatrix[0, 2]) * multi; var bitangent = new Vector3(finalMatrix[1, 0], finalMatrix[1, 1], finalMatrix[1, 2]) * multi; var normal = Vector3.Cross(E2, E1); Debug.Log("計算的Normal 歸一化 : " + normal.normalized); Debug.Log("計算的Tangent 歸一化 : " + tangent.normalized); Debug.Log("計算的Bitangent 歸一化 : " + bitangent.normalized); Debug.Log("Tangent 與 Bitangent 垂直? " + Vector3.Dot(tangent, bitangent)); var go = GameObject.CreatePrimitive(PrimitiveType.Quad); var mesh = new Mesh(); mesh.vertices = new Vector3[] { p1, p2, p3 }; mesh.triangles = new int[] { 0, 1, 2 }; mesh.uv = new Vector2[] { uv1, uv2, uv3 }; mesh.RecalculateNormals(); mesh.RecalculateTangents(); go.GetComponent<MeshFilter>().mesh = mesh; var meshTangents = mesh.tangents; var meshNormals = mesh.normals; for(int i = 0, imax = Mathf.Min(meshTangents.Length, meshNormals.Length); i < imax; i++) { var n = new Vector3(meshNormals[i].x, meshNormals[i].y, meshNormals[i].z); Debug.Log("Mesh的Normal 歸一化 : " + n.normalized); var t = new Vector3(meshTangents[i].x, meshTangents[i].y, meshTangents[i].z); Debug.Log("Mesh的Tangent 歸一化 : " + meshTangents[i]); Debug.Log("Mesh的Bitangent 歸一化 : " + Vector3.Cross(t, n).normalized); } }
結果 :
直接給出三個點和對應的UV值, 通過理論計算出Tangent, Bitangent, Normal等值, 打印出來, 然后生成Mesh, 用Mesh自帶的計算功能計算相關值,
也打印出來, 可以看到Normal和Tangent在前后兩種模式下得到的都是一樣的, 而Bitangent在兩種計算中完全不一樣, 並且在理論計算中甚至Bitangent和Tangent不是正交的,
這是為什么呢? 看前面的等式:
這個等式顯然沒有設定T向量一定跟B向量正交, 這是數學上的解釋, 從幾何上來說的話, 按照上面我寫的幾何論證過程, 就是說原圖像要覆蓋到三角形上需要經過拉伸旋轉等操作,
使得Tangent / Bitangent坐標軸也被拉伸旋轉了, 導致T, B不再是正交的了. 看圖理解 :
注 : 右圖的中國地圖應該是拉伸的平行四邊形, 用WORD做的沒辦法表現出來
所以可以看出, 在理論計算中的Bitangent方向跟Tangent方向有可能正交也可能不正交. 取決於UV對圖片的拉伸. 不過我們需要的只有Tangent, 不管在美術制作還是
自動計算, 切空間都是先計算出Tangent然后叉積算出Bitangent, 而理論計算出來的Bitangent並不是切空間的Z軸, 請注意.
寫了這么多應該很清楚了吧. 在我寫之前有幾個問題一直沒搞清楚, 這里自問自答了.
提問 : Tangent軸是不是就是三角形的某一個邊? 如果是的話就不用這么復雜計算半天了吧?
有可能是, 也有可能不是, 參考數學邏輯, 只有在特殊情況下才有E1 == T 或E2 == T. 或者幾何方法看看上面的圖片, 如果上面的三角形的Uv(1,0)這個點變成Uv(1, 0.5)的話, Tangent軸也是跟三角形的邊是錯開的.
提問 : 理論計算的Bitangent軸它的意義是什么? 沒用的話可以省點計算量嗎?
它跟Tangent一樣是對等的, 如果我們取Bitangent作為基礎軸的話, Tangent就是沒用的了, 反之就是我們現在約定俗成地使用Tangent軸, 其實最省事的是直接使用三角形的某條邊做Tangent才是最省計算量的, 可是
三角形有三條邊, 不是唯一, 不同的平台有不同的表達. 而Tangent計算出來是唯一的.