노멀 매핑
모든 장면은 수백, 많게는 수천 개의 삼각형으로 이루어진 메시로 가득 차 있습니다. 이러한 평면 삼각형에 2D 텍스처를 입혀 폴리곤이 작은 평면 삼각형이라는 사실을 감춰 사실감을 높였습니다. 텍스처가 도움이 되긴 하지만, 메시를 자세히 살펴보면 여전히 평평한 표면이 눈에 띕니다. 하지만 실제 표면은 대부분 평평하지 않고 울퉁불퉁한 디테일을 가지고 있습니다.
예를 들어 벽돌 표면을 생각해 보세요. 벽돌 표면은 상당히 거칠고 완전히 평평하지 않습니다. 움푹 들어간 시멘트 줄무늬와 수많은 작은 구멍과 균열이 있습니다. 만약 이러한 벽돌 표면을 조명이 비추는 장면에서 본다면 몰입감이 쉽게 깨집니다. 아래 이미지에서 점광원에 의해 조명된 평평한 표면에 벽돌 텍스처가 적용된 것을 볼 수 있습니다.

조명 시스템은 작은 균열이나 구멍을 전혀 고려하지 않고 벽돌 사이의 깊은 줄무늬도 완전히 무시합니다. 표면이 완벽하게 평평해 보입니다. 스페큘러 맵을 사용하여 깊이나 다른 디테일 때문에 일부 표면이 덜 밝게 보이도록 하면 평평한 느낌을 어느 정도 해결할 수 있지만, 이는 근본적인 해결책이라기보다는 임시방편에 가깝습니다. 우리에게 필요한 것은 표면의 미세한 깊이감과 같은 디테일을 조명 시스템에 알려주는 방법입니다.
빛의 관점에서 생각해 보면, 표면이 어떻게 완전히 평평한 표면처럼 조명될까요? 그 답은 표면의 법선 벡터에 있습니다. 조명 기법의 관점에서 물체의 형태를 결정하는 유일한 방법은 물체에 수직인 법선 벡터를 이용하는 것입니다. 벽돌 표면은 하나의 법선 벡터만 가지고 있기 때문에, 이 법선 벡터의 방향에 따라 표면 전체가 균일하게 보여집니다. 그렇다면 각 프래그먼트마다 동일한 법선 벡터를 사용하는 대신, 각 프래그먼트마다 다른 법선 벡터를 사용하면 어떨까요? 이렇게 하면 표면의 미세한 디테일에 따라 법선 벡터를 조금씩 다르게 설정할 수 있습니다. 이는 표면이 훨씬 더 복잡해 보이는 착시 효과를 만들어냅니다.

프래그먼트별 노멀을 사용하면 조명이 표면이 (노멀 벡터에 수직인) 아주 작은 평면들로 구성되어 있다고 착각하게 만들어 표면의 디테일을 크게 향상시킬 수 있습니다. 표면별 노멀 대신 프래그먼트별 노멀을 사용하는 이 기법을 노멀 매핑 또는 범프 매핑이라고 합니다. 벽돌 평면에 적용하면 다음과 같습니다.

보시다시피, 이 방법은 상대적으로 낮은 비용으로 디테일을 크게 향상시켜 줍니다. 프래그먼트별로 법선 벡터만 변경하기 때문에 조명 방정식을 수정할 필요가 없습니다. 이제 보간된 표면 법선 대신 프래그먼트별 법선을 조명 알고리즘에 전달합니다. 그러면 조명 알고리즘이 나머지를 처리합니다.
노멀 매핑
노멀 매핑을 제대로 작동시키려면 프래그먼트별 노멀이 필요합니다. 디퓨즈 맵과 스페큘러 맵에서 했던 것처럼 2D 텍스처를 사용하여 프래그먼트별 노멀 데이터를 저장할 수 있습니다. 이렇게 하면 2D 텍스처를 샘플링하여 특정 프래그먼트에 대한 노멀 벡터를 얻을 수 있습니다.
법선 벡터는 기하학적 요소이고 텍스처는 일반적으로 색상 정보만 저장하는 데 사용되지만, 텍스처에 법선 벡터를 저장하는 것이 직관적이지 않을 수 있습니다. 텍스처의 색상 벡터를 생각해 보면, 색상 벡터는 r, g, b 성분을 가진 3D 벡터로 표현됩니다. 마찬가지로 법선 벡터의 x, y, z 성분을 각각의 색상 성분에 저장할 수 있습니다. 법선 벡터는 -1에서 1 사이의 값을 가지므로 먼저 [0,1] 범위로 매핑합니다.
vec3 rgb_normal = normal * 0.5 + 0.5; // transforms from [-1,1] to [0,1]
이처럼 일반 벡터를 RGB 색상 구성 요소로 변환하면 표면 모양에서 파생된 프래그먼트별 법선을 2D 텍스처에 저장할 수 있습니다. 이 장의 시작 부분에 나오는 벽돌 표면의 노멀 맵 예시는 아래와 같습니다.

이 노멀 맵(그리고 온라인에서 찾을 수 있는 거의 모든 노멀 맵)은 푸른빛을 띕니다. 이는 모든 법선 벡터가 양의 z축(0,0,1) 방향으로 향하고 있기 때문이며, 이 방향은 푸른색을 띕니다. 색상의 차이는 일반적인 양의 z축 방향에서 약간 벗어난 법선 벡터를 나타내며, 텍스처에 깊이감을 부여합니다. 예를 들어, 각 벽돌의 윗면은 녹색을 띠는 경향이 있는데, 이는 벽돌 윗면의 법선 벡터가 양의 y축 방향(0,1,0)을 더 많이 가리키고 있기 때문이며, 이 방향은 녹색에 해당합니다!
양의 z축을 바라보는 단순한 평면을 사용하여 이 확산 텍스처와 이 노멀 맵을 통해 이전 섹션의 이미지를 렌더링할 수 있습니다. 링크된 노멀 맵은 위에 표시된 것과 다릅니다. 그 이유는 OpenGL이 텍스처 좌표를 읽을 때 y(또는 v) 좌표가 일반적으로 텍스처가 생성되는 방식과 반전되어 있기 때문입니다. 따라서 링크된 노멀 맵의 y(또는 녹색) 성분이 반전되어 있습니다(녹색이 이제 아래쪽을 향하는 것을 볼 수 있습니다). 이를 고려하지 않으면 조명이 잘못 표현될 수 있습니다. 두 텍스처를 모두 로드하고 적절한 텍스처 유닛에 바인딩한 다음, 조명 프래그먼트 셰이더에서 다음과 같이 변경하여 평면을 렌더링합니다.
uniform sampler2D normalMap;
void main()
{
// [0,1] 범위의 노멀 맵에서 노멀을 추출합니다.
normal = texture(normalMap, fs_in.TexCoords).rgb;
// 법선 벡터를 [-1,1] 범위로 변환합니다.
normal = normalize(normal * 2.0 - 1.0);
[...]
// 평소처럼 조명을 진행하세요
}
여기서는 법선을 RGB 색상으로 매핑하는 과정을 역으로 수행합니다. 즉, 샘플링된 법선 색상을 [0,1]에서 [-1,1]로 다시 매핑한 다음, 샘플링된 법선 벡터를 후속 조명 계산에 사용합니다. 이 경우 블린-퐁 셰이더를 사용했습니다.
시간에 따라 광원을 천천히 이동시키면 노멀 맵을 사용하여 깊이감을 효과적으로 표현할 수 있습니다. 이 노멀 매핑 예제를 실행하면 이 장의 시작 부분에 나와 있는 것과 정확히 같은 결과가 나타납니다.

하지만 한 가지 문제가 있어 노멀 맵의 활용이 크게 제한됩니다. 우리가 사용한 노멀 맵의 법선 벡터는 모두 양의 z 방향을 향하고 있었습니다. 이는 평면의 표면 법선 벡터 또한 양의 z 방향을 향하고 있었기 때문에 가능했습니다. 그러나 만약 평면이 지면에 놓여 있고 표면 법선 벡터가 양의 y 방향을 향하고 있다면, 동일한 노멀 맵을 어떻게 적용해야 할까요?

조명이 제대로 나오지 않네요! 이는 이 평면의 샘플링된 법선 벡터가 여전히 대략 양의 z 방향을 가리키고 있기 때문입니다. 실제로는 양의 y 방향을 가리켜야 하는데 말이죠. 결과적으로 조명 시스템은 평면의 법선 벡터가 이전에 평면이 양의 z 방향을 가리켰을 때와 동일하다고 판단하여 잘못된 조명을 적용합니다. 아래 이미지는 이 표면에서 샘플링된 법선 벡터가 대략 어떻게 보이는지 보여줍니다.

보시다시피 모든 법선 벡터가 양의 y 방향을 향해야 함에도 불구하고 양의 z 방향을 어느 정도 향하고 있습니다. 이 문제를 해결하는 한 가지 방법은 표면의 가능한 모든 방향에 대해 법선 맵을 정의하는 것입니다. 정육면체의 경우 6개의 법선 맵이 필요합니다. 그러나 표면 방향이 수백 개 이상일 수 있는 복잡한 메시의 경우 이 방법은 현실적으로 불가능합니다.
다른 해결책으로는 조명 처리를 다른 좌표 공간에서 수행하는 방법이 있습니다. 이 좌표 공간에서는 법선 맵 벡터가 항상 양의 z 방향을 향하고, 다른 모든 조명 벡터는 이 양의 z 방향을 기준으로 변환됩니다. 이렇게 하면 방향에 관계없이 항상 동일한 법선 맵을 사용할 수 있습니다. 이 좌표 공간을 탄젠트 공간(tangent space)이라고 합니다.
탄젠트 공간
노멀 맵의 법선 벡터는 탄젠트 공간으로 표현되며, 이 공간에서 법선은 항상 대략 양의 z 방향을 가리킵니다. 탄젠트 공간은 삼각형 표면에 국한된 공간으로, 법선은 각 삼각형의 로컬 기준 좌표계에 상대적입니다. 즉, 노멀 맵 벡터의 로컬 공간이라고 생각하면 됩니다. 최종 변환 방향과 관계없이 모든 벡터는 양의 z 방향을 가리키도록 정의됩니다. 특정 행렬을 사용하면 이 로컬 탄젠트 공간의 법선 벡터를 월드 좌표계 또는 뷰 좌표계로 변환하여 최종 매핑된 표면의 방향을 따라 정렬할 수 있습니다.
이전 섹션에서 살펴본 것처럼 양의 y 방향을 바라보는 잘못된 노멀 맵이 적용된 표면이 있다고 가정해 보겠습니다. 노멀 맵은 탄젠트 공간에서 정의되므로, 이 문제를 해결하는 한 가지 방법은 탄젠트 공간의 노멀 벡터를 다른 공간으로 변환하는 행렬을 계산하는 것입니다. 이렇게 하면 노멀 벡터들이 표면의 법선 방향과 정렬되어 모든 노멀 벡터가 대략 양의 y 방향을 가리키게 됩니다. 탄젠트 공간의 장점은 어떤 유형의 표면에 대해서도 이 행렬을 계산할 수 있어 탄젠트 공간의 z 방향을 표면의 법선 방향에 정확하게 맞출 수 있다는 것입니다.
이러한 행렬을 TBN 행렬이라고 하며, 각 문자는 접선(Tangent), 종접선(Bitangent), 법선(Normal) 벡터를 나타냅니다. 이 행렬을 구성하는 데 필요한 벡터는 바로 이 세 가지입니다. 탄젠트 공간 벡터를 다른 좌표 공간으로 변환하는 이러한 기저 변환 행렬을 구성하려면 법선 맵의 표면을 따라 정렬된 세 개의 수직 벡터, 즉 위쪽, 오른쪽, 앞쪽 벡터가 필요합니다. 이는 카메라 챕터에서 했던 것과 유사합니다.
우리는 이미 표면의 법선 벡터인 위쪽 벡터를 알고 있습니다. 오른쪽 벡터와 앞쪽 벡터는 각각 접선 벡터와 종접선 벡터입니다. 다음 표면 이미지는 표면 위의 세 벡터를 모두 보여줍니다.

접선 벡터와 종접선 벡터를 계산하는 것은 법선 벡터를 계산하는 것만큼 간단하지 않습니다. 이미지에서 볼 수 있듯이 법선 맵의 접선 벡터와 종접선 벡터의 방향은 표면의 텍스처 좌표를 정의하는 방향과 일치합니다. 이 사실을 이용하여 각 표면의 접선 벡터와 종접선 벡터를 계산할 것입니다. 이 벡터들을 얻으려면 약간의 수학적 계산이 필요합니다. 다음 이미지를 참조하세요.

이미지에서 볼 수 있듯이 삼각형의 모서리 \(E_2\)의 텍스처 좌표 차이(\(\Delta U_2\) 및 \(\Delta V_2\)로 표시됨)는 접선 벡터 \(T\) 및 종접선 벡터 \(B\)와 같은 방향으로 표현됩니다. 이 때문에 삼각형의 표시된 두 변 \(E_1\)과 \(E_2\)을 접선 벡터 \(T\)와 종접선 벡터 \(B\)의 선형 조합으로 나타낼 수 있습니다.
\[E_1 = \Delta U_1T + \Delta V_1B\] \[E_2 = \Delta U_2T + \Delta V_2B\]
이를 다음과 같이 쓸 수도 있습니다.
\[(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)\]
두 삼각형 위치 사이의 차이 벡터를 \(E\)로, 텍스처 좌표 차이를 \(\Delta U\)와 \(\Delta V\)로 계산할 수 있습니다. 그러면 두 개의 미지수(접선 \(T\)와 종접선 \(B\))와 두 개의 방정식이 남습니다. 대수학 수업에서 배웠듯이, 이를 통해 \(T\)와 \(B\)를 구할 수 있습니다.
마지막 방정식을 이용하면 행렬 곱셈 형태로 다음과 같이 표현할 수 있습니다.
\[\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} \]
행렬 곱셈을 머릿속으로 떠올려 보고, 이것이 실제로 같은 방정식인지 확인해 보세요. 방정식을 행렬 형태로 다시 작성하는 것의 장점은 \(T\)와 \(B\)를 구하는 과정이 더 이해하기 쉽다는 것입니다. 방정식의 양변에 \(\Delta U \Delta V\) 행렬의 역행렬을 곱하면 다음과 같습니다.
\[ \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} = \begin{bmatrix} T_x & T_y & T_z \\ B_x & B_y & B_z \end{bmatrix} \]
이를 통해 \(T\)를 구할 수 있습니다. (B). 이를 위해서는 델타 텍스처 좌표 행렬의 역행렬을 계산해야 합니다. 행렬의 역행렬을 계산하는 수학적 세부 사항은 생략하겠지만, 대략적으로 행렬의 행렬식의 제곱에 수반행렬을 곱한 값과 같습니다.
\[ \begin{bmatrix} T_x & T_y & T_z \\ B_x & B_y & B_z \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} \]
이 최종 방정식은 삼각형의 두 변과 텍스처 좌표를 이용하여 접선 벡터(\(T\))와 종접선 벡터(\(B\))를 계산하는 공식을 제공합니다.
이 과정의 수학적 원리를 완전히 이해하지 못하더라도 걱정하지 마세요. 삼각형의 꼭짓점과 텍스처 좌표(텍스처 좌표는 접선 벡터와 같은 공간에 존재하므로)를 이용하여 접선와 종접선를 계산할 수 있다는 사실만 이해해도 절반은 성공한 것입니다.
접선 및 종접선의 수동 계산
이전 데모에서는 양의 z 방향을 향하는 간단한 노멀 매핑된 평면을 사용했습니다. 이번에는 접선 공간을 사용하여 노멀 매핑을 구현하려고 합니다. 이렇게 하면 평면의 방향을 어떻게 설정하든 노멀 매핑이 계속 작동합니다. 앞서 설명한 수학적 방법을 사용하여 이 표면의 접선 벡터와 종접선 벡터를 직접 계산할 것입니다.
평면이 다음 벡터들로 구성되어 있다고 가정해 봅시다 (1, 2, 3과 1, 3, 4가 두 개의 삼각형을 이룬다고 가정합니다).
// positions
glm::vec3 pos1(-1.0, 1.0, 0.0);
glm::vec3 pos2(-1.0, -1.0, 0.0);
glm::vec3 pos3( 1.0, -1.0, 0.0);
glm::vec3 pos4( 1.0, 1.0, 0.0);
// texture coordinates
glm::vec2 uv1(0.0, 1.0);
glm::vec2 uv2(0.0, 0.0);
glm::vec2 uv3(1.0, 0.0);
glm::vec2 uv4(1.0, 1.0);
// normal vector
glm::vec3 nm(0.0, 0.0, 1.0);
먼저 첫 번째 삼각형의 변과 UV 좌표의 변화를 계산합니다.
glm::vec3 edge1 = pos2 - pos1;
glm::vec3 edge2 = pos3 - pos1;
glm::vec2 deltaUV1 = uv2 - uv1;
glm::vec2 deltaUV2 = uv3 - uv1;
접선과 종접선 계산에 필요한 데이터를 얻었으므로 이전 섹션의 방정식을 따라 계산을 시작할 수 있습니다.
float f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
[...] // 평면의 두 번째 삼각형에 대한 접선/종접선을 계산하는 데에도 유사한 절차가 적용됩니다.
여기서 먼저 방정식의 소수 부분을 f로 미리 계산한 다음, 각 벡터 성분에 대해 해당 행렬 곱셈을 f와 곱합니다. 이 코드를 최종 방정식과 비교해 보면 직접적인 변환임을 알 수 있습니다. 삼각형은 항상 평면 도형이므로 삼각형의 각 꼭짓점에 대해 동일한 접선/종접선 쌍을 하나씩만 계산하면 됩니다.
결과적으로 생성되는 접선 벡터와 종접선 벡터는 각각 (1,0,0)과 (0,1,0)의 값을 가지며, 이들은 법선 벡터 (0,0,1)와 함께 직교 TBN 행렬을 형성합니다. 평면상에서 시각화하면 TBN 벡터는 다음과 같이 나타납니다.

정점별로 접선 벡터와 종접선 벡터를 정의하면 적절한 노멀 매핑을 구현할 수 있습니다.
탄젠트 공간 노멀 매핑
노멀 매핑을 작동시키려면 먼저 셰이더에서 TBN 행렬을 생성해야 합니다. 이를 위해 이전에 계산한 접선 및 종접선 벡터를 정점 속성으로 정점 셰이더에 전달합니다.
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in vec3 aTangent;
layout (location = 4) in vec3 aBitangent;
다음으로 정점 셰이더의 메인 함수 내에서 TBN 행렬을 생성합니다.
void main()
{
[...]
vec3 T = normalize(vec3(model * vec4(aTangent, 0.0)));
vec3 B = normalize(vec3(model * vec4(aBitangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(aNormal, 0.0)));
mat3 TBN = mat3(T, B, N);
}
먼저 모든 TBN 벡터를 우리가 작업하고자 하는 좌표계로 변환합니다. 이 경우 모델 행렬과 곱하기 때문에 월드 좌표계가 됩니다. 그런 다음 mat3의 생성자에 관련 열 벡터를 직접 제공하여 실제 TBN 행렬을 생성합니다. 더욱 정확하게 하려면 벡터의 방향만 중요하므로 TBN 벡터를 법선 행렬과 곱해야 합니다. 엄밀히 말하면 정점 셰이더에서 종접선 변수는 필요하지 않습니다. 세 개의 TBN 벡터는 모두 서로 수직이므로 정점 셰이더에서 T 벡터와 N 벡터의 외적을 취하여 종접을 직접 계산할 수 있습니다. vec3 B = cross(N, T);
자, 이제 TBN 행렬을 얻었으니 어떻게 활용할까요? TBN 행렬을 정규화에 사용하는 방법은 두 가지가 있는데, 두 가지 모두 시연해 보이겠습니다.
- 우리는 임의의 벡터를 탄젠트 공간에서 월드 공간으로 변환하는 TBN 행렬을 가져와서 프래그먼트 셰이더에 전달하고, 샘플링된 법선을 TBN 행렬을 사용하여 탄젠트 공간에서 월드 공간으로 변환합니다. 그러면 법선은 다른 조명 변수들과 동일한 공간에 있게 됩니다.
- 우리는 임의의 벡터를 월드 공간에서 탄젠트 공간으로 변환하는 TBN 행렬의 역행렬을 구하고, 이 행렬을 사용하여 법선이 아닌 다른 관련 조명 변수들을 탄젠트 공간으로 변환합니다. 그러면 법선은 다시 다른 조명 변수들과 같은 공간에 있게 됩니다.
첫 번째 경우를 살펴보겠습니다. 노멀 맵에서 샘플링한 법선 벡터는 탄젠트 공간으로 표현되는 반면, 다른 조명 벡터(광원 및 시점 방향)는 월드 공간으로 표현됩니다. TBN 행렬을 프래그먼트 셰이더에 전달하면 샘플링된 탄젠트 공간 법선 벡터에 이 TBN 행렬을 곱하여 법선 벡터를 다른 조명 벡터와 동일한 기준 공간으로 변환할 수 있습니다. 이렇게 하면 모든 조명 계산(특히 내적)이 의미를 갖게 됩니다.
TBN 행렬을 프래그먼트 셰이더로 보내는 것은 간단합니다.
out VS_OUT {
vec3 FragPos;
vec2 TexCoords;
mat3 TBN;
} vs_out;
void main()
{
[...]
vs_out.TBN = mat3(T, B, N);
}
프래그먼트 셰이더에서도 마찬가지로 mat3를 입력 변수로 사용합니다.
in VS_OUT {
vec3 FragPos;
vec2 TexCoords;
mat3 TBN;
} fs_in;
이 TBN 행렬을 사용하면 이제 접선-월드 공간 변환을 포함하도록 노멀 매핑 코드를 업데이트할 수 있습니다.
normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normal * 2.0 - 1.0;
normal = normalize(fs_in.TBN * normal);
결과적으로 법선 벡터가 월드 공간에 있게 되므로, 조명 코드는 법선 벡터가 월드 공간에 있다고 가정하기 때문에 다른 프래그먼트 셰이더 코드를 변경할 필요가 없습니다.
두 번째 경우를 살펴보겠습니다. 이 경우에는 TBN 행렬의 역행렬을 사용하여 모든 관련 월드 공간 벡터를 샘플링된 법선 벡터가 있는 공간, 즉 탄젠트 공간으로 변환합니다. TBN 행렬의 구성은 동일하지만, 프래그먼트 셰이더로 보내기 전에 먼저 행렬을 역행렬로 만듭니다.
vs_out.TBN = transpose(mat3(T, B, N));
여기서는 역행렬 대신 전치행렬을 사용한다는 점에 유의하세요. 직교행렬(각 축이 서로 수직인 단위 벡터)의 중요한 특징 중 하나는 전치행렬이 역행렬과 같다는 것입니다. 역행렬을 구하는 것은 비용이 많이 드는 반면 전치행렬은 그렇지 않기 때문에 이 특징은 매우 유용합니다.
프래그먼트 셰이더 내부에서는 법선 벡터를 변환하지 않지만, lightDir과 viewDir 벡터와 같은 다른 관련 벡터들을 탄젠트 공간으로 변환합니다. 이렇게 하면 각 벡터가 동일한 좌표 공간, 즉 탄젠트 공간에 있게 됩니다.
void main()
{
vec3 normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normalize(normal * 2.0 - 1.0);
vec3 lightDir = fs_in.TBN * normalize(lightPos - fs_in.FragPos);
vec3 viewDir = fs_in.TBN * normalize(viewPos - fs_in.FragPos);
[...]
}
두 번째 접근 방식은 작업량이 더 많아 보이고 프래그먼트 셰이더에서 행렬 곱셈도 필요하므로, 굳이 두 번째 접근 방식을 사용할 이유가 있을까요?
월드 공간에서 탄젠트 공간으로 벡터를 변환하는 것은 추가적인 이점을 제공하는데, 바로 관련된 모든 조명 벡터를 프래그먼트 셰이더가 아닌 버텍스 셰이더에서 탄젠트 공간으로 변환할 수 있다는 점입니다. 이는 lightPos와 viewPos가 매 프래그먼트 실행마다 업데이트되지 않기 때문이며, fs_in.FragPos의 탄젠트 공간 위치는 버텍스 셰이더에서 계산하고 프래그먼트 보간을 통해 처리할 수 있기 때문입니다. 첫 번째 접근 방식에서는 샘플링된 법선 벡터가 각 프래그먼트 셰이더 실행마다 다르기 때문에 프래그먼트 셰이더에서 벡터를 탄젠트 공간으로 변환해야 하지만, 이 방식에서는 변환이 사실상 필요하지 않습니다.
따라서 TBN 행렬의 역행렬을 프래그먼트 셰이더로 보내는 대신, 탄젠트 공간 광원 위치, 뷰 위치, 그리고 정점 위치를 프래그먼트 셰이더로 보냅니다. 이렇게 하면 프래그먼트 셰이더에서 행렬 곱셈을 수행할 필요가 없어집니다. 정점 셰이더는 프래그먼트 셰이더보다 실행 빈도가 훨씬 낮기 때문에 이는 매우 효과적인 최적화입니다. 또한 이러한 이유로 이 방식이 종종 선호됩니다.
out VS_OUT {
vec3 FragPos;
vec2 TexCoords;
vec3 TangentLightPos;
vec3 TangentViewPos;
vec3 TangentFragPos;
} vs_out;
uniform vec3 lightPos;
uniform vec3 viewPos;
[...]
void main()
{
[...]
mat3 TBN = transpose(mat3(T, B, N));
vs_out.TangentLightPos = TBN * lightPos;
vs_out.TangentViewPos = TBN * viewPos;
vs_out.TangentFragPos = TBN * vec3(model * vec4(aPos, 1.0));
}
프래그먼트 셰이더에서는 이러한 새로운 입력 변수를 사용하여 탄젠트 공간에서 조명을 계산합니다. 법선 벡터가 이미 탄젠트 공간에 있으므로 조명이 제대로 적용됩니다.
탄젠트 공간에 노멀 매핑을 적용하면 이 장의 시작 부분에서 얻었던 것과 유사한 결과를 얻을 수 있습니다. 하지만 이번에는 평면의 방향을 원하는 대로 설정해도 조명이 여전히 정확하게 유지됩니다.
glm::mat4 model = glm::mat4(1.0f);
model = glm::rotate(model, (float)glfwGetTime() * -10.0f, glm::normalize(glm::vec3(1.0, 0.0, 1.0)));
shader.setMat4("model", model);
RenderQuad();
이것은 실제로 적절한 노멀 매핑처럼 보입니다.

소스 코드는 여기에서 찾을 수 있습니다.
복잡한 오브젝트
우리는 접선 벡터와 종접선 벡터를 수동으로 계산하여 접선 공간 변환과 함께 법선 매핑을 사용하는 방법을 보여주었습니다. 다행히도 이러한 접선 벡터와 종접선 벡터를 수동으로 계산해야 하는 경우는 그리 많지 않습니다. 대부분의 경우 사용자 정의 모델 로더에서 한 번 구현하거나, 우리처럼 Assimp를 사용하는 모델 로더를 사용할 때만 구현하면 됩니다.
Assimp에는 모델을 불러올 때 설정할 수 있는 매우 유용한 구성 비트인 aiProcess_CalcTangentSpace가 있습니다. Assimp의 ReadFile 함수에 aiProcess_CalcTangentSpace 비트를 제공하면 Assimp는 이 장에서 설명한 방식과 유사하게 로드된 각 정점에 대한 부드러운 접선 벡터와 종접선 벡터를 계산합니다.
const aiScene *scene = importer.ReadFile(
path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace
);
Assimp 내에서 다음과 같이 계산된 접선을 가져올 수 있습니다.
vector.x = mesh->mTangents[i].x;
vector.y = mesh->mTangents[i].y;
vector.z = mesh->mTangents[i].z;
vertex.Tangent = vector;
그런 다음 텍스처가 적용된 모델에서 노멀 맵을 로드하도록 모델 로더를 업데이트해야 합니다. 웨이브프론트 객체 형식(.obj)은 Assimp의 규칙과 약간 다르게 노멀 맵을 내보냅니다. aiTextureType_NORMAL은 노멀 맵을 로드하지 않지만 aiTextureType_HEIGHT는 로드합니다.
vector<Texture> normalMaps = loadMaterialTextures(material, aiTextureType_HEIGHT, "texture_normal");
물론 이는 로드된 모델 유형과 파일 형식에 따라 다릅니다.
업데이트된 모델 로더를 사용하여 반사광 맵과 법선 맵이 있는 모델에서 애플리케이션을 실행하면 다음과 같은 결과가 나타납니다.

보시다시피, 노멀 매핑은 큰 비용 증가 없이 객체의 디테일을 놀라울 정도로 향상시켜 줍니다.
노멀 맵을 사용하는 것은 성능을 향상시키는 좋은 방법이기도 합니다. 노멀 맵이 도입되기 전에는 메시에 높은 수준의 디테일을 표현하려면 많은 수의 정점을 사용해야 했습니다. 하지만 노멀 맵을 사용하면 훨씬 적은 수의 정점으로도 동일한 수준의 디테일을 표현할 수 있습니다. 아래 이미지는 파올로 치뇨니(Paolo Cignoni)가 제공한 것으로, 두 가지 방법을 잘 비교하고 있습니다.

노멀 매핑이 적용된 고밀도 메쉬와 저밀도 메쉬의 디테일은 거의 구별할 수 없습니다. 따라서 노멀 매핑은 보기 좋을 뿐만 아니라, 디테일 손실(대부분의 경우) 없이 고밀도 메쉬를 저밀도 메쉬로 대체할 수 있는 훌륭한 도구입니다.
마지막으로 한 가지만 더
마지막으로 비용을 크게 늘리지 않고 품질을 약간 향상시키는 비법 하나를 더 살펴보겠습니다.
정점을 상당수 공유하는 큰 메쉬에서 접선 벡터를 계산할 때, 일반적으로 접선 벡터들을 평균화하여 매끄러운 결과를 얻습니다. 하지만 이 방식의 문제점은 세 개의 TBN 벡터가 서로 직교하지 않을 수 있다는 점입니다. 이 경우 생성되는 TBN 행렬이 더 이상 직교하지 않게 됩니다. 직교하지 않는 TBN 행렬을 사용하더라도 노멀 매핑은 약간의 오차만 발생시키겠지만, 이는 개선의 여지가 있는 부분입니다.
그람-슈미트 과정이라는 수학적 기법을 사용하여 TBN 벡터들을 다시 직교화함으로써 각 벡터가 다른 벡터들과 다시 수직이 되도록 할 수 있습니다. 정점 셰이더 내에서는 다음과 같이 구현합니다.
vec3 T = normalize(vec3(model * vec4(aTangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(aNormal, 0.0)));
// N에 대해 T를 다시 직교화합니다.
T = normalize(T - dot(T, N) * N);
// 그런 다음 T와 N의 외적을 이용하여 수직 벡터 B를 구합니다.
vec3 B = cross(N, T);
mat3 TBN = mat3(T, B, N)
이 방법은 약간의 추가 비용을 감수하더라도 일반적으로 정규 사상 결과를 향상시킵니다. 이 과정이 실제로 어떻게 작동하는지에 대한 자세한 설명은 추가 자료에 있는 Normal Mapping Mathematics 비디오의 마지막 부분을 참조하십시오.
추가 자료
- Tutorial 26: Normal Mapping: normal mapping tutorial by ogldev.
- How Normal Mapping Works: a nice video tutorial of how normal mapping works by TheBennyBox.
- Normal Mapping Mathematics: a similar video by TheBennyBox about the mathematics behind normal mapping.
- Tutorial 13: Normal Mapping: normal mapping tutorial by opengl-tutorial.org.