레이블이 Lighting인 게시물을 표시합니다. 모든 게시물 표시
레이블이 Lighting인 게시물을 표시합니다. 모든 게시물 표시

2012년 2월 1일 수요일

새로운 기법 != 새 장난감

게임데브포에버에 무슨 글을 올릴까 고민을 좀 해봤는데... 그래픽 전문자료들은 이미 다른 필자분들이 열심히 올리고 계셔서 요번에는 그냥 제 경험담을 올리도록 하지요.. (이런 글을 원하시는 분들도 꽤 계실듯...?)


제가 이리도 오래 쉰내나도록(?) 게임 업계에 머무르는 이유 중 하나가 언제나 새로운 것들을 시도해 볼 기회가 충분하기 때문입니다. 특히나 그래픽 프로그래밍 쪽은 하루가 멀다하고 계속 발전하는 분야라서 마약처럼 매우 짜릿하죠. (마약이라고 좀 언급해두면 한국 정부에서 게임개발 셧다운제를 해주지 않을까하는 생각에...그럼 한국 게임개발자 분들도 정시퇴근 하실 수 있습니다. 회사 매출이 높으면 일찍 퇴근!)

근데 가끔은 이런 짜릿함에 눈이 멀어 게임을 망치는 경우도 좀 있습니다. 아직 검증되지 않은 새로운 기법을 동원할 때 그에 부수하는 단점들을 간과하는 경우가 허다하거든요. 심지어는 그런 단점들이 이미 잘 알려져 있더라도 장점보다 단점을 더 크다고 착각하는 경우도 문제입니다. (보통 이미 그 기법을 사용해서 게임을 출시한 개발자들이 하는 이야길 듣는게 검증인데.... 그 개발자들이 컨퍼런스에서 발표를 할 때는 당연히 단점보단 장점을 부각시키는게 일반적이라지요.)

디퍼드 라이팅
제가 요번에 적어드릴 경험담은 디퍼드(deferred) 렌더링에 대해서 입니다. 이미 아시는 분들은 아시겠지만 스페이스마린은 자체 개발한 디퍼드 라이팅(deferred lighting) 엔진을 씁니다. 뭐 찾아보면 좀 더 있겠지만 제가 당장 생각하는 퍼드 라이팅 렌더러의 장단점은 다음과 같습니다. (제가 생각하는 중요도 순서로...)

장점:

  1. 포워드(forward) 렌더링에 비해 사용할 수 있는 광원의 수가 많다. (예, '물체당 광원 3개' 라는 제한이 없어짐)
  2. 대부분의 조명을 동적으로 처리함으로 아티스트들의 작업시간이 빨라진다.
  3. 화면공간에서 행하는 후처리(post-processing) 기법들을 사용하기 쉽다. (예, SSAO, Screen Space Decal 등)

단점:
  1. (반)투명한 물체 처리가 골아프다.
  2. 하드웨어 자체적으로 앤티에일리어싱(anti-aliansing)을 처리하기 힘들다.
  3. 메모리를 좀 더 많이 잡아먹는다. (화면 해상도 크기의 렌더타겟들이 여러 개 필요)

스페이스 마린에서 디퍼드를 사용한 이유

원래 시작은...
스페이스 마린에서 디퍼드 렌더링을 사용한 이유는 사실 역사적인 이유가 강합니다. 스페이스 마린을 만들기 전에 Grand Theft Auto 류의 오픈월드 게임을 제작하고 있었는데 이 때 (2008년 중순) 다음과 같이 디퍼드 라이팅 엔진을 판단했었습니다.
  • 오픈월드 게임이니 광원의 수가 꽤 많겠군? 디퍼드가 좋겠어. (장점 #1)
  • 아무래도 밤낮이 바뀌는 효과가 있어야 하니 동적 조명이 좋겠는걸? (장점 #2)
  • 근데 배경이 도시니까 투명한 물체가 꽤 필요하겠는데? (단점 #1).... 으음... 뭐 투명한 물체는 디퍼드 말고 따로 포워드로 그려줘야겠군.. (어쩔수 없는그럴듯한 해결책)
  • 근데 앤티에일리어싱은 어쩌지? (단점 #2) 아직 1~2년 남았으니 나중에 고민해보지 뭐...(대충 책임 회피 -_-)
근데 이 게임이 한 6개월 만에 취소됩니다. 게임 자체에 문제는 아니었고 THQ가 구조 조정을 하면서 그 당시 스페이스 마린 게임을 개발중이던 THQ 호주 스튜디오를 문을 닫았죠. 워낙 렐릭이 워햄머 40,000 게임을 잘 만드는 회사로 유명했던지라 저희쪽에서 대신 해달라고.... 

그래서 처음부터 다시 스페이스마린을 만들었습니다. -_- (THQ 호주에서 만들어 놨던건 하나도 안썼죠.. 저희가 원하는 방향과 너무나 달라서...)

그리고 다시 결정을 내리기엔...
자, 그럼 이번엔 스페이스마린을 만들기로 했으니 다시 한 번 렌더링 엔진에 대해 고민해볼 차례인데... 이 때 (2009년) 저희의 생각은 이랬습니다.
  • 과연 광원의 수가 많을까? (장점 #1이 시들해짐)
  • 밤낮이 바뀌는 효과도 없는데? (장점 #2도 시들해짐)
  • 그런데... 아티스트들이 이미 디퍼드 라이팅에 맛을 들여서(iteration 시간이 매우 빨라졌어요... 모든게 동적 조명이니) 매우 원함... (장점 #2가 다시 살아남)
  • 또한 디퍼드에 기반해서 구현한 Screen Space Decal을 역시 아티스트들이 너무 좋아함 (장점 #3)
  • 서기 40,000년엔 투명한 유리창 따윈 이미 다 뽀개지고 없으니.. 투명한 물체는 그닥 문제가 안될 거야.. (단점 #1이 좀 완화됨)
  • 근데 앤티에일리어싱은 어쩌지? (단점 #2)요즘들어 이 분야에 대한 좀 발전이 있으니(Kill Zone 2가 SSAA를 대충 사용할 때..) 좀더 기다려보지.. (여전히 책임 회피... -_-)
  • 그럼 딱히 디퍼드를 할 이유가 없지 않아?..... 응... 없지.... 근데 이미 만들어 놓은거 다시 포워드로 돌리는 데 드는 시간과 비용이 과연 값어치가 있을까?......... 없군...... 
그래서 결국 디퍼드로 그냥 가기로 했죠. 최소한 아티스트들이 작업을 빨리할 수 있으니까 비주얼 품질이 높아질거라 생각했거든요. 그리고 그건 현실이 되었죠. 아티스트들이 여러번 손 대니까 확실히 스페이스마린의 비주얼 퀄리티도 상승.



그래서 단점은 어케 극복을?
그리고 시간은 흐르고 흘러서 2011년 9월 스페이스 마린을 출시했죠. 그렇다면 저 단점들은 어떻게 극복 했을까요?

투명한 물체
"서기 4만년엔 모든 유리들이 뽀작나서 더이상 투명한 물체가 없습니다 -_-;" 는 저희가 장난처럼 한 말이었는데... 사실 저희 게임에서 투명한 물체가 거의 없습니다. 종류따라 다음과 같이 처리했어요.
  1. 알파테스트: 반투명 블렌딩을 하기 보다 대부분의 물체는 완전투명 아니면 완전 안투명의 두가지로 처리했습니다. 이러면 디퍼드를 사용할 수 있죠.
  2. Screen Space Decal(SSD): 다른 물체의 표면에 찰싹 붙은 반투명한 물체는 SSD로 처리했습니다. SSD에 대한 자세한 내용은 위에 링크 걸어드린 발표자료를 참조하시길. 역시 깊이버퍼를 업데이트할 필요가 없는 놈들이라 디퍼드에 무난히 사용가능
  3. 특수 쉐이더: 머리카락에만 쓴 쉐이더인데 딱히 깊이 버퍼를 업데이트 하지 않고 머리통에 있는 법선 조명 정보를 대충 가져다가 씁니다. 즉 디퍼드도 포워드도 아닌 이상한 꼼수를 썼죠.
  4. 파티클: 파티클은 여전히 포워드로 했습니다. 워낙 투명하니... 저희 파티클 시스템은 또 워낙 빨라놔서.. (파티클 질을 저렇게 해도 콘솔에서 2.75 ms 밖에 안걸림)
이래서 투명한 물체는 해결... 사실 이걸 해결할 수 있던 가장 큰 원인은 아티스트들의 워크플로우를 뚜렷하게 정했다는 거에요. 뭐는 안되고 뭐는 되고를 확실히 알려줬고.. 안되는게 있으면 그걸 성취할 수 있는 다른 방법을 제시했고요.

앤티에일리어싱
그럼 앤티에일리어싱은 어떻게 해결을 했을까요? 사실 운이 좋았죠... -_-

다행히도 게임을 출시할 때쯤 해서 MLAA(God of War 3, The Saboteur)와 FXAA라는 기법들이 이미 개발되었었고... 저희도 FXAA에서 영감을 받아서 그것보다 한 0.1ms 정도 빠른 자체 기법을 개발했습니다. 한 0.8ms 걸렸죠. FXAA라고 해봐야 화면의 색상(또는 조도)을 대충 분석해서 갑자기 픽셀값이 변하는 부분을 적당히 블렌딩 해주는 기법이거든요.

콘솔에서 사용하는 FXAA 기법은 사실 좀 화면에 흐릿해진다는 단점이 있습니다. (PC버전과 달라요) 그래도 스페이스 마린의 비주얼은 만화스럽기보단 사실적에 좀 더 가까워서... 약간 흐릿해져도 큰 문제가 없었죠. (만화처럼 색이 강렬하고 짜잘한 디테일들이 막 들어가있으면 이렇게 흐릿해지는게 문제가 많아요.) 그래서 운좋게 대충 무사히 해결... 

지금와서 생각하는데 타사의 개발자들이 이런 기법들을 개발해 놓지 않았다면, 거기에서 영감을 받지도 못했을거고... 그러면 스페이스마린은 앨리어싱 때문에 꽤 타격을 받았을 거 같아요. 그렇다고 Gears of Wars 3처럼 아예 앤티 엘리어싱을 꺼버릴수도 없는거고... 운이 좋았죠. 책임회피는 했지만 운이 좋은.... -_-v

그렇다고 다 우리처럼 운이 좋을리는 없지?



그리고 스페이스마린을 출시한 뒤, 다른 회사의 게임을 좀 도와줬습니다. 몇 년전에 출시했던 게임의 후속작인데요. 따라서 그래픽 쪽으로는 특별히 손봐줄게 없겠다고 생각했죠. 어차피 컨텐츠만 좀 바꾸면 되니까. 그래픽 쪽은 좀 빠르게 만들어주거나 눈사탕 몇 개만 슬쩍 추가...?

근데 ... 아.뿔.사... -_- 소스코드를 열어보니... 포워드로 잘돌던 그래픽 엔진을 디퍼드로 바꿔버렸더군요..... 과연 왜 그랬는지 마땅히 말해주는 개발자들이 없어서.. 혼자 장단점을 따져봤습니다.
  • 광원의 수가 전 게임에 비해 늘었니? 아니... 거의 똑같은데... (장점 #1 실패 -_-)
  • 그럼 아티스트들의 작업시간은? 그림자를 오프라인에서 baking 하지 않으니 빨라짐... (장점 #2).... 근데 그림자 품질이 오프라인 처리할 때보다 저하되서 다시 baking을 시작하고 있음.. (결국 장점 #2 실패 -_-)
  • 화면공간에서 하는 후처리 기법은? 저번 게임하고 그닥 달라진게 없음... SSAO 정도 추가했나? (미약한 장점 #3)
  • 반투명한 물체는? 화면의 절반... -_- 여전히 포워드로 처리함... 한 10 ms 걸림... 쿨럭 -_- (심각한 단점 #1)
  • 앤티에일리어싱은? 아직 구현 안했었음...  스페이스마린에서 사용한 AA를 구현해줬으니 게임자체의 색상이 화려한 편이라 흐릿함이 눈에 거슬림.... 이걸 제대로 고치려면 PC버전에서 쓰는 FXAA를 써서 3ms낭비하거나... 아니면 깊이 및 법선 비교까지 해야함. 이러면 2.6 ms 정도 걸림.... (단점 #2)

아무리 생각해도 디퍼드로 갈 이유가 없는 게임이더군요. 아직도 정확한 이유는 모릅니다. 왜 디퍼드로 가기로 결정했는지.... '이론상으로' 포워드보다 낫다고 생각했고... 새로운 기법에 대한 짜릿함 때문에 그렇게 결정해버린 게 아닌지.. 생각만 할뿐..... 처음 게임이 더 비주얼이 좋을 거 같아요......버럭!

대충 정리
글만 주저리주저리 길게 쓰는 놈이라.. 대충 이 일화의 교훈(?)을 정리.
  1. 새로운 기법을 도입하기 전에는 반드시 장/단점을 반드시 따져볼 것. 특히 단점을 위주로...
  2. 그 기법을 이용해서 게임을 출시한 사람들이 발표하는 장/단점은 언제나 장점에 치우쳐 있음. 단점의 심각함을 2배로 곱해서 생각할 것...
  3. 그 기법을 이용해 컨텐츠를 제작할 아티스트 및 디자이너들을 프로토타입 과정에 포함시킬 것. 그 개발자들의 피드백이 좋지 않으면 그 보다 큰 단점이 없음.
  4. measure, measure and measure!: 언제나 실제로 성능을 측정해볼 것....



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;

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



2012년 1월 9일 월요일

[포프의 쉐이더 입문강좌] 04. 기초적인 조명쉐이더 Part 3

이전편 보기

정 반사광

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

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

그림 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을 다시 한번 정규화시켜 줍니다. 정점쉐이더에서 이미 단위벡터로 만들었던 이 벡터들을 다시 정규화해 주는 이유는 보간기를 거치는 동안 그 값이 흐트러질 수 있기 때문입니다. (보간기가 선형적(linear)으로 보간을 해서 그렇습니다.)

   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번 거듭제곱을 했는데 각 물체마다 이 값을 다르게 하는 것이 보통입니다. (거듭제곱의 수가 높을 수록 정반사광의 범위가 타이트해집니다. 숫자를 바꿔보면서 실험해보세요.)  따라서 이 값을 float형의 전역변수로 선언해주는 게 보다 나은 방법이 되겠습니다. 이 정도는 독자 분들의 몫으로 남겨두도록 하지요.

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

  return float4(specular, 1);

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

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

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

    return float4(diffuse + specular, 1);

위 코드에서 난 반사광과 정 반사광을 더하면 그 결과가 1이 넘는 경우가 있는데 크게 걱정하지 않으셔도 됩니다. 그런 경우엔 알아서 1이 됩니다. (현재 하드웨어 백버퍼의 포맷이 8비트 이미지이기 때문입니다. 부동소수점 텍스처를 사용하면 1 이상의 값을 저장할 수도 있습니다.)

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

그림 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장으로 넘어오시기 바랍니다.

2012년 1월 2일 월요일

[포프의 쉐이더 입문강좌] 04. 기초적인 조명쉐이더 Part 2


이전편 보기



기초설정

  1. 여태까지 해왔던 것과 마찬가지로 렌더몽키 안에서 새로운 DirectX 이펙트를 만든 뒤, 정점쉐이더와 픽셀쉐이더의 코드를 삭제합니다.
  2. 이제 쉐이더의 이름을 Lighting으로 바꾸도록 합시다.
  3. 정점의 위치를 변환할 때, 필요한 gWorldMatrix, gViewMatrix, gProjectionMatrix를 추가하고 변수 시맨틱에 연결해주는 것도 잊지 마세요.


람베르트 모델을 이용해서 난 반사광을 계산하려면 어떤 정보가 필요했었죠? 입사광의 벡터와 표면법선 벡터입니다. 법선 정보는 각 정점에 저장되어 있는 것이 보통입니다. (언제나 그렇지는 않습니다. 나중에 법선매핑(normal mapping)을 배울 때 법선을 구하는 다른 방법을 알아봅니다.) 따라서 정점버퍼로부터 이 정보를 가져와야 합니다. 저번 장에서 정점버퍼에서 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 );


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


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




   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) 값이므로 방향벡터에 아무런 영향도 미치지 않습니다. (동일한 방향을 가리키는 화살표 2개를 다른 위치에 놓는다고 해서 그 방위가 바뀌지 않지요? 따라서 방향벡터에서 평행이동 값은 아무 의미도 없습니다.)

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

   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에는 어떤 형과 시맨틱을 써야 할까요? 두 벡터의 내적을 구하면 그 결과는 벡터가 아닌 숫자 하나입니다. (이것을 스케일러(scalar)라고 합니다. 보통은 스칼라라고 많이 하시는데 스케일러가 맞는 발음입니다.) 따라서 float만 사용해도 사실 큰 문제는 아니지만 나중에 이 값을 픽셀의 RGB값으로 출력할 것이니 그냥 float3를 사용하겠습니다. 그렇다면 시맨틱은 어떻게 할까요? DIFFUSELIGHTING이라는 시맨틱이 존재할까요? 불행히도 그렇지 않습니다. (여기서 COLOR0 시맨틱을 사용하지 않은 이유는 정점쉐이더 2.0 규격에서 COLOR 시맨틱을 사용한면 변수의 값이 0~1 사이로 클램프 되기 때문입니다. 따라서 보간기를 거쳐 펙셀쉐이더에서 이 값을 넘겨받으면 좀 오차가 보이더군요.)쉐이더 프로그래밍을 하다 보면 용도에 딱 맞는 시맨틱이 없는 경우가 종종 있는데, 그럴 때는 그냥 TEXCOORD 시맨틱을 사용하는 게 보통입니다. 최소한 8개(TEXCOORD0 ~ TEXCOORD7)의 TEXCOORD가 존재하니까 별로 모자라는 경우가 없거든요. 여기서는 TEXCOORD1을 사용하겠습니다. (TEXCOORD0 대신 TEXCOORD1을 사용한 이유는 다음 장에서 TEXCOORD0을 텍스처의 UV 좌표로 쓰기 위해서입니다.)

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

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 난 반사광 효과



2011년 12월 28일 수요일

Lighting 모델의 성능 비교


최근에 KGC 에서 발표를 한 이후 예전에 영문 블로그에 써놨던 Oren-Nayar관련 포스트들을 좀 살펴봤다. 근데 이 포스트를 한글 블로그에는 올리지 않은 것 같아 재빨리 올려본다.

조명 공식을 바꿀때마다 성능에 신경이 안쓰일 수가 없는데 다행히 쉐이더 코드와 그 성능을 비교해 놓은 표를 찾았다. 특히 diffuse와 specular 조명을 모두 계산하는 Cook-Torrance 모델이 Blinn-Phong 스페큘라와 Oren-Nayar 디퓨즈 모델을 합친 것보다 빠르다는 건 주목할만 하다.

자세한 내용은 여기를 볼 것.

관련 글:


2011년 12월 26일 월요일

[포프의 쉐이더 입문강좌] 04. 기초적인 조명쉐이더 Part 1

이전편 보기


샘플파일받기

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

이 장에서 새로 배우는 HLSL

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


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


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

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

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

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

난 반사광
배경
대부분의 물체는 스스로 빛을 발하지 않습니다. 그럼에도 저희가 이 물체들을 지각할 수 있는 이유는 다른 물체(예, 태양)가 발산하는 빛이 이 물체의 표면에서 반사되기 때문입니다. 이 때, 여러 방향으로 고르게 반사되는 빛이 있는데 이것을 난 반사광(diffuse light)이라고 합니다. (diffuse 광은 아직도 용어정립이  안되고 있습니다따라서  용어를 사용할  때마다 종종 영문 표기를 같이 하도록 하겠습니다다른 용어로는 산란광확산광 등이 있는데  반사광이 가장 적합한  같습니다.) 어느 방향에서 바라봐도 물체의 명암이나 색조가 크게 변하지 않는 이유를 아시나요? 여러 방향으로 고르게 퍼지는 난 반사광 덕분입니다. 만약 빛이 한 방향으로만 반사된다면(이것이 뒤에서 살펴볼 정 반사광입니다.) 그 방향에서만 물체를 지각할 수 있겠지요.

참고로 물체의 표면이 거칠수록 난반사가 심해지는 것이 보통입니다. (표면이 완전히 매끈하더라도 난반사가 완전히 사라지는 경우는 극히 드뭅니다. 표면을 뚫고 들어간 뒤, 물체 내부에서 반사되는 빛도 있기 때문입니다.)

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

그림 4.2. 난 반사광

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

자, 그렇다면 수학적으로 난 반사광을 어떻게 계산할까요? 당연히 수학자마다 다른 주장을 하지만 그 중에서 게임에서 주로 사용하는 람베르트(lambert) 모델을 살펴봅시다. 요한 람베르트라는 수학자가 창시한 람베르트 모델은 표면법선(법선(normal)이란 표면의 방위(orientation)를 나타내는 벡터입니다. 따라서 그림 4.2에서처럼 좌우로 평평한 평면의 법선은 위쪽으로 수직인 선이 됩니다.)과 입사광이 이루는 각의 코사인 값을 구하면 그게 바로 난 반사광의 양이라고 합니다. 그렇다면 일단 코사인 함수의 그래프를 볼까요?


그림 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로 만들어서 공식을 간단하게 만드는 게 훨씬 나아 보이는군요. (이렇게 길이가 1인 벡터를 단위벡터(unit vector)라고 하며, 단위벡터를 만드는 과정을 정규화(normalize)라고 합니다.)

그럼 내적이 코사인 함수보다 값싼 연산인 이유를 살펴볼까요? 벡터 A의 성분을  (a, b, c)로 두고 벡터 B의 성분을 (d, e, f)로 두면 두 벡터의 내적을 이렇게 간단히 구할 수 있습니다.

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

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

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