노멀맵 생성 가이드

노멀맵 생성기는 이미지 한 장의 밝기(휘도)를 표면의 높이로 해석하고, 그 기울기에서 탄젠트 공간 노멀맵을 만들어 냅니다. 실제 3D 지오메트리(고폴리 메시)를 참조하지 않고 2D 픽셀의 밝기 패턴만으로 높이를 추정하는 방식이라, 고폴리→저폴리 베이크와는 정보의 출처가 다른 근사치입니다. 아래 문서는 이 파이프라인이 실제로 무엇을 계산하는지와, 결과를 게임 엔진에 넣을 때 어디서 어긋나는지를 이 도구의 구현(Sobel 코어) 기준으로 정리합니다.

작동 원리 — 휘도에서 노멀까지

변환은 다섯 단계 파이프라인입니다: ① 픽셀의 색을 휘도로 바꿔 높이장을 만들고, ② 필요하면 블러로 다듬은 뒤, ③ Sobel 연산자로 가로·세로 기울기를 구하고, ④ 이 기울기로 법선 벡터를 만들어, ⑤ [−1,1] 범위를 [0,255] 색으로 인코딩합니다. 작은 이미지는 메인 스레드에서, 큰 이미지는 백그라운드 워커에서 처리하지만 두 경로가 완전히 같은 함수를 호출하므로 결과 로직은 한 벌입니다.

  • 휘도(높이) 계산 — Rec.601 가중치 (0.299·R + 0.587·G + 0.114·B) / 255를 픽셀마다 적용해 [0,1] 높이장을 만듭니다. 밝을수록 높다고 봅니다. 알파 채널은 이 단계에서 건드리지 않고 따로 다룹니다.
  • Sobel 기울기 — 표준 3×3 커널(Gx = [[−1,0,1],[−2,0,2],[−1,0,1]], Gy는 그 전치)로 각 픽셀의 가로 기울기 dx와 세로 기울기 dy를 구합니다. Sobel–Feldman 연산자(1968, Irwin Sobel & Gary Feldman, 스탠퍼드 AI Lab) 원문이 정의하는 커널이 정확히 Gx = [[−1,0,+1],[−2,0,+2],[−1,0,+1]], Gy = [[−1,−2,−1],[0,0,0],[+1,+2,+1]]이고, 이 도구의 구현도 같은 계수를 그대로 씁니다 — 원문과 이 도구 사이에 변형이 없다는 뜻입니다. 다만 입력이 되는 높이장 자체는 Sobel 원문이 정의하지 않는 이 도구의 선택입니다: 위에서 설명한 휘도 0.299R + 0.587G + 0.114B로 만든 [0,1] 스칼라장을 “이미지”로 놓고 그 위에 커널을 그대로 컨볼브합니다.
  • 법선 인코딩 — nx = −dx·강도, ny = ±dy·강도, nz = 1로 벡터를 만들고 정규화한 뒤 round((n·0.5+0.5)·255)로 색을 냅니다. 경사가 없는 평탄한 면은 자연히 (128, 128, 255)—연보라—로 수렴하고, 출력 알파는 원본 알파를 그대로 보존합니다.

핵심은 이 도구가 단일 2D 이미지의 밝기만으로 높이를 “추정”할 뿐 실제 3D 형상을 참조하지 않는다는 점입니다. LearnOpenGL은 노멀맵을 “지오메트리를 실제로 바꾸지 않고도 표면이 복잡해 보이게 하는 착시”—즉 조명 계산에 들어가는 법선을 프래그먼트 단위로 치환하는 기법—으로 설명합니다. 노멀맵이 지오메트리의 대체재가 아니라 조명 입력의 치환이라는 이 원칙은 이 도구의 산출물에도 그대로 적용됩니다. 참고로 “단일 높이 입력에서 기울기로 노멀을 유도”하는 접근 자체는 정당한 기법입니다 — Unity ShaderGraph의 공식 “Normal From Height” 노드도 ddx/ddy 미분(기울기) 연산으로 높이를 노멀로 바꿉니다. 다만 그 노드는 강도 파라미터 없이 출력 공간(탄젠트/월드) 토글만 갖는다는 점에서 이 도구의 강도 슬라이더와 똑같지는 않습니다.

Y축 규격과 엔진 매핑 (OpenGL vs DirectX)

노멀맵에서 가장 자주 어긋나는 지점이 초록(G) 채널의 방향입니다. 이 도구의 Y축 옵션은 OpenGL(+1)과 DirectX(−1) 사이에서 G 채널 방향을 바꾸며, 내부적으로 ny = Y부호 · dy · 강도로 곱해집니다. 구현상 dy 항은 dx 항과 달리 부호를 반전하지 않고 그대로(+dy) 씁니다 — 배열의 y는 아래로 증가하지만 OpenGL의 +Y(초록)는 화면 위를 향하므로, X항(−dx)과 반대 부호가 되는 것이 의도된 설계입니다. 이 부호를 뒤집으면 OpenGL↔DirectX 매핑 전체가 통째로 뒤바뀝니다.

Adobe(Substance) 문서는 두 포맷의 차이를 OpenGL이 첫 픽셀을 아래(bottom), DirectX가 위(top)에 둔다고 설명하며, 관례적으로 OpenGL=Y+, DirectX=Y−로 부릅니다. 다만 어느 엔진이 어느 쪽인지는 “대상 애플리케이션을 확인하라”고만 안내하므로, 아래에 엔진별 매핑을 따로 정리합니다.

  • glTF(Khronos) — Y+(OpenGL 계열): 정식 스펙은 텍셀을 red→X, green→Y, blue→Z로 매핑하고, 노멀 스케일 후 재정규화를 요구하며, 탄젠트가 없을 때 MikkTSpace로 탄젠트를 계산하도록 지정합니다.
  • Unity — Y+(OpenGL 방향): Ben Golus의 글이 Unity를 OpenGL 방향으로 명시하며, Unity 임포터에는 다른 컨벤션을 뒤집는 “Flip Green Channel” 옵션이 있습니다.
  • 언리얼 엔진 4 — Y−(DirectX 방향): 같은 글이 언리얼 4를 DirectX 방향으로 명시하며, 이 불일치는 “일부 광원 방향에서만 조명이 뒤집혀 보이는” 증상으로 나타난다고 설명합니다.
  • Godot — Y+(OpenGL과 같은) 규격: 공식 StandardMaterial3D 문서원문: “Godot requires the normal map to use the X+, Y+ and Z+ coordinates, this is known as OpenGL style. If you’ve imported a material made to be used with another engine it may be DirectX style, in which case the normal map needs to be converted so its Y axis is flipped.” 이 도구는 위에서 설명한 대로 ySign으로 dy 항의 부호만 뒤집으므로, 같은 입력이라도 두 모드의 G(초록) 채널 값은 255 − G 관계로 정확히 반전됩니다 — 2026-08-20 QA 실측으로 같은 픽셀에서 OpenGL 모드 G=135, DirectX 모드 G=120이 나왔습니다 (135 + 120 = 255). Godot 4에는 반대 컨벤션 소스를 위한 “Normal Map Invert Y” 임포트 옵션도 있습니다.
  • Blender — OpenGL 방향 + MikkTSpace: 그린 채널 방향은 Unity와 같은 OpenGL, MikkTSpace를 쓰지만 탄젠트 계산은 언리얼처럼 퍼-프래그먼트 바이노멀을 써서, “OpenGL이냐 DirectX냐”만으로는 완전한 호환이 보장되지 않습니다(아래 탄젠트 절 연결).
  • Babylon.js — DirectX 포맷 기본: 공식 문서에 따르면 기본적으로 DirectX 포맷 노멀 텍스처를 기대하고, glTF(OpenGL 관례) 에셋을 로드할 때는 로더가 탄젠트의 Y를 자동으로 반전해 맞춥니다. 수동 보정용 invertNormalMapY 프로퍼티(켜면 y = 1.0 − y)도 있습니다.

현장에서 흔한 함정은, 이 도구의 프리뷰에서는 요철이 정상으로 보이는데 엔진에 넣으면 파임이 반대로 나타나는 경우입니다. 대개 위 Y 규격 불일치가 원인이므로 다른 값을 만지기 전에 Y축(OpenGL↔DirectX) 토글부터 바꿔 보세요. Khronos의 glTF 이슈 트래커에도 두 관례가 병존해 Substance Painter가 OpenGL/DirectX 두 내보내기를, Sketchfab이 “Flip green(−Y)” 토글을 제공해 왔다는 기록이 남아 있습니다.

강도·블러·높이 반전 튜닝

  • 강도(strength) — 기본값 2, 내부에서 [0, 10] 범위로 강제 클램프됩니다(NaN은 기본값 2로, ±무한대는 각각 10/0으로). 강도는 nx·ny 두 항의 기울기(dx, dy)에 곱해지는 배율이라, 값이 클수록 기울기가 그대로 증폭돼 노멀이 크게 기울고(요철 과장), 작을수록 nz=1에 가까워져 평탄한 노멀에 수렴합니다 — 정규화 전 벡터식에서 바로 나오는 수학적 사실입니다.
  • 프리블러 반경 — 기본값 0(블러 없음). 블러는 Sobel 이전 단계인 높이장에만 적용되는 분리형 O(N) 박스 블러라, 반경을 키우면 높이장이 매끄러워지고 그 결과 기울기의 고주파 성분(잔노이즈·잔디테일)이 줄어듭니다. JPEG 블록 노이즈나 스캔 잡티가 많은 이미지에 유용합니다.
  • 높이 반전(invertHeight) — “밝은 곳을 낮게” 해석하도록 뒤집습니다. 구현은 높이장 자체를 뒤집는 게 아니라 dx·dy 두 기울기의 부호를 동시에 반전하는 방식입니다. 음각으로 새겨진 무늬나, 밝은 곳이 오히려 파여 보일 때 켭니다.
  • 경계 처리(boundary) — clamp(가장자리 픽셀 복제) 또는 wrap(순환/타일링)으로, Sobel의 8-이웃 샘플링이 이미지 밖 좌표를 어떻게 채울지 정합니다. 좌우·상하로 반복되는 타일 텍스처라면 wrap이 이음매를 막습니다.
  • 투명 영역은 튜닝과 무관 — 알파가 임계값(8) 미만인 픽셀은 강도·블러·반전 설정과 상관없이 항상 평탄 노멀(128, 128, 255)로 나옵니다. 즉 투명부는 이 파라미터들의 영향을 받지 않습니다(아래 엣지케이스 절 참조).

사용 순서

  1. 변환할 이미지를 끌어다 놓거나 눌러 불러옵니다(한 변 4096px 이하 — 초과하면 거부됩니다).
  2. 강도로 요철 세기를 정합니다(기본 2). 노이즈가 도드라지면 값을 낮춥니다.
  3. 사진·스캔처럼 잡티가 많으면 프리블러 반경을 조금 올려 높이장을 매끄럽게 합니다.
  4. 밝은 곳이 파여 보이면 높이 반전을 켜고, 반복 타일이라면 경계를 wrap으로 바꿉니다.
  5. 사용할 엔진 규격에 맞춰 Y축(OpenGL 또는 DirectX)을 고르고, 노멀맵을 생성해 PNG로 내려받습니다.
  6. 내려받은 PNG를 엔진에 넣을 때는 반드시 non-color/linear 텍스처로 임포트합니다(아래 색 공간 절 참조).

엣지케이스와 색 공간 (sRGB vs linear)

  • 투명 픽셀 — 원본 알파가 임계값(8) 미만이면 Sobel을 건너뛰고 곧바로 평탄 노멀(128, 128, 255)을 출력하며, 원본 알파는 그대로 보존합니다. 투명부에 가짜 절벽(경계 굴곡)이 생기는 것을 막기 위한 처리라고 코드에 명시돼 있습니다.
  • 알파 분리 — 불투명 픽셀도 출력 알파는 원본 알파를 그대로 씁니다. 노멀맵 RGB 계산과 알파 채널은 완전히 분리된 경로라, 결과가 불투명한 사각형으로 덮이지 않습니다.
  • 크기 제한과 워커 — 한 변이 4096px를 넘으면 디코드 전에 거부하고, “2048로 축소 후 생성” 옵션이 축소본을 만듭니다. 또 픽셀 수가 1024×1024(=1,048,576) 이상이면 UI가 백그라운드 워커로 처리를 넘겨 큰 이미지에서도 화면이 멈추지 않습니다.
  • 경계 픽셀 — 가장자리 픽셀의 8-이웃 중 이미지 밖 좌표는 별도 특수 분기 없이 경계 옵션(clamp/wrap) 하나로 동일하게 처리됩니다.

내려받은 노멀맵을 엔진에 넣을 때 가장 중요한 설정: 노멀맵은 “색”이 아니라 3D 방향(벡터) 데이터이므로 sRGB가 아니라 linear(비색상)로 취급해야 합니다. Khronos의 공식 glTF 샘플 저장소에서도 여러 표준 샘플 모델(NormalTangentTest, Sponza 등)이 실수로 sRGB로 저장돼 있던 것이 스펙 위반으로 정리된 적이 있습니다. 이유는 분명합니다 — sRGB 감마 보정을 적용하면 조명 계산(TBN 행렬 기반 내적)에 쓰이는 방향 값 자체가 왜곡돼 잘못된 라이팅·반사가 나옵니다. three.js 공식 문서도 노멀맵을 non-color 데이터로 취급하라고 안내합니다. 실무에서는 임포트 시 텍스처를 linear(비색상) 픽셀 포맷으로 지정하거나 컬러스페이스 메타데이터를 무시하도록 설정하면 됩니다.

이 도구 vs “진짜” 베이크

이 도구는 3D 지오메트리를 전혀 참조하지 않습니다. 입력은 단일 2D 래스터 이미지(RGBA) 하나뿐이고, 그 휘도로 높이장을 만든 뒤 Sobel 기울기로 노멀을 유도합니다 — 메시·UV·레이캐스트·케이지 개념이 파이프라인 어디에도 없습니다. 반면 고폴리→저폴리 “진짜” 베이크는 실제 3D 표면 사이의 기하학적 관계를 측정합니다:

  • Marmoset Toolbag — 고해상도(소스) 메시의 표면을 저해상도(대상) 메시로 레이트레이스 투영해 베이크하며, 투영이 엉뚱한 곳에 맞지 않도록 케이지로 레이 거리를 제한합니다.
  • Substance Painter 메시맵 베이킹 — 저폴리에서 고폴리로 레이를 쏘는 방식으로, 케이지를 Distance based/Automatic/Custom file로 설정하고 레이 개수·거리 임계값·확산 각도가 결과에 영향을 줍니다. 이 문서는 “단일 이미지에서 유도된 노멀맵”과 “메시 베이킹(지오메트리 기반)”을 개념적으로 구분합니다.
  • xNormal — 별도의 고폴리 소스로부터 노멀맵·AO 등을 베이크하는 무료 앱으로, 역시 2D 이미지 한 장이 아니라 실제 지오메트리를 입력으로 씁니다.

정리하면(아래는 위 공식 문서들의 사실을 묶은 해석입니다), 진짜 베이크는 실제 표면 사이의 레이 히트 지점·거리·각도를 측정해 노멀을 만들지만, 이 도구는 2D 밝기를 높이로 추정할 뿐 실제 형상을 모릅니다 — 같은 스프라이트라도 지오메트리 기반 베이크와는 정보의 출처가 다른 근사치라는 뜻입니다. 어느 출처도 “height-derived 방식이 부정확하다”고 직접 말하지는 않았으므로, 용도에 맞으면 충분히 유효한 결과입니다.

탄젠트 베이스와 MikkTSpace

“프리뷰에서는 맞는데 엔진에서는 미묘하게 다르다”는 현상의 또 다른 원인은 탄젠트 베이스입니다. MikkTSpace는 그 저장소 스스로가 “베이킹 툴이 노멀맵을 만들 때 쓰는 탄젠트 공간의 공통 표준”이라 설명하는 참조 구현이고, glTF 정식 스펙도 탄젠트가 지정되지 않았을 때 클라이언트가 MikkTSpace 알고리즘으로 탄젠트를 계산하도록 지정합니다(사실상 기본값). 이 도구는 그 표준을 구현하지 않습니다 — 명시적 한계입니다: MikkTSpace는 메시의 UV·정점·삼각형 위상을 입력받아 퍼-버텍스(또는 퍼-프래그먼트) 탄젠트·바이노멀을 계산하는 알고리즘인데, 이 도구는 메시를 전혀 참조하지 않는 2D 이미지 파이프라인이라 UV도 정점도 갖고 있지 않습니다. 즉 MikkTSpace가 푸는 문제(탄젠트 베이스 계산) 자체를 이 도구는 풀지 않습니다 — “다르게 푼다”가 아니라 “풀 대상 데이터가 없다”는 뜻입니다. 그런데 “MikkTSpace를 쓴다”는 이름만으로는 완전한 호환이 보장되지 않습니다. Ben Golus의 핵심 원칙은 “노멀맵을 베이크한 프로그램은 그것을 소비하는 프로그램과 정확히 같은 방식으로 탄젠트 공간을 계산해야 한다”는 것입니다. 예를 들어:

  • xNormal과 Substance는 베이킹 시 퍼-버텍스/퍼-픽셀 바이노멀 계산을 선택할 수 있습니다.
  • Unity 빌트인 렌더러는 퍼-버텍스, HDRP는 퍼-픽셀을 써서, 한쪽용으로 베이크한 노멀맵이 다른 쪽에선 안 맞을 수 있습니다.
  • Blender는 Unity처럼 MikkTSpace와 OpenGL 방향을 쓰지만 언리얼처럼 퍼-프래그먼트 바이노멀을 써, 같은 “MikkTSpace 사용”이라는 이름표를 공유해도 세부 계산(퍼-버텍스 vs 퍼-프래그먼트)이 갈리면 결과가 달라집니다.
  • 전형적 증상은 “라이팅이 뒤집히거나 일부 광원 방향에서만 이상하게 보이는” 것으로, 흔히 OpenGL/DirectX Y축 문제로 오인되지만 실제로는 탄젠트 베이스 불일치인 경우가 있습니다.

이 도구에 대한 적용(아래는 위 원칙을 이 도구 맥락으로 옮긴 해석입니다): 이 도구는 노멀맵을 “베이크”하지 않고 2D 이미지에서 직접 생성하므로, Golus가 논하는 “베이크 툴 사이의 불일치” 시나리오 그 자체는 아닙니다. 다만 원리는 전이됩니다 — 이 도구가 출력한 탄젠트 공간 노멀맵을 최종적으로 올바르게 표시하려면, 그것을 소비하는 엔진·렌더러가 런타임에 계산하는 탄젠트 베이스(예: glTF 런타임의 MikkTSpace 기본 알고리즘)와 맞아야 합니다. 이것이 “프리뷰에선 맞아 보이는데 엔진에 넣으면 미묘하게 달라 보인다”는 현상의 근본 원인입니다.

주의·실패 케이스

  • 엔진에서 조명·요철이 반대로 → 대개 Y(초록) 채널 규격 불일치입니다. 다른 값보다 Y축(OpenGL↔DirectX) 토글을 먼저 바꿔 보세요(위 Y축 절).
  • 라이팅·반사가 이상하게 보임 → 노멀맵이 sRGB로 임포트됐을 수 있습니다. linear(비색상)로 지정하세요(위 색 공간 절).
  • 프리뷰는 맞는데 엔진에서 미묘하게 다름 → 탄젠트 베이스 계산 방식(퍼-버텍스 vs 퍼-프래그먼트) 차이일 수 있습니다(위 탄젠트 절).
  • 노이즈 사진을 강도만 높여 변환 → 표면이 지저분해집니다. 강도를 낮추고 프리블러를 함께 쓰세요(위 튜닝 절).
  • 밝기와 실제 높이가 어긋난 이미지 → 알베도(색)와 높이가 다르면 결과가 어색할 수 있습니다. 휘도 기반이라 “밝은 곳”과 “높은 곳”이 항상 일치하지는 않으므로 높이 반전이나 프리블러로 완화하세요.
  • 한 변 4096px 초과 → 디코드 전에 거부됩니다. 4096×4096 RGBA 버퍼 한 장만 해도 4096²×4바이트 ≈ 64MiB인데, 파이프라인이 높이장·블러·기울기 단계마다 같은 크기의 중간 Float32 버퍼를 여러 개 들고 있어 큰 이미지는 수백 MiB까지 불어날 수 있습니다. 그 할당을 막기 위한 제한이니 “2048로 축소 후 생성”을 쓰거나 먼저 리사이즈하세요.

자주 묻는 질문

Q. 엔진에 넣으니 조명이나 요철이 반대로 보여요.
대개 초록(G) 채널의 Y 규격이 엔진과 다른 경우입니다. 이 도구의 Y축 옵션을 OpenGL(+)↔DirectX(−)로 바꿔 보세요. 엔진별로 glTF·Unity·Godot·Blender는 Y+(OpenGL 계열), 언리얼 엔진 4는 Y−(DirectX), Babylon.js는 기본이 DirectX 포맷이며 glTF를 로드할 때는 자동으로 보정합니다.

Q. 결과의 라이팅이나 반사가 이상하게 보여요.
노멀맵은 색이 아니라 3D 방향(벡터) 데이터라 linear(비색상)로 임포트해야 합니다. sRGB로 두면 감마 보정이 방향 값을 왜곡해 조명 계산이 틀어집니다. Khronos의 공식 glTF 샘플조차 실수로 sRGB로 저장돼 스펙 위반으로 정리된 적이 있을 만큼 흔한 실수입니다.

Q. xNormal·Substance·Marmoset 같은 "진짜" 베이크와 뭐가 다른가요?
그 도구들은 고폴리 메시를 저폴리로 레이캐스트 투영해 실제 3D 표면 사이의 기하학적 관계를 측정합니다. 이 도구는 메시 없이 2D 이미지의 밝기를 높이로 추정할 뿐이라 지오메트리 기반 베이크와는 정보의 출처가 다른 근사치입니다. 원본 하이트맵이나 3D 모델 없이 단일 이미지만으로 노멀맵이 필요할 때 쓰는 방식입니다.

Q. 프리뷰에서는 맞는데 엔진에 넣으면 미묘하게 달라 보여요.
Y 규격이 맞는데도 어긋난다면 탄젠트 베이스 계산 방식의 차이일 수 있습니다. 노멀맵은 그것을 소비하는 엔진·렌더러가 런타임에 계산하는 탄젠트 공간(예: glTF의 MikkTSpace 기본 알고리즘, 또는 퍼-버텍스/퍼-프래그먼트 바이노멀)과 맞아야 정확히 표시됩니다. 흔히 OpenGL/DirectX 문제로 오인되지만 실제로는 탄젠트 불일치인 경우가 있습니다.

Q. 강도 값은 어떻게 정하나요?
기본값은 2이고 0~10 범위로 제한됩니다. 강도는 기울기에 곱하는 배율이라 값이 클수록 요철이 과장되고 원본 노이즈까지 증폭되며, 작을수록 평탄한 노멀에 가까워집니다. 잡티가 많은 사진은 강도를 낮추고 프리블러를 함께 쓰는 편이 깔끔합니다.

Q. 가로세로가 큰 이미지는 왜 거부되나요?
한 변이 4096px를 넘으면 디코드 전에 거부합니다. 4096×4096 RGBA 버퍼 한 장만 해도 4096²×4바이트 ≈ 64MiB인 데다, 파이프라인이 높이장·블러·기울기 단계마다 같은 크기의 중간 Float32 버퍼를 여러 개 들고 있어 큰 이미지는 수백 MiB까지 불어날 수 있어, 그 할당을 막기 위한 제한입니다. 그래도 변환하려면 "2048로 축소 후 생성"으로 축소본을 만드세요. 또 약 100만 픽셀(1024×1024) 이상이면 화면이 멈추지 않도록 백그라운드 워커에서 처리합니다.

관련 가이드

노멀맵 생성기 열기

최종 업데이트 2026-08-21