샘플파일: 

제6장 만화 같은 명암을 입히는 툰쉐이더

이 장에서 새로 배우는 HLSL
  • ceil() - 무조건 올림 함수



이 장에서 새로 사용하는 수학
  • 행렬합치기 - 여러 개의 행렬을 미리 곱해놓은 뒤, 그 결과를 정점변환에 사용해도 결과는 동일함. 단, 속도는 더 빠름.
  • 역행렬 - 반대 방향으로 공간변환을 할 때 유용.




배경
얼마 전에 저희 회사의 아트 디렉터가 했던 말이 있습니다. 프로그래머는 언제나 사실적인 3D 그래픽을 추구하지만, 게이머들의 보통 미적 스타일을 최대로 살린 비사실적인 그래픽에 열광한다고요. 생각해보니  맞는 말이더군요. 프로그래머들은 언제나 수학적으로 옳은 것을 추구하려고 하지만 언제나 미적 스타일을 갖춘 게임들이 흥행을 하니까요. 스트리트 파이터 4, 팀포트리스 2, 보더랜드 등이 그 좋은 예겠죠?

그 동안 현대 3D 그래픽의 주 초점도 사실적인 그래픽을 재현해 내는 것이었습니다. 하지만 그 와중에도 미적인 효과를 살리기 위한 비사실적 렌더링 기법들도 간간이 등장했는데요 여기서 살펴볼 툰쉐이딩(toon shading)[각주:1]도 그 중 하나입니다. 툰(toon)이라 하면 만화(cartoon)를 뜻합니다. 만화를 보면 명암처리를 부드럽게 하는 대신에 칼같이 딱딱 끊어서 2~3 단계로 하죠? 뭐, 만화를 안보신 분들은 없을 듯 하니 다 아시겠네요. ^^ 여기서 구현할 쉐이더가 바로 그런 일을 할 겁니다. 일단 결과를 미리 사진으로 보여드리면 대충 감이 오시겠네요.

그림. 6.1. 이장에서 만들어 볼 툰쉐이더


 
위의 사진을 잘 관찰해 봅시다. 여태까지 사용했던 평범한(?) 난반사광 쉐이더와 뭐가 다르죠? 난반사광이 부드럽게 어두워지는 대신 단계적으로 팍팍 줄어든다는 거죠? 마치 계단을 걸어 내려가는 것처럼요. 그렇다면 이것을 그래프로 그려보면 어떨까요? 일반적인 난반사광의 그래프와 비교해서 보면 좀 더 이해가 쉽겠네요.

그림 6.2 일반 난반사광 그래프와 툰쉐이딩 그래프



위 그래프를 보니 감이 팍팍 오지 않나요? 아니라고요? 으음... 그럼 위 그래프를 표로 간단하게 정리해 보겠습니다..

 난반사광의 양  툰쉐이더 값
 0  0
 0 ~ 0.2  0.2 
 0.2 ~ 0.4  0.4 
 0.4 ~ 0.6  0.6 
 0.6 ~ 0.8  0.8 
 0.8 ~ 1  1 
표 6.1 난반사광의 양과 툰쉐이더 값의 비교

이렇게 비교를 하니 정말 쉽네요. 난반사광의 값을 가져다가 0.2단위로 무조건 올림을 하면 툰쉐이더 값이 나오는군요? 그럼 이 정도만 알면 툰쉐이더를 만드는 건 식은 죽 먹기일 듯 합니다. 곧바로 렌더몽키로 가 볼까요?

기초설정
렌더몽키를 실행한 뒤, DirectX 이펙트를 추가합니다. 새로 생긴 이펙트의 이름을 Default_DirectX_Effect에서 ToonShader로 바꿉니다. matViewProjection이란 행렬이 정의되어 있는 것도 보이시죠? 삭제해 주세요.

그림 6.1에서 주전자 모델을 보여드렸었죠? 주전자는 3D 그래픽 논문에서 즐겨 사용하는 모델 중에 하나입니다. 이리저리 다양한 굴곡이 많아서 쉐이더의 결과를 딱 한눈에 살펴보기 좋다나요? 저희도 주전자 모델을 사용하겠습니다. 렌더몽키의 작업공간 패널에서 Model을 찾으세요. 이 위에 마우스 오른쪽 버튼을 누른 뒤, Change Model > Teapot.3ds를 선택합니다.

툰쉐이딩을 하려면 일단 난반사광을 계산해야겠죠? 그래야 그 결과를 0.2 단위로 올림할 수 있으니까요. 그렇다면 '제4장: 기초적인 조명쉐이더'에서 그랬던 것처럼 빛의 위치와 정점의 법선정보가 필요하겠군요. 우선 빛의 위치를 변수로 선언하겠습니다. ToonShader에 오른쪽 마우스 버튼을 누른 뒤, Add Variable > Float > Float4를 선택하고, 변수의 이름을 gWorldLightPosition으로 바꿉니다. 이 변수의 값은 예전과 마찬가지로 (500, 500, -500, 1)으로 맞춰주세요. 다음은 정점에서 법선정보를 읽어올 차례입니다. Stream Mapping을 더블클릭해서 NORMAL 필드를 추가하면 되겠죠? 데이터형은  FLOAT3, Index는 0으로 해주는 것도 잊지 마세요.

그림 6.1을 다시 한번 봐 보죠. 주전자가 녹색이죠? 주전자의 색을 지정해주는 방법은 여러 가지가 있지만 여기서는 전역변수 하나로 전체 표면의 색을 지정해 주겠습니다.[각주:2] ToonShader에 마우스 오른쪽 버튼을 누른 뒤, Add Variable > Float > Float3를 선택합니다. 새로 변수가 생기면 이름을 gSurfaceColor로 바꿉니다. 이제 이 변수 위에 마우스를 더블클릭하여 값을 (0, 1, 0)으로 변경합니다. 쉐이더에서 색상을 0~1 사이의 백분율 값으로 표현한다는 거 잊지 않으셨죠?

이제 행렬들을 좀 추가해 주겠습니다. 여태까지 다뤘던 쉐이더 기법에서는 기초설정을 할 때마다 월드행렬, 뷰행렬, 투영행렬을 따로 정의해 줬었죠? 여기서는 조금 다른 방법을 사용하도록 하죠. 3D 그래픽에서 공간변환을 할 때, 행렬을 사용하는 이유 중 하나가 여러 행렬들을 미리 합쳐놓으면(concatenation) 불필요한 연산을 줄일 수 있기 때문입니다. 예를 들면, 정점의 위치를 공간변환 할 때 월드행렬, 뷰행렬, 투영행렬을 차례대로 곱해줘야 하죠? 이러지 말고 미리 월드행렬, 뷰행렬, 투영행렬을 곱해서 새로운 행렬을 하나 구해 놓은 뒤, 그 행렬을 정점에 곱해도 결과는 동일합니다. 그러나 성능상으로 보면 행렬을 3번 곱하는 것보다 1번 곱하는 게 당연히 빠를 테니 행렬을 미리 합치는 방법이 더 낫지요.

여기서도 미리 행렬을 합치겠습니다. 그럼 그 결과 행렬을 건네 받을 전역변수를 하나 추해야겠죠? ToonShader에 오른쪽 마우스 버튼을 눌러 Add Variable > Matrix > Float(4x4)를 선택한 뒤, 변수명을 gWorldViewProjectionMatrix로 바꿉니다. 이제 이 변수 위에 마우스 오른쪽 버튼을 눌러 Variable Semantic > WorldViewProjection을 선택합니다.

자, 그럼 이렇게 행렬을 한 번만 곱하는 건 좋은데 난반사광을 계산하려면 월드행렬이 필요했던 것 같은데요? 빛의 위치가 월드공간에 정의되어 있으니까 빛의 방향벡터를 만들려면 월드공간에서의 정점위치가 필요했었네요. 그럼 당연히 월드행렬을 곱해야겠죠. 그리고 난반사광을 구하려면 역시 월드공간에서의 정점법선도 필요했었으니 역시 월드행렬이 필요하군요. 그렇다면 월드행렬을 전역변수로 전달해줘서 이렇게 행렬곱을 두 번 더 해줘야 할까요? 뭐 그러셔도 상관 없습니다. 틀린 방법은 아니거든요. 하지만 조금만 생각을 더 해보면 행렬곱 1번만으로 똑같은 일을 할 수 있습니다.

정점의 위치와 법선을 월드공간으로 변환하는 이유는 빛의 위치가 월드공간에 정의되어 있어서였습니다. 모든 변수가 동일한 공간에 있어야만 올바른 결과를 구할 수 있으니까요. 그럼 정점의 위치와 법선벡터를 월드공간으로 변환하는 대신에 빛의 위치를 지역공간으로 변환해버리면 어떨까요? 그러면 정점의 위치와 법선에 손을 대지 않아도 모든 매개변수들이 동일한 공간에 있겠죠? 이 방법은 행렬을 1번만 곱하니 아무래도 조금 더 빠르겠네요.

그렇다면 월드공간을 물체공간으로 어떻게 변환할까요? 월드행렬의 역행렬(inverse matrix)을 곱하면 됩니다. 그럼 렌더몽키에 월드행렬의 역행렬도 추가해 보도록 하죠. ToonShader에 마우스 오른쪽 버튼을 누른 뒤, Add Variable > Matrix > Float(4x4)를 선택합니다. 이제 이 변수 이름을 gInvWorldMatrix로 바꿔 주세요. 마지막으로 변수 위에 마우스 오른쪽 버튼을 눌러 Variable Semantic > WorldInverse를 누르면 모든 설정이 마무리 되었습니다.

그림 6.3. 기초설정을 마친 렌더몽키 프로젝트




정점쉐이더
일단 전체 소스코드부터 보여드린 뒤, 한 줄씩 차근차근 설명해드리겠습니다.

struct VS_INPUT 
{
   float4 mPosition : POSITION;
   float3 mNormal: NORMAL;   
};

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float3 mDiffuse : TEXCOORD1;   
};

float4x4 gWorldViewProjectionMatrix;
float4x4 gInvWorldMatrix;

float4 gWorldLightPosition;

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldViewProjectionMatrix );
   
   float3 objectLightPosition = mul( gWorldLightPosition, gInvWorldMatrix);
   float3 lightDir = normalize(Input.mPosition.xyz - objectLightPosition);
   
   Output.mDiffuse = dot(-lightDir, normalize(Input.mNormal));
   
   return( Output );
   
}




정점쉐이더 입출력데이터 및 전역변수
조명(난반사광)을 계산하려면 법선이 필요하죠? 따라서 정점쉐이더 입력데이터로 위치와 법선이 필요합니다.[각주:3]

struct VS_INPUT 
{
   float4 mPosition : POSITION;
   float3 mNormal: NORMAL;   
};


정점출력데이터도 별로 어렵지 않습니다. 난반사광을 계산한 뒤, 픽셀쉐이더에 전달해 주는 게 전부입니다.[각주:4] 이게 잘 이해가 안 되시는 분들은 '제4장: 기초적인 조명쉐이더'를 다시 한 번 읽어 주세요.

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float3 mDiffuse : TEXCOORD1;   
};


이제 전역변수로는 위에서 설명 드렸던 행렬 2개와 광원의 위치를 선언해야겠네요.

float4x4 gWorldViewProjectionMatrix;
float4x4 gInvWorldMatrix;

float4 gWorldLightPosition;


이러면 앞서 렌더몽키 프로젝트에 더했던 변수들을 다 처리한 거 같죠? 이제 정점쉐이더 함수를 보겠습니다.

정점쉐이더 함수
우선 정점쉐이더의 가장 중요한 임무를 수행하겠습니다. 정점의 위치를 투영공간으로 가져옵니다. 월드행렬, 뷰행렬, 투영행렬을 하나로 미리 합쳐버렸으니 코드 한 줄로 이런 일을 할 수 있겠네요.

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldViewProjectionMatrix );



이제 난반사광의 양을 계산할 차례입니다. 앞서 말씀 드렸듯이 빛의 위치를 지역공간으로 변환한 뒤, 모든 계산을 이 공간에서 해보겠습니다. 우선 빛의 위치를 지역공간으로 변환합니다.

   float3 objectLightPosition = mul( gWorldLightPosition, gInvWorldMatrix);


이제 광원의 위치에서 현재 위치(이미 지역공간에 있습니다)를 가리키는 방향벡터를 만듭니다. 이 방향벡터의 길이를 1로 만드는 것도 잊지 말아야겠죠?

   float3 lightDir = normalize(Input.mPosition.xyz - objectLightPosition);


이제 그 결과와 정점의 법선(역시 지역공간에 존재합니다) 간의 내적을 구하면 난반사광의 양을 구할 수 있습니다.

   Output.mDiffuse = dot(-lightDir, normalize(Input.mNormal));


위에서 법선의 길이를 1로 만들기 위해 normalize()함수를 호출한 거 보이시죠? 보통 정점버퍼로부터 곧바로 가져온 법선은 이미 정규화가 되어있는 게 보통이나 혹시나 해서 normalize()를 한 번 더 호출해 봤답니다.

이제 가볍게 Output을 반환합니다.

   return( Output );
   
}


정점쉐이더 함수는 별로 어려운 게 없었습니다. '제4장: 기초적인 조명쉐이더'에서 다 배웠던 내용이니까요. 그냥 다른 공간을 사용했다는 게 좀 다른 내용이지만 그리 어렵지 않게 이해하시리라 믿습니다. 이제 픽셀쉐이더를 살펴보겠습니다.

픽셀쉐이더
정점쉐이더에서와 마찬가지로 전체 소스코드부터 보여드립니다.

float3 gSurfaceColor;

struct PS_INPUT 
{
   float3 mDiffuse : TEXCOORD1;
};

float4 ps_main(PS_INPUT Input) : COLOR
{  
   float3 diffuse = saturate(Input.mDiffuse);
   
   diffuse = ceil(diffuse * 5) / 5.0f;
   
   return float4( gSurfaceColor * diffuse.xyz, 1);
   
}


우선 전역변수와 픽셀쉐이더 입력데이터를 정의하죠. 표면의 색상을 전역변수로 선언하고 정점쉐이더에서 계산을 마친 난반사광의 양을 입력데이터로 받겠습니다.

float3 gSurfaceColor;

struct PS_INPUT 
{
   float3 mDiffuse : TEXCOORD1;
};


이제 픽셀쉐이더 함수를 봅시다. 우선 mDiffuse에서 저희에게 별 의미가 없는 0 이하의 값을 잘라 냅니다.

float4 ps_main(PS_INPUT Input) : COLOR
{  
   float3 diffuse = saturate(Input.mDiffuse);


이제 이 값을 0.2단위로 딱딱 잘라야겠네요. 0.2단위로 무조건 올림을 하면 된다고 했었죠? HLSL에서 무조건 올림을 하는 함수는 ceil()입니다.[각주:5] 근데 ceil() 함수는 언제나 바로 위의 정수로만 올림을 한다는군요. 저희는 0.2단위로 올림을 해야 하는데 어쩌죠? 다음과 같이 간단히 곱셈과 나눗셈을 하면 됩니다.

   diffuse = ceil(diffuse * 5) / 5.0f;


위 공식(?)을 자세히 살펴보죠. diffuse가 0~1 사이의 값이니 여기에 5를 곱하면 범위가 0~5가 될 것입니다. 여기에 ceil()을 적용하면 그 결과값이 0, 1, 2, 3, 4, 5중에 하나가 되겠죠. 이제 이 값을 5로 나누면 최종 결과값이 0, 0.2, 0.4, 0.6, 0.8, 1 중에 하나가 될 겁니다. 이게 바로 저희가 원하는 값 맞죠? 그림 6.2와 표 6.1를 다시 봐도 이 값이 맞네요.

그럼 이제 표면의 색을 곱하기만 하면 끝입니다.[각주:6]

   return float4( gSurfaceColor * diffuse.xyz, 1);
}


이제 F5를 눌러 정점쉐이더와 픽셀쉐이더를 각각 컴파일 한 뒤, 미리 보기 창을 보시면 그림 6.1와 똑같은 주전자가 보이죠? 아, 배경색이 다르다고요? 미리 보기 창 안에서 마우스 오른쪽 버튼을 누른 뒤 Clear Color를 선택하시면 배경색을 바꿀 수 있습니다.

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

우선 '제3장: 텍스처매핑'에서 만들었던 프레임워크의 사본을 만들어 새로운 폴더에 저장합니다. 그 다음은 렌더몽키에서 사용했던 쉐이더와 3D 모델을 DirectX 프레임워크에서 사용할 수 있도록 파일로 저장할 차례입니다.

  1. Workspace 패널에서 ToonShader를 찾아 오른쪽 마우스 버튼을 누릅니다.
  2. 팝업메뉴에서 Export > FX Exporter를 선택합니다.
  3. 위에서 새로 만든 폴더를 찾아 그 안에 ToonShader.fx란 이름으로 파일을 저장합니다.
  4. 이제 Worspace 패널에서 Model을 찾아 오른쪽 마우스 버튼을 누릅니다.
  5. 팝업메뉴에서 Save > Geometry Saver를 선택합니다.
  6. 위에서 새로 만든 폴더를 찾아 그 안에 Teapot.x란 이름으로 파일을 저장합니다.

이제 비주얼 C++ 에서 프레임워크의 솔루션 파일을 엽니다.

우선 예전에 있던 전역변수들부터 살펴보겠습니다. gpTextureMappingShader 변수가 있는데 이 변수가 언급되어 있는 곳을 모두 찾아 gpToonShader로 바꿉니다. gpSphere변수도 똑같은 방법으로 모두 gpTeapot으로 바꿔 줍니다. 텍스처 변수, gpEarthDM도 있네요. 여기서는 텍스처를 전혀 사용하지 않으니 gpEarthDM변수를 사용하는 코드를 모두 찾아 삭제해주세요.

이제 새로 추가해야 할 전역변수들을 알아볼까요? 새로 추가해야 할 전역변수는 빛의 위치와 표면의 색상밖에 없는 것 같군요. 다음의 코드를 추가합니다.

// 광원의 위치
D3DXVECTOR4 gWorldLightPosition = D3DXVECTOR4(500.0f, 500.0f, -500.0f, 1.0f);

// 표면의 색상
D3DXVECTOR4 gSurfaceColor =       D3DXVECTOR4(0, 1, 0, 1);


위 코드에서 전역변수를 선언할 때 렌더몽키에서 사용했었던 값도 그대로 대입해줬습니다. 

이제 LoadAssets() 함수로 가서 로딩해올 쉐이더와 모델의 이름을 각각 Toonshader.fx와 Teapot.x로 바꿔줍니다.

bool LoadAssets()
{
    // 텍스처 로딩

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

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

    return true;
}


다음은 실제로 장면을 그리는 RenderScene() 함수입니다. 행렬 2개를 새로 전달해줘야 했었죠? 월드/뷰/투영행렬을 합친 행렬과 월드행렬의 역행렬이었습니다. 우선 월드행렬의 역행렬을 구해봅시다. 월드행렬을 구했던 코드 아래에 다음의 라인을 추가합니다.

    // 월드행렬의 역행렬을 구한다.
    D3DXMATRIXA16 matInvWorld;
    D3DXMatrixTranspose(&matInvWorld, &matWorld);


위 코드에서 사용한 D3DXMatrixTranspose() 함수는 전치행렬(transpose matrix)을 구합니다. 여기서 역행렬 대신에 전치행렬을 구한 이유는 월드행렬이 직교행렬이기 때문이죠. 직교행렬의 전치행렬은 역행렬과 같습니다.[각주:7]

이제 월드/뷰/투영행렬을 서로 곱할 차례입니다. D3DXMatrixMultiply() 함수를 사용하겠습니다.

    // 월드/뷰/투영행렬을 미리 곱한다.
    D3DXMATRIXA16 matWorldView;
    D3DXMATRIXA16 matWorldViewProjection;
    D3DXMatrixMultiply(&matWorldView, &matWorld, &matView);
    D3DXMatrixMultiply(&matWorldViewProjection, &matWorldView, &matProjection);


월드행렬 X 뷰행렬 X 투영행렬 순으로 곱해준 거 보이시죠?

이제 위에서 만들었던 두 행렬을 쉐이더에 전달해 주겠습니다. 예전에 사용했던 SetMatrix() 함수 호출들을 다 지우시고 아래의 코드를 대신 삽입해주세요.


    // 쉐이더 전역변수들을 설정
    gpToonShader->SetMatrix("gWorldViewProjectionMatrix",
        &matWorldViewProjection);
    gpToonShader->SetMatrix("gInvWorldMatrix", &matInvWorld);


마지막으로 광원의 위치와 표면의 색상을 전달해주는 것도 잊지 마셔야겠죠?

    gpToonShader->SetVector("gWorldLightPosition", &gWorldLightPosition);
    gpToonShader->SetVector("gSurfaceColor", &gSurfaceColor);


이제 코드를 컴파일 한 뒤, 프로그램을 실행하시면 빙글빙글 도는 주전자를 보실 수 있을 겁니다. 아무래도 회전을 하니까 손잡이나 주둥이에서 툰쉐이더 효과가 더 잘 나타나죠?

정리
다음은 이 장에서 배운 내용을 짧게 요약해 놓은 것입니다.
  • 툰쉐이더는 비실사 렌더링 기법 중에 하나이다.
  • 툰쉐이딩은 난반사광을 단계적으로 감소시키는 것에 지나지 않는다.
  • 행렬들을 미리 곱해놓으면 공간변환을 더 빨리 할 수 있다.



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


포프 올림
  1. 셀 쉐이딩(cell shading)이라고도 합니다. [본문으로]
  2. 만약 한 표면 위에서 여러 가지 색상을 사용하시고 싶으시다면 3DS MAX에서 메쉬를 만드실 때, 정점색상(vertex color)를 칠하셔도 됩니다. 그리고 정점쉐이더 입력데이터에서 COLOR0이나 COLOR1 시맨틱을 사용하시면 이 정점정보를 읽어올 수 있습니다. [본문으로]
  3. 정점마다 색을 지정해주셨다면 float3 mColor : COLOR0; 도 추가하셔야 합니다. Stream Mapping에서도 COLOR0을 더해주는 거 잊지 마세요. [본문으로]
  4. 마찬가지로 정점마다 색을 지정해주셨다면 float3 mColor: COLOR0;도 추가하셔야 합니다. [본문으로]
  5. 영어로 ceiling이 '천장'이라는 뜻이니까, ceil을 '천장으로 올리다' 정도로 생각하시면 이해에 도움이 되실 겁니다. 이 반대로 무조건 내림을 하는 함수로 floor()입니다. 이것은 '바닥으로 내리다' 정도로 이해하세요. [본문으로]
  6. 빛의 색상은 흰색(1, 1, 1)이라고 가정했습니다. 어떤 수에 1을 곱해도 결과는 바뀌지 않는 거 아시죠? [본문으로]
  7. 이에 대한 증명 및 자세한 설명은 이미 시중에 나와있는 훌륭한 수학책을 참고하시기 바랍니다. 참고로 순수하게 역행렬을 구하시려 한다면 D3DXMatrixInverse() 함수를 쓰시면 됩니다. [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License


픽셀쉐이더
픽셀쉐이더도 전체 소스코드를 보여드린 뒤, 새로 추가된 내용만 설명 드립니다.

struct PS_INPUT
{
   float2 mUV : TEXCOORD0;
   float3 mDiffuse : TEXCOORD1;
   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;
};

sampler2D DiffuseSampler;
sampler2D SpecularSampler;

float3 gLightColor;

float4 ps_main(PS_INPUT Input) : COLOR
{
   float4 albedo = tex2D(DiffuseSampler, Input.mUV);
   float3 diffuse = gLightColor * albedo.rgb * saturate(Input.mDiffuse);
   
   float3 reflection = normalize(Input.mReflection);
   float3 viewDir = normalize(Input.mViewDir); 
   float3 specular = 0;
   if ( diffuse.x > 0 )
   {
      specular = saturate(dot(reflection, -viewDir ));
      specular = pow(specular, 20.0f);
      
      float4 specularIntensity  = tex2D(SpecularSampler, Input.mUV);
      specular *= specularIntensity.rgb * gLightColor;
   }

   float3 ambient = float3(0.1f, 0.1f, 0.1f) * albedo;
   
   return float4(ambient + diffuse + specular, 1);
}


우선 새로 렌더몽키에 추가했던 세 변수를 전역적으로 선언하겠습니다.

sampler2D DiffuseSampler;
sampler2D SpecularSampler;

float3 gLightColor;


그리고 PS_INPUT구조체에 UV좌표를 추가합니다.

  float2 mUV : TEXCOORD0;


이제 픽셀쉐이더 함수의 젤 윗줄에서 디퓨즈맵을 샘플링 해보죠. 

   float4 albedo = tex2D(DiffuseSampler, Input.mUV);


이것이 바로 현재 픽셀이 반사하는 색깔입니다. 여기에 난반사광의 양과 빛의 색상을 곱해야 한다고 했죠? 이전에 diffuse변수를 구하던 코드를 다음과 같이 바꿉니다.

   float3 diffuse = gLightColor * albedo.rgb * saturate(Input.mDiffuse);


이제 한번 F5를 눌러서 정점쉐이더와 픽셀쉐이더를 각각 컴파일 한 뒤, 미리 보기 창을 한 번 봐볼까요?

그림 5.3. 디퓨즈맵만 적용한 결과

 
담벽 텍스처가 보이죠? 푸르스름한 빛도 보이네요. 근데 스페큘러맵을 아직 쓰지 않아서인지 정 반사광이 너무 강하네요. 돌 사이의 틈새까지도 말이지요! 그럼 스페큘러맵을 더해보도록 하죠.

팁: 미리 보기 창에서 물체를 회전하는 법
그림 5.3 처럼 틈새에 빛이 들어오게 하려면 물체를 회전하셔야 할 겁니다. 미리 보기 창에서 물체를 회전하시려면 창 안에 왼쪽 마우스 버튼을 누른 채 마우스를 이리저리 움직여보세요. 만약 회전 대신에 이동이나 확대/축소가 된다면 툴바에서 오른쪽 두 번째 아이콘(Overloaded Camera Mode)을 눌러주시면 됩니다.



픽셀쉐이더에서 specular 변수의 거듭제곱을 구하는 코드(pow함수 호출) 바로 밑에서 스페큘러맵을 샘플링 합니다.

      float4 specularIntensity  = tex2D(SpecularSampler, Input.mUV);


이제 이것과 빛의 색상을 specular에 곱해야겠죠? 코드는 다음과 같습니다.

      float4 specularIntensity  = tex2D(SpecularSampler, Input.mUV);
      specular *= specularIntensity.rgb * gLightColor;


위 그림에서 또 다른 문제점 하나는 난 반사광이 사라지는 순간부터 디퓨즈 텍스처의 디테일도 사라진다는 것입니다. 이것은 주변광 값으로 (0.1, 0.1, 0.1)만을 사용했기 때문인데요. 주변광은 그냥 저희가 임의로 정한 빛의 양이므로 여기에도 디퓨즈맵을 곱해주는 것이 맞습니다. ambient변수를 구하는 코드를 찾아 다음과 같이 바꿔주세요.

float3 ambient = float3(0.1f, 0.1f, 0.1f) * albedo;


다시 한번 정점쉐이더와 픽셀쉐이더를 컴파일 한 뒤 미리 보기 창을 볼까요?

그림 5.4. 스페큘러맵과 주변광까지 제대로 적용한 결과

 
확실한 차이를 볼 수 있죠? 정 반사광이 그렇게 강하지도 않고, 틈새도 완벽하네요. 거기다가 어두운 픽셀에서도 디퓨즈맵의 흔적을 찾아 볼 수 있네요.


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

우선 저번 장에서 사용했던 프레임워크의 사본을 만들어 새로운 폴더에 저장합니다. 그 다음, 렌더몽키에서 사용했던 쉐이더와 3D 모델을 DirectX 프레임워크에서 사용할 수 있도록 파일로 저장합니다. Sphere.x와 SpecularMapping.fx라는 파일이름을 사용하도록 하겠습니다. 또한 렌더몽키에서 사용했었던 텍스처 2개를 복사해다가 프레임워크 폴더에 저장합니다. Fieldstone_DM.tga와 Fieldstone_SM.tga로 이름을 사용하지요.

이제 비주얼 C++ 솔루션 파일을 연 뒤, 소스코드에서 gpLightingShader 변수가 언급된 곳을 모두 찾아gpSpecularMappingShader로 변경합니다.

그 다음, 전역변수 섹션에 가서 두 텍스처를 저장할 포인터들과 빛의 색상을 저장하는 변수를 선언합니다.

// 텍스처
LPDIRECT3DTEXTURE9        gpStoneDM    = NULL;
LPDIRECT3DTEXTURE9        gpStoneSM    = NULL;

// 빛의 색상
D3DXVECTOR4               gLightColor(0.7f, 0.7f, 1.0f, 1.0f);


렌더몽키에서 사용했던 푸르스름한 빛의 값인 (0.7, 0.7, 1.0)을 그대로 사용하는 거 보이시죠? 위에서 새로운 D3D 자원(텍스처)을 둘 선언했으니 이들을 해제하는 코드를 추가하도록 하죠. CleanUp() 함수에 다음의 코드를 추가합니다.

    // 텍스처를 release 한다.
    if ( gpStoneDM )
    {
        gpStoneDM->Release();
        gpStoneDM = NULL;
    }

    if ( gpStoneSM )
    {
        gpStoneSM->Release();
        gpStoneSM = NULL;
    }


이제 D3D자원들을 로딩할 차례입니다. LoadAssets() 함수에 다음의 코드를 더합니다.

    // 텍스처 로딩
    gpStoneDM = LoadTexture("Fieldstone_DM.tga");
    if ( !gpStoneDM )
    {
        return false;
    }

    gpStoneSM = LoadTexture("Fieldstone_SM.tga");
    if ( !gpStoneSM )
    {
        return false;
    }


쉐이더 파일의 이름을 SpecularMapping.fx로 바꿔주는 것도 잊지 맙시다.

    gpSpecularMappingShader = LoadShader("SpecularMapping.fx");


이제 마지막으로 RenderScene() 함수를 봅시다. 이미 쉐이더가 모든 일을 하고 있으니 간단히 새로운 변수들을 대입해주기만 하면 되겠네요. 예전에 SetMatrix() 함수들을 호출 해주던 곳이 있었죠? 그 아래에 다음의 코드를 추가합시다.

    gpSpecularMappingShader->SetVector("gLightColor", &gLightColor);
    gpSpecularMappingShader->SetTexture("DiffuseMap_Tex", gpStoneDM);
    gpSpecularMappingShader->SetTexture("SpecularMap_Tex", gpStoneSM);


위 코드는 빛의 색상과 두 텍스처맵을 쉐이더에 전달해 줍니다. 텍스처맵을 대입해 줄 때 _Tex 접미사를 붙여줘야 한다는 건 '제3장: 텍스처매핑'에서 설명했었죠?

이제 코드를 컴파일 한 뒤, 프로그램을 실행하면 렌더몽키에서와 동일한 결과를 보실 수 있을 겁니다.

이쯤 되면 느끼셨겠지만 쉐이더를 사용하면 DirectX 프레임워크에서 책임져야 할 그래픽 관련 업무가 현저히 줄어듭니다. D3D 자원을 로딩하는 것과 쉐이더 매개변수, 그리고 렌더상태(render state)를 관리하는 것이 거의 전부입니다.

정리
다음은 이 장에서 배운 내용을 짧게 요약해 놓은 것입니다.
  • 인간이 물체의 색을 볼 수 있는 이유는 물체마다 흡수 및 반사하는 빛의 스펙트럼이 다르기 때문이다.
  • 3D 그래픽에서는 디퓨즈맵과 스페큘러맵을 사용하여 빛의 흡수 및 반사를 재현한다.
  • 스페큘러맵은 각 픽셀의 정 반사광을 조절하는 것을 주 목적으로 한다.
  • 빛의 색도 물체의 최종 색에 기여한다.
  • 텍스처는 색상정보만을 저장하는 것이 아니다. 스페큘러맵의 경우가 그 예이다.

이 장은 예상보다 매우 쉽게 끝나버렸습니다. 저번 장에서 작성했던 조명쉐이더에 살을 붙였기 때문인데요. 이렇게 기본이 되는 쉐이더를 잘 짜놓으면 매우 쉽게 구현할 수 있는 기법들이 많습니다.

하지만 이번 장에서 배운 내용을 절대 가볍게 보시지 말기 바랍니다. 최근 게임에서 3D 물체를 렌더링 할 때 여기서 배웠던 기법을 법선매핑[각주:1]과 혼합하는 것이 거의 표준입니다.



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


포프 올림
  1. 법선매핑(normal mapping)은 제7장에서 배웁니다. [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License
샘플파일 받기:




제5장 물체에 색을 입히는 디퓨즈/스페큘러 매핑

실 세계에서 물체들이 다른 색을 갖는 이유는 물체마다 흡수/반사하는 빛의 스펙트럼(spectrum)이 다르기 때문입니다. 예를 들면 검정색 표면은 모든 스펙트럼을 흡수해서 검정색이고, 하얀색 표면은 모든 스펙트럼을 반사해서 하얀색입니다. 빨간색 표면 같은 경우는 빨강색의 스펙트럼을 반사하고 그 외의 스펙트럼을 흡수하니 빨간색으로 보이는 것이죠.

그렇다면 표면이 빛을 흡수하는 성질을 쉐이더에서 어떻게 표현할까요? 표면 전체가 한가지 색으로 칠해져 있다면 그냥 간단히 전역변수를 사용하면 되겠죠? 하지만 대부분의 경우, 물체의 표면은 다소 복잡한 패턴을 가지고 있습니다. 따라서 각 픽셀마다 색상을 정해줘야겠지요. 그럼 표면에서 반사할 색을 이미지로 그린 뒤, 픽셀쉐이더에서 이 텍스처를 읽어와 조명계산의 결과에 곱하면 되겠죠?

그런데 '제4장: 기초적인 조명쉐이더'에서 빛을 계산할 때, 난 반사광(diffuse light)와 정 반사광(specular light)을 따로 구했었죠? 그렇다면 난 반사광과 정 반사광을 합친 결과에 이 텍스처를 곱해야 할까요? 아니면 따로 해야 할까요? 전에도 말씀드렸듯이 인간이 물체를 지각할 수 있는 이유는 대부분 난 반사광 덕분입니다. (정 반사광은 타이트한 하이라이트를 추가해줄 뿐이지요.) 따라서 위 텍스처를 난 반사광의 결과에만 적용하는 것으로 충분합니다. 이렇게 난 반사광에 적용하는 텍스처를 디퓨즈맵(diffuse map)이라 부릅니다.

그렇다면 정 반사광은 어떻게 할까요? 물론 디퓨즈맵을 정 반사광에 그대로 사용할 수도 있습니다만 다음과 같은 두 가지 이유 때문에 정 반사광용으로 스페큘러맵(specular map)을 따로 만드는 경우가 허다합니다. 첫째, 난 반사광이 반사하는 빛과 정 반사광이 반사하는 빛의 스펙트럼이 다른 경우가 있습니다. 둘째, 각 픽셀이 반사하는 정 반사광의 정도를 조절하는 용도로 스페큘러맵을 사용할 수도 있습니다. 예를 들어, 사람의 얼굴에 정 반사광을 때린다고 생각해 보죠. '제4장: 기초적인 조명쉐이더'에서 봤던 것처럼 얼굴의 전체에서 고르게 정 반사광이 보일까요? 거울을 보시면 알겠지만 이마나 코가 더 반짝거리죠? 그리고 이마나 코에서도 정 반사광이 좀 듬성듬성 보일 겁니다. (이건 모공이나 털 때문에 피부가 매끄럽지 않기 때문입니다.) 스페큘러맵을 잘 칠하면 이런 효과를 낼 수 있습니다. 따라서 이 장에서는 디퓨즈맵과 스페큘러맵을 따로 사용하도록 하겠습니다.

이 외에도 물체의 색에 영향을 미치는 다른 요소가 있습니다. 바로 조명의 색입니다. 흰색 물체에 빨간색 빛을 비추면 물체가 불그스름하게 되죠? 조명의 색은 전역변수로 쉽게 지정할 수 있습니다.

여태까지 말씀 드린 것을 보기 쉽게 설명하면 다음과 같습니다.


난 반사광 = 빛의 색상 X 난 반사광의 양 X 디퓨즈맵의 값
정 반사광 = 빛의 색상 X 난 반사광의 양 X 스페큘러맵의 값


그러면 위 내용들을 염두에 두고 디퓨즈/스페큘러매핑 쉐이더를 작성해 볼까요?

기초설정
일단 저번 장에서 사용했던 렌더몽키 프로젝트의 사본을 만들어 새로운 폴더에 저장합니다. 혹시 렌더몽키 프로젝트를 저장해 놓지 않으신 분들은 부록 CD에서 samples\04_lighting\lighting.rfx 파일을 복사해 오시면 됩니다.

이 파일을 렌더몽키에서 연 다음, 쉐이더의 이름을 SpecularMapping으로 바꾸겠습니다. 이름까지 바꾸셨다면 이제 디퓨즈맵과 스페큘러맵으로 사용할 이미지들을 추가해야겠군요. 쉐이더 이름에 마우스 오른쪽 버튼을 누르면 나오는 팝업메뉴에서 Add Texture > Add 2D Texture > Fieldstone.tga파일을 선택합니다. 그러면 Fieldstone이라는 이름의 텍스처가 보이시죠? 이 이름을 DiffuseMap으로 바꾸도록 합시다. 

이제 Pass 0에 마우스 오른쪽 버튼을 눌러 Add Texture Object > DiffuseMap을 선택합니다. Texture0이라는 텍스처 개체가 생겼을 것입니다. 이 이름을 DiffuseSampler로 변경합니다.

이제 스페큘러맵을 추가해야 하는데 아무리 렌더몽키 폴더를 뒤져봐도 마땅한 놈이 안 보이는군요. 그래서 제 손으로 직접 스페큘러맵을 만들어서 부록 CD에 넣어놨습니다. 일단 이 스페큘러맵이 어떻게 생겼는지 한번 볼까요? 디퓨즈맵과 같이 비교해서 보면 좋겠군요

그림 5.1. 디퓨즈맵(왼쪽)과 스페큘러맵(오른쪽)


스페큘러맵에서 돌판 사이의 틈새를 검정색으로 칠해 놓은 거 보이시죠? 따라서 이 틈새는 전혀 정 반사광을 반사하지 않을 겁니다. (하지만 난 반사광은 여전히 존재하지요.) 여기서 한가지 기억하실 점은 텍스처가 언제나 색상정보를 가지지는 않는다는 것입니다. 스페큘러맵이 그 좋은 예죠. 스페큘러맵에 저장된 정보는 최종이미지의 색상 값이라기 보다는 각 픽셀이 반사하는 정 반사광의 양입니다. 이와 마찬가지로 픽셀 수준에서 제어하고 싶은 변수가 있다면 이렇게 텍스처를 사용하는 것이 보통입니다. 후에 법선매핑을 다룰 때, 이렇게 텍스처를 이용하는 예를 보실 수 있을 것입니다.

팁: 픽셀수준에서 제어하고 싶은 변수가 있다면 텍스처를 이용합니다.


자, 그럼 부록CD에서 Samples\05_DiffuseSpecularMapping\Fieldstone_SM을 찾아서 렌더몽키 프로젝트에 추가합니다. 추가하는 방법은 간단히 파일을 끌어다 이펙트 이름 위에 놓아주면 됩니다. 텍스처 이름은 SpecularMap으로, 텍스처 개체의 이름은 SpecularSampler로 하겠습니다.

다음은 빛의 색을 추가하도록 하지요. 쉐이더 이름에 오른쪽 버튼을 누른 뒤에 Add Variable > Float > Float3를 선택합니다. 변수명을 gLightColor로 하지요. 이제 이 변수를 더블클릭하면 값을 대입할 수 있습니다. 약간 푸르스름한 빛을 사용한다는 의미로 (0.7, 0.7, 1.0)을 대입하겠습니다.

마지막으로 Stream Mapping을 설정할 차례입니다. '제4장: 기초적인 조명쉐이더'와는 달리 여기서는 텍스처를 사용하니 정점데이터에서 UV 좌표를 읽어와야 합니다. Stream Mapping에 더블클릭을 해서 TEXCOORD0을 추가합니다. 당연히 데이터형은 float2입니다.

여기까지 설정을 마치셨다면 렌더몽키 작업공간이 아래와 같을 겁니다.

그림 5.2. 기초설정을 마친 렌더몽키 프로젝트

 
정점쉐이더
이제 정점쉐이더를 보도록 하지요. 우선 전체 소스코드부터 보여드린 뒤, 새로 추가된 코드만 설명 드리겠습니다.

float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;

float4 gWorldLightPosition;
float4 gWorldCameraPosition;

struct VS_INPUT 
{
   float4 mPosition : POSITION;
   float3 mNormal: NORMAL;
   float2 mUV: TEXCOORD0;
};

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float2 mUV: TEXCOORD0;
   float3 mDiffuse : TEXCOORD1;
   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;
};

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldMatrix );

   float3 lightDir = Output.mPosition.xyz - gWorldLightPosition.xyz;
   lightDir = normalize(lightDir);
   
   float3 viewDir = normalize(Output.mPosition.xyz - gWorldCameraPosition.xyz);
   Output.mViewDir = viewDir;
   
   Output.mPosition = mul( Output.mPosition, gViewMatrix );
   Output.mPosition = mul( Output.mPosition, gProjectionMatrix );
   
   float3 worldNormal = mul( Input.mNormal, (float3x3)gWorldMatrix );
   worldNormal = normalize(worldNormal);

   Output.mDiffuse = dot(-lightDir, worldNormal);
   Output.mReflection = reflect(lightDir, worldNormal);

   Output.mUV = Input.mUV;
   
   return Output;
}



새로 추가해야 할 전역변수가 있던가요? 새로 추가된 변수는 빛의 색상하고 2개의 텍스처 샘플러인데 텍스처 샘플러야 당연히 픽셀쉐이더에서 사용하는 거니까 여기선 선언하지 않아도 되겠네요. 빛의 색상도 픽셀쉐이더에서 그냥 곱하면 되겠는걸요?

그렇다면 정점쉐이더 입출력 구조체는 어떨까요? 새로 추가해야 할 게 하나 생겼죠? 픽셀쉐이더에서 텍스처매핑을 하려면 UV 좌표가 필요하니까요. 정점버퍼에서 UV 좌표를 가져와서 픽셀쉐이더에 전달해 줘야겠네요. 다음의 코드를 정점쉐이더의 입력구조체와 출력구조체 양쪽에 모두 추가합시다.

    float2 mUV: TEXCOORD0;


정점쉐이더 함수에 추가해야 할 코드도 딱 한 줄 뿐입니다. UV좌표를 전달해주는 것이죠.

   Output.mUV = Input.mUV;


정말 간단했죠? 이게 전부랍니다.



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


포프 올림

저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License


정 반사광

배경
정 반사광(specular light)[각주:1]은 난 반사광과는 달리 한 방향으로만 반사되는 빛으로 입사각이 출사각과 같은 것이 특징입니다. 따라서 정 반사광의 효과를 보려면 빛이 반사되는 방향에서 물체를 바라봐야만 합니다. 모니터에 빛이 반사 되서 화면을 보기 힘들었던 기억 있으시죠? 그 때 모니터를 조금 돌리면 조금 살만했던 거도요? 그게 바로 정 반사광입니다.

앞에서 보여 드렸던 난 반사광을 그림에 정 반사광도 추가해 보지요.

그림 4.8 난 반사광과 정 반사광




난 반사광과 마찬가지로 정 반사광을 수학적으로 재현해내는 수학공식이 여럿 있습니다. 여기서는 게임업계에서 널리 사용하는 기법인 퐁(phong) 모델을 사용하겠습니다. 퐁 모델은 반사광과 카메라벡터(카메라에서 현재 위치까지 선을 그은 벡터)가 이루는 각도의 코사인 값을 구하고, 그 결과를 여러번 거듭제곱하면 정 반사광을 구할 수 있다고 합니다. 아래의 그림을 보시죠.

그림 4.9. 정반사광의 예



반사광(R)과 카메라벡터(V)가 이루는 각도의 코사인 값을 구하는 것은 난 반사광에서 했던 것과 별반 차이가 없겠군요. 법선벡터와 입사광 벡터 대신에 반사광 벡터와 카메라벡터를 쓰는 것만 빼면요. 근데 왜 이 결과에 다시 거듭제곱을 할까요? 역시 코사인 그래프를 보면 답이 보입니다.

그림 4.10. 거듭제곱수가 늘어남에 따라 빠르게 줄어드는 코사인 그래프



위 그래프에서 보면 거듭제곱수가 늘어남에 따라 코사인 값이 빠르게 줄어드는 거 보이시죠? 실생활에서 정 반사광을 관찰해봅시다. 정반사광의 폭이 얼마나 되나요? 난 반사광에 비해 상당히 타이트하지 않나요? 바로 이런 타이트한 정 반사광을 재현하기 위해 코사인 값에 거듭제곱을 하는 겁니다.

그러면 거듭제곱은 몇 번이나 해야 할까요? 이건 사실 표면의 재질에 따라 다릅니다. 거친 표면일수록 정 반사광이 덜 타이트할 테니까 거듭제곱 수를 줄여줘야겠죠. 보통 한 20번 정도 거듭제곱을 해주면 대충 괜찮은 결과를 얻으실 수 있습니다.

그럼 이제 쉐이더를 작성해 봅시다.

기초설정
바로 조금 전에 작성했었던 난 반사광 쉐이더에 정 반사광 조명 코드를 추가하도록 하죠. 어차피 이 두 광이 합쳐져야 제대로 된 조명효과니까요.

그림 4.9에서 새로 추가된 것이 뭐가 있었죠? 반사광 벡터하고 카메라 벡터죠? 반사광 벡터야 입사광 벡터를 법선에 대해 반사시킨 것이니(입사각과 출사각이 같습니다) 이미 가지고 있는 정보에서 구할 수 있겠네요. 카메라 벡터는요? 입사광의 벡터를 구했던 것과 마찬가지 방법으로 카메라 위치에서 현재 위치까지 선을 쭈욱~ 그으면 되겠죠? 그러려면 카메라 위치를 전역변수로 만들어야 겠네요. 렌더몽키의 Lighting 쉐이더 위에 마우스 오른쪽 버턴을 눌러 새로운 float4 변수를 추가합시다. 이름은 gWorldCameraPosition이 적당하겠네요. 이제 이 변수 위에 마우스 오른쪽 버튼을 눌러 ViewPosition이라는 변수 시맨틱을 대입합니다.

이 외에 별다른 설정은 없는 것 같군요. 이제 정점쉐이더를 살펴봅시다.

정점쉐이더
마찬가지로 정점쉐이더의 전체 소스코드부터 보여드리겠습니다.

float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;

float4 gWorldLightPosition;
float4 gWorldCameraPosition;

struct VS_INPUT 
{
   float4 mPosition : POSITION;
   float3 mNormal: NORMAL;
};

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float3 mDiffuse : TEXCOORD1;
   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;
};

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldMatrix );

   float3 lightDir = Output.mPosition.xyz - gWorldLightPosition.xyz;
   lightDir = normalize(lightDir);
   
   float3 viewDir = normalize(Output.mPosition.xyz - gWorldCameraPosition.xyz);
   Output.mViewDir = viewDir;
   
   Output.mPosition = mul( Output.mPosition, gViewMatrix );
   Output.mPosition = mul( Output.mPosition, gProjectionMatrix );
   
   float3 worldNormal = mul( Input.mNormal, (float3x3)gWorldMatrix );
   worldNormal = normalize(worldNormal);

   Output.mDiffuse = dot(-lightDir, worldNormal);
   Output.mReflection = reflect(lightDir, worldNormal);

   return Output;
}


정점쉐이더 입력데이터 및 전역변수
일단 정점쉐이더 입력데이터를 보죠. 새로 필요한 정점정보가 있나요? 아무리 생각해도 별 다른 게 안 떠오르는 거 보니 없는 거 같네요. 난 반사광에 사용했던 입력구조체를 그냥 사용해도 될 거 같습니다.

그렇다면 전역변수는 어떻죠? 방금 전에 추가했던 gWorldCameraPosition을 선언해야겠죠? 다음의 코드를 추가합니다.

float4 gWorldCameraPosition;


정점쉐이더 출력데이터
이제 정점쉐이더 출력데이터를 살펴보도록 하죠. 난 반사광에서 그랬던 것처럼 정점쉐이더에서 정 반사광을 계산한 뒤에 픽셀쉐이더에 전달해 주면 될까요? 불행히도 그렇진 않습니다. 정 반사광을 구하려면 코사인 값에 거듭제곱을 해야 하는데 거듭제곱을 한 뒤 보간(interpolate)을 한 결과와 보간을 한 뒤에 거듭제곱을 한 결과의 차이는 엄청납니다. 따라서 정 반사광 계산은 픽셀 쉐이더에서 해야 하니 이 계산에 필요한 두 방향벡터인 R과  V를 구한 뒤에 픽셀쉐이더에 전달해 주도록 하겠습니다. 다음의 코드를 VS_OUTPUT에 추가합시다.

   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;


정점쉐이더 함수
이제 정 반사광을 계산하는데 필요한 두 방향벡터를 구해보죠. 카메라 벡터는 어떻게 구한다고 했었죠? 그냥 카메라 위치로부터 현재위치까지 선을 그으면 된다고 했죠? 입사광의 방향벡터를 구하는 것과 별 다를 바가 없겠네요. 입사광의 방향벡터를 구하는 코드 바로 아래에 다음의 코드를 추가합니다.

   float3 viewDir = normalize(Output.mPosition.xyz - gWorldCameraPosition.xyz);
   Output.mViewDir = viewDir;


이제 정 반사광의 방향벡터를 구할 차례입니다. 이 때, 빛의 입사각과 출사각이 같다고 말씀드렸었죠? 그럼 반사벡터를 구하는 수학 공식이 필요하겠군요. 근데 이런 공식은 굳이 기억하지 않으셔도 됩니다. (저도 수학책 다시 열어봐야 압니다. -_-) 여태까지 그랬던 것처럼 당연히 이런 것을 척척 처리해주는 HLSL 함수가 있겠죠? reflect()라는 함수입니다. reflect는 첫 번째 인자로 입사광의 방향벡터를 두 번째 인자로 반사 면의 법선을 받습니다. Output을 반환하기 바로 전에 다음의 코드를 입력합니다.

   Output.mReflection = reflect(lightDir, worldNormal);


자, 이제 두 벡터를 다 구해봤으니 정점쉐이더에서 할 일은 끝났습니다.

픽셀쉐이더
마찬가지로 픽셀쉐이더의 전체 코드부터 보여드립니다.

struct PS_INPUT
{
   float3 mDiffuse : TEXCOORD1;
   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;
};

float4 ps_main(PS_INPUT Input) : COLOR
{
   float3 diffuse = saturate(Input.mDiffuse);
   
   float3 reflection = normalize(Input.mReflection);
   float3 viewDir = normalize(Input.mViewDir); 
   float3 specular = 0;
   if ( diffuse.x > 0 )
   {
      specular = saturate(dot(reflection, -viewDir ));
      specular = pow(specular, 20.0f);
   }

   float3 ambient = float3(0.1f, 0.1f, 0.1f);
   
   return float4(ambient + diffuse + specular, 1);
}


우선 정점쉐이더 출력데이터에서 가져올 두 벡터를 PS_INPUT 구조체에 추가합니다.

   float3 mViewDir: TEXCOORD2;
   float3 mReflection: TEXCOORD3;


이전에 diffuse를 구했던 코드 바로 밑에 새로운 코드들을 추가하겠습니다. 우선 mReflection과 mViewDir을 다시 한번 정규화시켜 줍니다. 정점쉐이더에서 이미 단위벡터로 만들었던 이 벡터들을 다시 정규화해 주는 이유는 보간기를 거치는 동안 그 값이 흐트러질 수 있기 때문입니다.[각주:2]

   float3 reflection = normalize(Input.mReflection);
   float3 viewDir = normalize(Input.mViewDir);


이제 이 두 벡터의 내적을 구한 뒤, 거듭제곱을 합니다.

   float3 specular = 0;
   if ( diffuse.x > 0 )
   {
      specular = saturate(dot(reflection, -viewDir ));
      specular = pow(specular, 20.0f);
   }


위에서 난반사광의 양이 0% 이상일 때에만 정 반사광을 계산하는 거 보이시죠? 난 반사광이 존재하지 않는 표면에는 이미 빛이 닿지 않으므로 정 반사광이 존재할 수가 없기 때문입니다. 내적을 구할 때 -viewDir을 사용한 것도 보이시죠? 난 반사광을 구할 때와 마찬가지로 두 벡터의 밑동이 만나야 올바른 내적의 결과를 구할 수 있기 때문입니다.

또한 거듭제곱을 할 때 pow() 함수를 이용한 것도 눈 여겨 봐주시기 바랍니다. 여기서는 20번 거듭제곱을 했는데 각 물체마다 이 값을 다르게 하는 것이 보통입니다.[각주:3] 따라서 이 값을 float형의 전역변수로 선언해주는 게 보다 나은 방법이 되겠습니다. 이 정도는 독자 분들의 몫으로 남겨두도록 하지요.

이제 결과를 반환할 차례입니다. 일단 정 반사광의 효과만을 보기 위해 specular만을 반환해볼까요? 이전에 있던 return문을 다음과 같이 바꿉니다.

  return float4(specular, 1);


이제 쉐이더를 컴파일한 뒤 실행해보면 다음의 결과를 보실 수 있을 것입니다.

그림 4.11. 난 반사광에 비해 매우 강렬하고 타이트한 하이라이트를 보여주는 정 반사광



이제 정 반사광이 어떤 건지 확실히 보이시죠? 여기에 난 반사광을 더하면 보다 완벽한 조명효과가 되겠네요. return 코드를 다음과 같이 바꿔봅시다.

    return float4(diffuse + specular, 1);


위 코드에서 난 반사광과 정 반사광을 더하면 그 결과가 1이 넘는 경우가 있는데 크게 걱정하지 않으셔도 됩니다. 그런 경우엔 알아서 1이 됩니다.[각주:4]

이제 정점쉐이더와 픽셀쉐이더를 각각 컴파일 하신 뒤 미리 보기 창을 보면 다음과 같은 결과가 보이죠?

그림 4.12. 난 반사광 + 정 반사광




자, 이 정도면 훌륭한 조명효과입니다. 하지만 공의 왼쪽 밑부분이 칠흑같이 어두운 게 좀 망에 안 드는군요. 앞서 말씀 드렸다시피 실제세계에서는 간접광이 저 어두운 부분을 비춰줄 텐데 말이지요. 그럼 아주 간단하게 주변광을 정의해줘서 저 부분을 조금이나마 밝혀볼까요? 주변광을 10%로 선언해서 ambient 변수에 대입해주도록 합시다.

    float3 ambient = float3(0.1f, 0.1f, 0.1f);


그리고 최종 반환 값에 ambient를 추가합니다.

   return float4(ambient + diffuse + specular, 1);



이제 결과가 아래와 같이 바뀔 겁니다.

그림 4.13. 주변광 + 난 반사광 + 정 반사광




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

우선 '제3장: 텍스처매핑'에서 사용했던 프레임워크의 사본을 만들어 새로운 폴더에 저장합니다. 그 다음, 렌더몽키에서 사용했던 쉐이더와 3D 모델을 DirectX 프레임워크에서 사용할 수 있도록 파일로 저장합니다. Sphere.x와 Lighting.fx라는 파일이름을 사용하도록 하겠습니다.

이제 비주얼 C++에서 솔루션 파일을 엽니다.

자, 그럼 전역변수를 먼저 살펴보겠습니다. 일단 이 장에서는 텍스처를 사용하지 않으니 저번 장에서 선언했던 텍스처 변수, gpEarthDM를 지우겠습니다. 그 다음, 쉐이더 변수의 이름을 gpTextureMappingShader에서 gpLightingShader로 바꿉니다.

이제 새로운 변수들을 선언할 차례입니다. 광원의 위치와 카메라의 위치가 필요했었죠? 이 둘은 모두 월드공간 안에 있었네요. 렌더몽키에서 사용했던 빛의 위치를 다시 사용하겠습니다.

// 빛의 위치
D3DXVECTOR4                gWorldLightPosition(500.0f, 500.0f, -500.0f, 1.0f);


카메라 위치는 예전에 RenderScene() 함수 안에서 사용했던 값을 그대로 가져왔습니다.

// 카메라 위치
D3DXVECTOR4                gWorldCameraPosition( 0.0f, 0.0f, -200.0f, 1.0f );


이제 CleanUp() 함수로 가봅시다. 더 이상 gpEarthDM 텍스처를 사용하지 않으니 이를 해제하는 코드를 지웁니다.

다음은 LoadAssets() 함수 입니다. 우선 gpEarthDM 텍스처를 로딩하는 코드를 삭제합니다. 그리고 쉐이더의 파일명을 Lighting.fx로 바꿉니다. gpTextureMappingShader라는 변수명을gpLightingShader로 바꾸는 것도 잊지 마세요.

    // 텍스처 로딩

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


마지막으로 RenderScene() 함수를 보겠습니다. 일단gpTextureMappingShader 라는 변수명을 모두 찾아gpLightingShader로 바꿉니다. 이제 뷰행렬을 만드는 코드를 보죠. 뷰행렬을 만들 때 사용했던 vEyePt라는 변수가 있었죠? 이 변수의 값이 앞서 정의했던 gWorldCameraPosition의 값과 동일하니 gWolrldCameraPosition의 값을 사용하도록 하지요.

예전에 아래처럼 되어 있던 코드를
 

    D3DXVECTOR3 vEyePt( 0.0f, 0.0f, -200.0f );


다음과 같이 바꿉니다.

    D3DXVECTOR3 vEyePt( gWorldCameraPosition.x, gWorldCameraPosition.y, 
        gWorldCameraPosition.z ); 


이제gpLightingShader->SetTexture() 코드를 지웁니다. 이 장에서 만든 쉐이더에는 텍스처를 사용하지 않으니 이 코드가 필요 없습니다. 그럼 마지막으로 광원의 위치와 카메라의 위치를 쉐이더에 전달해 줍니다. 이들의 데이터형은 D3DXVECTOR4이므로 쉐이더에서 SetVector()를 호출합니다.

    gpLightingShader->SetVector("gWorldLightPosition", &gWorldLightPosition);
    gpLightingShader->SetVector("gWorldCameraPosition", &gWorldCameraPosition);


이제 코드를 컴파일 한 뒤 실행해보시죠. 아까 렌더몽키에서 보셨던 것과 동일한 결과를 볼 수 있죠?

기타 조명기법
여전히 대부분의 게임이 사용하는 조명기법은 람베르트 + 퐁이지만 최근 들어 다른 조명기법들을 사용하는 게임들이 늘어나고 있습니다. 조명기법을 좀 더 심층적으로 연구하고 싶으신 독자 분들을 위해 몇 가지 기법을 언급하겠습니다.

  • 블린-퐁(Blinn-Phong): 퐁과 거의 비슷한 기법. 현재도 많이 사용함
  • 오렌-네이어(Oren-Nayar): 표면의 거친 정도를 고려한 난 반사광 조명기법
  • 쿡-토런스(Cook-Torrance): 표면의 거친 정도를 고려한 정 반사광 조명기법
  • 구면조화 조명기법(spherical harmonics lighting): 오프라인에서 간접광을 사전 처리한 뒤, 실시간에서 이를 주변광으로 적용할 때 사용할 수 있음

정리
다음은 이 장에서 배운 내용을 짧게 요약해 놓은 것입니다.
  • 람베르트 모델은 난 반사광을 계산하는 기법으로 코사인 함수를 사용한다.
  • 퐁 모델은 정 반사광을 계산하는 기법으로 코사인 값을 거듭제곱 한다.
  • 벡터의 길이를 1로 바꾸면 내적을 구하는 것만으로도 코사인 함수를 대신할 수 있다.
  • 동일한 계산을 어느 쪽에서도 할 수 있다면 픽셀쉐이더 보다는 정점쉐이더에서 한다.
  • 이 장에서 배운 조명보다 훨씬 사실적이고 복잡한 기법들이 존재한다. 그 중 일부는 이미 몇몇 게임에서 쓰이고 있다.


이제 조명기법까지 마쳤으니 쉐이더의 기초는 다 배운 거나 다름없습니다. 다음 장부터는 여태까지 배웠던 지식들을 잘 혼합하여 보다 실용적인 기법들을 구현해 보겠습니다. 제1~4장 중에 잘 이해가 안 되는 내용이 있었다면 다시 한 번 복습을 하신 뒤에 제5장으로 넘어오시기 바랍니다.


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


포프 올림
  1. 이것을 반사광이라 부르기도 합니다. [본문으로]
  2. 보간기가 선형적(linear)으로 보간을 해서 그렇습니다. [본문으로]
  3. 거듭제곱의 수가 높을 수록 정반사광의 범위가 타이트해집니다. 숫자를 바꿔보면서 실험해보세요. [본문으로]
  4. 현재 하드웨어 백버퍼의 포맷이 8비트 이미지이기 때문입니다. 부동소수점 텍스처를 사용하면 1 이상의 값을 저장할 수도 있습니다. [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License
기초설정
  1. 여태까지 해왔던 것과 마찬가지로 렌더몽키 안에서 새로운 DirectX 이펙트를 만든 뒤, 정점쉐이더와 픽셀쉐이더의 코드를 삭제합니다.
  2. 이제 쉐이더의 이름을 Lighting으로 바꾸도록 합시다.
  3. 정점의 위치를 변환할 때, 필요한 gWorldMatrix, gViewMatrix, gProjectionMatrix를 추가하고 변수 시맨틱에 연결해주는 것도 잊지 마세요.


람베르트 모델을 이용해서 난 반사광을 계산하려면 어떤 정보가 필요했었죠? 입사광의 벡터와 표면법선 벡터입니다. 법선 정보는 각 정점에 저장되어 있는 것이 보통입니다.[각주:1] 따라서 정점버퍼로부터 이 정보를 가져와야 합니다. 저번 장에서 정점버퍼에서 UV좌표를 불러오기 위해 별도로 해줬던 설정이 있었죠? 렌더몽키의 작업공간 패널에서 Stream Mapping을 더블클릭 한 뒤, NORMAL이란 새로운 필드를 추가합니다. 법선은 3차원 공간에 존재하는 벡터이니 FLOAT3로 선언해주겠습니다. Attribute Name은 크게 신경 쓰지 않으셔도 되지만 Index를 0으로 해주는 것은 잊지 마세요.

그렇다면 입사광의 벡터는 어떻게 구할까요? 사실 이거 별거 아닙니다. 그냥 광원의 위치에서 현재 픽셀 위치까지 직선을 하나 그으면 그게 입사광의 벡터입니다. 따라서 광원의 위치만 알면 입사광의 벡터는 쉽게 구할 수 있습니다. 그렇다면 광원의 위치는 어떻게 정의할까요? 그냥 '월드에서 (500, 500, -500)에 있는 광원' 정도로 하면 되겠지요? 따라서 광원은 전역변수가 됩니다. 렌더몽키의 작업공간에서 Lighting 위에 마우스 오른쪽 버튼을 누른 뒤, Add Variable > Float > Float4를 선택합니다. 새로 생긴 변수의 이름을 gWorldLightPosition이라 바꾼 뒤, 변수 이름을 더블클릭하여 광원의 위치를 (500, 500, -500, 1)으로 설정합니다.

이 모든 설정을 마쳤으면 작업공간이 다음 그림과 같을 것입니다.

그림 4.6. 기초설정을 마친 렌더몽키 프로젝트



정점쉐이더
일단 전체 소스코드부터 보여드린 뒤, 한 줄씩 차근차근 설명해드리겠습니다.

struct VS_INPUT
{
    float4 mPosition : POSITION;
    float3 mNormal : NORMAL;
};

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float3 mDiffuse : TEXCOORD1;
};

float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;

float4 gWorldLightPosition;

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldMatrix );


   Output.mPosition = mul( Output.mPosition, gViewMatrix );
   Output.mPosition = mul( Output.mPosition, gProjectionMatrix );
   float3 lightDir = Output.mPosition.xyz - gWorldLightPosition.xyz;

   lightDir = normalize(lightDir);

   float3 worldNormal = mul( Input.mNormal, (float3x3)gWorldMatrix );
   worldNormal = normalize( worldNormal );

   Output.mDiffuse = dot(-lightDir, worldNormal);

   return Output;
}



정점쉐이더 입력데이터
우선 '제2장: 진짜 쉬운 빨강쉐이더'에서 사용했던 입력데이터의 구조체를 가져와 보도록 하지요.

struct VS_INPUT
{
    float4 mPosition : POSITION;
};



자, 이제 여기에 법선을 더해야겠죠? 정점버퍼에서 법선을 가리키는 시맨틱은 NORMAL입니다. 법선은 3차원 공간에서 방향을 나타내는 벡터이니 float3가 되겠군요.

struct VS_INPUT
{
    float4 mPosition : POSITION;
    float3 mNormal : NORMAL;
};


정점쉐이더 함수
이번 장에서는 정점쉐이더 함수를 먼저 작성한 뒤, 정점쉐이더 출력데이터와 전역변수를 살펴보겠습니다. 아무래도 함수부터 살펴보는 것이 이해가 더 잘 될 겁니다.
 
우선 정점위치를 변환하는 코드부터 보겠습니다.

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldMatrix );


   Output.mPosition = mul( Output.mPosition, gViewMatrix );
   Output.mPosition = mul( Output.mPosition, gProjectionMatrix );


위 코드는 더 이상 설명하지 않아도 잘 아시리라 믿습니다. 그렇다면 이 이외에 정점쉐이더에서 따로 계산할 것들이 뭐가 있을까요? 난 반사광을 계산하려면 입사광의 벡터와 법선의 내적을 구해야 하는데 과연 이런 일을 정점쉐이더 안에서 해야 할까요? 아니면 픽셀쉐이더 안에서 해야 할까요? 잠시 짬을 내어서 생각해 보세요.

...

자, 뭐가 정답이라고 생각하세요? 사실 정답은 없습니다. 어느 쪽에서도 이 계산을 할 수 있거든요. 정점쉐이더에서 이 계산을 한다면 정점마다 두 벡터의 내적을 구한 뒤, 그 결과를 VS_OUPUT의 일부로 반환할 것입니다. 그러면 보간기를 통해 보간된 값들이 픽셀쉐이더에 전달될 것이니 픽셀쉐이더는 그냥 그 값을 가져다가 사용하면 됩니다.

이와 반대로 픽셀쉐이더에서 이 계산을 한다면 정점쉐이더가 법선정보를 VS_OUTPUT의 일부로 반환할 것이고, 픽셀쉐이더는 보간된 법선을 읽어와 입사광의 벡터와 내적을 구하겠지요.

어느 쪽에서 계산을 해도 차이가 없다면[각주:2] 당연히 성능상 유리한 쪽을 택해야겠죠? 어느 쪽에서 계산을 해야 더 빠른지는 각 함수가 호출되는 횟수를 따져보면 알 수 있습니다.[각주:3] 삼각형을 하나 그려보시죠. 이 삼각형을 그릴 때, 정점쉐이더가 몇 번 실행될까요? 삼각형을 이루는 정점의 수가 셋이니 3번 실행됩니다. 그렇다면 픽셀쉐이더는 몇 번 실행될까요? 삼각형이 화면에서 차지하는 픽셀 수만큼입니다. 물론 삼각형이 매우 작아서 화면에서 픽셀 하나 크기 밖에 안 된다면 픽셀쉐이더는 한번만 실행됩니다. 하지만, 보통 삼각형이 차지하는 픽셀 수가 3개는 넘겠지요? 따라서 동일한 계산이라면 픽셀쉐이더 보단 정점쉐이더에서 하는 것이 낫습니다. 그럼, 난 반사광의 계산도 정점쉐이더에서 하겠습니다.

 - 동일한 계산을 어느 쪽에서도 할 수 있다면 픽셀쉐이더 보단 정점쉐이더에서 하는 것이 성능 상 유리합니다.


그럼 먼저 입사광 벡터를 만들어 보도록 하지요. 입사광의 벡터는 광원의 위치에서 현재 위치까지 선을 쭈욱~ 그으면 된다고 했죠? 이렇게 선을 쭉 긋는 것을 벡터의 뺄셈이라 합니다. 즉, 현재 위치에서 광원의 위치를 빼면 입사광의 벡터를 구할 수 있습니다. 하지만, 한가지 주의해야 할 점이 있습니다. 3D 수학에서 올바른 결과를 얻으려면 모든 변수의 공간이 일치해야 합니다. 앞서 광원의 위치를 이미 월드공간에서 정의했었죠? 그렇다면 정점의 위치는 어느 공간에 있을까요? Input.mPosition은 지역공간에, Output.mPosition은 투영공간에 있네요. 저희가 필요한 것은 월드공간인데 말이지요. 아까 보여드렸던 정점쉐이더의 코드를 다시 한 번 살펴볼까요? 월드행렬을 곱한 다음에 빈 칸을 좀 남겨둔 것이 보이시죠? 월드행렬을 곱한 직후의 Output.mPosition이 바로 월드공간에서의 위치이니 이것에서 광원의 위치를 빼면 되겠네요. 그럼 월드행렬을 곱하는 코드 바로 밑에 다음의 코드를 추가하죠.

   float3 lightDir = Output.mPosition.xyz - gWorldLightPosition.xyz;


이제 이 벡터의 길이를 1로 만듭시다. 벡터의 길이가 1이면 내적만으로도 코사인 값을 구할 수 있다고 했죠? 이렇게 벡터의 길이를 1로 만드는 과정을 정규화(normalize)라고 한다는 것도 말씀드렸던가요? 수학적으로 단위 벡터를 만들려면 각 성분을 벡터의 길이로 나누면 됩니다. 하지만 그 대신 HLSL에서 제공하는 정규화 함수, normalize()를 사용하도록 하지요.

   lightDir = normalize(lightDir);


이제 입사광의 벡터가 준비되었으니 법선을 가져올 차례지요? 정점쉐이더 입력데이터에 있는 법선을 그냥 사용하면 될까요? 이 법선은 어느 공간 안에 있죠? 정점버퍼에서 곧바로 오는 데이터니까 당연히 물체공간이겠죠? 그렇다면 이 법선을 월드공간으로 변환해 줘야만 제대로 난 반사광을 구할 수 있겠네요. 

주의 - 3D 연산을 할 때는 모든 변수들이 존재하는 공간이 일치해야 합니다.


   float3 worldNormal = mul( Input.mNormal, (float3x3)gWorldMatrix );


이 위의 코드에서 Input.mNormal이 float3형이니 월드행렬을 그에 맞게 3 X 3 행렬로 바꾼 거 보이시나요? (float3x3)를 앞에 붙이는 방법으로 캐스팅을 했네요. 4 X 4행렬에서 4번째 행(또는 열)은 평행이동(translation) 값이므로 방향벡터에 아무런 영향도 미치지 않습니다.[각주:4]

이 벡터를 단위벡터로 만드는 것도 잊지 마세요.

   worldNormal = normalize( worldNormal );


이제 입사광의 벡터와 법선이 모두 준비되었으니 내적을 구할 차례입니다. 내적의 공식이 어떻게 되었었죠? 사실 별로 어려운 공식은 아니었는데 굳이 기억하자니 귀찮군요. 그 대신 HLSL자체에서 제공하는 내적함수, dot()을 사용하겠습니다.

   Output.mDiffuse = dot(-lightDir, worldNormal);


위 코드를 보니 내적을 구한 결과를 mDiffuse라는 출력변수에 대입해줬군요. 근데 위에서 lightDir 대신 -lightDir을 쓴 거 보이시죠? 이렇게 한 이유는 두 벡터의 내적을 구할 때, 화살표의 밑동이 서로 만나야 하기 때문입니다. lightDir을 쓰면 입사광 벡터의 머리가 법선의 밑동과 만나므로 잘못된 결과를 발생시킵니다.

또한 내적의 결과는 실수 하나인데 float3인 mDiffuse에 곧바로 대입해준 거 보이시죠? 이렇게 하면 float3의 세 성분이 모두 이 실수 값으로 채워집니다. dot(-lightDir, worldNormal).xxx을 대입해주는 것과 동일하지요.
 
이제 간단히 결과를 반환해 줍시다.

   return Output;
}


전역변수
이제 왜 쉐이더함수를 먼저 살펴봤는지 아시겠나요? 아무 설명 없이 '빛의 위치를 전역변수로 선언하겠습니다.'라고 말씀드리기가 뭐해서 였습니다.

다음의 전역변수들을 소스 코드 제일 위에 추가해주세요.

float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;

float4 gWorldLightPosition;


정점쉐이더 출력데이터
정점쉐이더 함수를 짜보면서 이미 살펴봤듯이 출력데이터는 mPosition과 mDiffuse입니다. 위치야 float4형에 POSITION 시맨틱을 쓰는 걸 이미 알고 있는데, mDiffuse에는 어떤 형과 시맨틱을 써야 할까요? 두 벡터의 내적을 구하면 그 결과는 벡터가 아닌 숫자 하나입니다.[각주:5] 따라서 float만 사용해도 사실 큰 문제는 아니지만 나중에 이 값을 픽셀의 RGB값으로 출력할 것이니 그냥 float3를 사용하겠습니다. 그렇다면 시맨틱은 어떻게 할까요? DIFFUSELIGHTING이라는 시맨틱이 존재할까요? 불행히도 그렇지 않습니다.[각주:6] 쉐이더 프로그래밍을 하다 보면 용도에 딱 맞는 시맨틱이 없는 경우가 종종 있는데, 그럴 때는 그냥 TEXCOORD 시맨틱을 사용하는 게 보통입니다. 최소한 8개[각주:7]의 TEXCOORD가 존재하니까 별로 모자라는 경우가 없거든요. 여기서는 TEXCOORD1을 사용하겠습니다.[각주:8]

다음의 출력데이터를 소스 코드 제일 위에 추가해주세요.

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
   float3 mDiffuse : TEXCOORD1;
};


픽셀쉐이더
막상 픽셀쉐이더를 짜려고 하니까 뭔가 허무한데요? 정점쉐이더가 난 반사광까지도 계산해 줬으니 픽셀쉐이더가 할 일이라곤 그냥 그 값을 가져다가 출력하는 정도겠네요. 그런데 내적은 결국 코사인 함수니까 -1~1의 결과 값을 가지겠죠? 난반사광의 범위는 0~1이니까 -1이하인 값을 0으로 바꾸도록 하죠. 물론 if문을 사용할 수도 있지만 그보다 훨씬 빠른 HLSL함수를 사용하도록 하죠. saturate()라는 함수는 0 이하의 값을 0으로, 1 이상의 값을 1로 바꿔줍니다. 그리고 이 함수는 성능에 아무 영향을 미치지 않는 공짜 함수입니다.

struct PS_INPUT
{
   float3 mDiffuse : TEXCOORD1;
};

float4 ps_main(PS_INPUT Input) : COLOR
{
   float3 diffuse = saturate(Input.mDiffuse);
   return float4(diffuse, 1); 
}


위 코드에서 float4(diffuse, 1)이란 형식으로 float4 변수를 만든 것 보이시나요? float4 변수를 만드는 생성자 정도로 생각하시면 되겠네요.

이제 F5를 눌러 정점쉐이더와 픽셀쉐이더를 각각 컴파일 한 뒤, 미리 보기 창을 보시면 부드러운 난 반사광 효과를 볼 수 있으실 겁니다.


그림 4.7 난 반사광 효과




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


포프 올림
 

  1. 언제나 그렇지는 않습니다. 나중에 법선매핑(normal mapping)을 배울 때 법선을 구하는 다른 방법을 알아봅니다. [본문으로]
  2. 사실 정점쉐이더에서 구한 결과와 픽셀쉐이더에서 구한 결과 사이에는 약간의 차이가 있습니다. 하지만 거의 눈에 띄지 않을 정도입니다. [본문으로]
  3. 이 외에도 성능을 저해하는 여러 가지 요인이 있으니 그냥 가이드라인으로만 생각하세요. [본문으로]
  4. 동일한 방향을 가리키는 화살표 2개를 다른 위치에 놓는다고 해서 그 방위가 바뀌지 않지요? 따라서 방향벡터에서 평행이동 값은 아무 의미도 없습니다. [본문으로]
  5. 이것을 스케일러(scalar)라고 합니다. 보통은 스칼라라고 많이 하시는데 스케일러가 맞는 발음입니다. [본문으로]
  6. 여기서 COLOR0 시맨틱을 사용하지 않은 이유는 정점쉐이더 2.0 규격에서 COLOR 시맨틱을 사용한면 변수의 값이 0~1 사이로 클램프 되기 때문입니다. 따라서 보간기를 거쳐 펙셀쉐이더에서 이 값을 넘겨받으면 좀 오차가 보이더군요. [본문으로]
  7. TEXCOORD0 ~ TEXCOORD7 [본문으로]
  8. TEXCOORD0 대신 TEXCOORD1을 사용한 이유는 다음 장에서 TEXCOORD0을 텍스처의 UV 좌표로 쓰기 위해서입니다. [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License
샘플파일
 

제4장 기초적인 조명쉐이더

이 장에서 새로 배우는 HLSL
  • NORMAL: 정점의 법선정보를 불러올 때 사용하는 시맨틱
  • normalize(): 벡터 정규화 함수
  • dot(): 내적 함수
  • saturate(): 0 ~ 1을 넘어서는 값의 범위를 짤라 냄.
  • reflect(): 벡터반사 함수
  • pow(): 거듭제곱 함수



이 장에서 새로 사용하는 수학
  • 내적: 코사인 함수를 재빨리 계산할 때 사용할 수 있음
  • 정규화: 벡터를 단위벡터(길이가 1인 벡터)로 만듬.




빛이 존재하지 않는다면 물체를 볼 수 없습니다. 매우 당연한 이치인데도 이걸 까먹고 지내는 분들이 많은 것 같습니다. (저도 종종 까먹습니다.) 예를 들어, 창문이 하나도 없는 방에 들어가서 문을 닫아버리면 아무것도 볼 수가 없지요? 어디서 새어 들어오는 빛이 있지 않는 한 아무리 어둠 속에서 오래 있어도 아무것도 보이지 않습니다. 이 당연한 사실을 자꾸 까먹는 이유는 실생활에서 완전히 칠흑 같은 어둠을 찾기가 쉽지 않기 때문입니다. 왜일까요? 바로 끝없이 반사하는 빛의 성질 때문입니다. 딱히 눈에 뜨이는 광원이 없더라도 대기중의 미세입자에 반사되어 들어오는 빛까지 있으니까요. 이렇게 다른 물체에 반사돼서 들어오는 빛을 간접광이라 합니다. 반대로 직접광은 광원으로부터 직접 받는 빛입니다. 그림 4.1에 예를 들어보겠습니다.

그림 4.1 직접광과 간접광의 예




직접광과 간접광 중에서 어떤 것을 더 쉽게 계산할 수 있을까요? 위 그림만 봐도 딱 답이 나오죠? 직접광입니다. 간접광은 수없이 반사의 반사를 거치므로 당연히 직접광보다 계산하기 어렵습니다. 간접광을 계산하는 방법 중 하나로 광선추적(ray-tracing)이라는 기법이 있습니다. 아마 3D 그래픽에 관심이 많으신 분들이라면 최근 들어 광선추적에 대해 논하는 많은 자료를 보셨을 겁니다. 하지만, 아직도 실시간 광선추적기법이 게임에서 널리 사용되지 않는 이유는 하드웨어 사양이 따라주지 않기 때문이라죠.[각주:1] 그렇기 때문에 아직도 컴퓨터 게임 등을 비롯한 실시간 3D 프로그램에서는 주로 직접광만을 제대로 계산하고 간접광은 흉내내기 정도로 그치는 게 보통입니다. 따라서 이 장에서도 직접광만을 다루도록 하겠습니다.[각주:2] 참고로 이 장에서 배우는 조명 쉐이더는 아직까지도 대부분의 게임에서 표준으로 사용하는 기법이므로 잘 숙지해 두세요.

빛을 구성하는 요소는 크게 난 반사광(diffuse light)과 정 반사광(specular light)이 있습니다. 이 둘을 따로 살펴보도록 하겠습니다.

난 반사광
배경
대부분의 물체는 스스로 빛을 발하지 않습니다. 그럼에도 저희가 이 물체들을 지각할 수 있는 이유는 다른 물체(예, 태양)가 발산하는 빛이 이 물체의 표면에서 반사되기 때문입니다. 이 때, 여러 방향으로 고르게 반사되는 빛이 있는데 이것을 난 반사광(diffuse light)[각주:3]이라고 합니다. 어느 방향에서 바라봐도 물체의 명암이나 색조가 크게 변하지 않는 이유를 아시나요? 여러 방향으로 고르게 퍼지는 난 반사광 덕분입니다. 만약 빛이 한 방향으로만 반사된다면[각주:4] 그 방향에서만 물체를 지각할 수 있겠지요.

참고로 물체의 표면이 거칠수록 난반사가 심해지는 것이 보통입니다.[각주:5]

일단 난 반사광을 그림으로 그려 보겠습니다.

그림 4.2. 난 반사광



그림 4.2에서 아직 보여 드리지 않은 것이 조금 후에 배워 볼 정 반사광입니다. 정 반사광이 무엇인지는 나중에 알려 드릴 테니 일단은 입사광 중의 일부는 난 반사광이 되고 다른 일부는 정 반사광이 된다고만 기억해 두세요.

자, 그렇다면 수학적으로 난 반사광을 어떻게 계산할까요? 당연히 수학자마다 다른 주장을 하지만 그 중에서 게임에서 주로 사용하는 람베르트(lambert) 모델을 살펴봅시다. 요한 람베르트라는 수학자가 창시한 람베르트 모델은 표면법선[각주:6]과 입사광이 이루는 각의 코사인 값을 구하면 그게 바로 난 반사광의 양이라고 합니다. 그렇다면 일단 코사인 함수의 그래프를 볼까요?

그림 4.3. y = cos(x) 그래프




위 그래프를 보시면 입사광과 표면 법선의 각도가 0일 때, 결과(y축의 값)가 1인 거 보이시죠? 그리고 각도가 늘어날수록 결과가 점점 작아지다가 90도가 되니 0이 돼버립니다. 여기서 더 나아가면 그 후로는 아예 음수 값이 돼버리네요? 그러면 실제 세계에서 빛의 각도에 따라 결과가 어떻게 바뀌는지 살펴 볼까요?

그림 4.4. 입사광과 법선이 이루는 다양한 각도



위의 그림에서 평면이 가장 밝게 빛나는 때가 언제일까요? 당연히 해가 중천에 떠있을 때겠죠? (그림 a) 그리고 해가 저물어감에 따라 점점 표면도 어두워지겠네요. (그림 b) 이제 해가 지평선을 넘어가는 순간, 표면도 깜깜해집니다. (그림 c) 그렇다면 해가 지고 난 뒤엔 어떻게 되죠? 여전히 표면이 깜깜하겠죠? 표면에 전혀 빛이 닿지 않으니까요. 자, 그럼 이 현상을 그래프로 그려보면 어떻게 될까요? 법선과 해가 이루는 각도를 X축으로 두고 표면의 밝기를 Y축으로 하겠습니다. 여기서 Y축이 가지는 값의 범위는 0~1인데0은 표면이 아주 깜깜한 때를(0%), 1은 표면이 최고로 밝은 때(100%)를 나타냅니다.

그림 4.5. 관찰결과를 그려본 그래프

 


위 그래프에서 -90 ~ 90도사이의 그래프에 물음표를 달아둔 이유는 각도가 줄어듦에 따라 얼마나 빠르게 표면이 어두워지는지를 모르기 때문입니다. 이제 이 그림을 그림 4.3과 비교해 볼까요? 그림 4.3에서 결과가 0 이하인 부분들을 0으로 만들면 지금 만든 그래프와 꽤 비슷하네요? 차이점이라고는 -90 ~ 90도 사이에서 그래프가 떨어지는 속도가 조금 다르다 뿐이군요. 그렇다면 람베르트 아저씨가 표면이 어두워지는 속도를 아주 꼼꼼히 잘 관찰한 뒤에, 위 코사인 공식을 만들었다고 믿어도 될까요? 전 그렇게 믿고 있습니다. -_-

자, 그럼 람베르트 모델을 적용하면 코사인 함수 한 번으로 난 반사광을 쉽게 구할 수 있겠군요! 하지만 코사인 함수는 그다지 값싼 함수가 아니어서 쉐이더에서 매번 호출하는 것이 영 꺼림직합니다. 다른 대안이 없을까요? 수학책을 뒤적여 보니까 내적(dot product)이라는 연산이 코사인을 대신할 수 있다고 나오는 걸요?
 

θ = A와 B가 이루는 각도
| A |  = 방향벡터 A의 길이
| B |  = 방향벡터 B의 길이


A ∙ B = cosθ | A || B |


즉,

cosθ = (A ∙ B) ÷ (| A |ⅹ| B |);


위의 내적 공식에 따르면 두 벡터가 이루는 각의 코사인 값은 그 둘의 내적을 구한 뒤 두 벡터의 길이를 곱한 결과로 나눈 것과 같습니다. 여기서 두 벡터의 길이를 1로 만들면 공식을 더 간단히 만들 수 있습니다.
 

cosθ = (A' ∙ B')


두 벡터가 이루는 각의 코사인 값은 두 벡터의 내적과 같다는 군요. 근데 이렇게 저희 맘대로 벡터의 길이를 바꿔도 되는 걸까요? 이 질문을 다르게 표현하면, '난 반사광을 계산할 때 법선의 길이나 입사광 벡터의 길이가 중요한가요?'입니다. 전혀 그렇지 않지요? 두 벡터가 이루는 각이 중요할 뿐 벡터의 길이는 결과에 아무런 영향을 미치지 않습니다. 따라서 이 두 벡터의 길이를 각각 1로 만들어서 공식을 간단하게 만드는 게 훨씬 나아 보이는군요.[각주:7]
 
그럼 내적이 코사인 함수보다 값싼 연산인 이유를 살펴볼까요? 벡터 A의 성분을  (a, b, c)로 두고 벡터 B의 성분을 (d, e, f)로 두면 두 벡터의 내적을 이렇게 간단히 구할 수 있습니다.

A ∙ B = (a ⅹ d) + (b ⅹ e) + (c ⅹ f)


코사인 함수보다 훨씬 간단해 보이는 게 맞죠? 당장 코사인 함수를 구하라고 하면 머리부터 긁적이실 걸요? ^^

자, 그럼 이 정도면 난 반사광에 대해 충분히 설명을 드린 것 같으니 지금 배운 내용들을 까먹기 전에 곧바로 쉐이더를 작성해 보겠습니다.



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


포프 올림

  1. 특히 콘솔 하드웨어의 하드웨어 사양이 더 큰 문제입니다. [본문으로]
  2. 간접광까지도 다루는 조명모델을 전역조명모델(global illumination model)이라고 합니다. 반대로 직접광만을 다루는 조명모델을 지역조명모델(local illumination model)이라 합니다. [본문으로]
  3. diffuse 광은 아직도 용어정립이 잘 안되고 있습니다. 따라서 이 용어를 사용할 때마다 종종 영문 표기를 같이 하도록 하겠습니다. 다른 용어로는 산란광, 확산광 등이 있는데 난 반사광이 가장 적합한 것 같습니다. [본문으로]
  4. 이것이 뒤에서 살펴볼 정 반사광입니다. [본문으로]
  5. 표면이 완전히 매끈하더라도 난반사가 완전히 사라지는 경우는 극히 드뭅니다. 표면을 뚫고 들어간 뒤, 물체 내부에서 반사되는 빛도 있기 때문입니다. [본문으로]
  6. 법선(normal)이란 표면의 방위(orientation)를 나타내는 벡터입니다. 따라서 그림 4.2에서처럼 좌우로 평평한 평면의 법선은 위쪽으로 수직인 선이 됩니다. [본문으로]
  7. 이렇게 길이가 1인 벡터를 단위벡터(unit vector)라고 하며, 단위벡터를 만드는 과정을 정규화(normalize)라고 합니다. [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License


픽셀쉐이더
정점쉐이더에서와 마찬가지로 전체 소스코드부터 보여드립니다.

sampler2D DiffuseSampler;

struct PS_INPUT
{
   float2 mTexCoord : TEXCOORD0;
};

float4 ps_main( PS_INPUT Input ) : COLOR
{
   float4 albedo = tex2D(DiffuseSampler, Input.mTexCoord);
   return albedo.rgba;
}

 

픽셀쉐이더 입력데이터 및 전역변수
이제 픽셀쉐이더를 살펴보기로 하죠. 픽셀쉐이더에서 할 일은 텍스처 이미지에서 텍셀(texel)[각주:1]을 구해와 그 색을 화면에 출력하는 것이겠군요. 그렇다면 텍스처로 사용할 이미지와 현재 픽셀의 UV 좌표가 필요하겠죠? 텍스처 이미지는 픽셀마다 변하는 값이 아니므로 전역변수로, UV 좌표는 정점쉐이더로부터 보간기를 거쳐 들어온 입력데이터가 되겠네요. 우선 픽셀쉐이더 입력데이터의 구조체부터 만들겠습니다.

struct PS_INPUT
{
   float2 mTexCoord : TEXCOORD0;
};


어라? 별 다른 게 없네요? VS_OUTPUT 구조체를 가져와 mPosition을 지워버린 것 뿐이군요. 사실 픽셀쉐이더의 입력데이터는 정점쉐이더의 출력데이터와 일치할 수밖에 없습니다. 어차피 정점쉐이더에서 반환한 값을 가져오는 거니까요.

다음은 텍스처를 선언할 차례군요. 앞서 렌더몽키 프로젝트를 설정할 때 DiffuseSampler 라는 이름의 텍스처 개체를 만들었던 거 기억하시죠? 바로 이 개체가 텍셀을 구할 때 사용하는 텍스처 샘플러입니다. 따라서 HLSL 코드에서 사용하는 텍스처 샘플러의 이름도 DiffuseSampler 여야 합니다.

sampler2D DiffuseSampler;


sampler2D는 HLSL에서 지원하는 데이터형 중에 하나로 2D 텍스처에서 텍셀 하나를 구해오는데 사용합니다. 이 외에도sampler1D, sampler3D, samplerCUBE 등의 샘플러가 있습니다.

이제 픽셀쉐이더 함수를 작성해보죠.


픽셀쉐이더 함수
우선 헤더부터 보겠습니다.

float4 ps_main( PS_INPUT Input ) : COLOR
{


이전과 달라진 점이라면 PS_INPUT형의 Input 매개변수를 받는다는 것뿐이군요. 보간기가 계산해준 UV 좌표 값을 받아오기 위해서입니다. 자, 이제 UV 값과 텍스처 샘플러가 있으니 텍셀 값을 구하는 일만 남았군요. tex2D라는 HLSL 내장함수를 사용하시면 매우 쉽게 이런 일을 할 수 있습니다. tex2D는 첫 번째 매개변수로 텍스처 샘플러를,두 번째 매개변수로 UV 좌표를 받습니다. 

    float4 albedo = tex2D(DiffuseSampler, Input.mTexCoord);


위 코드는 DiffuseSampler에서 Input.mTexCoord 좌표에 있는 텍셀을 읽어옵니다. 그 값은 albedo라는 변수에 저장되겠네요. 이제 이 값을 가지고 무슨 일을 해야 할까요? 으음.... 텍스처를 그대로 보여주는 게 목적이니까 그냥 반환하면 되겠네요.

   return albedo.rgba;
}


이제 F5키를 눌러 정점쉐이더와 픽셀쉐이더를 각각 컴파일 한 뒤, 미리 보기 창을 보면...... 엉망이군요?!. 왜일까요? 그것은 정점 버퍼에서 올바른 UV  좌표 값을 불러오도록 설정을 하지 않았기 때문입니다. Workspace 패널 아래에서 Stream Mapping을 찾아 마우스를 더블클릭하세요. POSITION이라는 항목만 있는 거 보이시죠? 이제 Add 버튼을 눌러 새 항목을 추가한 뒤 Usage를 TEXCOORD로 바꿉니다. Index가 0이고 Data Type이 FLOAT2로 되어있는지도 확인하세요. Attribute Name은 굳이 손 안대셔도 됩니다. 이제 OK버튼을 누르면 다음 그림과 같이 제대로 된 지구본을 보실 수 있을 것입니다.


그림 3.6. 그럴듯해 보이는 지구본


 

근데 albedo란 변수를 반환할 때 return albedo;라고 하지 않고 return albedo.rgba;라고 한 것 보이시죠? 사실 return albedo;라고 해도 전혀 상관은 없지만 뭔가 새로운 것을 보여 드리기 위해 일부러 저렇게 썼습니다.

HLSL에서는 벡터형 변수 뒤에 xyzw나 rgba 등의 접미사를 붙이는 방법을 사용하여 벡터의 성분들에 매우 쉽게 접근할 수 있습니다. 예를 들어, float4를 4개의 요소를 가진 float 배열(즉, float[4])라고 본다면 x나 r은 첫 번째 요소를, y나 g는 두 번째 요소를, z나 b는 세 번째 요소를, w나 a는 네 번째 요소를 가리킵니다. 예를 들어서 위의 albedo에서 rgb값만을 가져오고 싶다면 

float3 rgb = albedo.rgb;


라고 하시면 됩니다. 하지만 이에 그치지 않습니다. 이들 접미사의 순서를 마음대로 뒤섞어 새로운 벡터를 만들 수도 있습니다.예를 들어 r, g, b채널의 순서를 뒤바꾸고 싶다면 

float4 newAlbedo = albedo.bgra;


이라고 하시면 됩니다. 심지어는 다음과 같이 r채널만 세 번 반복할 수도 있습니다.

float4 newAlbedo = albedo.rrra;


매우 멋지지 않나요? 이렇게 rgba나 xyzw를 이용해서 마음대로 순서를 바꿔가면서 벡터의 성분에 접근하는 것을 스위즐(swizzle)이라고 합니다.

자, 그러면 연습도 할 겸 스위즐을 이용해서 방금 만들었던 지구본의 빨강채널과 파랑채널을 뒤바꿔보는 것은 어떨까요? 누워서 떡 먹기죠? ^_^

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

우선 '제2장: 진짜 쉬운 빨강쉐이더'에서 사용했었던 프레임워크의 사본을 만들어 새로운 폴더에 저장합니다. 그 다음, 렌더몽키에서 사용했던 쉐이더와 3D 모델을 DirectX 프레임워크에서 사용할 수 있도록 파일로 저장합니다. Sphere.x와 TextureMapping.fx라는 파일이름을 사용하도록 하겠습니다. 이제 렌더몽키에서 사용했던 earth.jpg라는 텍스처 파일도 복사해옵니다. 이 파일은 렌더몽키의 설치디렉터리를 보시면 \Examples\Media\Textures 폴더 안에 있습니다.

우선 전역변수들을 살펴봅시다. '제2장: 진짜 쉬운 빨강쉐이더' 사용했던 쉐이더 변수의 이름이 gpColorShader였었군요. 이것을 gpTextureMappingShader로 바꿉시다. 

// 쉐이더
LPD3DXEFFECT            gpTextureMappingShader = NULL;


지구 텍스처를 메모리에 저장할 때 사용할 텍스처 포인터도 하나 선언합니다.

// 텍스처
LPDIRECT3DTEXTURE9      gpEarthDM              = NULL;


이제 CleanUp() 함수로 가서 여기서 선언했던 D3D자원들을 해제하는 코드도 추가해야겠군요. 이래야 훌륭한 프로그래머이신 거 아시죠? gpColourShader의 이름을 변경하는 것도 잊지 맙시다.



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

     // 텍스처를 release 한다.
     if ( gpEarthDM )
     {
         gpEarthDM->Release();
         gpEarthDM = NULL;
     }


이제 텍스처와 쉐이더를 로딩해 보겠습니다. 당연히 LoadAssets() 함수에서 로딩을 해야죠.

일단 쉐이더 변수의 이름과 쉐이더 파일의 이름을 각각 gpTextureMappingShader와 TextureMapping.fx로 변경합니다.

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


그리고 이전에 만들어 두었던 LoadTexture() 함수를 이용해서 earth.jpg 파일을 로딩합니다.

     // 텍스처 로딩
     gpEarthDM = LoadTexture("Earth.jpg");
     if ( !gpEarthDM )
     {
        return false;
     }


이제 렌더링을 담당하는 RenderScene() 함수를 살펴보도록 하죠. 일단 gpColorShader 변수명이 쓰이는 곳이 많네요. 이것을 모두 찾아 gpTextureMappingShader로 변경합시다.

텍스처매핑 쉐이더에서 새로 추가한 전역변수가 하나 있었죠? 바로 텍스처 샘플러입니다. 그런데 D3D 프레임워크에서 쉐이더에 텍스처를 대입해줄 때, 곧바로 샘플러에 대입해주는 게 아니라 텍스처 변수에 대입해줘야 합니다. 렌더몽키에서 DiffuseSampler말고 DiffuseMap이라고 불리던 텍스처가 있었죠? 이게 바로 텍스처 변수입니다. 그럼 DiffuseMap이란 이름의 쉐이더변수에 텍스처를 대입해주면 될 것 같죠? 사실 그래야 정상인데 렌더몽키가 자기 멋대로 텍스처 변수의 이름을 바꾸더군요. -_-;; TextureMapping.fx 파일을 메모장에서 열어보시면 아실 겁니다. 코드를 쭉 보다 보면 sampler2D DiffuseSampler바로 위에 texture 데이터형으로 선언된 변수가 하나 보일 겁니다? 자기 맘대로 _Tex 접미사를 붙여놨군요. 나쁜 원숭이 같으니라고...

texture DiffuseMap_Tex


뭐 불평해봐야 뭐 달라질게 있겠습니까? 그냥 이 변수명을 사용해서 텍스처를 대입해주도록 합시다. 쉐이더에 텍스처를 대입할 때는 SetTexture()함수를 사용합니다. 이 함수는 SetMatrix함수와 마찬가지로 쉐이더 내부의 변수명을 첫 번째 매개변수로 받습니다.

     gpTextureMappingShader->SetTexture("DiffuseMap_Tex", gpEarthDM);


자, 이제 프로그램을 컴파일 한 뒤 실행시켜 보세요. 렌더몽키에서 봤던 것과 동일한 결과를 보실 수 있죠? 근데, 이 지구가 천천히 회전하면 더 괜찮겠는걸요? 그럼 지구를 빙그르르 돌리는 코드를 추가해보죠.

일단, 현재 회전 값을 기억할 전역변수를 하나 추가합니다.

// 회전값
float gRotationY = 0.0f;


물체의 회전과 위치 등의 정보는 월드행렬의 일부가 됩니다. 따라서 RenderScene()함수로 다시 돌아와 월드행렬을 만드는 코드를 이렇게 바꾸겠습니다.

    // 프레임마다 0.4도씩 회전을 시킨다.
    gRotationY += 0.4f * PI / 180.0f;
    if ( gRotationY > 2 * PI )
    {
        gRotationY -= 2 * PI;
    }

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


이 코드가 하는 일은 프레임마다 회전 각도를 0.02도씩 추가하고, 현재 회전 각도에 따라 회전행렬을 만들어서 그것을 월드행렬로 사용합니다. 현재 사용하시는 컴퓨터의 사양에 따라 이 회전 값이 너무 빠르거나 느릴 수도 있습니다. 본인의 컴퓨터에 맞게 적절히 값을 조정하세요.[각주:2]

자, 이제 다시 코드를 실행해보면 자전을 하는 지구의 모습을 볼 수가 있죠?

정리
다음은 이 장에서 배운 내용을 짧게 요약해 놓은 것입니다.
  • 텍스처매핑을 하려면 UV 좌표가 필요하다.
  • UV 좌표는 각 정점 상에 정의된 가변 값이다.
  • 픽셀쉐이더가 정점데이터를 이용하려면 정점쉐이더의 도움이 필요하다.
  • 정점쉐이더가 출력하는 값은 모두 보간기를 거친다.
  • tex2D함수를 이용하면 쉽게 텍스처를 샘플링할 수 있다.


고급쉐이더 기법 중에 텍스처매핑을 사용하지 않는 기법은 없다고 해도 과언이 아닐 정도로 텍스처매핑은 쉐이더 프로그래밍에 없어서는 안 될 존재입니다. 다행히도 HLSL에서 텍스처매핑이 그리 어렵진 않으니 잘 익혀두시기 바랍니다.

수고하셨습니다.


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


포프 올림


 
  1. 그림(picture)의 최소구성단위가 픽셀(pixel)인 것처럼 텍스처(texture)의 최소구성단위가 텍셀(texel)입니다. [본문으로]
  2. 실제 게임에서는 지난 프레임 이후 경과한 시간에 따라 회전량을 계산하는 게 옳은 방법입니다. 여기서 보여 드리는 코드는 쉐이더 데모를 위한 것이므로 그냥 이 정도로 놔두겠습니다. [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License
샘플파일 받기: 


제3장 텍스처매핑

이 장에서 새로 배우는 HLSL
  • sampler2D - 텍스처에서 텍셀을 구해올 때 사용하는 샘플러 데이터형
  • tex2D() - 텍스처 샘플링에 사용하는 HLSL 함수
  • 스위즐(swizzle) - 벡터 성분의 순서를 마음대로 뒤섞을 수 있는 방법



저번 장에서 배운 내용 어떠셨나요? 너무 쉬었다고요? 실제 게임에서 별 쓸모가 없어 보인다고요? 네, 사실 그렇습니다. 저번 장의 주 목적은 실습을 통해 HLSL의 기초 문법을 배우는 것이었습니다. 보통 프로그래밍 책에서 헬로월드(hello world) 예제를 처음에 드는 것과 마찬가지 이치죠. 그럼 이번 장에서는 그보다 조금 더 쓸모가 있는 내용을 배워볼까요? 물체를 단색으로 출력하는 대신에 표면에 텍스처(texture)[각주:1]를 입혀보는 게 어떨까요? 이걸 보통 텍스처매핑(texture mapping)이라고 부른다는 것쯤은 다 아시죠?

텍스처매핑과 UV 좌표
3D 물체를 이루는 구성요소는 삼각형이라고 이전에 말씀드렸습니다. 정점 3개로 삼각형을 만들 수 있다는 것도요. 그렇다면 삼각형 위에 이미지를 입히려면 어떻게 해야 할까요? '이 삼각형의 왼쪽 꼭짓점에 저 이미지의 오른쪽 귀퉁이 픽셀을 출력할 것'과 같은 지시를 내릴 수 있어야겠죠?[각주:2] 삼각형은 이미 정점 3개로 이루어져 있으니 각 정점을 텍스처 위에 있는 한 픽셀에 대응시켜 주면 되겠군요. 그럼 텍스처 위에서 한 픽셀을 어떻게 가리킬까요? 텍스처란 결국 이미지 파일이니까 'x = 30, y = 101에 있는 픽셀'이라는 식으로 정의하면 될까요? 만약 이렇게 정의를 해버리면 나중에 이미지 파일의 크기를 2배로 늘리면 이것을 다시 x = 60, y = 202로 바꿔야겠네요. 별로 바람직하지 않죠?

저번 장에서 배운 내용을 떠올려보니 색상을 표현할 때도 비슷한 이야길 했던 것 같군요. 그 때, 채널의 비트 수에 상관없이 통일적으로 색상을 표현하려면 어떻게 해야 한다고 했죠? 모든 값을 백분율(0~1)로 표현한다고 했죠? 여기서도 똑같은 방법을 사용합니다.  x = 0이 텍스처의 제일 왼쪽 열을, x = 1은 제일 오른쪽 열을 나타낸다고 하면 되겠죠? 마찬가지로 y = 0은 텍스처의 제일 처음 행을, y = 1은 마지막 행을 나타냅니다. 참고로 텍스처매핑을 사용할 때는 XY대신에 UV를 사용하는 게 보통입니다. 특별한 이유는 없고 그냥 위치를 표현할 때 흔히 xy를 사용하니 그와 혼돈을 피하기 위해서 입니다. 이것을 그림으로 표현하면 다음과 같습니다.

 

그림 3.1. 텍스처의 UV 좌표






이제 다양한 UV 좌표를 대입하면 어떻게 결과가 달라지는지는 몇 가지 예를 들어보도록 하죠. 역시 그림으로 보면 이해가 쉽겠죠?

그림 3.2 다양한 텍스처매핑의 예


 
(a) 아직 텍스처를 입히지 않은 두 삼각형입니다. 정점 v0, v1, v2와 v0, v2, v3가 각각 삼각형을 하나씩 이루고 있군요.
(b) UV 좌표의 범위가 (0,0) ~ (1,1)입니다. 텍스처를 전부 다 보여줍니다.
(c) UV 좌표의 범위가 (0,0) ~ (0.5, 1)입니다. 따라서 텍스처의 왼쪽 절반만을 모여줍니다. 0.5가 백분율로는 50%니까 딱 중간인 거 맞죠?
(d) UV 좌표의 범위가 (0,0) ~ (0.5, 0.5) 이군요. 따라서 이미지의 왼쪽 절반과 위쪽 절반만을 보여줍니다.
(e) UV 좌표의 범위가 (0,0) ~ (1,2)니까 텍스처를 위아래로 두 번 반복을 해줘야겠네요.[각주:3] 
(f) UV 좌표의 범위가 (0,0) ~ (2,2)니까 텍스처를 위아래로 두 번, 그리고 좌우로 두 번 반복해줍니다.[각주:4]

이 외에도 UV 좌표의 범위를 (1,0) ~ (0,1)로 하면 텍스처의 좌우를 뒤집는 등의 효과도 줄 수 있습니다. 이 정도면 어떻게 UV 좌표를 지정해야 원하는 결과를 얻을 수 있는 지 대충 아시겠죠? 이 정도면 실제로 텍스처매핑 쉐이더를 작성할 준비가 된 것 같군요.

기초설정
  1. '제2장: 진짜 쉬운 빨강쉐이더'에서 했던 것과 마찬가지로 렌더몽키 안에서 새로운 DirectX 이펙트를 만든 뒤, 정점쉐이더와 픽셀쉐이더 코드를 삭제합니다.
  2. 이제 쉐이더의 이름을 TextureMapping으로 바꿉니다.
  3. 정점의 위치를 변환할 때 사용할 gWorldMatrix, gViewMatrix, gProjectionMatrix를 추가하는 것도 잊지 맙시다. 변수 시맨틱을 이용해서 실제 데이터를 전달해 주는 방법도 기억하시죠?
  4. 이제 텍스처로 사용할 이미지를 추가할 차례입니다. TextureMapping 쉐이더에 오른쪽 마우스 버튼을 누른 뒤, Add Texture > Add 2D Texture > 렌더몽키 설치폴더\examples\media\textures\earth.jpg 파일을 선택합니다. Earth라는 이름의 텍스처가 추가되었을 겁니다. 
  5. 이 텍스처의 이름을 DiffuseMap으로 변경합니다.
  6. 이제 Pass 0위에 마우스 오른쪽 버튼을 누른 뒤, Add Texture Object > DiffuseMap을 선택합니다. Texture0 이란 이름의 텍스처 개체가 추가되었죠?
  7. 이제 Texture0의 이름을 DiffuseSampler로 바꿉니다.
  8. 이 모든 설정을 마치셨다면 Workspace 패널이 다음 그림처럼 보일 겁니다.

그림 3.3. 기초설정을 마친 렌더몽키 프로젝트


 



정점쉐이더
일단 전체 소스코드부터 보여드린 뒤, 한 줄씩 차근차근 설명해드리겠습니다.

struct VS_INPUT
{
   float4 mPosition : POSITION;
   float2 mTexCoord : TEXCOORD0;
};

struct VS_OUTPUT
{
   float4 mPosition : POSITION;
   float2 mTexCoord : TEXCOORD0;
};

float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;


VS_OUTPUT vs_main(VS_INPUT Input)
{
   VS_OUTPUT Output;
   
   Output.mPosition = mul(Input.mPosition, gWorldMatrix);
   Output.mPosition = mul(Output.mPosition, gViewMatrix);
   Output.mPosition = mul(Output.mPosition, gProjectionMatrix);
   
   Output.mTexCoord = Input.mTexCoord;
   
   return Output;
}


정점쉐이더를 살펴보기 전에 텍스처매핑을 하려면 어떤 데이터가 새로 필요한지 생각해봅시다. 일단 당연히 텍스처로 사용할 이미지 하나가 필요하겠죠? 그렇다면 텍스처를 입히는 작업을 어디에서 해야 할까요? 정점쉐이더일까요? 아니면 픽셀쉐이더일까요? 각 쉐이더가 실행되는 시점을 생각해보면 이에 대한 대답을 쉽게 구할 수 있습니다. 정점쉐이더는 각 정점마다 실행이 된다고 말씀드렸었죠? 근데 텍스처는 어디에 입히죠? 정점에 입히는 게 아니라 표면을 구성하는 모든 픽셀에 입혀야 하죠? 따라서 정점쉐이더에서 하기엔 뭔가 부족할 듯 싶군요. 정점쉐이더와는 달리 픽셀쉐이더는 각 픽셀마다 호출이 되니까 당연히 픽셀쉐이더에서 텍스처매핑을 해야겠군요. 자, 그럼 이미지는 텍스처로 사용할 테니 정점쉐이더에서 선언해줄 필요가 없네요.

그럼 이 외에 다른 정보가 필요할까요? 바로 위에서 말씀드렸었는데 말이죠. 그렇습니다. UV 좌표가 필요하지요. UV 좌표를 어디에 지정했었죠? 각 정점마다였죠? 따라서 UV 좌표는 전역변수가 아니라 정점데이터의 일부로 전달됩니다. 자~ 그럼 이 점을 염두에 두고 정점쉐이더의 입출력 데이터를 살펴보도록 합시다.

정점쉐이더 입력데이터
'제2장: 진짜 쉬운 빨강쉐이더'에서 사용했던 입력데이터의 구조체를 일단 가져와보도록 하지요.

struct VS_INPUT
{
    float4 mPosition : POSITION;
};



자, 이제 여기에 UV 좌표를 추가해야겠죠? UV 좌표는 u하고 v로 나뉘니까 데이터형은 float2가 되겠네요. 그렇다면 어떤 시맨틱을 사용해야 할까요? 위치정보가 POSITION이라는 시맨틱을 가졌듯이 UV 좌표도 자신만의 시맨틱을 가지겠죠? TEXCOORD[각주:5]란 시맨틱이 바로 그것입니다. UV 좌표 데이터를 삽입한 뒤의 정점쉐이더 입력데이터는 아래와 같습니다.

struct VS_INPUT
{
    float4 mPosition : POSITION;
    float2 mTexCoord : TEXCOORD0;
};


TEXCOORD뒤에 0을 붙인 이유는 HLSL에서 지원하는 TEXCOORD 수가 여럿이기 때문입니다. 쉐이더에서 여러 개의 텍스처를 동시에 사용할 때, 둘 이상의 UV 좌표를 사용할 경우가 있는데 그럴 때에는 TEXCOORD0, TEXCOORD1등으로 시맨틱을 사용하시면 됩니다.

정점쉐이더 출력데이터
우선 '제2장: 진짜 쉬운 빨강쉐이더'에서 사용했던 정점쉐이더 출력데이터를 가져와 봅시다.

struct VS_OUTPUT 
{
    float4 mPosition : POSITION;
};


여기에 다른 정보를 추가해야 할까요? '제2장: 진짜 쉬운 빨강쉐이더'에서 설명해 드리지 않았던 내용 중 하나가 정점쉐이더는 위치정보 외에도 다른 정보들을 반환할 수 있다는 것입니다. 정점쉐이더가 위치정보를 반환하는 이유는 래스터라이저가 픽셀들을 찾아낼 수 있도록 하기 위해서였습니다. 하지만, 위치 이외의 다른 정보를 반환하는 이유는 래스터라이저를 위해서가 아닙니다. 이는 오히려 픽셀쉐이더를 위해서입니다. 텍스처매핑에 필요한 UV 좌표가 그 좋은 예입니다. 

픽셀쉐이더는 정점 버퍼 데이터에 직접적으로 접근을 못 합니다. 따라서, 픽셀쉐이더에서 사용해야 할 정점데이터가 있다면(예, UV 좌표), 그 데이터는 정점쉐이더를 거쳐 픽셀쉐이더에 전달돼야 합니다. 좀 쓸데없는 제약 같다고요? 다음의 그림을 보시면 왜 이런 제약이 붙어 있는지를 알 수 있으실 것입니다.

그림 3.4. 과연 저 픽셀의 UV 좌표 값은 무엇일까?




UV 좌표가 정의된 장소는 각 정점입니다. 하지만 위 그림에서 볼 수 있듯이 픽셀의 UV 좌표는 정점의 UV 좌표와도 다른 것이 대부분입니다.[각주:6] 따라서 이 픽셀의 올바른 UV 값을 구하는 방법은 현재 위치에서 세 정점까지의 거리를 구한 뒤 그 거리의 비율에 따라 세 UV 값을 혼합하는 것이겠지요. 하지만 이런 혼합을 직접해줄 필요는 없습니다. 정점쉐이더에서 출력한 위치 정보를 래스터라이저가 알아서 처리해줬듯이 정점 이외의 기타 정보는 보간기(interpolator)라는 장치가 알아서 혼합해줍니다. 그럼 '제1장: 쉐이더란 무엇이죠?'에서 보여드렸던 GPU 파이프라인에 보간기를 추가해보죠. 그림 3.5가 되겠습니다.

그림 3.5. 보간기까지 추가했지만 여전히 너무 간략한 3D 파이프라인




참고로 보간기가 보간[각주:7]을 하는 것은 UV 좌표만이 아닙니다. 정점쉐이더가 반환하는 어떤 값이든 보간기는 보간을 해서 픽셀쉐이더에 전달해줍니다.

자, 그럼 이제 정점쉐이더에서 UV 좌표값도 반환해야 한다는 사실, 이해하시겠죠? 추가합시다.

struct VS_OUTPUT 
{
    float4 mPosition : POSITION;
    float2 mTexCoord : TEXCOORD0;
};



전역변수
'제2장: 진짜 쉬운 빨강쉐이더'에서 사용했던 것 이외에 별도로 필요한 전역변수는 없습니다. 따라서 별다른 설명 없이 코드만 보여 드리겠습니다.

float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;


정점쉐이더 함수
누누이 말씀드리지만 정점쉐이더의 가장 중요한 임무는 정점의 위치를 투영공간으로 변환시키는 것입니다. 이 코드는 '제2장: 진짜 쉬운 빨강쉐이더'의 쉐이더에서 사용했던 것과 똑같습니다.

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldMatrix );
   Output.mPosition = mul( Output.mPosition, gViewMatrix );
   Output.mPosition = mul( Output.mPosition, gProjectionMatrix );


이제 UV 좌표를 전달해 줄 차례군요. Output 구조체에 UV 좌표를 대입하기 전에 공간변환을 적용해야 할까요? 그렇지 않습니다. UV 좌표는 여태까지 다뤘던 3차원 공간에 존재하는 게 아니라 삼각형의 표면상에 존재하기 때문입니다. 따라서 아무 변환 없이 UV 좌표를 전달해 줍니다.

   Output.mTexCoord = Input.mTexCoord;


더는 처리할 데이터가 떠오르지 않는군요. 이제 Output을 반환하면서 이 함수를 마무리 짓겠습니다.

   return Output;
}



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


포프 올림



  1. 3D 그래픽에서는 이미지를 사용하여 표면의 색감 및 질감(texture)을 표현합니다. 여기서 사용하는 이미지들을 텍스처라고 부릅니다. [본문으로]
  2. 이렇게 다른 두 점을 서로 대응시키는 것을 영어로 매핑(mapping)이라고 합니다. [본문으로]
  3. UV 좌표가 0~1 범위 밖에 있을 때 이것을 처리하는 방법에는 여러 가지가 있습니다. 위에서 든 예는 랩(wrap, 반복) 모드를 사용할 때만 올바릅니다. 이 외에도 미러(mirror, 거울)라던가 클램프(clamp, 비반복) 모드도 있습니다. [본문으로]
  4. [본문으로]
  5. 텍스처좌표(texture coordinate)의 줄임말입니다. [본문으로]
  6. 픽셀의 위치가 정점과 일치하는 경우에는 UV 좌표가 같습니다. [본문으로]
  7. 보간(interpolate)이란 단어가 잘 이해 안 되시는 분들은 그냥 위에서 설명해 드렸다시피 '인접한 세 정점까지의 거리에 비례하여 값을 혼합하는 것'이라고 이해하세요. [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License


픽셀쉐이더
자, 이제 픽셀쉐이더를 작성해 볼 차례입니다. 정점쉐이더에서 했던 것과 마찬가지로 렌더몽키의 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이면 완전 투명입니다. [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License
샘플파일

제2장 진짜 쉬운 빨강쉐이더

이 장에서 새로 배우는 HLSL
  • float4: 4개의 성분을 가지는 벡터 데이터형
  • float4x4: 4 X 4 행렬 데이터형
  • mul(): 곱하기 함수. 거의 모든 데이터형을 변수로 받음.
  • POSITION: 정점위치 시맨틱. 정점데이터 중 위치정보만을 불러옴.



이 장에서 새로 사용하는 수학
  • 3D 공간변환 - 행렬 곱을 이용함.




'제1장: 쉐이더란 무엇이죠?'에서 쉐이더란 픽셀의 위치와 색을 계산하는 함수라고 말씀드렸습니다. 그렇다면 이번 장에서는 실제로 픽셀의 위치와 색을 계산하는 쉐이더를 만들어봐야겠죠? 처음 쉐이더를 짜보시는 분들도 쉽게 이해하실 수 있게끔 매우 간단한 쉐이더 프로그램을 만들어 보겠습니다. 우선 렌더몽키에서 빨간색 공을 그리는 쉐이더를 작성해보면서 HLSL 문법을 처음으로 접해보는 게 좋겠군요![각주:1]  렌더몽키에서 쉐이더를 작성하면 그 결과를 .fx 파일로 익스포트(export)해서 이걸 DirectX 프레임워크에 그대로 가져다 쓸 수도 있습니다.

기초설정
다음의 단계를 따라서 기초적인 설정을 마무리합시다.

  1. 렌더몽키를 시작합니다. 무서운(?) 원숭이 얼굴이 잠시 스쳐 지나간 뒤에 빈 작업공간(workspace)가 등장할 것입니다. 
  2. Workspace 패널 안에서 Effect Workspace위에 마우스 오른쪽 버튼을 누릅니다. 팝업 메뉴가 등장할 겁니다.
  3. 팝업메뉴에서 Add Default Effect > DirectX > DirectX를 선택합니다. 이제 미리 보기(preview) 창에 빨간색 공 하나가 보이죠?
  4. Workspace패널에 Deafult_DirectX_Effect라는 새로운 쉐이더도 추가되었을 것입니다. 쉐이더의 이름을 ColorShader로 바꿉니다.
  5. 이제 화면이 아래와 같을 것입니다.

그림 2.1. 기초설정을 마친 렌더몽키 프로젝트


 

정점쉐이더
이제 ColorShader옆에 있는 더하기(+) 표시를 누릅니다. 제일 아래쪽에 Pass 0이 보이시죠? 그 옆에 있는 더하기 표시를 다시 누르세요. 이제 Vertex Shader를 더블클릭하시면 오른쪽 쉐이더 편집기 안에 Vertex Shader코드가 등장할 겁니다. 사실 여기에 들어있는 코드가 이미 빨간 공을 그리고 있지만 저희는 한 줄씩 연습을 해봐야 하니 이 속에 있는 코드를 모두 지우겠습니다. 

코드를 다 지우셨나요? 그렇다면 이제 본격적으로 시작해보죠! 우선 한 눈에 보실 수 있게끔 정점쉐이더 코드를 전부 보여드린 뒤 한 줄씩 설명해 나가도록 하겠습니다.

struct VS_INPUT 
{
   float4 mPosition : POSITION;
};

struct VS_OUTPUT 
{
   float4 mPosition : POSITION;
};

float4x4 gWorldMatrix;         
float4x4 gViewMatrix;          
float4x4 gProjectionMatrix;    


VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.mPosition = mul( Input.mPosition, gWorldMatrix );
   Output.mPosition = mul( Output.mPosition, gViewMatrix );
   Output.mPosition = mul( Output.mPosition, gProjectionMatrix );
   
   return Output;
}




전역변수 vs 정점데이터
쉐이더에서 사용할 수 있는 입력 값으로는 전역변수와 정점데이터가 있습니다. 이 둘을 구분 짓는 기준은 한 물체를 구성하는 모든 정점이 동일한 값을 사용하느냐의 여부입니다. 만약 동일한 값을 사용한다면 이것은 전역변수가 될 수 있지만 각 정점마다 다른 값을 사용한다면 당연히 전역변수는 안되겠지요. 그 대신 정점 버퍼, 즉 정점 데이터의 일부로 이 값을 받아들여야 합니다.

전역변수의 예로는 월드행렬, 카메라의 위치 등이 있고, 정점데이터 변수의 예로는 정점의 위치, UV좌표 등이 있습니다.

정점쉐이더 입력데이터
우선 정점쉐이더에서 입력으로 받을 데이터들을 VS_INPUT이라는 구조체로 선언해보겠습니다.

struct VS_INPUT
{
    float4 mPosition : POSITION;
};


'제1장: 쉐이더란 무엇이죠?'에서 정점쉐이더의 가장 중요한 임무는 각 정점의 위치를 공간 변환하는 것이라고 했던 거 기억하시나요? 그러기 위해서는 정점의 위치를 입력으로 받아야 하죠? 그게 바로 위 구조체가 멤버변수 mPosition을 통해 정점의 위치를 얻어오는 이유입니다. 이 변수가 DirectX의 정점버퍼[각주:2]로부터 위치정보를 구해올 수 있는 이유는 POSITION이라는 시맨틱(semantic)[각주:3]때문입니다. 정점버퍼에는 정점의 위치, UV좌표, 법선 등을 비롯한 다양한 정보가 담겨 있을 수 있는데 이 중에서 필요한 정보만을 쏙쏙 빼오는 것을 시맨틱이라고 해두죠. 

따라서 float4 mPosition : POSITION; 이라는 코드는 '정점데이터에서 위치(POSITION) 정보를 가져와서 mPosition에 대입해라!'라는 명령입니다.

아차! 그렇다면 float4는 뭘까요? 이건 변수의 데이터형입니다. float4는 HLSL자체에서 지원하는 데이터형의 하나로 4개의 성분(x ,y, z, w)을 가지는 벡터입니다. 각 성분은 부동소수점(floating-point)형 입니다. HLSL은 float4외에도 float, float2, float3 등의 데이터형을 지원합니다.[각주:4]

정점쉐이더 출력데이터
정점쉐이더의 입력데이터를 선언해봤으니 이제 출력데이터를 살펴봐야겠죠? '제1장: 쉐이더란 무엇이죠?'에서 보여드렸던 초 간단 GPU 파이프라인의 그림을 기억하시나요? 각 픽셀의 위치를 찾아내려면 정점쉐이더가 위치변환 결과를 래스터라이저에 전달해줘야만 했습니다. 따라서 정점쉐이더는 반드시 위치변환 결과를 반환해야 합니다. 자, 그렇다면 정점쉐이더 출력데이터 구조체를 VS_OUTPUT이란 이름으로 선언해보지요!

struct VS_OUTPUT 
{
    float4 mPosition : POSITION;
};


float4형으로 위치데이터를 반환하면서 '이것은 위치(POSITION)요!'라는 시맨틱을 붙여준 거 보이시죠?

전역변수
정점쉐이더에서 공간 변환을 할 때, 사용해야 할 전역변수들이 몇 있는데 그 전에 공간 변환이 무언지부터 설명해 드려야 할 듯 싶군요.

3D 공간변환
3D물체를 모니터에 그리려면 정점들의 위치를 공간 변환해야 한다고 말씀드렸습니다. 그렇다면 과연 어떤 공간들을 거쳐야 3D물체를 모니터에 보여줄 수 있을까요? 사과를 예로 들어보죠.

물체공간
자, 일단 사과를 손에 쥐어봅시다. 사과의 중앙을 원점으로 삼고 그 점을 시작으로 오른쪽(+x), 위쪽(+y), 앞쪽(+z)으로 3개의 축을 만들어 볼까요? 이제 원점으로부터 사과의 표면까지의 거리를 이리저리 재보면 각 점들을 (x, y, z) 좌표로 표현할 수 있겠죠? 그리고 이 정점들을 3개씩 묶어 삼각형들을 만들면 폴리곤으로 사과모델을 만들 수 있겠네요.

이제 사과를 손에 쥔 채 팔을 이리저리 움직여봅시다. 사과를 어디로 움직이던 간에 원점으로부터 각 정점까지의 거리는 변하지 않죠? 이것이 바로 물체공간(object space) 또는 지역공간(local space)입니다. 물체공간에서는 각 물체(3D 모델)가 자신만의 좌표계를 가지므로 다수의 물체를 통일적으로 처리하기 어렵습니다.

그림 2.2. 물체공간의 예


 

월드공간
이제 사과를 모니터 옆에 놓아볼까요? 모니터도 물체니까 자신만의 물체공간을 가지고 있겠군요. 이 둘을 통일적으로 처리하고 싶은데 그러려면 어떻게 해야 할까요? 이 두 물체를 같은 공간으로 옮겨오면 될 거 같은데요? 그러면 새로운 공간을 하나 만들어야겠군요. 현재 계신 방의 입구를 원점으로 삼고 오른쪽, 위쪽, 앞쪽으로 +x, +y, +z인 3개의 축을 만들어보죠. 이제 그 원점에서부터 모니터를 구성하는 정점들까지의 거리를 재면 새로운 (x, y, z) 좌표로 정점들을 표현할 수 있겠죠? 사과도 똑같은 방법으로 표현할 수 있겠네요. 이 새로운 공간을 월드공간(world space) 또는 세계공간이라고 합니다.

그림 2.3. 월드공간의 예


 

뷰공간
자, 그렇다면 이제 카메라를 가져다가 사진을 좀 찍어볼까요? 일단 위 두 물체들이 모두 사진 속에 들어오도록 사진을 찍고, 다음에는 이들이 전혀 보이지 않도록 전혀 엉뚱한 곳을 찍어봅시다. 이 두 사진은 확연히 다르죠? 처음 사진에서는 두 물체를 볼 수 있는데, 다른 사진에서는 흔적도 찾아볼 수 없군요. 그렇다면 이 두 사진 간에 뭔가 위치 변화가 있어야 한단 이야긴데 월드공간에서는 그 두 물체들의 위치가 전혀 변하지 않았는걸요? 아하! 그렇다면 이 카메라가 다른 공간을 사용하는 거로군요! 이렇게 카메라가 사용하는 공간을 뷰공간(view space)이라고 부릅니다. 뷰공간의 원점은 카메라 렌즈의 정 중앙이고 역시 그로부터 오른쪽, 위쪽, 앞쪽으로 3개의 축을 만들 수 있습니다.

그림 2.4 뷰공간의 예. 물체들이 카메라 안에 있음.

 

그림 2.5 뷰공간의 예. 물체들이 카메라 밖에 있음.




투영공간
일반 카메라로 사진을 찍으면 인간의 눈을 통해 보는 것과 마찬가지로 멀리 있는 물체는 조그맣게 보입니다. 근데 왜 우리 눈이 이렇게 작동하는지 아세요? 이건 인간의 시야가 좌우로 각각 100도 정도, 상하로 각각 75도 정도 되어서 그렇습니다. 따라서 멀리를 바라볼 수록 눈에 들어오는 범위가 넓어지는데 이 늘어난 범위를 일정한 크기의 망막에 담으려다 보니 멀리 있는 물체가 작게 보이는 거지요. 일반 카메라도 사람의 눈을 흉내 내는데 이와는 달리 직교카메라란 것도 있습니다. 직교카메라는 상하좌우로 퍼지는 시야를 가지지 않습니다. 무조건 앞쪽만 바라보지요. 따라서 직교카메라를 사용하면 거리에 상관없이 물체의 크기가 변하지 않습니다.

그러면 결국 카메라로 사진을 찍는 과정을 두 단계로 나눌 수 있는 것 같네요. 첫째는 월드공간에 있는 물체들을 카메라 공간으로 이동, 회전, 확대/축소시키는 단계고요, 둘째는 이렇게 새로운 공간에 위치된 물체들을 2D 이미지 위에 투영하는 것입니다. 이러면 첫 번째 단계를 뷰공간, 두 번째 단계를 투영공간이라고 확실히 구분할 수 있겠죠? 이제 직각투시법(orthogonal projection)을 사용하던 원근투시법을 사용하던 간에 뷰공간은 아무 영향을 받지 않겠네요. 그 대신 투영공간에서 이 투시법을 적용하겠죠.

이렇게 투영까지 마친 결과가 바로 화면에 보여지는 최종 이미지입니다.

정리
3D 그래픽에서 정점위치의 공간을 변환할 때 흔히 사용하는 방법이 정점의 위치벡터에 공간행렬을 곱하는 것입니다. 물체를 지역공간에서 화면공간까지 옮겨올 때 거치는 공간이 총 셋(월드공간, 뷰공간, 투영공간)이므로 행렬도 3개를 구해야 합니다. 참고로 각 공간의 원점과 세 축을 알면 그 공간을 나타내는 행렬을 쉽게 만들 수 있습니다.[각주:5]

자, 그럼 여태까지 논한 모든 공간변환들을 정리해서 보여드리면 다음과 같습니다.

물체공간 ----------> 월드공간 --------> 뷰공간 ---------> 투영공간
              ⅹ월드행렬                   ⅹ뷰행렬                ⅹ투영행렬


위의 모든 행렬들은 각 정점마다 값이 변하지 않으니 전역변수로 선언하기에 적합하군요.

전역변수 선언
그럼, 이제 어떤 전역변수들이 필요한지 아시겠죠? 그렇습니다. 공간변환을 할 때 사용할 월드행렬, 뷰행렬, 투영행렬이 필요합니다. 정점쉐이더 코드에 다음의 세 라인을 삽입합시다.

float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;


float4x4라는 새로운 데이터형이 나왔군요? 이것도 역시 HLSL에서 지원하는 데이터형 중에 하나입니다. 4 X 4 행렬이라는 거 쉽게 아시겠죠? 이 외에도 float2x2, float3x3 등의 데이터형이 있습니다.

자, 그럼 이제 행렬들도 선언했는데 과연 누가 이 변수들에 값을 전달해줄까요? 보통 게임에서는 그래픽 엔진에서 전역변수들의 값을 대입해주는 코드가 있습니다. 렌더몽키에서는 변수시맨틱(variable semantic)을 통해 변수 값을 대입해줍니다. 그럼 변수시맨틱을 사용해보죠.

  1. Workspace 패널 안에서 ColorShader를 찾아 마우스 오른쪽 버튼을 누릅니다.
  2. 팝업메뉴에서 Add Variable > Matrix > Float(4x4)를 선택합니다. f4x4Matrix란 이름의 새로운 변수가 추가될 겁니다.
  3. gWorldMatrix로 변수의 이름을 변경합니다.
  4. 이제 gWorldMatrix 위에 마우스 오른쪽 버튼을 눌러, Variable Semantic > World를 선택합니다. 이게 바로 렌더몽키에서 변수 시맨틱을 통해 변수 값을 전달하는 방법입니다.
  5. 이제 위의 과정을 반복하여 뷰 행렬과 투영행렬을 만듭니다. 변수 명을 각각 gViewMatrix와 gProjectionMatrix를 만들고 View와 Projection 변수시맨틱을 대입합니다.
  6. 마지막으로 matViewProjection이란 변수를 지웁니다. 처음 이펙트를 만들 때 같이 딸려온 변수인데 저희는 이 대신 gViewMatrix와 gProjectionMatrix를 씁니다.

이 과정을 마치셨다면 Workspace 패널이 아래 그림처럼 보일 겁니다.

그림 2.6. 변수시맨틱을 대입한 뒤의 Workspace 패널


 

정점쉐이더 함수
이제 모든 준비작업이 끝났습니다. 드디어 정점쉐이더 함수를 작성할 때가 왔군요. 우선 함수헤더부터 볼까요?

VS_OUTPUT vs_main( VS_INPUT Input )
{


이 함수헤더가 의미하는 바는 이와 같습니다.
  • 이 함수의 이름은 vs_main이다.
  • 이 함수의 인수는 VS_INPUT 데이터형의 Input이다.
  • 이 함수의 반환값은 VS_OUPUT 데이터형이다.

C에서 함수를 정의하는 것과 별 차이가 없죠? HLSL은 C와 비슷한 문법을 사용한다고 전에 말씀드렸습니다. 자, 그럼 다음 라인을 보죠.

   VS_OUTPUT Output;


이건 그냥 함수의 끝에서 반환할 구조체를 선언한 것 뿐입니다. 함수헤더에서 선언했다시피 데이터형이 VS_OUTPUT인 거 보이시죠? VS_OUTPUT의 멤버로는 무엇이 있었죠? 투영공간으로 변환된 mPosition이 있었죠? 그럼 이제 공간변환을 해볼 차례군요! 우선, Input.mPosition에 담긴 모델공간 위치를 월드공간으로 변환합시다. 공간변환을 어떻게 한다고 했었죠? 정점위치에 행렬을 곱하는 거였네요. 그러면 float4형의 위치벡터와 float4x4 행렬을 곱해야겠네요? 행렬과 벡터를 곱하는 법을 찾기 위해 수학책을 뒤지실 필요는 없습니다. HLSL은 이미 여러 데이터형 간의 곱셈을 처리해주는 내장함수 mul()을 가지고 있습니다. 이 함수를 사용하면 공간변환이 이렇게 간단해집니다.

   Output.mPosition = mul( Input.mPosition, gWorldMatrix );


위의 코드는 모델공간에 존재하는 정점위치(Input.mPosition)에 월드행렬(gWorldMatrix)를 곱해서 그 결과(월드공간에서의 위치)를 Output.mPosition에 대입합니다. 이제 똑같은 방식으로 뷰공간과 투영공간으로 변환하면 됩니다.

   Output.mPosition = mul( Output.mPosition, gViewMatrix );
   Output.mPosition = mul( Output.mPosition, gProjectionMatrix );


복잡한 거 하나도 없죠? 자, 이제 무슨 일이 남았을까요? 정점쉐이더의 가장 중요한 임무는 모델공간에 있는 정점의 위치를 투영공간까지 변환하는 것이었으니까... 음.... 그 중요한 임무를 방금 막 마친 듯 한데요? 자, 그럼 이 결과를 반환하는 걸로 정점쉐이더를 마치겠습니다.

   return Output;
}


이제 F5를 눌러서 정점쉐이더를 한 번 컴파일 해보면 여전히 빨간 공이 보이죠? 그럼 일단 정점쉐이더는 잘 마무리가 된 듯 하네요. 혹시라도 컴파일 에러가 보이면 뭔가 잘못했단 이야기니 한번 코드를 다시 검토해보세요.

팁: 쉐이더 컴파일에 실패한 경우
오타나 문법적 오류 때문에 쉐이더 컴파일에 실패한 경우, 미리 보기 창에 컴파일에 실패했다는 오류메시지가 등장할 것입니다. 이 때, 정확히 어떤 코드에 문제가 있는지 알고 싶으시다면 렌더몽키의 젤 아래쪽에 위치한 출력(output)창을 보세요. 자세한 오류메시지와 더불어 문제가 있는 코드의 행과 열 번호까지도 보여줍니다.



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


포프 올림




  1. [본문으로]
  2. 버텍스 버퍼(vertex buffer)라고도 합니다. [본문으로]
  3. 태그(tag) 정도로 이해하시는 게 편할 겁니다. [본문으로]
  4. 참고로 GPU는 부동소수점 벡터를 처리하는데 최적화된 장치입니다. 따라서 쉐이더에서 사용하는 기본적인 데이터형은 정수가 아닌 부동소수점입니다. 정수는 오히려 쉐이더의 성능을 저하시키는 요인입니다. [본문으로]
  5. 이 행렬을 직접 만드는 방법은 3D 수학책을 참조하시기 바랍니다. 이 책에서는 Direct3D에서 제공하는 함수를 사용해서 이 행렬들을 구성합니다. [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License
샘플파일

쉐이더 프로그래밍
자, 그럼 쉐이더가 무엇인지는 대충 알아보았는데 쉐이더를 코드를 짠다는 것은 무슨 뜻일까요? 일단 그림 1.1을 다시 한번 살펴보죠. 그림 1.1을 보면 사각형으로 표현한 파이프라인 단계도 있고 둥글게 표현한 것도 있죠? 원형으로 표현한 단계들은 GPU(graphics processing unit, 그래픽 처리장치)가 알아서 처리해주는 -- 즉 프로그래머가 따로 제어할 수 없는 -- 단계들입니다. 그와 반대로 사각형으로 표현한 단계들은 프로그래머가 마음대로 제어할 수 있는 단계들이죠. 이 단계에서 사용할 함수를 작성하는 것이 바로 쉐이더 프로그래밍입니다. 그림 1.1에서 사각형으로 표현된 단계들은 정점쉐이더와 픽셀쉐이더 뿐인 거 보이시죠? 따라서 정점쉐이더와 픽셀쉐이더에 사용할 함수를 하나씩 만드는 것이 쉐이더 프로그래밍입니다.[각주:1]

시중에 나와 있는 여러 쉐이더 언어 중에 이 책에서 사용할 언어는 DirectX에서 지원하는 HLSL입니다. HLSL(High Level Shader Language, 고수준 쉐이더언어)은 C와 매우 비슷한 문법을 사용하는 언어로 GLSL[각주:2]이나 CgFX[각주:3] 등의 기타 쉐이더 언어와 매우 흡사합니다. 따라서 HLSL을 배우시면 다른 쉐이더 언어를 익히시는데도 큰 무리가 없을 것입니다.

한 언어를 배우는 최선의 방법은 직접 코딩을 하면서 배우는 것입니다. 이 언어의 철학은 이러네, 이 언어의 문법은 저러네 하면서 백날 떠들어봐야 입문자들은 하품만 하고 무슨 이야긴지 알아듣지도 못합니다. 일단 재미있게 코드를 짜봐야 프로그래밍에 애착도 생기고, 애착이 생기면 보다 나은 프로그래머가 되기 위해 노력을 하지요. 따라서 이 책에서는 쓸데없이 HLSL 문법을 나열하면서 독자분들의 짜증을 부추기는 대신 무조건 아주 쉬운 쉐이더부터 짜보는 방법으로 HLSL을 배우도록 하겠습니다. 정 문법이 궁금하신 분들은 부록을 참고하시길 바랍니다.

하지만 HLSL 코드를 곧바로 짜기 전에 준비해야 할 것들이 좀 있군요. 이건 좀 지루하시더라도 꾹 참고 따라 해주시기 바랍니다.

쉐이더 프로그래밍을 위한 기본준비
서문에서도 말씀드렸듯이 이 책의 초점은 쉐이더 프로그래밍입니다. 이 책에서 DirectX에 대한 내용을 자세히 다루지 않기로 결정한 이유는 이미 훌륭한 DirectX 입문 책들이 시중에 나와있는데 굳이 DirectX를 다시 처음부터 소개하면서 쓸데없이 지면을 낭비하고 싶지 않았기 때문입니다.[각주:4] 또한 프로그래머 분들 외에 테크니컬 아티스트 분들도 이 책을 읽으실 수 있도록 하기 위해서입니다.

마찬가지 이유로 이 책에서 쉐이더를 만드는 과정도 둘로 나눴습니다. 첫 번째 단계는 쉐이더 작성만을 하는 단계로 AMD(전 ATI) 사의 렌더몽키(render monkey)라는 프로그램을 사용합니다. 이 단계는 프로그래머와 아티스트 분들을 모두 대상으로 하므로 반드시 따라 해 주시기 바랍니다.

두 번째 단계는 렌더몽키에서 만든 쉐이더를 C++/DirectX 프레임워크에서 불러와 사용하는 것으로 프로그래머 분들을 위한 단계입니다. 프로그래머이시더라도 C++/DirectX 프레임워크에 관심이 없으신 분들은 이 단계를 건너 뛰셔도 됩니다. 실제로 쉐이더 코드를 작성하는 곳은 첫 번째 단계입니다.

자, 그러면 위 두 단계에서 쉐이더를 배우는 데 필요한 것들을 준비해보죠.

렌더몽키
렌더몽키는 AMD사에서 제공하는 쉐이더 작성도구로 프로토타이핑에 유용합니다. 부록 디스크에서 /RenderMonkey/ RenderMonkey.2008-12-17-v1.82.322.msi를 찾아 설치해 주세요. 그냥 기본(default) 옵션으로 설치하시면 되겠습니다.

선택사항: 간단한 DirectX 프레임워크
C++/DirectX 프레임워크에서 쉐이더를 실행해보고 싶으신 분들만 이 절을 따라 해주세요.

우선 비주얼 C++ 2008과 DirectX SDK를 설치하시기 바랍니다. 비주얼 C++을 소장하고 계시지 않으신 분들은 마이크로소프트사의 웹 페이지에서 공짜 버전인 익스프레스 버전을 다운받으실 수 있습니다. DirectX SDK는 부록 CD의 DXSDK 폴더에 포함되어 있습니다.

위 두 프로그램의 설치를 마치셨다면 비주얼 C++ 2008에서 부록 CD에 있는 samples/01_DxFramework/BasicFramework.sln 파일을 여시기 바랍니다. 별다른 수정 없이 이 프로그램을 실행하면 다음과 같은 파란 화면을 보실 수 있을 것입니다.

그림 1.2. 별볼일 없는 초 간단 프레임워크



이 프레임워크는 다음과 같은 기능들을 구현합니다.

  • 창의 생성 및 메시지 루프 등의 기본적인 윈도우 기능
  • Direct 3D 장치 생성
  • 텍스처, 모델, 쉐이더 등의 자원 로딩
  • 간단한 게임루프
  • 간단한 키보드 입력처리

참고로 말씀드리는데 이 프로그램은 쉐이더 코드를 재빨리 실행할 수 있도록 매우 간단하게 만든 프레임워크입니다. 그 결과, 모든 함수들이 .cpp 파일 하나 안에 들어있고, 클래스나 개체도 사용하지 않지요. 따라서 모든 함수들은 C스타일로 작성되어 있고, 모든 변수들도 전역적으로 선언되어 있습니다. 실제 게임을 만드실 때, 이렇게 프레임워크를 만드시면 절대 안됩니다. 다시 한 번 말씀드리는데 이 프레임워크는 쉐이더 데모를 실행할 수 있도록 만든 프로그램일 뿐입니다.

자, 그럼 적당히 주의도 드렸으니 이제 프레임워크를 살펴보도록 합시다. 우선 BasicFramework.h를 엽니다.

//**********************************************************************
//
// ShaderFramework.h
//
// 쉐이더 데모를 위한 C스타일의 초간단 프레임워크입니다.
// (실제 게임을 코딩하실 때는 절대 이렇게 프레임워크를
// 작성하시면 안됩니다. -_-)
//
// Author: Pope Kim
//
//**********************************************************************


#pragma once

#include <d3d9.h>
#include <d3dx9.h>

// ---------- 선언 ------------------------------------
#define WIN_WIDTH  800
#define WIN_HEIGHT  600

// ---------------- 함수 프로토타입 ------------------------

// 메시지 처리기 관련
LRESULT WINAPI MsgProc( HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam );
void ProcessInput(HWND hWnd, WPARAM keyPress);

// 초기화 과련
bool InitEverything(HWND hWnd);
bool InitD3D(HWND hWnd);
bool LoadAssets();
LPD3DXEFFECT LoadShader( const char * filename );
LPDIRECT3DTEXTURE9 LoadTexture(const char * filename);
LPD3DXMESH LoadModel(const char * filename);

// 게임루프 관련
void PlayDemo();
void Update();

// 렌더링 관련
void RenderFrame();
void RenderScene();
void RenderInfo();

// 뒷정리 관련
void Cleanup();



이 헤더파일에서 눈 여겨 볼만한 것은 WIN_WIDTH와 WIN_HEIGHT밖에 없습니다. 이 두 #define문은 데모 프로그램의 창 크기를 정의합니다. 나머지 코드들은 단순히 함수선언들일 뿐입니다. 실제 함수들의 구현은 ShaderFramework.cpp 파일에 들어 있으니 ShaderFramework.cpp 파일을 열어보도록 할까요?

이 파일의 제일 위에는 다음과 같은 전역변수들이 정의되어 있습니다.

//----------------------------------------------------------------------
// 전역변수
//----------------------------------------------------------------------

// D3D 관련
LPDIRECT3D9       gpD3D          = NULL;        // D3D
LPDIRECT3DDEVICE9 gpD3DDevice    = NULL;        // D3D 장치

// 폰트
ID3DXFont*        gpFont         = NULL;

// 모델

// 쉐이더

// 텍스처

// 프로그램 이름
const char*       gAppName        = "초 간단 쉐이더 데모 프레임워크";



이제 프로그램의 창을 생성할 차례입니다.


//-----------------------------------------------------------------------
// 프로그램 진입점/메시지 루프
//-----------------------------------------------------------------------

// 진입점
INT WINAPI WinMain( HINSTANCE hInst, HINSTANCE, LPSTR, INT )
{


프로그램의 창을 생성하려면 우선 윈도우 클래스를 등록해야 합니다.


    // 윈도우 클래스를 등록한다.
    WNDCLASSEX wc = { sizeof(WNDCLASSEX), CS_CLASSDC, MsgProc, 0L, 0L,
                      GetModuleHandle(NULL), NULL, NULL, NULL, NULL,
                      gAppName, NULL };
    RegisterClassEx( &wc );



이제 CreateWindow 함수를 사용해서 위에서 등록한 윈도우 클래스의 인스턴스를 만듭니다. 이 때, 앞서 정의했던 WIN_WIDTH와 WIN_HEIGHT를 창의 크기로 지정합니다.


    // 프로그램 창을 생성한다.
    DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX;
    HWND hWnd = CreateWindow( gAppName, gAppName,
                              style, CW_USEDEFAULT, 0, WIN_WIDTH, WIN_HEIGHT,
                              GetDesktopWindow(), NULL, wc.hInstance, NULL );


창의 크기를 WIN_WIDTH와 WIN_HEIGHT로 만들면 실제 렌더링을 할 수 있는 공간이 이보다 작습니다. 창의 크기에 타이틀 바 및 경계선이 포함되기 때문이라죠. 따라서 실제 렌더링이 가능한 공간(client rect)이 WIN_WIDTH와 WIN_HEIGHT와 같도록 창의 크기를 재조정해야 겠네요.

    // Client Rect 크기가 WIN_WIDTH, WIN_HEIGHT와 같도록 크기를 조정한다.
    POINT ptDiff;
    RECT rcClient, rcWindow;
 
    GetClientRect(hWnd, &rcClient);
    GetWindowRect(hWnd, &rcWindow);
    ptDiff.x = (rcWindow.right - rcWindow.left) - rcClient.right;
    ptDiff.y = (rcWindow.bottom - rcWindow.top) - rcClient.bottom;
    MoveWindow(hWnd,rcWindow.left, rcWindow.top, WIN_WIDTH + ptDiff.x, WIN_HEIGHT + ptDiff.y, TRUE);


이제 창의 크기도 적절히 조정했으니 창을 보여줄 차례입니다.

    ShowWindow( hWnd, SW_SHOWDEFAULT );
    UpdateWindow( hWnd );



다음은 Direct3D를 초기화하고 모든 D3D 자원들(텍스처, 쉐이더, 메쉬 등)을 로딩합니다. 이 모든 기능들은 InitEverything() 함수 안에 포함되어 있습니다. 만약 Direct3D 및 기타 초기화에 실패하면 데모를 보여주는 게 불가능하므로 프로그램을 종료합니다.
 

    // D3D를 비롯한 모든 것을 초기화한다.
    if( !InitEverything(hWnd) )
    {
        PostQuitMessage(1);
    }


D3D 및 기타 초기화를 마쳤다면 남은 일은 WM_QUIT 메시지를 받을 때까지 데모를 실행하는 것이 전부입니다. WM_QUIT은 데모를 종료하라는 윈도우 메시지입니다.


    // 메시지 루프
    MSG msg;
    ZeroMemory(&msg, sizeof(msg));
    while(msg.message!=WM_QUIT)
    {
        if( PeekMessage( &msg, NULL, 0U, 0U, PM_REMOVE ) )
        {
            TranslateMessage( &msg );
            DispatchMessage( &msg );
        }
        else // 메시지가 없으면 게임을 업데이트하고 장면을 그린다
        {
            PlayDemo();
        }
    }


데모를 종료할 때가 되면 윈도우 클래스의 등록을 해제하고 프로그램을 끝마칩니다.


    UnregisterClass( gAppName, wc.hInstance );
    return 0;
}


다음은 윈도우 메시지를 처리하는 함수입니다.


// 메시지 처리기
LRESULT WINAPI MsgProc( HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam )
{
    switch( msg )
    {


키보드 입력은 ProcessInput이라는 함수에서 처리할 것입니다.

    case WM_KEYDOWN:
        ProcessInput(hWnd, wParam);
        break;



창이 닫힐 때는 초기화 도중에 생성했던 D3D 자원들을 해제하고 프로그램을 종료하라는 메시지를 보냅니다.


    case WM_DESTROY:
        Cleanup();
        PostQuitMessage(0);
        return 0;
    }


이 데모에서 처리하지 않는 윈도우 메시지들은 기본(default) 메시지 처리기가 처리하도록 합니다.

    return DefWindowProc( hWnd, msg, wParam, lParam );
}


이 프레임워크가 현재 처리하는 키보드 입력은 ESC 키가 전부입니다. ESC키가 눌리면 프로그램의 실행을 마칩니다.

// 키보드 입력처리
void ProcessInput( HWND hWnd, WPARAM keyPress)
{
 switch(keyPress)
 {
 // ESC 키가 눌리면 프로그램을 종료한다.
 case VK_ESCAPE:
  PostMessage(hWnd, WM_DESTROY, 0L, 0L);
  break;
 }
}



이제 초기화 코드를 살펴볼까요?


//------------------------------------------------------------
// 초기화 코드
//------------------------------------------------------------
bool InitEverything(HWND hWnd)
{


우선 InitD3D함수를 호출하여 D3D를 초기화합니다. D3D 초기화에 실패하지 않았다면 LoadAssets() 함수를 통해 모델, 쉐이더, 텍스처 등의 D3D 자원들을 로딩합니다.

    // D3D를 초기화
    if( !InitD3D(hWnd) )
    {
        return false;
    }

    // 모델, 쉐이더, 텍스처 등을 로딩
    if( !LoadAssets() )
    {
        return false;
    }


그 다음은 폰트를 로딩할 차례입니다. 이 폰트를 사용하여 화면에 디버그 정보 등을 보여줄 것입니다.


    // 폰트를 로딩
    if(FAILED(D3DXCreateFont( gpD3DDevice, 20, 10, FW_BOLD, 1, FALSE,
        DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, DEFAULT_QUALITY,
        (DEFAULT_PITCH | FF_DONTCARE), "Arial", &gpFont )))
    {
 return false;
    }

    return true;
}



위의 D3DXCreateFont()에서 사용했던 각 매개변수의 의미는 순서대로 다음과 같습니다.

  • gpD3DDevice: D3D 장치
  • 20: 폰트의 높이
  • 10: 폰트의 너비
  • FW_BOLD: 볼드체(두꺼운 폰트)를 이용함
  • 1: 밉맵레벨
  • FALSE: 이탤릭체를 쓰지 않음
  • DEFAULT_CHARSET: 기본 문자셋을 사용
  • OUT_DEFAULT_PRECIS: 실제 화면에 출력되는 폰트가 여기서 지정한 속성과 어느 정도 비슷해야 하는지를 설정
  • DEFAULT_QUALITY: 여기서 지정하는 폰트와 실제 폰트의 품질이 얼마나 비슷해야 하는지 설정
  • DEFAULT_PITCH | FF_DONTCARE: 기본 피치를 사용하고 폰트군은 상관 없음
  • "Arial": 사용할 폰트이름
  • gpFont: 새로 만든 폰트를 저장할 포인터


이제 D3D 객체와 D3D 장치를 생성하는 InitD3D 함수를 살펴봅시다. D3D를 이용해서 자원을 로딩하거나 렌더링을 하려면 반드시 D3D장치를 생성해야 합니다.


// D3D 객체 및 장치 초기화
bool InitD3D(HWND hWnd)
{

우선 Direct3D 객체를 구합니다.

    // D3D 객체
    gpD3D = Direct3DCreate9( D3D_SDK_VERSION ); 
    if ( !gpD3D )
    {
        return false;
    }


그 다음에는 D3D 장치를 생성할 때 필요한 구조체에 정보를 채워 넣겠습니다.

    // D3D장치를 생성하는데 필요한 구조체를 채워 넣는다.
    D3DPRESENT_PARAMETERS d3dpp;
    ZeroMemory( &d3dpp, sizeof(d3dpp) );

    d3dpp.BackBufferWidth            = WIN_WIDTH;
    d3dpp.BackBufferHeight           = WIN_HEIGHT;
    d3dpp.BackBufferFormat           = D3DFMT_X8R8G8B8;
    d3dpp.BackBufferCount            = 1;
    d3dpp.MultiSampleType            = D3DMULTISAMPLE_NONE;
    d3dpp.MultiSampleQuality         = 0;
    d3dpp.SwapEffect                 = D3DSWAPEFFECT_DISCARD;
    d3dpp.hDeviceWindow              = hWnd;
    d3dpp.Windowed                   = TRUE;
    d3dpp.EnableAutoDepthStencil     = TRUE;
    d3dpp.AutoDepthStencilFormat     = D3DFMT_D24X8;
    d3dpp.Flags                      = D3DPRESENTFLAG_DISCARD_DEPTHSTENCIL;
    d3dpp.FullScreen_RefreshRateInHz = 0;
    d3dpp.PresentationInterval       = D3DPRESENT_INTERVAL_ONE;



위에서 주목할만한 정보들은 다음과 같습니다.

  • BackBufferWidth: 백버퍼(렌더링 영역)의 너비
  • BackBuferHeight: 백버퍼의 높이
  • BackBufferFormat: 백버퍼의 포맷
  • AutoDepthStencilFormat: 깊이/스텐실 버퍼의 포맷
  • SwapEffect: 백버퍼를 스왑(swap)할 때의 효과. 성능 상 D3DSWAPEFFECT_DISCARD를 사용하는 것이 좋습니다.
  • PresentationInterval: 모니터 주사율과 백버퍼를 스왑하는 빈도간의 관계. D3DPRESENT_INTERVAL_ONE은 모니터가 수직동기될 때마다 백버퍼를 스왑해 줍니다. 게임에서는 성능상 모니터의 수직동기를 기다리지 않고 곧바로 스왑해 주는 경우가 많습니다.(D3DPRESENT IMMEDIATE). 단, 이럴 땐 전 프레임과 현재 프레임이 서로 찢겨 보이는 부작용이 있습니다.

이제 위에서 채워 넣은 정보들을 이용해서 D3D장치를 생성합니다.

    // D3D장치를 생성한다.
    if( FAILED( gpD3D->CreateDevice( D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hWnd,
        D3DCREATE_HARDWARE_VERTEXPROCESSING,
        &d3dpp, &gpD3DDevice ) ) )
    {
        return false;
    }

    return true;
}


D3D 자원을 읽어오는 LoadAssets() 함수 안에는 현재 아무 내용도 들어있지 않습니다. 이 책을 진행하면서 텍스처, 쉐이더, 모델 등을 읽어올 일이 있을 때마다 이 함수 안에서 LoadShader(), LoadTexture(), LoadModel() 함수들을 호출하겠습니다.

bool LoadAssets()
{
    // 텍스처 로딩

    // 쉐이더 로딩

    // 모델 로딩

    return true;
}


다음은 .fx 포맷으로 저장된 쉐이더 파일을 불러오는 LoadShader() 함수입니다. .fx파일은 정점쉐이더와 픽셀쉐이더를 모두 포함하고 있는 텍스트 파일로 D3DXCreateEffectFromFile() 함수를 통해 로딩 및 컴파일합니다. 따라서 HLSL코드에 문법적인 오류가 있다면 이 함수를 호출하는 도중에 쉐이더 컴파일 에러가 나겠죠? 그럴 때는 D3DXCreateEffectFromFile()의 마지막 매개변수를 통해 에러메시지를 구해올 수 있습니다. 이 에러메시지를 비주얼 C++의 출력 창에 보여주도록 하겠습니다.


// 쉐이더 로딩
LPD3DXEFFECT LoadShader(const char * filename )
{
    LPD3DXEFFECT ret = NULL;
    LPD3DXBUFFER pError = NULL;
    DWORD dwShaderFlags = 0;

#if _DEBUG
    dwShaderFlags |= D3DXSHADER_DEBUG;
#endif

    D3DXCreateEffectFromFile(gpD3DDevice, filename,
        NULL, NULL, dwShaderFlags, NULL, &ret, &pError);

    // 쉐이더 로딩에 실패한 경우 output창에 쉐이더
    // 컴파일 에러를 출력한다.
    if(!ret && pError)
    {
        int size  = pError->GetBufferSize();
        void *ack = pError->GetBufferPointer();

        if(ack)
        {
            char* str = new char[size];
            sprintf(str, (const char*)ack, size);
            OutputDebugString(str);
            delete [] str;
        }
    }

    return ret;
}


위에서 D3DXCreateEffectFromFile() 함수를 호출할 때 사용한 인자들의 의미는 순서대로 아래와 같습니다.

  • gpD3DDevice: D3D 장치
  • filename: 읽어올 쉐이더 파일의 이름
  • NULL: 쉐이더를 컴파일 할 때 추가로 사용할 #define 정의
  • NULL: #include 지시문을 처리할 때 사용할 인터페이스 포인터
  • dwShaderFlags: 쉐이더를 컴파일 할 때 사용할 플래그
  • NULL: 매개변수 공유에 사용할 이펙트 풀
  • ret: 로딩된 이펙트를 저장할 포인터
  • pError: 컴파일러 에러 메시지를 가리킬 포인터

다음은 .x 포맷으로 저장된 모델을 로딩해오는 코드입니다. .x 파일은 DirectX에서 자체적으로 지원하는 메쉬 포맷입니다.

// 모델 로딩
LPD3DXMESH LoadModel(const char * filename)
{
    LPD3DXMESH ret = NULL;
    if ( FAILED(D3DXLoadMeshFromX(filename,D3DXMESH_SYSTEMMEM, gpD3DDevice,
        NULL,NULL,NULL,NULL, &ret)) )
    {
        OutputDebugString("모델 로딩 실패: ");
        OutputDebugString(filename);
        OutputDebugString("\n");
    };

    return ret;
}


위에서 D3DXLoadMeshFromX() 호출에 사용했던 매개변수의 의미는 순서대로 다음과 같습니다.

  • filename: 로딩해 올 메쉬의 파일명
  • D3DXMESH_SYSTEMMEM: 시스템 메모리에 메쉬를 로딩할 것
  • gpD3DDevice: D3D 장치
  • NULL: 인접 삼각형 데이터를 따로 구해오지 않음.
  • NULL: 머테리얼(material) 정보를 따로 구해오지 않음
  • NULL: 이펙트 인스턴스를 따로 구해오지 않음
  • NULL: 머테리얼 수를 따로 구해오지 않음
  • ret: 로딩해온 메쉬를 저장할 포인터

아래는 다양한 포맷으로 저장된 이미지들을 텍스처로 로딩해 오는 LoadTexture() 함수입니다.

// 텍스처 로딩
LPDIRECT3DTEXTURE9 LoadTexture(const char * filename)
{
    LPDIRECT3DTEXTURE9 ret = NULL;
    if ( FAILED(D3DXCreateTextureFromFile(gpD3DDevice, filename, &ret)) )
    {
        OutputDebugString("텍스처 로딩 실패: ");
        OutputDebugString(filename);
        OutputDebugString("\n");
    }

    return ret;
}



다음은 게임루프 함수인 PlayDemo()입니다. 이 함수는 처리할 윈도우 메시지가 없을 때마다 호출됩니다. 실제 게임에서는 지난 프레임으로부터 경과한 시간을 구해서 업데이트 및 렌더링에 사용할 테지만 이 프레임워크에서는 간략함을 위해 그 부분을 생략했습니다.


//------------------------------------------------------------
// 게임루프
//------------------------------------------------------------
void PlayDemo()
{
    Update();
    RenderFrame();
}


현재 Update() 함수에는 아무 내용도 없습니다. 나중에 필요하다면 코드를 채워 넣도록 하지요.


// 게임로직 업데이트
void Update()
{
}


다음은 정작 무언가를 그리는 함수인 RenderFrame()입니다.

//------------------------------------------------------------
// 렌더링
//------------------------------------------------------------

void RenderFrame()
{


우선 화면을 파란색으로 지웁니다.

    D3DCOLOR bgColour = 0xFF0000FF; // 배경색상 - 파랑

    gpD3DDevice->Clear( 0, NULL, (D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER),
        bgColour, 1.0f, 0 );


그 후, 장면(scene)과 디버그 정보를 그립니다.

    gpD3DDevice->BeginScene();
    {
        RenderScene();  // 3D 물체등을 그린다.
        RenderInfo();  // 디버그 정보 등을 출력한다.
    }
    gpD3DDevice->EndScene();



모든 그리기를 마쳤다면 백 버퍼에 저장되어 있는 렌더링 결과를 화면에 뿌려줍니다.

    gpD3DDevice->Present( NULL, NULL, NULL, NULL );
}



현재 3D 물체 등을 그리는 RenderScene() 함수에는 아무 코드도 들어있지 않습니다. 다음 장에서 3D 물체를 그릴 때 여기에 코드를 채워 넣도록 하지요.

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


RenderInfo() 함수는 간단한 키 매핑 정보를 화면에 보여줍니다.

// 디버그 정보 등을 출력.
void RenderInfo()
{
    // 텍스트 색상
    D3DCOLOR fontColor = D3DCOLOR_ARGB(255,255,255,255);  

    // 텍스트를 출력할 위치
    RECT rct;
    rct.left=5;
    rct.right=WIN_WIDTH / 3;
    rct.top=5;
    rct.bottom = WIN_HEIGHT / 3;
 
    // 키 입력 정보를 출력
    gpFont->DrawText(NULL, "데모 프레임워크\n\nESC: 데모종료", -1, &rct,
        0, fontColor );
}


프로그램을 종료할 때, GPU상의 메모리 누수를 방지하려면 D3D를 통해 생성했던 자원들을 모두 해제(release)해줘야 합니다. 자원들을 모두 해제한 뒤에는 D3D 장치와 D3D 객체들도 해제해 줍니다.

//------------------------------------------------------------
// 뒷정리 코드.
//------------------------------------------------------------

void Cleanup()
{
    // 폰트를 release 한다.
    if(gpFont)
    {
        gpFont->Release();
        gpFont = NULL;
    }

    // 모델을 release 한다.

    // 쉐이더를 release 한다.

    // 텍스처를 release 한다.

    // D3D를 release 한다.
    if(gpD3DDevice)
    {
        gpD3DDevice->Release();
        gpD3DDevice = NULL;
    }

    if(gpD3D)
    {
        gpD3D->Release();
        gpD3D = NULL;
    }
}



자, 이것으로 아주 간단한 쉐이더 프레임워크의 작성을 마쳤습니다. 위의 코드가 잘 이해가 안 되시는 분들이 계실지도 모르겠는데, 이 책에서 HLSL 프로그래밍을 익히는 데는 크게 문제가 안됩니다. 단, 그래픽 프로그래머가 되는 것이 꿈이신 분들은 이 책을 마친 뒤에라도 반드시 DirectX를 제대로 배우시라고 권해드리고 싶습니다.

여기까지 다소 지루한 준비과정을 견뎌내시느라 수고하셨습니다. 후딱 정리를 마친 뒤에 다음 장부터 실제로 재미있게 쉐이더를 작성해 보기로 하죠!

정리
다음은 이 장에서 배운 내용을 짧게 요약해 놓은 것입니다.

  • 쉐이더는 어느 픽셀을 어떤 색으로 칠할지를 계산하는 함수이다.
  • 쉐이더를 화가가 그림을 그리는 것에 비유하면 정점쉐이더는 투시를 픽셀쉐이더는 명암을 담당한다.
  • 쉐이더 프로그래밍이란 정점쉐이더와 픽셀쉐이더에서 실행시킬 함수를 작성하는 것이다.
  • AMD사의 렌더몽키를 사용하면 재빨리 쉐이더를 프로토타입할 수 있다.

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


포프 올림




  1. DirectX 10과 11에서 새로운 쉐이더들이 추가되었습니다. 하지만 아직 실무에서 널리 사용되지 않아서 실용적인 접근이 어렵고, 입문자에게 적당하지 않은 내용이라 이 책에서 다루지 않습니다. [본문으로]
  2. OpenGL Shader Language의 약자로 OpenGL에서 지원하는 쉐이더 언어입니다. HLSL과 문법 정도가 조금 다릅니다. [본문으로]
  3. 엔비디아에서 지원하는 쉐이더 언어입니다. HLSL과 한두 개 빼고는 완전히 똑같습니다. [본문으로]
  4. 지면이 늘어나면 쓸데없이 책 값도 오릅니다. [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License
KGC 2011에서 발표했던 자료입니다. 

 
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License

제1장 쉐이더란 무엇이죠?

쉐이더의 정의
제가 학생들을 가르치면서 제일 처음 듣는 질문 중 하나가 '도대체 쉐이더가 뭐에요?'였습니다. 사실 뒤돌아보면 제가 쉐이더를 처음 접할 때도 스스로 이런 질문을 던지곤 했었는데 그 누구도 저에게 이해하기 쉽도록 '쉐이더란 바로 이런 것이다!' 라고 설명을 해준 적이 없더군요. 또한, 기존의 자료들도 찾아봤는데 학생들이 쉽게 이해할만한 정의를 내려주는 자료를 찾을 수 없었습니다. 그래서 제 맘대로(?) 아주 쉽게 정의를 내렸습니다. 쉐이더란 화면에 출력할 픽셀의 위치와 색상을 계산하는 함수입니다. 어휘적/구조적인 측면에서 쉐이더를 살펴보면 이를 자세히 이해할 수 있을 겁니다.

어휘적 접근
사실 영어만 잘해도 거저 주워 먹는 것이 많은 분야가 컴퓨터 프로그래밍입니다. 쉐이더만 해도 크게 예외는 아닌데요. 쉐이더(shader)란 '색의 농담, 색조, 명암 효과를 주다.'라는 뜻을 가진 shade란 동사와 행동의 주체를 나타내는 접미사 '-er'을 혼합한 단어입니다. 즉, 색의 농담, 색조, 명암 등의 효과를 주는 주체가 쉐이더란 뜻이지요. 컴퓨터 그래픽에서 색이라 하면 당연히 화면에 등장하는 픽셀의 색상이므로 이를 다시 정리하면 다음과 같습니다.

쉐이더는 픽셀의 농담, 색조, 명암을 결정한다.

여기서 농담, 색조, 명암이라고 하니 '아니, 그렇다면 쉐이더가 출력하는 결과가 3개나 된다는 말인가요?'라고 하시는 분들이 계실 듯한데 그런 것은 아닙니다. 쉐이더의 최종결과는 농담, 색조, 명암 효과를 전부 짬뽕해서 나온 RGBA색상 값 하나입니다.[각주:1] 미술시간에 수채화 그려봤던 것 기억하시죠? 일단 밑그림을 완성하면 물감의 색을 고르고, 여기에 물을 혼합시키는 양을 바꿔가면서 다양한 명암효과를 냅니다. 하지만, 일단 그림이 완성되면 캔버스에 있는 결과는 결국 최종색상뿐이죠? 쉐이더도 이와 마찬가지입니다. 온갖 기법들을 이리저리 섞어서 픽셀들의 최종 색상 값을 구하는 것이 바로 쉐이더입니다.

구조적 접근
저희가 이 책에서 다루는 쉐이더는 정점쉐이더(vertex shader)와 픽셀쉐이더(pixel shader)인데 위의 어휘적 접근에서 살펴봤던 쉐이더의 정의는 이 중 하나에만 적용됩니다. 어떤 것일까요? 네, 그렇습니다. 픽셀쉐이더 입니다. 그렇다면 정점쉐이더란 무엇일까요? 이걸 이해하려면 3D 그래픽파이프라인의 구조를 살펴봐야겠군요.

3D 파이프라인이 존재하는 이유 중 하나는 3차원 공간에 존재하는 물체를 컴퓨터 모니터라는 2차원 평면 위에 보여주기 위해서입니다. 우선 3D 그래픽파이프라인을 극단적으로 간략화시킨 그림 1.1을 살펴봅시다.[각주:2]

그림 1.1 극단적으로 간략화시킨 3D 파이프라인




그림 1.1에서 정점쉐이더가 입력 값으로 받는 것은 3D 모델 자체입니다. 3D 모델은 폴리곤(polygon, 다각형)으로 구성하는 것이 업계표준인데, 폴리곤이란 결국 삼각형들의 집합에 지나지 않습니다. 삼각형은 3개의 정점(vertex)[각주:3]으로 이뤄져 있죠? 그러니 정점데이터가 정점쉐이더의 입력 값이라고 해도 전혀 틀린 게 아니겠네요.

정점쉐이더가 수행하는 가장 중요한 임무는 3D 물체를 구성하는 정점들의 위치를 화면좌표로 변환하는 것입니다. 이를 화가에 비유한다면 투시원근법을 사용하여 실제세계에 있는 물체들을 캔버스 위에 옮겨 그리는 과정이라 할까요? 이렇게 물체의 위치를 다른 공간으로 옮기는 과정을 공간변환(space transformation)이라고 부르는데 이에 대한 자세한 설명은 다음 장에서 드리도록 하겠습니다. 조금 전에 3D 모델은 결국 정점들의 집합이라고 말씀드렸었죠? 따라서 모든 정점을 하나씩 공간 변환하면 3D 물체 자체를 공간 변환하는 것과 똑같은 결과를 얻을 수 있습니다. 이게 바로 정점쉐이더가 하는 일이지요. 그렇다면 정점쉐이더 함수는 몇 번이나 호출될까요? 다음 문장을 보시면 답을 아실 수 있겠네요.

정점쉐이더의 주된 임무는 각 정점의 공간을 변환하는 것이다.

네, 그렇습니다. 정점쉐이더는 3D 물체를 구성하는 정점의 수만큼 실행됩니다.

정점쉐이더가 반드시 출력하는 결과 값은 화면공간 안에 존재하는 정점의 위치[각주:4]입니다. 이 위치를 3개씩 그룹 지으면 화면에 출력할 삼각형을 만들 수 있지요.

자, 그렇다면 이 삼각형 안에 픽셀이 몇 개나 들어갈까요? 화면을 구성하는 단위는 픽셀이니까 화면에 뭔가 그림을 그리려면 픽셀을 어디에 몇 개나 그려야 하는지를 알아야겠죠? 이게 바로 래스터라이저(rasterizer)란 장치가 하는 일입니다. 래스터라이저는 정점쉐이더가 출력하는 정점의 위치를 차례대로 3개씩 모아 삼각형을 만든 뒤, 그 안에 들어갈 픽셀들을 찾아냅니다. 자, 그러면 픽셀쉐이더 함수는 몇 번이나 호출될까요? 래스터라이저가 찾아내는 픽셀 수 만큼이겠죠?

그렇다면 위에서 보여드렸던 초 간략 파이프라인의 마지막 단계인 픽셀쉐이더가 하는 일은 무엇일까요? 이미 위에서 살펴본 것 같지만 그래도 다시 한번 반복해 드리지요.

픽셀쉐이더의 주된 임무는 화면에 출력할 최종색상을 계산하는 것이다.

이제 정점쉐이더와 픽셀쉐이더의 임무를 합치면 아까 제 맘대로 내렸던 쉐이더의 정의가 나오죠?

쉐이더란 화면에 존재하는 각 픽셀의 위치와 색상을 계산하는 함수이다.

솔직히 이 정도 말씀을 드려도 쉐이더를 처음 접하시는 분들은 아직도 감이 안 잡히실 겁니다. 사실 쉐이더를 짜 보지 않으면 이해가 어렵습니다. 3D 물체를 화면에 그릴 때, 그 물체를 구성하는 픽셀들의 위치와 색을 프로그래머 맘대로 조작하는 거라고 하면 이해가 좀 더 되실까요? 아직도 이해가 안되시더라도 크게 걱정은 마세요 이 책을 읽으시다 보면 '아~ 이런 거였구나~'하고 갑자기 이해가 되실 겁니다. ^^


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



포프 올림



  1. 쉐이더가 반드시 한가지 색상만을 출력해야 하는 것은 아닙니다. 고급 쉐이더 기법들에서는 다수의 결과를 동시에 출력하는 경우가 있습니다. [본문으로]
  2. 이 그림은 정점쉐이더 및 픽셀쉐이더의 역할을 이해하기 위해서 극단적으로 간략화시킨 버전입니다. 실제 그래픽 파이프라인은 이 그림에 나와 있는 것보다 훨씬 복잡합니다. [본문으로]
  3. 꼭짓점이라고도 합니다. [본문으로]
  4. 이외에도 다양한 정보를 정점쉐이더의 결과 값에 담을 수 있습니다. 자세한 내용은 이 책의 뒷부분에서 살펴볼 것입니다. [본문으로]
저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License

[포프의 쉐이더 입문강좌] 00. 들어가며

Shader 2011.11.21 09:30 Posted by 김포프

(원래 출판을 목적으로 썼던 책인데 출판사 사정으로 책 출판이 어려워져 인터넷에 그대로 공개합니다.)

들어가며

안녕하세요. 캐나다 렐릭 엔터테인먼트에서 시니어 그래픽 프로그래머로 일하고 있는 포프입니다. 이 쉐이더 입문 책은 제가 2007년 1월부터 2009년 12월까지 3년간 캐나다의 The Art Institute of Vancouver 대학에서 쉐이더 프로그래밍 강의를 하면서 축적한 자료와 지식을 글로 옮겨 놓은 것입니다.

책을 쓰게 된 배경
2007년에 제가 강의를 시작할 때, 수업시간에 사용할 교과서를 찾으려고 참 많은 노력을 했습니다. 하지만 시중에 나와있는 책들 중, 쉐이더 입문과목에 적합한 놈이 없더군요. (몇 년이 지난 지금에도 마찬가지인 것 같습니다.) 시중에 나와있는 쉐이더 책들은 대부분 이미 쉐이더 코드를 짤 줄 아는 중고급 프로그래머를 위한 것이었습니다. 따라서 쉐이더에 입문하는 학생들이 보면 뭔 소린지 몰라서 그냥 포기할 게 뻔했죠. 그나마 쉐이더 입문 내용이 DirectX 책에 담겨있는 경우가 있었지만 그 중에서도 마땅한 책이 없다고 생각했던 이유가

  • 쉐이더는 구색 맞추기 식으로 넣어놓아서 너무 수박 겉 핥기 식이다.
  • 학계에 계신 분들이 쓴 책은 너무 이론이나 문법에만 치우쳐져 있다.
  • 실무에 그다지 쓸모가 없는 내용들을 너무 많이 담고 있다.
  • 지면수만 많아 책값이 너무 비싸다.
등 이었습니다. 그래서 결국엔 교과서 없이 강의를 시작했죠. 이론이나 수학에 치우치기 보다는 실무에 곧바로 쓸 수 있거나 실무에서 쓸 수 있는 기법의 기초가 되는 내용들만을 가르치는데 주력했습니다. 강의를 하면서 좋았던 점은 저는 그리 어렵지 않다고 생각해왔는데 학생들이 이해하지 못하는 부분들을 알아낼 수 있다는 거였죠. 그래서 그걸 다시 쉽게 이해시킬 수 있도록 강의자료를 다듬고 다듬은 결과가 바로 이 책입니다. 강의를 하는 3년 내내 게임프로그래밍 학과 학생들이 이 과목을 AI 대학의 가장 훌륭한 수업으로 꼽을 정도였으니 (좀 부끄럽지만) 이 책을 자신있게 권해드릴 수 있을 것 같습니다. 그리고 제 과목에서 만든 데모 프로그램을 포트폴리오로 삼아서 Ubisoft 및 EA같은 세계 유수의 회사에 취직한 학생들도 몇 됩니다. 가슴 뿌듯한 일이죠. ^^

현재는 게임개발에 좀 더 집중해 보려고 대학강의를 중단한 상황이지만 이 내용을 그냥 썩혀두기엔 아깝다고 생각되어 책을 내기로 결정을 했습니다. 이 책이 쉐이더를 배우시려는 분들에게 도움이 될 수 있었으면 좋겠습니다.

이 책의 기본원칙
강의에서도 그랬듯이 이 책을 쓸 때 다음의 원칙을 기초로 삼았습니다.

  • 실습 위주: 물론 쉐이더를 짤 때 수학이나 이론을 전혀 무시할 수는 없습니다. 하지만, 이론을 먼저 배우고 그걸 코드로 옮기는 것보단 일단 코드를 좀 짜본 뒤에 뭔가 막히면 이론을 찾아보는 것이 훨씬 훌륭한 학습방법입니다. 이렇게 문제를 해결하기 위해 찾아본 이론은 기억에 오래 남습니다. 따라서 이 책은 실습위주로 구성되어있습니다. 책의 내용을 한 줄씩 따라 하면서 코드를 짜다 보면 어느덧 배경 이론까지 적당히 이해하시게 될 겁니다.
  • 쉬운 설명: 제 수업에 청강을 하러 오는 게임아트 학과 학생들도 꽤 있었습니다. 따라서 아티스트들도 이해할 수 있도록 쉽게 설명을 하는 것이 제 목표 중 하나였습니다. 그러려면 무언가를 설명할 때, 수학공식을 보여주기 보다는 실제 생활에서 일어나는 현상을 예로 드는 것이 낫더군요. 이 책을 쓸 때도 마찬가지 원칙을 따랐습니다. 책을 읽으시다 보면 100% 이론적으로 옳지 않은 설명들도 가끔 보실 겁니다. 이것은 말 그대로 설명을 쉽게 하기 위해 제가 이론들을 적당히 무시하였거나 아니면 저 조차 이론을 100% 제대로 이해하지 못하는 경우입니다. 게임 그래픽은 어차피 눈에 보이는 결과가 맞으면 그게 정답인 분야이므로 이론적으로 약간 틀려도 결과만 맞으면 전 크게 신경 쓰지 않습니다.
  • 입문자만을 위한 책: 이 책은 순수하게 입문자를 위한 책입니다. 이미 고급기법을 다루는 훌륭한 책이 많이 나와있는 상황에서 굳이 그 책들과 경쟁할 필요를 못 느끼고, 중복되는 내용을 다루면서 지면수를 늘리고 싶지도 않기 때문입니다. 이 책을 보신 후에 쉐이더에 재미가 붙으신 분들이 다른 고급기법들을 즐겁게 찾아 보실 수 있다면 전 행복합니다. 그리고 정말 괜찮은 새 기법을 찾으시면 저에게 살짝 귀뜸이라도 해주시면 더 좋겠지요. ^^
  • 순서대로 배우는 내용: 강의를 할 때 좋았던 점은 쉬운 내용부터 어려운 내용까지 순서대로 가르칠 수 있었다는 것입니다. 이 책도 그런 식으로 진행이 됩니다. 처음 장부터 시작해서 천천히 지식을 축적해간다고 할까요? 따라서 뒷장으로 가면 갈 수록 기본적인 내용은 다시 설명을 하지 않습니다. 예를 들면, 법선매핑을 배우기 전에 이미 조명기법들을 배워보므로 법선매핑에서는 조명기법에 대해 다시 설명하지 않는거죠. 따라서 이 책을 읽으실 때는 대학강의를 들으시듯이 처음부터 순서대로 읽으셔야 합니다. 이 책이 다른 쉐이더 책들처럼 여러 논문을 한군데 모아놓은 게 아니니 그 정도는 이해해주시면 좋겠습니다. 그리고 지면수도 그리 많지 않으니 무리한 요구는 아닐 거라고 믿습니다.

이 책에서 다루는 내용
이 책에서 다루는 내용은 정점쉐이더와 픽셀쉐이더를 이용한 쉐이더 기법들입니다. 이 책은 크게 세 부분으로 나뉘어져 있습니다.
  • 제1부: 쉐이더의 정의를 알아 본 뒤, 모든 쉐이더 기법의 기초가 되는 색상, 텍스처 매핑, 조명 쉐이더를 만들어 봅니다.
  • 제2부: 1부에서 배운 내용에 살을 붙여 게임에서 널리 사용하는 스페큘러매핑, 법선매핑, 그림자 매핑 등의 기법들을 구현합니다.
  • 제3부: 요즘 게임에서 점점 중요해져 가는 2D 이미지 처리 기법을 배워봅니다.


이 책에서 DirectX 10과 11에서 새로 추가된 지오메트리(geometry), 헐(hull), 연산(compute) 쉐이더들을 다루지 않는 이유는 초급자에겐 좀 어려운 내용일 뿐만 아니라 아직 실무에서 널리 이용되지 않기 때문입니다. 따라서 실용적인 내용을 알려드리기가 좀 어렵죠. DirectX 10이 처음 소개될 때만 해도 홍보자료에서는 엄청 대단한 것처럼 광고를 해댔지만 실제 실무에서 제대로 이용한 경우가 없으니까요. 일단 이 책에서 기초를 다잡으시면 몇 년 뒤에  이 내용을 배우셔도 크게 문제가 없을 겁니다.

대상독자
프로그래머
제가 가르쳤던 학생들은 게임 프로그래밍 학과 2학년 학생들이었습니다. 제 과목을 듣기 전에 C++, 3D 수학, DirectX 등을 이미 마친 학생들이었지요. 게임개발자 분들이 쉐이더 프로그래밍에 입문하는 과정도 이와 다르지 않다고 생각합니다. 최소한 DirectX는 마치신 뒤에 쉐이더를 살펴보시는 게 보통이니까요. 이 책의 대상독자도 마찬가지로 하겠습니다. 이 책을 보시려면 최소한 C++과 DirectX 정도는 공부하셨어야 합니다. 3D 수학까지 아시면 더 도움이 되겠습니다.

테크니컬 아티스트
요즘 들어 프로그래머와 아티스트 사이를 조율해주는 테크니컬 아티스트 분들의 입지가 높아지고 있습니다. 그리고 이제 테크니컬 아티스트들이 쉐이더 프로토타입을 만드는 경우도 허다합니다. 강의를 하는 도중에 일반 아티스트(청강생)들도 어느 정도 이해를 했던 내용들이니 테크니컬 아티스트 정도 되시면 아무 문제가 없으시겠지요? 테크니컬 아티스트들은 굳이 DirectX를 직접 다루지 않아도 되니 별다른 준비사항 없이 이 책을 보셔도 될 것 같습니다. (보시다 이해가 안 되는 수학 같은 게 있으시면 정석 책을 열어보시거나 인터넷 검색을 좀 하셔야 할지도 모르지만요. ^^) 각 장의 마지막에 DirectX 프레임워크를 다루는 부분이 있는데 그 부분만 건너 뛰시면 됩니다.

온라인 커뮤니티
이 책을 보시다가 궁금하신 것이 있으시면 제 블로그로 오시기 바랍니다. 토론장을 열어두겠습니다. 그 외에 정오표나 기타 업데이트들도 이 사이트를 통해 공개할 예정입니다.


(사실 이미 제 블로그에 이 글을 올리는 마당에 정오표나 기타 업데이트들을 굳이 갈렉산드리아에 올릴 필요가 있나 모르겠습니다. -_-)

감사의 말씀
이 책이 나오기까지 많은 분들이 도움을 주셨습니다. 이 자리를 빌어 감사의 뜻을 표현하는 것이 최소한의 도리라고 생각합니다.

우선 이 책을 쓸 수 있는 계기를 마련해주신 조진현님께 감사의 말씀을 드리고 싶습니다.

강의실에서 학생들과 직접 얼굴을 맞대면서 가르친 내용을 책으로 옮기는 건 사실 쉬운 일이 아니었습니다. 강의실 환경과는 달리 책은 일방적인 의사소통 수단이어서 과연 제가 말하고자 하는 바가 독자분들께 잘 전달이 될런지 매우 걱정이 되더군요. 이 때, 이 책의 내용과 샘플코드들을 꼼꼼히 테스트 해주신 개발자 분이 두 분 계십니다. 두 분 다 제 대상독자층에 속한 분이셨죠. 한 분은 이미 게임개발업계에 꽤 계셨지만 쉐이더 프로그래밍은 안 하셨던 분이고, 다른 분은 일반 프로그래머 일을 시작한 지 얼마 안 되시는 분입니다. 이 분들이 책을 처음부터 끝까지 꼼꼼히 읽어주시고, 코드를 한 줄 씩 직접 테스트해 주신 덕에 잘못된 내용을 최소한으로 줄일 수 있었습니다. 또한 이 분들이 보내주신 피드백에 따라 부족한 내용을 보완한 덕에 더욱 튼실한 책을 만들 수 있었죠. 유스하이텍의 이경배님과 네오플의 송진영님, 정말 많은 도움이 되었어요. 고맙습니다.

마지막으로 이 책의 준비 단계부터 블로그와 트위터를 통해 많은 관심을 가져주시고 응원해주신 전직/현직/미래 게임개발자 분들과 일반인(?) 분들께 감사의 말씀을 드리고 싶습니다. 강다니엘, 고경석, 김동환, 김성완, 김영민, 김정현, 김혁, 김호용, 박경희, 박수경, 손기호, 신성일, 안진우, 유영운, 이경민, 이상대, 최재규 님, 책 나왔어요~~~


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


2011년 3월 캐나다 밴쿠버에서
포프 올림


저작자 표시 비영리 변경 금지
신고
크리에이티브 커먼즈 라이선스
Creative Commons License


 

티스토리 툴바