RTR에서는 최적화에 있어서 CPU의 메모리에서 GPU의 VRAM으로 데이터를 전송할때의 병목을 신경 써야 한다.
UMA(Unified Memory Architecture)가 아닌 이기종 장치에서
GPU 그래픽스 파이프라인에서 오브젝트가 그려지기 위해서는
RAM에서 다이렉트로 그래픽스 파이프라인에 데이터를 보내지 못하고
결국은 그래픽스 하드웨어의 VRAM에 데이터가 적재되어야 하기에
cpu -> gpu로 전송하는 데이터의 크기를 줄여야 할 필요가 있다.
고품질 하이 폴리곤 모델과 고품질 텍스쳐를 사용하려 한다면 폴리곤의 정점 수나 이미지의 해상도에 있어서 메모리 사용량이 커지므로 RTR환경에서의 부담이 커지게 된다.
선대 그래픽스 학자들은 이러한 문제를 해결하기 위해
고품질 폴리곤 모델에서의 법선 벡터(3차원 x,y,z) 정보를 모델의 UV좌표에 따른 이미지 텍스쳐의
RGB값에다가 저장하여 대응하여 로우폴리의 모델에서도 해당 노말 데이터를 통해 고품질 폴리곤의조명효과를
얻을 수 있도록 고안해 내었다.
Normal Map을 생성하는 과정은 간단하게 말해보면
1. 요철등이 표현되어 있는 하이폴리곤 모델을 준비한다.
2.리토폴로지되어 요철이없는 flat한 로우 폴리곤 모델을 준비한다.
3. 로우폴리곤 모델에서 하이폴리곤 모델로 Ray를 주사하여 해당 위치의 노말 값을 Read 하고 이를 텍스쳐에 색상으로 write한다.
이러한 과정을 통틀어 Bake 라고 하며 Normal을 Bake 했다고 말한다(Bake는 노말맵을 만들때만 사용되는 용어는 아니다. Lightmap, Glossymap 등등 텍스쳐를 절차적으로 생성하는 과정을 Bake라고 함.)


다음과 같이 하이 폴리곤 오브젝트를(무려 Vertex 수가 130만개다...)
242개의 vertex인 로우 폴리 오브젝트의 표면에 노말을 주사하여 bake 해 본다고 하자.
블렌더와 같은 DCC 툴은 기본적으로 bake 기능을 지원하며 다음과 같은 옵션이 존재한다.
Space -> tangent, object
여기서 탄젠트 스페이스 저장 옵션은 로컬 오브젝트의 표면의 TBN 좌표를 기준으로 값을 저장하는 옵션이다.
게임과 같은 RTR에서 Normal Map을 탄젠트 스페이스로 저장하는 이유는
local object가 global space으로의 변환이나 회전 등을 나중에 계산하는 옵션으로 빼놓아
재사용 가능하게 하고 위치에 따른 제약을 없애기 위해서이다.
Object Space 기준으로 저장하게 되면 특히 armature가 존재하는 skinnedmesh의 경우
각 joint에 따른 transform을 개별적으로 각각 처리해야 되는데 이를 대처하기 어렵고 normalmap과 다르게 xyz의 방향에 대한 정보들을 텍스쳐에 저장해 놓아야 하므로 압축 효율이 떨어진다.
특별한 경우가 아니라면 노말맵은 Tangent Space를 기준으로 처리한다.
tangent space에 텍스쳐 저장의 장점 정리
- transform에 독립적
- 자원 재사용 가능
- 스키닝시 재변환 할 필요 없음
- 압축 효율 좋음
Swizzle RGB -> XYZ
각 게임 엔진들이나 DCC 툴, Graphics API에서 Directx와 OpenGL이 사용하는 좌표계가 서로 다르기 때문에
이를 위한 변형 옵션이다.
Extrusion (Cage) -> 노말맵은 Ray를 하이폴리곤에서 로우폴리곤으로 주사하여야 하기 때문에 로직을 처리하기 위해서는
시작점과 방향, 그리고 충돌판정이 필요하다.
해당 옵션은 정확도를 위해서 하이폴리곤의 표면으로부터 일정거리 띄운 곳에서부터 ray를 쏘는 옵션으로
ray가 표면에 완전히 맞닿아서 주사하는 것을 방지한다.
Max Ray Distance ->
Ray가 닿았을때 다른 표면에 맞는 것을 방지하는 constraint이다
물체의 scale이 작거나 표면과 표면사이의 간격이 좁을때 보다 정확하게 bake하기 위한 옵션이다.
여기서 노말맵의 핵심은,
노말맵은 실제로 메시의 형태를 바꾸지 않는다는 것이다.
단지 각 픽셀에서 사용되는 법선 벡터를 변경하여 빛이 반사되는 방향만 바꾸는 기법이다.
따라서, 실루엣은 변하지 않지만, 표면의 요철이 있는 것처럼 보이게 된다.




레퍼런스에는 다음과 같은 노말맵이 존재한다.
오른쪽의 노말맵 이미지를 보았을 때 원본 노말맵 텍스쳐는 노란색의 색상을 띄고 있다.
왜 이렇게 보여지고 만들어졌으며(baked) 게임엔진에서 어떻게 변환이 되는지에 대하여 알아보기 위해서는
먼저 텍스쳐 처리 저장 및 처리 방식에 대해 알아볼 필요가 있다.
텍스쳐 처리에 있어서 cpu->gpu로 데이터를 전송하기 위한 병목을 고려 + gpu에서 텍스쳐 활용을 위한 포맷을 고려하기 위해
GPU에는 일반적으로 많이 쓰이는 jpg, png, tga 포맷과는 다르게 여러가지의 압축 포맷 형식들을 지원한다.
jpg, png등의 압축 방식은 인접 텍스쳐의 픽셀 값에 기반한 압축 방식이기 때문에(CPU 친화적)
GPU환경에서 사용하려면 이를 디코딩 하여 VRAM에 적재해야 한다.
이러한 부분에서 런타임 비용이 발생하게 되어 속도가 중요한 RTR에 있어서 어느정도의 비용을 감수해야 하는
적합하지 않은 포맷이다.
GPU의 경우, 압축된 텍스쳐 자원 그대로 즉석으로 샘플링이 가능한데 이것이 가능한 이유는
앞에서부터 디코딩 해야 하는 가변길이 압축 방식인 jpg, png와 달리
GPU압축 방식은 4x4 크기의 block compression 방식을 사용하며
고정크기 블록으로 압축이 처리가 되기 때문에 즉시 읽는것이 가능하다.
따라서,
텍스쳐들의 정보들에 있어서 GPU에서 용이하게 관리하게 하기 위한
새로운 포맷을 도입할 필요가 있었고 이를 위해 등장한 것이
DDS(DirectDraw Surface) 텍스쳐 포맷이다.
DDS 파일에는
압축 포맷, Mipmap 체인, Cubemap 등의 데이터를 저장할 수 있도록 지원하는 포맷이며 따라서
일반적으로 게임 텍스쳐들은 사용할때는 jpg나 png가 아닌 DDS 포맷을 사용하는 것이 바람직하다.
여기서 알아보아야 할 내용은 압축 포맷에 관련된 내용이다.
텍스쳐 압축 포맷은 모바일 환경, pc 환경 등에 따라서 다양하게 있지만 중점적으로 PC환경에서 많이 쓰이는
BC(Block Compression)압축 방식에 대해서 알아보자.
압축 포맷은 BCn의 형식으로 되어 있으며
n은 1부터7까지 총 7개의 압축 포맷을 지원한다.
각 BC 압축포맷마다 RGBA 채널에 대해서 저장되는 바이트수나 채널 개수 등이 다르고 사용환경, 용도에 따라서 다르다.
여기서는 노말맵 압축 포맷에 친화적인 BC5 압축 포맷에 대해서만 얘기한다.
BC5 압축 포맷은 16byte에 2채널 R, G값만을 사용하는 압축 포맷이다.
주로 노말맵 텍스쳐를 압축할때 사용하는 압축 포맷인데, 여기서 B값이 없는 이유는
노말맵의 RGB 값은 xyz에 대한 정규화된 벡터를 의미하므로
R,G를 통해 B값을 복원할 수 있기 때문이다.
노말맵은 Tangent 공간에서 다뤄지는데, 실제로 로컬, 월드 공간에서는 정규화된 벡터값이 -1~1의 값을 가질수 있다.
따라서 노말맵 텍스쳐를 복원하는 과정에서
0.5값의 색상인 127을 기준으로 각 채널의 벡터값을
0(0) -> -1
0.5(127) -> 0
1(255) -> 1
로 매핑한다.
노말맵 복원 예제1


노란색깔의 노말맵의 색상을 확인해 보았을때
R : 127
G : 127
B: 0
의 색상을 띄고 있다.
한편.
게임엔진에서 복원된 노말맵의 색상을 확인해 보면
R: 127
G: 127
B: 255
의 값을 띄고 있다.
원본 텍스쳐의 RG 값을 통해 z벡터인 B값을 복원해 본다면
R 값은 127 = 0.5
0.5 * 2 -1 = 0
G 값은 127 = 0.5
0.5 * 2 - 1 =0
R,G 값에 대하여 norm2 의 크기가 1이어야 하므로 B는 1에 대한 값을 띄고 있어야 한다.
복원하면
b*2 -1 = 1, b = 1
B = b * 255 = 255
따라서, 해당 픽셀의 B 채널 값이 255임을 확인할 수 있다.
노말맵 복원 예제2


또다른 예제로 RGB 값이 서로 다른 색상에 대해서 B값의 복원 과정에 대해서 알아보자
R:66
G:184
B:0
에 대해서 마찬가지로 분석해 보면
66 -> 0.25
0.25 *2 -1 = -0.5
184 -> 0.72
0.72*2 -1 = 0.44
B값에 대하여
1- (-0.5)^2 - 0.44^2 = 0.55
sqrt(0.55) = 0.75
즉,
1.75/2 = 0.875 -> 약 223
BC5 압축 포맷에 대해서 노말맵 복원은
(sqrt(1-r^2-g^2) +1)/2
를 통해서 처리된다는 것을 알 수 있다.


노말맵을 처리 한 것과 처리하지 않은 결과물에 대한 차이를 먼저 알아보자.




노말맵 적용 전 / 노말맵 적용 후
적용 전에 대한 메쉬에도 요철이 존재하여 어느정도의 음영 처리가 잘 처리되는것을 볼 수 있으나,
노말맵을 적용했을때 보다 음영과 조명이 각지게 잡혀 보다 Solid한 효과를 나타내는것을 확인해 볼 수 있다.
노말맵을 적용하기 위해서 텍스쳐에 대해 Tangent space에 대한 처리를 해야 한다고 사전에 설명하였다.
이를 위한 처리 과정에 대해서 알아보자.
구현
[Normal] _NormalMap("Normal Map", 2D) = "bump" {}
프로퍼티에서 노말맵 텍스쳐를 받아 올 수 있도록 한다.
bump는 color에서 white가 기본 색상을 의미하듯 의미는 기본 노말맵 텍스쳐를 뜻하는 것인데,
기본적으로 요철 없는 평평한 노말값을 의미한다. (0,0,1)
[Normal] 키워드는 유니티에서 제공하는 메타데이터인데,
Unity가 해당 텍스처를 normal map 용도로 사용함을 인식하고,
올바른 import 설정(sRGB off, normal map compression 등)을 적용하도록 돕는 메타데이터이다.
이는 노말맵은 데이터 텍스쳐이므로 sRGB 옵션을 꺼야 하기에 이를 보장해 주고
Compression 처리에 대해서 BC5 타입에 대한 Compression을 보장하도록 하는 키워드이다.

만약 해당 키워드를 사용한 프로퍼티에 대해서
해당 텍스쳐에 Normal Map 타입이 아닌 텍스쳐를 입력으로 받는다면 다음과 같은 경고 메세지가 출력된다.
half3x3 tangentToWorld = half3x3(input.tangentWS, input.bitangentWS, input.normalWS);
float4 normalmap = SAMPLE_TEXTURE2D(_NormalMap,sampler_NormalMap, input.uv);
half3 normalTS = UnpackNormal(normalmap);
half3 normalWS = TransformTangentToWorld(normalTS, tangentToWorld, true);
input.normalWS = normalWS;
input의 메서드에서,
normal, bitangent, tangent의 world space vector에 대한 부분은
내부 Core.hlsl의 vertexnormalinput구조체에 대하여 값을 계산하는
GetVertexNormalInputs 함수를 통해 계산 결과를 활용할 수 있었다.
여기서 중요한건 해당 값은 삼각형의 Barycentric 보간을 통한
각 fragment 마다의 Tangent, Bitangent, Normal 값에 대해서 보간된 결과값을 TBN Matrix로 사용한다는 것이다.
Varyings vert(Attributes input)
해당 부분까지는 정점 데이터의 입력값 -> 출력 값에 대한 정점 쉐이더를 처리하는 것이도 정점 단위로 일어나지만
half4 frag(Varyings input) : SV_Target
해당 부분에서 input을 인자로 받을대는 사이 과정에
rasterizer가 이미 정점들을 통한 barycentric interpolation을 처리한 결과에 대해서
fragment shader에서 값을 사용하여 계산하는 것이다.

rasterizer 단계에서 barycentric interpolation을 통해 보간된
Tangent, Bitangent, Normal 값이 fragment shader로 전달되며,
fragment shader에서는 이를 이용해 픽셀 단위 TBN matrix를 구성한다.
half3x3 tangentToWorld = half3x3(input.tangentWS, input.bitangentWS, input.normalWS);
노말맵 텍스쳐는 0~1사이의 값을 가지고 있으므로 이를 실제로 내부에서 사용하기 위해서는 -1~1 사이의 값으로
매핑해 줄 필요가 있다.
DCC에서 normal vector -> texture mapping 한 과정을 역순으로
texture mapping -> normal vector로
unpacking 해 주는 것이다.
half3 normalTS = UnpackNormal(normalmap);
normal map을 unpacked 하여 나온 각 xyz에 대한 -1~1사이의 벡터값을
해당 월드의 TBN 기저에 선형변환하여 월드기준 노말벡터의 방향을 구하게 된다.
해당 과정이 TransformTangentToWorld의 역할이다.
여기서 Transform Tangent To World에서는
단순 TBN 기저에 대해서 normal을 변환하는 과정 뿐만 아니라 계산된 결과를
SafeNormalize(벡터크기가 0일때 예외처리)처리해주는 옵션이 존재한다.
이러한 옵션이 있는 이유는 rasterizer에서 barycentric 계산시에 보간된 벡터들이
서로 정규화 되어 있는지 등을 보장할 수 없기 때문에
계산 후 크기가 1이 되도록 보장하는 옵션이다.
half3 normalWS = TransformTangentToWorld(normalTS, tangentToWorld, true);
최종적으로 노말맵을 통해 계산된 월드 노말 벡터를 fragment의 월드 노말 값으로 대처하여 모델에 노말맵을 적용한다.
input.normalWS = normalWS;


노말맵 적용 / 노말맵 미적용
'NPR Project Demo' 카테고리의 다른 글
| 05. Rim Light_[NPR Project] (0) | 2026.02.22 |
|---|---|
| 04. Specular_[NPR Project] (0) | 2026.02.17 |
| 02. Ramp Color Diffuse_[NPR Project] (0) | 2026.02.06 |
| 01. Face SDF_[NPR Project] (0) | 2026.02.04 |
| 2025-12-25~2026-01-23 까지 진행한 것들 (0) | 2026.01.23 |