Programming/컴퓨터그래픽스 (DX 11)

[DirectX 11] 노말맵(Normal Map)과 기저 공간(Tangent Space)

양양줘 2025. 10. 11. 01:58

 

게임을 개발하는 사람이라면

노말맵이라는 것에 대해 들어 보았을 것이다.

노말맵은 텍스처의 한 종류로 이름 그대로 법선 벡터가 저장되어있다.

 

왜 노말맵을 사용할까?

 

디테일한 음영을 표현해야 할 때 실제 폴리곤의 수를 늘리면

vertex와 pixel shader의 실행 횟수도 늘어난다.

그렇다고 삼각형을 줄이면 vertex의 normal까지 줄어들어

음영의 디테일이 사라진다.

이때 로우 폴리를 유지한 채 따로 노말맵 데이터를 사용하여

빛의 음영만 디테일하게 표현할 수 있다.

 

 

Normal Map Texture



노말맵(Normal Map) 모델 표면 디테일(요철, 굴곡)을 표현하기 위한 텍스처로 실제 지오메트리를 세밀하게 나누지 않고 픽셀 단위의 법선 벡터를 텍스처로 저장해두는 방식이다. 이때 법선 벡터는 접선 공간(Tangent Space)를 기준으로 저장된다.

 

노말맵 저장 방식

 

노말맵은 RGB 색상값으로 법선 벡터를 표현하여 저장되어있다. 이 vector3 데이터를 directx 기준으로 매핑하여 각 픽셀마다의 법선 벡터를 구한다.

채널 의미 범위
R 탄젠트 방향 (x) 0~1
G 비 탄젠트 방향 (y) 0~1
B 법선 방향 (z) 0~1

 

RGB는 0~1값으로 정규화 되어있고 directX에서 필요한 Normal은 -1~1값이기 때문에 매핑을 해야한다.  

// 노말맵에서 텍스처 읽기
float3 normalTS = texNormal.Sample(samplerState, input.uv).xyz;

// RGB(0.0~1.0) <-> Normal xyz (-1.0~1.0)
// [0, 1] → [-1, 1] 변환
normalTS = normalTS * 2.0f - 1.0f;

 

 

 

 

법선 벡터의 World Space 변환


노말맵은 모델의 로컬 좌표계(Lacal)가 아닌, 접선 공간(Tangent Space)를 기준으로 기록된다. 때문에 노말맵의 법선 벡터를 월드 공간으로 변환하려면 TBN 행렬을 사용해야한다.

 

접선 공간 (Tangent Space)과 TBN 행렬

 

탄젠트 공간(Tangent Space)은 메시 표면의 vertex마다 정의되는 로컬 좌표계(Local Coordinate System)이다. 이 좌표계는 각 vertex의 기저 벡터 집합으로 만들어지며 탄젠트, 비탄젠트, 노말 총 3벡터로 축을 이룬다. 이 세 벡터는 모두 월드 공간 방향을 나타내므로 이 벡터를 묶은 TBN행렬은 Tangent Space, World Space 변환을 위한 기저 벡터의 집합이 된다.

  1. 탄젠트 (Tangent, T)
    텍스처 UV 좌표의 u방향으로 나아가는 표면 위의 접선 벡터. 즉, 텍스처의 가로 방향.
  2. 비탄젠트 (Bitangent or Binormal, B)
    UV 좌표의 v방향으로 나아가는 표면 위의 또 다른 접선 벡터. 즉 텍스처의 세로 방향.
  3. 노말 (Normal, N)
    표면에 수직인 벡터.

접선(Tangent)은 말 그대로 ‘접하는 선(벡터)’로 표면 위에 완전히 붙어있는 방향 벡터를 의미한다. 예를 들어 공 표면 위의 한 점을 생각해볼 때 공 표면을 따라 미끄러질 수 있는 모든 방향 벡터가 바로 접선 벡터(Tangent Vector)인 것이다. 이 점 P에서의 모든 접선 벡터들을 모으면 그 벡터들은 한 평면 위에 존재하게 된다. 이 평면을 접평면(Tangent Plane)으로 부른다.

 

정리하자면, 표면에 대한 한 점에 대한 접평면 안에서의 한 축을 잡으면 그것이 접선 벡터(Tangent Vector)이며, 이 평면에 수직한 벡터가 법선 벡터(Normal Vector)가 된다. 접선 공간(Tangent Space)는 바로 이 개념을 uv 좌표계와 연관시켜 표현한 좌표계이며, 노말맵의 법선 벡터는 이 접선 공간을 기준으로 저장되어있다. 때문에 월드 공간 변환시에 TBN행렬이 필요한 것이다.

 

 

 

법선 벡터의 World Space 변환 방법

 

탄젠트 공간에서 정의된 노말 벡터(𝑣_𝑇𝑆=(𝑥,𝑦,𝑧))는 TBN 월드 변환 행렬을 통해 월드 공간으로 변환한다.

월드 기준 노말 벡터 = normalize(TBN행렬 ⋅ 탄젠트공간 노말 벡터)

 

노말맵에 저장되어있는 탄젠트 공간 기준의 노말 벡터(x,y,z)를 TBN행렬과 곱한 값을 정규화하면 월드 기준의 노말 벡터를 구할 수 있다. 이렇게 구한 월드 기준 노말로 음영 연산을 하는 것이다.

// 월드 공간 노말 계산
float3 normalWS = normalize(mul(normalTS, TBN));

 

 

 

 

전체 과정 요약



1. 노말맵 데이터 맵핑하기

// 노말맵 RGB 값(0~1) → 탄젠트 공간 기준 노말 벡터(-1~1)
float3 normalTS = texNormal.Sample(samplerState, input.uv).xyz;
normalTS = normalTS * 2.0f - 1.0f;

 

2. 모델 vertex의 TBN행렬 구성하기
TBN행렬을 구하기 위해선 먼저 각 vertex마다의 기저벡터가 필요하며, 이 벡터들을 vertex shader에서 월드로 변환해주어야 한다. pixel shader에서는 이 월드 기준 기저벡터들로 TBN행렬을 구성하여 연산에 활용한다.

 

 

3. 월드 노말 벡터 구하기

float3 normalWS = normalize(mul(normalTS, TBN));

이렇게 구한 World Sapce기준의 Normal을 라이팅 연산에 사용하면 된다!

 

 

 

 

코드 예시


  • shared.fxh
// constant buffer
cbuffer ConstantBuffer : register(b0)
{
    matrix world;
    matrix view;
    matrix projection;
    
    float4 lightDirection;
    float4 lightColor;
    
    float indirectLight;
    float directLight;
    
    float ambientHighlight;
    float diffuseHighlight;
    float specularHighlight;
    float shininess;
    float2 padding1;
    
    float3 cameraPos;
    float padding2;
}

struct VS_INPUT
{
    float3 pos : POSITION;
    float3 tangent : TANGENT;
    float3 bitangent : BITANGENT;
    float3 normal : NORMAL;
    float2 texCoord : TEXCOORD;
};

struct PS_INPUT
{
    float4 pos : SV_Position;
    float3 worldPos : WORLD_POSITION;
    float3 tangent : TANGENT;
    float3 bitangent : BITANGENT;
    float3 normal : NORMAL;
    float2 texCoord : TEXCOORD; 
};

 

  • vertexshader.hlsl
#include <shared.fxh>

PS_INPUT main(VS_INPUT input)
{
    PS_INPUT output = (PS_INPUT) 0;
    
    // clip position
    output.pos = mul(float4(input.pos, 1.0f), world); // local -> world
    output.worldPos = output.pos.xyz;                 // (world pos 저장)
    output.pos = mul(output.pos, view);               // world -> view
    output.pos = mul(output.pos, projection);         // view -> clip
    
    // world TBN
    output.tangent = normalize(mul(input.tangent, (float3x3) world));
    output.bitangent = normalize(mul(input.bitangent, (float3x3) world));
    output.normal = normalize(mul(input.normal, (float3x3) world));
    
    // uv
    output.texCoord = input.texCoord;
    
    return output;
}

 

  • pixel shader
#include <shared.fxh>

Texture2D diffuseMap : register(t0);
Texture2D normalMap : register(t1);
Texture2D specualrMap : register(t2);
SamplerState samLinear : register(s0);


float4 main(PS_INPUT input) : SV_TARGET
{
    // tbn matrix
    float3x3 TBN = float3x3(input.tangent, input.bitangent, input.normal);
    
    // world nomal
    float3 local_normal = normalMap.Sample(samLinear, input.texCoord).xyz * 2.0f - 1.0f;
    float3 world_normal = normalize(mul(local_normal, TBN));
    
    float3 N = normalize(world_normal);
    float3 L = normalize(-lightDirection.xyz);
    float3 V = normalize(cameraPos - input.worldPos);
    float3 H = normalize(L + V);

    // ambient
    float3 ambient = indirectLight * ambientHighlight * lightColor.rgb;

    // diffuse
    float3 diffuse_color = diffuseMap.Sample(samLinear, input.texCoord);
    float diff = max(dot(N, L), 0.0f);
    float3 diffuse = directLight * diffuseHighlight * diffuse_color * diff * lightColor.rgb;
   
    // specular
    float3 specular_color = specualrMap.Sample(samLinear, input.texCoord);
    float spec = pow(max(dot(N, H), 0.0f), shininess);
    float3 specular = directLight * specularHighlight * specular_color * spec * lightColor.rgb;

    // final color (블린퐁)
    float3 finalColor = ambient + diffuse + specular;
    return float4(finalColor, 1.0f);
}

 

 

 

끝!