2012년 1월 30일 월요일

[포프의 쉐이더 입문강좌] 06. 만화같은 명암을 입히는 툰쉐이더

이전편 보기

샘플파일 받기

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


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

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


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

그 동안 현대 3D 그래픽의 주 초점도 사실적인 그래픽을 재현해 내는 것이었습니다. 하지만 그 와중에도 미적인 효과를 살리기 위한 비사실적 렌더링 기법들도 간간이 등장했는데요 여기서 살펴볼 툰쉐이딩(toon shading, 셀 쉐이딩(cell shading)이라고도 합니다) 도 그 중 하나입니다. 툰(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을 다시 한번 봐 보죠. 주전자가 녹색이죠? 주전자의 색을 지정해주는 방법은 여러 가지가 있지만 여기서는 전역변수 하나로 전체 표면의 색을 지정해 주겠습니다. (만약 한 표면 위에서 여러 가지 색상을 사용하시고 싶으시다면 3DS MAX에서 메쉬를 만드실 때, 정점색상(vertex color)를 칠하셔도 됩니다. 그리고 정점쉐이더 입력데이터에서 COLOR0이나 COLOR1 시맨틱을 사용하시면 이 정점정보를 읽어올 수 있습니다.) 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 );
 
}



정점쉐이더 입출력데이터 및 전역변수
조명(난반사광)을 계산하려면 법선이 필요하죠? 따라서 정점쉐이더 입력데이터로 위치와 법선이 필요합니다. (정점마다 색을 지정해주셨다면 float3 mColor : COLOR0; 도 추가하셔야 합니다. Stream Mapping에서도 COLOR0을 더해주는 거 잊지 마세요.)

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

정점출력데이터도 별로 어렵지 않습니다. 난반사광을 계산한 뒤, 픽셀쉐이더에 전달해 주는 게 전부입니다. (마찬가지로 정점마다 색을 지정해주셨다면  float3 mColor: COLOR0;도 추가하셔야 합니다.)  이게 잘 이해가 안 되시는 분들은 '제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()입니다. (영어로 ceiling이 '천장'이라는 뜻이니까, ceil을  '천장으로 올리다' 정도로 생각하시면 이해에 도움이 되실 겁니다. 이 반대로 무조건 내림을 하는 함수로 floor()입니다. 이것은 '바닥으로 내리다' 정도로 이해하세요.) 근데 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를 다시 봐도 이 값이 맞네요.

그럼 이제 표면의 색을 곱하기만 하면 끝입니다. (빛의 색상은 흰색(1, 1, 1)이라고 가정했습니다. 어떤 수에 1을 곱해도 결과는 바뀌지 않는 거 아시죠?)

   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)을 구합니다. 여기서 역행렬 대신에 전치행렬을 구한 이유는 월드행렬이 직교행렬이기 때문이죠. 직교행렬의 전치행렬은 역행렬과 같습니다.(이에 대한 증명 및 자세한 설명은 이미 시중에 나와있는 훌륭한 수학책을 참고하시기 바랍니다. 참고로 순수하게 역행렬을 구하시려 한다면 D3DXMatrixInverse() 함수를 쓰시면 됩니다.)

이제 월드/뷰/투영행렬을 서로 곱할 차례입니다. 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);

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

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

  • 툰쉐이더는 비실사 렌더링 기법 중에 하나이다.
  • 툰쉐이딩은 난반사광을 단계적으로 감소시키는 것에 지나지 않는다.
  • 행렬들을 미리 곱해놓으면 공간변환을 더 빨리 할 수 있다.




다음편 보기



2012년 1월 25일 수요일

디테일맵과 감마보정에 대한 뻘쭘 경험담


디퓨즈 맵에 디테일 맵을 곱하는 방법은 텍스처 타일링으로 인해 발생하는 반복패턴을 숨길 때 사용할 수 있는 좋은 방법입니다.

스페이스 마린을 제작하는 도중에도 아티스트의 요청에 따라 이런 일을 하는 쉐이더를 만든 적이 있는데요. (스페이스 마린에 들어가는 쉐이더는 거의 전부 제가 작성했음... Shader Guy = Pope 였다는...).  그 때 제가 사용했던 블렌딩(?) 공식은 다음과 같았습니다.


디퓨즈맵 * 디테일맵 * 2

마지막에 2를 곱해준 이유는 디퓨즈 맵보다 밝은 색을 만드는 것이 가능하게 하기 위해서였죠. (이게 없으면 쉐이더에서 구해오는 텍스처맵의 범위는 0 ~ 1(0~100%)이기 때문에 디퓨즈맵을 어둡게 만드는 효과밖에 없습니다.) 따라서 아티스트가 디테일맵을 50%  회색(0.5)으로 칠하면 최종결과는 디퓨즈맵의 색과 같게 됩니다.

이 방법이 한동안 아무 무리 없이 작동했었는데.. 그 후에 렌더링 엔진을 "감마 친화적"으로 만들면서 좀 이상해졌습니다. 텍스처를 읽어올 때 하드웨어 자체에서 sRGB 변환을 켜줬는데, 실수로 디테일맵을 읽어올 때도 자동변환 기능을 켜버린 거죠.... 그리고 한 두달이 지나는 동안 이 새로운 쉐이더를 이용한 몇몇 아트들이 탄생했죠.


이 쯤되서 기술력 위주의 아티스트 한 명이 디테일 텍스처의 값을 0.5로 설정하면 최종결과가 디퓨즈맵의 색보다 어두워진다는걸 발견했습니다.... 아뿔싸.... 그 이유는 하드웨어에서 자동으로 sRGB -> Linear 변환을 사용한 결과 저희 공식이 이따위가 되었기 때문이지요.


{ (디퓨즈맵 ^ 2.2) * (디테일맵 ^ 2.2) * 2 } ^ (1/2.2)

0.5에 2.2 제곱을 하면 이 값이 0.25 보다도 작아지기 때문에 여기에 2를 곱해도 50프로보다도 더 어두워지기 때문.... 고치는 건 간단했습니다. 디테일맵을 읽어올때 자동 sRGB 변환을 꺼줬죠. 이래서 블렌딩 연산이 다음과 같이 되었습니다.

{ (디퓨즈맵 ^ 2.2) * 디테일맵 * 2 } ^ (1/2.2)

훨 낫죠?

이제 쉬운건 끝났고.. 그 다음은 아티스트들에게 지난 한두달 동안 이 쉐이더를 이용해서 만든 아트들을 다시 고쳐달라고 할 차례였죠. 뭐, 그리 무리한 요구는 아니였습니다. 지난 한 두달간 새로 만든 아트가 몇 안되었거든요. 디테일맵을 고치는 방법은 매우 간단했습니다.
  1. 포토샵에서 디테일 맵을 연다.
  2. 메인 메뉴에서 Image > Adjustment > Exposure를 선택한다.
  3. gamma를 0.454545(1 / 2.2와 같음)로 정해준다.
  4. OK를 누른다.
하지만 이번에는 시각위주의 아티스트가 디테일맵 대신 디퓨즈맵을 바꾸고 싶다고 하더군요. 디테일맵의 감마를 고쳐주니까 너무 어둡게 보여서 작업하는데 힘드시다고... 기존에 있던 디퓨즈맵을 새로운 공식에 맞게 고칠 수 있는 방법을 알려달라더군요. 위에서 디테일맵을 간단히 바꿀수 있었듯이... 그래서 곰곰히 생각해봤는데 수학적으로 옳게 변환을 할 수 있는 방법이 딱히 안떠오르더군요. 그래서.... '직접 눈으로 결과를 확인하시면서 변환을 하셔야 할 것 같네요. 죄송합니다~'라고 해드릴 수밖에 없었던 일이 있습니다.

뒤돌아보면 이게 수학적으로 옳은 것이 아티스트의 삶을 좀더 평안하게 만들 수 있었던 흔치않던 경우라고 생각하는데요... 뭐 불행히도 디퓨즈맵을 변환하는 공식을 찾을 수 없었으니... 제가 멍청한 건지도 모르지만 일단 불가능하다고 생각...

혹시 이거 할 수 있는 공식 아시는분은 연락주세요 ^^


2012년 1월 23일 월요일

[포프의 쉐이더 입문강좌] 05. 물체에 색을 입히는 디퓨즈/스페큘러 매핑 Part 2


이전편 보기


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

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 물체를 렌더링 할 때 여기서 배웠던 기법을 법선매핑(법선매핑(normal mapping)은 제7장에서 배웁니다)과 혼합하는 것이 거의 표준입니다.


다음편 보기

2012년 1월 18일 수요일

KGC 2011 강연자료 공개후 국/내외 반응

뒤져보니 KGC 2011 발표자료를 공개한 뒤에 받은 피드백을 모아놓은 게 있었는데 깜박잊고 안올렸네요. 저 번에 블로그에 올렸던 강연 들으신 분의 피드백은 중복을 막기 위해 생략...
좀 뒤늦었지만 잘난 척에는 기한이 없으니 -_-... 오늘 포스팅... 영어로된건 제맘대로 번역질....





번역: 올해 본 게임 중, 가장 비주얼이 뛰어난 게임 중에 하나가 스페이스마린 이었는데 니가 만든 게임인 줄 몰랐네 그려~ Great Job!



번역: 훌륭한 정보!


번역: 그 모든 것을 렌더타겟 하나로 할 수 있다는 매우 놀랍군! WebGL 에서도 디퍼드 렌더링을 시도하고 싶게끔 만드는걸?

번역: 공유해줘서 고마워~


번역: 쵝오! 올해 해본 게임중에서 가장 잘 돌아가는 엔진이었던듯..(몇년된 내 노트북에서도 잘 실행되었음)



번역: 이미 강연자료 봤어. 고맙~ 화면공간 데칼(Screen Space Decal)과 디퍼드 쉐이딩을 조합한건 흥미로운 아이디어였음.. 지난 몇 년간 꼭 한번 시도해보고 싶은 거였는데 가능한 기법이라니 다행이군.

번역: 실용적인 아이디어들이 그득하군 (미니 사전 깊이패스, 월드 오클루션 꼼수, 카스케이딩 혼합을 위한 스텐실 기법 등등)



번역: 렌더링 프로그래머라면 이 슬라이드를 반드시 읽어야 함!




2012년 1월 16일 월요일

[포프의 쉐이더 입문강좌] 05. 물체에 색을 입히는 디퓨즈/스페큘러 매핑 Part 1

이전편 보기


샘플파일 받기


제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;

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



컴파일 중에 문자열해쉬 만들기....(를 시도해 보자? -_-)


동기
최근에 한동안 동고동락했던 게임엔진에서는 모든 것을 문자열(string)로 참조했었습니다. 물론 문자열 비교를 계속 해야하니 꽤 느린 방법이었지요. 그래서 이걸 빠르게 하기 위해 해쉬값(정수, int)을 사용했었더라죠.

이놈이 대충 이렇게 동작했습니다. 일단 엔진에 싱글턴으로 해쉬 문자열 매니저가 있고요, 게임실행 도중에 문자열을 사용할 때마다 이 매니저를 통해서 해쉬 값을 받는데, 그때마다 해쉬 값과 문자열이 해쉬문자열 매니저안에 저장됩니다. 그리고 문자열을
비교할 때는 그냥 해쉬 값만 비교하고, 실제 문자열을 char*가 필요할 때는 hashStringManager->GetString(hashKey); 이런 식으로 호출해 주면 되었죠.

개인적으로는 이 시스템이 그닥 맘에 들지 않았는데요......-_- 그 이유는:
  • 메모리 낭비가 심하다:
    • 해쉬문자열 매니저에 저장된 놈 중에 정작 게임에서 char* 문자열로 형태로 사용하는 놈은 10% 정도 밖에 안되었습니다.
    • 따라서 90%는 그냥 룩업(look-up) 키처럼 해쉬값(int)만 사용할 뿐이었죠.
    • 즉, 90% 에 대해선 실제 char* 을 저장해 둘 필요가 없었습니다. 그냥 해쉬값만 있으면 충분했죠.
    • 그래서 한 생각이... .이 90%에 대해서는 굳이 해쉬값을 게임 실행중에 계산할게 아니라 오프라인에서(툴이나 컴파일 도중에) 만들어 주면 되겠다고....
  • 멀티쓰레딩을 할 때도 레이스(race) 컨디션이 없도록 하려다보니 해쉬문자열 매니저가 꽤 느려졌습니다. lock을 추가했는데, 특히 리소스 로딩도중에 여러 쓰레드가 이 lock을 걸려고 하다보니 이게 꽤 bottleneck이 걸리더라구요?
  • 실행파일이나 게임리소스 데이터파일에 들어가있는 문자열은 hex 에디터 하나만으로도 쉽게 볼 수 있으니....좀 더 해킹당할 위험이 클껄요...?
그래서 이보다 좀 나은 방법을 찾아보자..... 하고 시도했던 게 바로... 이 밑에 아주 장황하게 설명하는 놈입니다... 

일단 문자열의 종류를 2가지로 나누자
위에서 90%와 10%로 나눴던 문자열을 다른 포맷으로 저장하는게 우선입니다.

1. char* 문자열 (10%)
게임속에서 char* 형태로 사용해야 하는 문자열들은 (예전과 비슷하게) char[]포맷으로 저장합니다.
  • 게임실행중에 로딩해야할 파일의 이름들 (단, 파일이름마저 해쉬로 만들어서 1HDA3820.ext 라는 형식으로 저장하는 경우에는 예외겠죠... 근데 정말 이렇게까지 하는 게임들이 몇이나 있을까요.....? 머엉.... -_-)
  • 화면에 출력해야할 문장들: 게이머들에게 0xFFFFF, 0x888888 또는 0x000000 따위로 대화창을 보여주고 싶진 않겠죠....? (아래 사진을 보니 그래도 될거 같긴....  ^_^)
  • Image Source: icanhascheeszburger.com
대부분의 게임에서 쓰이는 문자열 중에 순수 char* 문자열이 필요한 경우는 대충 10% 정도 될테니까... 굳이 해쉬값을 계산하는 대신 곧바로 strcmp()로 한글자씩 문자열을 비교해도 될거 같은데요. 뭐, 정 해쉬값을 사용하려면 위에서 설명드렸던 해쉬 문자열 매니저를 사용해도 되구요. (최소한 예전에 비해 1/10정도의 문자열만 저장할테니 메모리 낭비는 적겠죠....)

2. 해쉬값으로 표현한 문자열 (90%)
위의 10%를 제외한 다른 문자열들, 즉 char* 형태가 전혀 사용되지 않는 문자열들은 그냥 간단히 해쉬값(int)로 저장합니다. 여기에 포함되는 문자열들로는 다음과 같은 놈들이 있죠.
  • 문자열 비교에만 쓰이는 놈
  • (해쉬맵 등의) 룩업키로만 쓰이는 놈

char* 문자열을 사용하는 법은 이미 다들 아실테니.... 두번째 방법인 해쉬값으로 표현하는 문자열에 대해서만 좀더 다루겠습니다.

괜찮은 해쉬함수 고르기: x65599
해쉬함수가 뭔지는 다들 아실란가요? 모르시는 분들을 위해 간단히 말하면 다른 문자열마다 독특한('고유한'이라고도 표현합니다) 정수값을 만들어 내줄려고 "노력"하는 함수입니다. ("노력"에 따옴표를 두른 이유는 해쉬함수가 독특한 정수값을 계산해내지는 못하는 경우가 있기 때문입니다. 이렇게 문자열이 서로 다른데도 동일한 해쉬값이 나오는 경우를 두고 해쉬 충돌이 생겼다고 하지요.) 뭐든간에 각 문자열마다 독특한 해쉬값을 만들어 내었다면 문자열을 비교할 때, 그 안에 있는 글자를 하나씩 비교할 필요 없이 그냥 해쉬값 2개만 비교하면 되지요. 이것의 장점? 아무래도 빠르죠. 간단하고... ^^

그렇다면 문자열마다 고유한 해쉬값을 계산하는 방법은 뭘까요? 뭐... 이미 꽤 많은 해쉬함수들이 공개되어있지요. 각, 함수따라 해쉬 충돌이 나는 횟수도 좀 다르고, 속도도 다양합니다. 그냥 본인의 필요에 따라 가장 적절한 함수를 선택하면 됩니다. 제 개인적으로 생각하는 게임에 적합한 해쉬 함수는 다음과 같은 조건을 충족해야 합니다.
  • 해쉬 충돌이 거의 없어야 한다.
  • 컴파일 도중과 게임실행 중에 모두 사용할 수 있을 정도로 유연해야 한다: 예를 들어 게임실행도중에 두 문자열을 합친(concat) 뒤 그 결과물에 해쉬값을 계산하려 한다면 컴파일 시점만 아니라 런타임에서도 이 함수를 쓸 수 있어야 겠죠?
  • 게임속에서 사용할 수 있을 정도로 속도가 빨라야 한다.
그래서 인터넷질을 좀 하던 도중 Chrisis Savoie 아저씨네 블로그에서 해쉬함수를 비교해둔 차트를 찾았습니다. 차트를 쭉 둘러보니 x65599라고 불리는 해쉬함수가 저에게 가장 적합해 보이더군요. (그리고 이름도 멋지잖아요.. 앞에 X가 떡하니 붙으니.. 먼가 간지가 풀풀~ -_-;;; ) x65599는 성경책에 나오는 단어들을 모두 돌려도 해쉬 충돌이 없다더군요. (역시 위 링크 참조)

그리고 실제 코드도 한번 봤는데(바로 아래 붙여놓았음) 매우 짧더군요. -_-;; 그냥 65599를 계속 곱해주면 끝(물론 오버플로우를 이용하는 거지만....) 아하! 그래서 이름이 x65599였군요.. ㅎㅎ... (참고로 65599는 소수(prime number)입니다. 해쉬값을 계산해 낼 때는 이렇게 소수를 많이 씁니다.)

// 65599를 곱하는 해쉬함수. (Red Dragon 책에서 훔쳐옴 -0-)
unsigned int generateHash(const char *string, size_t len)
{
  unsigned int hash = 0;
  for(size_t i = 0; i < len; ++i)
  {
     hash = 65599 * hash + string[i];
  }
  return hash ^ (hash >> 16);
}

자, 그럼 그럴듯해 보이는 해쉬 함수도 골랐으니... 이제 툴과 게임코드에서 어떤 짓을 해야하는지 가볍게 살펴보죠.

툴에서 데이터 세이브하기
char * 값을 게임데이터로 저장하는 툴이 있다면, char * 대신 해쉬값(int)을 저장하도록 툴 코드를 바꿔줍니다. 뭐 그냥 저 위의 해쉬함수에 char* 를 인자로 호출한 뒤, 그 결과를 저장해 주면 됩니다. (툴이 C#처럼 다른 언어로 되어있으면  그 언어에서 똑같은 함수를 만들어주던가.. 아니면 interop으로 감싸주던가.... )

간단하죠? 이러면 데이터에서 char*는 사라집니다. 이제 게임코드쪽으로 고고고,,,

게임코드에서 컴파일시에 해쉬값 만들기
예를 들어 게임코드에서 "funny_bone"이란 이름의 조인트를 찾으려 한다고 하죠. 예전 같으면 이런 코드를 썼겠죠.

bones.find("funny_bone");

근데 이제 툴에서 "funny_bone"이란 문자열 대신에 해쉬값을 저장하니... 이제는 대신 이렇게 코드를 작성해야 합니다.

const char * boneToFind = "funny_bone";
bones.find( generateHash(boneToFind, strlen(boneToFind) );

근데 이렇게 하면 "funny_bone"이라는 문자열이 여전히 실행파일에 삽입되지 않을까요?  만약 그렇다면 예전에 쓰던 해쉬문자열 매니저보다 메모리를 적게 잡아먹을리도 없겠고.... 으음.... 그렇다면.. 여태까지 왜 글을 쓴거지? -_-;;;; 는 아니고........

위를 잘보면 문자열이 상수(const) 잖아요? 그럼 저기에 generateHash() 함수에서 하는 계산을 적용하면 나오는 그 결과 해쉬값(int)도 정해져 있을 수밖에 없죠. 즉, 똑똑한 컴파일러라면 "아하! funny_bone이란 문자열이 이미 상수로 정의되어 있고 여기에 generateHash()란 함수를 호출하면서 이런저런 계산을 하는군. 그렇다면 굳이 프로그램 실행도중에 이런 계산을 할 필요가 없겠는걸? 컴파일 도중에 미리 해버려서 그 결과인 해쉬값'만' 코드에 넣으면 되지 않을까?" 라는 논리적 사고를 할 수 있어야 한다.... 는게 제 소망이자 바램이죠.. -_-;; 만약 컴파일러가 이리 똑똑할 수 있다면 컴파일 도중에 다음과 같이 코드가 바뀔 겁니다.

// 0XF1C66FD7F이 실제 "funny_bone"의 해쉬값입니다.
bones.find( 0xF1C6FD7F );    

이런 마법은(?) 다음 두 조건만 충족된다면 가능합니다.
  • 컴파일러가 위 해쉬 함수를 인라인(inline)한다: 컴파일러가 해쉬함수를 인라인으로 삽입해주지 않으면 컴파일 도중에 해쉬값을 계산할 턱이 없지요. 그냥 함수를 호출할 테니까요. 따라서 이 조건이 반드시 충족되야 합니다. 대부분의 컴파일러에서 inline 키워드는 강제성이 없는 게 문제긴 한데... (가이드라인일 뿐)... 뭐 그닥 해결하기 어려운 문제는 아닙니다.
  • 컴파일러가 해쉬함수 안에 있는 for루프를 언롤(unroll )해 준다: 언롤이란 루프 코드가 있을 때, 각 루프 회차를 일일이 코드로 풀어서 써주는 걸 뜻합니다. 컴파일러가 컴파일시에 루프를 몇번 돌릴 지 예측할 수 있다면 이걸 일일이 풀어 써주는게 불가능하지만은 않죠....
generateHash(const char *, size_t) 함수의 인라인
우선 컴파일시에 generateHash(const char*, size_t) 함수가 인라인 될 수 있게 만들어줘야 겠죠. 그러려면 헤더파일에 함수본체를 넣는 방법이 최고입니다. 더 나아가, 문자열의 길이를 구하려고 strlen(const char *)함수를 따로 호출할 필요가 없도록 다음과 같은 매크로를 만들겠습니다.

#define HASH_STRING(str) generateHash(str, strlen(str));

이 매크로까지 들어간 hash.h 파일을 보여드리면 다음과 같습니다.

// 컴파일 타임 해쉬문자열 만들기 테스트
// author: Pope Kim (www.popekim.com)

#include <string.h>
#define HASH_STRING(str) generateHash(str, strlen(str));

// 65599를 곱하는 해쉬함수. (Red Dragon 책에서 훔쳐옴 -0-)
// 이 함수의 몸체까지 헤더파일에 넣어서 컴파일러의 인라인을 돕는다.
inline unsigned int generateHash(const char *string, size_t len)
{
  unsigned int hash = 0;
  for(size_t i = 0; i < len; ++i)
  {
    hash = 65599 * hash + string[i];
  }
  return hash ^ (hash >> 16);
}

테스트 코드
이제 테스트 코드를 만들어 컴파일러와 최적화 옵션에 따라 원하는 결과(HASH_STRING(str)이 정수로 탈바꿈 하는 것... 물론 컴파일시에...)가 나오는지 살펴보겠습니다.

이게 테스트 코드, main.cpp입니다.

// 컴파일 타임 해쉬문자열 만들기 테스트
// author: Pope Kim (www.popekim.com)


#include <stdio.h>

#include "hash.h"


int main(int args, char** argv)
{
  unsigned int hashValue = HASH_STRING("funny_bone");
  printf("해쉬 값: 0x%8x\n", hashValue);

  return 0;
}


이제 컴파일러들이 얼마나 똑똑한지 알아봅시다 -_-;

Visual Studio 2010 SP1
비주얼 스튜디오에서 Win32 콘솔 프로젝트를 만든 뒤, 최적화 옵션을 바꿔가며 테스트 해봤습니다. (제가 영문 비졀 스튜디오를 써서 옵션은 대충 영문으로 남겨둡니다 -_-)
  1. 프로젝트 설정을 Release로 바꿔줍니다.
  2. 어셈블리 파일을 출력하기 위해 Project Properties > C/C++ > Output Files > Assembler Output 옵션으로 가서 Assembly-Only Listing (/FA)을 선택 해줍니다.
  3. 최적화 플래그를 바꿔주기 위해 Project Properties > C/C++ > Optimization으로 가서 아래의 최적화 옵션들을 바꿔줘가며 컴파일을 합니다.
Disabled (/Od)
주목할 만한 것은 대략 2가지....
  • generateHash() 함수가 인라인 안되었군요.. (뭐 최적화를 전혀 안했으니 당연한...?)
  • 재미있게도 strlen() 호출은 10으로 탈바꿈 했군요. push 10이라고 된 어셈코드를 보세요...
_main PROC      ; COMDAT
; File e:\temp\x65599\x65599\main.cpp
; Line 11
 push ebp
 mov ebp, esp
 push ecx
; Line 12
 push 10     ; 0000000aH
 push OFFSET $SG-5
 call [email protected]@[email protected]  ; generateHash
 add esp, 8
 mov DWORD PTR _hashValue$[ebp], eax
; Line 13
 mov eax, DWORD PTR _hashValue$[ebp]
 push eax
 push OFFSET $SG-6
 call DWORD PTR __imp__printf
 add esp, 8
; Line 15
 xor eax, eax
; Line 16
 mov esp, ebp
 pop ebp
 ret 0
_main ENDP

Minimize Size(/O1)
최적화 끈 거와 별 차이는 없습니다. 해쉬함수가 인라인되긴 했는데 여전히 루프를 돌립니다. (문자열 길이인 10하고 비교한 뒤 다시 루프 처음으로 점프(jb)하는 부분을 보시면 암)

_main PROC ; COMDAT
; File e:\temp\x65599\x65599\main.cpp
; Line 12
xor ecx, ecx
xor eax, eax
imul ecx, 65599 ; 0001003fH
add ecx, edx
inc eax
cmp eax, 10 ; 0000000aH
mov eax, ecx
shr eax, 16 ; 00000010H
xor eax, ecx
; Line 13
push eax
call DWORD PTR __imp__printf
pop ecx
pop ecx
; Line 15
xor eax, eax
; Line 16
ret 0
_main ENDP


Maximize Speed(/O2)
오옷! 첫 줄을 봐봐요. push -238617217 이게 16진수로 0xF1C6FDF?이거든요. 모든 계산이 다 사라지고 해쉬값 하나로 탈바꿈 했군요! 이야! 역시 가능한 거였어요 -_- 흣~

; Line 13
push -238617217 ; f1c6fd7fH
call DWORD PTR __imp__printf
add esp, 8
; Line 15
xor eax, eax
; Line 16
ret 0
_main ENDP

그리고 여기서 나온 .exe파일을 텍스트 에디터에서 열어서 funny_bone이란 문자열이 있나 찾아보니 없군요!




Full Optimization(/Ox)
이 옵션으로도 어셈블리어는 그럴듯해 보입니다.

_main PROC ; COMDAT
; File e:\temp\x65599\x65599\main.cpp
; Line 13
push -238617217 ; f1c6fd7fH
push OFFSET $SG-6
call DWORD PTR __imp__printf
add esp, 8
; Line 15
xor eax, eax
; Line 16
ret 0
_main ENDP

하지만 .exe파일을 열어서 funny_bone을 찾아보니...

funny_bone이 왜 있는 건데...? 응?

이거 뭐하자는 건지... -_- Full Optimization이 사용안하는 문자열 하나 제거하지 않다니.. 참으로 웃긴 일입니다. 이 외에도 다른 테스트 프로그램을 만들어서 실험해봐도 결과는 같았습니다. 심지어 이따위 함수를 만들고 컴파일해도 exe파일안에 스트링이 그대로 있더군요.

void idiot()
{
  const char* idiot = "OMG";
}

사실 .exe 파일 안까지 뒤져볼 생각은 첨에 안했었는데 진영군(denoil)이 안되는거 아니냐고 물어와서 그거 확인해보다 찾아낸 결과입니다. 진영군이 VS 2008과 VS2010 버전에서 실험했을때도 결과는 똑같이 개판이었어요 -_-;

그래서 회사동료인 Karl하고 뒤적거리다 보니 C/C++ > Code Generation > Enable String Pooling이란 옵션이 있더군요. 이걸 Yes(/GF)로 켜주면 그제서야 문자열이 exe에서 사라집디다. 뭔 이유인진 모르겠지만 이 옵션이 /O1, /O2에는 켜있는데 /Ox에는 기본적으로 꺼져있더라는....

g++
그렇다면 g++ 컴파일러는 과연 어떨까요. 테스트에 사용한 g++ 버젼은 4.5.3이고.. 컴파일러 플랙은 이렇게 했습니다.

g++ *.cpp -pedantic -Wall -S <최적화-플랙>

-S 플랙은 어셈블러 코드만 만들고 컴파일을 중지하라는 의미임..(어셈블리어를 봐야 제대로 마법을 부렸는지 확인할 수 있으니.... -_-)

-O0
-O0 플랙은 최적화를 하지말란 의미죠. 따라서 결과는 뻔한... 비졀스튜디오와 마찬가지로 strlen() 함수가 10으로 탈바꿈 해버렸단 게 좀 특이할 뿐.... 하지만 여전히 해쉬함수는 인라인 안되었습니다.

LFE4:
.def ___main; .scl 2; .type 32; .endef
.section .rdata,"dr"
LC0:
.ascii "funny_bone\0"
LC1:
.ascii "hash value is 0x%8x\12\0"
.text
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
LFB5:
pushl %ebp
LCFI4:
movl %esp, %ebp
LCFI5:
andl $-16, %esp
LCFI6:
subl $32, %esp
LCFI7:
call ___main
movl $10, 4(%esp)
movl $LC0, (%esp)
call __Z12generateHashPKcj
movl %eax, 28(%esp)
movl 28(%esp), %eax
movl %eax, 4(%esp)
movl $LC1, (%esp)
call _printf
movl $0, %eax
leave
LCFI8:
ret

-O1
이 플래그에서는  generateHash() 함수가 인라인 됩니다만 여전히 계산은 다 합니다. 비졀 스튜디오랑 매우 비슷하군요?

.def ___main; .scl 2; .type 32; .endef
.section .rdata,"dr"
LC0:
.ascii "funny_bone\0"
LC1:
.ascii "hash value is 0x%8x\12\0"
.text
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
LFB5:
pushl %ebp
LCFI0:
movl %esp, %ebp
LCFI1:
andl $-16, %esp
LCFI2:
pushl %ebx
LCFI3:
subl $28, %esp
LCFI4:
call ___main
movl $LC0, %eax
movl $LC0+10, %ebx
movl $0, %edx
L2:
imull $65599, %edx, %edx
movsbl (%eax), %ecx
addl %ecx, %edx
addl $1, %eax
cmpl %ebx, %eax
jne L2
movl %edx, %eax
shrl $16, %eax
xorl %eax, %edx
movl %edx, 4(%esp)
movl $LC1, (%esp)
call _printf
movl $0, %eax
addl $28, %esp
popl %ebx
LCFI5:
movl %ebp, %esp
LCFI6:
popl %ebp
LCFI7:
ret

-O2
-O1플래그와 결과가 같군요. (뭐 그도 그럴법한게 루프 언롤은 -O3 플래그에서나 활성회 돠거든요...)

.def ___main; .scl 2; .type 32; .endef
.section .rdata,"dr"
LC0:
.ascii "funny_bone\0"
LC1:
.ascii "hash value is 0x%8x\12\0"
.text
.p2align 4,,15
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
LFB5:
pushl %ebp
LCFI0:
movl %esp, %ebp
LCFI1:
andl $-16, %esp
LCFI2:
subl $16, %esp
LCFI3:
call ___main
movl $LC0, %eax
xorl %edx, %edx
.p2align 4,,7
L2:
imull $65599, %edx, %edx
movsbl (%eax), %ecx
addl $1, %eax
addl %ecx, %edx
cmpl $LC0+10, %eax
jne L2
movl %edx, %eax
shrl $16, %eax
xorl %edx, %eax
movl %eax, 4(%esp)
movl $LC1, (%esp)
call _printf
xorl %eax, %eax
leave
LCFI4:
ret

-O3
드디어 결과가 나왔습니다!  movl $-238617217, 4(%esp) 보이시죠? 드디어 정수값 하나로 탈바꿈 했군요.

.def ___main; .scl 2; .type 32; .endef
.section .rdata,"dr"
LC0:
.ascii "hash value is 0x%8x\12\0"
.text
.p2align 4,,15
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
LFB5:
pushl %ebp
LCFI0:
movl %esp, %ebp
LCFI1:
andl $-16, %esp
LCFI2:
subl $16, %esp
LCFI3:
call ___main
movl $-238617217, 4(%esp)
movl $LC0, (%esp)
call _printf
xorl %eax, %eax
leave
LCFI4:
ret

exe 파일을 열어서 문자열 검색을 해봐도 없었습니다. (스크린샷은 생략)

-Os
-Os 는 크기를 제일 작게 최적화하란 플래그인데요. 역시 원하는 결과는 아닙니다.

LFE4:
.def ___main; .scl 2; .type 32; .endef
.section .rdata,"dr"
LC0:
.ascii "funny_bone\0"
LC1:
.ascii "hash value is 0x%8x\12\0"
.text
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
LFB5:
pushl %ebp
LCFI7:
movl %esp, %ebp
LCFI8:
andl $-16, %esp
LCFI9:
subl $16, %esp
LCFI10:
call ___main
movl $10, 4(%esp)
movl $LC0, (%esp)
call __Z12generateHashPKcj
movl $LC1, (%esp)
movl %eax, 4(%esp)
call _printf
xorl %eax, %eax
leave
LCFI11:
ret


간단 정리
자, 이 놀라운(어쩌면 어이없는 걸지도 -_-) 꼼수를 동작하게 하려면 필요한 비졀 스튜디오 2010과 g++의 최적화 플래그를 간단히 정리.

Visual Studio 2010 SP1
  • /O2
  • /Ox (단 Enable String Pooling option을 킬 것)
g++ 4.5.3
  • -O3

디버깅
자, 그럼 코드에서 char* 문자열도 제거했으니 실행파일 용량도 작아질테고... 어랏? 근데 디버깅은 어쩌죠? 예를 들어서 "0xF1C6FD7F"란 이름을 가진 본에서 크래쉬가 낫다면..... 이게 대체 3DS Max에서 어떤 본인지 어케 알까요? 디버깅을 하려면 char* 문자열이 여전히 필요하군요...... 써글 -_-;;;

그렇다면 여태까지 한 걸 모두 접어야 할까요... 물론 그럴거면 이 글도 안썼겠죠 -_-; 이 데이터는 디버깅에만 유용한 거니까 디버깅에만 사용할 법한 꼼수를 찾아야죠. 생각해보면 사실 그닥 어려운 문제는 아닙니다. 그냥 문자열 데이터베이스 파일만 하나 있으면 되죠. 그 데이터베이스는 <해쉬키, char*>로 된 목록을 가질거고, 이제 1)게임코드에서 사용하는 모든 문자열과 2)툴에서 게임데이터로 저장하는 모든 문자열을 데이터베이스에 저장해 두기만 하면 됩니다.

디버그 문자열 데이터베이스 만들기
디버그 문자열 DB는 무슨 파일포맷으로 저장해야할까요? SQL DB 라이트도 나쁜 생각은 아니죠. 근데 전 그냥  텍스트 파일에 저장할 거 같습니다. 아무래도 SQL DB보다는 텍스트 파일이 게임엔진에서 쉽게 읽을 수 있을 거 같아서요. 뭐, 무슨 포맷을 선택하던 그냥 파일이름은 debug.string_db로 하죠.

도구에서 디버그문자열 DB 저장하기
뭐, 도구에서 할 일은 크게 없습니다. 그냥 게임데이터 파일에 새로운 문자열을 저장할 때마다 debug.string_db 파일에도 저장하면 됩니다.

끝 -_-. 룰루~

게임코드에서 디버그 문자열 DB 저장하기
그렇다면 코드 안에 있는 문자열은 어케 할까요. HASH_STRING() 매크로 안에 인자로 전해주는 문자열들이요. 뭐, 다행히 HASH_STRING()이란 매크로를 정의해뒀군요. 간단히 C#이나 파이썬 같은 스크립트 언어로 소스코드 디렉토리를 다 뒤지면서 HASH_STRING() 패턴이 보일때마다 그 안에 있는 char*를 해쉬값으로 변환해서 debug.string_db에 저장하는 코드를 짜면 됩니다. regular expression을 쓰면 와따지요. 그리고 비졀 스튜디오 프로젝트의 포스트 빌드 이벤트로 이 스크립트를 한 번씩 호출해주면 끝... 속도도 꽤 빨라요... -_-

뭐, 이건 아주 간단하진 않지만... 그닥 어렵지도 않은 문제였으니...... 끝.... -_- 룰루~

문자열 값 찾기
그럼 이제 비주얼 스튜디오에서 디버깅을 할 때 디버그 문자열들을 찾는 문제만 남았는데.... (어차피 watch 창에는 int로 된 해쉬 값밖에 안보일테니까요.)

문자역 룩업 툴
DirectX SDK에 딸려오는 DirectX Error Lookup 툴 써보신 분 있으세요? 이따위로 생겼지요.



간단히 이런 툴을 작성해도 됩니다. 툴이라고 해봤자 그냥 debug.string_db 파일을 읽어온 뒤 유저가 입력한 해쉬값과 일치하는 문자열을 찾아서 보여주는 게 전부죠. 일일이 해쉬값을 비졀 스튜디오 watch 창에서 복사해와 붙여넣는게 귀찮긴 하지만...... 쓰는데 큰 문제는 없겠죠?

Visual Studio 플러그인?
다음으로 해 본 생각은... 비주얼 스튜디오 플로그인을 만드는 겁니다. 제가 직접 비주얼 스튜디오 플러그인을 만들어 본 적이 없어서 이게 가능한지는 확실치 않은데...

비주얼 스튜디오 플러그인에서 텍스트 파일이라던가 SQL DB를 읽어올 수 있다면... 그리고 watch 창 안에 디버그 데이터를 보여주는 방법을 맘대로 주무를 수 있다면 가능할 거 같은데요? 언젠가 시간이 남는다면 한 번 제작할지도 모르겠지만....일단 전 대충 문자열 룩업 툴로 만족 -_-

디버그전용 해쉬문자열 매니저
아니면 게임코드 안에 디버그전용 해쉬문자열 매니저를 만들어도 되죠. 디버그 빌드에서만
debug.string_db 파일을 로딩하게 만들면 되니까요. 그러면 코드 안에서 쉽게 문자열을 찾아낼 수 있죠. 이건 디버그 빌드에서만 동작하는 코드고 디스크 빌드에서는 컴파일러 스위치로 뿅~ 사라져야 하는 놈...

좀 더 어이없는 생각 하나 더....
디버그전용 해쉬문자열 문자열 매니저에 대해 쓰던 도중 갑자기 떠오른 생각.... 디버그전용 해쉬문자열 매니저가 로칼라이제이션 데이터베이스하고 되게 비슷한 거 같은데요? 문자열 ID를 키로 쓰고 거기에 대응하는 실제 문자열이 char* 값으로 들어가 있는 게 전부니... 나중에 언어를 바꿔주고 싶으면 그냥 각 문자열 ID마다 다른 언어로 char* 값이 들어가있는 로칼라이제이션 DB 파일을 로딩해버리면 되니까.... 해쉬문자열 매니저와 매우 비슷....

따라서 디버그전용 해쉬문자열을 구현하기로 결정을 했다면 동일한 아키텍처를 이용해서 로칼라이제이션을 해버리면 어떨까 하는 생각... 사실 로칼라이제이션 DB에 대해서는 아는게 별로 없으므로 허무맹랑한 소리일지도 모릅니다. 그냥 이딴 생각이 들었을뿐입니다.... -_-


아악! 좀 커다란 문제가 하나....
위에 글을 쓰고 매우 기뻐하고 있었는데... 제 동료인 Noel 아저씨가 갑자기 이딴 질문을.... "그 루프 언롤은 문자열의 길이에 상관없이 잘 돌아? 졸 길면 안되지 않을까?"... 그래서 다시 한번 재빨리 테스트를 해보니....... 흙~

Visual Studio 2010 SP1
Visual Studio 2010 SP1 는 10글자까지만 제대로 동작하더군요. -_-  "funny_bone1"이라고 11글자를 넣으니 이따위 결과가...!

_main PROC ; COMDAT

; File e:\temp\x65599\main.cpp
; Line 12
xor ecx, ecx
xor eax, eax
npad 12
[email protected]:
movsx edx, BYTE PTR $SG-5[eax]
imul ecx, 65599 ; 0001003fH
inc eax
add ecx, edx
cmp eax, 11 ; 0000000bH
jb SHORT [email protected]
mov eax, ecx
shr eax, 16 ; 00000010H
xor eax, ecx
; Line 13
push eax
push OFFSET $SG-6
call DWORD PTR __imp__printf
add esp, 8
; Line 15
xor eax, eax
; Line 16
ret 0
_main ENDP


g++
g++은 좀 납디다.. 아니 많이... g++은 무려 17글자까지! 두둥! "funny_bone12345678"이라고 17글자를 넣으니 그제서야 이런 결과가....


.def _main; .scl 2; .type 32; .endef
_main:
LFB5:
pushl %ebp
LCFI0:
movl %esp, %ebp
LCFI1:
andl $-16, %esp
LCFI2:
subl $16, %esp
LCFI3:
call ___main
movl $LC0, %eax
xorl %edx, %edx
.p2align 4,,7
L2:
imull $65599, %edx, %edx
movsbl (%eax), %ecx
addl $1, %eax
addl %ecx, %edx
cmpl $LC0+18, %eax
jne L2
movl %edx, %eax
shrl $16, %eax
xorl %edx, %eax
movl %eax, 4(%esp)
movl $LC1, (%esp)
call _printf
xorl %eax, %eax
leave
LCFI4:
ret


그래서, 뭐 어쩌라고?
위의 실험이 의미하는 바는.... 컴파일러 따라 10이나 17자 까지만 멋지게 최적화가 된다는 거지요. 다른 문자열들은 실행도중에 계산됩니다... 으음... 그래도 과연 이런 짓(?)을 할 가치가 있을까 생각을 해봤는데요. 그래도 가치는 있다고 생각합니다. 가장 큰 이유는 최소한 게임데이터 파일 안에서 문자열이 사라지니까요. 대신 다음과 같은 가이드라인을 좀 따라야겠죠.
  • 가능한 룩업키로 사용하는 문자열의 길이를 짧게 만든다.
  • 동일한 문자열에 HASH_STRING() 매크로를 여러 번 호출하지 않는다. 대신 계산은 한 번만 하고 그 값을 다른 데 저장해뒀다 필요할 때마다 불러와 사용한다. (예. 오브젝트의 멤버변수로 저장)
또 미래의 컴파일러가 루프 언롤을 좀 더 잘 해줄 수도 있구요. 한 64 글자까지만 되면 좋을텐데 말이죠....  (비졀 스튜디오 2011 Preview에서도 여전히 10글자더군요)

아니면 C+11의 constexpr를 여따 쓸 수 있을까요..... 하지만 비졀스튜디오 2011 프리뷰에서도 아직 이 놈을 지원 안하는 걸요?...... 그러니 별 소용이 -_-

제가 가장 선호하는 해결책은 MS사에서 다음과 같은 컴파일러 스위치를 추가해주는 겁니다.

inline unsigned int generateHash(const char *string, size_t len)
{
  unsigned int hash = 0;


  #pragma unroll
  for(size_t i = 0; i < len; ++i)
  {
    hash = 65599 * hash + string[i];
  }
  return hash ^ (hash >> 16);
}


이러면 len의 길이가 컴파일시에 이미 정해져 있는 경우 컴파일러가 루프 전체를 언롤해주는거죠. IBM 컴파일러에 저거랑 비슷한 컴파일러 스위치가 있다고 들었고, HLSL 컴파일러는 이미 저걸 지원하니 C++ 컴파일러에 저걸 넣지 못할 이유가 없을 듯 한데 말이죠.

마소 아저씨들! 저 컴파일러 옵션좀 추가해 주세요!


급한대로 땜빵 해법
영문 블로그에 며칠전에 이 글을 올렸었는데 그 뒤에 Mikkel Gjoel 아찌가 트위터에서 말해주기를 Humus 아찌가 이 글자 제한에 상관없이 컴파일시에 해쉬를 만들어 내는 법을 알고 있다고 귀뜸 해줬습니다.

그래서 냅따 시도해봤지요. 오오~ 잘 작동합니다. 64글자까지 실험해봤는데 다 되요! 프로그래머가 사용하기 좀 불편하다는 단점은 있는데요. 범용적인 generateHash(const char*) 함수를 특화된 generateHash(const char &(string)[N]); 함수들과 동시에 선언해둘수가 없거든요. 컴파일러가 헷갈려해요 -_-


영문 블로그에 커멘트로 달린 AltDevBlogADay 링크에서 바로 위에 지워버린 내용을 해결할 수 있는 방법을 발견했습니다.


struct ConstCharWrapper
{
    inline ConstCharWrapper(const char* str) : m_str(str) {}
    const char* m_str;
};




inline unsigned int generateHash(ConstCharWrapper wrapper, size_t len)
{
    const char* string = wrapper.m_str;


    // 요밑은 이전과 똑같은 코드
}



그리고 비졀 스튜디오 2010이 좀 멍청해서... 다음의 두 코드가 같은 놈이란 걸 모르고.. 첫번째 놈을 해쉬값으로 바꿔주는 데 실패하더군요. 물론 제 해쉬 함수를 쓸때도요... (g++은 잘 함...)

#1
const char * const funny = "funny_bone";
unsigned int hashValue = HASH_STRING(funny);


#2

unsigned int hashValue = HASH_STRING("funny_bone");


그러나 #1 방식을 쓰면 디버그 스트링 DB를 만들려고 regular expression을 쓸 때도 개판이 나니... 첫번째 방법을 쓰면 안되겠죠. 그럼 상수 문자열을 쓸 때 다른 프로그래머들이 첫번째 방법을 쓰지 않도록 강제 교육할 방법이 있어야 할텐데..... 일단 좀더 생각해봐야 겠어요.

어쨌든 이 새로운 정보를 좀 덜 짜증나게 쓸 수 있는 방법을 찾아내면 새로운 글을 올리지요. 이미... 너무 길어요.. 글이... 흙~ -_-