기초설정
- 여태까지 해왔던 것과 마찬가지로 렌더몽키 안에서 새로운 DirectX 이펙트를 만든 뒤, 정점쉐이더와 픽셀쉐이더의 코드를 삭제합니다.
- 이제 쉐이더의 이름을 Lighting으로 바꾸도록 합시다.
- 정점의 위치를 변환할 때, 필요한 gWorldMatrix, gViewMatrix, gProjectionMatrix를 추가하고 변수 시맨틱에 연결해주는 것도 잊지 마세요.
람베르트 모델을 이용해서 난 반사광을 계산하려면 어떤 정보가 필요했었죠? 입사광의 벡터와 표면법선 벡터입니다. 법선 정보는 각 정점에 저장되어 있는 것이 보통입니다. 따라서 정점버퍼로부터 이 정보를 가져와야 합니다. 저번 장에서 정점버퍼에서 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의 일부로 반환할 것이고, 픽셀쉐이더는 보간된 법선을 읽어와 입사광의 벡터와 내적을 구하겠지요.
어느 쪽에서 계산을 해도 차이가 없다면 당연히 성능상 유리한 쪽을 택해야겠죠? 어느 쪽에서 계산을 해야 더 빠른지는 각 함수가 호출되는 횟수를 따져보면 알 수 있습니다. 삼각형을 하나 그려보시죠. 이 삼각형을 그릴 때, 정점쉐이더가 몇 번 실행될까요? 삼각형을 이루는 정점의 수가 셋이니 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) 값이므로 방향벡터에 아무런 영향도 미치지 않습니다.
이 벡터를 단위벡터로 만드는 것도 잊지 마세요.
worldNormal = normalize( worldNormal );
이제 입사광의 벡터와 법선이 모두 준비되었으니 내적을 구할 차례입니다. 내적의 공식이 어떻게 되었었죠? 사실 별로 어려운 공식은 아니었는데 굳이 기억하자니 귀찮군요. 그 대신 HLSL자체에서 제공하는 내적함수, dot()을 사용하겠습니다.
Output.mDiffuse = dot(-lightDir, worldNormal);
위 코드를 보니 내적을 구한 결과를 mDiffuse라는 출력변수에 대입해줬군요. 근데 위에서 lightDir 대신 -lightDir을 쓴 거 보이시죠? 이렇게 한 이유는 두 벡터의 내적을 구할 때, 화살표의 밑동이 서로 만나야 하기 때문입니다. lightDir을 쓰면 입사광 벡터의 머리가 법선의 밑동과 만나므로 잘못된 결과를 발생시킵니다.
또한 내적의 결과는 실수 하나인데 float3인 mDiffuse에 곧바로 대입해준 거 보이시죠? 이렇게 하면 float3의 세 성분이 모두 이 실수 값으로 채워집니다. dot(-lightDir, worldNormal).xxx을 대입해주는 것과 동일하지요.
이제 간단히 결과를 반환해 줍시다.
전역변수
이제 왜 쉐이더함수를 먼저 살펴봤는지 아시겠나요? 아무 설명 없이 '빛의 위치를 전역변수로 선언하겠습니다.'라고 말씀드리기가 뭐해서 였습니다.
다음의 전역변수들을 소스 코드 제일 위에 추가해주세요.
float4x4 gWorldMatrix;
float4x4 gViewMatrix;
float4x4 gProjectionMatrix;
float4 gWorldLightPosition;
정점쉐이더 출력데이터
정점쉐이더 함수를 짜보면서 이미 살펴봤듯이 출력데이터는 mPosition과 mDiffuse입니다. 위치야 float4형에 POSITION 시맨틱을 쓰는 걸 이미 알고 있는데, mDiffuse에는 어떤 형과 시맨틱을 써야 할까요? 두 벡터의 내적을 구하면 그 결과는 벡터가 아닌 숫자 하나입니다. 따라서 float만 사용해도 사실 큰 문제는 아니지만 나중에 이 값을 픽셀의 RGB값으로 출력할 것이니 그냥 float3를 사용하겠습니다. 그렇다면 시맨틱은 어떻게 할까요? DIFFUSELIGHTING이라는 시맨틱이 존재할까요? 불행히도 그렇지 않습니다. 쉐이더 프로그래밍을 하다 보면 용도에 딱 맞는 시맨틱이 없는 경우가 종종 있는데, 그럴 때는 그냥 TEXCOORD 시맨틱을 사용하는 게 보통입니다. 최소한 8개의 TEXCOORD가 존재하니까 별로 모자라는 경우가 없거든요. 여기서는 TEXCOORD1을 사용하겠습니다.
다음의 출력데이터를 소스 코드 제일 위에 추가해주세요.
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. 여기는 댓글 달릴때마다 이메일이 오지 않아서 답변을 빨리빨리 드리기가 쉽지 않군요.
제 개인 블로그에 포스팅 된 버전에 댓글을 다시는게 제일 답변이 빠릅니다. 오탈자 및 잘못된 내용 수정도 제일 빠릅니다.
포프 올림