컴퓨터 그래픽스에서 텍스처 매핑을 처음 사용한 목적은 물체의 색을 정하는 것이었습니다. 그러나 정점 단위보다 세밀하게 표면의 어떤 성질을 지정하고 싶을 때라면 텍스처 매핑을 사용할 수 있습니다. 일반인이 이해하는 의미의 ‘질감(texture)’을 가장 직접적으로 제어하는 표면 성질은 사실 표면 법선일지도 모릅니다.
범프 매핑(bump mapping)은 텍셀마다 표면 법선을 제어하는 적어도 두 가지 서로 다른 방법을 가리킬 수 있는 포괄적인 용어입니다. 높이 맵(height map)은 밝기가 표면의 국소적인 ‘고도’를 나타내는 회색조 맵입니다. 밝은 곳은 표면이 밖으로 튀어나온 부분이고, 어두운 곳은 안으로 들어간 부분입니다. 높이 맵은 만들기 매우 쉽다는 장점이 있습니다. 그러나 법선이 직접 주어지지 않고 밝기의 기울기로부터 계산해야 하므로 실시간 용도로는 이상적이지 않습니다. 여기서는 당시 매우 널리 쓰였으며 사람들이 ‘범프 맵’이라고 할 때 대개 의미하는 노멀 매핑(normal mapping)에 초점을 맞춥니다.
노멀 맵에는 표면 법선의 좌표가 직접 인코딩됩니다. 가장 기본적인 방법은 를 각각 빨강, 초록, 파랑 채널에 넣는 것입니다. 일부 하드웨어는 더 최적화된 형식을 지원하기도 합니다. 보통 값을 스케일링하고 바이어스를 더한 뒤 양자화하여 좌표 −1은 0으로, +1은 최대 색 값, 대개 255로 인코딩합니다.
원리상 노멀 맵을 쓰는 방법은 간단합니다. 조명 계산에서 정점 법선을 보간한 결과를 사용하는 대신, 노멀 맵에서 법선 하나를 읽어 그 값을 쓰면 됩니다. 짠! 아, 그렇게 쉽기만 하다면 얼마나 좋을까요…….
문제가 복잡해지는 주된 이유는 두 가지입니다. 첫째, 노멀 맵은 직관적으로 편집하기 어렵습니다. 높이 맵이나 실제 변위 맵은 Photoshop에서 쉽게 그릴 수 있지만, 노멀 맵은 그렇게 쉽게 시각화하고 편집할 수 없습니다. 노멀 맵의 잘라내기와 붙여넣기는 대개 괜찮지만, 유효한 노멀 맵이 되려면 각 픽셀이 정규화된 벡터를 나타내야 합니다.
보통 아티스트가 저해상도와 고해상도 메시를 실제로 각각 모델링하여 노멀 맵을 만듭니다. 저해상도 메시는 실행 시 실제로 사용하는 것이고, 고해상도 메시는 범프 맵을 만드는 데만 씁니다(원주 10-20). 자동화 도구가 고해상도 메시에 광선을 추적하여 노멀 맵의 각 텍셀에 들어갈 표면 법선을 구합니다.
더 까다로운 문제는 텍스처 메모리가 귀한 자원이라는 것입니다(원주 10-21). 일부 단순한 경우에는 노멀 맵의 각 텍셀이 메시 표면에 많아야 한 번만 사용됩니다. 이런 경우 법선을 물체 공간으로 인코딩하면 앞서 설명한 방법이 잘 동작합니다. 그러나 현실의 물체에는 대칭과 자기 유사성이 많고 같은 무늬가 반복되는 경우도 흔합니다.
예를 들어 상자 여러 면에 비슷한 돌출부와 홈이 있을 수 있습니다. 따라서 같은 메모리와 아티스트 작업 시간을 사용한다면, 맵의 해상도를 높이고 같은 노멀 맵 전체 또는 일부를 여러 모델이나 한 모델의 여러 위치에 재사용하는 편이 당시에는 더 효율적이었습니다. 물론 이 원칙은 노멀 맵뿐 아니라 모든 종류의 텍스처 맵에 적용됩니다.
하지만 노멀 맵은 벡터를 인코딩하므로 마음대로 회전하거나 거울 대칭시킬 수 없다는 차이가 있습니다. 정육면체 여섯 면에 같은 노멀 맵을 쓴다고 상상해 보겠습니다. 정육면체 표면의 한 점을 셰이딩할 때 맵에서 텍셀 하나를 가져와 3차원 벡터로 디코딩합니다. 윗면의 특정 텍셀은 아랫면의 같은 텍셀과 같은 방향을 가리키는 표면 법선을 만들 것입니다.
두 방향은 서로 반대여야 하는데 말입니다! 따라서 텍스처에서 가져온 법선을 어떻게 해석할지 알려 주는 다른 정보가 필요합니다. 그 추가 정보가 기저 벡터(basis vector)에 저장됩니다.
원주 10-20: 그리고 제품 상자 그림에 쓸 고해상도 렌더링에도 사용합니다. 고해상도 모델로 실제 게임 화면인 것처럼 오해하게 만드는 스크린샷을 만드는 사람들도 있습니다. 패스트푸드점에서 받은 햄버거가 메뉴 사진 속 햄버거와 다르게 생긴 것과 비슷합니다.
원주 10-21: 모두가 id Tech 5의 MegaTexturing을 가지고 있는 것은 아니기 때문입니다.
10.9.1 접선 공간
당시 가장 흔한 기법은 맵에 저장하는 법선을 접선 공간(tangent space) 좌표로 표현하는 것이었습니다. 접선 공간에서 +z는 표면 바깥을 가리킵니다. +z 기저 벡터는 바로 표면 법선 입니다. x 기저 벡터는 접선 벡터(tangent vector)라고 하며 로 나타내겠습니다.
이 벡터는 텍스처 공간에서 u가 증가하는 방향을 가리킵니다. 다시 말해 3차원에서 접선 벡터 방향으로 움직이는 것은 2차원 노멀 맵에서 오른쪽으로 움직이는 것에 대응합니다. 범프 맵은 다른 맵들과 같은 UV 좌표를 공유하는 경우가 많지만, 서로 다르다면 여기서 중요한 것은 범프 매핑에 사용하는 좌표입니다.
마찬가지로 y 기저 벡터는 바이노멀(binormal)이라고 하며(원주 10-22), 여기서는 로 표기합니다. 이는 v가 증가하는 방향에 대응합니다. 다만 그 움직임이 텍스처 공간에서 ‘위’인지 ‘아래’인지는 앞서 설명했듯이 서로 다를 수 있는 (u,v) 공간의 원점 관례에 달려 있습니다.
물론 접선과 바이노멀의 좌표는 표면 법선과 마찬가지로 모델 공간으로 표현됩니다. 변수 위의 모자 기호가 나타내듯이 기저 벡터는 보통 단위벡터로 저장합니다.
예를 들어 노멀 맵의 어떤 텍셀이 RGB 값 [37, 128, 218]을 갖는다고 하겠습니다. 이 값은 단위벡터 [−0.707, 0, 0.707]로 디코딩됩니다. 이것은 국소적인 표면 법선이 정점 법선을 보간하여 얻은 ‘평평한’ 표면 법선에서 약 45° 기울어져 있다는 뜻입니다. 방향은 ‘왼쪽’입니다. 여기서 ‘왼쪽’은 노멀 맵의 이미지 공간에서 의미가 있으며, 실제로는 ‘u가 감소하는 방향’을 뜻합니다.
정리하면 접선, 바이노멀, 법선은 접선 공간이라는 좌표 공간의 축입니다. 텍셀별 법선의 좌표는 이 좌표 공간을 기준으로 해석합니다. 접선 공간 법선으로부터 모델 공간 법선을 얻으려면, 먼저 맵에서 법선을 디코딩한 뒤 다른 벡터와 똑같이 모델 공간으로 변환하면 됩니다. 접선 공간의 표면 법선을 , 모델 공간의 표면 법선을 라고 하겠습니다. 기저 벡터의 선형결합만으로 을 구할 수 있습니다.
이제 우리는 이것이 기저 벡터를 행으로 가지는 행렬에 를 곱하는 것과 같다는 것을 압니다.
다각형 메시는 곡면일 수도 있는 표면의 근사일 뿐이라는 점을 기억하겠습니다. 따라서 조명에 사용하는 표면 법선은 실제 표면 법선을 근사하기 위해 각 면 위에서 연속적으로 변합니다. 마찬가지로 접선과 바이노멀 기저 벡터도 메시 위에서 연속적으로 변합니다. 표면 법선에 수직이어야 하며 근사하고 있는 표면에 접해야 하기 때문입니다.
그러나 평평한 표면에서도 텍스처를 압축하거나 찌그러뜨리거나 비틀면 기저 벡터가 표면에 따라 달라질 수 있습니다. 인쇄본 396쪽의 그림 10.19에 도움이 되는 두 가지 예가 있습니다. 왼쪽은 ‘찌그러뜨린’ 경우입니다. 이때 접선 벡터 는 수평인 다각형 변과 평행하게 오른쪽을 가리킵니다.
반면 바이노멀 는 각 정점에서 수직 방향의 휘어진 다각형 변과 국소적으로 평행합니다. 면 내부의 임의의 점에서 기저 벡터를 구하려면, 표면 법선과 마찬가지로 정점의 기저 벡터를 보간합니다. 오른쪽의 평면 텍스처 매핑과 비교해 보겠습니다. 그 예에서는 모든 정점과 모든 내부 점의 바이노멀이 정확히 아래를 향합니다.
그림 왼쪽의 텍스처 매핑에서는 접선과 바이노멀이 서로 수직이 아니라는 점에 주목하겠습니다. 이런 일이 가능함에도, 텍스처를 상당히 거칠게 변형한 경우에도 기저 벡터들이 정규직교 기저를 이룬다고 가정하거나 그렇게 되도록 조정하는 경우가 흔합니다. 이 가정은 두 가지 최적화를 쉽게 하기 위해 사용합니다.
첫 번째 최적화는 모델 공간 대신 접선 공간에서 조명을 계산하는 것입니다. 모델 공간에서 조명을 계산하면 면 전체에 걸쳐 세 기저 벡터를 보간하고, 픽셀 셰이더에서 접선 공간 법선을 모델 공간으로 변환해야 합니다. 그러나 접선 공간에서 조명을 계산하면 조명에 필요한 벡터 과 를 정점 셰이더에서 한 번만 접선 공간으로 변환한 뒤 래스터화 중 접선 공간에서 보간할 수 있습니다.
여러 상황에서 이 방법이 더 빠릅니다. 정규직교 기저라면 변환 행렬의 역행렬이 바로 전치행렬이므로, 내적만으로 모델 공간에서 접선 공간으로 변환할 수 있습니다. 이해가 잘되지 않는다면 3.3.3절과 6.3절을 살펴보세요. 기저가 정규직교하지 않아도 내적 계산 자체는 가능하지만, 그 결과가 정확한 접선 공간 좌표 변환과 일치한다고 보장할 수는 없습니다.
실제로 기저 벡터를 보간하고 다시 정규화하면 직교성이 조금 흐트러질 가능성이 큽니다. 이 경우 변환은 완전히 정확하지 않지만, 대개 문제를 일으키지 않습니다. 표면 법선과 기저 벡터를 보간한다는 아이디어 자체가 처음부터 근사라는 점을 기억하는 것이 중요합니다.
기저 벡터의 수직성을 가정하여 할 수 있는 두 번째 최적화는 두 기저 벡터 중 하나를 아예 저장하지 않고 필요할 때 계산하는 것입니다. 보통 바이노멀을 생략합니다. 성능 병목이 정점별 계산보다 메모리 데이터 이동에 있을 때 이 방법이 더 빠를 수 있습니다. 다만 한 가지 문제가 있습니다.
거울 대칭된 범프 맵입니다. 대칭적인 물체에는 범프 맵을 포함한 텍스처 맵을 두 번 사용하는 경우가 매우 흔합니다. 한쪽에서는 ‘정상적인’ 방향으로 사용하고 다른 쪽에서는 거울 대칭시킵니다. 따라서 텍스처를 정상적인 방향으로 적용했는지, 거울 대칭시켰는지 알아야 합니다. 이를 위해 거울 대칭 여부를 나타내는 플래그를 저장합니다. +1은 정상 방향, −1은 거울 대칭을 뜻합니다.
보통 이 플래그를 저장해 두는 기저 벡터의 w 성분에 편리하게 끼워 넣습니다. 이제 생략한 기저 벡터를 구하려면 적절한 외적, 예를 들어 를 계산한 다음 플래그를 곱하여 필요하면 방향을 뒤집습니다. 이 플래그는 삼중곱 로 계산합니다. 이것은 식 (10.18)에 나온 변환 행렬의 행렬식과 같습니다.
원주 10-22: 아마 ‘바이탄젠트(bitangent)’라는 용어가 더 정확하겠지만, 덜 흔히 사용됩니다.
10.9.2 접선 공간 기저 벡터 계산
마지막으로 기저 벡터를 어떻게 계산하는지 살펴보겠습니다. 여기서는 Lengyel [42]의 전개를 따릅니다. 정점 위치가 , , 인 삼각형이 주어지고, 각 정점의 UV 좌표가 라고 하겠습니다. 이런 조건에서는 항상 평면 매핑을 구할 수 있습니다. 즉 매핑의 기울기가 삼각형 전체에서 일정합니다.
앞으로의 수식을 단순하게 만들기 위해 다음과 같이 정의하여 원점을 로 옮기겠습니다.
우리가 찾는 기저 벡터는 삼각형 평면 위에 놓입니다. 따라서 삼각형의 변 벡터 를 기저 벡터의 선형결합으로 표현할 수 있습니다. 각 변에서 이미 알고 있는 u와 v의 변화량이 그 선형결합의 좌표입니다.
를 정규화하면 원하는 단위벡터를 얻습니다. 이 식들을 행렬 표기로 더 간결하게 쓸 수 있습니다.
그러면 우아한 풀이가 눈앞에 나타납니다. 양변 왼쪽에 s,t 행렬의 역행렬을 곱하면 다음을 얻습니다.
기저 벡터를 정규화할 예정이므로 앞에 곱해진 상수인 분수를 버릴 수 있습니다. 그러면 다음이 남습니다.
이렇게 하면 각 삼각형의 기저 벡터를 얻습니다. 서로 수직이라는 보장은 없지만, 우리의 주목적인 정점 단위의 기저 벡터를 구하는 데 사용할 수 있습니다. 정점 법선을 계산할 때와 비슷한 요령으로 정점 기저를 구할 수 있습니다. 각 정점에서 인접한 삼각형들의 기저 벡터를 평균냅니다.
보통 정규직교 기저가 되도록 강제하기도 합니다. 가장 간단한 방법은 그람–슈미트 직교화입니다(6.3.3절). 또한 기저 벡터 하나를 생략할 계획이라면 이 단계에서 기저의 행렬식을 저장해야 합니다. 코드 10.9는 정점 기저 벡터를 계산하는 한 가지 방법입니다.
struct Vertex {
Vector3 pos;
float u, v;
Vector3 normal;
Vector3 tangent;
float det; // 접선 변환의 행렬식. 거울 대칭이면 -1.
};
struct Triangle {
int vertexIndex[3];
};
struct TriangleMesh {
int vertexCount;
Vertex *vertexList;
int triangleCount;
Triangle *triangleList;
void computeBasisVectors() {
// 주의: 정점 법선은 유효하다고 가정한다.
Vector3 *tempTangent = new Vector3[vertexCount];
Vector3 *tempBinormal = new Vector3[vertexCount];
// 먼저 누적값을 비운다.
for (int i = 0; i < vertexCount; ++i) {
tempTangent[i].zero();
tempBinormal[i].zero();
}
// 각 면의 기저 벡터를 그 면에 인접한 정점들의 평균에 반영한다.
for (int i = 0; i < triangleCount; ++i) {
// 간단하게 접근할 수 있는 참조를 구한다.
const Triangle &tri = triangleList[i];
const Vertex &v0 = vertexList[tri.vertexIndex[0]];
const Vertex &v1 = vertexList[tri.vertexIndex[1]];
const Vertex &v2 = vertexList[tri.vertexIndex[2]];
// 중간값을 계산한다.
Vector3 q1 = v1.pos - v0.pos;
Vector3 q2 = v2.pos - v0.pos;
float s1 = v1.u - v0.u;
float s2 = v2.u - v0.u;
float t1 = v1.v - v0.v;
float t2 = v2.v - v0.v;
// 이 삼각형의 기저 벡터를 계산한다.
Vector3 tangent = t2*q1 - t1*q2;
tangent.normalize();
Vector3 binormal = -s2*q1 + s1*q2;
binormal.normalize();
// 인접 정점들의 누적합에 더한다.
for (int j = 0; j < 3; ++j) {
tempTangent[tri.vertexIndex[j]] += tangent;
tempBinormal[tri.vertexIndex[j]] += binormal;
}
}
// 이제 정점에 값을 채운다.
for (int i = 0; i < vertexCount; ++i) {
Vertex &v = vertexList[i];
Vector3 t = tempTangent[i];
// 접선이 법선에 수직이 되게 한다(그람–슈미트).
// 그런 다음 정규화한 결과를 보관한다.
t -= v.normal * dot(t, v.normal);
t.normalize();
v.tangent = t;
// 거울 대칭인지 판별한다.
if (dot(cross(v.normal, t), tempBinormal[i]) < 0.0f) {
v.det = -1.0f; // 거울 대칭이다.
} else {
v.det = +1.0f; // 거울 대칭이 아니다.
}
}
// 정리한다.
delete[] tempTangent;
delete[] tempBinormal;
}
};
C++
복사
코드 10.9. 인접한 삼각형 법선의 평균으로 기저 벡터를 계산하는 단순한 방법.
코드 10.9가 다루지 않는 성가신 문제 하나는 매핑에 불연속이 있을 수 있다는 것입니다. 이런 곳에서는 기저 벡터들을 함께 평균내면 안 되며, 공유하는 변의 양쪽에서 기저 벡터가 달라야 합니다. 대부분 UV 좌표나 법선이 서로 맞지 않으므로 이런 변을 따라 면들이 이미 분리되어 있습니다.
즉 정점이 복제되어 있습니다. 그러나 안타깝게도 이렇지 않은 매우 흔한 경우가 하나 있습니다. 대칭 물체에 거울 대칭 텍스처를 사용하는 경우입니다. 예를 들어 캐릭터 모델 등 대칭적인 메시의 중앙에는 흔히 한쪽 텍스처가 다른 쪽으로 거울 대칭되는 선이 있습니다. 이 이음선을 따라 놓인 정점들은 동일한 UV가 필요하면서도 또는 는 반대여야 하는 경우가 매우 많습니다.
이음선을 따라 잘못된 기저 벡터가 생기는 일을 막으려면 이러한 정점들을 분리해야 합니다.
10.11.4절에서는 기저 벡터를 실제로 사용하여 범프 매핑을 수행하는 셰이더 예제를 보여 줍니다. 모든 데이터를 올바른 형식으로 가공해 두면 실행 시 코드는 놀랄 만큼 단순합니다. 이는 당시 실시간 그래픽스에서 흔히 나타나는 경향을 보여 줍니다. 코드의 적어도 75%는 데이터를 최적화하고, 패킹하고, 그 밖의 가공을 통해 정확히 알맞은 형식으로 만드는 도구에 들어갑니다. 나머지 25%인 실행 시 코드가 최대한 빠르게 동작하도록 하기 위해서입니다.
노멀 맵 구현에서 확인할 조건
식 유도에서 1/(s1 t2 − s2 t1)을 정규화할 예정이므로 생략한다는 원문 설명은 분모가 음수일 때의 부호까지 버립니다. 양의 배율만 정규화로 없어지므로, 일반적인 구현에서는 UV 행렬식의 부호를 유지해야 합니다. 원문의 식과 코드 10.9는 학습용 원형을 보존했으며, 실제 적용 시에는 이 부호와 UV가 퇴화하여 행렬식이 0이 되는 경우를 별도로 처리해야 합니다. 법선이나 평균 접선이 영벡터가 되는 경우의 정규화도 보호해야 합니다.
노멀 맵 구현에서 확인할 조건
코드 10.9의 원문 캡션은 ‘인접한 삼각형 법선의 평균’이라고 쓰지만, 코드에서 실제로 평균내는 것은 삼각형의 접선과 바이노멀입니다. 원문 캡션을 보존한 뒤 여기서 구분합니다.
노멀 맵 구현에서 확인할 조건
RGB [37,128,218]을 [−0.707,0,0.707]로 디코딩하는 예시는 양자화 오차를 무시한 근사입니다. 8비트 값을 2c/255−1로 복원하면 정확히 그 수가 되지는 않습니다. 노멀 맵의 y 채널 방향과 UV의 위아래 관례도 사용 도구와 렌더러 사이에 맞추어야 합니다.




