개발하는 리프터 꽃게맨입니다.
Directional Light Shadow Mapping 본문
그림자는 조명이 존재하는 장면에서 현실감을 더해줍니다.
더하여 장면과 객체에 깊이감, 공간감을 부여해죠
위 그림을 보면 그림자가 주는 효과를 체험할 수 있습니다.
그림자가 있으면 객체들이 어떻게 배치되어있는지 명확하게 알 수 있습니다. 하지만 그림자를 구현하는 것은 매우 까다롭고, 현재 실시간 래스터라이드 그래픽스에서는 완벽한 그림자 알고리즘이 개발되지 않았습니다. 그래서 여러가지 훌륭한 그림자 근사 기술을 사용할 수 있씁니다.
대부분 실시간 그래픽스에서 사용하는 것은 그림자 매핑기술 입니다.
그림자 매핑은 구현이 쉽고 결과는 꽤나 괜찮습니다. 그리고 이 개념을 확장하여 Omnidirectional Shadow Map, Cascaded Shadow Map 으로 발전해나가보겠습니다.
그림자 매핑의 기본 개념은 매우 간단한데
광원이 마치 카메라인양 광원의 시점에서 장면을 렌더링하는 것입니다. 이것이 키포인트죠
그림자는 광원에서 나온 빛이 앞의 물체에 막히면 검정색을 보여야 합니다. 기본적인 개념은 Ray 를 쏴서 충돌검사를 하는 것이겠지만, 레스터라이저 파이프라인에서는 광원 시점에서 장면을 한 번더 렌더링하는 것이 동일한 효과를 줍니다.
예를 들어, 위 그림에서 바닥에 큰 상자가 놓여 있고 그 상자와 광원 사이에 상자가 하나 있다고 할 때, 광원 시점에서는 바닥의 상자 일부가 가려져서 보이지 않습니다. 이것을 고려하여 그림자를 렌더링하면 됩니다.
여기서 모든 파란 선은 광원이 볼 수 있는 프래그먼트를 나타내고
검은 색은 반대로 그림자로 렌더링되는 부분입니다.
우리는 그림자 매핑을 구현하기 위해 깊이버퍼를 사용합니다.
조명 시점에서 조명의 깊이버퍼를 사용하여 장면을 렌더링한다면 광원과의 거리에 의존하는 깊이값이 기록되고, 모든 장면을 렌더링했을 떄 광원의 시점에서 볼 수 있는 가장 가까운 깊이 값을 얻어낼 수 있다.
이렇게 깊이 값을 저장한 텍스처를 깊이 맵, 쉐도우 맵 등으로 부른다.
왼쪽 그림에서는 방향성 광원이 만드는 그림자를 보여준다.
방향성 광원은 모든 광선이 서로 평행하기 떄문에 특정한 위치를 가지지 않는다. 하지만 그림자 매핑을 위해서는 광원의 서점에서 장면을 렌더링해야 하므로, 우리는 광원의 방향에 따라 임의의 위치에서 장면을 렌더링하는 방식으로 처리한다.
깊이 맵을 이용해서 어떻게 그림자를 판별하는가?
먼저 광원의 관점에서 장면을 렌더링하여 그림자 맵을 생성해야한다.
그렇기 위해서는 광원에 맞는 뷰, 프로젝션 행렬을 사용해야 한다.
오른쪽 이미지를 집중하라.
관찰자가 있고 픽셀 P를 렌더링한다고 하자.
이 프래그먼트가 그림자 속에 있는지 어떻게 판단하는가?
먼저 광원의 ViewProj 행렬을 이용하면 점 P를 광원의 클립 좌표계로 변환할 수 있고, 동차나눗셈을 통해 NDC 좌표계까지 변환할 수 있다 그러면 변환 결과인 점 P의 Z 좌표는 깊이를 가지고 있다.
위 예시에는 그 값은 0.9 이다.
그리고 NDC 에 있는 점 P를 이용하여 깊이 맵을 인덱싱한다. 그러면 광원의 관점에서 가장 가까운 가시 깊이를 얻을 수 있으며 위 예시에서 이 값은 0.4 이다.
만약.. 그림자가 아닐 경우 앞서 제시한 두 값이 같을 것이다.
그러나 이번 예시에서는 두 값이 다르다. 그러므로 해당 픽셀은 그림자라고 판단할 수 있다.
따라서 그림자 매핑은 두 개의 패스로 구성된다.
먼저 깊이 맵을 렌더링하는 Depth Only Pass
그리고 깊이 맵을 사용하여 그림자 속에 프래그먼트를 판단하는 Pass (이는 일반적인 Rendering Pass와 동일하다.)
뎁스 맵을 생성하고 깊이만 렌더링 해보자.
먼저 사용할 일반적인 3D 씬
깊이만 출력할 버퍼를 생성한다.
깊이 버퍼는 SRV가 필요하며 해상도는 굳이 화면의 해상도와 동일할 필요는 없다.
깊이 맵의 해상도가 높을수록 그림자의 성능이 높아질 수 있으나 성능적으로는 좋지 않다.
깊이만 렌더링할 때는 굳이 색상 정보가 필요없기 때문에 단순한 쉐이더를 사용하는 것이 성능상 이점이다.
#include "ShaderCommon.hlsli"
PS_Input main(VS_Input input)
{
PS_Input output;
output.posW = mul(float4(input.posL, 1.0f), g_world).xyz;
output.posH = mul(float4(output.posW, 1.0f), g_viewProj);
output.normalW = 0.f;
output.tangentW = 0.f;
output.bitangentW = 0.f;
output.texCoord = input.texCoord;
return output;
}
#include "ShaderCommon.hlsli"
float4 main(PS_Input input) : SV_TARGET
{
return float4(1.0f, 1.0f, 1.0f, 1.0f);
}
위와같이 매우 매우 단순한 쉐이더를 사용해도 된다.
그러면 카메라 시점에서 깊이 맵 렌더링이 어떻게 되는지 시각적으로 살펴보도록 하자.
깊이 값을 시각화한 예
#include "DepthVisualizeCommon.hlsli"
PS_Input main(VS_Input input)
{
PS_Input output;
output.posH = float4(input.posL, 0.0f, 1.0f);
output.texCoord = input.texCoord;
return output;
}
#include "DepthVisualizeCommon.hlsli"
float4 main(PS_Input input) : SV_TARGET
{
float z = g_depthMap.Sample(g_sampler, input.texCoord).r;
float2 ndcXY = input.texCoord * 2.0f - 1.0f;
float4 ndxPos = float4(ndcXY, z, 1.0f);
float4 worldPos = mul(ndxPos, g_viewProjInv);
worldPos.xyz /= worldPos.w;
float dist = length(worldPos.xyz - g_eyePosW) * g_depthFactor;
dist = clamp(0.f, 1.f, dist);
return float4(dist, dist, dist, 1.0f);
}
사용한 쉐이더
다음은 시점을 조명으로 변경해야 한다.
마치 조명이 카메라 인듯양 조명 시점에서 깊이맵을 생성해야 한다.
방향광을 렌더링한다고 가정하면.. 직교 투영 행렬을 사용하여 이를 구현한다.
조명 시점에서의 투영행렬을 만드는 예시이다.
평행광을 쓴다면 직교투영 행렬을.. 스팟 라이트를 사용한다면 원근투영 행렬을 사용해야한다.
평행광은 기본적으로 무한하게 먼 거리에 있다는 것을 가고 빛의 방향또한 평행하다. 그렇기 때문에 직교투영을 사용하는 것이다. 여기서 고려해야하는 것은 viewport의 설정이다. 그림자 맵과 백버퍼의 해상도가 다를 수 있기 떄문에 알맞게 설정해주지 않으면 제대로 그림자 맵이 생성되지 않는다.
그림자 맵에서 가장 핵심인 아이디어는 빛의 관점에서 씬을 렌더링하는 것이다.
이렇게 생성하는 것 까지는 좋으나.. 개념상 평행광은 위치에 구애를 받지 않지만, 렌더링을 위해서는 위치정보가 꼭필요하기에 역설적이게도 평행광에서 광원의 위치를 잡아줘야만 한다.
이는 현재 설정된 메인 카메라의 위치에서 빛의 반대 방향으로 충분히 멀리 떨어지도록 한다.
빛의 관점에서 씬을 렌더링할 때는 앞서 말했듯 매우 간단한 쉐이더를 사용하는 것이 성능상 이점이다.
실제로 이런 PS 를 사용해도 아무런 상관이 없다.
(에초에 렌더타겟을 바인딩하지 않기 때문에 그려질 곳도 없다..)
이제 그림자를 렌더링 해보자.
그림자를 렌더링하기 위해서는 픽셀 쉐이더를 이용해야만 한다.
이런 식으로 쉐도우 맵을 선언한 후..
쉐도우 맵에서 사전에 계산된 깊이 값을 가지고 온다. 이 깊이 값을 가져오기 위해서는 쉐도우 맵의 UV를 알아야하는데, 이를 아는 방법은 간단하다.
바로 상수 버퍼로 조명의 뷰 * 원근투영 행렬을 가져오는 것이다.
우리는 픽셀 쉐이더의 인풋으로 해당 픽셀의 WorldPosition 을 가지고 있고 특정 원근 투영 행렬을 곱하면 어떤 투영 평면으로든 점을 투영할 수 있다. 여기서도 조명의 뷰 투영 행렬을 이용하여 조명의 투영 평면에 대한 x, y, z, w 값을 얻어낼 수 있다. (수동으로 원근 나눗셈을 해주는 것이 기존 버텍스 쉐이더에서 해왔던 것과의 차이이다.)
즉, 해당 좌표는 NDC 공간의 좌표다.
여기서 쉐도우 맵의 uv 공간으로 좌표를 변경해보자.
NDC 의 x를 [0, 1] 으로 만들고 -y를 [0, 1] 으로 만들어주기만 하면 된다. (directx 의 uv는 위에서 아래를 띄기에 -y이다.)
그렇다면 그저 2를 나눈 뒤 +0.5를 하면 된다. (혹은 1을 더 한 뒤 / 2 를 해주자)
그러면 그림자 맵에 저장되어있는 z를 얻어올 수 있다.
NDC 공간에 있는 z와 그림자 맵에 저장되어있는 z를 비교하여 해당 픽셀이 가려져 있는지 아닌지 알 수 있다.
그림자 맵에 저장되어 있는 z는 조명에서 가장 가까운 픽셀이기 때문에
NDC z와 그림자맵 z가 거의 유사할 경우 해당 픽셀은 그림자를 생성하지 않는다고 이해할 수 있고
NDC z가 더 클경우 (= 더 깊을 경우) 가려진다고 볼 수 있다.
그러면 결과를 확인해보자.
해당 쉐도우 매핑은 3가지 문제가 있다.
1) 잡음이 생긴다.
2) 위쪽을 보면 그림자가 하나 더 생긴다.
3) 왼쪽 위를 보면 특정 경계를 넘어설 경우 완전 까맣게 처리된다.
그 외에도 더 있으나 추후에 다루도록 하고
먼저 위 3개부터 해결해보자.
1) 이러한 잡음을 shadow acne 라고 부른다. (번역하면 그림자 여드름)
혹은 더 일반적인 용어로는 모아레 패턴이 보인다 라고 부른다.
이것이 발생하는 이유는 그림자 맵의 해상도에 의해 발생한다. 깊이 맵에서 여러 프래그먼트가 동일한 값을 샘플링할 떄, 특히 빛의 원근 차이가 클 때 문제가 발생한다.
깊이 맵의 해상도가 제한되어 있기 때문에 빛의 원근이 떨어질수록 여러 프래그먼트가 동일한 깊이 맵의 샘플 값을 샘플링하게 된다.
이것 자체는 문제가 되지 않는다. 그러나 빛의 원근이 기울어진 각도에서 보면, 깊이 맵 또한 각도에 따라 렌더링되기 때문에 문제가 발생한다. 몇몇 프래그먼트는 바닥을 넘어서거나 그 아래에 위치하게 되어, 그림자 불일치가 생긴다. 이로 인해 일부 프래그먼트는 그림자 속에 있고, 일부는 그렇지 않게 되어 모아레 패턴이 나타나게 되는 것이다.
해결하는 간단한 방법은 바이어스 bias 라고 부르는 상수값 (혹은 변수값) 을 추가해주는 것이다.
바이어스의 존재는 다음과 같은 방법으로 그림자 여드름을 해결한다.
그림자 계산공식을 바꾸니 여드름이 사라졌다.
그다음은 여러개의 그림자가 생기는 것인데.. 이는 샘플러를 wrap 모드로 사용해서 그렇다.
clamp로 바꾸니 훨씬 깔끔해진 모습이다.
이제 뒤에 있는 녀석을 제거해야한다.
이는 쉐도우 매핑의 결점으로 투영 평면을 벗어나는 요소는 렌더링하지 못하기 때문에 발생하는 문제이다.
충분한 해상도로 전체 장면을 그림자 맵으로 만들 수 있다면 좋겠지만, 현재 하드웨어에서는 불가능하다. (시야에 보이는 모든 것을 담는 쉐도우 맵은 얼마나 클지 상상이 가지 않는다.)
더하여 지금 그림자 맵은 항상 원점 주변에 생성되므로 뷰 프러스텀의 크기에 제대로 맞지 않다. 그림자 맵에 대한 최상의 해상도를 얻으려면 그림자 맵의 투영 공간이 카메라의 프러스텀에 꼭 맞아야 한다.
밉맵과 LOD의 존재가 말해주듯 멀리 있는 정보는 세부적으로 표시하지 않아도 된다.
가까운 것에 대해서만 선명한 그림자를 표시하고 멀리 떨어져있는 그림자는 흐릿해도 전혀 눈에 띄지 않는다.
이는 가까운 것과 먼 것을 다른 그림자 맵에 렌더링하면 해결된다.
그리고 픽셀 쉐이더에서 프래그먼트의 깊이에 따라 샘플링만 다르게 하면 된다.
알고리즘은 다음과 같다.
1. 뷰 프러스텀을 n개의 부분 절두체로 나누면
프러스텀 i의 원평면은 프러스텀 i+1 의 근평면이 도니다.
2. 각 프러스텀에 대해 꼭 맞는 투영 행렬을 계산한다.
3. 각 프러스텀에 대해 그림자 맵을 렌더링한다.
4. 모든 그림자 맵을 픽셀 쉐이더로 보내 골라서 샘플링한다.
먼저 우리는 투영행렬을 절두체에 잘 맞추는 작업부터 시작해야하며, 이를 위해서 절두체의 월드 공간 좌표를 알아야 한다.
이는 어려워 보이지만 실은 엄청 간단하다.
NDC 좌표는
x [-1, 1]
y [-1, 1]
z [0, 1]
의 범위를 가진다.
이 범위는 뷰 -> 원근 -> 동차나눗셈을 순서대로 계산하여 얻어낸 것이다.
나눗셈이라는 것이 비가역적이기는 하지만, 우리는 이미 특정 점을 월드 공간으로 바꾸는 방법을 알고 있다.
바로 원근, 뷰 행렬의 역행렬을 곱한 다음 w 값을 나눠주는 것이다.
그렇다면 다음과 같은 코드가 나온다.
우리가 원하는 것은 위와같이 딱 맞는 조명 관점의 뷰 행렬과 투영 행렬을 만드는 것이다.
그러면 빛 관점에서 어떻게 딱 맞는 행렬을 만들 수 있을까?
먼저 뷰 행렬을 만들어보자.
우리는 빛이 오는 방향을 알고있기 때문에 특정 지점에서 빛의 반대 방향으로 카메라를 멀어 보내주면 된다.
특정 지점은 절두체의 중심으로 설정하면 되는데, 이 지점을 프러스텀의 중심으로 한다.
기존 만들었던 함수를 해당 구조체로 모두 옮겨두었다.
투영 행렬은 조금 더 복잡하다.
빛이 방향성 빛이기 때문에 행렬은 정 투영 행렬이어야 한다.
이 함수를 사용하여 만들 것이기 때문에 상하좌우 깊이를 얻어야만 한다.
이 좌표들은 로컬 좌표계이기 때문에
앞서 얻어낸 프러스텀의 좌표들에 뷰 행렬을 곱해주어 조명의 로컬로 변환해주고
최대 최소 값들을 찾아 상화좌우전후의 값을 구해준다.
여기서 끝이 아니다.
이러면 오로지 프러스텀 내부에 있는 도형들의 그림자만 생성하게 되는데,
x,y 축만 고려하면 많지만
z 축의 경우 더 늘려줘야 한다.
그러므로 일정한 비만큼 Z 범위를 늘려주도록 한다.
최종적으로 완성한 함수
그 다음.. 그림자 렌더링을 위해서 다수의 그림자 맵을 사용해야하기 때문에 텍스처 배열을 사용하는 것이 현명하다.
구현은 다음 포스팅에서 다룰 것이다.