Post Lists

2018년 8월 15일 수요일

Advanced Lighting - Normal Mapping (5)

https://learnopengl.com/Advanced-Lighting/Normal-Mapping

Normal Mapping
모든 우리의 scenes들은 폴리곤들로 채워져있따. 그것들 각각은 수백개 또는 아마 수천개의 flat triangles로 구성된다. 우리는 추가 세부사항을 주기 위해서 이러한 평평한 삼각형에 2D textures를 발라서 현실성을 증가시켰다. 텍스쳐들은 도움되지만, 너가 그것들을 가까이서 봤을 떄, 밑에 깔려있는 flat surfaces를 보는 것은 꽤 쉽다. 그러나 대부분의 실세계의 표면들은 평평하지 않고 많은 (울퉁불퉁한) 세부사항을 보여준다.

예를들어, 벽돌 표면을 봐보자. 벽돌 표면은 꽤 거친 표면이고, 명백히 완전히 평평하지 않다: 그것은 가라앉은 cement 줄무늬와 많은 세부적인 구멍들과 cracks들을 포함한다. 만약 우리가 조명이 있는 scene에서 그러한 brick surface를 본다면, 몰입도가 쉽게 망가진다. 아래에서 우리는 point light에 비춰진 flat surface에 적용된 brick texture를 볼 수 있다.

그 조명은 작은 크랙들과 구멍들을 전혀 고려하지 않고, 완전히 벽돌 사이의 깊은 줄무늬들을 무시한다; 그 표면은 완벽히 평평해 보인다. 우리는 부분적으로 몇몇 표면들이 깊이 또는 다른 세부사항 때문에 덜 비춰지는 것처럼 하기 위해 specular map을 사용하여 그 평평함을 일부 해결할 수 있다. 그러나 그것은 실제 해결책 이상의 hack이다. 우리가 필요한 것은 그 표면의 모든 작은 depth같은 details들에 대한 lighting system을 알리는 방법이다.

만약 우리가 빛의 관점으로부터 이것을 생각한다면, 그 표면은 완전히 평평한 표면으로서 어떻게 비춰지는가? 그 답은 surface의 normal vector에 있다. lighting 알고리즘의 관점으로부터 그것이 한 오브젝트의 모형을 결정하는 유일한 방법은 그것의 수직의 normal vector이다. 그 벽돌 표면은 오직 단일이ㅡ normal vector를 가진다. 그리고 결과적으로 그 표면은 이 normal vector의 방향을 기반으로 균일하기 비춰진다. 만일 각 fragment에 같은 surface normal 대신에, 각 fragment마다 다른 fragment 마다의 normal을 사용한다면 어떨까? 이 방식으로 우리는 다소 표면의 작은 세부사항을 기반으로 normal vector를 벗어날 수 있다; 결과적으로 이것은 우리에게 그 표면이 좀 더 복잡하다는 환상을 준다:

per-fragment normals을 사용하여, 우리는 lighting이 표면이 표면에 엄청난 세부사항 증가를 주는 아주 작은 면들 (normal vectors에 수직한)로 구성되어 있다고 믿도록 할 수 있다. per-surface normals 대신에 per-fragment normals를 사용하는 이 기법은 normal mapping or bump mapping이라고 불려진다. brick plane에 적용된 그것은 이것 처럼 보인다:

너가 볼 수 있듯이, 그것은 세부사항에서 엄청난 증가를 주고, 상대적으로 낮은 비용으로 할 수 있다. 우리가 오직 fragment마다 normal vectors를 바꾸기 때문에, 어떠한 lighting equation을 바꿀 필요가 없다. 우리는 이제 lighting algorithm에 interpolated된 surface normal 대신에 per-fragment normal를 넘겨준다. 그 알고리즘은 한 표면이 그것의 세부사항을 주게 하는 것이다.

Normal mapping
normal mapping을 작동시키기 위해서, 우리는 per-fragment normal이 필요할 것이다. 우리가 diffuse maps과 specular maps와 했던 것과 비슷하게, 우리는 per-fragment data를 저자하기 위해 2D texture를 사용할 수 있다. color and lighting data를 제쳐두고, 우리는 또한 2D texture에 normal vectors를 저장할 수 있다. 이 방식으로 우리는 그 특정한 fragment에 대한 normal vector를 얻기위해 2D texture로부터 샘플링할 수 있다.

normal vectors가 geometric entities이고 텍스쳐가 일반적으로 오직 color information을 위해 사용되는 반면에, normal vectors를 텍스쳐에 저장하는 것은 바로 명백하지 않을 것이다. 만약 너가 한 텍스쳐에 있는 color vectors를 생각한다면, 그것들은 3개의 벡터 r,g and b component로 나타내어진다. 우리는 유사하게 한 normal vector의 x,y and z component를 개별 컬러 컴포넌트에 저장할 수 있다. Normal vectors는 -1 ~ 1사이의 범위에 걸친다. 그래서 그것들은 처음에 [0, 1]의 범위에 매핑된다:

  vec3 rgb_normal = normal * 0.5 + 0.5; // transform from [-1, 1] to [0,1]

normal vectors가 RGB color component로 이렇게 바뀌면서, 우리는 surface의 모형으로 부터 2D texture로 유래되는 per-fragment normal를 저장할 수 있다. 이 튜토리얼의 시작에서 bricak surface의 예제 normal map은 아래에서 보인다:

이것은 (그리고 거의 너가 온라인에서 찾을 모든 노멀 맵들)은 파란색의 색조를 갖는다. 왜냐하면 모든 normals들이 거의 양의 (0,0,1)인 (푸르스름한 컬러) z-axis쪽으로 향하고 있기 때문이다. 컬러에서의 작은 편차들은 일반적인 양의 z 방향으로부터의 약간의 변형된 normal vectors를 나타내며, 이것은 텍스쳐에 깊이감을 준다. 예를들어, 너는 각 brick의 꼭대기에서 그 컬러가 좀 더 파랗게 되는 경향이 있는 것을 볼 수 있다. 이것은 한 brick의 top side가 양의 y 방향 (0,1,0)로 좀 더 가리키는 normals를 가지도록 하기 때문에 말이된다. 이것은 초록색이 되게 한다.

z-axis를 바라보는 간단한 plane으로 우리는 이전 섹션으로부터 그 이미지를 렌더링 하기 위해 이 diffuse texture와 이 normal map을 취할 수 있다. 그 링크의 normal map이 위에서 보여진것과 다르다는 것에 주목해라. 이것에 대한 이유는 OpenGL은 텍스쳐가 일반적으로 생성되는 방식으로부터 반대인 y(or V)좌표로 텍스쳐 좌표를 읽기 때문이다. 그 링크의 normal map은 따라서 그것의 y (or green) component가 역으로 되게 만들었다. (너는 green colors가 이제 아래쪽을 가리키고 있는 것을 볼 수 있다.); 만약 너가 이것을 고려하지 않는다면, 그 라이팅은 부정확할 것이다. 두 텍스쳐를 load하고, 그것들을 적절한 텍스쳐 units에 바인드시키고, lighting fragment shader에 다음의 변화를 가진 plane을 렌더링해라:


#version 330 core
out vec4 FragColor;

in VS_OUT
{
 vec3 FragPos;
 vec2 TexCoords;
} fs_in;


uniform sampler2D texture1;
uniform sampler2D texture1_normal;

uniform vec3 lightPos;
uniform vec3 viewPos;

// Blinn-Phong lighting system with normal mapping practice
void main()
{
 vec3 color = vec3(texture(texture1, fs_in.TexCoords));
 vec3 normal = vec3(texture(texture1_normal, fs_in.TexCoords));
 normal = normalize(normal * 2.0 - 1.0);
 vec3 lightColor = vec3(1.0);

 // ambient
 vec3 ambient = vec3(0.15);

 // diffuse
 vec3 lightDir = normalize(lightPos - fs_in.FragPos);
 float diff = max(dot(lightDir, normal), 0.0);
 vec3 diffuse = diff * lightColor;

 // specular with blinn-phong
 vec3 viewDir = normalize(viewPos - fs_in.FragPos);
 vec3 halfwayDir = normalize(viewDir + lightDir);
 float spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
 vec3 specular = spec *  lightColor;

 vec3 result = (ambient + diffuse + specular) * color;
 
 FragColor = vec4(result, 1.0);
}

여기에서 우리는 샘플된 normal color를 [0,1]에서 다시 [-1, 1]로 remapping하여 normals를 RGB 컬러로 매핑시키는 프로세스를 거꾸로 했다. 그러고나서 그 샘플된 normal vectors를 앞으로 할 lighting calculations을 위해 사용한다. 이 경우에 우리는 Blinn-Phong shader를 사용했다.

시간에 따라 천천히 light source를 움직여서, 너는 normal map을 사용한 깊이감을 얻을 수 있다. 이 normal mapping을 작동시킨 예제는 이 튜토리얼이ㅡ 샂ㄱ에서 보여진 것과 정확히 같은 결과를 준다:


그러나 이 normal maps의 사용을 제한하는 한 가지 문제가 있다. 우리가 사용한 그 노멀맵은 모두 대강 양의 z방향을 가리키는 normal vectors를 가졌다는 것이다. 이것은 평면의 표면의 normal이 또한 양의 z방향을 가리키고 있기 때문에 작동했다. 그러나, 만약 우리가 양의 y 방향을 가리키는 땅에 누워있는 평면의 같은 normal map을 사용하면 어떨까?

그 조명은 좋아보이지 않는다! 이것은 왜냐하면 이 평면의 sampled normals가 여전히 대강 양의 z 방향을 가리키고 있기 때문이다. 비록 그것들이 어느정도 surface normal의 y 방향으로 가리켜야만 할 지라도. 결과적으로 lighting은 surface의 normals들이 그 표면이 여전히 양의 z방향을 바라볼 때 이전과 같다고 생각한다; 그 lighting은 부정확하다는 것이다. 아래의 이미지는 그 sample normals가 대충 이 표면에서 어떻게 보이는지를 보여준다:

너는 모든 normals들이 대충 양의 z방향을 가리키고 있는 것을 볼 수 있다. 반면에 그것들은 표면의 법선을 따라 양의 y방향을 가리켜야 한다. 이 문제에 대해 가능한 해결책은 한 표면의 각 가능한 방향에 대한 normal map을 정의하는 것이다. cube의 경우에 우리는 6개의 normal maps이 필요할 것이다. 그러나 수백개 이상의 가능한 표면을 가질 수 있는 advanced models의 경우 이것은 그럴듯 하지 않은 접근법이다.

다르고 조금 더 어려운 해결책은 다른 좌표 공간에서 lighting을 해서 작동한다: normal map vectors가 항상 대강 양의 z 방향을 가리키는 좌표공간이다; 모든 다른 lighting vectors들은 그러고나서 이 양의 z 방향을 기준으로 변환된다. 이 방식으로 우리는 항상 같은 normal map을 사용할 수 있다. 방향과 상관없이. 이 좌표 공간은 tangent space이다.

Tangent space
normal map에서 normal vectors는 tangent space에서 표현되는데, 그 공간에서 normals들을 항상 대강 양의 z 방향을 가리킨다. Tangent space는 삼각형의 표면에 local인 공간이다: 그 normals들은 개별 삼각형들의 local reference frame에 상대적이다. 그것을 normal map의 벡터들의 local space로 생각해라; 그것들은 모두 최종 변환된 방향과 상관없이 양의 z 방향을 가리키고 있다. 특정한 행렬을 사용하여, 우리는 normal vectors를 이 local tangent space에서 world or view coordinates로 변환할 수 있다. 이것은 그것들이 최종 매핑된 surface의 방향을 따라 향하도록 한다.

가령 우리가 이전 섹션의 양의 y방향을 바라보는 잘못된 normal을 가지고있다고 해보자. 그 normal map은 tangent space에서 정의된다. 그 normal map은 tangent space에서 정의된다. 그래서 그 문제를 해결하는 한 가지 방법은 normals을 tangent space에서 다른 공간으로 변환하는 행렬을 계산하는 것이다. 이것은 그것들이 surface의 normal direction에 정렬되도록 하기 위해서이다: normal vectors들을 그러고나서 모두 양의 y 방향을 가리키게 된다. tangent space에 대해 훌륭한 것은 우리가 적절히 tangent space의 z방향을 표면의 normal direction에 정렬되도록 우리가 어떤 타입의 표면에 대해 그러한 행렬을 계산할 수 있다는 것이다.

그러한 행렬을 TBN matrix라고 불려지는데, 거기에서 그 문자들은 tangent, bitangent and normal vector를 말한다. 이러한 것들은 우리가 이 행렬을 구성하는데 필요한 벡터이다. tangent-space vector를 다른 좌표 공간으로 변환하는 그러한 change-of-basis matrix(기저변환 행렬)을 구성하기위해서, 우리는 normal map의 표면을 따라 정렬하는 세 개의 직교하는 벡터가 필요하다: up, right, forward vector; 우리가 camera tutorial에서 했던 것과 유사하게.

우리는 이미 표면의 normal vector인 up vector를 안다. 그 right and forward vector는 각각 tangent와 bitangent vector이다. 한 표면의 다음의 이미지는 한 표면에서 모든 세 개의 벡터를 보여준다:

tangent and bitangent vectors를 계산하는 것은 normal vector 만큼 간단하지 않다. 우리는 이미지에서 normal map의 tangent and bitangent vector의 방향이 우리가 표면의 텍스쳐 좌표를 정의한 방향을 따라 정렬한다는 것을 볼 수 있다. 우리는 각 표면에 대해 tangent and bitangent vectors를 계산하기 위해 이 사실을 사용할 것이다. 그것들을 얻는 것은 꽤 수학을 요구한다; 다음의 이미지를 봐보자:

이미지로부터 우리는 한 삼각형의 edge E_2의 텍스쳐 좌표의 차이가 deltaU_2 와 deltaV_2가 tangent vector T와 bitangent vector B로서 같은 방향에서 표현되듯이 표기된다. 이 때문에, 우리는 그 삼각형의 두 개의 보이는 edges E_1과 E_2를 tangent vector T와 bitangent vector B의 선형 결합으로 쓸 수 있다: (+ 같은 평면상에 있는 R^2공간에서 어떤 방향은 기저의 선형결합으로 표현될 수 있다.)



우리는 또한 이것으로 쓸 수 있다:

우리는 E를 두 벡터 위치 사이의 차이로 계산할 수 있고, deltaU와 deltaV를 텍스쳐 좌표의 차이로 계싼할 수 있다. 우리는 그러면 두 개의 미지수 (tangent T and bitangent B)와 두 개의 방정식이 남겨져있다. 너는 너의 대수 수업에서 이것이 우리가 T와 B에 대해서 풀 수 있게 한다는 것을 기억할지도 모른다:

마지막 방정식은 우리가 다른 형태로 그것을 쓰게 해준다: 행렬 곱의 형태:



너의 머리에서 행렬 곱을 시각화하려고 하고, 이것이 정말 같은 방정식인 것을 확인해라. 방정식을 행렬 형태로 다시 쓰는 것의 이점은 T와 B에 대해 해를 구하는 것이 좀 더 명백해진다는 것이다. 만약 우리가 그 방정식의 양변에 deltaUdeltaV 행렬의 역행렬을 곱한다면, 다음을 얻는다:



이것은 T와 B에 대해 해를 구하게 해준다. 이것은 우리가 delta texture coordinate matrix의 역행렬을 계산하도록 요구한다. 나는 행렬의 역행렬을 계산하는 수학적 세부사항까지 들어가고 싶지 않지만, 그것은 대충 그것의 adjugate matrix에 의해 곱해지는 행렬의 1 / 행렬식으로 바뀐다:



최종 방정식은 우리에게 한 삼각형의 두 개의 edges와 그것의 텍스쳐 좌표로 부터 tangent vector T와 bitangent vector B를 계산하는 공식을 준다.

만약 너가 이 뒤에 있는 수학을 정말 이해하지 못한다면 걱정하지 말아라. 너가 우리가 한 삼각형의 정점과 그것의 텍스쳐 정점으로부터 tangents와 bitangents를 계산할 수 있는 것을 이해하는 한 (텍스쳐 좌표가 tangent vectors로서 같은 공간에 있기 때문에), 너는 반 정도 온 것이다.

Manual calculation of tangents and bitangents
튜토리얼의 데모 scene에서 우리는 양의 z방향을 바라보는 간단한 2D plane을 가졌었다. 이번에 우리는 tangent space를 사용한 normal mapping을 구현하고 싶다. 그래서 우리는 이 평면을 우리가 원하는대로 바라보도록 할 수 있다. 그리고 normal mapping은 여전히 작동할 것이다. 이전에 이야기한 수학을 사용하여, 우리는 수동으로 이 surface의 tangent and bitangent vectors를 계산할 것이다.

평면이 다음의 벡터들로부터 구성된다고 가정하자 (두 개의 삼각형들로서 1, 2, 3 and 1, 3, 4로):


// positions 
glm::vec3 pos1(-1.f, 1.f, 0.f);
glm::vec3 pos2(-1.f, -1.f, 0.f);
glm::vec3 pos3(1.f, -1.f, 0.f);
glm::vec3 pos4(1.f, 1.f, 0.f);
// texture coordinates
glm::vec2 uv1(0.f, 1.f);
glm::vec2 uv2(0.f, 0.f);
glm::vec2 uv3(1.f, 0.f);
glm::vec2 uv4(1.f, 1.f);
// normal vector
glm::vec3 nm(0.f, 0.f, 1.f);

우리는 첫 번째 삼각형의 edges와 delta UV coordinates를 계산한다:


glm::vec3 edge1 = pos2 - pos1;
glm::vec3 edge2 = pos3 - pos1;
glm::vec2 deltaUV1 = uv2 - uv1;
glm::vec2 deltaUV2 = uv3 - uv1;

tangents와 bitangents를 계산하기 위해 요구되는 데이터로, 우리는 이전 섹션의 방정식을 따라가기 시작할 수 있다:


float f = 1.f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);

glm::vec3 tangent1;
tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
tangent1 = glm::normalize(tangent1);

glm::vec3 bitangent1;
bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
bitangent1 = glm::normalize(bitangent1);

여기에서 우리는 처음에 방정식의 소수 부분을 (행렬식) f로서 미리 계산한고 그러고나서 각 vector component에 대해 우리는 f로 곱해지는 대응되는 행렬 곱을 한다. 만약 너가 이 코드와 최종 방정식을 비교한다면, 너는 그것이 직접적인 번역이라는 것을 알 수 있다. 끝에서, 우리는 또한 tangent/bitangent vectors가 단위 벡터가 되도록 표준화를 해야한다.

한 삼각형이 항상 flat shape이기 때문에, 우리는 오직 삼각형마다 single tangent/bitangent 쌍을 계산할 필요가 있다. 왜냐하면 그것들은 그 삼각형의 정점들 각각에 대해 같기 때문이다. 대부분의 구현들이 (예를들어, model loaders and terrain generators) 일반적으로 다른 삼각형들과 함께 정점들을 공유하는 삼각형을 가지는 것이 주목되어야 한다. 그러한 경우에 개발자들은 각 정점이 좀 더 부드러운 결과를 얻기 위해 보통 normals와 tangents/bitangents와 같은 vertex properties를 평균화해야한다. 우리의 평면의 삼각형은 또한 몇 가지 정점들을 공유하지만, 두 삼각형은 서로에게 평행하기에, 결과들을 평균화 할 필요는 없다. 하지만 너가 이러한 상황에 만났을 때 염두에 두는 것을 좋다.

최종 tangent and bitangent vector는 normal (0, 0, 1)과 함께 orthogonal TBN matrix를 형성하는 (1, 0, 0)과 (0, 1, 0)의 값을 개별적으로 가져야 한다. 평면에서 시각화된다면, TBN vectors는 이것처럼 보인다:

tangent와 bitangent vectors가 정점마다 정의되어, 우리는 적절한 normal mapping을 구현하기 시작할 수 있다.

Tangent space normal mapping
normal mapping을 작동시키기 위해서, 우리는 처음에 쉐이더에서 TBN matrix 를 만들어야만 한다. 그렇게 하기 위해서, 우리는 이전의 계산된 tangent와 bitangent vectors를 vertex shader에 vertex attributes로서 넘겨야만 한다:


#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in vec3 aTangent;
layout (location = 4) in vec3 aBitangent;

그러고나서 vertex shader의 main 함수에서 우리는 TBN matrix를 만든다:


void main()
{
 vec3 T = normalize(vec3(model * vec4(aTangent, 0.0)));
 vec3 B = normalize(vec3(model * vec4(aBitangent, 0.0)));
 vec3 N = normalize(vec3(model * vec4(aNormal, 0.0)));
 mat3 TBN = mat3(T, B, N);
}

여기에서 우리는 처음에 모든 TBN 벡터들을 우리가 작업하고자 하는 좌표 시스템으로 변환한다. 이 경우에 그것은 world-space이다. 우리가 model matrix로 곱하기 때문이다. 그러고나서 우리는 직접적으로 mat3의 생성자에 관련된 벡터들을 넣어 실제 TBN 행렬을 만든다. 만약 우리가 정말 정확히 하고 싶다면, 우리는 TBN 벡터들을 model matrix로 곱하지 않을 것이다. 하지만 normal matrix와 함께 우리는 오직 벡터의 방향만을 신경쓰고 translation(평행이동) and/or scaling transformations에는 신경쓰지 않는다.

  Green Box
  기술적으로 vertex shader에서 bitangent 변수가 있을 필요가 없다. 모든 세 개의 TBN 벡터들은 서로 수직이여서, 우리는 bitangent를 vertex shader에서 간단히 T와 N 벡터의 외적을 취하여 계산할 수 있다: vec3 B = cross(N,T);

그래서 우리가 TBN 행렬을 가졌으니, 우리는 그것을 어떻게 사용할 것인가? 기본적으로 normal mapping을 위해 우리가 TBN matrix를 사용할 수 있는 두 가지 방식이 있고, 우리는 그것들 둘 다를 보여줄 것이다:

  1. 우리는 어떤 벡터를 tangent에서 worldspace로 바꾸는 TBN 행렬을 받아들이고, 그것을 fragment shader로 보내고, TBN 행렬을 사용하여 샘플링된 normal을 tangent space에서 WORLD SPACE로 변환한다; 그 normal는 그러고나서 다른 lighting 변수들로서 같은 공간에 있다.
  2. 우리는 어떤 벡터를 world space에서 tangent space로 바꾸는 TBN은 역행렬을 취한고 normal이 아닌 관련된 lighting 변수들을 tangent space로 바꾸기 위해 사용한다; 그 normal은 그러고나서 다시 다른 lighting variables로서 같은 공간에 있게 된다.
(



여기에서 TBN이 왜 tangent space -> world space로 바꾸는지 생각해보면, worldspace는 basis가 {vec3(1,0,0), vec3(0,1,0), vec3(0,0,1))로 이루어진 R^3이다. 그래서 TBN의 구성을 생각해보면, P(W<-T) == TBN이 될텐데, 그것의 entries들이 이미 R^3의 표준기저로 표현되기 때문에, W<-T로 가는 기저변환 행렬이 된다. 따라서 normal에 TBN을 곱하는 것은 tangent space에서 world space로 바꾸는 것이다. 여기에서 구현의 미세사항이 있는데, 이 TBN이 orthonormal basis가 아니라는 점이 있다. 그 이유는 첫 째로 정점을 공유하는 삼각형의 tangents와 normal을 average시키기도 한다느 점이고, 두 번째로 , rasterizer로 tangents와 normal이 보간되기 때문이다. 그럼에도 불구하고 어느정도의 smoothness를 주어서 사용한다는 것이다. + 기저변환 행렬의 역행렬은 두 space를 바꾸게 한다.
)

첫 번째 케이스를 봐보자. 우리가 normal map으로부터 샘플링하는 그 normal vector는 tangent space에서 표현된다. 반면에 다른 lighting vectors는 (light and view pos)는 world space에서 표현된다. TBN 행렬을 fragment shader로 넘겨서, 우리는 그 샘플링된 tangent space normal에 이 TBN 행렬을 곱해서 normal vector를 다른 lighting vectors와 같은 reference spaec로 변환시킨다. 이 방식으로 모든 라이팅 계산이 (특히 내적) 맞게 된다.

TBN행렬을 fragment shader에 보내는 것은 쉽다:

fragment shader에서 우리는 간단히 mat3를 input 변수로 받는다:

TBN 행렬로, 우리는 이제 tangent-to-world space 변환을 포함하기 위해 normal mapping code를 업데이트할 수 있다:



vec3 normal = vec3(texture(texture1_normal, fs_in.TexCoords));
normal = normalize(normal * 2.0 - 1.0);
normal = normalize(fs_in.TBN * normal);

최종 normal은 이제 world space에 있기 때문에, 다른 fragment shader code를 어떠한 것도 바꿀 필요가 없다. 왜냐하면 lighting code는 normal vector가 world space에 있는 것을 가정하기 때문이다.

또한 두 번째 경우를 봐보자, 거기에서 우리는 모든 관련된 world-space vectors를 sampled된 normal vectors가 있는 공간으로 변환하기 위해 TBN 행렬의 역을 취한다: tangent space. TBN의 행렬의 구성은 같지만 우리는 처음에 그것을 fragment shader에 보내기전에 그 행렬의 역행렬을 구한다:

  vs_out. TBN = transpose(mat3(T,B,N));

우리가 inverse 함수 대신에 여기서 transpose 함수를 사용한다는 것에 주목해라. orthogonal 행렬의 훌륭한 특징 (각 축이 수직하는 단위 벡터이다)은 orthogonal matrix의 전치행렬이 그것의 역행렬과 같다는 것이다. 이것은 역행렬 구하는 것이 꽤 비싸고 전치행렬은 그렇지 않기 때문에 훌륭한 특징이다; 결과는 같다는 것이다.

fragment shader 내에서, 우리는 그러고나서 그 normal vector를 변환하지 않고, 우리는 tangent space와 관련된 벡터들을 변환시킨다. 이름이 lightDir과 viewDir 벡터들. 그러한 방식으로 각 벡터는 또 다시 같은 좌표 시스템인 tangent space에 있게 된다:

두 번째 접근법은 좀 더 많은 일을 있고, fragment shader에서 더 많은 행렬 곱을 요구한다 (조금 비싼) 그래서 왜 우리는 두 번째 접근법과 씨름중인가?

음, 벡터들을 world to tangent space로 변환하는 것은 우리가 모든 관련된 벡터들을 fragment shader내에서 말고 vertex shader에서 tangent space로 바꿀 수 있다는 점에서 부가 이익을 가지고 있다. 이것은 효과가 있는데 왜냐하면 lightPos와 viewPos는 각 fragment run을 바꾸지 않고, fs_in.FragPos에 대해 우리는 또한 그것의 tangent-space position을 vertex shader에서 계산할 수 있고, fragment interpolation이 그것의 일을 하도록 할 수 있기 때문이다.; 기본적으로 어떤 벡터를 fragment shader에서 tangent space로 바꿀 필요는 없다. 반면에 첫 번째 접근법으로는 샘플링된 노멀 벡터가 각 fragment shader의 작동에 특정하기 때문에 필요하다.

그래서 fragment shader에 TBN 행렬의 역행렬을 보내는 대신에, 우리는 tagent-space의 light position, view position and vertex position을 fragment shader에 보낸다. 이것은 우리에게 fragment shader에서의 행렬 곱을 안하게 해준다. 이것은 vertex shader가 fragment shader보다 상당히 종종 덜 작동하기 떄문에 좋은 최적화이다. 이것은 또한 이 접근법이 종종 선호되는 이유이다.


#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in vec3 aTangent;
layout (location = 4) in vec3 aBitangent;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

out VS_OUT
{
 vec3 FragPos;
 vec2 TexCoords;
 vec3 TangentLightPos;
 vec3 TangentViewPos;
 vec3 TangentFragPos;
} vs_out;

uniform vec3 lightPos;
uniform vec3 viewPos;

void main()
{
 gl_Position = projection * view * model * vec4(aPos, 1.0);
 vs_out.FragPos = vec3(model * vec4(aPos, 1.0));
 vs_out.TexCoords = aTexCoords;

 vec3 T = normalize(vec3(model * vec4(aTangent, 0.0)));
 vec3 B = normalize(vec3(model * vec4(aBitangent, 0.0)));
 vec3 N = normalize(vec3(model * vec4(aNormal, 0.0)));
 mat3 TBN = transpose(mat3(T, B, N));
 vs_out.TangentLightPos = TBN * lightPos;
 vs_out.TangentViewPos = TBN * viewPos;
 vs_out.TangentFragPos = TBN * vec3(model * vec4(aPos, 1.0));
}
(확실히 첫 번째 접근법은 3000 후반대의 프레임이 나왔는데 두 번째 접근법으로 행렬 곱의 연산을 줄이니 4000 중반대의 프레임이 나온다. 확실히 최적화가 잘된다.)

fragment shader에서 우리는 그러고나서 이러한 새로운 입력 변수들을 tangent space에서 조명을 계산하기 위해 사용한다. normal vector가 이미 tangent space에 있기 때문에, lighting은 이제 잘 작동한다.

tangent space에서 normal mapping이 적용되어, 우리는 우리가 이 튜토리얼의 시작에서 했던 것과 유사한 결과를 얻을 것이지만, 이번에 우리는 우리의 평면을 우리가 좋아하는 방향으로 향하도록 할 수 있고, 조명을 여전히 정확할 것이다:

그리고 이것은 적절한 normal mapping처럼 보인다:


너는 여기에서 소스코드를 볼 수 있다:

Complex objects
우리는 수동으로 tangent and bitangent vectors를 계산하여 tangent space transformations와 함께 어떻게 normal mapping을 사용할 수 있는지를 보였다. 우리에게 운 좋게도, 수동으로 이러한 tangent and bitangent vectors를 계산해야만 하는 것은 너가 종종 하는 것이 아니다; 대부분 너는 그것을 한 번 custom model loader에 구현한다. 또는 우리의 경우에 Assimp를 사용하는 model loader를 사용한다.

Assimp는 매우 유용한 설정 bit 을 가지고 있는데, 우리는 ai_Process_CalcTangentSpace라고 불려지는 모델을 불러올 때 쓸 수 있다. aiProcess_CalcTangentSpace 비트가 Assimp의 ReadFile 함수에 넣어질 때, Assimp는 smooth tangent와 bitangent vectors를 각각의 불러오진 vertices에 대해 계산한다. 이것은 이 튜토리얼에서 했던것과 비슷한 것이다.

  const aiScene* scene = import.ReadFile(m_directory, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_GenNormals | aiProcess_CalcTangentSpace);

Assimp 내에서, 우리는 계산된 tangents를 다음을 통해 얻을 수 있다:


vector.x = mesh->mTangents[i].x;
vector.y = mesh->mTangents[i].y;
vector.z = mesh->mTangents[i].z;
vertex.Tangent = vector;  

그러고나서, 너는 또한 텍스쳐된 모델로부터 normal maps을 불러오기 위해 model loader를 업데이트 해야만 할 것이다. wavefront object format (.obj)는 normal maps를 다소 다르게 exports한다. Assimp의 aiTextureType_NORMAL는 그것의 normal maps를 불러오지 않고 aiTextureType_HEIGHT가 그것을 한다. 그래서 나는 그것들을 종종 이렇게 불러온다:

  vector<Texture> normalMaps = loadMaterialTextures(material, aiTextureType_Height, "texture_normal");

물로 ㄴ이것은 불러와진 모델과 파일 포맷의 유형에 따라 다르다. 또한 aiProcess_CalcTangentSpace가 항상 작동하지 않을 것이라는 것을 아는 것이 중요하다. tangents를 계산하는 것은 텍스쳐좌표에 기반으로하고, 몇몇 모델 아티스트들은 텍스쳐 좌표의 절반을 미러링 시켜서 한 모델에 대해 텍스쳐 표면을 반사시크는 어떤 텍스쳐 트릭들을 한다; 이것은 반사가 고려되지 않는다면 (Assimp가 하지 않는) 부정확한 결과를 준다; 그 nanosuit model은 예를들어 그것이 반사된 텍스쳐 좌표를 갖기 때문에, 적절한 tangents를 만들어내지 안흔ㄴ다.

업데이트된 모델 로더를 사용하여 적절히 specular and normal maps로 텍스쳐 매핑된 한 모델에서 프로그램을 돌리는 것은 이것과 같은 결과를 준다:

너가 볼 수 있듯이, normal mapping은 많은 추가 비용 없이 엄청난 양으로 오브젝트의 디테일을 증가시킨다.

normal maps를 사용하는 것은 너의 scene의 performance를 증가시키는 훌륭한 방법이다. normal mapping전에 너는 한 메쉬에서 높은 세부사항을 보여주기 위해 많은 수의 정점을 사용해야만 했었지만, normal mapping으로 우리는 더 적은 정점들을 사용하여 한 메쉬에서 같은 수준의 세부사항을 보여줄 수 있다. Paolo Cignoni의 아래의 이미지는 두 방식의 좋은 비교를 보여준다:

두 개의 많은 high-veretx mesh와 normal mapping이 있는 low-vertex mesh에서의 세부사항은 거의 구분할 수 없다. 그래서 normal mapping은, 좋게 보일 뿐만 아니라, 디테일을 손상시키는 것 없이 high-vertex polygons을 low-vertex polygons으로 대체하는 훌륭한 도구이다.

One last thing
너무 많은 추가 비용없이 퀄리티를 다소 개선시키는 normal mapping과 관련하여 내가 말하고 싶은 한 가지 마지막 트릭이 있다.

tangent vectors가 상당한 양의 정점들을 공유하는 더욱 큰 메쉬들에서 계산될 때, 그 tangent vectors들은 일반적으로 nice and smooth results를 주기위해 평균화 된다. normal mapping이 이러한 표면들에 적용될 때. 이 접근법의 문제점은 그 세 개의 TBN 벡터들이 결국에는 서로서로 수직하지 않게 될 수 있다는 것이다. 이것은 최종 TBN 행렬이 더이상 orthogonal하지 않다는 것을 의미한다. Normal mapping은 조금 non-orthogonal TBN matrix 이지만, 그것은 여전히 우리가 개선시킬 수 있는 어떤 것이다.

Gram-Schmidt process라고 불리는 수학적 트릭을 사용하여 우리는 각 벡터가 다른 벡터들에 대해 다시 수직하도록 TBN 벡터들을 re-orthogonalize할 수 있다. vertex shader 내에서 우리는 그것을 이렇게 한다:


vec3 T = normalize(vec3(model * vec4(aTangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(aNormal, 0.0)));
T = normalize(T - dot(T, N) * N);
vec3 B = cross(N, T);
mat3 TBN = transpose(mat3(T, B, N));

이것은 조금일지라도, 조금의 추가 비용으로 normal mapping 결과를 개선시킨다. 이 프로세스가 실제로 어떻게 작용하는지에 대해 이 튜토리얼의 아래에서 참조되는 Normal Mapping Mathematics video의 끝을 봐보아라.

(

https://en.wikipedia.org/wiki/Vector_projection
Gram-Schmidt 방법에 벡터 사영까지 있다. 그래서 위키피디아에서 벡터사영을 이해했으며,
좀 더 올바른 orthogonal한 벡터들을 만들기 위해 Gram-Schmidt방법을 사용하자.
)

==============================================
Normal mapping은 반드시 성능을 위해서 넣어야 할 것이다. Polygon의 개수를 줄임에도 디테일을 그만큼 살릴 수 있기 때문이다.

나중의 게임을 위해 고민할 것은 성능을 위해 lighting calculation시에 Tangent space에서 할 수 있도록 넘기는 것이고, model loading시에 적절한 normal map과 texture uv를 체크하는 것이다.



댓글 없음:

댓글 쓰기