本篇文章主要讲解计算机图形学中切线空间是如何计算的,且会以法线贴图的例子来验证切线空间是否计算正确,以及展现切线空间的用途.
本文需要读者掌握一定的 3D 坐标空间变换和简单光照相关的知识,以及法线贴图的基本知识(但切线空间不仅仅只用于法线贴图)。
切线空间 (Tangent Space) 与 世界空间 (World Space) 和 观察空间 (View Space) 一样,都是一个坐标空间,它是由顶点所构成的平面的 UV 坐标轴以及表面的法线所构成,一般用 T (Tangent), B (Bitangent), N (Normal) 三个字母表示,即切线,副切线,法线, TT 对应 UV 中的 UU, BB 对应 UV 中的 VV,下图是切线空间的示意图:
这里可能会有一个疑问,就是为什么 TT 对应 UV 中的 UU, BB 对应 UV 中的 VV 。理论上,只要 TT 和 BB 垂直且都位于三角形的平面内,就可以达到使用切线空间的目的,因为这样我们总可以把所有需要的数据变换到同一个坐标空间下,但由于我们知道 UV 坐标的值,所以用 UV 坐标来对应 TT 和 BB 计算出数据了。
要理解为什么要有切线空间,可以从法线贴图入手。众所周知,绝大部分的法线贴图,颜色都是偏蓝色的,这是因为法线贴图中存储的法线向量大部分都是朝向或者接近 z 轴的,即 (0,0,1)(0,0,1),换算到 RGB 中,就是偏向蓝色,即 (0.5,0,5,1)(0.5,0,5,1) (后面的 Shader 中有算法),这种贴图就是切线空间 (Tangent Space)下的贴图。这显然存在一个问题,想象一个位于世界坐标原点且没有进行任何变换的立方体,表面法线方向就有 6 个,因为有 6 个不同朝向的面(确切的说,可能是 12 个面,因为一个矩形一般由两个三角形组成),而且每个面完全相同,所以这时候我应该只需要一个面的法线贴图就可以了。但其实这时再用这种偏蓝色的法线贴图就不行了,因为立方体的上表面在世界空间的法线方向为 (0,1,0)(0,1,0),而在法线贴图中采样出来的法线基本都是接近于 (0,0,1)(0,0,1) 的,使用错误的法线会得到错误的光照结果。所以这时候需要做一张包含立方体所有面的法线信息的法线贴图,也就是模型空间 (Object Space)下的法线贴图,而这种贴图看起来就不单单是偏蓝色了,而是包含了多种颜色。
这样看起来好像也没什么问题,但其实用切线空间下的法线贴图要比用模型空间下的法线贴图要有一些优势:
综上所述,一般的法线贴图都是使用切线空间的,而直接使用切线空间下的法线贴图又会出现之前提到的立方体的那个问题,所以我们在使用前需要先进行切线空间相关的变换,把所需要的数据变换到同一个坐标空间下再进行计算(可以全部变换到世界空间也可以全部变换到切线空间)。
要进行切线空间相关的计算,需要先求出构成切线空间三个轴的单位基向量,然后就可以构造出从切线空间变换到世界空间的矩阵,从而进行之后的计算。
切线空间的计算可以通过前面的示意图来理解,这里为了方便,再放一次:
设:
则由图和共面向量基本定理可知:
观察这两个等式,我们发现这其实可以写成矩阵乘法的形式,如下所示:
如果你求解一下等号右边的矩阵乘法,你就会发现,他就是我们在上面得到的等式。根据这个矩阵形式的等式,我们不难求解 TB 矩阵,只需要两边同时左乘 ΔUΔV 的逆矩阵,再进行计算即可,步骤如下:
逆矩阵的计算公式为 矩阵的行列式的值的倒数再乘以它的伴随矩阵 (Adjugate Matrix, 如果对这些概念不熟悉需要读者自行查阅),其实伴随矩阵的求解并不容易,不过 二阶矩阵的伴随矩阵 有一个简单的公式,即 主对角线的元素互换,副对角线的元素乘以 −1 ,所以最终结果如下所示:
似乎我们还缺少 E1和 E2 的信息,但其实这个信息是已知的,因为他们就是三角形的两个边,而三角形的顶点坐标是我们知道的,所以求出 T 和 B 所需的数据我们都已经有了,只需要代入公式就可以了。
设:
则:
B 也可以如此求解,但其实只需要用 T 和 法线向量 叉乘 即可。
因为 E1 和 E2是用顶点坐标表示的,而 U 和 V 是纹理坐标,他们的坐标单位是不同的,所以我们求出的结果自然不太可能是已经归一化了的,而我们使用坐标空间转换矩阵的时候需要的是归一化的坐标,所以我们需要进行归一化。
本节将以一个法线贴图的例子,来展示切线空间是如何工作的,在这个例子中,我只计算了漫反射等颜色(因为除了法线贴图外我只找到一张漫反射的贴图,但足够演示用了,不过光照效果看起来未必会很好),下面两张图是我使用的漫反射贴图和法线贴图:
为了方便展示,我准备了一个立方体的顶点数据,一共有36个顶点(6个面,每个面2个三角形),为了这篇文章的编写方便,我采用直接绘制顶点而非索引的方式,并且之后的一些计算会有些暴力。
36个顶点数据如下所示,每一行分别为顶点坐标(3个),法线向量(3个),以及纹理坐标(2个),每6行为一个面。
float vertices[] = {
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f,
0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 1.0f, 0.0f,
0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 1.0f, 1.0f,
0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 1.0f, 1.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.0f, 1.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,
0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f,
0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f,
-0.5f, 0.5f, 0.5f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f,
-0.5f, 0.5f, -0.5f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f,
-0.5f, -0.5f, -0.5f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f,
-0.5f, -0.5f, -0.5f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f,
-0.5f, -0.5f, 0.5f, -1.0f, 0.0f, 0.0f, 0.0f, 0.0f,
-0.5f, 0.5f, 0.5f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f,
0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,
0.5f, -0.5f, -0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f,
0.5f, -0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f,
-0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f, 0.0f, 1.0f,
0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f, 1.0f, 1.0f,
0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f,
0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f,
-0.5f, -0.5f, 0.5f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f,
-0.5f, -0.5f, -0.5f, 0.0f, -1.0f, 0.0f, 0.0f, 1.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f,
0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f,
0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f
};
|
|
我们还要给每一行再增加6个数据,即 TT 和 BB 各 3 个坐标。上面一共有 288 个浮点数,让我直接再写 216 个会累死我的,所以切线空间的数据我直接用代码算出来了,实际使用过程中也许在导入模型的时候就可以直接导入切线空间了,当然没有也没关系,因为我已经讲了如何计算切线空间,并且这里也给出了一个例子。下面的代码就是计算切线空间的代码,就如之前说的,很暴力,我并没有多写几个循环来减少几行代码,何必呢。
float tbnFloats[216]; // 36 个顶点的切线和副切线向量一共有 216 个浮点数
// 一个立方体一共有 12 个三角形面(每 2 个构成一个立方体面)
for (int i = 0; i < 12; ++i)
{
Vector3 tbn;
int firstIndex = i * 24; // 三角形第 1 个顶点坐标起始索引
int secondIndex = firstIndex + 8; // 三角形第 2 个顶点坐标起始索引
int thirdIndex = secondIndex + 8; // 三角形第 3 个顶点坐标起始索引
// 求得一个三角形的三个顶点坐标
Vector3 pos1(vertices[firstIndex], vertices[firstIndex + 1], vertices[firstIndex + 2]);
Vector3 pos2(vertices[secondIndex], vertices[secondIndex + 1], vertices[secondIndex + 2]);
Vector3 pos3(vertices[thirdIndex], vertices[thirdIndex + 1], vertices[thirdIndex + 2]);
// 求得一个三角形的三个顶点对应的 UV 坐标
Vector2 uv1(vertices[firstIndex + 6], vertices[firstIndex + 7]);
Vector2 uv2(vertices[secondIndex + 6], vertices[secondIndex + 7]);
Vector2 uv3(vertices[thirdIndex + 6], vertices[thirdIndex + 7]);
// 求出三角形的两条边的向量以及 UV 坐标之间的差向量,用于代入公式
// 需要注意的是,当表示 UV 坐标时,x 对应 U,y 对应 V
Vector3 edge1 = pos2 - pos1;
Vector3 edge2 = pos3 - pos1;
Vector2 deltaUV1 = uv2 - uv1;
Vector2 deltaUV2 = uv3 - uv1;
// 计算切线和副切线向量
// 其实这里就是套用上面求出来的公式
Vector3 tangent;
Vector3 bitTangent;
GLfloat f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
tangent.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
tangent = glm::normalize(tangent);
bitTangent.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitTangent.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitTangent.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
bitTangent = glm::normalize(bitTangent);
// 将每个三角形顶点的切线和副切线数据放到数组里
int startTBNIndex = i * 18;
tbnFloats[startTBNIndex + 0] = tangent.x;
tbnFloats[startTBNIndex + 1] = tangent.y;
tbnFloats[startTBNIndex + 2] = tangent.z;
tbnFloats[startTBNIndex + 3] = bitTangent.x;
tbnFloats[startTBNIndex + 4] = bitTangent.y;
tbnFloats[startTBNIndex + 5] = bitTangent.z;
tbnFloats[startTBNIndex + 6] = tangent.x;
tbnFloats[startTBNIndex + 7] = tangent.y;
tbnFloats[startTBNIndex + 8] = tangent.z;
tbnFloats[startTBNIndex + 9] = bitTangent.x;
tbnFloats[startTBNIndex + 10] = bitTangent.y;
tbnFloats[startTBNIndex + 11] = bitTangent.z;
tbnFloats[startTBNIndex + 12] = tangent.x;
tbnFloats[startTBNIndex + 13] = tangent.y;
tbnFloats[startTBNIndex + 14] = tangent.z;
tbnFloats[startTBNIndex + 15] = bitTangent.x;
tbnFloats[startTBNIndex + 16] = bitTangent.y;
tbnFloats[startTBNIndex + 17] = bitTangent.z;
}
|
|
接着我们将两个数组合并成一个新的顶点数组:
float finishVertices[504];
// 一共 36 个顶点,按照特定顺序合并即可
for (int i = 0; i < 36; ++i)
{
int finishStartIndex = i * 14;
int verticesStartIndex = i * 8;
int tbnStartIndex = i * 6;
finishVertices[finishStartIndex + 0] = vertices[verticesStartIndex + 0];
finishVertices[finishStartIndex + 1] = vertices[verticesStartIndex + 1];
finishVertices[finishStartIndex + 2] = vertices[verticesStartIndex + 2];
finishVertices[finishStartIndex + 3] = vertices[verticesStartIndex + 3];
finishVertices[finishStartIndex + 4] = vertices[verticesStartIndex + 4];
finishVertices[finishStartIndex + 5] = vertices[verticesStartIndex + 5];
finishVertices[finishStartIndex + 6] = vertices[verticesStartIndex + 6];
finishVertices[finishStartIndex + 7] = vertices[verticesStartIndex + 7];
finishVertices[finishStartIndex + 8] = tbnFloats[tbnStartIndex + 0];
finishVertices[finishStartIndex + 9] = tbnFloats[tbnStartIndex + 1];
finishVertices[finishStartIndex + 10] = tbnFloats[tbnStartIndex + 2];
finishVertices[finishStartIndex + 11] = tbnFloats[tbnStartIndex + 3];
finishVertices[finishStartIndex + 12] = tbnFloats[tbnStartIndex + 4];
finishVertices[finishStartIndex + 13] = tbnFloats[tbnStartIndex + 5];
}
|
|
这样我们就有一个新的包含 504 个浮点数的数组了,把数组抽象成行列的形式,每个顶点一行,每一行从左到右的形式是这样的:顶点坐标(3个),法线向量(3个),以及纹理坐标(2个),切线向量(3个),副切线向量(3个)。
但其实我这里多了一步,就是当我们求出切线后,只需要让其和三角形表面法线 叉乘 即可,因为他们都是互相垂直的,不过我这里没这么写。
这个例子使用 OpenGL 编写,我没有全部给出代码,比如如何将这些数据传给 Shader 等,这些对于本篇文章并不重要,也不是本篇文章所要讲的,我在这里会直接给出相关的 Shader 片段,我觉得这就足够了。例子里只有一个立方体,一个平行光,且平行光垂直于立方体朝向世界坐标 Z 轴的一面,而法线贴图采用切线空间下的法线贴图,也就是看起来偏蓝色的法线贴图,这意味着大部分法线值都是偏向正 Z 轴的。
首先我们不使用法线贴图,只使用顶点数组里的顶点法线,来观察一下它的样子,如下图所示:
然后我们加入法线贴图,但不使用切线空间,直接从法线贴图中采样法线向量,再来看下它的样子,如下图所示:
通关观察可以发现,上面两张图中前者是相对正常的,因为整个世界里只有一个垂直于亮面的平行光,所以只能看到一个面有颜色,其它面都是黑色。而后者中,除了垂直于平行光的面,其余面也是有颜色的,这显然是不对的,因为按照物理法则,其余几个面不应该被任何光照到(我也没有添加环境光),所以应该是黑色的。之所以有这样错误的效果,是因为这个立方体六个面都用的相同的漫反射贴图和法线贴图,每一个面不管朝向哪里,采样出来的都是偏向正 Z 轴的值,所以 Shader 代码自然会认为这个面中大部分片段就是面向正 Z 轴的,而我们的平行光正好是照着负 Z 轴,所以这时每个面看起来都有了颜色,这也是我在前面提到的法线贴图的一个问题。
另外,如果你仔细发现,你会看到后者大面积对着屏幕的那一面要比前者大面积对着屏幕的那一面要稍微更有立体感,因为后者我使用了法线贴图,这是法线贴图最基本的作用。但这里的确不明显,因为我为了方便演示,并没有花时间调整出好的光照效果,毕竟这篇文章不是演示法线贴图的,而是用另一个方式去验证切线空间是否计算正确。
为了解决前面的问题,我们需要使用切线空间。切线空间有两种方式可以得到正确的光照结果:
很多人喜欢在世界空间中计算,因为将所有数据转换到世界空间再进行计算,是非常直观的,对于我们在讨论的问题也是如此。但这里我们使用第二种方式来计算,原因是它更高效。
如果我们使用第一种方式,我们需要将每个从法线贴图中采样出来的法线变换到世界空间,这一步是在 片段着色器 中完成的,因为必须知道每个片段对应的的法线值,而不能简单的在顶点着色器中采样出来然后再插值到片段着色器中。如果我们使用第二种方式,我们会在 顶点着色器 中把所需要的数据,在这个例子中有平行光方向向量,顶点坐标,观察坐标(因为这个例子只有一个漫反射贴图,所以其实这个数据并没什么卵用)变换到切线空间,然后在片段着色器中只需要采样出法线向量,不需要再进行其他转换就可以直接进行计算了。而一般来说片段着色器执行的次数远大于顶点着色器执行的次数,所以第二种方式一般来说更高效。
当然这里你可能有一个疑问,我们将一些数据从世界空间转换到切线空间,会涉及到矩阵的求逆,这一步是开销比较大的。理论上说,是的,但实际上,我们利用一个性质,即 正交矩阵的逆矩阵等于它的转置矩阵 就可以做到高效求逆矩阵,你在后面会看到。
首先我们将顶点数组传入顶点着色器,然后构造 TBN 矩阵 来把一些数据变换到切线空间,最后再传入到片段着色器里。我先列出顶点着色器中所需要的数据(除传入的顶点数据外,其余数据都是在世界空间下):
#version 330 core
layout (location = 0) in vec3 vertexPosition; // 顶点坐标
layout (location = 1) in vec3 vertexNormal; // 顶点法线
layout (location = 2) in vec2 textureCoordinate; // 顶点纹理采样坐标
layout (location = 3) in vec3 tangent; // 顶点切线
layout (location = 4) in vec3 bitTangent; // 顶点副切线
// 这是 OpenGL 中的 uniform 缓存,就是把一次渲染中不变的通用数据从外部代码传给 Shader
layout (std140) uniform CameraInfo
{
vec3 viewPosition; // 摄像机位置(观察位置)
};
// 平行光的数据
struct DirectionalLight
{
vec3 direction; // 方向
vec3 diffuseColor; // 漫反射颜色
};
uniform mat4 mvpMatrix;
uniform mat4 modelMatrix;
uniform DirectionalLight directionalLight;
|
|
然后我们还需要定义输出给片段着色器的数据:
out V_OUT
{
vec2 textureCoordinate; // 纹理坐标
vec3 vertexPosition; // 切线空间顶点坐标
vec3 normal; // 发现向量
vec3 viewPosition; // 切线空间观察坐标
vec3 directionalLightDirection; // 切线空间平行光方向
} v_out;
|
|
这些数据定义好后,我们就可以着手编写转换各个数据到切线空间的代码了:
void main()
{
// 计算顶点的世界坐标
vec4 vertexPositionVector = vec4(vertexPosition, 1.f);
gl_Position = mvpMatrix * vertexPositionVector;
// 计算法线矩阵(这个矩阵可以使法线的坐标空间变换更精确,详细信息可以查阅【法线矩阵】 或 【Normal Transform】)
mat3 normalMatrix = transpose(inverse(mat3(modelMatrix)));
// 求 TBN 矩阵,三个向量均变换到世界空间
vec3 T = normalize(normalMatrix * tangent);
vec3 B = normalize(normalMatrix * bitTangent);
vec3 N = normalize(normalMatrix * vertexNormal);
// 求 TBN 矩阵的逆矩阵,因为 TBN 矩阵由三个互相垂直的单位向量组成,所以它是一个正交矩阵
// 正如前面所说,正交矩阵的逆矩阵等于它的转置,所以无需真的求逆矩阵
// 详情可查阅 【正交矩阵】 或 【Orthogonal Matrix】
mat3 inverseTBN = transpose(mat3(T, B, N));
// 将一些数据从世界空间变换到切线空间(并非所有数据都需要变换),然后传给片段着色器
v_out.directionalLightDirection = inverseTBN * directionalLight.direction;
v_out.vertexPosition = inverseTBN * vec3(gl_Position);
v_out.viewPosition = inverseTBN * viewPosition;
v_out.textureCoordinate = textureCoordinate;
v_out.normal = N;
}
|
|
写到这里我发现,我本来想只放出 Shader 片段的,但最后还是把整个顶点着色器的代码都写上了。我在里面添加了详细的注释,应该不会有什么很困惑的地方。
由于我们将数据都变换到了切线空间下,那么片段着色器在计算的时候就方便多了,因为它们都在同一个空间下了。同样我们先定义所需要的数据:
#version 330 core
out vec4 f_color; // 输出的颜色
// 这个跟顶点着色器中的 out 一致
in V_OUT
{
vec2 textureCoordinate;
vec3 vertexPosition;
vec3 normal;
vec3 viewPosition;
vec3 directionalLightDirection;
} v_out;
struct Material
{
sampler2D diffuseTexture; // 漫反射贴图
sampler2D normalTexture; // 法线贴图
};
// 跟顶点着色器中的一致
struct DirectionalLight
{
vec3 direction;
vec3 diffuseColor;
};
uniform Material material; // 材质
uniform DirectionalLight directionalLight; // 平行光信息
|
|
最后计算最终的颜色:
vec3 viewDirection; // 观察方向
vec3 CaculateDiractionalLightColor()
{
// 从法线贴图中采样出数据,并转换成法线值
// 转过算法为:贴图中存储 0 到 1 的值,而法线值是 -1 到 1
vec3 normal = vec3(texture(material.normalTexture, v_out.textureCoordinate));
normal = normalize(normal * 2.0 - 1.0);
// 计算漫反射
float diffuseRatio = max(dot(-v_out.directionalLightDirection, normal), 0.0);
vec3 diffuseColor = directionalLight.diffuseColor * diffuseRatio * vec3(texture(material.diffuseTexture0, v_out.textureCoordinate));
// 因为这个例子只用了漫反射贴图和法线贴图,所以其余如镜面反射或者环境光等就不计算了
return diffuseColor;
}
void main()
{
viewDirection = normalize(v_out.vertexPosition - v_out.viewPosition);
f_color = vec4(CaculateDiractionalLightColor(), 1.0); // 输出最终颜色
}
|
|
最终的结果如下图所示:
从图中可以看到,除了正对着平行光的一面外,其余面在凹凸的地方会有一点颜色,而其他地方依然是黑色。这是因为对于这个砖墙的图来说,在法线贴图中砖的凹凸处所对应的法线向量显然不是 (0,0,1)(0,0,1) ,所以在这个使用了切线空间的例子中,平行于平行光方向的面转换到切线空间后,可以直接对法线贴图进行采样,而砖墙的大部分面积采样出来的法线向量是 (0,0,1)(0,0,1) ,所以对于平行于平行光方向的墙面来说,大部分像素的法线向量都垂直于平行光照射的方向,所以计算出的颜色自然为0,而砖墙的凹凸处的法线值不垂直于平行光照射的方向,所以会得到一些颜色,这应该足以说明我们的切线空间计算结果是正确的。