픽셀쉐이더
자, 이제 픽셀쉐이더를 작성해 볼 차례입니다. 정점쉐이더에서 했던 것과 마찬가지로 렌더몽키의 Workspace에서 Pixel Shader를 찾아 더블클릭합니다. 그리고 그 안에 있는 코드를 모두 지웁니다. 실제로 코드를 한 줄씩 쳐보셔야 실력이 늡니다. ^^ 꼭 코드를 다 지우세요.

이제 정점쉐이더에서 그랬던 거와 마찬가지로 전체코드를 보여드린 뒤, 한 줄씩 살펴보기로 하죠.

float4 ps_main() : COLOR
{   
   return float4( 1.0f, 0.0f, 0.0f, 1.0f );
}



픽셀쉐이더의 가장 중요한 임무는 픽셀의 색을 반환하는 것입니다. 현재 저희가 만드는 쉐이더기 빨강쉐이더니까 그냥 빨간색을 반환하면 되겠죠? 그렇다면 빨간색을 RGB값으로 어떻게 표현할까요? RGB(255, 0, 0)이 제일 먼저 떠오르시나요? 흠... 그렇다면 픽셀쉐이더 코드를 작성하기 전에 다음 절을 먼저 보셔야겠습니다.

색의 표현방법
빨간색을 RGB로 표현하라고 하면 (255, 0, 0)을 먼저 떠올리시는 이유는 RGB의 각 채널을 8비트로 저장하는 경우가 대부분이기 때문입니다. 8비트를 정수로 표현하면 총 256개의 값(2∧8 = 256)을 표현할 수 있습니다. 이 값을 0부터 시작하면 0 ~ 255가 되므로 각 채널의 최대값이 255이 되는 거지요. 근데 8비트가 아니라 5비트로 각 채널을 표현하면 어떻게 될까요? 2∧5 = 32이므로 31이 최대 값이 되지요. 따라서 8비트 이미지에서 빨간색은 (255, 0, 0) 이지만 5비트 이미지에서의 빨간색은 (31, 0, 0)이라는 찹찹한 결과가 생기고 마네요?

그러면 비트 수에 상관없이 통일적으로 색을 표현할 방법은 없을까요? 아마 포토샵에서 HDR 이미지를 다뤄보신 분들이라면 이미 그 답을 알고 계실 듯 하네요. 바로 백분율(%)을 사용하면 되지요. 백분율을 사용하면 비트 수에 상관없이 빨간색의 RGB값이 언제나 (100%, 0%, 0%)가 됩니다. 이게 바로 쉐이더에서 색상을 표현할 때 사용하는 방법입니다. 백분율을 그냥 유리수로 나타내면 0.0 ~ 1.0이 되니까 쉐이더에서 빨간색의 RGB값은 (1.0, 0.0, 0.0)이 됩니다.

픽셀쉐이더 함수
자, 그럼 이젠 어떤 RGB 값을 반환해야 할지도 알아봤으니 픽셀쉐이더 함수를 작성할 일만 남았군요. 픽셀쉐이더 함수의 헤더부터 살펴봅시다.

float4 ps_main() : COLOR
{


이 헤더가 의미하는 바는 다음과 같습니다.
  • 이 함수의 이름은 ps_main이다.
  • 이 함수는 매개변수를 받지 않는다.
  • 이 함수의 반환형은 float4이다.
  • 이 함수의 반환 값을 백 버퍼의 색상(COLOR)값으로 처리할 것.

여기서 딱히 추가로 설명해 드릴 부분은 반환 값의 데이터형으로 float3가 아니라 float4를 쓴다는 정도입니다. 4번째 값은 알파 채널로 보통 투명효과를 나타내는 용도로 쓰이곤 합니다.[각주:1]

자, 그럼 이 함수 안에선 무슨 일을 해야 했었죠? 그렇죠. 빨간색을 반환해야죠. 이렇게 코드를 짜면 됩니다.

   return float4( 1.0f, 0.0f, 0.0f, 1.0f );
}


여기서 특별히 설명드릴 것은 float4(r, g, b, a)라는 형태로 float4 벡터를 새로 생성한다는 것과 알파 채널의 값이 1.0(100%)이므로 픽셀이 완전히 불투명 하다는 정도 입니다. 이제 쉐이더 편집기 안에서 F5키를 눌러 정점쉐이더와 픽셀쉐이더를 각각 컴파일 하면 미리 보기 창에서 다음과 같은 빨간색 공을 보실 수 있을 겁니다.

팁: 렌더몽키에서 쉐이더를 컴파일 하는 법
렌더몽키에서는 정점쉐이더와 픽셀쉐이더를 별도로 컴파일 해줘야 합니다. 편집기에서 각 쉐이더를 불러온 뒤 F5를 눌러주세요. 미리 보기 창이 열릴 때도 두 쉐이더가 모두 컴파일 됩니다.


그림 2.7. 처음으로 만들어본 빨강쉐이더!



정말 간단한 쉐이더였죠? 여기서 빨간색 대신 파란색을 보여주려면 어째야 할까요? float4(0.0, 0.0, 1.0, 1.0)을 반환하면 되겠죠? 노란색은요? 노란색은 연두색과 빨간색을 섞은 거니까.... 음.... 제가 굳이 답을 알려드리지 않아도 아시죠?

이제 이 렌더몽키 프로젝트를 잘 저장해 두세요. 각 장이 끝날 때마다 렌더몽키 프로젝트를 저장해 두시기 바랍니다. 나중에 다른 장에서 다시 이용할 거거든요.

선택사항: DirectX 프레임워크
이제 C++로 작성한 DirectX 프레임워크에서 쉐이더를 사용하시고자 하는 분들을 위한 선택적인 절입니다.

우선 '제1장: 쉐이더란 무엇이죠?'에서 만들었던 프레임워크의 사본을 만들어 새로운 폴더에 저장합니다. 각 장마다 프레임워크를 따로 저장하는 이유는 다른 장에서 이 프레임워크를 가져다가 코드를 추가할 예정이기 때문입니다.

다음은 렌더몽키에서 사용했던 쉐이더와 3D 모델을 DirectX 프레임워크에서 사용할 수 있도록 파일로 저장할 차례입니다.

  1. Workspace 패널에서 ColorShader를 찾아 오른쪽 마우스 버튼을 누릅니다.
  2. 팝업메뉴에서 Export > FX Exporter를 선택합니다.
  3. 위에서 DirectX 프레임워크를 저장했던 폴더를 찾아 그 안에 ColorShader.fx란 이름으로 파일을 저장합니다.
  4. 이제 Workspace 패널에서 Model을 찾아 오른쪽 마우스 버튼을 누릅니다.
  5. 팝업메뉴에서 Save > Geometry Saver를 선택합니다.
  6. 역시 DirectX 프레임워크가 있는 폴더 안에 Sphere.x란 이름으로 파일을 저장합니다.

이제 비주얼 C++ 에서 프레임워크의 솔루션 파일을 연 뒤, 다음의 코드들을 차례대로 추가해보도록 하죠. ShaderFramework.cpp 파일을 열겠습니다.

우선, 투영행렬을 만들 때 필요한 상수들을 #define으로 정의하겠습니다.

#define PI           3.14159265f
#define FOV          (PI/4.0f)                     // 시야각
#define ASPECT_RATIO (WIN_WIDTH/(float)WIN_HEIGHT) // 화면의 종횡비
#define NEAR_PLANE   1                             // 근접 평면
#define FAR_PLANE    10000                         // 원거리 평면


이제 Sphere.x하고 ColorShader.fx 파일을 로딩해서 메모리에 저장해둘 때 사용할 포인터 2개를 선언합니다.

// 모델
LPD3DXMESH        gpSphere        = NULL;

// 쉐이더
LPD3DXEFFECT      gpColorShader   = NULL;


이제 모델과 쉐이더 파일을 로딩해야겠죠? '제1장: 쉐이더란 무엇이죠?'에서 속을 비워두었던 LoadAssets()함수 안에 다음의 코드를 추가할 때로군요.

    // 쉐이더 로딩
    gpColorShader = LoadShader("ColorShader.fx");
    if ( !gpColorShader )
    {
        return false;
    }

    // 모델 로딩
    gpSphere = LoadModel("sphere.x");
    if ( !gpSphere )
    {
        return false;
    }


위 코드는 '제1장: 쉐이더란 무엇이죠?'에서 미리 구현해 두었던 LoadShader() 함수와 LoadModel() 함수를 호출해서 파일들을 로딩한 뒤, 그 중에 하나라도 NULL 포인터이면 로딩에 실패했다는 의미로 false를 반환합니다. 이렇게 로딩에 실패한 경우 비주얼 C++의 출력 창에 에러메시지가 있을 테니 살펴보시기 바랍니다.

새로운 D3D 자원을 로딩할 때 마다 이를 해제하는 코드를 추가하는 습관을 기르도록 합시다. GPU 상의 메모리 누수를 막기 위해서입니다. CleanUp() 함수에서 D3D를 해제하기 바로 전에 다음의 코드를 삽입하겠습니다.

    // 모델을 release 한다.
    if ( gpSphere )
    {
        gpSphere->Release();
        gpSphere = NULL;
    }

    // 쉐이더를 release 한다.
    if ( gpColorShader )
    {
        gpColorShader->Release();
        gpColorShader = NULL;
    }



이제 사전작업은 모두 끝났으니 마지막으로 쉐이더를 이용해서 물체를 그리기만 하면 됩니다. 3D 물체를 그리는 코드는 RenderScene()에 넣기로 했었죠? RenderScene() 함수로 갑니다.

// 3D 물체 등을 그린다.
void RenderScene()
{


쉐이더 안에서 전역변수들을 사용했던 것 기억하시나요? 렌더몽키에서는 변수 시맨틱을 통해 이 값들을 대입해줬지만 여기서는 직접 이 값들을 만들어서 쉐이더에 전달해 줘야 합니다. 우선 뷰행렬부터 만들어 볼까요?

    // 뷰 행렬을 만든다.
    D3DXMATRIXA16 matView;
    D3DXVECTOR3 vEyePt(    0.0f, 0.0f, -200.0f ); 
    D3DXVECTOR3 vLookatPt( 0.0f, 0.0f,  0.0f );
    D3DXVECTOR3 vUpVec(    0.0f, 1.0f,  0.0f );
    D3DXMatrixLookAtLH( &matView, &vEyePt, &vLookatPt, &vUpVec );


위에서 볼 수 있듯이 카메라의 현재 위치와 카메라가 바라보는 곳의 위치, 그리고 카메라의 위쪽을 가리키는 벡터만 있으면 D3DXMatrixLookAtLH() 함수를 호출하여 뷰행렬을 만들 수 있습니다. 여기서는 카메라가 현재 (0, 0, -200)에 위치해 있고 (0, 0, 0)을 바라보고 있다고 가정합니다. 실제 게임에서는 카메라 클래스로부터 이 정보를 가져와서 뷰행렬을 만드는 것이 정석입니다.

다음은 투영행렬을 만들 차례입니다. 투영행렬은 원근투시법(perspective projection)을 사용하느냐 직교투시법(orthogonal projection)을 사용하느냐에 따라 사용할 함수와 매개변수들이 달라집니다. 여기서는 원근투시법을 사용하므로 D3DXMatrixPerspectiveFOVLH() 함수를 사용하겠습니다. 

    // 투영행렬을 만든다.
    D3DXMATRIXA16 matProjection;
    D3DXMatrixPerspectiveFovLH( &matProjection, FOV, ASPECT_RATIO, NEAR_PLANE,
        FAR_PLANE );


이제 월드행렬을 만들어 보겠습니다. 사실 월드행렬은 한 물체의 위치와 방위, 그리고 확장/축소 변환을 합친 것입니다. 따라서 뷰행렬 및 투영행렬과 달리 각 물체마다 월드행렬을 만들어줘야 합니다. 본 예제에서는 월드의 원점(0, 0, 0)에 물체를 놓아둔다고 가정하므로 월드행렬을 그냥 단위행렬(identity matrix)로 놔두겠습니다.

    // 월드행렬을 만든다.
    D3DXMATRIXA16 matWorld;
    D3DXMatrixIdentity(&matWorld);


쉐이더에서 사용할 전역변수 3개를 전부 다 만들었으니 이제 이 값들을 쉐이더에 전달해줘야 겠군요. 이 때 쉐이더의 SetMatrix함수를 이용하면 이런 일을 쉽게 할 수 있습니다. SetMatrix의 첫 번째 인수는 쉐이더 안에서 사용하는 변수의 이름이고, 두 번째 변수는 위에서 정의한 D3DXMATRIXA16형의 변수입니다.

    // 쉐이더 전역변수들을 설정
    gpColorShader->SetMatrix("gWorldMatrix", &matWorld);
    gpColorShader->SetMatrix("gViewMatrix",  &matView);
    gpColorShader->SetMatrix("gProjectionMatrix",  &matProjection);


쉐이더에 필요한 변수들의 값을 모두 전달해줬다면 이제 GPU에게 명령을 내릴 차례입니다. '앞으로 그릴 모든 물체들에 이 쉐이더들을 적용할 것'이라는 명령을 말입니다. 이런 명령은 쉐이더의 Begin() / BeginPass()와 EndPass() / End() 함수호출로 내립니다. BeginPass()와 EndPass()가 구성하는 블럭 안에 물체를 그리는 함수를 넣으면 물체가 그려질 때 이 쉐이더가 사용되죠. 우선 아래의 코드를 보시죠.


    // 쉐이더를 시작한다.
    UINT numPasses = 0;
    gpColorShader->Begin(&numPasses, NULL);
    {
        for (UINT i = 0; i < numPasses; ++i )
        {
            gpColorShader->BeginPass(i);
            {
                // 구체를 그린다.
                gpSphere->DrawSubset(0);
            }
            gpColorShader->EndPass();
        }
    }
    gpColorShader->End();
}



DrawSubset() 호출이 BeginPass() / EndPass() 안에 있고, 이는 다시 Begin() / End() 호출 안에 있는 거 보이시죠? 이렇게 하면 GPU가 gpColorShader 쉐이더를 이용해서 gpSphere 물체를 그릴 것입니다.

위의 코드를 보시면 쉐이더에서 Begin() 함수를 호출 하고 난 뒤에 다시 BeginPass()를 호출하는 거 보이시죠? 가끔 패스(pass)를 보고 '아니, 쉐이더는 알겠는데 그 안에 들어있는 패스는 또 뭐여?'라고 혼돈스러워하는 학생들을 본 적이 있는데 크게 신경 쓰지 않으셔도 됩니다. 패스는 다양한 쉐이더를 이용하여 동일한 물체를 여러 번 그릴 때 유용하지만 실무에서 둘 이상의 패스를 쓰는 경우가 별로 없으니 그냥 무시하세요. 그냥 Begin() 함수를 호출할 때, numPasses 변수의 주소를 전달하여 쉐이더 안에 들어있는 패스의 수(대부분의 경우 1)를 구해온다는 정도만 아시면 됩니다. 만약 2개 이상의 패스가 존재한다면 정점/픽셀쉐이더 쌍도 둘 이상이 존재한다는 거니까 그 수만큼 BeginPass()/EndPass()를 호출하면서 여러 번 물체를 그려주면 되는 거죠.

이제 코드를 컴파일 한 뒤 프로그램을 실행하면 아까 렌더몽키에서 봤던 것과 동일한 결과를 보실 수 있습니다.
 
정리
다음은 이 장에서 배운 내용을 짧게 요약해 놓은 것입니다.
  • 각 정점마다 변하는 값은 정점데이터의 멤버변수로 받는다.
  • 모든 정점에 공통적으로 사용되는 값은 전역변수로 받는다.
  • HLSL은 벡터연산에 간편히 사용할 수 있는 float4, float4x4 등의 데이터형을 제공한다.
  • 정점의 공간을 변환할 때는 행렬 곱을 사용한다. HLSL에서 제공하는 내장함수 mul()을 사용하면 손쉽게 행렬과 벡터를 곱할 수 있다.
  • HLSL에서 색상을 표현할 때는 0 ~1 사이로 정규화한 값을 사용한다.


이 장에서 배운 내용은 정말 기초 중의 기초입니다. 이렇게 간단한 쉐이더를 혼자서도 뚝딱 작성하실 정도로 쉐이더 문법의 기본이 되어야 나중에 다른 복잡한 쉐이더도 쉽게 작성하실 수 있습니다. 제가 강의를 할 때, 이 빨갱이 쉐이더가 너무 쉽다고 눈으로만 대충 훑어보고 넘어간 일부 학생들이 나중에 다른 쉐이더에서 고생하는 경우를 종종 봤습니다. 쉐이더 자체가 어려워서가 아니라 아주 기초적인 HLSL 문법조차도 제대로 숙지하지 않았기 때문이었습니다. 다음 장으로 가시기 전에 반드시 빨강쉐이더 정도는 직접 작성하실 수 있을 정도로 한두 번 연습을 해두시기 바랍니다.



p.s. 여기는 댓글 달릴때마다 이메일이 오지 않아서 답변을 빨리빨리 드리기가 쉽지 않군요. 제 개인 블로그에 포스팅 된 버전에 댓글을 다시는게 제일 답변이 빠릅니다. 오탈자 및 잘못된 내용 수정도 제일 빠릅니다.

포프 올림




 
  1. 이 값이 1이면 완전 불투명, 0이면 완전 투명입니다. [본문으로]