---
title: "노멀맵은 왜 파란가"
description: "방향 벡터를 색 채널에 욱여넣으면 그 밋밋한 하늘색이 나옵니다. 그리고 그 방향이 기준으로 삼는 축이 탄젠트 공간입니다."
author: "유니얼"
pubDate: 2026-09-14
category: "game-dev/graphics"
tags: ["렌더링", "텍스처", "셰이더"]
canonical: https://blog.unialgames.com/posts/normal-map-tangent-space/
---
노멀맵 파일을 처음 열어 보면 당황스럽습니다. 벽돌이든 나무든 천이든
전부 비슷한 **밋밋한 하늘색**입니다. 자세히 보면 보라와 연두가 섞여
있는데 전체적으로는 하늘색입니다.

이유는 두 가지입니다. 방향을 색에 담는 방식 때문이고, 그 방향이
기준으로 삼는 축 때문입니다.

## 방향을 색 채널에 욱여넣기

법선은 x, y, z 가 각각 -1에서 1 사이인 벡터입니다.
텍스처 한 채널은 0에서 255 사이의 정수 하나입니다.

범위가 안 맞으니 옮겨 심습니다.

```
저장할 때   c = n * 0.5 + 0.5      (-1 ~ 1  →  0 ~ 1)
읽을 때     n = c * 2.0 - 1.0      (0 ~ 1   →  -1 ~ 1)
```

```hlsl
float3 n = tex2D(_BumpMap, uv).xyz * 2.0 - 1.0;
```

x 가 R, y 가 G, z 가 B 로 갑니다. 아래에서 법선이 도는 동안 세 채널이
어떻게 따라 움직이는지 볼 수 있습니다.

<x-tbn>
<script type="application/json">
{
  "label": "법선 하나가 색 하나가 되는 과정",
  "view": "color",
  "note": "원판은 법선을 위에서 내려다본 것입니다. 화살표가 짧을수록 법선이 정면을 향해 서 있습니다."
}
</script>
</x-tbn>

처음 한동안 화살표가 가운데에 서 있습니다. 이때 법선은 `(0, 0, 1)` 이고
색은 **`(128, 128, 255)`** 입니다. 그 하늘색이 바로 이 값입니다.

## 대부분의 픽셀이 거의 안 기울어 있습니다

노멀맵이 전체적으로 하늘색인 건 **대부분의 자리가 별로 안 파여 있기**
때문입니다. 벽돌 벽이라도 줄눈 몇 줄을 빼면 나머지는 평평합니다.

그리고 z 는 거의 항상 양수이고 1에 가깝습니다. 표면 안쪽으로 파고드는
법선은 없기 때문입니다. 그래서 **B 채널은 늘 높고 R 과 G 만 128 근처에서
흔들립니다.** 하늘색이 나오는 이유입니다.

여기서 실수가 하나 나옵니다. 노멀맵을 sRGB 텍스처로 읽으면 엔진이
감마 보정을 걸어 버립니다. 색이 아니라 좌표인데 값이 휘어서 조명이
미묘하게 어긋납니다. 노멀맵은 **리니어로** 읽어야 합니다.
Unity 라면 임포터에서 텍스처 타입을 Normal map 으로 잡는 것이 이 일을 합니다.

## `(0, 0, 1)` 은 어느 쪽인가

여기서 진짜 질문이 나옵니다. z 가 1 이라는 건 z 축 방향이라는 뜻인데,
**그 z 축이 어느 쪽을 가리키고 있나요?**

월드의 위쪽이라고 하면 곤란합니다. 그러면 벽에 붙인 노멀맵은 벽의
법선이 옆을 보는데 값은 위를 가리키게 됩니다. 같은 텍스처를 바닥과 벽에
같이 못 씁니다. 캐릭터가 움직이면 아예 못 씁니다.

그래서 노멀맵의 값은 월드 기준이 아니라 **그 면 기준**으로 적습니다.

## 탄젠트 공간

면마다 축 세 개를 세웁니다.

| 축 | 어느 방향 |
| --- | --- |
| **N** | 면의 수직. 그 자리의 원래 법선입니다 |
| **T** (탄젠트) | 면을 따라 눕는 방향. UV 의 u 가 늘어나는 쪽입니다 |
| **B** (바이탄젠트) | 나머지 한 축. UV 의 v 가 늘어나는 쪽입니다 |

노멀맵에 적힌 값은 이 세 축을 기준으로 한 방향입니다.
`(0, 0, 1)` 은 "N 방향 그대로, 안 기울었다" 는 뜻이고,
`(0.5, 0, 0.87)` 은 "T 쪽으로 30도쯤 기울었다" 는 뜻입니다.

**면이 돌면 T 와 N 도 같이 돕니다.** 그래서 텍스처에 적힌 값은
하나도 안 바뀌는데 결과로 나오는 월드 법선은 계속 달라집니다.

<x-tbn>
<script type="application/json">
{
  "label": "면이 기울면 기준 축도 같이 기웁니다",
  "view": "space",
  "mapAngle": 38,
  "note": "왼쪽 색 조각이 텍스처에 적힌 값입니다. 면이 어떻게 돌아도 그 값은 안 변합니다."
}
</script>
</x-tbn>

이것 때문에 노멀맵 한 장을 벽에도 바닥에도 붙일 수 있고,
스키닝으로 구부러지는 캐릭터 팔에도 붙일 수 있습니다.

## 옮겨 심기

탄젠트 공간의 법선을 조명 계산에 쓰려면 월드로 옮겨야 합니다.
T, B, N 을 행으로 쌓은 3×3 행렬을 곱하면 됩니다.

```hlsl
float3x3 TBN = float3x3(T, B, N);
float3 worldNormal = normalize(mul(nTangent, TBN));
```

반대로 하는 방법도 흔합니다. 법선을 월드로 올리는 대신 **빛 방향과
시선 방향을 탄젠트 공간으로 끌어내리는 것**입니다. 그쪽 변환은 정점
셰이더에서 한 번만 하면 되고 픽셀 셰이더는 곱셈이 줄어듭니다.
방향 벡터가 두 개뿐인 상황에서는 이쪽이 더 쌉니다.

## 왜 B 는 저장 안 하나

세 축이 서로 수직이니 **B 는 N 과 T 의 외적으로 만들 수 있습니다.**
정점마다 벡터 하나를 안 들고 다녀도 됩니다.

다만 UV 를 좌우로 뒤집어 붙인 면에서는 B 의 부호가 반대여야 합니다.
그래서 탄젠트를 `float4` 로 두고 w 에 +1 또는 -1 만 넣어 둡니다.

```hlsl
float3 B = cross(N, T.xyz) * T.w;
```

캐릭터 얼굴처럼 좌우 대칭 UV 를 쓰는 모델에서 한쪽 볼의 요철만
반대로 파여 보인다면 이 부호가 어긋난 것입니다.

## 두 채널만 저장하기

z 가 거의 항상 양수이고 벡터 길이가 1 이라는 걸 알고 있으면
**x 와 y 만 저장해도 z 를 되살릴 수 있습니다.**

```hlsl
float2 xy = tex2D(_BumpMap, uv).ag * 2.0 - 1.0;
float  z  = sqrt(saturate(1.0 - dot(xy, xy)));
```

BC5 같은 두 채널 압축 포맷이 이 성질을 씁니다. 세 채널을 다 쓰는
압축보다 용량이 작으면서 품질이 좋습니다. 법선은 색과 달리 값이 조금만
어긋나도 광택이 얼룩지기 때문에 이 차이가 눈에 보입니다.

## 정리

- 방향을 색에 담을 때 `-1 ~ 1` 을 `0 ~ 1` 로 옮겨 심습니다. 안 기울어진 법선 `(0, 0, 1)` 이 `(128, 128, 255)` 가 됩니다.
- 대부분의 자리가 거의 안 기울어 있고 z 는 늘 양수라 B 채널이 항상 높습니다. 그래서 전체가 하늘색입니다.
- 노멀맵은 감마가 걸리면 안 되는 좌표 데이터입니다. 리니어로 읽어야 합니다.
- 값의 기준은 월드가 아니라 면에 붙은 T, B, N 세 축입니다. 면이 돌면 축이 같이 돌아서 같은 텍스처를 어디에나 붙일 수 있습니다.
- B 는 외적으로 만들고 부호만 탄젠트의 w 에 담습니다. 좌우 대칭 UV 에서 이 부호가 어긋나면 한쪽만 반대로 파여 보입니다.
- x 와 y 만 저장하고 z 를 복원하면 용량이 줄면서 품질이 좋아집니다.