DirectX

50. Light, Material_Normal Mapping

devRiripong 2024. 2. 17.
반응형

 

이번 주차의 주제는 Normal Mapping에 관한 것이며, 이는 3D 그래픽스에서 상당히 중요한 개념입니다. 코드 구현은 비교적 단순할 수 있지만, 이면에 있는 개념과 원리는 이해하기 까다로울 수 있습니다. 본 내용을 통해 우리는 Normal Mapping이 무엇인지, 왜 필요한지, 그리고 어떻게 구현할 수 있는지에 대해 알아볼 것입니다. 

1. 이론

1) Normal Mapping의 필요성

3D 모델에 디테일을 더하기 위해 삼각형의 개수를 늘리는 방법이 있지만, 이는 렌더링 파이프라인을 통과하는 각 점마다 추가적인 계산 부담을 가져옵니다. 이러한 문제를 해결하고자, 삼각형의 수를 늘리지 않으면서도 음영 효과를 향상시킬 수 있는 방법으로 Normal Mapping이 개발되었습니다. 이 기술은 이미지 내에 Normal 벡터의 좌표와 관련된 정보를 저장함으로써, 모델의 표면에 복잡한 디테일과 질감을 추가할 수 있게 합니다.

2) Normal Mapping의 원리

Normal Mapping에서는 모델의 각 정점에 대해 Normal (파란색), Tangent (빨간색), 그리고 Binormal (녹색) 벡터를 계산합니다. 이 세 벡터는 각각의 정점에서 정의되는 탄전트 스페이스를 형성합니다. 따라서, 모델의 각 부분은 자신만의 탄전트 공간을 갖게 되며, 이 공간에서 T(Tangent), B(Binormal), N(Normal)의 정보를 픽셀 단위로 이미지에 저장합니다.

3) 색상의 의미

Normal Mapping에서는 왜 주로 파란색 계열이 사용될까요? 이는 Normal 벡터가 탄전트 스페이스에서 좌표로 표현될 때, 해당 벡터의 방향을 기준으로 좌표가 설정되기 때문입니다. 파란색인 N(Normal) 벡터의 방향이 좌표계에서 가장 높은 확률로 나타나는 방향이 되므로, 파란색 계열이 주로 사용됩니다.

4) 요약

Normal Mapping은 텍스쳐에 픽셀 단위로 T(Tangent), B(Binormal), N(Normal)과 관련된 정보를 저장하는 기술입니다. 이 정보를 추출하고 탄전트 스페이스를 기준으로 각 성분을 해석함으로써, 삼각형의 수를 증가시키지 않고도 3D 모델에 복잡한 디테일과 질감을 추가할 수 있습니다. 이로써 고도의 렌더링 퀄리티를 유지하면서도 계산 부담을 줄일 수 있는 효과적인 방법을 제공합니다.

 

1) 노멀 매핑이 왜 필요 할까?

삼각형 늘리지 않고 음영 효과를 내기 위해 노멀 방향을 매핑하기 위한 용도로 사용하는 거

 

2) 좌표계를 어떻게 변환하고 normal map 이미지가 어떤 의미를 갖고 있는가? 

TangentSpace라는 공간에서의 좌표를 나타내고, TangentSpace에서 local로 world로 넘어가는 변환 행렬을 구하는데 만능 공식으로 구할 수 있다.

t, b, n을 world 좌표계를 기준으로 했을 때의 좌표로 구하고, 변환행렬에 배치해서 변환 행렬을 만들어서 값을 추출해서 사용할 준비가 끝난다고 볼 수 있다.

 

 

2. 탄전트 스페이스->로컬->월드로 넘어가는 변환 행렬 구하기 위해 해줘야 할 작업들

3D 그래픽스에서 실질적으로 쉐이더 코드를 작성할 때, 작업할 영역을 결정하는 것은 쉐이더를 통해 이루어진다. 이 과정에서 탄전트 스페이스에서 시작하여 로컬 공간을 거쳐 월드 공간으로 넘어가는 변환 단계가 필수적이다.

 

텍스쳐 상에서의 좌표는 탄전트 스페이서에서의 좌표라고 할 수 있다.

 

1) 만능 공식을 이용해 월드 스페이스 변환 행렬 구하기

변환행렬을 구해야 한다.

공식유도(https://devriripong.tistory.com/116)

A좌표계에서 B로 넘어가야 한다.

 

B를 기준으로 하는 A의 u, v, w 벡터를 구해주는 게 핵심이다.

B를 기준으로 했을 때 A의 right, up, look 벡터를 구해줘서 그걸 행렬의 첫 번째, 두 번째, 세 번째 줄에 배치를 한다.

A에서 B로 translation이 일어난다고 하면, B좌표계를 기준으로 했을 때의 A의 좌표를 Qx, Qy, Qz에 넣어 주면 완성된 좌표계 변환 행렬이 된다.

벡터 v = x,y,z,1에서 1로 하면 translation도 적용 시키는 거고,

벡터v = x, y, z,0으로 하면 마지막 줄을 Qx, Qy, Qz는 무시가 되어서 회전과 관련된 부분들만 적용이 된다.

 

중요한 건 탄전트 스페이스에서 어떤 좌표를 알았을 때 world로 끄집어내는 작업을 하고 싶은 것이다.

그것을 하려면 만능 공식에 의해서 A가 Tangent 스페이스고 B가 World 스페이스라 가정했을 때,

 

알아야 할 것은 탄전트 스페이스에서 원래 좌표축이 됐던 u, v, w의 좌표를 월드스페이스 기준으로 변환해서 그 좌표를 알아야 한다.

u, v, w라는 탄전트 스페이스에서 T, B, N이라는 방향벡터가 월드 스페이스로서는 어 떤 방향을 갖고 있는지 어떤 성분으로 이루어져 있는지 좌표만 구하면 된다.

 

2) 알고 있는 정보 조합해 얻은 TBN과 만능공식을 이용해 World 변환 행렬 만들기

알고 있는 정보를 이리저리 조합을 해보자면

local→World는 쉽게 구할 수 있다.

지금까지 사용했던 w대문자가 월드 변환 행렬인 것이고,

로컬 좌표를 기준으로 했을 때, T, B, N이 무엇인지 구할 수 있다.

메쉬에서 기본적으로 제공하는 이 정보가 로컬 좌표계를 기준으로 했을 때의 normal, 그리고 tangent의 좌표라고 볼 수 있다. B는 둘을 외적 해서 구할 수 있다.

 

알아야 하는 건 TBN을 World를 기준으로 했을 때의 좌표인데

알고 있는 것은 로컬을 기준으로 하는 TBN의 좌표를 알고 있다는 얘기가 된다.

 

한 단계 넘어가고 싶어 world를 곱하게 되면 TBN이 World좌표를 기준으로 했을 때 어떤 성분을 가지고 있는지 구할 수 있을 것이다.

 

다시

여기의 행렬의 첫 번째, 두 번째, 세 번째 줄의 T, B, N을 각 배치해서

3X3 행렬을 만들게 되면 그게 결국에는 원했던 Tangent space에서 어떤 좌표가 있을 때 그것을 world 좌표계로 변환을 하는 변환 행렬을 얻어 줄 수가 있다.

 

3) RGB 0~255를 -1~1 수로 변환하기

텍스쳐의 값을 -1~1 사이의 값으로 변환을 해줘야 한다. (1에서 1 사이의 값으로 작업하는 것을 표준이기 때문)

RGB는 색의 값이자 탄전트 스페이스를 기준으로 했을 때의 좌표를 말하는 것이지만

여기다가 방금 구했던 로컬 → 월드로 넘어가는 변환행렬을 곱해주면, normal 벡터의 방향을 월드좌표로 했을 때 어떤 좌표를 갖고 있는지를 추출할 수 있게 된다.

 

정점을 늘리지 않고도 픽셀 단위로 각기 다른 normal 값을 갖게 하는 게 사실상 normal mapping의 핵심이다.

 

3.  코드 작업하기

1) VertexData.h에 struct VertexTextureNormalTangentData 추가하고 연관 코드 수정하기

VertexData.h를 보면

struct VertexTextureNormalData
{
	Vec3 position = { 0, 0, 0 };
	Vec2 uv = { 0, 0 };
	Vec3 normal = { 0, 0, 0 };
};

기존의 코드를 보면 이렇게 normal 까지만 사용했다.

 

대부분은 정점에는 normal과 tangent 정보까지는 포함이 되어서 들어간다.

그러기 때문에 struct를 추가해준다.

struct VertexTextureNormalTangentData
{
	Vec3 position = { 0, 0, 0 };
	Vec2 uv = { 0, 0 };
	Vec3 normal = { 0, 0, 0 };
	Vec3 tangent = { 0, 0, 0 };
};

normal에 tangent까지 넣어 줬다.

Binormal을 넣어서 B까지 넣어줘도 되지만 Normal과 Tangent를 알면 외적을 통해서 B도 구할 수 있기 때문에 2개만 넣었다.

 

 

Geometry 만들 때 지금까지 사용하던 Cube나 Sphere 같은 걸 보면

ResourceManager라는 애를 통해 Mesh를 이용해서 만들고 있었다.

Mesh가 어떤 정보를 들고 있나 타고 가 보면

shared_ptr<Geometry<VertexTextureNormalData>> _geometry;

VertexTextureNormalData를 들고 있는 걸 볼 수 있다.

이것을 Tangent를 받는 버전으로 수정을 해준다.

shared_ptr<Geometry<VertexTextureNormalTangentData>> _geometry;

기존에 작업하던 쉐이더는 먹통이 될 수 있다.

Mesh.cpp에 가서도 수정해 준다.

 

 

GeometryHelper 클래스에서도

static void CreateQuad(shared_ptr<Geometry<VertexTextureNormalTangentData>> geometry);
static void CreateCube(shared_ptr<Geometry<VertexTextureNormalTangentData>> geometry);
static void CreateGrid(shared_ptr<Geometry<VertexTextureNormalTangentData>> geometry, int32 sizeX, int32 sizeZ);
static void CreateSphere(shared_ptr<Geometry<VertexTextureNormalTangentData>> geometry);

이렇게 VertexTextureNormalTangentData를 사용하는 버전을 하나 더 만들어 준다.

이 새로운 버전의 함수들을 구현한다.

 

이렇게 tangent까지 세팅이 되었다.

나중에 메쉬 로딩할 때 탄젠트가 포함된 버전으로 할 것이다.

 

이제 매쉬를 사용할 때 탄전트가 들어간 버전을 사용할 것이다가 핵심이다.

 

엔진을 빌드한다.

 

 

Global.fx에도 마찬가지로 struct VertexTextureNormalTangent를 추가한다.

struct VertexTextureNormalTangent
{
    float4 position : POSITION;
    float2 uv : TEXCOORD;
    float3 normal : NORMAL;
    float3 tangent : TANGENT;
};

Tangent가 붙은 걸로 셰이더를 만들어야 GeometryHelper로 만들어서 넘겨줄 때 이거랑 같은 포맷으로 넘어가게 된다.

 

Global.fx의 struct MeshOutput에서도

struct MeshOutput
{
    float4 position : SV_POSITION;
    float3 worldPosition : POSITION1;
    float2 uv : TEXCOORD;
    float3 normal : NORMAL;
    float3 tangent : TANGENT; 
};

이렇게 tangent를 추가해야 한다.

 

결국 Normal Mapping위해 tangent라는 벡터가 추가되었다는 게 된다.

 

2) 14. NormalMapping.fx와 18. NormalMappingDemo를 생성하고 각각 NormalMappingDemo::Init과 Main에서 세팅하기

 

NormalMapping 실습을 위해 지금까지 했던 루틴을 그대로 한다.

 

13. Lighting.fx 쉐이더를 복제해서 이름을 14. NormalMapping.fx로 하고,

Client/Shaders/Weeks에 넣는다.

 

17. MaterialDemo 클래스도 복제해서 18. NormalMappingDemo으로 한다. Client/Game/Week2에 넣는다.

코드를 NormalMappingDemo에 맞게 수정한다.

void NormalMappingDemo::Init()
{
	RESOURCES->Init();
	_shader = make_shared<Shader>(L"14. NormalMapping.fx");

Main으로 가서

#include "18. NormalMappingDemo.h"
desc.app = make_shared<NormalMappingDemo>();

이렇게 해주면 된다.

 

빌드를 하면 통과된다.

 

3) 14. NormalMapping.fx 쉐이더 작업하기(VS에서 normal과 tangent를 world스페이스 값으로 변환하고 PS에서 ComputeNormalMapping함수에 인자로 넣어 주기)

쉐이더 작업은 어렵지 않다.

 

14. NormalMapping.fx의 VS에서

MeshOutput VS(VertexTextureNormalTangent input)
{

이렇게 매개변수를 VertexTextureNormalTangent로 바꿔준다.

그리고

output.tangent = mul(input.tangent, (float3x3)W);

이렇게 해줄 것인데

그렇다는 얘기는 처음 input.tangent를 받았을 때는 local 좌표계를 기준으로 하는 input.tangent를 받은 건데 그거를 world 행렬을 곱함으로 인해서 output으로 넘어가서 PS로 넘어갈 때는 World 좌표계를 기준으로 하는 tangent 값을 갖게 된다고 볼 수 있다.

 

normal 도 마찬가지다.

 

MeshOutput VS(VertexTextureNormalTangent input)
{
    MeshOutput output;
    output.position = mul(input.position, W);
    output.worldPosition = input.position.xyz;
    output.position = mul(output.position, VP);
    output.uv = input.uv; 
    output.normal = mul(input.normal, (float3x3)W);
    output.tangent = mul(input.tangent, (float3x3)W); 
    
    return output;     
}

 

PS로 넘어가는 순간

normal, tangent의 값의 경우 World 좌표계를 기준으로 하는 Normal과 Tangent라는 걸 알 수 있고,

PS에서 해야 할 것은 normal mapping과 관련된 함수를 만들어 주는 것이다.

float4 PS(MeshOutput input) : SV_TARGET
{
    ComputeNormalMapping(input.normal, input.tangent, input.uv);
    
    float4 color = ComputeLight(input.normal, input.uv, input.worldPosition);
    
    return color;
}

ComputeNormalMapping이라는 함수를 호출하는데 매개 변수를 normal, tangent, uv 이렇게 3개를 넣어줄 것이다.

 

4) Light.fx에서 ComputeNormalMapping함수 정의하기(인자로 받은 N과 T를 이용해 NTB를 조합한 월드 스페이스 변환 행렬을 만들고, NormalMap 텍스쳐의 데이터를  Sampling 한 normal 값과 NTB로 만든 월드변환 행렬을 곱해 worldNormal 값을 계산해 normal에 대입해 주기)

이건 공용으로 사용할 것이기 때문에 Light.fx에다가 만들어줄 것인데

 

여기서 하고 싶은 건 normal을 받은 걸 그대로 사용하다가 여기서 새로운 normal값을 구해주면 그걸로 다시 바꿔치기를 할 예정이기 때문에 float3 normal에 inout 키워드를 넣어준다.

 

input.normal을 기입해서 넣어주게 되면 input.normal을 공식을 통해가지고 새로운 normal 좌표를 계산한 다음에 그거를 input.normal에다가 다시 대입을 시켜 줘 가지고 다시 조작을 해주게 된다는 얘기가 된다. C++로 치면 레퍼런스나 포인터 같은 느낌이라고 보면 된다.

void ComputeNormalMapping(inout float3 normal, float3 tangent, float2 uv)
{
	// [0,255] 범위에서 [0,1]로 변환
	float4 map = NormalMap.Sample(LinearSampler, uv);
	if (any(map.rgb) == false)
		return;

	float3 N = normalize(normal); // z
	float3 T = normalize(tangent); // x
	float3 B = normalize(cross(N, T)); // y
	float3x3 TBN = float3x3(T, B, N); // TS -> WS

	// [0,1] 범위에서 [-1,1] 범위로 변환
	float3 tangentSpaceNormal = (map.rgb * 2.0f - 1.0f);
	float3 worldNormal = mul(tangentSpaceNormal, TBN);

	normal = worldNormal;
}

 

다시 14. NormalMapping.fx의 PS로 돌아가서 

float4 PS(MeshOutput input) : SV_TARGET
{
    ComputeNormalMapping(input.normal, input.tangent, input.uv); 
    
    float4 color = ComputeLight(input.normal, input.uv, input.worldPosition);
    
    return color;
}

ComputeNormalMapping이 실행이 되면

input.normal의 값이 ComputeNormalMapping 함수를 거치고 나서의 NormalMap 텍스쳐의 픽셀이 가진 값과 연산을 하고 world 좌표 변환 행렬이 곱해지면서 world 좌표를 기준으로 한 값으로 변하게 된다.

 

처음에 정점에 넣어준 그 값으로 되는 게 아니라 텍스쳐에 넣어준, 픽셀단위로 세팅해 준 그 좌표를 uv 맵을 통해서 꺼내 준 다음에 바꿔주기 때문에 정밀하게 컨트롤할 수 있게 된다.

 

PS의 나머지 코드는 안 고쳐도 된다.

 

5) NormalMappingDemo에서 테스트하기

이거를 효과를 보려면 NormalMappingDemo를 만들어 줄 때 관련된 텍스쳐를 세팅을 해야 normalMapping이 적용된다.

 

NormalMappingDemo::Init에서

{
    auto texture = RESOURCES->Load<Texture>(L"Leather", L"..\\Resources\\Textures\\Leather.jpg");
    material->SetDiffuseMap(texture); 
}
{
    auto texture = RESOURCES->Load<Texture>(L"LeatherNormal", L"..\\Resources\\Textures\\Leather_Normal.jpg");
    material->SetNormalMap(texture);
}

이렇게 새로운 가죽 이미지와 NormalMap 이미지를 세팅하고

desc.specular = Vec4(1.f);

specular를 1,1,1,1로 설정하고

RESOURCES->Add(L"Leather", material);

Leather라는 이름으로 바꿔서 material을 Add 한다.

 

Get 할 때도

auto material = RESOURCES->Get<Material>(L"Leather");

Leather를 사용하게 수정한다.

 

{
    auto material = RESOURCES->Get<Material>(L"Leather");
    _obj2->GetMeshRenderer()->SetMaterial(material);
}

클론 할 필요도 없어졌으니 ->Clone() 관련 코드를 삭제하고 이렇게 사용한다.

 

이렇게 세팅이 끝났고,

 

NormalMappingDemo::Update()에서

lightDesc.direction = Vec3(1.f, 0.f, 1.f);

빛 방향을 바꾼다.

 

실행을 해보면

훨씬 번쩍번쩍 해졌다.

 

큐브의 파인 부분마다 normal 값이 다르기 때문에 훨씬 효과를 보고 있다고 결론을 내릴 수 있다.

 

6) normal map 텍스쳐를 세팅을 해제해 역체감 하기

NormalMappingDemo::Init()에서

//material->SetNormalMap(texture);

이렇게 주석처리 하고 실행해 보면

차이를 느낄 수 있다.

 

모든 면이 동일한 방식으로 연산이 되고 있기 때문에 밋밋하다.

 

NomalMapping을 적용하면 점마다 normal 벡터가 다르기 때문에 훨씬 표현이 풍부해진다.

 

4. 맺음말

  1. 왜 필요한지 이해하고
  2. 왜 텍스쳐 푸른색인지 이해하려면

탄전트 스페이스의 개념을 이해해야 하고,

탄전트 스페이스에서 월드로 변환하는 행렬을 어떻게 구했는지 생각해 보면 된다.

 

메쉬에서 추출한 normal이랑 tangent는 로컬 스페이스를 기준으로 하는 벡터를 얘기하고

로컬 스페이스를 기준으로 하는 T, N을 구해준 것이다.

그것에 W를 곱해 월드 변환을 해줘서 월드 좌표계를 기준으로 하는 T, N을 구했고, 그걸 외적 해서 B을 구해줬다.

그 T, B, N을 토대로 WorlrdSpace변환 행렬을 만들어 normal 텍스쳐에서 Sampling한 tangentSpaceNormal과 곱해 worldSpaceNormal을 구할 수 있었다.

 

VS, PS, ComputeNormalMapping에서 한 내용 정리

메쉬에서 추출한 Normal과 Tangent는 로컬 스페이스(모델 스페이스)를 기준으로 하는 벡터입니다. 이들은 각 정점에서의 표면의 방향성과 질감 매핑 방향을 나타냅니다.

로컬 스페이스에서 정의된 이 Normal(N)과 Tangent(T)에 월드 변환 행렬 W를 곱함으로써, 월드 좌표계 기준의 Normal과 Tangent를 얻습니다. 이 과정은 모델의 회전, 스케일 등의 기하학적 변형을 반영합니다.

이렇게 변환된 월드 스페이스의 Normal과 Tangent를 이용하여, 그 둘의 외적을 통해 Binormal(B)을 계산합니다. 이렇게 구한 T, B, N은 월드 좌표계에서의 벡터들이며, 이들을 조합하여 TBN 변환 행렬을 생성합니다. TBN 행렬은 탄전트 스페이스에서 정의된 벡터들을 월드 스페이스로 변환하는데 사용됩니다.

노멀 텍스처에서 샘플링한 탄전트 스페이스 노멀은 [0, 1] 범위의 RGB 값을 가지며, 이를 [-1, 1] 범위로 조정한 후, 위에서 구성한 TBN 행렬과 곱합니다. 이 연산을 통해, 탄전트 스페이스에서 샘플링된 노멀을 월드 스페이스의 노멀(worldSpaceNormal)로 변환합니다. 이 최종적으로 변환된 worldSpaceNormal은 노멀 맵에 저장된 표면 디테일을 실제 3D 모델의 조명 계산에 적용하기 위해 사용됩니다.

이 과정은 노멀 맵을 이용하여 3D 모델에 더욱 실감나고 복잡한 표면 질감과 조명 효과를 제공하며, 렌더링된 장면의 시각적 품질을 향상시킵니다.

반응형

'DirectX' 카테고리의 다른 글

52. 모델_Assimp  (0) 2024.02.18
51. Light, Material_버그수정(카메라 좌표)  (0) 2024.02.17
49. Light, Material_Material  (0) 2024.02.16
48. Light, Material_Light 통합  (0) 2024.02.15
47. Light, Material_Emissive  (0) 2024.02.14

댓글