그래픽스/DirectX12

[Directx12][20장]그림자 매핑

우향우@ 2023. 3. 21. 21:51

개요

그림자 매핑은 광원에서 한 방향으로 본 장면을 깊이만 렌더링하여 만든 그림자 맵을 이용해 그림자를 표현하는 기법이다. 정확히는 광원의 광선에 처음 맞는 부분과 아닌 부분을 구분하여 빛의 세기를 조절해 그림자를 표현한다. 이러한 기법은 기존의 스텐실 그림자와 달리 평면 뿐 아닌 모든 기하구조에 정상적으로 그림자가 나타나게 된다.

직교 투영

우선 직교 투영이라는 개념부터 배워보자. 지금까지 어떠한 시점에서 바라본 장면을 만들때 원근 투영을 이용하여 장면의 정점들을 투영창에 투영했다. 절두체와 시점 - 정점을 잇는 시선을 사용했던 것이 원근 투영이라면 직교 투영은 절두체가 아닌 직사각형으로 z축과 평행한 시선으로 정점들을 투영한다. 직교 투영의 투영선은 그냥 z축과 평행 하기에 시야 공간에서 한 정점의 xy좌표가 투영창에서의 xy값이 된다. 즉 직교 투영은 정점의 xy, 깊이 값만 ndc정규 좌표에 맞추어 변환 해주면 투영이 완료된다. 또한 이렇게 만든 투영행렬은 행렬을 곱한 후 w나누기 까지 해줘야 ndc좌표가 나왔던 원근투영행렬과 달리 바로 ndc공간이 나오게 된다.

이러한 직교 투영으로 투영행렬을 만들면 직교 투영의 투영 직육면체 안의 물체들이 원근법없이 그대로 화면에 찍혀 렌더링 되게 된다. 이는 3d 모델링이나 과학 시뮬레이션 프로그램등에서 특별한 용도로 사용된다.

현재 그림자 매핑에선 그림자 맵을 만들 때 점광이나 점적광에 대해선 원근 투영을, 평행광에 대해선 직교 투영을 사용하게 된다.

그림자 맵

그림자 맵은 한 광원점(또는 광원평면)에서 어떠한 방향으로 바라본 장면을 렌더링한 깊이 버퍼이다. 그림자 맵은 해당 광원을 카메라로 생각했을 때의 시야행렬, 투영행렬을 이용하여 그림자를 만들 물체들을 깊이 버퍼에 대해서만 렌더링하면 된다. 이때 투영행렬은 평행광의 경우 직교투영 행렬을, 점적광이나 점광의 경우 원근투영 행렬을 사용한다. 그렇게 그려진 깊이 버퍼들은 각 광원에서 바라본 가장 가까운 픽셀들의 깊이 값이 기록되게 된다. 이러한 값들은 이후 실제 카메라 렌더링에서 특정 물체의 픽셀의 광원에서의 깊이와 그림자 맵에서 그 광원에서 해당 시점으로 가장 가까운 픽셀의 깊이 값을 비교해 그 픽셀이 시선에서 가장 가까운 픽셀인지 아닌지를 판명하여 그림자 유무를 판정한다. 이때 평행광의 경우 광원평면에서 평행광 방향으로 쏘아진 광선들을 가장 먼저 맞은 픽셀의 깊이가 기록되는 셈이며, 점광이나 점적광의 경우 광원에서 투영창의 해당 픽셀로 가는 광선에서 가장 먼저 맞은 픽셀의 깊이가 기록되게 된다.

따라서 그림자 맵에 적힌 깊이값들의 의미는 해당 광선을 가장 먼저 맞은 픽셀의 깊이 값이 되게된다.

투영 텍스처 좌표

그림자 맵을 활용하여 카메라 렌더링을 하기전에 우선 투영 텍스처 적용이라는 기법을 먼저 살펴보자.

투영 텍스처 적용은 장면의 기하구조들에 한 점이나 평면에서 슬라이드 영사기처럼 텍스처를 쏴서 텍스처의 값들을 입히는 것을 말한다. 그 기하구조들은 슬라이드 영사기의 화면을 맞은 것처럼 텍스처의 색깔이 위에 씌어 질 것이다.

투영 텍스처 적용은 다음과 같이 구현한다.

1. 텍스처를 쏠 영사기의 위치와 방향을 카메라의 위치와 방향이라고 생각하여 시야행렬과 투영행렬을 구한다. 이때 직육면체로 텍스처를 쏘려면 직교 투영 행렬을, 절두체로 텍스처를 쏘려면 원근 투영 행렬을 쓴다.

2. 해당 행렬들을 쉐이더 자원으로 달고 정점 셰이더를 실행한다.

3. 정점 셰이더에서는 각 정점의 월드 좌표에 영사기의 시야, 투영행렬을 곱한다.(원근 투영이면 w나누기 까지)

3. 그러면 영사기 기준에서의 해당 정점의 ndc 좌표가 나온다.

4. 해당 좌표의 xy좌표는 영사기의 투영창의 -1 ~ 1 사이의 투영 위치로 투영창 전체를 텍스처라고 봤을 때 영사기가 텍스처를 쐈을 때 해당 정점이 맞게될 텍셀의 좌표에 해당한다.

5. 실제 텍셀의 좌표는 0~1로 정규화 되므로 -1 ~ 1사이의 좌표를 이동, 비례로 텍스처 좌표로 변환한다.

6. 해당 정점이 영사기로부터 받은 텍스처의 좌표가 계산됬으므로 이를 출력한다.

7. 이후 해당 좌표는 보간을 통해 픽셀셰이더에서 각 픽셀이 영사기로 부터 받은 텍스처의 좌표로 전달 된다.

8. 해당 텍스처 좌표로 색상을 추출하고 픽셀의 원래 섹상에 더하기 연산등으로 적용시킨다. 

9. 만약 텍스처 좌표가 0~1을 벗어난다면 영사기의 출력 범위를 벗어난 것이므로 (0,0,0,0)으로 취급하거나 텍스처 테두리를 검은색으로 하고 좌표 지정 방식을 적절히 설정하는 방법도 가능하다.

위 방식은 영사기가 여러 기하구조를 비출때 앞의 기하구조에 의해 뒤의 기하구조가 가려져서 텍스처를 받지 못하는 현상은 발생하지않는다. 이러한 부분까지 구현하라면 이후 배울 그림자 매핑에서 사용하는 기법을 응용하여 적용시키면 구현가능하다.

그림자 매핑

위의 기법들을 이용하여 그림자 매핑을 구현해보자.

그림자 매핑은 다음과 같이 구현된다.

  1. 그림자를 만들 광원에 대해서 적절한 범위와 방향에 대해 그림자 맵을 만든다.
  2. 그림자 맵을 쉐이더 자원으로 달고 셰이더를 호출한다.
  3. 정점 셰이더에서 광원을 영사기로 생각하여 그림자 맵을 만들 때 썼던 시야, 투영행렬(+w 나누기)로 해당 정점이 광원의 투영창에서 가지는 -1 ~ 1 사이의 xy좌표값과 0~1사이의 깊이 값을 계산한다.
  4. 그 값의 xy를 텍스처 좌표로 변환하고 깊이 값은 그대로 하여 출력한다.
  5. 해당 값의 xy는 해당 정점이 광원으로부터 나온 광선에 대한 그림자맵의 텍스처 좌표가 되며 깊이 값은 해당 정점 까지의 광원에서의 거리가 된다.
  6. 픽셀 셰이더에서 각 픽셀에 대해서 보간된 텍스처 좌표와 광원으로부터의 거리를 받는다.
  7. 각 픽셀 별로 해당 픽셀의 그림자 맵 텍스처 좌표로 광원의 해당 광선에서 가장 가까웠던 깊이값을 추출하고 보간된 해당 픽셀의 광원으로부터의 거리와 비교하여 그림자 맵의 깊이가 광원으로부터 더 가깝다면 이 픽셀을 광원으로부터 가리는 다른 픽셀이 존재하는 것이므로 그림자를 적용하고, 같거나 더 멀다면 해당 픽셀이 광원에서 가장 가까운 픽셀이므로 정상적인 빛을 적용시킨다.

앨리어싱과 편향치

위의 방법으로 그림자 매핑을 하면 한가지 문제가 발생한다. 바로 그림자 맵의 해상도가 무한하지 않다는 것이다. 그림자 맵은 깊이 버퍼의 한 픽셀 방향으로 쏜 광선에서의 가장 가까운 깊이만을 각 텍셀에 기록하게 되는데 카메라 렌더링의 픽셀 셰이더에서는 한 픽셀에 보간된 그림자 맵 텍스처 좌표로 이를 추출하게된다. 만약 점필터링을 사용한다면 가장 가까운 텍셀의 깊이값을 가지고 오게 되는데 만약 그 픽셀이 광선 방향중 가장 광원에 가깝더라도 대신 추출된 텍셀의 깊이 값이 더 가까워 그림자 처리가 될 수 도 있다. 이러한 현상으로 인해 그림자가 적절치않게 나타나는 현상을 그림자 여드름, 즉 앨리어싱 현상이라고 말한다. 

이를 방지하기 위해선 그림자 맵에 약간의 패널티를 주는 편항치를 주어 조금씩 더 먼 깊이값을 가지도록 해야한다. 이때 편항치를 해당 깊이를 만드는 기하구조가 광선에 대해 더 많이 기울어 있을 수록 많이 필요한데 텍셀 사이의 깊이 값 차이가 크기 때문이다. 이러한 깊이 편향치를 적용시키기 위해서는 DirectX에서 래스터화기 상태를 통해 주어지는 기울기 비례 깊이 편향치 기능을 사용하면된다. 그림자 맵을 렌더링 할 때 사용하는 PSO의 래스터화기 상태 DESC에 세가지 수치를 줄 수 있다. 이는 각각 기본 편향치, 기울기에 따른 추가 편향치 계수, 최대 편향치로 이를 설정하여 깊이 렌더링을 수행하면 해당 기하구조의 시선에 대한 기울기에 따른 추가 편향치가 적용되어 깊이가 적용된다. 이로인해 더 가깝지만 더 기울어진 픽셀이 깊이 판정에 실패 할 수 있는데 이는 편향치를 고려했을 때 가장 엄격한 깊이값을 추출해내는 것이 현재 상황에 맞기에 적절한 처리라고 볼 수 있다. 

이러한 기울기 편향치가 적용된 그림자 맵과 점 필터링을 사용하면 근접한 텍셀로 부터 추출하다라도 적절한 깊이 비교가 일어나 앨리어싱 현상이 방지가 된다. 다만 편향치를 너무 크게 설정하면 다른 픽셀에 가려진 픽셀이 그 편향치로 가린 픽셀의 깊이까지 이겨버려 그림자 처리가 일어나지 않는 현상이 일어날 수도 있다. 따라서 편향치 값을 적절히 설정할 필요가 있다. 정상적인 편향치와 보통의 상황이라면 기하구조들의 두께에 의해 편향치에 의한 잘못된 그림자는 거의 일어나지 않을 것이다. 

비율 근접 필터링

만약 그림자 맵의 해상도가 너무 낮다면 점 필터링으로 깊이 비교를 할 때 특정 텍셀에 추출이 몰려 그림자의 계단현상이 발생할 수 있다. 이를 줄이려면 PCF(비율 근접 필터링)을 사용해야한다. 기본적인 4표본 PCF는 특정 텍스처 좌표를 추출할 때 그 텍스처 좌표를 둘러싸는 네개의 텍셀에서 점 필터링으로 값을 추출하고 그 값들에 적절한 연산을 수행한 뒤 그 결과들을 겹선형 보간하여 값을 돌려준다. 해당 기능은 HLSL의 SampleCmpLevelZero로 사용할 수 있다. 여기에 사용되는 표본 추출기는 추출한 값들에 적용할 연산을 서술하는 특별한 표본 추출기이며 매개변수는 추출할 텍스처 좌표와 연산에 사용할 상수값을 넣어서 호출하면 된다. 그림자 매핑의 경우 추출한 깊이 값들에 작거나 같은지를 확인하는 연산자와 픽셀의 깊이값을 넣어 호출하면 된다. 그러면 추출된 각 깊이와 픽셀 깊이 값을 비교하여 픽셀 깊이값이 작거나 같으면 1을, 아니라면 0값이 나오게 된다. 그후 네개의 값들에 텍스처 좌표위치에 따라 겹선형 보간을 하여 0~1사이의 값을 추출하게 된다. 그러면 그 값을 그림자 계수로 사용하여 해당 광원이 픽셀에 추가하게 될 색상에 해당 그림자 계수를 곱하여 더하면 된다. 즉 그림자가 들거나 아니거나가 아닌 어느정도 드는지를 나타내게 된다. 이로인해 그림자의 가장자리는 흐릿하게 되어 부드러운 그림자가 구현된다.

큰 PCF 문제 (참고)

그림자를 더욱 부드럽게 만드려면 PCF의 크기를 늘리면 된다. 이는 PCF의 표본수 자체를 늘리거나 텍스처 좌표를 주변 좌표 여러개로 복사하여 PCF를 여러번 수행한 뒤 이를 평균내는 등으로 늘릴 수 있다. 이때 PCF를 너무 크게 만들면 앨리어싱 현상이 다시 발생할 수 있다. 한 픽셀에서 너무 먼 텍셀 까지 추출하여 기존의 편향치를 넘어버리면 의도치 않은 그림자가 발행하기 때문이다. 이를 방지하기 위해선 편향치를 키워야하는데 이에는 한계가 있다. 아니면 큰 PCF대신 높은 해상도의 그림자맵을 사용해야하는데 이는 성능이 부담이 간다.

이를 해결하기 위해 카메라 렌더링에서 픽셀의 깊이 비교를 수행할 때 기하구조의 기울기에 따른 각 이웃 텍셀의 깊이 변화량을 예측하여 동적으로 편향치를 계산하는 방법을 사용한다. 여기에는 화면의 이웃 픽셀의 특정 값의 변화량을 제공해주는 ddx, ddy HLSL 함수를 응용한다. 그 변화량을 화면 좌표 xy변화량에 따른 픽셀의 텍스처 좌표 uv, 깊이 값 z의 변화량을 구하고 uv 변화량에 따른 화면 좌표 xy의 변화량을 구해 결국 텍스처 좌표의 변화량에 따른 깊이 값의 변화량을 구한다. 이로 이웃 텍셀의 예상 깊이 변화량을 구해 동적 편향치를 계산하여 사용한다.

개인적으로 이 주제에는 서로 다른 기하도형이 겹친 경우나 그냥 단순히 이웃 텍스처간의 깊이 변화량을 바로 비교하여 고려하는 방법보다 나은점 등의 추가적인 고려사항들이 있지만 참고서적에서는 위 문단까지의 내용만 서술되있다. 이 주제를 깊게 공부하고 싶다면 따로 찾아볼 필요가 있을 것 같다. 

참고서적

DirectX 12를 이용한 3D 게임 프로그래밍 입문

'그래픽스 > DirectX12' 카테고리의 다른 글

[Directx12][22장]사원수  (0) 2023.03.21
[Directx12][21장]주변광 차폐  (0) 2023.03.21
[Directx12][19장]법선 매핑  (0) 2023.03.21
[Directx12][18장]입방체 매핑  (0) 2023.03.21
[Directx12][17장]3차원 물체의 선택  (0) 2023.03.21