Path Tracing 렌더링을 수행하기 위해서는 Random Number의 생성이 필수적입니다. 그 이유는
위 렌더링방정식의 적분계산을위해 Random Sampling을 통한 Monte Carlo적분을 수행하기 때문입니다.
GPU에서 Random Number를 생성하는 것은 언뜻보면 쉬워보일 수 있습니다.
float nrand(float2 uv)
{
return frac(![](https://velog.velcdn.com/images/15ywt/post/858341a2-3b17-4c36-8df8-bbf6595f3b6e/image.png)
) * 43758.5453);
}
위 함수는 2개의 시드값을 기반으로 [0,1)
기반의 Pseudo
Random Number를 생성해주는 함수 입니다. 이 방법으로 Path Tracing에 사용되는 랜덤 샘플들을 생성하는데는 아무런 문제가 발생하지 않습니다. 하지만, 렌더링 결과물에서 문제가 발생합니다.
float rand0 = nrand(float2(
g_sampleCountCB.sampleCount + payload.recursionDepth + DispatchRaysIndex().x,
g_sampleCountCB.sampleCount + payload.recursionDepth + DispatchRaysIndex().y + 1
));
float rand1 = nrand(float2(
g_sampleCountCB.sampleCount + payload.recursionDepth + DispatchRaysIndex().y,
g_sampleCountCB.sampleCount + payload.recursionDepth + DispatchRaysIndex().x + 1
));
실험을 위해서 Ray가 hit한 표면에서 반구공간상의 랜덤샘플을 생성하는데 필요한 [0,1)
범위의 2개의 Random Number를 매 샘플마다 생성하는 방식으로 Path Tracing렌더링 실험을 해봤습니다.
실험 결과 대각선으로 특정한 패턴이 나오는 모습을 볼 수 있습니다. 이전 프레임과 다른 샘플을 생성하기 위한 sampleCount, TraceRay재귀호출마다 다른 샘플을 생성하기 위한 recursionDepth, 각 픽셀마다 다른 샘플을 생성하기 위한 RayIndex값들을 시드값으로 부여했음에도 생성된 Random Number에서는 쉽게 규칙성이 발견되었습니다. 그 이유는 Random Number를 생성하는데 사용한 nrand
함수가 sin
삼각함수를 기반으로 구현되어있는데,
sin
함수는 의 주기를 가지는 주기함수이기 때문입니다. 아무리 샘플별로 다른 값을 구하기 위해 각 샘플만이 가진 고유값을 사용한다고 한들 규칙성에서 벗어나지 못하는 것입니다.
위의 문제상황을 해결하기 위해서는 2가지 방향이 존재했습니다. 첫번째로 GPU난수생성을 포기하고 CPU에서 널리 알려진 Uniform분포에 가까운 난수 생성 알고리즘을 이용해서 난수를 생성하고 GPU메모리로 복사하는 방향이 있고
두번째로 GPU의 각 픽셀수 만큼의 스레드별로 고유의 상태를 업데이트해가면서 CPU와의 데이터 통신없이 질좋은 난수를 계산하는 방향이 있습니다. 저는 전자의 방향으로 문제를 해결했습니다.
c++에는 MT19937
이라는 의사난수 생성기가 내장되어 있습니다. https://ko.wikipedia.org/wiki/%EB%A9%94%EB%A5%B4%EC%84%BC_%ED%8A%B8%EC%9C%84%EC%8A%A4%ED%84%B0
위키피디아문서에서 MT19937
의사 난수의 특성을 알 수 있는데, 핵심은
시뮬레이션
에 자주 사용됨같은 것들이 있습니다. 주기가 매우 길어서 규칙을 발견하기 힘든 특성이 있기 때문에 Path Tracing의 랜덤샘플링에 사용하기 매우 적합하다고 할 수 잇습니다.
c++ 생성코드는 다음과 같습니다.
#include <random> //MT19937난수 생성기
...
FLOAT RandomGenerator::randFloat()
{//c++ mt19937 생성기를 이용해 uniform분포에 가까운 의사 난수생성(0,0~1.0범위)
static std::random_device rd;//random_device: 프로그램 실행마다 다른시드값을 주기 위해 사용
static std::mt19937 gen(rd());//mt19937 : 2^19937 -1 주기의 의사난수 생성기
static std::uniform_real_distribution<FLOAT> dist(0.0f, 1.0f);//0~1범위에서 uniform분포를 가지게 변환
return dist(gen);
}
이렇게 생성한 난수들을 GPU Constant Buffer에 매 프레임 업데이트하는 방식으로 구현하였습니다.
uint firstSeqLinearIndex = DispatchRaysIndex().x + 1600 * DispatchRaysIndex().y;
uint seqLinearIndex0 = (firstSeqLinearIndex + payload.recursionDepth) % RANDOM_SEQUENCE_LENGTH;
uint seqLinearIndex1 = (firstSeqLinearIndex + payload.recursionDepth) % RANDOM_SEQUENCE_LENGTH;
float rand0 = g_randomCB.randFloats0[seqLinearIndex0 / 4][seqLinearIndex0 % 4];
float rand1 = g_randomCB.randFloats1[seqLinearIndex1 / 4][seqLinearIndex1 % 4];
GPU의사난수 생성기에서 생성된 난수대비 품질이 좋은것을 바로 확인할 수 있습니다.
1번 방법인 CPU에서 난수를 생성해서 GPU에서 사용하는 방식은 시뮬레이션 목적으로 봤을때는 아주 좋은 방향이지만, 성능상으로 좋은 방식은 아닙니다. 매 프레임 CPU에서 질좋은 난수를 몇백개 순차적으로 생성하는시간 + GPU메모리에 복사하는 시간
만큼의 시뮬레이션 시간 손실이 발생하게 됩니다. 물론, 이런 계산시간이 시뮬레이션을 못돌릴만큼 크진 않아서 1번 방법으로 구현하는데 무리는 없었지만 GPU자체적으로 질좋은 난수를 생성할 수 있는 방법을 한번 고민해봤습니다.
GPU에서는 컴퓨트 셰이더를 통해서 그래픽목적이 아닌 일반적인 계산(GPGPU)을 병렬로 빠르게 처리할 수 있습니다. 난수생성기 또한 그렇습니다. 각각의 난수 생성 스레드에 별도의 메모리를 할당하고 상태를 업데이트하면서 매 프레임 Path Tracer에 의한 랜덤샘플링 시작전에 컴퓨트 셰이더 난수 생성 Pass를 추가한다면 CPU와 완전히 독립적인 난수생성기를 구현할 수 있을것입니다.