【问题标题】:How to calculate Tangent and Binormal?如何计算正切和副法线?
【发布时间】:2021-04-02 18:56:56
【问题描述】:

在 OpenGL 着色语言 (GLSL) 中谈论凹凸贴图、镜面高光和这类东西

我有:

  • 顶点数组(例如 {0.2,0.5,0.1, 0.2,0.4,0.5, ...})
  • 法线数组(例如 {0.0,0.0,1.0, 0.0,1.0,0.0, ...})
  • 点光源在世界空间中的位置(例如 {0.0,1.0,-5.0})
  • 观察者在世界空间中的位置(例如 {0.0,0.0,0.0})(假设观察者在世界的中心)

现在,如何计算每个顶点的 Binormal 和 Tangent?我的意思是,计算 Binormals 的公式是什么,我必须根据这些信息使用什么?关于切线?

无论如何我都会构造 TBN 矩阵,所以如果你知道一个公式来直接基于这些信息构造矩阵会很好!

哦,是的,如果需要,我也有纹理坐标。 正如我所说的 GLSL,每个顶点的解决方案会很好,我的意思是,一个不需要一次访问多个顶点信息的解决方案。

---- 更新-----

我找到了这个解决方案:

vec3 切线; vec3 副法线; vec3 c1 = cross(a_normal, vec3(0.0, 0.0, 1.0)); vec3 c2 = cross(a_normal, vec3(0.0, 1.0, 0.0)); 如果(长度(c1)>长度(c2)) { 切线 = c1; } 别的 { 切线 = c2; } 切线 = 归一化(切线); 副法线=交叉(v_nglNormal,切线); 副常态=归一化(副常态);

但我不知道它是否 100% 正确。

【问题讨论】:

  • 可能是gamedev.stackexchange.com 的问题?
  • @user464230:不,这里也可以。 3D 图形不仅限于游戏。
  • 该“解决方案”假设您将在法线 = x 的平面上应用法线贴图纹理。如果应用于任意模型,这将不起作用。你真正需要做的是求解我给你的每个面的方程组,并使用顶点的平均值。如果你想做严肃的 3D 编程,你必须学习如何将线性代数(如下所示)转化为源代码。
  • “如果你想做严肃的 3D 编程,你必须学习如何将线性代数 - 如下所示 - 翻译成源代码。”......伙计,真的,你不会见不到我!!!不要告诉我“你会学习”……你根本不知道我做了什么……小心你的言辞!
  • @user464230:不,我不知道你实际做了什么。但是您询问了如何计算切线和副法线。所以你可以期待的是这个过程的数学描述。如果你只是想要一些源代码,你可以复制和粘贴,好吧,那里有很多。但是要了解如何有效地使用它,您必须了解它在数学层面上的作用。这需要您学习阅读和理解线性代数。你提出问题和评论我的帖子的方式清楚地表明,你还没有学会如何正确地阅读数学。

标签: math opengl 3d glsl shader


【解决方案1】:

与您的问题相关的输入数据是纹理坐标。 Tangent 和 Binormal 是局部平行于对象表面的向量。在法线贴图的情况下,它们描述的是法线纹理的局部方向。

因此,您必须计算纹理向量指向的方向(在模型空间中)。假设你有一个三角形 ABC,纹理坐标为 HKL。这给了我们向量:

D = B-A
E = C-A

F = K-H
G = L-H

现在我们想用切线空间 T、U 来表示 D 和 E,即

D = F.s * T + F.t * U
E = G.s * T + G.t * U

这是一个有6个未知数和6个方程的线性方程组,可以写成

| D.x D.y D.z |   | F.s F.t | | T.x T.y T.z |
|             | = |         | |             |
| E.x E.y E.z |   | G.s G.t | | U.x U.y U.z |

反转 FG 矩阵得到

| T.x T.y T.z |           1         |  G.t  -F.t | | D.x D.y D.z |
|             | = ----------------- |            | |             |
| U.x U.y U.z |   F.s G.t - F.t G.s | -G.s   F.s | | E.x E.y E.z |

与顶点法线T和U一起构成一个局部空间基,称为切空间,由矩阵描述

| T.x U.x N.x |
| T.y U.y N.y |
| T.z U.z N.z |

从切线空间转换到对象空间。要进行照明计算,需要与此相反。稍加练习就会发现:

T' = T - (N·T) N
U' = U - (N·U) N - (T'·U) T'

对向量 T' 和 U' 进行归一化,称它们为切线和副法线,我们获得了从对象转换到切线空间的矩阵,我们在其中进行光照:

| T'.x T'.y T'.z |
| U'.x U'.y U'.z |
| N.x  N.y  N.z  |

我们将 T' 和 U' 与顶点法线一起存储为模型几何的一部分(作为顶点属性),以便我们可以在着色器中使用它们进行光照计算。 我重复一遍:您不会在着色器中确定切线和副法线,而是预先计算它们并将它们存储为模型几何体的一部分(就像法线一样)。

(上面竖线之间的符号都是矩阵,不是行列式,通常在符号中使用竖线而不是括号。)

【讨论】:

  • 你的矩阵求逆是错误的。矩阵的逆不是标量而是矩阵。所以应该是 (1/fsgt-ftgs)((gt, -ft)(-gs, gs))
  • @Gottfried:你在哪里看到标量?您在此处看到的是求逆矩阵的行列式方法。比例因子中的分母是矩阵 [F G] 的行列式(F,G 是列向量)。您也可以使用 Gauss-Jordan 消元法,得到完全相同的结果。
  • 我知道您正在通过行列式进行反转,但是如果您查看您的帖子,您会发现您忘记了矩阵部分。您只除以标量的行列式。比较例如与link 我希望我能澄清这一点,我确信这只是一个疏忽。
  • @Gottfried:啊,我看错了等式。是的,我完全错过了。谢谢
  • @felipeek 在三角网格是具有平滑嵌入切线空间的平滑表面的近似的情况下,您可以假设切线空间基础是相邻的三角形。 – 本质上,这与我们所做的近似/假设相同,当我们通过平均相邻三角形的法线来计算顶点法线时;事实上,法线是切空间基的一个基向量,因此将其扩展到其他基向量是有根据的。
【解决方案2】:

通常,您有两种生成 TBN 矩阵的方法:离线和在线。

  • 在线 = 在片段着色器中使用衍生指令。这些推导为多边形的每个点提供了一个平坦的 TBN 基础。为了得到一个平滑的,我们必须根据给定的(平滑的)顶点法线重新正交化它。这个过程在 GPU 上比初始 TBN 提取更加繁重。

    // compute derivations of the world position
    vec3 p_dx = dFdx(pw_i);
    vec3 p_dy = dFdy(pw_i);
    // compute derivations of the texture coordinate
    vec2 tc_dx = dFdx(tc_i);
    vec2 tc_dy = dFdy(tc_i);
    // compute initial tangent and bi-tangent
    vec3 t = normalize( tc_dy.y * p_dx - tc_dx.y * p_dy );
    vec3 b = normalize( tc_dy.x * p_dx - tc_dx.x * p_dy ); // sign inversion
    // get new tangent from a given mesh normal
    vec3 n = normalize(n_obj_i);
    vec3 x = cross(n, t);
    t = cross(x, n);
    t = normalize(t);
    // get updated bi-tangent
    x = cross(b, n);
    b = cross(n, x);
    b = normalize(b);
    mat3 tbn = mat3(t, b, n);
    
  • 离线 = 准备切线作为顶点属性。这更难获得,因为它不仅会添加另一个顶点属性,而且还需要重新组合所有其他属性。此外,它不会 100% 为您提供更好的性能,因为您将获得存储/传递/动画(!)vector3 顶点属性的额外成本。

数学在很多地方都有描述(谷歌搜索),包括@datenwolf 帖子。

这里的问题是 2 个顶点可能具有相同的法线和纹理坐标,但切线不同。这意味着您不能只将顶点属性添加到顶点,您需要将顶点拆分为 2 并为克隆指定不同的切线。

获得每个顶点唯一切线(和其他属性)的最佳方法是尽早在导出器中执行此操作。在按属性对纯顶点进行排序的阶段,您只需将切线向量添加到排序键。

考虑使用四元数作为解决问题的根本方法。单个四元数 (vec4) 可以成功地表示预定义灵巧度的切线空间。保持正交(包括传递给片段着色器)、存储和提取法线(如果需要)很容易。更多信息请关注KRI wiki

【讨论】:

  • @user464230:它不是很优雅。这是一种利用现代 GPU 丰富计算能力的蛮力方法,有点像“如果我不理解数学,就用乏味的方式来做”的方法。 @kvark 已经告诉过它会表现得更差。
  • @datenwolf。我没有说 GLSL 方法更慢,即使它是:)。我喜欢它的普遍性:你不需要做你自己的导出器(或网格属性重新计算),你不需要关心骨骼动画期间的切线。它只是工作。
  • @kvark:您写道“它在 GPU 上更重”,是的。提取部分导数对于 GPU 来说确实是一项艰巨的工作(归结为,对于每个片段,必须模拟邻居的计算,或者必须等待它们(如果片段被杀死或其他代码,并不总是有结果)分支被执行)。是的,它只是工作,但以降低填充率为代价。
  • @datenwolf。引用我自己的话“这个过程在 GPU 上比最初的 TBN 提取更加繁重”——我的意思不是你读到的 :)。不过,我同意你的说法。
  • @user464230:这些四元数只是 TBN 矩阵的更紧凑的表示。切线空间可以理解为将曲面局部切线空间与(全局)对象空间对齐(旋转)所需的局部变换。 IE。 TBN 矩阵是一个旋转矩阵。现在,旋转也可以表示为四元数。所以得到这个正切四元数的方法(@kvark:好主意顺便说一句!)是确定 TBN 矩阵并推导出它的等效四元数,这是一个特征值问题。维基百科有数学:en.wikipedia.org/wiki/Quaternions_and_spatial_rotation
【解决方案3】:

根据 kvark 的回答,我想补充一些想法。

如果你需要一个正交归一化的切线空间矩阵,你必须做一些工作。 即使您添加了切线和副法线属性,它们也会在着色器阶段进行插值 最后,它们既不是规范化的,也不是彼此正常的。

假设我们有一个归一化的法线向量 n,我们有切线t 和binormalb,或者我们可以从推导中计算它们,如下所示:

// derivations of the fragment position
vec3 pos_dx = dFdx( fragPos );
vec3 pos_dy = dFdy( fragPos );
// derivations of the texture coordinate
vec2 texC_dx = dFdx( texCoord );
vec2 texC_dy = dFdy( texCoord );
// tangent vector and binormal vector
vec3 t = texC_dy.y * pos_dx - texC_dx.y * pos_dy;
vec3 b = texC_dx.x * pos_dy - texC_dy.x * pos_dx;

当然可以通过使用叉积来计算正交正切空间矩阵, 但这仅适用于右手系统。如果矩阵被镜像(左手系统),它将转向右手系统:

t = cross( cross( n, t ), t ); // orthonormalization of the tangent vector
b = cross( n, t );             // orthonormalization of the binormal vector 
                               //   may invert the binormal vector
mat3 tbn = mat3( normalize(t), normalize(b), n );

在上面的代码 sn-p 中,如果切线空间是左手系统,则副法向量被反转。 为了避免这种情况,必须走硬路:

t = cross( cross( n, t ), t ); // orthonormalization of the tangent vector
b = cross( b, cross( b, n ) ); // orthonormalization of the binormal vectors to the normal vector 
b = cross( cross( t, b ), t ); // orthonormalization of the binormal vectors to the tangent vector
mat3 tbn = mat3( normalize(t), normalize(b), n );

正交化任何矩阵的常用方法是Gram–Schmidt process

t = t - n * dot( t, n ); // orthonormalization ot the tangent vectors
b = b - n * dot( b, n ); // orthonormalization of the binormal vectors to the normal vector 
b = b - t * dot( b, t ); // orthonormalization of the binormal vectors to the tangent vector
mat3 tbn = mat3( normalize(t), normalize(b), n );

另一种可能性是使用由纹理坐标texC_dxtexC_dy 推导得到的 2*2 矩阵的行列式来考虑副法线向量的方向。其思想是正交矩阵的行列式为1,正交镜像矩阵的行列式为-1。

行列式也可以通过 GLSL 函数 determinant( mat2( texC_dx, texC_dy ) 计算 或者可以通过公式texC_dx.x * texC_dy.y - texC_dy.x * texC_dx.y来计算。

对于正交归一化切空间矩阵的计算,不再需要副法向量和单位向量的计算 (normalize) 的副法向量可以被规避。

float texDet = texC_dx.x * texC_dy.y - texC_dy.x * texC_dx.y;
vec3 t = texC_dy.y * pos_dx - texC_dx.y * pos_dy;
t      = normalize( t - n * dot( t, n ) );
vec3 b = cross( n, t );                      // b is normlized because n and t are orthonormalized unit vectors
mat3 tbn = mat3( t, sign( texDet ) * b, n ); // take in account the direction of the binormal vector

【讨论】:

  • @AlexGreen 非常感谢,已修复
【解决方案4】:

计算切线的方法有很多种,如果法线贴图烘焙器的计算方式与渲染器不同,您将得到细微的伪影。许多面包师使用 MikkTSpace 算法,这与片段导数技巧不同。

幸运的是,如果你有一个来自使用 MikkTSpace 的程序的索引网格(并且没有具有相反方向的纹理坐标三角形共享索引),那么算法的困难部分主要是为你完成的,你可以像这样重建切线:

#include <cmath>
#include "glm/geometric.hpp"
#include "glm/vec2.hpp"
#include "glm/vec3.hpp"
#include "glm/vec4.hpp"

using glm::vec2;
using glm::vec3;
using glm::vec4;

void makeTangents(uint32_t nIndices, uint16_t* indices,
                  const vec3 *positions, const vec3 *normals,
                  const vec2 *texCoords, vec4 *tangents) {
  uint32_t inconsistentUvs = 0;
  for (uint32_t l = 0; l < nIndices; ++l) tangents[indices[l]] = vec4(0);
  for (uint32_t l = 0; l < nIndices; ++l) {
    uint32_t i = indices[l];
    uint32_t j = indices[(l + 1) % 3 + l / 3 * 3];
    uint32_t k = indices[(l + 2) % 3 + l / 3 * 3];
    vec3 n = normals[i];
    vec3 v1 = positions[j] - positions[i], v2 = positions[k] - positions[i];
    vec2 t1 = texCoords[j] - texCoords[i], t2 = texCoords[k] - texCoords[i];

    // Is the texture flipped?
    float uv2xArea = t1.x * t2.y - t1.y * t2.x;
    if (std::abs(uv2xArea) < 0x1p-20)
      continue;  // Smaller than 1/2 pixel at 1024x1024
    float flip = uv2xArea > 0 ? 1 : -1;
    // 'flip' or '-flip'; depends on the handedness of the space.
    if (tangents[i].w != 0 && tangents[i].w != -flip) ++inconsistentUvs;
    tangents[i].w = -flip;

    // Project triangle onto tangent plane
    v1 -= n * dot(v1, n);
    v2 -= n * dot(v2, n);
    // Tangent is object space direction of texture coordinates
    vec3 s = normalize((t2.y * v1 - t1.y * v2)*flip);
    
    // Use angle between projected v1 and v2 as weight
    float angle = std::acos(dot(v1, v2) / (length(v1) * length(v2)));
    tangents[i] += vec4(s * angle, 0);
  }
  for (uint32_t l = 0; l < nIndices; ++l) {
    vec4& t = tangents[indices[l]];
    t = vec4(normalize(vec3(t.x, t.y, t.z)), t.w);
  }
  // std::cerr << inconsistentUvs << " inconsistent UVs\n";
}

在顶点着色器中,它们被旋转到世界空间中:

  fragNormal = (model.model * vec4(inNormal, 0)).xyz;
  fragTangent = vec4((model.model * vec4(inTangent.xyz, 0)).xyz, inTangent.w);

然后像这样计算副法线和世界空​​间法线(见http://mikktspace.com/):

  vec3 binormal = fragTangent.w * cross(fragNormal, fragTangent.xyz);
  vec3 worldNormal = normalize(normal.x * fragTangent.xyz +
                               normal.y * binormal +
                               normal.z * fragNormal);

(副法线通常是按像素计算的,但有些面包师会为您提供按顶点计算并插值的选项。This page 有关于特定程序的信息。)

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2023-02-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-12-24
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多