노멀맵은 왜 파란가
노멀맵 파일을 처음 열어 보면 당황스럽습니다. 벽돌이든 나무든 천이든 전부 비슷한 밋밋한 하늘색입니다. 자세히 보면 보라와 연두가 섞여 있는데 전체적으로는 하늘색입니다.
이유는 두 가지입니다. 방향을 색에 담는 방식 때문이고, 그 방향이 기준으로 삼는 축 때문입니다.
방향을 색 채널에 욱여넣기
법선은 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)
float3 n = tex2D(_BumpMap, uv).xyz * 2.0 - 1.0;
x 가 R, y 가 G, z 가 B 로 갑니다. 아래에서 법선이 도는 동안 세 채널이 어떻게 따라 움직이는지 볼 수 있습니다.
처음 한동안 화살표가 가운데에 서 있습니다. 이때 법선은 (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 도 같이 돕니다. 그래서 텍스처에 적힌 값은 하나도 안 바뀌는데 결과로 나오는 월드 법선은 계속 달라집니다.
이것 때문에 노멀맵 한 장을 벽에도 바닥에도 붙일 수 있고, 스키닝으로 구부러지는 캐릭터 팔에도 붙일 수 있습니다.
옮겨 심기
탄젠트 공간의 법선을 조명 계산에 쓰려면 월드로 옮겨야 합니다. T, B, N 을 행으로 쌓은 3×3 행렬을 곱하면 됩니다.
float3x3 TBN = float3x3(T, B, N);
float3 worldNormal = normalize(mul(nTangent, TBN));
반대로 하는 방법도 흔합니다. 법선을 월드로 올리는 대신 빛 방향과 시선 방향을 탄젠트 공간으로 끌어내리는 것입니다. 그쪽 변환은 정점 셰이더에서 한 번만 하면 되고 픽셀 셰이더는 곱셈이 줄어듭니다. 방향 벡터가 두 개뿐인 상황에서는 이쪽이 더 쌉니다.
왜 B 는 저장 안 하나
세 축이 서로 수직이니 B 는 N 과 T 의 외적으로 만들 수 있습니다. 정점마다 벡터 하나를 안 들고 다녀도 됩니다.
다만 UV 를 좌우로 뒤집어 붙인 면에서는 B 의 부호가 반대여야 합니다.
그래서 탄젠트를 float4 로 두고 w 에 +1 또는 -1 만 넣어 둡니다.
float3 B = cross(N, T.xyz) * T.w;
캐릭터 얼굴처럼 좌우 대칭 UV 를 쓰는 모델에서 한쪽 볼의 요철만 반대로 파여 보인다면 이 부호가 어긋난 것입니다.
두 채널만 저장하기
z 가 거의 항상 양수이고 벡터 길이가 1 이라는 걸 알고 있으면 x 와 y 만 저장해도 z 를 되살릴 수 있습니다.
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 를 복원하면 용량이 줄면서 품질이 좋아집니다.