[DirectX11]

Normal Mapping

럭키🍀 2024. 9. 18. 14:23

지난번 시간의 Material에 쉐이더에 넘겨줄 값들을 한데 모으는 작업을 했었다.

이번 시간엔 Material에 질감을 주는 방법을 더 연구해보자.

예를들어 같은 메쉬에 같은 디퓨즈맵 텍스쳐를 입히면 같은 오브젝트처럼 보일것이다. 그러나 어떤한곳에는 울툴불퉁한 느낌을 주고싶다. 그럼 어떻게 해야될까? 한 면에서도 빛을 반사시키는 각도를 다르게 보이게 하면 마치 굴절이 있는것처럼 보인다. 그럼 이를 구현하는 방법은 무엇이 있을까?

방법 1) 메쉬의 정점수를 늘리는 것이다. 그럼 정말로 표면이 울퉁불퉁한 것이고 빛 계산도 정점에 따라 다르게 될 것이다. 그러나 정점수가 늘어난다는 것은 파이프라인에 들어가는 정점의 수가 늘어난단다는것으로 렌더링의 부하가 같이 커지게 된다.

방법2) 그럼 음영효과를 줄 수 있으면서 삼각형의(정점)의 갯수를 늘리지 않는 방법은 무엇일까?

바로 노멀맵핑을 사용하는 것이다. 이를 알아보자.

 

빛의 굴절이 생기려면 실제로 한 면에서도 노멀 값이 달라야 하고 이를 모아서 새로운 텍스쳐로 관리를 해야한다.

디퓨즈맵, 색을 뽑아주는 텍스쳐와 별도로말이다.

여기에서 텍스쳐에서 어떤 정보를 뽑아내느냐에 따라 쓰임이 다양하다는 걸 알 수 있다. 

 

 

탄젠트 스페이스

 

그럼 기준을 무엇으로 삼아서 노멀벡터를 구해야하냐가 관건인데 '탄젠트 스페이스'의 개념이 나타난다.

지금까지 좌표계 변환이 로컬-월드-뷰 스페이스로 넘어갔는데 사실은 노멀벡터와 관련된 제 3의 공간이 있는것이고 얘를 탄젠트 스페이스라고 한다.

탄젠트는 접하는것에 의미가 있는데 구의 표면에 접하는 평면이 있다고 가정해보자.(좌 하단) 그 표면에 3가지 축을 가진 벡터가 있는데 탄젠트, 바이노멀, 노멀이다. 각각 x,y,z축을 의미한다.

참고로 로컬은 구의 원점(중심)을 기준으로 x,y,z가 있었는데 탄젠트스페이스는 정점마다 다 다른값을 가지고있을수 있어 정점의 개수와 비례한다?

결론은 로컬스페이스가 있기전에부터 이미 눈에는 보이지 않았지만 모든 공간에 모든 방향에 대해 탄젠트 스페이스가 존재했다.

 

T,B,N중에서 N은 노멀벡터는 표면을 기준으로 구할 수있고 B,T도 구하는 식이 있으나 생략하고 그냥 접하는 평면으로부터 구할수 있고 나중에 툴로만든 fbx,메쉬를 로드해도 N,B,T를 거기에서 추출할 수 있다. 또 N,B,T 중 두 개만 주어지더라도 외적을 통해 나머지 하나를 구할 수 있다.

 


우선 테스트할 노멀맵 텍스쳐를 살펴보자. 솔루션 하위의 Resources\Textures에 Leather_Normal.jpg로 저장해두었다. 이 텍스쳐에서 어떻게 노멀맵의 정보를 가져올 수 있을까? 어떻게 노멀맵을 구성할지 생각해보면 우선 한 픽셀마다 rgb값이 있다. 얘를 이용해 x,y,z값이 각각 -1 ~ 1의 값으로 사용할 것이다. 중요한 것은 이 좌표값들은 탄젠트스페이스를 기준으로 나타나는 값이다. 그런데 왜 샘플에 있는 노멀맵은 푸른색을 띄고있을까? 그것은 대부분의 노멀맵이 푸른색인데 r,g,b값이 t,b,n을 나타내고있고 파랑색값이 normal을 나타내는데 노멀벡터값이 제일 높은 경우가 많기 때문이다. (탄젠트스페이스 기준으로 대각선 기울기보다 높이가 더 높은 경우이겠지) 

 

또 rgba로 탄젠트스페이스의 x,y,z를 나타낸다는 것 외에도 유의할 점은 어떤 좌표계에서 만나서 쉐이더코드를 연산하느냐이다. 지금까지는 픽셀 쉐이더 작업을 할 때 월드에서 만나서 월드에서 연산을 하는 식으로 했다. 그러나 뷰스페이스에서 만나서 연산을 하는것도 방법이다.?-> 아마 픽셀쉐이더에 월드변환 좌표를 넘겨줘서 그 기준으로 연산했다는것인가?

지금까지는 로컬->월드->카메라->뷰 스페이스 변환이 있었는데 여기에 탄젠트 스페이스가 추가된것이다.

이 탄젠트스페이스는 좌표계 변환 단계 중 어디에서 먼저 연산되냐면 로컬스페이스의 전이다. 탄젠트 스페이스의 좌표들이 로컬 스페이스의 좌표계로 넘어가야한다. 탄젠트스페이스에서의 좌표를 로컬스페이스계로 바꿨다가 월드스페이스계로 또 두 번 연산해 바꿀 수도 있지만 한번에 탄젠트좌표계에서 월드좌표계로 바꿀 수도 있다. 그 행렬을 구해보자.

 

 

만능 좌표계 변환 행렬을 보면 A좌표계에서 B좌표계를 변환할 때

u,v벡터를 가진 A좌표계에선 한 점이 (x,y,z)좌표를 갖고 있었는데

U,V벡터를 가진 B좌표계에선 같은 값이 (X,Y,Z)좌표를 갖고 있는 것으로 바뀌는 것이다. 이때 x,y,z를 X,Y,Z로 바꿔주는 행렬 M은 사진과 같은 원소들을 가지게 되는 것이다. 

 

 

 

즉, A좌표계에서 원점을 가져서

1,0,0

0,1,0

0,0,1 이 각각 A 좌표계에서의 u,v,w좌표였는데 B 좌표계서는 다른 벡터값들을 가지게 되고 그게

ux,uv,uz

vx,vy,vz

wx,wy,wz값을 가진다.

각각 right, up, look벡터를 나타낸다.

그리고 A좌표계에서 B좌표계로 회전뿐만 아니라 이동도 일어났다면 네번째 행에 B좌표계 기준 A의 좌표 x,y,z값이 들어간다. => 이동을 포함할거면 A좌표계에서의 한 점의 좌표를 나타낼 때, x,y,z외에 마지막 성분으로 1을 넣고 아니면 0을 넣으면 합 Qx+Qy+Qz의 값이 안나오게 되어 회전만 적용된다.

 

위 만능 좌표계 변환 행렬에서 유도해보면 탄젠트 스페이스에서의 한 좌표를 알았을 때 이를 월드스페이스에서의 좌표로 바꾸는 행렬을 어떻게 만드는지 알 수 있다. 다시말하면 탄젠트스페이서의 T,B,N이 월드스페이스에서 어떤 값을 가질지를 예상해보는 것이다. 우선 지금까지는 노멀값(N)만 기본 메쉬에서 쓰고있었는데 탄젠트 값(T)도 넣어서 사용한다. 이 둘을 외적해서 바이노멀값(B)을 구할 수도 있다.

 미리 기본으로 넣어준 노멀값, 탄젠트 값은 로컬스페이스를 기준으로 준 값이다. 이를 로컬에서 월드좌표계 변환 행렬에 넣으면 월드에서의 T,B,N 좌표가 나오게 된다.

그러면 좌표계 변환행렬의 첫행, 둘째행, 셋째행에 T,B,N의 성분을 넣으면 탄젠트스페이스에서 월드스페이스로의 변환 행렬이 되는 것이다..?

다시 정리를 해보면 노멀맵에 넣어주는 r,g,b값은 탄젠트 스페이스 기준 -1~1 사이의 t,b,n값이 0~255로 치환되어 들어가 있는 값이고 이 값이 다시 쉐이더에서 -1~1사이로 변환되고 또 로컬X월드좌표계로 변환해주는 좌표계 변환 행렬을 곱해주면 노멀값이 월드좌표에서 어떤 값을 가지는지가 나온다.

 

정점을 늘리지 않으면서도 픽셀마다 다른 노멀값을 가지고 있는것 같은 효과를 주는 것이 바로 노멀맵핑의 핵심이다.

  1. 노멀맵핑이 필요한 이유 : 점점추가해 삼각형을 늘리지 않고 음영효과를 내기 위해 노멀 맵핑을 사용한다.
  2. 노멀맵 텍스쳐에 들어가있는 값의 의미: 텍스쳐의 r,g,b가 탄젠트 스페이스(표면에 접하는 평면)에서의 t,b,n값을 의미한다.
  3. 노멀맵에서 읽은 값을 로컬좌표계로, 또 다시 월드좌표계로 한번에 변환되는 변환행렬: t,b,n이 월드좌표계 기준으로 구할 수 있으면 그 값이 변환행렬의 up, right, look벡터가 된다.

그럼 다음 단계에서 픽셀쉐이더 단계에서 노멀 값을 이용해 빛연산이 들어가면 더 입체감이 생기게 된다.

 

이를 한번 구현해보자.

제일 앞서 VertexData.h에 VertexTextureNormalData구조체를 만들어서 position, uv, normal값이 들어있었다.

그리고 normal값을 직접 계산식이나 변환행렬을 이용해 구할 수도 있지만 사실상 대부분(fbx파일등을 읽어서 사용할 때) 정점에서 노말과 탄젠트 값도 이미 포함돼서 쉐이더에 들어간다. 그래서 Tangent값도 들어간 정점 구조체 하나를 더 VertexTextureNormalData타입으로 만들었다.

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

 

구조체에 Binormal값도 넣을 수 있지만 이미 normal값과 tangent값만 알면 외적을 통해 구할 수 있으므로 굳이 넣지 않아도 된다.

이 구조체를 사용하도록 모두 수정해야한다.

ResourceManager에서 메쉬를 만드는데 메쉬가 들어있는 geometry정보도, 메쉬를 만들때 사용하는 GeometryHelper도 VertexTextureNormalData를 VertexTextureNormalTangentData를 사용하도록 바꿔준다. 아래는 큐브만 일부를 넣었다.

// GeometryHelper.cpp
// ...
void GeometryHelper::CreateCube(shared_ptr<Geometry<VertexTextureNormalTangentData>> geometry)
{
	float w2 = 0.5f;
	float h2 = 0.5f;
	float d2 = 0.5f;

	vector<VertexTextureNormalTangentData> vtx(24);

	// 앞면
	vtx[0] = VertexTextureNormalTangentData(Vec3(-w2, -h2, -d2), Vec2(0.0f, 1.0f), Vec3(0.0f, 0.0f, -1.0f), Vec3(1.0f, 0.0f, 0.0f));
	vtx[1] = VertexTextureNormalTangentData(Vec3(-w2, +h2, -d2), Vec2(0.0f, 0.0f), Vec3(0.0f, 0.0f, -1.0f), Vec3(1.0f, 0.0f, 0.0f));
	vtx[2] = VertexTextureNormalTangentData(Vec3(+w2, +h2, -d2), Vec2(1.0f, 0.0f), Vec3(0.0f, 0.0f, -1.0f), Vec3(1.0f, 0.0f, 0.0f));
	vtx[3] = VertexTextureNormalTangentData(Vec3(+w2, -h2, -d2), Vec2(1.0f, 1.0f), Vec3(0.0f, 0.0f, -1.0f), Vec3(1.0f, 0.0f, 0.0f));
	// 뒷면
	vtx[4] = VertexTextureNormalTangentData(Vec3(-w2, -h2, +d2), Vec2(1.0f, 1.0f), Vec3(0.0f, 0.0f, 1.0f), Vec3(-1.0f, 0.0f, 0.0f));
	vtx[5] = VertexTextureNormalTangentData(Vec3(+w2, -h2, +d2), Vec2(0.0f, 1.0f), Vec3(0.0f, 0.0f, 1.0f), Vec3(-1.0f, 0.0f, 0.0f));
	vtx[6] = VertexTextureNormalTangentData(Vec3(+w2, +h2, +d2), Vec2(0.0f, 0.0f), Vec3(0.0f, 0.0f, 1.0f), Vec3(-1.0f, 0.0f, 0.0f));
	vtx[7] = VertexTextureNormalTangentData(Vec3(-w2, +h2, +d2), Vec2(1.0f, 0.0f), Vec3(0.0f, 0.0f, 1.0f), Vec3(-1.0f, 0.0f, 0.0f));
	// 윗면
	vtx[8] = VertexTextureNormalTangentData(Vec3(-w2, +h2, -d2), Vec2(0.0f, 1.0f), Vec3(0.0f, 1.0f, 0.0f), Vec3(1.0f, 0.0f, 0.0f));
	vtx[9] = VertexTextureNormalTangentData(Vec3(-w2, +h2, +d2), Vec2(0.0f, 0.0f), Vec3(0.0f, 1.0f, 0.0f), Vec3(1.0f, 0.0f, 0.0f));
	vtx[10] = VertexTextureNormalTangentData(Vec3(+w2, +h2, +d2), Vec2(1.0f, 0.0f), Vec3(0.0f, 1.0f, 0.0f), Vec3(1.0f, 0.0f, 0.0f));
	vtx[11] = VertexTextureNormalTangentData(Vec3(+w2, +h2, -d2), Vec2(1.0f, 1.0f), Vec3(0.0f, 1.0f, 0.0f), Vec3(1.0f, 0.0f, 0.0f));
	// 아랫면
	vtx[12] = VertexTextureNormalTangentData(Vec3(-w2, -h2, -d2), Vec2(1.0f, 1.0f), Vec3(0.0f, -1.0f, 0.0f), Vec3(-1.0f, 0.0f, 0.0f));
	vtx[13] = VertexTextureNormalTangentData(Vec3(+w2, -h2, -d2), Vec2(0.0f, 1.0f), Vec3(0.0f, -1.0f, 0.0f), Vec3(-1.0f, 0.0f, 0.0f));
	vtx[14] = VertexTextureNormalTangentData(Vec3(+w2, -h2, +d2), Vec2(0.0f, 0.0f), Vec3(0.0f, -1.0f, 0.0f), Vec3(-1.0f, 0.0f, 0.0f));
	vtx[15] = VertexTextureNormalTangentData(Vec3(-w2, -h2, +d2), Vec2(1.0f, 0.0f), Vec3(0.0f, -1.0f, 0.0f), Vec3(-1.0f, 0.0f, 0.0f));
	// 왼쪽면
	vtx[16] = VertexTextureNormalTangentData(Vec3(-w2, -h2, +d2), Vec2(0.0f, 1.0f), Vec3(-1.0f, 0.0f, 0.0f), Vec3(0.0f, 0.0f, -1.0f));
	vtx[17] = VertexTextureNormalTangentData(Vec3(-w2, +h2, +d2), Vec2(0.0f, 0.0f), Vec3(-1.0f, 0.0f, 0.0f), Vec3(0.0f, 0.0f, -1.0f));
	vtx[18] = VertexTextureNormalTangentData(Vec3(-w2, +h2, -d2), Vec2(1.0f, 0.0f), Vec3(-1.0f, 0.0f, 0.0f), Vec3(0.0f, 0.0f, -1.0f));
	vtx[19] = VertexTextureNormalTangentData(Vec3(-w2, -h2, -d2), Vec2(1.0f, 1.0f), Vec3(-1.0f, 0.0f, 0.0f), Vec3(0.0f, 0.0f, -1.0f));
	// 오른쪽면
	vtx[20] = VertexTextureNormalTangentData(Vec3(+w2, -h2, -d2), Vec2(0.0f, 1.0f), Vec3(1.0f, 0.0f, 0.0f), Vec3(0.0f, 0.0f, 1.0f));
	vtx[21] = VertexTextureNormalTangentData(Vec3(+w2, +h2, -d2), Vec2(0.0f, 0.0f), Vec3(1.0f, 0.0f, 0.0f), Vec3(0.0f, 0.0f, 1.0f));
	vtx[22] = VertexTextureNormalTangentData(Vec3(+w2, +h2, +d2), Vec2(1.0f, 0.0f), Vec3(1.0f, 0.0f, 0.0f), Vec3(0.0f, 0.0f, 1.0f));
	vtx[23] = VertexTextureNormalTangentData(Vec3(+w2, -h2, +d2), Vec2(1.0f, 1.0f), Vec3(1.0f, 0.0f, 0.0f), Vec3(0.0f, 0.0f, 1.0f));

	geometry->SetVertices(vtx);

	vector<uint32> idx(36);

	// 앞면
	idx[0] = 0; idx[1] = 1; idx[2] = 2;
	idx[3] = 0; idx[4] = 2; idx[5] = 3;
	// 뒷면
	idx[6] = 4; idx[7] = 5; idx[8] = 6;
	idx[9] = 4; idx[10] = 6; idx[11] = 7;
	// 윗면
	idx[12] = 8; idx[13] = 9; idx[14] = 10;
	idx[15] = 8; idx[16] = 10; idx[17] = 11;
	// 아랫면
	idx[18] = 12; idx[19] = 13; idx[20] = 14;
	idx[21] = 12; idx[22] = 14; idx[23] = 15;
	// 왼쪽면
	idx[24] = 16; idx[25] = 17; idx[26] = 18;
	idx[27] = 16; idx[28] = 18; idx[29] = 19;
	// 오른쪽면
	idx[30] = 20; idx[31] = 21; idx[32] = 22;
	idx[33] = 20; idx[34] = 22; idx[35] = 23;

	geometry->SetIndices(idx);
}

GeometryHelper에 VertexTextureNormalTanentData로 바꿔줄 때 tangent값은 계산한 값이나 기본값으로 일단 넣어줬다.

또 이 정점 구조체 형식에 맞게 쉐이더도 수정해줘야한다.

Client프로젝트의 00.Global.fx에도 새로운 구조체

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

를 똑같이 추가해준다. 그래야 GeometryHelper로 만든 구조체가 쉐이더에도 똑같은 형태로 만들어진다.

또 MeshOutput도 아래와 같은 형식으로 바꿔주었다.

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

우선 노멀맵핑을 위한 탄젠트값 추가는 다 준비되었다. 이제 이를 활용해보자.

 

13.Lighting.fx를 복붙해서 14.NormalMappin.fx를 Client프로젝트의 Shaders필터에 넣었고 또 17.MaterialDemo.h,cpp파일을 복붙해서 18.NormalMappingDemo 파일들을 만들었다.

그리고 쓸 쉐이더 파일도 방금 만든 파일로 대체해줬다.

14.NormalMapping.fx 쉐이더에서 수정할 것은 VS의 input 타입과 VS에서 세팅해줄 MeshOutput에 tangent 타입을 넣어주는 것이다. output.tangent값은 로컬좌표계에서의 탄젠트 좌표정보를 월드좌표계변환 행렬에 곱해준 값을 넣어준다.

그리고 픽셀쉐이더에는 월드좌표기준의 노멀과 탄젠트값이 MeshOutput에 들어와서 input으로 넘어온다. 이 값들을 가지고 픽셀쉐이더에 노멀 맵핑과 관련된 함수를 사용하도록 추가해준다.

공용으로 사용할것이므로 00.Light.fx에 만들어서 가져다 쓰자. ComputeNormalMapping이라는 이름으로 input.normal, input.tangent, input.uv 세가지를 넣으면 normal값을 이용해 새로운 노멀값을 구해 다시 전해줄 것이기 때문에 inout키워드를 넣어주었다. (C++에서 &,*같은 역할)

// 00.Light.fx
// ...
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;
}

우선 제일 먼저 하는일은 미리 읽어들였던 NormalM ap에서 uv좌표에 있는 픽셀을 리니어 샘플링하는 것 이다. 그리고 그 값이 r,g,b값을 체크해서 값이 아무 것도 없으면 사용할 정보가 없다는 뜻이므로 바로 리턴하게 해줬다.(any는 rgb값중 하나라도 0이 아닌 값이 있으면 true를 반환하고 아니면 false를 반환하 는 함수이다.)
그 다음으로 할 일은 혹시 몰라 normal, tangent값 을 정규화하여 이 둘을 이용해 Binormal값을 외적하여 구하고 첫행을 T,둘째행을 B, 셋째행을 N으로 하 는 3*3행렬을 만든다. 이 변환행렬이 탄젠트스페이스에서 월드스페이스로 변환해주는 변환행렬이 된다. 위의 NormalMap에서 샘플링을 통해 0~255값을 가지던 픽셀값이 0~1사이의 값으로 변환됐다면 다시 이를 -1~1사이의 값으로 변환해야한다. 그냥 단순히 *2를 한다음에 1을 해주면 된다. 이렇게 구한 tangentSpaceNormal값은 노멀맵에서 추출한 값이 -1~1 사이의 값을 갖도록 된 것이다. 그리고 이 값은 tang ent space에서의 값이므로 world좌표로 바꿔야하 는데 이를 TBN행렬에 곱하면 구할 수 있다.

=>이렇게 만든 ComputNormalMapping함수를 픽셀쉐이더에서 호출하고 나온 새로운 노멀값은 처음에 정점에 넣었던 input.normal값이 아니라 텍스쳐에 넣어준 픽셀 단위로 세팅해준 좌표를 uv맵을 통해서 가져온 값으로 바꿔줘 훨씬 정밀한 값을 갖게된다.

 

18.NormalMappingDemo에서도 Init에서 Leather텍스쳐를 읽어와서 디퓨즈맵으로 설정해주고 노멀맵으로 쓸 텍스쳐도 읽어와서 SetNormalMap으로 넘겨줬다.

마지막으로 라이트의 방향을 오른쪽에서 안쪽으로 들어가도록 light.desc값을 1,0,1로 바꿔줬다.

결국이 노멀맵을 적용했을 때 훨씬 더 생생하게 물체를 표현할 수 있다.

노멀맵을 제거한 결과과 비교해보면 훨씬 다른게 보인다.

좌) 노멀맵 적용X, 우)노멀맵 적용 O

'[DirectX11]' 카테고리의 다른 글

모델 #2 Assimp라이브러리 이용해 Material, Bone, Mesh로딩하기.  (0) 2024.09.19
모델 #1 Assimp 라이브러리  (0) 2024.09.19
Material  (0) 2024.09.10
Light #5 Light 통합  (0) 2024.09.09
Light #4 - Emissive  (0) 2024.09.09