# 노멀맵은 왜 파란색일까

> 2026-11-05 · 그래픽스 · 노멀맵, 셰이더, 텍스처, 렌더링
> https://hyna.dev/posts/why-normal-maps-are-blue

노멀맵 텍스처를 처음 열어 보면 조금 당황스럽습니다. 벽돌이든 갑옷이든 나무껍질이든, 거의 모든 노멀맵이 같은 연보랏빛 파란색입니다. 디테일이 있는 곳에만 분홍색과 하늘색 테두리가 살짝 묻어 있을 뿐입니다.

아래는 무료로 공개된 실제 게임용 텍스처 세 가지입니다(Poly Haven, CC0). 위가 색을 담은 컬러맵, 아래가 노멀맵입니다. 벽돌도, 나무껍질도, 금속판도 노멀맵은 모두 같은 색입니다.

![벽돌, 나무껍질, 금속판 텍스처. 위 줄은 각각의 컬러맵이고, 아래 줄은 노멀맵입니다. 컬러맵은 빨강, 갈색, 남색으로 제각각이지만 노멀맵은 셋 다 연보랏빛 파란색입니다.](/images/posts/why-normal-maps-are-blue/game-textures.webp)

이 색은 우연이 아닙니다. 그 파란색은 정확히 `(128, 128, 255)`이고, 그렇게 될 수밖에 없는 이유가 있습니다. 이 글에서는 그 이유를 처음부터 차근차근 따라가 보겠습니다. 결론부터 말하면 이렇습니다. **노멀맵은 그림이 아니라 방향을 적어 둔 표이고, 표면 기준으로 적힌 방향은 거의 다 "위"를 가리키기 때문입니다.**

## 노멀맵은 무엇을 저장하나

노멀(normal)은 표면에 수직인 길이 1짜리 벡터입니다. 셰이더는 빛이 표면을 얼마나 정면으로 비추는지를 이 벡터로 계산합니다. 가장 단순한 확산광은 노멀과 빛 방향의 내적입니다.

$$
\text{밝기} = \max(0,\; \mathbf{n} \cdot \mathbf{l})
$$

폴리곤이 적은 모델은 노멀도 거칠게 바뀝니다. 그래서 리벳이나 이음매처럼 작은 굴곡을 폴리곤으로 만드는 대신, 픽셀마다 "여기서는 표면이 이쪽을 향한 척해 줘"라는 방향을 텍스처에 적어 둡니다. 이것이 노멀맵입니다. 텍셀 하나에 방향 하나가 들어갑니다.

![면이 6개뿐인 같은 상자 두 개에 같은 벽돌 컬러맵을 입혔습니다. 왼쪽은 컬러맵만 입혀 벽돌이 평평한 그림처럼 보이고, 노멀맵을 더한 오른쪽은 벽돌과 줄눈이 빛을 받아 튀어나와 보입니다.](/images/posts/why-normal-maps-are-blue/cube-with-without.webp)

두 상자는 면이 6개뿐인 같은 상자이고, 같은 벽돌 컬러맵을 입혔습니다. 오른쪽에만 위의 벽돌 노멀맵을 더했습니다. 폴리곤은 하나도 늘지 않았는데, 벽돌과 줄눈이 빛을 받아 튀어나와 보입니다.

문제는 텍스처가 색을 담는 그릇이라는 점입니다. 방향을 색으로 바꿔 넣어야 합니다.

## 방향을 색으로 바꾸는 규칙

길이가 1인 벡터의 각 성분은 -1부터 1 사이의 값입니다. 반면 텍스처의 채널은 0부터 1(8비트로는 0부터 255) 사이만 담을 수 있습니다. 그래서 반으로 줄이고 반만큼 올립니다.

$$
\mathbf{c} = \tfrac{1}{2}\,\mathbf{n} + \tfrac{1}{2}, \qquad \mathbf{n} = 2\,\mathbf{c} - 1
$$

x는 빨강(R), y는 초록(G), z는 파랑(B) 채널에 들어갑니다. 성분이 -1이면 0, 0이면 128, 1이면 255가 됩니다.

![x, y, z 성분이 각각 R, G, B 채널로 옮겨지는 그림. -1, 0, +1이 0, 128, 255에 대응하고, 평평한 노멀 (0, 0, 1)은 R 128, G 128, B 255가 되어 연보랏빛 파란색이 됩니다. B 채널의 아래쪽 절반은 빗금으로 표시돼 있습니다.](/images/posts/why-normal-maps-are-blue/mapping.svg)

이제 아무 굴곡도 없는 평평한 곳을 생각해 보겠습니다. 표면이 똑바로 위를 보고 있으니 노멀은 `(0, 0, 1)`입니다.

$$
(0,\,0,\,1) \;\mapsto\; (0.5,\,0.5,\,1.0) \;\mapsto\; (128,\,128,\,255)
$$

빨강과 초록은 딱 중간, 파랑은 최대. 이것이 노멀맵의 그 파란색입니다. 0.5 × 255는 127.5라서 도구에 따라 127로 적기도 합니다. 어느 쪽이든 같은 "평평함"입니다.

## 손으로 기울여 보기

말로만 들으면 감이 잘 안 오니, 이 변환을 기계로 만들어 봤습니다. 볼 위에 얹힌 타일이 표면이고, 타일에 수직으로 선 바늘이 노멀입니다. 마이크로미터 나사 두 개가 타일을 x축과 y축으로 기울이고, 뒤의 기둥 세 개가 바늘의 x, y, z를 0–255로 읽습니다. 앞의 칩은 그 세 값을 섞은 색입니다.

그림 위에서 포인터를 움직이면 바늘이 그쪽으로 기웁니다. 키보드 방향키로도 움직일 수 있습니다.

> 인터랙티브 데모: 노멀을 색으로 바꾸는 장치: 기울어지는 타일 위에 수직으로 선 바늘이 노멀이고, 세 게이지가 바늘의 x, y, z를 0–255의 R, G, B로 읽습니다. 평평하면 128, 128, 255(파란색)이고, 바늘은 표면 아래로 내려갈 수 없어 B는 128 아래로 내려가지 않습니다. — https://hyna.dev/posts/why-normal-maps-are-blue

몇 번 기울여 보면 두 가지가 눈에 들어옵니다. 빨강과 초록 눈금은 가운데(128)를 중심으로 위아래로 오르내립니다. 그런데 파랑 눈금은 늘 위쪽에 머뭅니다. 파란 기둥의 128 자리에는 멈춤쇠까지 달려 있습니다. 바늘은 타일에 수직으로 서 있으니, 타일을 아무리 기울여도 바늘이 타일 아래로 내려갈 수는 없기 때문입니다.

이 멈춤쇠가 이 글의 핵심입니다.

## 파랑이 늘 절반 이상인 이유: 탄젠트 공간

방금 그림에서 바늘의 x, y, z는 바닥이 아니라 **타일을 기준으로** 잰 값이었습니다. 노멀맵도 똑같이 합니다. 표면 위의 점마다 작은 좌표계를 하나씩 세우고, 노멀을 그 좌표계로 적습니다. 이 좌표계를 탄젠트 공간(tangent space)이라고 합니다.

- **T**(tangent, 탄젠트): 텍스처의 U가 늘어나는 방향. 노멀맵의 x, 빨강
- **B**(bitangent, 바이탄젠트): 텍스처의 V가 늘어나는 방향. 노멀맵의 y, 초록
- **N**(normal): 원래 메시의 노멀. 노멀맵의 z, 파랑

이 좌표계에서 z는 "표면 바깥쪽"입니다. 그리고 표면의 굴곡이 아무리 심해도, 그 굴곡의 노멀이 원래 표면의 안쪽을 향할 일은 없습니다. 안쪽을 향한다는 것은 표면을 뒤에서 본다는 뜻이니까요. 그래서 탄젠트 공간 노멀은 모두 위쪽 반구 안에 있고, z는 0 이상이며, 파랑은 128 이상입니다.

![탄젠트 공간에서 노멀이 가질 수 있는 방향. 표면 위쪽 반구의 화살표들이 각자의 인코딩 색으로 칠해져 있고, 그 색을 펼친 띠는 청록에서 파랑과 연보라를 지나 분홍으로 이어집니다. 표면 아래쪽 반구에 해당하는 올리브·갈색 계열의 색은 빗금으로 지워져 있습니다.](/images/posts/why-normal-maps-are-blue/hemisphere.svg)

위쪽 반구의 색을 모아 보면 청록에서 파랑과 연보라를 지나 분홍으로 이어집니다. 모두 파랑이 절반 이상 섞인 색입니다. 올리브나 갈색처럼 파랑이 빠진 색은 노멀이 표면 아래를 향해야 나오는 색이라 탄젠트 공간 노멀맵에는 나타나지 않습니다.

여기에 하나가 더 붙습니다. 실제 표면은 대부분 평평합니다. 리벳 하나, 이음매 하나를 빼면 나머지는 그냥 판입니다. 그래서 텍셀 대부분이 `(128, 128, 255)` 근처에 몰려 있고, 그림 전체가 연보랏빛 파란색으로 보입니다. 아래는 이 글의 데모에 쓴 리벳 판 노멀맵의 한 칸을 채널별로 나눈 것입니다.

![리벳 하나가 있는 노멀맵 한 칸과 그 R, G, B 채널을 흑백으로 나눈 그림. R과 G는 대부분 중간 회색이고 리벳과 이음매 테두리에서만 밝거나 어둡습니다. B는 거의 전부 흰색입니다.](/images/posts/why-normal-maps-are-blue/channels.png)

빨강과 초록은 대부분 중간 회색(128)이고 굴곡의 가장자리에서만 밝아지거나 어두워집니다. 파랑은 거의 하얗습니다. 이 텍스처 전체의 평균은 `(128, 128, 251)`이고, 파랑의 최솟값도 228입니다.

## TBN 행렬: 탄젠트 공간에서 월드로

노멀맵의 값이 "표면 기준"이라면, 셰이더는 그 값을 다시 월드 기준으로 돌려놓아야 조명을 계산할 수 있습니다. 이때 쓰는 것이 T, B, N 세 벡터를 열로 세운 행렬, 흔히 TBN 행렬이라고 부르는 것입니다.

$$
\mathbf{n}_{\text{world}} = \begin{bmatrix} \mathbf{T} & \mathbf{B} & \mathbf{N} \end{bmatrix} \mathbf{n}_{\text{tangent}} = n_x\,\mathbf{T} + n_y\,\mathbf{B} + n_z\,\mathbf{N}
$$

식으로 쓰면 거창하지만 하는 일은 단순합니다. 노멀맵에서 읽은 x만큼 T 방향으로, y만큼 B 방향으로, z만큼 N 방향으로 가면 됩니다. 평평한 텍셀 `(0, 0, 1)`은 그냥 N, 즉 메시의 원래 노멀이 됩니다.

![곡면 위 세 점에 각각 T(빨강), B(초록, 지면에서 나오는 방향), N(파랑) 좌표축이 그려져 있습니다. 세 점 모두 같은 텍셀 값 (0.50, 0.00, 0.87)을 읽지만, 점마다 좌표축이 달라 월드에서의 노멀 방향은 서로 다릅니다.](/images/posts/why-normal-maps-are-blue/tbn.svg)

그림의 세 점은 노멀맵에서 똑같은 값을 읽습니다. 그런데 점마다 T, B, N이 다르니 월드에서의 방향은 다 다릅니다. 탄젠트 공간 노멀맵이 "모양"과 상관없이 같은 값으로 굴곡을 표현할 수 있는 이유가 이것입니다. 같은 리벳 판 텍스처를 평면에도, 원통에도, 휘어지는 캐릭터의 팔에도 붙일 수 있습니다.

프래그먼트 셰이더에서는 보통 이렇게 씁니다.

```glsl
vec3 n = texture(normalMap, uv).xyz * 2.0 - 1.0;   // [0,1] -> [-1,1]
mat3 TBN = mat3(normalize(vT), normalize(vB), normalize(vN));
vec3 worldNormal = normalize(TBN * n);
float diffuse = max(dot(worldNormal, lightDir), 0.0);
```

T와 B는 메시의 UV가 어느 방향으로 늘어나는지에서 계산합니다. 같은 메시라도 이 계산법이 조금만 달라지면 노멀맵이 엉뚱하게 보이기 때문에, 요즘은 노멀맵을 굽는 도구와 엔진이 MikkTSpace라는 같은 계산법을 쓰는 경우가 많습니다.

## 오브젝트 공간 노멀맵은 무지갯빛

노멀을 꼭 탄젠트 공간으로 적어야 하는 것은 아닙니다. 모델 자체의 좌표계, 즉 오브젝트 공간으로 적을 수도 있습니다. 같은 리벳 판을 구에 입히고 두 방식으로 구워 보면 차이가 확연합니다.

![같은 리벳 판을 구에 입혀 구운 두 노멀맵. 왼쪽 탄젠트 공간 노멀맵은 전체가 고른 연보랏빛 파란색이고, 오른쪽 오브젝트 공간 노멀맵은 위쪽은 초록, 아래쪽은 보라, 가운데는 파랑, 오른쪽은 빨강인 무지갯빛입니다.](/images/posts/why-normal-maps-are-blue/tangent-vs-object.png)

오브젝트 공간에서는 구의 위쪽 노멀이 +y(초록), 오른쪽이 +x(빨강), 앞쪽이 +z(파랑)를 향하니 텍스처가 무지갯빛이 됩니다. 구의 뒤쪽처럼 -z를 향하는 곳도 있으니 파랑이 0까지 내려갑니다(텍스처의 양 끝). 이 텍스처의 평균은 `(128, 127, 128)`, 그냥 회색입니다.

두 방식은 이런 차이가 있습니다.

| | 탄젠트 공간 | 오브젝트 공간 |
| --- | --- | --- |
| 색 | 연보랏빛 파란색 | 무지갯빛 |
| 셰이더 | TBN이 필요합니다 | 텍스처 값을 그대로 씁니다 |
| 같은 텍스처를 다른 메시에 | 됩니다(타일링, 재사용) | 안 됩니다(그 메시 전용) |
| 뼈대 애니메이션으로 휘는 메시 | 그대로 됩니다 | 휜 만큼 따로 돌려야 합니다 |
| 좌우 대칭 UV | 됩니다 | 어렵습니다 |
| 파랑 채널 생략(아래 압축) | 됩니다 | 안 됩니다 |

게임에서 보는 노멀맵이 거의 다 파란 이유는 결국 대부분이 탄젠트 공간 노멀맵이기 때문입니다. 재사용과 애니메이션의 이점이 워낙 커서입니다.

## 직접 만져 보기

아래 실험실에서 지금까지의 이야기를 한 번에 확인할 수 있습니다. 왼쪽은 저장된 텍스처, 오른쪽은 그 텍스처를 입힌 구를 셰이더로 그린 결과입니다.

- **공간**을 바꾸면 같은 굴곡이 파란 텍스처와 무지갯빛 텍스처로 구워집니다. 결과는 둘 다 똑같습니다.
- **채널**을 B로 바꿔 보세요. 탄젠트 공간에서는 거의 하얗고, 오브젝트 공간에서는 양 끝(구의 뒤쪽)이 검게 내려갑니다.
- 텍스처 위에 포인터를 올리면 그 텍셀의 RGB와 디코딩한 벡터가 보입니다.
- 구 위에서 포인터를 움직이면 조명이 따라갑니다.

> 인터랙티브 데모: 같은 리벳 판의 노멀을 탄젠트 공간과 오브젝트 공간으로 구워 텍스처와 조명 결과를 나란히 보여 줍니다. 채널을 하나씩 볼 수 있고, DirectX 규약 텍스처를 OpenGL 규약으로 읽으면 음영이 뒤집히는 것도 볼 수 있습니다. — https://hyna.dev/posts/why-normal-maps-are-blue

## OpenGL과 DirectX: 초록 채널 하나 차이

노멀맵을 다른 엔진으로 옮기면 굴곡이 뒤집혀 보일 때가 있습니다. 볼록한 리벳이 오목하게 파인 것처럼 보이는 식입니다. 원인은 거의 늘 초록 채널입니다.

T가 U 방향이라는 데는 다들 동의합니다. 그런데 B, 즉 V가 늘어나는 방향이 이미지에서 위쪽이냐 아래쪽이냐는 규약이 둘로 갈립니다.

- **OpenGL 규약(Y+)**: 초록이 클수록 이미지의 위쪽을 향합니다. Unity, Blender, Godot이 이쪽입니다.
- **DirectX 규약(Y-)**: 초록이 클수록 이미지의 아래쪽을 향합니다. Unreal Engine이 이쪽입니다.

![같은 리벳을 두 규약으로 구운 노멀맵. 왼쪽 OpenGL 규약은 리벳의 위쪽 가장자리가 초록빛이 돌고 아래쪽이 보랏빛입니다. 오른쪽 DirectX 규약은 반대로 아래쪽 가장자리가 초록빛입니다.](/images/posts/why-normal-maps-are-blue/gl-vs-dx.png)

두 텍스처는 빨강과 파랑이 완전히 같고, 초록만 뒤집혀 있습니다. 눈으로 구분하는 요령도 여기서 나옵니다. 볼록한 굴곡의 위쪽 가장자리가 초록빛이면 OpenGL, 아래쪽 가장자리가 초록빛이면 DirectX입니다. 위의 실험실에서 텍스처 Y를 DirectX로 바꿔 보면, OpenGL 규약으로 읽는 셰이더에서 리벳의 위아래 음영이 뒤바뀌는 것을 볼 수 있습니다.

실제 텍스처에서도 똑같습니다. 앞에서 본 벽돌 텍스처는 두 규약의 노멀맵을 모두 제공합니다. 같은 부분을 확대해 보면 왼쪽(OpenGL)은 벽돌의 윗가장자리가, 오른쪽(DirectX)은 아랫가장자리가 초록빛입니다.

![같은 벽돌 노멀맵의 같은 부분을 확대한 두 장. 왼쪽 OpenGL판은 벽돌마다 윗가장자리가 초록빛이고, 오른쪽 DirectX판은 아랫가장자리가 초록빛입니다.](/images/posts/why-normal-maps-are-blue/bricks-gl-vs-dx.webp)

고치는 방법은 간단합니다. 초록 채널을 뒤집으면 됩니다(`G' = 255 - G`). 대부분의 엔진은 텍스처 설정에 초록 채널 뒤집기 옵션을 두고 있습니다.

## 덤: 파란 채널은 버려도 됩니다

탄젠트 공간에서 z가 늘 0 이상이라는 사실에는 쓸모가 하나 더 있습니다. 노멀은 길이가 1이므로, x와 y만 알면 z는 계산할 수 있습니다.

$$
n_z = \sqrt{1 - n_x^2 - n_y^2}
$$

제곱근은 양수와 음수 두 답이 나오는데, 탄젠트 공간에서는 z가 음수일 수 없으니 양수 쪽을 고르면 됩니다. 그래서 많은 엔진이 노멀맵을 BC5처럼 두 채널만 담는 압축 형식으로 저장하고, 파랑은 셰이더에서 다시 계산합니다. 같은 용량에 R과 G를 더 정밀하게 담을 수 있습니다.

결국 노멀맵이 파란 이유는, 파랑이 너무 뻔해서 아예 저장하지 않아도 될 만큼이라는 이야기로 이어집니다.

## 정리

- 노멀맵은 방향을 색으로 적은 표입니다. 각 성분을 `n / 2 + 1/2`로 0–255에 옮깁니다.
- 평평한 표면의 노멀 `(0, 0, 1)`은 `(128, 128, 255)`, 그 연보랏빛 파란색입니다.
- 탄젠트 공간에서 z는 표면 바깥쪽이라 늘 0 이상입니다. 그래서 파랑은 128 아래로 내려가지 않습니다.
- 셰이더는 TBN 행렬로 탄젠트 공간 노멀을 월드로 돌려놓습니다.
- 오브젝트 공간 노멀맵은 무지갯빛이고, 탄젠트 공간 노멀맵만큼 재사용하기 어렵습니다.
- OpenGL과 DirectX 규약은 초록 채널의 방향만 다릅니다.
- z가 늘 양수이니 파랑은 아예 버리고 다시 계산할 수도 있습니다.
