TL;DR — 노멀 매핑은 픽셀마다 노멀을 텍스처에서 읽어 조명을 계산하는 기법입니다. 탄젠트 공간 노멀을 월드 공간으로 변환하는 TBN 행렬의 수학적 원리와, Vulkan + ASSIMP 환경에서
cross순서와 UV 뒤집힘 문제를 해결한 과정을 정리합니다.
Table of contents
Open Table of contents
들어가며
게임에서 벽돌 벽을 렌더링한다고 가정합니다. 벽돌 하나하나의 요철을 실제 폴리곤으로 만들면 정점 수가 폭발합니다. 반면 텍스처만 입히면 조명을 움직여도 표면이 완전히 평평해 보입니다. 빛이 요철에 반응하지 않기 때문입니다.
노멀 매핑은 이 문제를 지오메트리 추가 없이 해결합니다. 픽셀 셰이더에서 각 픽셀의 노멀 벡터를 텍스처(노멀맵)로부터 읽어와 교체하면, GPU는 표면이 울퉁불퉁한 것처럼 조명을 계산합니다. PBR 파이프라인에서 거의 필수적으로 사용되는 핵심 텍스처링 기술입니다.
이 글에서 다루는 내용:
- 노멀맵이 파란색인 이유와 탄젠트 공간의 개념
- TBN 행렬의 수학적 유도와 기저변환 원리
- Vulkan + ASSIMP 환경에서의 셰이더 구현
cross순서, ASSIMP UV 뒤집힘, SRGB vs UNORM 이슈
사전 지식: Blinn-Phong 조명 모델, 텍스처 매핑, 행렬 변환에 대한 기본 이해를 전제로 합니다.
1. 왜 노멀 매핑이 필요한가
1.1 노멀맵은 왜 파란색인가
노멀맵은 각 텍셀의 노멀을 탄젠트 공간 기준으로 저장합니다. 탄젠트 공간에서 “표면 그대로의 방향”은 입니다. 텍스처는 0~1 범위로 저장하므로, 은 RGB 이 됩니다. 이것이 파란색입니다.
대부분의 표면은 크게 기울어지지 않으므로, 노멀맵 전체가 파란색 톤을 띱니다.
1.2 왜 월드 노멀이 아니라 탄젠트 공간인가
월드 노멀로 저장하면 모델이 회전할 때마다 노멀맵을 다시 만들어야 합니다. 탄젠트 공간으로 저장하면 모델이 어떻게 회전하든 같은 노멀맵을 재사용할 수 있습니다. 변환은 TBN 행렬이 담당합니다.
2. TBN 행렬의 수학
2.1 탄젠트 공간이란
표면 위의 한 점에서 세 방향을 정의한 로컬 좌표계입니다:
- T (Tangent): 텍스처 U 방향
- B (Bitangent): 텍스처 V 방향
- N (Normal): 표면 법선
UV 좌표계에 N축을 추가하여 3D로 확장한 것으로 이해할 수 있습니다.
2.2 탄젠트 벡터 유도 — 연립방정식으로 T를 구한다
삼각형의 두 edge , 를 T, B의 조합으로 분해할 수 있습니다:
직관적으로 보면, “edge 방향으로 가면 UV가 만큼 변한다”는 관계를 역으로 풀어 UV 변화량을 T, B로 분리하는 것입니다. 미지수 2개, 식 2개이므로 2원 연립방정식과 같은 구조입니다.
행렬로 정리하고 역행렬을 곱하면 다음과 같습니다:
실무에서는 보통 다음 순서로 접근합니다:
- 모델 파일(glTF 등)에 탄젠트가 이미 포함되어 있으면 그것을 사용
- 모델에 없으면 ASSIMP의
aiProcess_CalcTangentSpace같은 도구로 계산 - 그마저 없거나 학습/디버깅 목적이면 직접 공식을 구현해서 계산
단순하게 “edge 방향 = 탄젠트”()로 쓸 수도 있지만, edge를 따라 U와 V가 동시에 변하는 경우에는 정확하지 않습니다. 역행렬 유도가 UV 변화량을 정확히 분리하여 순수 U 방향 성분만 추출합니다.
2.3 T와 N은 다른 행렬로 월드 변환한다
| 벡터 | 변환 행렬 | 이유 |
|---|---|---|
| T (탄젠트) | 모델 행렬 의 선형 부분 | 표면 위 의 방향 벡터이므로 translation 영향 없음 |
| N (노멀) | 역전치 | 표면에 수직 인 벡터이므로 non-uniform 스케일에서 왜곡됨 |
uniform 스케일이거나 스케일이 없으면 둘 다 으로 사용해도 됩니다.
2.4 그람-슈미트 직교화 — 직교성 복원
모델 로컬에서 T와 N이 수직이었더라도, 서로 다른 행렬( vs 역전치)로 변환하면 더 이상 수직이 아닐 수 있습니다. TBN은 직교 좌표계여야 변환이 정확하므로, T에서 N 방향 성분을 빼서 보정합니다:
직각삼각형으로 보면, T가 빗변이고 N 방향 사영이 밑변입니다. 빗변에서 밑변을 빼면 높이(N에 수직인 성분)가 남습니다.
내적 순서에 대해 정리하면, dot(T, N)과 dot(N, T)의 스칼라 결과는 같습니다. 하지만 그 스칼라에 곱하는 벡터가 다릅니다:
dot(T, N) * N→ N 방향 벡터 (T를 N에 사영)dot(N, T) * T→ T 방향 벡터 (N을 T에 사영)
“T에서 N 방향 성분을 빼기”이므로 전자를 사용합니다.
2.5 TBN이 작동하는 원리 — 기저변환 행렬
노멀맵에서 읽은 는 탄젠트 공간 basis에 대한 좌표값 입니다.
같은 벡터를 탄젠트 공간 basis로 풀어쓰면 다음과 같습니다:
T, B, N을 월드로 변환한 T’, B’, N’으로 같은 가중치를 적용하면:
T’, B’, N’이 월드 공간 벡터이므로 결과도 월드 공간 노멀입니다. 행렬로 쓰면:
같은 가중치, 다른 basis, 다른 공간. TBN은 노멀맵 벡터를 회전시키는 것이 아니라, 탄젠트 공간 basis를 월드로 표현한 기저변환 행렬 입니다.
, 즉 노멀맵의 파란색을 넣으면 입니다. 버텍스 노멀 그대로입니다. “변화 없음”이 파란색인 이유가 여기에 있습니다.
3. 셰이더 구현
3.1 Vertex Shader — T는 model, N은 normalMatrix
fragWorldNormal = normalize(vec3(normalMatrix * vec4(inNormal, 0.0)));
fragTangent = normalize(vec3(model * vec4(inTangent, 0.0)));
T는 표면 위 벡터이므로 model 행렬로, N은 수직 벡터이므로 normalMatrix(역전치)로 변환합니다. 현재 실습 구현에서는 B를 버텍스 속성에 넣지 않고 프래그먼트 셰이더에서 외적으로 계산합니다.
3.2 Fragment Shader — TBN 구성 + 노멀맵 적용
// 0. 보간된 벡터 재정규화
// 래스터라이저가 정점 간 보간하면서 단위 벡터의 크기가 1에서 벗어날 수 있다.
vec3 N = normalize(fragWorldNormal);
vec3 T_in = normalize(fragTangent);
// 1. 직교화: T에서 N 성분 제거
float TdotN = dot(T_in, N);
vec3 T = normalize(T_in - (TdotN * N));
// 2. B = cross(T, N) — 외적 순서는 컨벤션 의존적 (§4 참고)
vec3 B = normalize(cross(T, N));
// 3. TBN 구성 (각 열이 월드 공간 벡터)
mat3 TBN = mat3(T, B, N);
// 4. 노멀맵 → 월드 노멀
vec3 texNormal = texture(normalSampler, fragUV).rgb * 2.0 - 1.0;
vec3 worldNormal = normalize(TBN * texNormal);
* 2.0 - 1.0을 빠뜨리면 노멀 벡터가 0~1 범위 그대로 들어가서 조명 계산이 완전히 깨집니다.
이 구현은 현재 실습에서 채택한 단순화 버전입니다. 현재 자산에서는 충분히 동작했지만, 일반 모델에서는 mirrored UV나 tangent handedness 때문에 cross만으로 복원한 B가 틀릴 수 있습니다. 그런 경우에는 B를 직접 버텍스 속성으로 읽거나, ASSIMP의 mBitangents 또는 tangent handedness sign을 사용해 B = sign * cross(N, T)처럼 복원하는 편이 더 안전합니다.
3.3 보간 후 재정규화가 필요한 이유
래스터라이저는 정점 속성을 삼각형 내부에서 선형 보간합니다. 두 단위 벡터 , 를 보간하면:
와 가 각각 길이 1이어도, 방향이 다르면 중간값의 길이는 1보다 짧아질 수 있습니다. 노멀과 탄젠트처럼 방향만 의미 있는 벡터 는 프래그먼트 셰이더 진입 시 normalize로 길이를 복원해야 합니다.
반면, 크기 자체에 의미가 있는 경우(예: 정점 컬러의 가중 평균)에는 정규화하지 않는 편이 맞습니다.
3.4 useNormalMap 토글로 비교
if (useNormalMap > 0.5) {
shading = blinnPhong(originColor, fragWorldPos, worldNormal); // 노멀맵 적용
} else {
shading = blinnPhong(originColor, fragWorldPos, N); // 버텍스 노멀만
}
ImGui 체크박스로 실시간 on/off 비교가 가능합니다.
4. 주의사항 — Vulkan + glTF + ASSIMP 환경
4.1 cross 순서 — 컨벤션 확인 후 실행으로 검증
B를 구할 때 cross(N, T)와 cross(T, N)은 방향이 반대입니다. 다만 어느 쪽이 맞는지는 V 방향 컨벤션 하나로만 결정되지 않습니다.
실제로는 다음 조합이 함께 영향을 줍니다:
- 모델의 UV 방향
- importer / postprocess가 UV를 뒤집었는지
- 그 UV 기준으로 계산된 tangent/bitangent handedness
- normal map 자산의 green(Y) 채널 convention
확인 순서는 다음과 같습니다:
-
사용 환경의 UV 컨벤션 확인
- glTF: V=0이 위, V가 아래로 증가
- Vulkan 텍스처: V=0이 위, V가 아래로 증가
- V가 아래로 증가하면 B도 아래 방향이어야 합니다
-
ASSIMP의 내부 처리 확인
- ASSIMP는 glTF 로딩 시 V를 내부적으로 뒤집습니다 (아래 4.2절 참조)
aiProcess_FlipUVs로 다시 원복합니다- 최종 UV 컨벤션이 원본 glTF와 같은지 확인합니다
-
실행으로 검증
- 두 순서를 각각 빌드하여 결과를 비교합니다
- 조명 방향과 표면 디테일이 일관되게 반응하는 쪽이 맞습니다
즉 cross(T, N)이 “항상 Vulkan에서 맞다”는 뜻은 아닙니다. 이 프로젝트의 현재 조합:
- glTF 모델
- ASSIMP glTF importer의 내부 V flip
aiProcess_FlipUVsCalcTangentSpace- 현재 DamagedHelmet normal map 자산
에서는 cross(T, N)이 더 자연스럽게 보였습니다.
4.2 ASSIMP가 glTF의 UV를 내부적으로 뒤집는다
glTF 스펙과 Vulkan 텍스처 좌표계는 동일한 컨벤션 (V=0이 위, 좌측 상단 원점)입니다. 따라서 이론적으로는 aiProcess_FlipUVs가 필요 없어야 합니다.
하지만 빼면 텍스처가 깨집니다.
원인은 현재 프로젝트에 vendoring 된 ASSIMP의 glTF importer 소스코드에 있습니다:
values[i].y = 1 - values[i].y; // Flip Y coordsexternal/assimp/code/AssetLib/glTF/glTFImporter.cpp
glTF를 읽을 때 UV를 추출한 직후 importer가 이미 v -> 1 - v를 적용합니다.
ASSIMP 문서에는 기본 출력 UV와 aiProcess_FlipUVs의 의미가 다음과 같이 적혀 있습니다:
- 기본 출력 UV origin: lower-left
aiProcess_FlipUVs: upper-left origin으로 뒤집기
따라서 현재 glTF 경로에서는:
- glTF importer가 먼저 V를 한 번 뒤집어 ASSIMP 기본 UV 컨벤션(lower-left)으로 맞춥니다
aiProcess_FlipUVs를 추가로 켜면 postprocess에서 다시 한 번 뒤집습니다- 최종 UV가 upper-left로 돌아옵니다
postprocess 실행 순서상 FlipUVsProcess가 CalcTangentsProcess보다 먼저 실행됩니다. 따라서 탄젠트/비탄젠트도 최종 UV 기준 으로 계산됩니다.
흐름을 정리하면 다음과 같습니다:
glTF 원본: V=0 위 (Vulkan과 동일)
↓ ASSIMP 내부 변환 (v = 1 - v)
ASSIMP 내부: V=0 아래 (OpenGL 스타일)
↓ aiProcess_FlipUVs (v = 1 - v)
최종: V=0 위 (원래대로 복원)
aiProcess_FlipUVs는 “Vulkan이라서 필요한 플래그”가 아니라, glTF importer가 내부적으로 한 번 뒤집은 V를 다시 upper-left로 되돌려주는 후처리 입니다.
스펙만 보고 판단하면 안 됩니다. 라이브러리가 내부적으로 좌표계를 변환하는 경우가 있으므로, 의심스러우면 라이브러리의 실제 소스 코드와 postprocess 순서 를 함께 확인해야 합니다.
4.3 노멀맵 텍스처 포맷 — SRGB vs UNORM
현재 구현에서는 diffuse 텍스처와 노멀맵 모두 VK_FORMAT_R8G8B8A8_SRGB로 로딩하고 있습니다. SRGB 포맷은 GPU가 샘플링 시 감마 보정(비선형 → 선형 변환)을 자동으로 적용합니다.
diffuse처럼 색상 데이터에는 적합하지만, 노멀맵은 방향을 나타내는 숫자 데이터이므로 값을 그대로 읽는 VK_FORMAT_R8G8B8A8_UNORM이 더 적절합니다. SRGB로 읽으면 * 2.0 - 1.0 결과가 미세하게 달라질 수 있습니다.
현재 단계에서는 시각적으로 큰 차이가 없어서 유지하고 있지만, PBR 파이프라인으로 확장할 때 분리를 검토할 예정입니다.
정리하며
핵심 요약:
- 노멀 매핑은 지오메트리를 추가하지 않고 픽셀 단위로 노멀을 교체하여 표면 디테일을 표현하는 기법입니다
- TBN 행렬은 탄젠트 공간 basis를 월드로 표현한 기저변환 행렬 입니다. 노멀맵의 파란색 은 TBN을 통과하면 버텍스 노멀 그대로가 됩니다
- T와 N은 서로 다른 행렬로 월드 변환하므로 그람-슈미트 직교화로 직교성을 복원해야 합니다
cross순서와 UV 컨벤션은 환경(glTF + ASSIMP + Vulkan)에 따라 달라지므로, 스펙 확인 후 실행으로 검증하는 것이 확실합니다- 노멀맵은 색상이 아닌 데이터이므로, SRGB 대신 UNORM 포맷이 원칙적으로 맞습니다
참고 자료
- Learn OpenGL — Normal Mapping — TBN 행렬 유도와 탄젠트 공간 개념 설명
- glTF 2.0 Specification — 텍스처 좌표계 컨벤션
- ASSIMP Documentation — Post Processing Flags —
aiProcess_FlipUVs,aiProcess_CalcTangentSpace설명
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.