본문 바로가기

프로그래밍 -----------------------/C,C++ 팁

3D 세계를 구성하는 필수 요소들 | [펌]DirectX


3D 세계를 구성하는 필수 요소들
  저 자 : 김익중
  출판일 : 2003년 10월호

   뒤통수에 꽂혀지는 시선을 애써 무시한 채 난 퇴근했다. 최근에는 매일 칼퇴근을 지향하고 있지만 자의 반 타의 반이 맞을 것이다. 집에는 버티가 빨리 돌아오길 바라고 있으니까. 내 사정을 모르는 팀원들은 애인이라도 생겼냐며 놀리지만 곧장 집에서 꼬마 녀석에게 당하는 내 사정을 알리는 없다. 그러고보니 어제 버티 앞으로 무슨 파이프가 배달됐다. 버티는 그 파이프 안으로 자신을 넣어달란다. 3D 파이프라인? 도대체 그게 뭐지?

3D 공간 위에 정점, 모든 것들의 시작점
3D 세계로 진입을 무사히 마쳤다면 본격적으로 폴리곤을 만들어 볼 차례가 왔다. 설명한대로 모든 폴리곤과 메시는 정점으로 이뤄졌다. 해당 정점들이 세 개가 놓여진다면 폴리곤을 만들 수 있으며 더 많은 정점들이 있다면 자동차나 몬스터, 혹은 사람까지 만들 수 있을 것이다. 사실 코드만으로 복잡한 조형물을 만든다는 것은 SetPixel 함수만으로 1600×1200 해상도에서 모나리자 그림을 그리는 것만큼 힘든 일이다. 따라서 별도의 메시 파일을 열지 않고 삼각형부터 시작해보도록 한다. 결코 쉽지 않으니 몇 번의 다독이 필요할 시간이다. 정신 바짝 차리고 정점을 3D 파이프라인으로 보내보자.
3차원의 점이니까 float 타입의 변수 세 개이면 하나의 점을 만들 수 있다. 따라서 다음과 같은 형태의 구조체를 취할 것이다. 삼각형이기에 정점은 3개가 필요하므로 전역 변수로 3개를 선언하였다.

typedef struct MYVERTEX
{
float x;
float y;
float z;
} MYVERTEX, *LPMYVERTEX;
MYVERTEX g_Vertices[3];
g_Vertices를 3개 선언했으니 각각의 위치를 지정하여 삼각형을 만들 수 있다. 다이렉트3D는 왼손 좌표계를 이용하므로 x, y값을 이용해 삼각형을 만들었다. 현재 Z는 전부 0으로 지정되었다.

void *lpVertices;
g_Vertices[0].x = 0.0;
g_Vertices[0].y = 3.0;
g_Vertices[0].z = 0.0f;

g_Vertices[1].x = -3.0;
g_Vertices[1].y = -3.0;
g_Vertices[1].z = 0.0f;

g_Vertices[2].x = 3.0;
g_Vertices[2].y = -3.0;
g_Vertices[2].z = 0.0f;

“내 이름이 왜 버티(Vertex)인지 이제까지 몰랐단 말야?” 버티는 정점 이야기를 하는 도중 또 트집을 잡기 시작했다. 다이렉트3D에서 정점이 중요한 존재이긴 하나보다. “하지만…” 버티는 조금 부끄러운 듯 말을 잇는다. “사실은, 정점 버퍼로 들어가야지만 진짜 정점이 될 수 있어. 날 좀 도와줘!”

3D 파이프라인의 제 1관문, 정점 버퍼로 들어가다
g_Vertices[]는 현재 단순한 구조체 변수일 뿐이다. 실제로 다이렉트3D를 위한 정점이 되기 위해서는 정점 버퍼(vertex buffer)로 보내져야 한다. 정점 버퍼는 정점 정보를 가진 구조체 변수(g_Vertices [3])를 포함하는 메모리 영역일 뿐이지만 폴리곤을 만들기 위한 첫 관문이다. 또한 시스템 메모리가 아닌 보다 빠른 속력을 위해 비디오 메모리에 위치될 수 있다는 사실은 잊지말기를 바란다. 정점 버퍼의 개발자가 선언한 정점 구조체 변수의 크기와 일치한다. 즉 200개의 정점을 가졌고 각 정점이 12바이트를 차지한다면 정점 버퍼 크기 역시 2400바이트를 차지한다.
정점 버퍼는 메모리에 불과하다고 언급했고 개발자의 구조체 변수를 그대로 넣는 곳이라고 말했다. 따라서 이제까지 배워 온 다이렉트드로우, 다이렉트사운드들에서 목격한 데이터 이동과 마찬가지의 과정을 거친다. 첫째, 정점 버퍼를 생성하고 둘째, 정점 버퍼를 잠그고(lock), 셋째 잠긴 상태에서 복사한다. 그리고 넷째 락 해제이다. 우선 정점 버퍼를 생성하자. 이름 그대로 IDirect3DDevice9::Create VertexBuffer 메쏘드로 버퍼를 생성한다.

HRESULT CreateVertexBuffer(
UINT Length,
DWORD Usage,
DWORD FVF,
D3DPOOL Pool,
IDirect3DVertexBuffer9** ppVertexBuffer,
HANDLE* pHandle
);

◆ UINT Length : 생성될 버퍼 사이즈
◆ DWORD Usage : 버퍼 플래그. 보통 0을 지정하거나 D3DUSAGE_ WRITEONLY | D3DUSAGE_DYNAMIC이라고 명시한다. 이 말은 버퍼가 동적 메모리를 사용하며 AGP 메모리에 배치시킨다. 또한 정점 버퍼를 쓰기용으로 만들어 다이렉트3D가 최적의 메모리 영역을 선택할 수 있다.
◆ DWORD FVF : FVF(Flexible Vertex Format, 유연 정점 포맷)을 사용하지 않을 경우에는 0
◆ D3DPOOL Pool : 정점 버퍼가 차지할 메모리 풀. 보통 D3DPOOL_DEFAULT나 D3DPOOL_MANAGED로 최적의 장소에 위치시킨다. 여기에서 장소란 바로 비디오 메모리인지 시스템 메모리인지를 의미한다. 강제적으로 시스템 메모리를 이용하겠다면 D3DPOOL_SYSTEMMEM를 지정한다.
◆ IDirect3DVertexBuffer9** ppVertexBuffer : LPDIRECT3DVERTEXBUFFER9 인터페이스를 획득할 포인터 주소. 먼저 선언한 LPDIRECT3DVERTEXBUFFER9의 변수
◆ HANDLE* pHandle : 아직 사용되지 않는다. NULL로 지정

세 번째 인자인 FVF의 경우는 그 유명한 유연 정점 포맷으로 설명은 실제 설정 시로 미루겠다. 이중 마지막 인자는 다이렉트X 8과 달리 새롭게 추가되었으나 사용되지 않는다. 현재 인터넷상에서 구할 수 있는 대부분의 소스들이 다이렉트X 8에 맞춰진 것이 많다. 다이렉트X 9으로 코드를 바꾸려면 해당 인자들의 변경은 필수적인데 다행히 대부분이 이렇듯 NULL로 지정받고 있다. 성공적으로 생성되었다면 D3D_OK를 리턴하고 정점 버퍼 객체가 생성된다.
정점 버퍼를 만들었다면 락을 걸어 복사시킬 준비를 하자. 다이렉트사운드편을 떠올려도 좋은데 락을 거는 주체는 정점 버퍼 바로 자신이니까 Lock() 메쏘드는 IDirect3DVertexBuffer9에서 찾아야 한다.

HRESULT Lock(
UINT OffsetToLock,
UINT SizeToLock,
VOID **ppbData,
DWORD Flags
);

◆ UINT OffsetToLock : 락 걸 영역의 오프셋(offset) 설정. 바이트 단위를 이용한다.
◆ UINT SizeToLock : 락을 걸 영역의 크기
◆ VOID **ppbData : 락될 정점 정보를 포함하게 될 포인터 주소
◆ DWORD Flags : 락 수행 시 플래그. 보다 자세한 내용은 MSDN을 참조할 것
-D3DLOCK_DISCARD : 버퍼 전체를 겹쳐 쓸 수 있도록 하지만 이전 정점 버퍼를 기억할 필요가 없으므로 새로운 메모리 위치에 할당한다.
-D3DLOCK_NO_DIRTY_UPDATE : 디폴트
-D3DLOCK_NOSYSLOCK : 락 작업이 길어질 경우 이용한다. 락 과정동안 마우스 커서의 변경같은 윈도우 갱신을 보장한다.
-D3DLOCK_READONLY : 정점 버퍼의 내용을 변경하지 않고 읽기만 하겠다고 지정한다. 따라서 가장 빠른 속력을 낼 수 있으나 D3DLOCK_DISCARD와 D3DUSAGE_WRITEONLY 플래그로 설정된 정점 버퍼와 함께 사용할 수는 없다.

락은 다이렉트3D의 속도 최적화에 직접적인 영향을 주는 메쏘드다. 다양한 플래그의 사용으로 현재 화면에 맞는 최적화를 선택해주자. 또한 락 옵셋의 사용법은 복잡하지만 이제까지 전체 정점 버퍼를 락걸지 않는 경우는 보지 못했다. 실제 이용 시 전체 정점 버퍼에 락을 걸게 되므로 다음과 같이 의외로 간단하게 사용된다.

Lock(0, sizeof(g_Vertices), (void**)&lpVertices, D3DLOCK_DISCARD )

락을 걸면 처음 선언한 정점 구조체를 실제 다이렉트3D가 알 수 있는 정점 버퍼로 옮긴다. 옮긴다는 의미는 데이터의 복사를 의미한다. 따라서 무언가 대단한 메쏘드가 등장할 것 같지만 고전함수 memcpy()로 무척 싱겁게(?) 처리된다.

memcpy(lpVertices, g_Vertices, sizeof(g_Vertices));

최첨단 API를 이용하는 과정일지라도 터보C 시절 알게 된 고전함수를 만나면 너무나 반가운 것은 필자만의 생각일까. 정점 버퍼로 옮겼다면 버퍼는 다시 해제시켜야 한다 IDirect3DVertex Buffer9 ::Unlock이 그 역할을 맡으며 인자는 없다.

lpVertexBuffer->Unlock();

Unlock()을 잊더라도 컴파일러는 잡아주지 못한다. 즉 정점 버퍼 내용을 나중에 변경하기에 처음 정점 버퍼가 할당된 메모리가 유효한지를 확인해야 한다.

파이프라인 안으로 들어간 버티는 밖으로 나오지 못하고 있다. 왠지 조금 불안하다. 녀석, 안에서 무슨 일이 생긴 건 아니겠지? 어이, 장난치지 말고 빨리 나와.



<그림 1> 다이렉트X의 왼손 좌표계. 모니터 안쪽으로 Z가 증가한다.





<그림 2> 현재 그림과 같은 위치에 준비되었다.

  폴리곤을 보여줘, 렌더링
이제 정점들을 위치시키고 다이렉트3D가 알 수 있도록 정점 버퍼에 넣었다. 하지만 여전히 화면은 빈 화면뿐…. 다이렉트3D의 처음 진입이 힘든 이유가 이 부분이라 생각하는데 정점을 위치시키는 것과 실제 화면을 그리는 것은 전혀 다른 부분이기 때문이다. 그럼 이제까지 만든 정점을 화면을 그려보자. 화면을 그리는 순서는 다음과 같으며 하나의 장면을 완성한다는 말은 렌더링(rendering)이라 지칭하겠다.

[1] IDirect3DDevice9::Clear()를 호출하여 백 퍼버를 지운다. 플립핑은 수행 중이므로 화면 역시 지워진다.
[2] IDirect3DDevice9::BeginScene()으로 화면을 그리겠다고 다이렉트3D에게 알린다.
[3] IDirect3DDevice9::SetStreamSource()로 원하는 정점 버퍼의 주소를 선택한다.
[4] IDirect3DDevice9::SetFVF()로 유연 정점 포맷을 다이렉트3D에게 알려준다.
[5] 실제 화면을 그리는 IDirect3DDevice9::DrawPrimitive()로 화면을 그린다.
[6] 그리기를 마쳤다면 IDirect3DDevice9::EndScene()을 호출하여 다이렉트3D가 모니터에 2차원 래스터화(rasterization)를 하도록 한다.
[7] [6]의 작업은 프라이머리 서피스가 아닌 백 버퍼(오프스크린 서피스)에서 작동한 작업들이다. 다이렉트3D용 플리핑(flipping) 함수인 IDirect3DDevice9::Present()를 호출한다.

먼저 화면, 아니 버퍼를 지워보자.

HRESULT Clear(
DWORD Count,
const D3DRECT *pRects,
DWORD Flags,
D3DCOLOR Color,
float Z,
DWORD Stencil
);

◆ DWORD Count : 0으로 지정하여 전체를 지운다. 부분을 지우려면 pRect 배열에서 D3DRECT를 선택한다.
◆ const D3DRECT *pRects : D3DRECT 구조체의 배열 포인터로 게임이라면 보통 NULL로 지정한다.
◆ DWORD Flags :
- D3DCLEAR_TARGET : 렌더링 타겟을 지우고 Color 인자 값으로 채운다.
- D3DCLEAR_ZBUFFER : 깊이 버퍼를 지운다.
- D3DCLEAR_STENCIL : 스텐실 버퍼를 지우고 스텐실 인자 값으로 한다.
◆ D3DCOLOR Color : 지운 후 채울 색을 지정한다. RGB()와 같지만 대신 D3DCOLOR_XRGB() 매크로를 이용한다.
◆ float Z : 깊이 버퍼의 새로운 값. 0.0~1.0 사이로 0.0은 관찰자에게 가장 가까운 거리이며 증가할수록 멀어진다.
◆ DWORD Stencil : 스텐실 버퍼 사용 시에 사용 현재는 0으로 설정하자.

BeginScene()는 VOID로 호출만 해주면 된다. 이제 정점 버퍼들에서 그리길 원하는 버퍼를 선택하자. 우리는 현재 하나의 버퍼만을 갖고 있다.

HRESULT SetStreamSource (
UINT StreamNumber,
IDirect3DVertexBuffer9 *pStreamData,
UINT OffsetInBytes,
UINT Stride
);

◆UINT StreamNumber : 스트림의 최대 수까지 설정할 수 있으나 보통 0을 붙인다.
◆IDirect3DVertexBuffer9 *pStreamData : LPDIRECT3DVERTEXBUFFER9의 포인터(정점 버퍼)
◆UINT OffsetInBytes : 정점 버퍼에 시작될 스트림 오프셋 값. 보통 0을 지정한다.
◆UINT Stride : 한 정점의 크기로 앞서 설정된 구조체 크기를 넣어준다.

정점 버퍼를 선택했다면 그리기 전에 FVF을 지정해줘야 한다. 앞서 정점 구조체를 설명할 때 미룬 부분으로 FVF는 다이렉트X 8.0부터 지원되는 기능이다.
우리는 현재 X, Y, Z 위치 정보만을 가지는 꼭지점을 갖고 있다. 하지만 정점의 기능은 위치 저장만이 전부가 아니다. 색상, 조명, 텍스처(texture) 등 현실적인 물체가 되기 위해 정점이 가질 정보는 다양하며 정점 구조체 크기 역시 거기에 맞게 다양해져야 한다. 그러나 모든 사용하지 않는 정보와 데이터까지 다이렉트3D의 파이조瓚恝?싣는다는 것은 낭비이다. 따라서 개발자는 특정 렌더링 작업에 맞도록 정점 데이터 포맷을 정의할 수 있어야 한다. 그것이 바로 FVF이다. FVF의 정의는 보통 define문으로 해당 플래그를 기입한다. 정의된 매크로 문장은 SetFVF에서 그대로 적어주게 된다.

#define D3DFVF_CUSTOMVERTEX (D3DFVF_XYZ)
..........
g_pd3dDevice->SetFVF( D3DFVF_CUSTOMVERTEX );

현재 X, Y, Z의 위치 값만을 지니므로 D3DFVF_XYZ 플래그를 사용하고 있다. 만약 정점에 색상 정보까지 들어가야 한다면 D3DFVF _DIFFUSE 플래그를 추가해야 할 것이며 텍스처를 입힌다면 D3DFVF_TEX0이 추가돼야 한다. FVF의 플래그는 그 수가 매우 많으므로 MSDN을 참조하길 바란다. 참고로 현재 인터넷에는 다이렉트X 8.0에 맞춰진 소스들이 많은 편이며 SetFVF()가 아닌 SetVertexShader()를 사용하고 있다. 다이렉트X 9.0으로 코드를 변환하고 싶다면 이 부분에서 SetFVF()로 바꿔주자.
원하는 정점 버퍼와 FVF까지 선택했다면 이제서야 그릴 수 있다. 정점이 폴리곤이 되기 위해서는 정점 사이에 직선이 존재해야 한다. 닫힌 정점은 면이 될 수 있다. 즉 정점을 보여지기 위한 그림을 만드는 메쏘드가 DrawPrimitive()이다.

HRESULT DrawPrimitive(
D3DPRIMITIVETYPE PrimitiveType,
UINT StartVertex,
UINT PrimitiveCount
);

◆ D3DPRIMITIVETYPE PrimitiveType : <그림 3> 참조
◆ UINT StartVertex : 그리기 시작할 정점의 인덱스, 일반적으로 0으로 한다.
◆ UINT PrimitiveCount : 그려질 면의 개수다. 3개의 정점이면 하나의 면이므로 1이 지정된다.

불안하다. 도대체 무슨 일이지? 파이프 안으로 들어간 버티는 밖으로 나올 출구를 찾지 못한 것 같다. 시끄럽던 녀석이 나타나지 않자 불안해졌다. 난 버티를 밖으로 나오게 할 방법을 찾았다. 그렇다, 뷰포트! 뷰포트를 만들면 다른 세계의 문을 열수 있다고 말했었지!

  세계을 보는 창, 뷰포트
정점과 실제 정점을 이용한 폴리곤(삼각형)을 만들었다. 지난 시간 버티의 설명대로 누군가가 보기 위해서는 볼 수 있는 창이 필요하다. 바로 뷰포트(viewport)이다. 뷰포트는 이제까지 만들어진 3D 세계 중 원하는 영역을 볼 수 있도록 해주며 대게 윈도우 전체를 뷰포트로 잡는다. 뷰포트를 지정하기 위한 함수는 SetViewPort()이지만 우리는 곧장 카메라와 뷰포트를 함께 쓸 것이다. 카메라를 지정한다면 관찰하는 시점을 마음껏 바꿀 수 있기 때문이다.
카메라는 현실 세계의 카메라를 생각하면 일치한다. 삼각형이 있는 3D 세상 속을 오직 카메라를 통해서만 볼 수 있고(윈도우에 그려진다는 의미) 카메라의 위치, 각도를 바꾼다면 당연히 보여지는 것도 달라질 것이다. 카메라를 위해서는 벡터 값이 두 개가 필요하다. 하나는 위치로서 카메라의 위치를 설정한다. 또 하나의 벡터는 방향이다. 카메라가 보는 방향을 벡터로 지정하는 것이다.
새로운 구조체가 두 개 추가되었다. D3DXMATRIX와 D3DX VECTOR3이다. 이것들은 D3DX라는 보조 라이브러리에서 지원해주는 구조체들로 행렬 정보와 벡터 정보를 포함한다. 다이렉트3D 표준 구조체인 D3DMATRIX(행렬)와 D3DVECTOR(벡터)와 같은 일을 하는 구조체이지만 C++ 타입으로 만들어져 연산자 오버로드가 가능하므로 앞으로 많이 접하게 될 것이다. 일단 vecCameraSource와 vecCameraTarget으로 카메라의 위치와 시선을 설정했다. 즉, (0, 0, -10)에 카메라를 설치하고 (0, 0, 0)을 본다는 말이다. 시선은 위치가 아니라 방향이다. 모니터 바깥쪽에서 화면 중앙으로 시선을 돌리고 이 시선을 윈도우에 그리겠다는 뜻이다.
설정대로 카메라를 위치시키고 싶다면 D3DXMatrixLookAtLH()를 사용한다. D3D뒤에 ‘X’라는 말에서 알 수 있듯이 D3DX 보조 함수이며 왼손 좌표계에 의한 행렬을 리턴시켜 준다.

D3DXMATRIX *D3DXMatrixLookAtLH(
D3DXMATRIX *pOut,
CONST D3DXVECTOR3 *pEye,
CONST D3DXVECTOR3 *pAt,
CONST D3DXVECTOR3 *pUp
);

◆D3DXMATRIX *pOut : 카메라가 본 세상은 행렬로 변환되어 저장된다. 카메라가 결과를 가지는 D3DXMATRIX 구조체 포인터
◆CONST D3DXVECTOR3 *pEye : 카메라의 위치
◆CONST D3DXVECTOR3 *pAt : 카메라의 시선
◆CONST D3DXVECTOR3 *pUp : 위쪽을 나타내는 정규 벡터

다이렉트3D는 기본적으로 개발자가 X, Y, Z 좌표 중 어디를 윗(up) 방향으로 결정하는지 모른다. 마지막 인자에 Y 좌표에 1.0f을 주는 이유는 바로 Y 증가 방향이 우리가 만든 세계의 윗 방향이란 사실을 알려주기 위해서다. 카메라을 설치한 후 SetTransform()로 무언가를 두 번이나 변환하는 것을 볼 수 있다. 이 변환은 다음 장에서 3D 파이프라인을 다룰 때 다시 설명할 것이다.
D3DXMatrixPerspectiveFovLH()는 카메라가 볼 세상의 원근감을 설정한다. 현실에서 그러하듯 가까운 물체는 커보이고 거리가 먼 물체는 작아진다. 원근감 역시 개발자가 지정할 몫이기에 D3DX Ma trixPerspectiveFovLH()가 준비된 것이다.

D3DXMATRIX *D3DXMatrixPerspectiveFovLH(
D3DXMATRIX *pOut,
FLOAT fovY,
FLOAT Aspect,
FLOAT zn,
FLOAT zf
);
◆D3DXMATRIX *pOut : 결과 값이 리턴될 D3DXMARIX 포인터
◆FLOAT fovY : 카메라 시야각(field of view) 설정. 시야각이란 가장 좌측에 보이는 범위에서 가장 우측에 보이는 범위의 각도다. 시야각이 커지면 어안 렌즈 효과(많은 각도를 담기 위해 물고기의 시야와 같이 둥글게 보인다)가 나며 작은 시야각은 확대된 것처럼 보인다. 보통 D3DX_PI/4를 이용하면 사람의 눈과 거의 같은 각도를 가지게 된다.
◆FLOAT Aspect : 화면 비율
◆FLOAT zn : Near View. 이 앞에 존재하는 물체는 보이지 않는다.
◆FLOAT zf : Far View. 이 뒤에 있는 물체은 보이지 않는다.
결국 카메라 시선이 만드는 것은 ‘관찰절두체’란 힘든 이름을 가진 또 하나의 육면체다. 이 안의 세계만이 변환되어 보여지게 된다. 이로써 모델과 뷰포트가 마련되었다. 마지막으로 아무리 멋진 장면이라도 조명이 없으면 보여지지 않는 법, 최소한의 조명은 존재해야 한다. 그러나 지면 관계상 조명은 다음 호로 미루고 이번 호에서는 기본적인 조명만을 이용한다. 여기까지 진행되었다면 정점 정보부터 시작된 예제 프로그램은 훌륭한 삼각형이 되어 윈도우에 보인다. 이제까지 설명된 부분에서 위치, 방향에 해당되는 값들을 조금씩 바꿔보자. 최종 그려지는 삼각형이 다르게 보일 것이다.

내가 어떻게 뷰포트를 만들었는지 놀라울 따름이다. 다급한 마음에 버티 녀석을 무사히 파이프라인에서 꺼낼 생각뿐이었다. 만들어진 뷰포트를 파이프라인에 붙이자 가쁜 숨을 몰아쉬며 버티가 돌아왔다! “고, 고마워... . 뷰포트를 미리 만들어야 한다는 것을 깜빡했어.” 버티 녀석, 조금 지친 듯한 표정과 미안함, 그리고 고마움이 숨겨진 복잡한 표정을 짓는다. 파이프라인을 거친 버티는 얼마 전까지의 꼬마가 아닌 제법 어른 티가 났다.



<그림 3> 투영 변환이 적용되는 관찰절두체 영역

  사각형과 인덱스 버퍼
정점을 공유해 사각형을 만들어 보자. 이미 삼각형 모양의 정점이 있으니까 정점을 하나 더 추가해 사각형을 만들 수 있다. D3D_2.dsw에서는 정점에 D3DFVF_DIFFUSE 속성을 추가해 색상을 추가했다.
정점이 4개이므로 정점 버퍼 역시 MYVERTEX 구조체에 정점의 수인 4를 곱해 정점 수만큼 메모리를 할당하였다. DrawPrimitive()에서는 D3DPT_TRIANGLESTRIP을 이용했다. 이번에는 삼각형이 두 개, 즉 면의 개수는 2개이므로 마지막 인자에는 2를 적어줘야 한다. 실행시켜보면 사각형이 회전하는 모습을 보게 될 것이다.
이번에는 다른 방법으로 사각형을 만들어 보자. 바로 인덱스 버퍼(index buffer)를 이용한 방법이다. 인덱스 버퍼는 정점 버퍼에 있는 서로 다른 버퍼들에 대한 위치를 갖고 있다. 인덱스 버퍼를 만들기 위해서는 CreateIndexBuffer()를 사용하는데 인자는 CreateVertex Buffer()와 비슷하므로 생략하겠다. D3D9_3.cpp는 인덱스 버퍼를 이용한 사각형 예제로 SetGeoMetry() 함수와 MyGame() 함수 안에는 <리스트 3>과 같은 내용이 추가되?있을 것이다.
<리스트 3>에서 dwIndices[]에 적힌 숫자들은 <그림 4>의 인덱스 정점을 의미한다. CreateIndexBuffer()로 인덱스 버퍼를 할당한 다음 그 안에 dwIndices[]에 차례대로 기입된 인덱스 버퍼 정점을 복사한다. 이로써 인덱스 버퍼에는 0, 1, 2, 0, 2, 3이란 정점 인덱스가 들어가게 되는데 첫 번째 삼각형은 인덱스 버퍼 0, 1, 2의 순서대로 삼각형을 그리고 두 번째 삼각형은 0, 2, 3의 순서대로 그리게 된다(시계 방향). 물론 이것은 SetGeoMetry()함수 안에 설정된 정점의 위치에 따라 기입한 것으로 각기 다른 삼각형이지만 강제적(?)으로 정점을 공유하도록 만들어준다. 실제 폴리곤을 그리는 역할도 Draw Primitive()가 아닌 DrawIndexedPrimitive()가 맡게 되었다. 인자 수는 늘어났지만 이 역시 DrawPrimitive()와 비슷하므로 쉽게 파악되리라 믿는다. 명시된 공유 정점을 이용한 것이므로 D3DPT_ TRIANGLESTRIP이 아닌 D3DPT_TRIANGLELIST를 이용한 것에도 주목해야 한다. 얼핏 생각하기에는 대단히 불편한 방법같지만 이렇듯 인덱스가 존재하지 않는다면 어떤 정점을 공유시켜야 하는지 알 수 없다. 또한 경우에 따라 원하는 모델을 만들기 위해 여러 번의 DrawPritive()를 호출하는 경우까지 발생한다. 인덱스 버퍼의 활용은 이런 불상사를 막고 정점을 원하는대로 공유시켜 다이렉트3D를 최적화시킨다.

내 손으로 뷰포트를 만든 그날은 잊혀지지 않는 날이었다. 자신의 힘만으로 무언가를 해내는 그런 기쁨을 오랫동안 난 잊고 지냈던 것이다. 나에게도 버티에게도 파이프라인은 정말 필요했던 일이었던 것 같다. 나를 제외하고도 버티 녀석, 아니 버티의 잔소리도 꽤 줄었으며 버티와 지내는 일상이 점점 크게 다가왔다.



<그림 4> 인덱스 버퍼의 인덱스 정점



3D 파이프라인 다시 보기
버티가 통과해 온 이제까지의 과정들을 다시 한번 상기해보자. 처음 정점 정보를 가진 구조체를 만들었고 정점 버퍼로 옮겼다. 카메라를 만들어 뷰포트로 지정하고 라이트를 설정하였다. 그리고 D3D MATRIX라는 행렬이 나타났고 의미를 알 수 없는 변환 과정도 거쳤었다. 그것을 그림으로 표현다면 <그림 5>와 같을 것이다.



<그림 5> 다이렉트3D의 파이프라인



단순한 float 변수 모임인 정점이 화면으로 그려지는 과정을 설명하고 있다. 그와 함께 정점이 거친 세 개의 변환 과정, 즉 월드 변환, 뷰(카메라) 변환, 투영 변환 과정이 나타난다. 첫 번째 월드 변환부터 살펴보자. 3D 프로그램들은 전형적으로 각각의 모델들이(비록 우린 이제까지 하나의 모델밖에 그리지 않았지만) 각자의 좌표계를 갖고 있다. 3D 게임에서 각각의 캐릭터는 자신만의 좌표, 예컨데 힙이 (0, 0, 0)이라면 머리는 (0, 5, 0), 오른팔은 (1, 3, 0) 정도의 좌표계를 갖고 있으며 이것을 흔히 로컬 좌표라고 부른다. 자신만의 로컬 좌표를 갖고 있더라도 3D 공간 속, 특정 위치에 존재하게 하기 위해서 모든 로컬 좌표를 월드 좌표계로 변환하게 된다.
다음은 뷰 변환이다. 우리는 방금 모델을 만들고 카메라를 만들었다. 카메라에게 보여지는 위치를 위해 다시 한번 변환을 거친다. X, Y, Z 좌표는 결코 절대적인 좌표가 아니다. 다이렉트3D에서 모든 물체는 최적으로 보여지기 위해서 상대적인 값을 가진다. (10, 10, 10)에 있는 모델을 보기 위해 카메라가 (10, 10, 9)에 있고 시선은 Z의 증가 방향을 바라본다면 모델은 보일 것이다. 그러나 치명적인 문제는 그래픽카드는 어디까지나 (0, 0, 0)에 고정되어 있다는 것과 다른 위치에서는 그림을 그릴 수 없다는 사실이다. 그렇다면 카메라와 모델을 옮겨야 한다. 카메라를 포함한 3D 월드의 모든 것을 (-10, -10, -9)로 이동한다. 이제 카메라는 (0, 0, 0)이 되었고 모델은 (0, 0, 1)이 되었다. 결국 (0, 0, 0)에서 (0, 0, 1)을 바라보는 셈이 된다. 이것이 뷰 변환이다. 카메라를 다뤘던 <리스트 1>에서 matCameraView란 행렬 구조체 변수가 있었다. 결국 matCameraView는 카메라가 보는 시선을 만들어낼 행렬인 것이다.
여기까지 오더라도 모니터에 3D 세계를 투영할 수는 없다. 모니터는 어디까지나 2D 표면이기 때문이다. 진짜 홀로그램 장치에 나타내지 않는 한 2D로 다시 변환하는 작업을 해야 한다. IDirect3D Dev ice9::SetTransform()은 뷰 변환에 이어 여기에서도 강력함을 발휘하는데 역시 matProj 행렬을 이용해 모니터에 투영될 2D를 만들어 낸다.

HRESULT SetTransform(
D3DTRANSFORMSTATETYPE State,
CONST D3DMATRIX *pMatrix
);

◆D3DTRANSFORMSTATETYPE State : 변환할 대상. D3DTS_WORLD 시에는 월드 변환, D3DTS_VIEW에는 뷰 변환, D3DTS_PROJECTION 시에는 투영 변환을 한다.
◆CONST D3DMATRIX *pMatrix : 사용할 행렬 포인터

마지막 파이프라인은 클리핑과 뷰포트의 크기를 맞추는 일이다. 카메라 설정 시에 만들어진 관찰절두체 영역이 이 작업을 위한 영역이 된다. 여기까지 마쳤으면 실제 모니터에 3D 세계가 보여진다. 이런 작업은 3D 세계를 채우는 수많은 정점들에 대해 초당 수천 번의 작용을 한다.
3D 파이프라인 속에서 발생하는 벡터와 행렬 연산에 대한 수학적 증명은 생략하기로 한다. 우리는 SetTransform()을 정상적으로 호출하고 이 과정만 이해하면 된다. 내부적인 증명에 관심있는 독자는 전문적인 3D 그래픽 자료를 참고하길 바란다.

깊이 버퍼
마지막으로 깊이 버퍼(depth buffer)에 대해 알아보자. 2D로 모니터에 투영되는 3D 세계는 먼 쪽 모델과 가까운 쪽 모델이 있다. 당연히 먼 쪽 모델은 가까운 모델에 의해 가려진다. 간단히 먼 쪽 모델부터 그리고 그 위에 가까운 모델을 그리면 되겠지만 만약 모델들이 사슬처럼 서로 꼬여있다면 모델 단위로 거리를 정한다는 것은 불가능하다. 따라서 모델이 아닌 픽셀의 Z값 단위로 깊이를 측정한다. 깊이 버퍼는 다른 버퍼들과 달리 메모리를 할당하고 복사하는 것이 아닌 IDirect3DDevice9::SetRenderState()로 설정한다. 참고로 이 메쏘드는 앞으로 꾸준히 등장하여 항상 여러분들을 괴롭혀줄 것이다. 깊이 버퍼는 다양한 Z픽셀 값을 저장할 수도 있는데 일단 예제에서는 Z버퍼의 사용 여부를 결정하는 것만 보여주고 있다. 그래픽카드에 따라 두 번째 인자로 D3DZB_USEW를 사용하면 Z버퍼가 아닌 W버퍼를 이용할 수 있으며 지원이 된다면 W버퍼를 쓰는 것이 좋다.

g_pd3dDevice->SetRenderState( D3DRS_ZENABLE, D3DZB_TRUE );
// 깊이 버퍼로 Z버퍼를 이용한다.
g_pd3dDevice->SetRenderState( D3DRS_ZENABLE, D3DZB_USEW );
// 깊이 버퍼로 W버퍼를 이용한다.
g_pd3dDevice->SetRenderState( D3DRS_ZENABLE, D3DZB_FALSE );
// 깊이 버퍼를 사용한다.

이번 호는 방대한 양을 요약했기에 3D 파이프라인과 T&L (Transformation & Lighting)에 관한 모든 것을 다룰 수는 없었다. 게임 드라이빙 스쿨 Ⅲ를 시작하며 이야기했지만 다이렉트3D의 진입 장벽은 너무나 거대하며 어떤 기능과 범위에 따라 구분짓기가 불가능하다. 하나의 부분은 다른 요소의 특정 부분과 물고 물리는 관계이다. 다이렉트3D의 필수 요소들을 완전히 자기 것으로 만들기에는 많은 시간이 요구되니 독자 스스로의 노력이 함께 하길 바란다.
다음 시간에는 이번에 익힌 폴리곤 위에 재질(materials)과 텍스처로 화장과 옷을 입히고 라이트로 조명발(?)까지 넣어 볼 것이다. 그 과정까지 마쳤으면 이제 3D 세계를 만들 수 있는 필수 요소들을 다 익힌 셈이다. 또한 다이렉트3D의 프레임워크를 분석하여 실제 도움이 되는 개발 환경으로 이동해 볼 것이다. 이번 시간에 미처 다루지 못한 벡터와 행렬 그리고 지원 함수들에 대한 다른 예제들은 필자의 홈페이지(http://rhea.pe.kr)를 찾길 바란다. 특유의 게으름으로 제법 독촉을 해야 내뱉을 것이지만 프로그래밍이란 ‘놀이’를 함께 할 수 있는 친구를 찾기란 쉬운 일은 아니다. 

'프로그래밍 ----------------------- > C,C++ 팁' 카테고리의 다른 글

billboard에서 D3DXMatrixInverse()  (0) 2008.10.13
행렬(Matrix)  (0) 2008.10.13
DirectX 용어집  (0) 2008.10.13
4*4행렬 회전  (0) 2008.10.13
내적 외적  (0) 2008.10.13