切线空间

偶然发现一篇不错的关于:切线空间的文章,于是着手翻译,这也是我第一次翻译这么长的文章,如果文中翻译不当,或是我标志没懂的地方,欢迎大家指正、指点。

自己总结

先来个译文后总结吧,因为发现这篇文章的: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:一个平面的切线轴

如图1,它能助你可视化的了解切线空间坐标系。它显示了一个由四个顶点和两个面组成的四边形,假设有一个简单的纹理采样应用到其上。

图2显示了顶点1, 2, 3定义的立方体上的面的切线空间轴。我们再次假设这个立方体有一个非常简单的纹理采样应用其上。

在左下角,你还可以看到X、Y、Z标记的世界空间坐标系的轴。

u、v、n轴表示u、v、n值在整个面上增量方向,正如x、y、z值表示世界空间坐标系中x、y、z值增量的方向一样。

图2:立方体中的某个平面的切线空间
图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:一个面

假设图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)

如之前所说

  1. 切线向量(u轴)将指向平面U分量的增量方向。
  2. 副切线向量(v轴)总是指向V分量的增量方向。
  3. 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) E21=V2V1=(200,2020,00)&(10,00)=(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) E31=V3V1=(00,020,00)&(00,10)=(0,20,0)&(0,1)

E 2 − 1 E_{2-1} E21 E 3 − 1 E_{3-1} E31实际上是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=E21.xyz/E21.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} E21的运算都没什么问题,但如果 E 2 − 1 . u E_{2-1}.u E21.u=0。这一般是由于美术同学在处理网格映射的u,v决定的。如果 E 2 − 1 E_{2-1} E21.u=0,那么我们就使用 E 3 − 1 E_{3-1} E31来处理计算求得T。用哪条边来算都没有关系,关键我们认为是相对平面来说,该边的正方向是u向量增量方向的边来使用就可以了。

第三步:计算出法线向量(N)

现在我们的切线向量了,我们可以从而得到N向量。以下一般都是求得一个平面的法线向量的做法。

使用边向量 E 2 − 1 E_{2-1} E21 E 3 − 1 E_{3-1} E31的叉乘来得到平面的法线。

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(E21,E31)=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}) (E21E31)再与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=100010001

噢耶!这就是我们要求得的矩阵了。


栗子

求TBN的栗子图

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 < M.VertexList.Count(); i++)
	{
		M.TSMarixList[i] = TangentSpaceVertex(i, M.FaceList, FaceMatrix);
	}
}

4.练习中的问题

上述中就是我们的所有的理论知识点,但通常来说你从理论到实践中验证是总会遇到一些问题。同样在我们生成切线空间的过程中一样会遇到问题。

多数的问题的出现在于对象的纹理坐标张开处理阶段时。多数的美术同学为了节省纹理占用的内容而这么做。最好的方案是让他(美术同学)修复一下纹理的末端的坐标映射问题。在多数情况我们都会遇到平面的问题,且我们需要解析如何去修复这些问题。多数的解决方案都不够完美的,我们都尽力避免这些方案。

倒置的纹理映射

如图4。有那么两个相邻平面分别定义于顶点1,2,3和2,6,3且v通道分量相反的平面(垂直翻转)。这将导致在逐像素的光照着色计算输出错误的结果。
(为何错误,因为:如图4中,我们翻转的是B轴,即:uv中的v分量,先看看右边的N轴轴向,向屏幕里头的方向的,而左边的是向外头的,N轴就是我们的平面的法线轴,法线都不一样了,那么光照计算时的平面放射方向就肯定不一样了,这个问题比较明显)
(以下所说的“翻转”你都可以理解为,为了达到某个轴达到于之前轴向翻转到相反的方向而旋转整个坐标轴的意思,其实这个不算是翻转,算是旋转,真的某个轴翻转的话,就应该是某个轴向直接乘以-1)
图4:4个平面。有两个是直接纹理坐标映射的,另两个是v通道分量相反的映射坐标
图4:4个平面。有两个是直接纹理坐标映射的,另两个是v通道分量相反的映射坐标

这么一组纹理映射将会导致一个问题,如果3个向量的生成仅从纹理坐标得来。如果你这么做,那么法线将会相反,由于副切线指向了相反的方向。

镜像纹理映射

图5:一个平面
图5:一个平面
(这次我们翻转的是T轴,然后我们也发现,N轴同样翻转了,一样会有光照问题)

OK,我们绕开的解决一个不是真的问题的问题(好绕)。但如果T向量翻转,那将会是镜像(水平翻转)的纹理映射了?

首先是以v通道分量来生成副切线向量。
我们从倒置纹理映射中(垂直翻转)得知,法线也将被翻转。一个简单的方法就是避免翻转向量。该而从法线与副切线的叉乘来生成切线向量。

另一个方法是:NVidia的某个家伙的方法:复制边上的共享倒置纹理坐标的方式(好绕,没懂)。即使这能再通过一个平面来创建一个有效的世界转换到切线空间的变换矩阵,但我不认为这方法能再计算光照时能行得通。

要修复上面的问题就是如果你仅仅只翻转(这里翻转就是乘以-1的意思)一个纹理的坐标,但如果你的代码需要在两个相邻平面同时倒置映射(垂直翻转)与镜像映射处理呢?这种情况,你就只能手动去翻转每个向量了。即使这种方法不太优雅,但也只有这种解决方案了。
(虽然我能理解作者的意思,但感觉作者说了N多废话,我就照着先翻译过来吧,其实整片文章废话的地方多的去了,但估计作者目标也是向让读者更清楚的了解,-_-!)

检测两个相邻平面是否纹理坐标翻转了方法很简单。

一旦切线与副切线向量都生成好了…

  1. 检测切线向量的平面方向是否相同。这可通过点乘的正负来判断。
  2. 然后得到两个面的法线。再检查面向是否相同。
  3. 如果第1第2步的计算检查结果都是正的,或都是负的,那么你的纹理坐标u通道分量方向(T轴方向)没有问题。
  4. 然后使用相同的方法来检测副切线的v通道分量反向(B轴方向)有没翻转。

圆柱体的映射

在这里插入图片描述图6:一个圆柱体与其背后的边缘闭合连接
图6:一个圆柱体与其背后的边缘闭合连接

另一个常见的问题是柱面纹理映射。如图6中,你可以看到一个柱面的纹理采样应用其上。第二个图同样是闭合的圆柱,显示了两个顶点边缘纹理末端的相交处。穿过边缘E1上的顶点的u通道将是1.0,并且边缘E2上的顶点的值将是0.0。这与柱面上其余的顶点方向相反(虽然我知道解决方案,但原文我也没看懂)。这也将导致切空间矩阵的向量在E1和E2之间的面上混合错误(虽然我知道解决方案,但原文我也没看懂)。

解决方法是复制沿着E2边缘的顶点,并将其叠合在E1所在的顶点来创建一个新的平面。这些新的顶点有些与旧顶点(E1边上的顶点)完全相同的位置。E2的旧顶点将不再属于E1与E2之前的面。注意一个重点:这些新建的顶点都是临时的。它们仅仅用于世界空间转换到切线空间的矩阵运算时用到。它们都不被用于渲染,所以原来的模型、纹理的渲染将不会有什么变化。

在生成纹理坐标时,可以将伪纹理坐标分配给临时顶点。这些伪坐标应该确保切线向量指向同一个方向。在上面的示例中,沿着E2的临时顶点可以具有1.05的u值(假设E1上的顶点具有1.0的u值)。

另一种方法是使用我们在镜像映射中使用的相同技巧。我们可以从V分量和面法线生成矢量。

(上面两段没懂,原因同上,-_-!)
(其实吧,我理解作者说的:倒置是指u轴或是T轴的处理,而镜像是指v轴或是B轴的处理,但觉得还是作者说得还不够详细)

第三个解决方案是拉伸相邻空间之间的切线空间计算。在上面的示例中,E1和E2之间的面将通过从左E1拉伸向量来计算它们的切空间向量。

球体

图7:球体,也不兼容切线空间的生成过程
图7:球体,也不兼容切线空间的生成过程

有一段时间,我有一个任务,在每个球体上应用逐像素的光照着色。如果看一下图7中球体建模的方式,您将认识到顶点在顶部和底部彼此更接近,直到它们最终连接单个顶点。这将使顶部顶点切空间矩阵在应用于转换到切线空间向量时将完全错误,因为相邻面上的切线空间向量将彼此无效(为何无效:因为所有相邻点的位置都完全相同,那么使用两个点的差值来作为轴向向量的方式就不可靠了,因为这些向量都为零向量)。

图8:一个几何球体(3D Max中的GeoSphere)
图8:一个几何球体(3D Max中的GeoSphere)

在这种情况下,你的美术同学刚刚对球体建模错误。您应该回过头来要求他创建这个球体作为地理球体,就像3DStudioMax中所知道的那个GeoSphere。在地理球体(GeoSphere)中,顶点将均匀地分布在球体上,生成切线空间矩阵应该没有任何问题。


4.在顶点与像素着色器中的逐像素光照

在本节中,我将把之前的所有小节中讨论的所有枯燥的数学都放到一些实际的用途上。

我假设你理解顶点和像素着色器的基本知识。我还假设您对DirectX 9 API很熟悉,因为我不会在这里尝试解释D3D9 API中的函数。另一点要记住的是,我不打算在这里教着色器。本节的目的是理解上述解释的实际用途。

一下需要说明的是,当我提到光时,指的是点光源。

在这里我只讨论计算一个表面光的漫反射分。为了让灯光看起来逼真,还需要做其他一些计算。这些将包括衰减,镜面,以及许多其他组件。这些超出了本文的范围,您可以找到几篇介绍这些主题的好文章。

什么是逐像素光照

先前的光照(颜色和亮度)是在顶点级别计算的,然后通过表面进行插值(渲染管线中的像素阶段插值处理)。如果模型具有极高的多边形数量(就是:极高精度的模型,N多顶点N多边,但基本我们的3D图形基本不会这么干,因为消耗太大,除了在一些影视的离线渲染时,才可能这么干),那么这工作得很好,但是大多数游戏需要将多边形数量保持在最小值,以便最大化它们的运行时效率(提高FPS)。在几乎所有的情况下,开发商只对玩家角色使用顶点光照处理,而场景的其余部分则使用光照映射图(场景的静态纹理烘焙)通过纹理静态地光照。光照映射图工作得非常好。您可以使用软阴影,以及非常精确的光照计算,但是一个非常严重的缺点是,它只能在运行时针对对象是静态的情况下才能工作。在游戏中这正好与玩家所期望的相反。玩家希望一切都能移动和都能被破坏(还有很多其他的……)。因此,大多数游戏设计团队在处理动态对象与非动态对象进行了权衡(权衡性能与效果的取舍,因为大多数情况下,效果好的总是消耗相当高,你懂的)。在许多情况下,动态对象具有较高的多边形数量,因此它们的顶点光照工作良好。

随着显卡硬件的日益更新越来越强大时,我们也得出来新的处理逐像素光照的算法处理。虽然也不是真实的效果(不是真实的物理处理方式,只是模拟),但只要看清来足够逼真就够了。

现在大多API都内置了顶点级别的处理了。但逐像素的光照还是需要我们实现。逐像素光照,就是每个像素点(这里的像素点叫做:纹素,会更适合)都有对应的法线。所以我们现在就可以实现高精度的凹凸表面的效果而不用考虑是否需要高模(极高的多边形数量的模型的简称)。

光如果影响表面与传统顶点光照的局限性

我们之前描述的各种计算方案,现在让我们快速的回顾一下光是如果影响一些表面的。
图9:一个表面的漫反射
图9:一个表面的漫反射

光影响一个表面的漫反射可以通过以下的一个公式来模拟。

I L = c o s ( θ ) I_{L}=cos(θ) IL=cos(θ)

已知:
I L = 灯 光 的 强 度 I_{L}=灯光的强度 IL=
θ = 表 面 的 法 线 与 光 源 方 向 的 夹 角 θ=表面的法线与光源方向的夹角 θ=线

如图9。显示了光源的位置是如何影响表面的亮度的。L表示光源的位置,N是表面的法线。我们假设表面的那个点不变(光源与表面接触的那边点),然后将L光源的位置调整到与表面呈 9 0 o 90^{o} 90o,那表面肯定会变得更暗。

现在问题是:在传统光照中一个表面的亮度是有顶点级别决定的。你看图9中的一个平面的纹理映射,即使表面映射了凹凸纹理,但不管这个表面的每个纹素是怎么样的,都只能整个便面的光照亮度统一变换(即:只能控制到以:面,为单位的细粒度的控制程度,你控制不了逐像素级别的)。当你的表面足够小的时候,这种方案可能还有点用(就是极高模的意思)。

所以这时就是逐像素光照到来的意义了。逐像素的亮度决定于每个纹素,而不是顶点,因为很明显可以提高逼真度(因为可控制的细粒度为:像素)。

凹凸纹理映射是如何工作的

图10:一个带有模拟高度映射图于右边的法线映射图
图10:一个带有模拟高度映射图于右边的法线映射图

当处理一个凹凸纹理映射时,你可理解为是:漫反射纹理与法线纹理映射的结合使用。

一个法线映射纹理(法线贴图)的内容决定于一个表面逐像素的朝向。在这之前,我们别混淆了法线贴图、高度贴图和凹凸贴图,正如我们使用某些3D软件中的感念,如:3D MAX中的。高度贴图你也可以用于凹凸贴图,但在使用前,必须将其先转为一张法线贴图。


现在你也许有些疑惑,如:一张贴图是如果将持有RGB的数据编码成浮点的x,y,z数据的。这很简单。一个单位化向量总有x,y,z。x,y,z的值总会在-1.0 ~ 1.0。一张24位深的贴图持有RGB的分量数据范围是0 ~ 255。浮点的x分量我们将存储在Red通道(红色通道),y分量在Green通道(绿色通道),z分量在Blue通道(蓝色通道)。再将x,y,z存储到RGB通道之前,将值经过缩放(乘除)、偏移(加减)来调整到0 ~ 255的数据范围。

例如:让我们想象一下某个点的法线向量的表示

V n = ( V n . x , V n . y , V n . z ) = ( 0.0 , 0.0 , 1.0 ) V_{n}=(V_{n}.x,V_{n}.y,V_{n}.z)=(0.0,0.0,1.0) Vn=(Vn.x,Vn.y,Vn.z)=(0.0,0.0,1.0)

在将 V n . x , V n . y , V n . z V_{n}.x,V_{n}.y,V_{n}.z Vn.x,Vn.y,Vn.z存储到法线贴图的r,g,b通道数据之前,我们要缩放、偏移一下处理,如下:

I n = ( I n . R , I n . G , I n . B ) = ( V n ∗ 127.5 ) + 127.5 = ( 128 , 128 , 255 ) I_{n}=(I_{n}.R,I_{n}.G,I_{n}.B)=(V_{n}*127.5)+127.5=(128,128,255) In=(In.R,In.G,In.B)=(Vn127.5)+127.5=(128,128,255)

将上述的步骤在像素着色器中反向处理的话,就可以种法线贴图的RGB数据中得到法线数据。我们再根据像素的这个点的表面法线与光源的单位化后的向量的的到点乘值。两个单位化后的向量等同于他们之前的夹角的cos,而且这个值就不需要其他额外的计算了,这个值就是可以作为这个纹素对应的亮度了。

为何法线贴图看起来总是偏蓝色呢?

因为一个表面法线指向不可能指向背面(相对这个切线空间中,这个面的法线,总是向前的)

V n . z &gt; 0.0 V_{n}.z&gt;0.0 Vn.z>0.0

等价于:

I n . B &gt; 127.5 I_{n}.B&gt;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.PosVj.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=(R10.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.总结

在这篇文章我们涵盖了不少的内容。最终希望你能对切线空间有所了解。

这里有些工具与文章能帮助你了解这篇文章没有讲解的部分。

  1. NVidia的某个人士制作的完整的SDK,叫:NVMeshRender,渲染目标在切线空间矩阵中生成(什么鬼,没理解)。你应该去尝试一下。
    http://developer.nvidia.com/object/NVMeshMender.html
    [在写这边文章时,这个链接已坏]
  2. Adobe Photoshop的插件,可以提取高度贴图与创建法线贴图额。
    http://developer.nvidia.com/object/photoshop_dds_plugins.html
  3. 最后,如果你还想更深入了解逐像素光照,可以看看http://www.gamedev.net/reference/articles/article1807.asp。这是个很好入门的地方。顺便说说,这是带有3部分的系列文章中的第1部分。你可以以“… Part II”了解后续文章。

扩展阅读

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值