노말맵 개발자는 천재다
벽돌 하나하나의 요철을 폴리곤으로 만들어 본 적이 있다면 그 절망을 안다. 벽 한 장 만드는 데 버텍스가 수천 개씩 들어간다. 그 벽이 캐릭터 갑옷이면 더 심각하다. 리깅이 걸린 오브젝트는 버텍스가 늘어난 만큼 스키닝 연산이 그대로 따라 늘어나니까. 그렇다고 굴곡을 뭉개면 모델링 퀄리티가 무너진다. 어느 쪽으로 가도 손해인 상황이다.
이 딜레마를 정면으로 깨버린 게 노말맵이다. 버텍스는 밋밋하게 두고, 굴곡 정보만 따로 떼어내 텍스처에 얹어놓는다. 그리고 렌더링할 때 그 텍스처를 읽어서 빛에 반응시킨다. 나는 이 발상을 볼 때마다 감탄한다. 문제를 푼 게 아니라, 문제가 성립하는 전제 자체를 바꿔버렸기 때문이다.
형상이 아니라 빛만 속이면 된다는 통찰
노말맵의 핵심은 “우리가 굴곡을 인식하는 건 형상 때문이 아니라 명암 때문"이라는 관찰이다.
Unity Korea의 알쓸유잡 - 노말맵에 대한 이해 영상은 이 부분을 밝기 계산으로 설명한다. 면에 빛이 수직으로 들어오면 밝기는 1이다. 면이 45도로 기울면 같은 양의 빛이 더 넓은 면적을 덮어야 하니까 코사인 45도, 즉 약 0.7로 떨어진다. 그래서 셰이더는 노말 벡터와 라이트 벡터를 내적해 음영을 만든다.
여기서 결론이 나온다. 밝기를 결정하는 건 실제 폴리곤이 아니라 노말 벡터 하나다. 그러면 폴리곤은 놔두고 노말 벡터만 픽셀 단위로 바꿔치기하면, 눈은 굴곡이 있다고 믿는다.
나는 이게 최적화의 교과서적인 사례라고 본다. 보통 최적화는 “덜 하기"다. 노말맵은 “다른 걸 하기"다. 비용이 비싼 지오메트리 문제를, 비용이 싼 텍스처 샘플링 문제로 번역해버린 거다.
비용을 어디로 옮겼는지 뜯어보면
노말맵을 쓰면 무엇이 줄고 무엇이 늘어나는지 정리하면 이렇다.
| 항목 | 폴리곤으로 굴곡 표현 | 노말맵으로 굴곡 표현 |
|---|---|---|
| 메시 데이터 크기 | 버텍스 수만큼 증가 | 거의 그대로 |
| 버텍스 셰이더 연산 | 버텍스 수에 비례 | 거의 그대로 |
| 스키닝(본 연산) | 버텍스 수에 비례해 폭증 | 거의 그대로 |
| 프래그먼트 셰이더 | 기본 | 텍스처 샘플 1회 + 행렬 곱 추가 |
| 텍스처 메모리 | 없음 | 노말맵 한 장만큼 증가 |
이 표에서 내가 가장 중요하게 보는 줄은 스키닝이다. 정적인 배경 오브젝트야 버텍스가 좀 많아도 버틴다. 하지만 본이 붙은 캐릭터는 매 프레임 모든 버텍스에 본 행렬을 먹여야 한다. 캐릭터 갑옷의 장식 요철을 폴리곤으로 만드는 순간, 그 비용이 프레임마다 반복된다. 노말맵은 이 반복 비용을 통째로 없애고, 대신 프래그먼트 셰이더에 약간의 부하만 남긴다.
거래 조건이 이 정도면 고민할 이유가 없다.
예전엔 사치였고, 지금은 기본이다
내가 모바일 3D 게임을 만들던 시절에는 노말맵이 사치였다. 텍스처 한 장 더 얹는 것 자체가 메모리와 대역폭 부담이었고, “노말맵 쓸 거면 그 예산으로 텍스처 해상도를 올리자"는 판단이 나오던 때였다.
영상에서도 비슷한 이야기를 한다. 5년 전만 해도 모바일에서 노말맵을 쓸지 말지 걱정을 많이 했는데, 지금은 디바이스 성능이 좋아져서 기본으로 쓸 수 있는 수준이 됐다는 설명이다. Unity의 URP, HDRP 표준 셰이더가 노말맵 슬롯을 기본 제공하는 것도 같은 맥락이다.
지금 내 입장은 이렇다. 연산이 남아돌아도 노말맵은 쓴다. 연산이 남는다고 폴리곤을 늘리는 선택보다, 노말맵으로 처리하고 남은 예산을 다른 곳에 쓰는 게 항상 낫기 때문이다. 노말맵을 뺀 자리에 들어오는 건 폴리곤이고, 폴리곤은 메모리·대역폭·스키닝을 동시에 먹는다.
노말맵은 왜 푸르딩딩한가
그래픽스 초보이던 시절, 이 질문이 계속 걸렸다. 왜 노말맵은 하나같이 저 연보라색인가. 그래서 Game Development Stack Exchange에 질문을 올렸다. 답변들이 잘 달렸고, 그때 이해한 내용이 지금까지 노말맵을 다루는 기준이 됐다.

위 이미지가 원리를 그대로 보여준다. 배경은 아무 편차가 없는 균일한 연보라색이다. 원숭이 머리에서 정면을 마주 보는 면들도 배경과 비슷한 연보라 계열이다. 반면 왼쪽으로 꺾인 면은 파랑·청록으로, 오른쪽으로 꺾인 면은 분홍·자홍으로, 위를 향한 정수리 쪽은 초록빛으로 물든다. 면이 어느 쪽으로 돌아섰는지가 그대로 색이 된다.
원리는 이렇다.
- 노말은 벡터다. x, y, z 각각 -1에서 1 사이 값을 가진다.
- 텍스처에는 음수를 저장할 수 없다. 그래서
(값 + 1) / 2로 0~1 범위에 밀어 넣고, 셰이더에서 읽을 때 다시 펼친다. - x는 R, y는 G, z는 B 채널에 들어간다.
- 노말맵에 저장되는 건 면 기준의 상대적인 편차다. 그래서 z는 항상 양수다. 뒤를 향하는 노말은 저장될 일이 없다.
- z가 항상 양수 → B 채널 값이 항상 높음 → 전체적으로 푸르딩딩.
편차가 전혀 없는 픽셀은 (0.5, 0.5, 1), 즉 딱 그 연보라색이 된다. 위 이미지의 배경이 정확히 그 색이다. 노말맵을 열었을 때 균일한 파란 영역은 “여기는 원래 면 그대로"라는 뜻이고, 붉거나 초록으로 얼룩진 부분은 “여기는 면 방향이 이만큼 꺾여 있다"는 뜻이다.
이걸 알고 나면 노말맵 텍스처를 눈으로 읽을 수 있게 된다. 아티스트가 뽑아준 노말맵이 이상하게 흐릿하거나, 특정 영역만 거의 흰색에 가깝다면 바로 의심할 수 있다.
z를 버려서 품질을 얻는 트릭
여기서 한 걸음 더 나가면 압축 이야기가 나온다.
노말 벡터의 길이는 1이다. 그러면 피타고라스에 의해 x² + y² + z² = 1이고, 정리하면 z = √(1 - x² - y²)가 된다. z는 저장할 필요 없이 셰이더에서 계산해낼 수 있다. 앞에서 z가 항상 양수라고 했으니 부호 모호성도 없다.
영상에서는 이 원리로 DXT5nm, ASTC의 노말맵 전용 압축 모드를 설명한다. 세 채널에 나눠 쓸 정밀도를 두 채널에 몰아주니 같은 용량에서 품질이 눈에 띄게 좋아진다. Unity가 텍스처 타입을 Normal Map으로 지정하라고 경고창을 띄우는 이유가 바로 이것이다. Default로 두면 이 특수 처리를 못 받는다.
실무에서 챙길 체크리스트는 이 정도다.
- 노말맵 텍스처 타입이 Normal Map으로 지정되어 있는가
- 인스펙터에 “This texture is not marked as a normal map” 경고가 남아 있지 않은가
- sRGB 옵션이 꺼져 있는가 (노말맵은 색이 아니라 데이터라 감마 보정 대상이 아니다)
- 커스텀 셰이더에서 언팩 함수를 거치지 않고 텍스처를 raw로 읽고 있지는 않은가
압정을 어느 방향으로 꽂을 것인가
노말맵 이론에서 마지막 관문이 탄젠트 스페이스다. 영상의 비유가 좋았다. 공에 압정을 꽂는다고 할 때, 꽂히는 지점은 노말이 정해준다. 그런데 압정에 매달린 종이는 그 자리에서 빙글빙글 돌 수 있다. 회전 기준이 없다.
그래서 UV 좌표를 따라 탄젠트 벡터를 만들고, 노말과 탄젠트를 외적해 바이탄젠트를 뽑는다. 이 세 축이 3×3 행렬을 이루고, 노말맵에서 읽은 값에 이걸 곱하면 월드 스페이스 노말이 나온다. 노말은 회전만 다루므로 4×4가 아니라 3×3이면 충분하다.
Shader Graph에는 Transform 노드가 있어서 Tangent → World, 타입 Normal로 지정하면 이 과정을 한 번에 처리해준다. 그런데 나는 이걸 노드 하나로 끝내고 넘어가는 걸 권하지 않는다. 커스텀 라이팅이나 카툰 렌더링을 직접 짜기 시작하면 반드시 이 안쪽을 열어야 하는 순간이 온다.
특히 실무에서 자주 터지는 지점이 이거다. 노말을 월드로 올릴 것인가, 라이트를 탄젠트로 내릴 것인가. 둘 다 정답이다. 두 벡터의 기준 공간만 맞으면 된다. URP는 월드 스페이스 기준으로 통일하고 있어서 셰이더 코드에 normalWS, lightDirectionWS처럼 접미사가 붙어 있다. 이 네이밍 규칙(TS는 탄젠트, WS는 월드, OS는 오브젝트)만 알아도 URP 셰이더 코드를 읽는 속도가 확 달라진다.
원리를 아는 게 왜 실익이 되는가
“노말맵 슬롯에 넣으면 알아서 되는데 굳이 원리까지 알아야 하나"는 반론이 있을 수 있다. 셰이더를 안 건드린다면 맞는 말이다.
하지만 내 경험상 문제는 항상 셰이더를 건드리기 시작할 때 터진다. 그리고 그때 원리를 모르면 증상만 보고 헤맨다. 이런 것들이다.
- 노말맵을 넣었는데 굴곡이 반대로 파여 보인다 → 그린 채널 방향 문제(DirectX 방식 vs OpenGL 방식)
- 노말맵이 거의 안 먹는다 → 텍스처 타입이 Default라 언팩이 잘못됐거나 sRGB가 켜져 있음
- 커스텀 셰이더에서 조명이 이상하다 → 노말과 라이트 벡터의 기준 공간이 안 맞음
- 확대하면 노말맵이 뭉개진다 → 노말맵 압축 모드를 안 쓰고 일반 압축을 씀
이 네 가지는 전부 원리를 알면 증상만 보고 원인을 짚을 수 있다. 반대로 모르면 각각을 별개의 미스터리로 취급하게 된다.
노말맵 옆의 형제들 — 왜 노말맵이 살아남았나
노말맵과 헷갈리는 기법이 패럴랙스 매핑과 디스플레이스먼트 매핑이다. 영상의 정리를 내 관점으로 요약하면 이렇다.
| 기법 | 저장하는 것 | 실제 형상 변화 | 비용 | 실무 사용도 |
|---|---|---|---|---|
| 노말맵 | 면 기준 방향 편차 | 없음 | 낮음 | 사실상 필수 |
| 패럴랙스 매핑 | 높이(하이트맵) | 없음 (깊이감만) | 중~높음 | 거의 안 쓰임 |
| 디스플레이스먼트 | 높이(하이트맵) | 있음 (버텍스 조작) | 높음 (테셀레이션 필요) | PC/콘솔 위주 |
패럴랙스가 사라진 이유가 흥미롭다. 노말맵은 해당 픽셀만 보면 되는데, 패럴랙스는 인접 픽셀끼리 가려지는지를 따져야 한다. 그래서 연산이 붙는다. 게다가 텍셀 해상도에 묶여 있어서 화면이 움직이면 지글거린다. 영상에서는 이를 두고 “가성비가 똥"이라 표현하며, 그럴 바에 디스플레이스먼트로 가는 게 낫다고 정리한다.
디스플레이스먼트는 실제 버텍스를 움직이니 결과는 확실하다. 다만 제대로 쓰려면 테셀레이션이 필요하고, 모바일에서는 하드웨어 제약이 여전히 크다. 실루엣까지 진짜로 바꿔야 하는 게 아니라면 노말맵이 가장 합리적인 선택으로 남는다.
여기서도 노말맵의 설계가 왜 훌륭한지가 드러난다. 딱 필요한 만큼만 속인다. 정면에서 본 명암만 속이고, 실루엣이나 시차는 포기한다. 그 포기 덕분에 연산이 픽셀 하나로 닫히고, 그래서 20년 가까이 살아남았다.
마치며
- 노말맵의 천재성은 굴곡을 표현한 데 있지 않고, 지오메트리 문제를 텍스처 문제로 바꿔버린 발상에 있다.
- 특히 본이 붙은 캐릭터에서 폴리곤 절감은 프레임마다 반복되는 스키닝 비용을 줄여준다. 이게 가장 큰 이득이다.
- 노말맵이 푸르딩딩한 건 z가 항상 양수라 B 채널이 높기 때문이고, 이 성질 덕분에 z를 빼고 저장해 압축 품질을 높일 수 있다.
- 연산이 남아도는 환경에서도 노말맵은 쓴다. 뺀 자리에 들어올 폴리곤이 더 비싸다.
- 셰이더를 직접 건드릴 계획이라면 탄젠트 스페이스는 미룰 수 없다. 노드 하나로 넘어가면 문제가 터졌을 때 원인을 못 짚는다.
자주 묻는 질문
Q. 노말맵은 왜 파란색인가?
노말맵의 z값은 면 기준 상대 방향이라 항상 양수다. z는 B 채널에 저장되므로 B값이 늘 높게 유지되고, 그래서 전체가 푸르딩딩하게 보인다. 편차가 전혀 없는 픽셀은 (0.5, 0.5, 1)이라 정확히 그 연보라색이 된다.
Q. 요즘 모바일도 성능이 좋은데 노말맵 대신 폴리곤을 늘리면 안 되나?
권하지 않는다. 폴리곤을 늘리면 메시 데이터, 버텍스 셰이더, 스키닝 비용이 동시에 늘어난다. 노말맵은 텍스처 한 장과 프래그먼트 셰이더의 약간의 부하로 끝난다. 성능이 남는다면 그 예산을 폴리곤이 아닌 다른 곳에 쓰는 편이 낫다.
Q. Unity에서 노말맵 넣었는데 경고창이 뜬다. 그냥 무시해도 되나?
Fix를 누르는 게 맞다. 텍스처 타입을 Normal Map으로 지정해야 감마 보정 제외와 노말맵 전용 압축(DXT5nm, ASTC 노말 모드)이 적용된다. Default로 두면 같은 용량에서 품질이 떨어지고, 언팩 과정도 의도대로 동작하지 않는다.
Q. 노말맵과 하이트맵(패럴랙스)은 뭐가 다른가?
노말맵은 면 방향의 편차를 저장하고, 하이트맵은 높이 값을 저장한다. 패럴랙스는 하이트맵으로 깊이감까지 만들지만 인접 픽셀의 차폐를 계산해야 해서 비용이 크고 움직일 때 지글거린다. 실무에서는 거의 쓰이지 않고, 그 정도 표현이 필요하면 디스플레이스먼트로 간다.
Q. 탄젠트 스페이스를 꼭 알아야 하나?
기본 셰이더만 쓴다면 몰라도 된다. 하지만 커스텀 라이팅이나 카툰 렌더링을 직접 구현하는 순간 반드시 필요하다. 노말맵의 값은 면 기준 상대 좌표라서, 라이트 벡터와 같은 공간으로 맞춰주지 않으면 조명이 엉뚱하게 나온다.
