GLSL 에서 그림자 맵(shadow map)을 활용한 그림자 구현 - Whitmem
GLSL 에서 그림자 맵(shadow map)을 활용한 그림자 구현
그래픽 개발
2025-01-07 21:06 게시 bc6ec195cfa8b2021103

0
0
76
이 페이지는 외부 공간에 무단 복제할 수 없으며 오직 있는 그대로 게시되며 부정확한 내용을 포함할 수 있습니다. 법률이 허용하는 한 가이드 라인에 맞춰 게시 내용을 인용하거나 출처로 표기할 수 있습니다.
This page is not to be distributed to external services; it is provided as is and may contain inaccuracies.
GLSL에서 기본적인 그림자 구현의 방법
임의의 광원에서 직접적으로 빛을 받는 표면은 제일 밝게 렌더링되고, 그 뒤에 렌더링되는 것들은 어둡게 렌더링하면 된다. 즉 뒤 그림자가 렌더링되는 구역 지점 앞에 이미 밝게 렌더링이 된다면, 그 구역은 어둡게 렌더링하면 되는 것이다. 이를 위해서 광원이 특정 지점을 바라보는 시점에서 깊이 정보를 기록하는 zBuffer을 생성하고, 현재 렌더링하는 지점의 깊이가 제일 앞을 의미하는 깊이 값 보다 깊으면 그림자를 렌더링한다.
예전 쉐이더 개발의 '개'짜 도 모를 때는, DirectX 3D 공간에서 객체를 생성하고 빛을 생성하면 자동으로 그림자가 생기지 않을까 그런 생각을 하곤 했다. 이제 생각해보면 레이 트레이싱 기술을 사용하지 않는 이상 ... 불가능한 것이었다. 구현하기 힘들뿐더러 값이 비싸 실시간 렌더링 게임에서는 쉽지 않기 때문이다. 그렇기 때문에 그림자를 모방할 수 있는 다른 방법이 필요한데, 바로 쉐도우 맵을 통해 그림자를 구현할 수 있다는 것이다. 다양한 방법이 존재하지만 위키 백과에 서술된 방법인 기본적인 방법을 통해 그림자를 구현 해 보고자 한다. 이 구현 과정에는 아직 익숙하지 않기에 일부 인공지능 사이트를 활용하였다.
기술 출처 위키 백과
위 사진은 쉐도우가 적용된 모습을 나타낸 것과 그림자 맵 하나를 임의로 나타낸 것이다. 그림자 맵은 물체가 존재하는 영역이 흰색으로 보이는 것 같지만, 사실상 모두 같은 흰색은 아니다. 위 그림자 맵은 광원이 원점을 바라볼 때의 깊이 값을 렌더링한 이미지라고 보면 좋다. 즉 상기 이미지는 카메라의 이미지가 아니다. 카메라의 뷰 행렬은 따로 있고, 빛이 하나의 카메라가 되어 원점을 바라보는 행렬이 따로 존재하는 것이다. 그 행렬로 렌더링된 하나의 결과인 것이다. 이 때 물체의 색상은 무조건 흰색인 vec4(1.0, 1.0, 1.0, 1.0)으로 지정하는 것이 아니라, 빛이 원점을 바라볼 때 각 물체가 존재하는 깊이를 vec4(깊이, 깊이, 깊이, 1.0)으로 나타낸 것이다. 사실 깊이 하나만 저장하면 되기 때문에 단색 이미지로 렌더링해도 상관 없지만... 귀찮아서 3색 이미지로 렌더링하였다. R,G,B 를 모두 동일하게 했기 때문에 사실상 단색 이미지와 시각적으로는 동일하다.
이 때, 위 이미지를 캡처해서 그림판의 색 채우기 도구로 채우면 동일하게 다 채워지는 것을 볼 수 있다. 하지만 명백히 각 픽셀마다 서로 다른 색상을 가지고 있는데, 위 객체는 3D 공간이기 때문에 한 점 한 점 다 다른 좌표에 존재하기 때문이다. 그런데 분명 상기 문단에서는 깊이를 렌더링한 것이라고 했다. 같은 색상으로 보이는 이유는, 위 텍스처를 32비트로 렌더링했기 때문이다. 모든 물체와 버텍스 좌표는 x, y, z 요소를 가지고 있으며 이를 카메라 기준(여기서는 빛이 원점을 내려다보는 뷰 공간)에서 z가 깊이를 의미한다. 월드 공간에 존재하는 객체는 월드 공간에서 x, y, z는 z가 깊이를 의미하지 않을 수 있다. 하지만 위 렌더링된 뷰는 광원 카메라(광원 뷰) 행렬을 거쳤기 때문에, 카메라의 시야 안에서 z가 깊이가 된다.
이 깊이 값은 소수점 하나 하나 큰 의미를 가지고 있기 때문에 256 개의 색상 정보를 가지는 8비트 공간안에 -1 ~ 1 정보를 담아내더라도 계단식으로 잘려나가 오차가 매우 심해질 수 있기에, 소수 점 하나 하나를 세밀하게 기록하기 위해 텍스처의 각 색상 크기를 32비트로 지정하였다.
이런 경우 각 색상에 32비트개의 경우의 수를 표현할 수 있으므로, 2^32 개의 좌표 정보를 하나의 색상에 나타낼 수 있게 된다. 즉 -1 ~ 1 을 2^32 개로 조각내어 텍스처에 저장한 것인데, 이를 스크린샷 하면 컴퓨터에서는 8비트인 256개의 색상으로 읽여들이기 때문에, 비로소 그림판에서는 색을 구별할 수 없게 되는 것이다. 물론 색상이 세밀할 수록 처리량이 늘어나기 때문에 실제 개발에서는 효율적인 방법을 선택해야할 것으로 보인다.
다시 돌아와서 그림자 맵은 표면의 z 깊이를 이미지로 렌더링한 것이기 때문에 렌더링 결과에는 광원에 제일 가까운 표면 영역의 z 깊이가 기록된다. 즉 표면 뒤에 있는 다른 객체의 z 깊이는 기록되지 않으며 그래픽 처리 단계에서 다른 렌더링 결과에 가려진 뒷 부분은 알아서 컬링되기 때문에 결과적으로 렌더링된 이미지에는 광원에 제일 가까운 표면의 z 깊이만 기록된다. 즉 렌더링된 그림자 맵은 광원에 얼마나 가까운지를 밝기로 나타낸 값이다. 광원 행렬인 카메라 뷰/투영 공간에서 카메라와 멀어질수록 밝아지고, 카메라와 가까워질수록 어두워진다. (뷰 행렬 공간에서는 -1~1 공간에서 카메라가 원점에 존재하고 카메라는 -1 방향을 쳐다보는 0~-1 범위이지만, 투영 행렬을 통해서 이를 다시 0~1로 정규화하기 때문이다.)
그렇기 때문에 이제 다른 객체를 렌더링하면서 그림자 깊이 맵과 비교해서 현재 그리는 깊이 정보가 그림자 맵에 그려진 것보다 더 멀리 있으면 이미 현재 그려지는 위치 앞에는 다른 객체가 이미 렌더링되고 있다는 것을 의미한다. 즉 그림자가 생겨야하는 영역으로 판단할 수 있다.
위 이미지는 하나의 예시를 나타낸 자료이다. 광원은 특정 지점에 존재하고 카메라는 사람이 쳐다보는 뷰이다. 실제 게임으로 따지면, 모니터 화면에 렌더링되는 영역은 Viewer 가 바라보는 방향이지만, 그래픽 내부에서는 Point 에도 카메라가 존재해 사실상 2개의 시점을 렌더링한다. Point가 바라보는 뷰 정보는 내부적으로 그림자 판정 처리를 위한 내부용 렌더 결과가 되는 것이다.
그래픽에서 광원이 특정 지점을 내려다보는 렌더링 맵을 그림자 맵에 그리고, 그 다음에 비로소 플레이어가 바라보는 카메라를 렌더링한다. 이 때, 플레이어의 카메라 시점에서 그리는 특정 지점의 픽셀에 대해 그림자 맵에서 좌표를 알아내야 한다. 즉 카메라 뷰의 공간과 광원 뷰의 공간은 완전 별개이다.
이미 그림자 맵 텍스처는 렌더링했고, 플레이어 시점에서 모니터에 출력되기 위한 시점은 카메라 뷰의 시점이다. 카메라 뷰의 시점에서 렌더링할 때 해당 지점이 그림자가 씌워지는지 확인하기 위해서는 그림자 맵을 가져와서 비교해야 하는데, 그림자 맵의 좌표 정보를 다시 계산할 필요가 있다. 플레이어 카메라 뷰의 좌표 정보는 그림자 맵의 좌표 정보와 전혀 매칭할 수 없기 때문이다. 따라서 해당 버텍스 쉐이더에서 그림자 맵 생성 당시의 행령 정보를 그대로 넘겨, 광원에서 바라볼 때의 좌표 정보를 픽셀 쉐이더로 넘기고, 카메라 뷰로 바라볼 때의 좌표 정보는 쉐이더 gl_Position 으로 넘겨 실제 해당 정점으로 렌더링되도록 해야한다.
그리고 픽셀에서 해당 그림자 좌표 위치를 받아 UV로 변환한다음 그림자 맵의 색상을 서로 비교하고, 그림자 판정이 발생한 경우 비로소 gl_Position 의 그려지는 지점에 색상을 반영해주면 되는 것이다.
위 사진에서는 그림자 맵을 생성하는 DefaultViewShader 와 실제 그림자를 반영해서 플레이어의 시야에 출력하는 ResultView 쉐이더 2개가 존재한다. 두 쉐이더 모두 객체의 조건 사항은 100% 동일해야 한다. 객체의 좌표, 크기, 회전 정보는 모두 동일하게 구성하였다.
광원이 원점을 바라보는 광원 뷰 행렬 역시 100% 두 쉐이더 동일하게 구축한다. 광원 그림자를 생성할 당시의 광원 뷰 행렬과 플레이어의 실제 렌더링 당시 사용할 광원 뷰 행렬이 동일해야지 똑같은 좌표 정점으로 처리되어 같은 지점끼리 그림자 비교 연산이 가능하기 때문이다.
이 때 뷰 행렬은 정규화된 내용이 아니기 때문에 정규화하기 위해서는 투영 행렬도 지정해야 한다. 이 투영 행렬 역시 두 쉐이더 동일하게 지정한다.
이제 그림자 맵을 생성하는 쉐이더 두 쌍을 생성한다. 그림자 맵을 생성하는 건 간단하다. gl_Position 에 대해 기존 플레이어의 카메라 뷰가 아니라, 광원에서 쳐다보는 뷰, 투영 행렬로 지정하여 넘겨주면 광원이 원점을 쳐다보는 좌표대로 화면에 렌더링된다. 이 화면에 렌더링되는 것은 실제 모니터에 렌더링되는 것이 아니라, 가상의 텍스처인 렌더 타겟에 담아 변수로 사용할 것이다.
색상 정보는 z 값을 그대로 뿌려주면 된다. 그전에 w로 나누는 이유는 계산 처리를 하면서 w가 1이 아닌 어떤 임의의 값이 되는데 이를 다시 1로 해주기 위함이다. 이 부분은 조금 더 자세히 다른 게시글로 공부하여 서술할 예정이다.
그러면 여기서 위와 같은 그림자 맵을 볼 수 있다. 이 그림자 맵을 참조하여 실제 플레이어의 시야에 렌더링하면 된다.
원본 객체는 우선 플레이어의 시야에 표시되어야 하므로, gl_Position 에는 원본 카메라 행렬로 계산해서 넘겨준다. 하지만 내부적으로 그림자 맵의 기준 좌표 정보도 필요하기 때문에 varying 을 하나 만들어 픽셀 쉐이더로 넘겨준다. 즉 gl_Position 은 일반 사용자 카메라 기준으로 객체가 렌더링되는 위치 정보를 넘기는 것이고, lightDirectionPosition 은 광원이 원점을 바라볼 때 각 객체가 렌더링되는 위치 정보를 넘기는 것이다. 뷰 행렬이 다른 것이다.
픽셀 쉐이더에서는 버텍스 쉐이더에서 넘겨받은 광원 뷰로 연산된 좌표 정보를 읽어들인다. 광원 뷰로 연산된 좌표 정보는 광원 시야에서 바라볼 때의 x, y, z 정보이다. 이 때 역시 w로 나누어주고, z를 꺼내면 픽셀 쉐이더내에서 광원 시야 기준 객체의 정점 정보를 알아낼 수 있게 되는 것이다. 이 대 우리가 필요한 것은 z 깊이가 그림자 맵에 그려진 것 보다 깊은지 얕은지 확인하는 것이 필요하다. 아까 그림자 맵에는 광원에 제일 가까운 표면만이 제일 어둡게 렌더링되고 먼 곳이 밝게 렌더링되기 때문에 해당 지점의 그림자 맵 색상을 가져와서 현재 광원 투영 z 보다 깊으면 현재 픽셀 쉐이더에서 검정으로 렌더링하면 그림자가 생기는 것이다. 광원 뷰 행렬로 연산된 정점 정보를 가져와서, z는 깊이 버퍼로 사용하기 위해 float 변수 공간에 담는다. 그리고 그림자 맵의 UV 좌표가 필요한데, 현재 비교 대상인 지점의 광원 투영 당시의 그림자 맵의 UV 를 계산해야 한다. 이미 x, y는 광원 뷰 기준에서 투영까지 된 정점 정보이기 때문에, -1~1의 정점 정보를 가진다. 따라서 -1~1 의 정점 정보를 가지는 x, y 를 0~1 로 선형 보간해주면 된다. 그러기 위해서 각 좌표에 1을 더하고 2로 나누면 0~1의 범위가 된다. 해당 범위가 결국 UV 좌표가 되는데 이를 texture 함수를 사용해서 그림자 맵에서의 UV 좌표에 대한 색상을 가져온다.
그러면 여기까지 했을 때, 광원 뷰 기준 현재 객체의 픽셀에 대한 깊이와, 광원에 표면이 가까운 정도를 기록한 그림자 맵의 픽셀에 대한 깊이 정보를 각각 알아낸 것이다. 이 깊이 정보는 광원에서 멀수록 1에 가까워진다고 했다. (뷰 공간이 아닌 화면 공간으로 이동해서 정규화했기 때문이다.) 현재 그려지는 지점의 깊이가 그림자 맵의 깊이보다 큰 경우에 이미 앞에 어떤 물체가 가로 막고 있다는 것을 의미한다.
현재 지점 > 그림자 맵 : 현재 그려지는 부분이 가려진다. 현재 지점 < 그림자 맵 : 현재 지점이 제일 광원과 가깝고, 앞에 가리는 물체가 없다.
따라서 if문을 사용해서 현재 깊이인 depthValue <= 그림자 맵의 깊이를 통해 그림자가 없는지 판정하고, 그 지점은 1.0 인 흰색으로 렌더링, 아닌 경우 그림자를 그리기 위한 0.1인 어두운 색으로 렌더링하면 된다.
하지만 막상 렌더링하면 위와 같이 색상이 깨지는 문제가 존재하는데, 확실히 그림자가 들어서는 부분은 잘 어두워지지만, 광원과 제일 가까운 표면 부분은 어느 부분은 밝고, 어느 부분은 어두워지는 문제가 발생한다. 그 이유는 깊이 정보를 저장한 그림자 맵에 오차가 존재하기 때문이다. 그림자 맵에 좌표 정보를 단계적으로 조각 내어 비트로 저장했는데, 그 과정에서 계단 형식으로 오차가 발생하게 된다. 즉 현재 표면이 가려지는 그림자가 없다면, 깊이가 그림자 맵도 0.500000001 이고, 현재 계산된 깊이도 0.500000001이겠지만, 소수점은 부동소수점으로 계산됨과 동시에 텍스처에 0.500000001을 저장하는 과정에서 조각되어 0.5 로 저장될 수도 있다. 즉 비교 연산 과정에서 0.500000001 <= 0.5 의 조건이 되어버려 그림자가 없는 영역임에도 불구하고 그림자 영역으로 판정될 수도 있다. 따라서 어느정도의 오차는 허용할 수 있도록 두 색상의 차가 0.000001 정도는 같은 값으로 간주하여 그림자가 안그려져야 하는 영역으로 판단하게끔 할 수 있다.
비로소 이제 제대로 그려진 것을 확인할 수 있다.
댓글 0개
댓글을 작성하는 경우 댓글 처리 방침에 동의하는 것으로 간주됩니다. 댓글을 작성하면 일회용 인증키가 발급되며, 해당 키를 분실하는 경우 댓글을 제거할 수 없습니다. 댓글을 작성하면 사용자 IP가 영구적으로 기록 및 부분 공개됩니다.
확인
Whitmemit 개인 일지 블로그는 개인이 운영하는 정보 공유 공간으로 사용자의 민감한 개인 정보를 직접 요구하거나 요청하지 않습니다. 기본적인 사이트 방문시 처리되는 처리 정보에 대해서는 '사이트 처리 방침'을 참고하십시오. 추가적인 기능의 제공을 위하여 쿠키 정보를 사용하고 있습니다. Whitmemit 에서 처리하는 정보는 식별 용도로 사용되며 기타 글꼴 및 폰트 라이브러리에서 쿠키 정보를 사용할 수 있습니다.
이 자료는 모두 필수 자료로 간주되며, 사이트 이용을 하거나, 탐색하는 경우 동의로 간주합니다.