tinyrenderer笔记(法线映射)
- tinyrenderer
- 个人代码仓库:tinyrenderer个人练习代码
引言
在前面的 Phong Shading 中,三角形内像素的法线是通过三角形三个顶点插值得到的。现在如果我们想让模型变得更加精细、表面具有更多细节,一种最简单的做法是让模型拥有更多的顶点,但在实际操作中,这种做法往往是不可取的,会带来巨大的开销。
思考一张纹理贴图里面可以存储什么东西?一个像素具有 RGB 三个分量,法线也是三个分量,这就很轻易的想到我们可以把模型的法线存储在一张纹理贴图中。在渲染的时候,根据当前像素的纹理坐标去贴图里面读取法线,然后使用这个法线来计算光照。这样我们就定义了更多的法线,但没有增加模型的顶点。
但这就引入了另外一个问题,前面我们使用的颜色纹理,因为内部存储的是颜色,而颜色是一个标量不具有方向信息,所以读取之后直接拿来用就可以了。但对于法线,法线是一个向量具有方向信息。在进行光照计算时,我们必须确保所有向量处于同一坐标空间,这样才能得到正确的光照结果。
模型空间法线
一种最直接的思路是将法线定义在模型空间中,在渲染的时候,根据纹理坐标读取模型空间下法线的值,然后将此法线乘上法线矩阵变换到世界空间来使用。这样可行吗?我们先来试一试。下图是定义在模型空间中的法线贴图:
引入法线贴图的代码很简单这里不多赘述,在片段着色器中,与之前对比只是获得法线的方式不同:
Vec3f normal = normalTex->texture(textureCoord.x, textureCoord.y);
normal = normalMatrix * normal;
normal.normalize();
我重载了 TGAColor
转化到 Vec3
的代码:
Vec3(const TGAColor& color) : x(color.r / 255.0f * 2 - 1), y(color.g / 255.0f * 2 - 1), z(color.b / 255.0f * 2 - 1) {}
得到的结果如下(左边的图应用了法线贴图):
效果似乎还不错,那是不是意味着这样做就没问题了?在某些情况下确实是这样的,但在哪些情况下会出问题呢?
我们现在使用的模型都是静态不动的,那就意味着一个顶点在模型空间的法线是确定的、不会变化的。但如果将一个动画引入到模型中会发生什么情况?比如说下面的情况,我们的模型张开了嘴巴:
对于一个张开了嘴巴的模型,嘴巴附近的顶点位置肯定会发生变化,法线也肯定会发生变化。如果我们还是使用原来的法线贴图,你就会得到左边的渲染结果,你会明显发现嘴巴内是黑暗的,没有被光线所照射,这肯定不是正确的光照结果。肯定有同学会思考,我们可以为这个张开嘴巴的模型新创建一个法线贴图,但一个动画有那么多帧,难道我们需要为动画的每一帧都创建一个法线贴图吗?答案显而易见,当然肯定有相应的优化措施来避免创建如此多的法线贴图,但定义在模型空间的法线贴图显然不适合用于有动画的模型。
这就引入了切线空间的概念,网上关于切线空间的定义与解释很多,下面给出我自己的理解,不一定正确!
切线空间
切线空间其实就是模型顶点的局部空间,在这个局部空间内,顶点的法向量总是大致指向 z z z 轴。
我们知道确立一个三维空间最少需要 u p up up, r i g h t right right 向量,和一个原点 o o o。对于法向量来说,平移没有意义,只关心归一化后的方向,所以不需要原点 o o o。那么我们至少需要 u p up up 与 r i g h t right right 向量来确定顶点的局部空间。
前面我们提到:顶点的法向量总是大致指向 z z z 轴,所以对于这个局部空间, u p up up 向量就是顶点的法向量。这里有点犯迷糊了,我们求的是法向量,但却需要用法向量来指定这个 u p up up 向量?其实指定局部空间 u p up up 向量的法向量是三角面内部插值得到的法向量,在前面的 Phong Shading 中,我们使用这个法向量来计算光照,但在此处我们拿它来指定 u p up up 向量。
还剩下一个 r i g h t right right 向量,在切线空间中,这个 r i g h t right right 向量我们叫做切向量(tangent)。过模型顶点的切向量理论上有无数条,我们该选取哪一条呢?这个切向量的生成需要考虑纹理贴图的 uv 坐标变化,切线一般由 u 坐标的变化导出。
为什么切向量的计算需要考虑 uv 坐标的变化呢?首先法线贴图也是人为创建的,在生成法线贴图的时候,就需要明确这个切线方向是什么。当渲染器(或游戏引擎)渲染模型时,着色器必须使用与法线贴图烘焙器相同的切线,否则将得到不正确的照明,尤其是在 uv 壳之间的接缝处。现在被广泛使用的切线计算方法是 MikkTspace,在这个方法下切线的计算按 uv 梯度方向。
请看这个回答:一个顶点的切线有无数条,法线空间到其他空间的变换是如何唯一确定的? - 知乎
唉,那为什么有些图形学算法采用了随机切线向量呢?尤其是一些采样积分算法。图形学有一句老话:看起来是对的,那么它就是对的!在一些不需要严格考虑纹理走向的场景,随机的切向量反而能带来更好的结果,随机生成切向量又提高了效率,何乐而不为呢?
请看这篇文章的评论区:切线空间(Tangent Space)完全解析 - 知乎
最后还需要明确的一点是:定义在切线空间的法线贴图存储的是相对于该坐标系的扰动方向 (而非绝对方向)。当顶点变形时,切线空间会随顶点法线和切线方向动态更新,法线扰动始终相对于当前表面方向,所以当动画应用于模型时,也能获得正确的光照结果。
说了这么多,还没有讲到如何计算切向量,在现代渲染引擎里面,可以自动算出顶点的切向量,或者干脆模型中就定义了顶点的切向量,不需要我们手动计算。而显然我们不具备这个条件,下面开始对切线的计算进行推导。
切向量的计算
推导
以下内容参考了法线贴图 - LearnOpenGL CN,也推荐看我主页 LearnOpenGL——高级光照(中)的这篇,对一些内容进行了修改。
已知 u p up up 向量是表面的法线向量, r i g h t right right 向量和 f o r w a r d forward forward 向量分别是切线向量和副切线向量。下图展示了一个表面上的这三个向量:
现在我们只考虑渲染一个三角形,它的三个顶点分别是 P 1 、 P 2 、 P 3 P_1、P_2、P_3 P1、P2、P3。在 3D 空间中,三角形的边 E 1 = P 2 − P 1 、 E 2 = P 3 − P 1 E_1=P2-P1、E_2=P3-P1 E1=P2−P1、E2=P3−P1。思考前面关于切线与副切线的意义:
- 切线 T T T:沿纹理坐标 U 增加的方向。
- 副切线 B B B:沿纹理坐标 V 增加的方向。
设纹理坐标 P 1 = ( U 1 , V 1 ) P_1=(U_1,V_1) P1=(U1,V1) , P 2 = ( U 2 , V 2 ) P_2=(U_2,V_2) P2=(U2,V2), P 3 = ( U 3 , V 3 ) P_3=(U_3,V_3) P3=(U3,V3)。则有 Δ U 1 = U 2 − U 1 \Delta U_1=U_2-U_1 ΔU1=U2−U1、 Δ U 2 = U 3 − U 1 \Delta U_2=U_3-U_1 ΔU2=U3−U1,所以在 3D 空间中,可以得到如下等式:
E 1 = Δ U 1 T + Δ V 1 B E 2 = Δ U 2 T + Δ V 2 B \begin{gathered} E_{1}=\Delta U_1T+\Delta V_1B \\ E_{2}=\Delta U_2T+\Delta V_2B \end{gathered} E1=ΔU1T+ΔV1BE2=ΔU2T+ΔV2B
将坐标展开得到:
( E 1 x , E 1 y , E 1 z ) = Δ U 1 ( T x , T y , T z ) + Δ V 1 ( B x , B y , B z ) ( E 2 x , E 2 y , E 2 z ) = Δ U 2 ( T x , T y , T z ) + Δ V 2 ( B x , B y , B z ) \begin{gathered} (E_{1x},E_{1y},E_{1z})=\Delta U_1(T_x,T_y,T_z)+\Delta V_1(B_x,B_y,B_z)\\ (E_{2x},E_{2y},E_{2z})=\Delta U_2(T_x,T_y,T_z)+\Delta V_2(B_x,B_y,B_z) \end{gathered} (E1x,E1y,E1z)=ΔU1(Tx,Ty,Tz)+ΔV1(Bx,By,Bz)(E2x,E2y,E2z)=ΔU2(Tx,Ty,Tz)+ΔV2(Bx,By,Bz)
两个未知量、两个方程,方程可解。也可转化为矩阵相乘形式:
[ E 1 x E 1 y E 1 z E 2 x E 2 y E 2 z ] = [ Δ U 1 Δ V 1 Δ U 2 Δ V 2 ] [ T x T y T z B x B y B z ] \begin{bmatrix}E_{1x}&E_{1y}&E_{1z}\\E_{2x}&E_{2y}&E_{2z}\end{bmatrix}=\begin{bmatrix}\Delta U_1&\Delta V_1\\\Delta U_2&\Delta V_2\end{bmatrix}\begin{bmatrix}T_x&T_y&T_z\\B_x&B_y&B_z\end{bmatrix} [E1xE2xE1yE2yE1zE2z]=[ΔU1ΔU2ΔV1ΔV2][TxBxTyByTzBz]
等式两边乘上 Δ U Δ V \Delta U \Delta V ΔUΔV 的逆矩阵:
[ T x T y T z B x B y B z ] = [ Δ U 1 Δ V 1 Δ U 2 Δ V 2 ] − 1 [ E 1 x E 1 y E 1 z E 2 x E 2 y E 2 z ] = 1 Δ U 1 Δ V 2 − Δ U 2 Δ V 1 [ Δ V 2 − Δ V 1 − Δ U 2 Δ U 1 ] [ E 1 x E 1 y E 1 z E 2 x E 2 y E 2 z ] \begin{aligned} \begin{bmatrix}T_x&T_y&T_z\\B_x&B_y&B_z\end{bmatrix}&= \begin{bmatrix}\Delta U_1&\Delta V_1\\\Delta U_2&\Delta V_2\end{bmatrix}^{-1}\begin{bmatrix}E_{1x}&E_{1y}&E_{1z}\\E_{2x}&E_{2y}&E_{2z}\end{bmatrix}\\ &=\frac{1}{\Delta U_1\Delta V_2-\Delta U_2\Delta V_1}\begin{bmatrix}\Delta V_2&-\Delta V_1\\-\Delta U_2&\Delta U_1\end{bmatrix}\begin{bmatrix}E_{1x}&E_{1y}&E_{1z}\\E_{2x}&E_{2y}&E_{2z}\end{bmatrix} \end{aligned} [TxBxTyByTzBz]=[ΔU1ΔU2ΔV1ΔV2]−1[E1xE2xE1yE2yE1zE2z]=ΔU1ΔV2−ΔU2ΔV11[ΔV2−ΔU2−ΔV1ΔU1][E1xE2xE1yE2yE1zE2z]
这样我们就可以从三角形的两条边及其纹理坐标计算切线向量 T T T 和副切线向量 B B B。
代码
回到我们的项目中,套用公式我们可以轻松得到如下的代码(只计算了切线 T T T ,副切线 B B B 可以叉乘获得):
Vec3f tangent(std::pair<Vec3f, Vec2f> p1, std::pair<Vec3f, Vec2f> p2, std::pair<Vec3f, Vec2f> p3)
{Vec3f t;Vec3f edge1 = p2.first - p1.first;Vec3f edge2 = p3.first - p1.first;Vec2f deltaUV1 = p2.second - p1.second;Vec2f deltaUV2 = p3.second - p1.second;float det = deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y;// 判断分母是否为0,若为0返回一个默认的值if (IsNearlyZero(det)){std::cout << "glm::tangent det is zero" << std::endl;return Vec3f(1, 0, 0);}t = (edge1 * deltaUV2.y - edge2 * deltaUV1.y) / det;return t;
}
请记住一定要判断分母是否为 0! 如果你遇到错误,可以尝试将法线输出到图像中观察,正确的法线图像如下:
分母也可以直接舍弃,因为后续会对切线 T T T 进行归一化操作。
将切线添加进 PhongVertexInfo
结构体里,在 main 函数里面计算:
std::array<std::pair<Vec3f, Vec2f>, 3> calTanInfos;
for (int j = 0; j < 3; j++)
{auto* info = new PhongVertexInfo();info->location = Vec4f(model->vert(face_v[j]), 1.f);info->textureCoord = model->vertTexture(face_vt[j]);info->normal = model->vertNormal(face_vn[j]);vertexInfos[j] = info;calTanInfos[j] = std::make_pair(info->location, info->textureCoord);
}
// 计算切线
Vec3f t = glm::tangent(calTanInfos[0], calTanInfos[1], calTanInfos[2]).normalized();
for (int j = 0; j < 3; j++)
{auto* info = static_cast<PhongVertexInfo*>(vertexInfos[j]);info->tangent = t;
}
切线的计算是针对特定的三角面进行的,三角面内部像素的切线一致。若同一个像素被多个三角面包含(边界像素),那么这个像素的切线应该是多个切线的均值,此处并没有考虑。
在片段着色器中,我们获得 T T T 与 N N N 之后,进行施密特正交化,然后叉乘获得 B B B:
Vec3f N = GetInterVaryingVar<Vec3f>("N", info.bc_coord).normalized();
Vec3f T = GetInterVaryingVar<Vec3f>("T", info.bc_coord).normalized();
T = (T - N * T.dot(N)).normalized();//施密特正交化
Vec3f B = N.cross(T).normalized();
随后构建 TBN 矩阵,将处于切线空间的法线转化到世界空间中:
glm::mat3 TBN = glm::Vec3toMat3(T, B, N);
Vec3f normal = normalTex->texture(textureCoord.x, textureCoord.y);
normal = TBN * normal;
normal.normalize();
注意这里的 T , B , N T, B, N T,B,N,在 T B N TBN TBN 矩阵中充当的是列向量。我们知道将 T , B , N T, B, N T,B,N 以行向量形式填充到矩阵中,可以得到将向量从世界空间转换到切线空间的矩阵 M M M,逆矩阵 M − 1 M^{-1} M−1 才能将向量从切线空间转换到世界空间,但经过施密特正交化过程, M M M 是一个正交矩阵,满足 M − 1 = M T M^{-1}=M^T M−1=MT,所以直接将 T , B , N T, B, N T,B,N 以列向量的形式填充到矩阵中就可以得到 T B N TBN TBN 矩阵。
现在来看一下渲染的效果:
本次代码提交记录:
这个版本的
LookAt
函数存在错误!2025-4-29 16.23 提交修复
参考
- tinyrenderer
- 法线贴图 - LearnOpenGL CN
- 一个顶点的切线有无数条,法线空间到其他空间的变换是如何唯一确定的? - 知乎
- 切线空间(Tangent Space)完全解析 - 知乎