偶然发现一篇不错的关于:切线空间的文章,于是着手翻译,这也是我第一次翻译这么长的文章,如果文中翻译不当,或是我标志没懂的地方,欢迎大家指正、指点。
自己总结先来个译文后总结吧,因为发现这篇文章的:why, how都没怎么说清楚,我就用简短的代码来表示一下吧
lighting in vs 伪代码struct VIn // 顶点输入数据
{
float4 vertex : POSITION;
float3 uv : TEXCOORD0;
};
struct V2F
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float3 lightV : TEXOORD1;
};
sampler2D _diffuseTex;
sampler2D _normalTex;
float4 _lightWs;
V2F vert(VIn in)
{
V2F o = (V2F)0;
o.vertex = mul(mat_MVP, in.vertex);
o.uv = in.uv;
// 扩展阅读中的第二种方法:在顶点着色器中,将除了法线贴图向量外的光照计算相关的向量都先转换到切线空间
float3 posW = mul(mat_M, in.vertex).xyz;
float3 lightV = _lightWs.xyz - posW;
o.lightV = normalize(mul((Matrix3X3)mat_WT, lightV)).xyz;
return o;
}
fixed4 frag(V2F in) : SV_Target
{
float normal = tex2D(_normalTex.in.uv);
return tex2D(_diffuseTex, in.uv) * (dot(in.lightV, normal) + 1) * 0.5;
}
lighting in ps 伪代码
struct VIn
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct V2F
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float3 posW : TEXCOORD1;
float3 lightV : TEXCOORD2;
};
sampler2D _diffuseTex;
sampler2D _normalTex;
float4 _lightWs;
V2F vert(VIn in)
{
V2F o = (V2F)0;
o.vertex = mul(mat_MVP, in.vertex);
o.uv = in.uv;
o.posW = mul(mat_M, in.vertex).xyz;
o.lightV = _lightWs - o.posW;
return o;
}
fixed4 frag(V2F in) : SV_Target
{
// 扩展阅读中的第一种方法:在像素着色器中,将法线贴图向量转换到世界空间
float3 normalW = normalize(mul((Matrix3x3)mat_M, tex2D(_normalTex, in.uv)));
return tex2D(_diffuseTex, in.uv) * (dot(in.lightV, normalW) + 1) * 0.5;
}
lighting in vs与ps的区别
- 在vs中的,如果光栅的像素在屏幕中比较多的时候(就是该三角面离镜头很近时,但通常都会占几百到几千个像素,甚至几百到几十万个像素,想想你的屏幕:1024*768=786432(70w+)的像素,何况现在的屏幕分辨率都很高基本都是1920*1080=2073600(200w+)的),在VS的比在PS的性能会高很多,毕竟就三顶点的计算量,只是在VS中将光源向量(其他shader可能还有视角向量,反射向量等)先转到切线空间,然后在PS就不用再转换了,直接与 normalTex(normalMap)采样出来的法线数据做光照计算就好了
- 在ps中的,如果光栅像素在屏幕中比较少的时候(就是该三角面离镜头很远时),就是少到只有10个像素以下的(屏幕上看几乎就一个点),那么这个计算量才可能比在vs中的少,但这种情况比较少 所以大家根据需求来采用不一样的实现方式吧
原文在这
原文笔误是有点多,而且写以前有10年+前的文章了,但偶尔发现对切线空间、TBN的了解还是很不错的,就尝试翻译了一下。
还有一个很不错的,在该文章最下面的扩展阅读部分,对切线空间讲解更好(看来还是OpenGL学习资源全面一些啊)
混乱的切线空间
1.假设我假设你熟悉一些基本向量数学和三维坐标系。我还将在整个文档中使用左手坐标系(OpenGL使用右手坐标系)。对于像素和顶点着色器,我假设您理解DirectX着色器的语法(下文针对DX的来讲解)。许多人似乎觉得装配着色器很吓人。本文的重点是帮助您理解概念而不提供代码。代码只是为了帮助您理解概念而提供的。
2.介绍在本文中,我将介绍什么是切空间,以及如何在世界空间和切空间之间转换一个点。我写这篇文章的主要原因是为了让一个对像素着色器不熟悉的人能够快速地理解一个相当重要的概念,并且能够自己实现它。
第二部分是用来演示世界空间到切空间转换的实际应用。这时您将真正理解为什么切线空间在当今的许多像素着色器中如此重要。我敢肯定你会在网上找到很多关于每像素照明的花式教程,但是这个教程是为了让你在切线空间里热身。
那么,我们开始吧…
3.切线空间 1.什么是切线空间顾名思义,切线空间在某些情况下也被称为纹理空间。
我们的3D世界可以分为成许多不同的坐标系。你已经熟悉了其中一些——世界空间,对象空间,相机空间。如果你不是,我想你还是跑太快了,强烈建议你先回去了解这些概念。
切线空间只是另一个这样的坐标系,有它自己的起源。该坐标系指定了一个面的纹理坐标。不同面的切线空间坐标系基本不同。
在这个坐标系的可视化的坐标轴中,X轴指向为U值增加的方向,Y轴为V值的增量方向。在大多数情况下,一个面的切线空间的U,V可以认为是一个对齐于该平面的2D坐标系(就类似一个平面的X,Y坐标)。
那么Z轴在切线空间的作用呢?
U,V考虑定义于2D空间,但在3D中,我们也需要Z轴的。Z轴可以认为是面的法线,且垂直于该U,V所在的平面。
我们称之为n(法线)。
图1:一个平面的切线轴
如图1,它能助你可视化的了解切线空间坐标系。它显示了一个由四个顶点和两个面组成的四边形,假设有一个简单的纹理采样应用到其上。
图2显示了顶点1, 2, 3定义的立方体上的面的切线空间轴。我们再次假设这个立方体有一个非常简单的纹理采样应用其上。
在左下角,你还可以看到X、Y、Z标记的世界空间坐标系的轴。
u、v、n轴表示u、v、n值在整个面上增量方向,正如x、y、z值表示世界空间坐标系中x、y、z值增量的方向一样。
图2:立方体中的某个平面的切线空间
再举个例子。在图2中,我们可视化的了解到一个盒子的顶面的切线空间。
为何需要切线空间如:切线空间,世界空间都是3D空间,正如英寸和米都是表示长度。如需要计算时,所有值都应该在相同的单位或坐标系下进行。
对于这点,你可能想了解…
首先为啥要使用切线空间呢?为什么不在世界空间中定义所有的位置和向量?逐像素的关照技术与其他许多的着色需要法线与其他的一些高度信息都定义在每个像素点。意思是我们要每个纹素都得有一个法线向量(n轴)。就如为一个平面定义凹凸的表面信息。
如果这些法线在世界空间坐标系中定义,就算是稍微的旋转模型,我们将不得不旋转这些法线。还有灯光,相机和其他包含这些计算的都定义于世界空间且都脱离于对象空间。这意味着成千上百万的逐像素的对象空间转换到世界空间的矩阵转换。我们都知道矩阵的转换消耗都不低。
我们没有这样做,而是在切线空间坐标系(切线空间的法线贴图)中定义成百上千个表面法线。然后我们只需要在逐顶点的把其他相关向量(主要由灯光、相机等)转换为相同的切线空间坐标系,然后传入像素着色器(这样像素着色器就少了很多计算量,毕竟像素基本光栅化出来的像素一般都很多,除非离得镜头很远)并在那里进行计算。在大多数情况下,这些在顶点计数光照相关的向量不会超过10个,如果在像素着色器计算个数的话,可能比顶点着色高出10W~200W个(计算量不是一个级别的)。
所以1000000 vs 10的计算量。嗯……要花多长时间才能决定哪一个更好(很明显)。
(上文所说的这么大的计算量的区别主要在于,如果我们要做到像素级别的表面法线细节,如果用顶点级别来表现成逐像素的程度的话,那么计算量真的就大的可怕至少每个像素的位置就得有一个顶点,而且还没算同个位置不同深度上的顶点呢,想想就知道用顶点来做这种表现是不可能的,所以果断用法线纹理)
想想图2中的盒子旋转。即使我们旋转它,切线空间轴也会保持相对于平面对齐。然后,我们将最多几百个灯光、相机、顶点转换到切线空间并运算,来替代成千上百万的表面(逐像素级别的表面,即:一个像素一个表面的细节程度)法线转换到世界空间。
切线空间还有其他优点:用于转换空间的矩阵我们可以预计算。这些矩阵仅需在每个相关联的顶点的位置改变了就重新计算一下即可。
希望您能理解为什么我们需要切空间,以及从世界空间到切空间的转换。如果没能理解,那么你就当它是需要这么做就可以,接下来你会在第二部分中了解的。
(其实我当时看的时候还没怎么看懂,后来都是看了一下相关的资料才更了解,这篇文章算是对TBN更了解,大家还是向看为何需要切线空间可以看看我下面的‘译文’总结,还有扩展阅读,这篇文章说的不是很清楚)
2.世界空间到切线空间变换矩阵的推导为了在坐标系A到坐标系B之间转换,我们需要定义B相对于A的基向量,并在矩阵中使用它们。
什么是基向量?每个n维坐标系都可以用n个基向量来定义。可以用3个基矢量定义3维坐标系。可以用2个基矢量定义2维坐标系。规则是这些基向量相互垂直。
这是基本而又奇特的数学术语之一,给定三个单位大小且相互垂直的向量(在3D世界中)。这三个向量可以指向任何方向,只要它们总是相互垂直。如果您想可视化了解一组基本向量,可以想象为在3D应用程序中绘制的轴。请看图1和图2。轴上的向量(x,y,z)定义一组基向量。向量u,v,n也定义了另一组。
世界空间到切线空间变换矩阵的推导的方法三个基向量能搞出什么花样? 为了达到我们的目的,第一步就是在世界空间坐标系下定义u,v和n三个基向量。到这步后,都是小菜一碟的事。
在这,我将u,v,n分别称为切线(T),副切线(B)和法线(N)。为何这么叫呢?正如他们叫法。
正如我之前所说的,我们需要找到关于世界空间的T,B,N基向量。这是过程中的第一步。
可以简单的理解为: 我有一个向量,它指向平面中u值增量的方向,那么它在世界空间坐标系中的值是多少。
也可以另一种理解为: 求出曲面上u,v,n分量相对于世界空间的x,y,z分量的变化率。T向量实际上是相对世界空间坐标中u分量的变化率。
无论你选择如何想象它,我们都会有T,B,N向量。
下一步是建立一个矩阵的形式: M w t = ( T x T y T z B x B y B z N x N y N z ) M_{wt} = \begin{pmatrix} \begin{array}{} T_x & T_y & T_z\\ B_x & B_y & B_z\\ N_x & N_y & N_z \end{array} \end{pmatrix} Mwt=⎝⎛TxBxNxTyByNyTzBzNz⎠⎞
世界坐标 ( P o s w s ) (Pos_{ws}) (Posws)乘以 M w t M_{wt} Mwt我们将得到在切线空间下的 ( P o s t s ) (Pos_{ts}) (Posts)
P o s t s = P o s w s × M w t Pos_{ts}=Pos_{ws} \times M_{wt} Posts=Posws×Mwt
如前所述,T、B、N矢量(以及所有基矢量)将始终彼此成直角(相互垂直)。我们需要做的就是导出任意两个向量,第三个向量是前两个向量的叉积。在大多数情况下,平面的法线已经被计算出来。这意味着我们只需要另一个向量来完成我们的矩阵。
创建一个平面的切线空间转换矩阵在这一小节,我讲用一个平面来推导出 M w t M_{wt} Mwt。( M w t M_{wt} Mwt是Matrix of world to tangent的意思,即:从世界空间转换到切线空间的矩阵的意思)
图3:一个面
假设图3中的面部顶点具有以下值
Vertex(顶点)Position(x,y,z)(位置)Texture coordinate(u,v)(纹理坐标)V1(0,20,0)(0,0)V2(20,20,0)(1,0)V3(0,0,0)(0,1)如之前所说
- 切线向量(u轴)将指向平面U分量的增量方向。
- 副切线向量(v轴)总是指向V分量的增量方向。
- n分量总是假定为常数,因此等于法向量(n轴),并将指向平面法线相同的方向。
为了推导T,B,N,需要分别计算出相对世界坐标的x,y,z的u,v,n平面分量值差值。
第一步:计算得出任意两条边的向量。边:E 2 − 1 = V 2 − V 1 = ( 20 − 0 , 20 − 20 , 0 − 0 ) & ( 1 − 0 , 0 − 0 ) = ( 20 , 0 , 0 ) & ( 1 , 0 ) E_{2-1}=V2-V1= (20-0,20-20,0-0)\&(1-0,0-0)=(20,0,0)\&(1,0) E2−1=V2−V1=(20−0,20−20,0−0)&(1−0,0−0)=(20,0,0)&(1,0) E 3 − 1 = V 3 − V 1 = ( 0 − 0 , 0 − 20 , 0 − 0 ) & ( 0 − 0 , 1 − 0 ) = ( 0 , − 20 , 0 ) & ( 0 , 1 ) E_{3-1}=V3-V1=(0-0,0-20,0-0)\&(0-0,1-0)=(0,-20,0)\&(0,1) E3−1=V3−V1=(0−0,0−20,0−0)&(0−0,1−0)=(0,−20,0)&(0,1)
边 E 2 − 1 E_{2-1} E2−1与 E 3 − 1 E_{3-1} E3−1实际上是V1,V2与V1,V3的x,y,z差值得出的u,v两向量。
第二步:计算出T向量为了得到T切线向量,我们需要得到x,y,z分量分别相对u向量的变化率。
T = E 2 − 1 . x y z / E 2 − 1 . u = ( 20 / 1 , 0 / 1 , 0 / 1 ) = ( 20 , 0 , 0 ) T=E_{2-1}.xyz/E_{2-1}.u=(20/1,0/1,0/1)=(20,0,0) T=E2−1.xyz/E2−1.u=(20/1,0/1,0/1)=(20,0,0)
(说真的这个除以.u真的没太看懂)
然后单位化上面计算的T向量,这里需要单位化,因为我们不需要T,B,N向量的标量,只要方向。
T = N o r m a l i z e ( T ) = ( 1 , 0 , 0 ) T=Normalize(T)=(1,0,0) T=Normalize(T)=(1,0,0)
注意:在上面这情况下 E 2 − 1 E_{2-1} E2−1的运算都没什么问题,但如果 E 2 − 1 . u E_{2-1}.u E2−1.u=0。这一般是由于美术同学在处理网格映射的u,v决定的。如果 E 2 − 1 E_{2-1} E2−1.u=0,那么我们就使用 E 3 − 1 E_{3-1} E3−1来处理计算求得T。用哪条边来算都没有关系,关键我们认为是相对平面来说,该边的正方向是u向量增量方向的边来使用就可以了。
第三步:计算出法线向量(N)现在我们的切线向量了,我们可以从而得到N向量。以下一般都是求得一个平面的法线向量的做法。
使用边向量 E 2 − 1 E_{2-1} E2−1于 E 3 − 1 E_{3-1} E3−1的叉乘来得到平面的法线。
N = C r o s s P r o d u c t ( E 2 − 1 , E 3 − 1 ) = C r o s s P r o d u c t ( ( 20 , 0 , 0 ) , ( 0 , − 20 , 0 ) ) = ( 0 , 0 , − 400 ) N=CrossProduct(E_{2-1},E_{3-1})=CrossProduct((20,0,0),(0,-20,0))=(0,0,-400) N=CrossProduct(E2−1,E3−1)=CrossProduct((20,0,0),(0,−20,0))=(0,0,−400)
N = N o r m a l i z e ( N ) = ( 0 , 0 , − 1 ) N=Normalize(N)=(0,0,-1) N=Normalize(N)=(0,0,−1)
第四步:计算出副切线向量(B)正如之前所说的,T,B,N向量都互为直角(互相垂直),因为我们可以使用之前两个向量( ( E 2 − 1 或 是 E 3 − 1 ) (E_{2-1}或是E_{3-1}) (E2−1或是E3−1)再与N法线向量)的叉乘来再次算得B向量。
B = C r o s s P r o d u c t ( T , N ) = C r o s s P r o d u c t ( ( 1 , 0 , 0 ) , ( 0 , 0 , 1 ) ) = ( 0 , 1 , 0 ) B=CrossProduct(T,N)=CrossProduct((1,0,0),(0,0,1))=(0,1,0) B=CrossProduct(T,N)=CrossProduct((1,0,0),(0,0,1))=(0,1,0)
第五步:由T,B,N来构建 M w t M_{wt} Mwt矩阵现在我们有T,B,N,我们可以构建 M w t M_{wt} Mwt矩阵了 M w t = ( T x T y T z B x B y B z N x N y N z ) = ( 1 0 0 0 1 0 0 0 − 1 ) M_{wt} = \begin{pmatrix} \begin{array}{} T_x & T_y & T_z\\ B_x & B_y & B_z\\ N_x & N_y & N_z \end{array} \end{pmatrix} = \begin{pmatrix} \begin{array}{} 1 & 0 & 0\\ 0 & 1 & 0\\ 0 & 0 & -1 \end{array} \end{pmatrix} Mwt=⎝⎛TxBxNxTyByNyTzBzNz⎠⎞=⎝⎛10001000−1⎠⎞
噢耶!这就是我们要求得的矩阵了。
栗子P1.TBN
P1.T=t
due P3.u != P1.u
so
t=(P3.xyz-P1.xyz)/(P3.u-P1.u)
t=((4,0,3)-(2,3,1))/(1-0)
t=(2,-3,2)
t=normalized:
t.len=sqrt(2*2+(-3*-3)+2*2)=4.1231056256176605498214098559741
t.normalized=t/t.len
t.normalized=(2,-3,2)/4.1231056256176605498214098559741
t.normalized=
(
0.48507125007266594703781292423224,
-0.72760687510899892055671938634836,
0.48507125007266594703781292423224
)
t.normalized=precision(t.normalized,3)
t.normalized=(0.485,-0.727,0.485)
t.normalized=(0.485,-0.727,0.485)
P1.N=n
n=cross((P3.xyz-P1.xyz),(P2.xyz-P1.xyz))
n=cross((2,-3,2),(-1,-4,1))
//due
//a=[a1,a2,a3]
//b=[b1,b2,b3]
//cross(a,b)=a×b=[a2b3-a3b2,a3b1-a1b3,a1b2-a2b1]
n=((-3)*1-2*(-4),2*(-1)-2*1,1*(-4)-(-3)*(-1))
n=(5,-4,-7)
n=normalized:
n.len=sqrt(5*5+(-4)*(-4)+(-7)*(-7))=9.4868329805051379959966806332982
n.normalized=n/n.len
n.normalized=(5,-4,-7)/9.4868329805051379959966806332982
n.normalized
(
0.52704627669472988866648225740545,
-0.42163702135578391093318580592436,
-0.73786478737262184413307516036763
)
n.normalized=precision(n.normalized,3)
n.normalized=(0.527,-0.421,-0.737)
n.normalized=(0.527,-0.421,-0.737)
P1.B=b
b=cross(t,n)
b=cross((0.485,-0.727,0.485),(0.527,-0.421,-0.737))
//due
//a=[a1,a2,a3]
//b=[b1,b2,b3]
//cross(a,b)=a×b=[a2b3-a3b2,a3b1-a1b3,a1b2-a2b1]
b=(
(-0.727)*(-0.737)-0.485*(-0.421),
0.485*0.527-0.485*(-0.737),
0.485*(-0.421)-(-0.727)*0.527
)
b=(0.739984,0.61304,0.178944)
b=precision(0.739,0.613,0.178)
t=(0.485,-0.727,0.485)
b=(0.739,0.613,0.178)
n=(0.527,-0.421,-0.737)
MAT_TBN=
| t.x, t.y, t.z |
| b.x, b.y, b.z |
| n.x, n.y, n.z |
// usage:
if have sampler2D diffuseTex;
if have float2 uv
if have sampler2D normalMap
if have float3 lightWorldPos
pixel shader diffuse =
fixed4 diffuseColor = tex2D(diffuseTex, uv);
float3 normalTs = tex2D(normalMap, uv); // tangent space normal
float3 lightTs = mul(MAT_TBN, lightWorldPos); // tangent space light
fixed diffuseIntensity = dot(normalTs, lightTs);
return diffuseColor * diffuseIntensity;
创建顶点的切线空间矩阵
现在你肯定为你求得的’魔法’矩阵而兴奋不已吧。但你还没意识到,根本就没有方法可以将平面的数据转到图形显卡中。当我们再屏幕上绘制一个平面是,我们是根据一些确定顺序的顶点来定义平面的,没有所谓的真的一个平面的数据。
所以我们还得需要这么一步。将我们给平面定义的变换矩阵分离到平面中的每个顶点中。使用处理顶点法线的技术的方式来处理即可。你可以用任何你想用的方式来存储T,B向量,这两向量用来计算求得N向量而使用的。剩下的都是一些基础技术的内容。你可以根据你的需求来使用更复杂的使用方式来使用。 (如:我们可以将相对切线空间的法线向量存储到一张纹理中,纹理中的.rgb分别对应法线向量的.xyz即可)
一个简单且通用的方式是:计算出那些所有平面中共享的顶点的向量的平均值向量的方式。 例如: 如图1中的正方形,我们可以生成两个平面的 M w t M_{wt} Mwt矩阵。这里用两个 M w t M_{wt} Mwt矩阵,分别是:平面1,2,3的 M 1 w t M1_{wt} M1wt,平面2,4,3的 M 2 w t M2_{wt} M2wt。
为了计算出顶点V1的T,B,N,我们这么算
V 1. M w t . T = M 1 w t . T / 1 V1.M_{wt}.T=M1_{wt}.T/1 V1.Mwt.T=M1wt.T/1 V 1. M w t . B = M 1 w t . B / 1 V1.M_{wt}.B=M1_{wt}.B/1 V1.Mwt.B=M1wt.B/1 V 1. M w t . N = M 1 w t . N / 1 V1.M_{wt}.N=M1_{wt}.N/1 V1.Mwt.N=M1wt.N/1
我们除以1,是因为V1顶点只用到1个平面中。
顶点V2的T,B,N,这么算
V 2. M w t . T = ( M 1 w t . T + M 2 w t . T ) / 2 V2.M_{wt}.T=(M1_{wt}.T+M2_{wt}.T)/2 V2.Mwt.T=(M1wt.T+M2wt.T)/2 V 2. M w t . B = ( M 1 w t . B + M 2 w t . B ) / 2 V2.M_{wt}.B=(M1_{wt}.B+M2_{wt}.B)/2 V2.Mwt.B=(M1wt.B+M2wt.B)/2 V 2. M w t . N = ( M 1 w t . N + M 2 w t . N ) / 2 V2.M_{wt}.N=(M1_{wt}.N+M2_{wt}.N)/2 V2.Mwt.N=(M1wt.N+M2wt.N)/2
这里除以2是因为顶点V2共享于两个平面中。 注意:T,B,N向量都需要在求得平均值后重新单位化。
集成到伪代码中我将我前两节中所讨论的内容要点都集成到伪代码中。我将尽可能的让伪代码更类似于C/C++,所以你会发现都比较易于理解。
定义: 数据类型:
Vector3,Vector2,Vertex,Face,Matrix3,Mesh- 每个顶点都有两部分。P持有顶点的位置,T持有顶点的纹理坐标。
- 每个平面包含平面中顶点列表中三个顶点的三个索引至。
- Matrix3是个3X3的矩阵,包含三个向量:切线,副切线与法线。
- Mesh(网格)是由许多的顶点与许多的平面中的顶点索引组成。一个网格持有n个顶点,与n个矩阵(顶点的切线转换矩阵)。
- []标记是数组的意思
Vector3 =
{
Float X, Y, Z;
}
Vector2 =
{
Float U, V ;
}
Vertex =
{
Vector3 P ;
Vector2 T ;
}
Face =
{
Integer A, B, C;
}
Matrix3 =
{
Vector3 T, B, N;
}
Mesh =
{
Vertex VertexList[];
Face FaceList[];
Matrix3 TSMarixList[];
}
预定义方法: Normalize(Vector3) - 返回单位化的向量 Cross(Vector3, Vector3) - 返回两个向量的叉乘
标准的向量运行假设为:分量/子分量级别的运算。
伪代码:
TangentSpaceFace描述:TangentSpaceFace方法是计算一个平面的切线空间变换矩阵,并返回一个平面的3X3切线空间变换矩阵。 注意:为了保持简洁,我没去考虑特殊情况的处理:两顶点差值的u分量为0的情况。
Matrix3 TangentSpaceFace(Face F, Vertex3 VertexList[])
{
Matrix3 ReturnValue;
Vertex E21 = VertexList[F.B] - VertexList[F.A];
Vertex E31 = VertexList[F.C] - VertexList[F.A];
ReturnValue.N = Cross(E21.P, E31.P);
ReturnValue.N = Normalize(ReturnValue.N);
ReturnValue.T = E21.P / E21.T.u;
ReturnValue.T = Normalize(ReturnValue.T);
ReturnValue.B = Cross(ReturnValue.T, ReturnValue.N);
return ReturnValue;
}
TangentSpaceVertex
描述:该方法是根据传入指定顶点的索引、平面列表、平面列表对应的切线空间变换矩阵的列表来计算出,指定索引顶点的平均共享切线空间变换矩阵。
Matrix3 TangentSpaceVertex(Integer VertexIndex, Face FaceList[], Matrix3 FaceMatrix[])
{
Integer Count = 0;
Integer i = 0;
Matrix3 ReturnValue = ((0, 0, 0), (0, 0, 0), (0, 0, 0));
ForEach ( Face in FaceList as CurrFace )
{
If(CurrFace.A == VertexIndex or CurrFace.B == VertexIndex or CurrFace.C == VertexIndex)
{
ReturnValue.T += FaceMatrix[i].T;
ReturnValue.B += FaceMatrix[i].B;
ReturnValue.N += FaceMatrix[i].N;
++Count;
}
++i;
}
if (Count > 0)
{
ReturnValue.T = ReturnValue.T / Count;
ReturnValue.B = ReturnValue.B / Count;
ReturnValue.N = ReturnValue.N / Count;
ReturnValue.T = Normalize(ReturnValue.T);
ReturnValue.B = Normalize(ReturnValue.B);
ReturnValue.N = Normalize(ReturnValue.N);
}
return ReturnValue;
}
TangentSpaceMesh
描述:该方法计算一个网格M中的所有顶点的切线空间变换矩阵。
TangentSpaceMesh(Mesh M)
{
Matrix3 FaceMatrix[M.FaceList.Count()];
Interger i;
ForEach Face in M.FaceList as ThisFace
{
FaceMatrix[i] = TangentSpaceFace(ThisFace, M.VertexList);
}
For(i = 0; i 0.0
等价于:
I
n
.
B
>
127.5
I_{n}.B>127.5
In.B>127.5
这就是为何你看到的法线贴图总是偏蓝或偏紫。如图10显示的是一个高度贴图与法线贴图。注意两者有区别。
另一个需要切记,法线贴图总是编码成相对切线空间坐标系下的值,而不是世界坐标系的。回到“为何需要使用切斜空间”那小节,你应该了解这点。
在顶点着色计算光源向量
光源向量都是放在逐顶点计算,在光栅后的像素着色前都会有差值顶点着色器阶段传过来的数据,所以顶点光源向量值会在像素着色得到的是一个顶点之间的插值。虽然这还不是最理想的方案,但相对在游戏设计开发来说效果已经可以掩盖方案的缺陷了。
通过以下几步处理:
第一步:计算光源向量
为了计算得到光源的向量,我们需要用到相对世界坐标系下的光源位置顶点位置的信息。光源向量的计算如下:
L
v
=
L
i
.
P
o
s
−
V
j
.
P
o
s
L_{v}=L_{i}.Pos-V_{j}.Pos
Lv=Li.Pos−Vj.Pos
已知:
L
v
=
光
源
向
量
L_{v}=光源向量
Lv=光源向量
L
i
.
P
o
s
=
光
源
的
世
界
坐
标
位
置
L_{i}.Pos=光源的世界坐标位置
Li.Pos=光源的世界坐标位置
V
j
.
P
o
s
=
顶
点
的
世
界
坐
标
位
置
V_{j}.Pos=顶点的世界坐标位置
Vj.Pos=顶点的世界坐标位置
第二步:将光源向量从世界坐标系转到顶点切线空间坐标系
假设你已经为逐顶点的从世界坐标系转换到切线空间坐标系计算用的矩阵(就是我们前面小节总的
M
w
t
M_{wt}
Mwt)已准备好了,且通过纹理的方式传入到了顶点着色,然后再简单的矩阵乘法一下处理。
L
v
=
M
w
t
×
L
v
L_{v}=M_{wt}\times L_{v}
Lv=Mwt×Lv
第三步:单位化光源向量
然后是光源向量的单位化。
L
v
=
N
o
r
m
a
l
i
z
e
d
(
L
v
)
L_{v}=Normalized(L_{v})
Lv=Normalized(Lv)
光源向量先是经过前面的纹理阶段后在继续传到像素着色器。这里有个非常大的优点,就是这么做的话,光源向量在像素着色阶段得到的将是硬件处理(通过所在平面中相关三角顶点之间的插值)的插值(所以消耗是很小的)。 注意:在像素着色器中传入的光源向量基本上就不需要再单位化了,因为在我们应该的大多数情况下,这小小的误差几乎没啥影响了。
像素着色器
像素级别的计算漫反射亮度
第一步:获取光源向量
与获取法线纹理不太一样,你需要从纹理阶段加载纹理坐标(纹理阶段是啥?好了一下微软的DX文档,貌似是个DX7的东西,汗,这么老旧的东西吗?没懂,反正我所理解他所说的纹理阶段,就是纹理数据的意思了)。这个纹理阶段通常都不指定任何纹理。光源向量肯定是存储在顶点着色器传过来的纹理坐标再到像素着色器中使用的。你可以PS1.4使用texcrd指令来完成这个功能。
L
v
=
t
e
x
c
r
d
(
V
x
)
L_{v}=texcrd(V_{x})
Lv=texcrd(Vx)
(texcrd指令已找不到说明文档,
L
v
L_{v}
Lv就当做是光源向量)
在这个阶段,不需要再重新单位化光源向量了。我们可以接受这个误差,就当做是已单位化来使用了。
第二步:从纹素获取法线数据
假设法线映射数据在之前的纹理阶段已定义好了,从纹素中加载法线(表面朝向),在缩放、偏移其值到-1.0 ~ 1.0范围。如果你还记得,RGB数据范围在0 ~ 255,在传入到像素着色器后它们将转换到0.0 ~ 1.0范围了。在PS1.4版本中颜色数据可以使用texld指令来加载。
R
1
=
t
e
x
l
d
(
V
y
)
R_{1}=texld(V_{y})
R1=texld(Vy)
S
v
=
(
R
1
−
0.5
)
∗
2.0
S_{v}=(R_{1}-0.5)*2.0
Sv=(R1−0.5)∗2.0
(texld我也没找到文档,当作是tex2D来使用吧,至少得有tex2D(Sampler2D,UV)两个参数吧,所以这个没太懂,
R
1
R_{1}
R1就是法线贴图中对应
V
y
V_{y}
Vy纹理坐标的采样值,
S
v
S_{v}
Sv是法线数据范围正常化后的值)
第一行加载被编码为颜色数据的法线。第二行将颜色数据转换回正常值。
第三步:计算表面亮度
现在我们已知:光源向量与纹素级别的表面法线,我们可以简单使用dot3方法来算出表面亮度
B
i
=
d
o
t
3
(
S
v
,
L
v
)
B_{i}=dot3(S_{v},L_{v})
Bi=dot3(Sv,Lv)
光源向量与表面法线都单位化的话,那么dot点乘出来的结果将会是0.0 ~ 1.0 范围的(这里我个人觉得作者弄错了,应该是-1.0 ~ 1.0范围,其实作者挺多笔误的,我在翻译时都更正了一下)
第四步:使用diffuse漫反射颜色来调节一下颜色
获得像素输出的颜色之前,我们仅仅使用diffuse纹理颜色与亮度相乘一下即可。
C
o
l
o
r
o
u
t
=
D
i
×
B
i
Color_{out}=D_{i} \times B_{i}
Colorout=Di×Bi
D
i
D_{i}
Di是漫反射纹理的采样值
B
i
B_{i}
Bi是上文的亮度值
最终这就是我们凹凸纹理映射最终的表面值了。
都集成到顶点与像素着色器中
在这小节,我将之前讨论的内容都写到伪代码中。我将使用的是VS2.0与PS1.4版本的shader来编写。当你了解后,你将可以按你自己想要实现的方式来编写。
顶点着色器
vs.2.0
// c0 - c3 -> has the view * projection matrix /// c0 - c3 是 v * p 矩阵
// c14 -> light position in world space /// c14 是光源在世界坐标的位置
dcl_position v0 // Vertex position in world space /// 顶点在世界坐标的位置
dcl_texcoord0 v2 // Stage 1 - diffuse texture coordinates /// 阶段1 - 漫反射的坐标
dcl_texcoord1 v3 // Stage 2 - normal map texture coordinates /// 阶段2 - 法线贴图的纹理坐标
dcl_tangent v4 // Tangent vector /// 切线向量
dcl_binormal v5 // Binormal vector /// 副切线向量
dcl_normal v6 // Normal vector /// 法线向量
// Transform vertex position to view /// 将顶点位置转换到相机空间下
// space -> A must for all vertex shaders
m4x4 oPos, v0, c0 /// 将matrixV * v0的值赋值到oPos
mov oT0.xy, v2 /// 将diffuse漫反射的纹理坐标赋值到oT0.xy
// Calculate the light vector /// 计算光源向量
mov r1, c14 /// 将世界坐标的光源位置赋值到 r1
sub r3.xyz, r1, v0 /// 使用世界坐标的光源位置 - 当前顶点的世界坐标位置 = 世界坐标下的光源向量
// Convert the light vector to tangent space /// 将光源向量转换到切线空间
m3x3 r1.xyz, r3, v4 /// 使用切线向量当做3x1的列向量与r3的光源世界坐标向量相乘 = 切线坐标系下的r1.xyz的光源向量
// Normalize the light vector /// 单位化光源向量
nrm r3, r1 /// 将r1单位化后赋值到r3
// Move data to the output registers /// 将输出数据到oT2.xyz
mov oT2.xyz, r3
像素着色器
ps.1.4
def c6, -0.5f, -0.5f, 0.0f, 1.0f
texld r0, t0 // Stage 0 has the diffuse color
// I am assuming there are no special tex
// coords for the normal map.
// Stage 1 has the normal map
texld r1, t0
// This is actually the light vector calculated in
// the vertex shader and interpolated across the face
texcrd r2.xyz, t2
// Set r1 to the normal in the normal map
// Below, we are biasing and scaling the
// value from the normal map. See Step 2 in
// section 2.5. You can actually avoid this
// step, but i'm including it here to keep
// things simple
add_x2 r1, r1, c6.rrrr
// Now calculate the dot product and
// store the value in r3. Remember the
// dot product is the brightness at the texel
// so no further calculations need to be done
dp3_sat r3, r1, r2
// Modulate the surface brightness and diffuse
// texture color
mul r0.rgb, r0, r3
5.总结
在这篇文章我们涵盖了不少的内容。最终希望你能对切线空间有所了解。
这里有些工具与文章能帮助你了解这篇文章没有讲解的部分。
- NVidia的某个人士制作的完整的SDK,叫:NVMeshRender,渲染目标在切线空间矩阵中生成(什么鬼,没理解)。你应该去尝试一下。 http://developer.nvidia.com/object/NVMeshMender.html [在写这边文章时,这个链接已坏]
- Adobe Photoshop的插件,可以提取高度贴图与创建法线贴图额。 http://developer.nvidia.com/object/photoshop_dds_plugins.html
- 最后,如果你还想更深入了解逐像素光照,可以看看http://www.gamedev.net/reference/articles/article1807.asp。这是个很好入门的地方。顺便说说,这是带有3部分的系列文章中的第1部分。你可以以“… Part II”了解后续文章。
扩展阅读
- Normal Map(文中:切线空间中的第二种方法就是我翻译的这篇的方法) (第一种方法是:Normal Map from tangent space to world space—将法线从切线空间转到世界空间在于其他在世界空间在的向量来运算) (第二种方法是:Except Normal Map from world space to tangent space—将除了切线贴图以外的所有世界坐标系下的向量转到切线空间下运算) (这篇扩展阅读的文章还有列出了求TBN得代码,可以参考) (在我的自己译文后总结的伪代码中有个:mat_WT矩阵,就是TBN的逆矩阵,而TBN求法在这篇扩展阅读有代码,求TBN逆矩阵也是很简单,因为TBN刚好是个:正交基矩阵,正交基矩阵有个特性,其转置矩阵等于其逆矩阵,所以,
T
B
N
i
n
v
=
t
r
a
n
p
o
s
e
(
T
B
N
)
TBN_{inv}=tranpose(TBN)
TBNinv=tranpose(TBN), 即:
M
T
=
M
−
1
M^{T}=M^{-1}
MT=M−1)
- 为什么要有切线空间(Tangent Space),它的作用是什么?
- 切线空间(Tangent Space) 的计算与应用
- Unity3d中Shader的一些关于矩阵变换的基本信息