
이제 프로브 볼륨 캐시를 다 준비했으니, 카메라 기준 픽셀 단위 간접 조명을 계산할 차례입니다. 먼저 디퓨즈 패스부터 보겠습니다.

디퓨즈 로브의 경우, 각 픽셀마다 쿼터 해상도로 레이를 하나 쏩니다.
기술적으로는 풀 해상도도 지원하지만, 비용이 4배 늘어나는 것에 비해 품질 차이가 크지 않아서, 실제 게임에서는 쿼터 해상도 설정을 선택했습니다.
레이 방향은 코사인 분포 기반의 중요도 샘플링으로 정하고, 최대 레이 길이는 픽셀 위치가 카메라에서 얼마나 멀리 있는지에 따라 가변적으로 조정합니다. 카메라에서 멀어질수록 더 긴 레이를 쏘게 됩니다.
먼저 화면 공간 레이마칭을 수행하고, 유효한 기하를 맞으면 해당 픽셀을 히트 리스트(슬라이드에서는 초록색 픽셀로 표시)에 넣고 히트 위치를 저장합니다.
최대 거리까지 갔는데도 아무것도 맞지 못하면, 그 픽셀은 미스 리스트(파란색 픽셀)에 들어갑니다.
나머지 경우에는 레이를 BVH로 넘겨서 월드 RT를 한 번 더 수행합니다. 이 경우가 전체 픽셀의 약 49% 정도를 차지합니다.
- BVH로 넘겨야 하는 경우를 정리해 보면 다음과 같습니다.
- 레이가 최대 거리까지 가기 전에 화면 밖으로 나가는 경우
- 화면 공간에서는 기하를 맞았지만, 히트 위치가 깊이 버퍼 값보다 너무 뒤에 있는 경우 (이때는 마지막으로 화면에 보이던 위치까지 되돌아간 뒤, 그 지점에서 BVH로 레이를 넘깁니다)
- 머티리얼이 반투명이고, 화면 앞에 있는 기하 뒤쪽으로 레이를 쏘는 경우
- 통계를 간단히 말씀드리면,
- 13%는 아예 스킵(87%만 화면 기반 RT 경로 사용),
- 38%는 화면 공간 RT만 사용,
- 49%는 월드 RT까지 사용하는 픽셀입니다.

BVH로 레이를 넘겨야 하는 픽셀에 대해서는, 아까 화면 공간 레이마칭에서 마지막으로 유효했던 위치를 시작점으로 사용합니다.
기하를 맞으면 그 픽셀을 히트 리스트(초록색)로 분류하고, 라이팅에 필요한 머티리얼 정보와 히트 거리를 G-버퍼에 기록합니다.
여기서도 아무것도 맞지 못하면, 픽셀은 미스 리스트(파란색)로 들어가게 됩니다.
자기 교차(self-intersection)를 줄이기 위해, 최소 시작 거리 t = 0.05m를 사용하고 있습니다.
그리고 저장할 때는 히트 위치 대신 히트 거리만 저장합니다. 나중에 같은 시드로 레이 방향을 재생성하면, 그때 히트 거리를 이용해 실제 위치를 다시 만들어 낼 수 있기 때문입니다.

이제 레이 캐스팅이 모두 끝났으니, 간접 G-버퍼를 가지고 라이팅을 계산할 수 있습니다.
프로브 볼륨과 마찬가지로, 여기서도 두 패스로 나누어 처리합니다.
먼저 히트 픽셀들에 대해 모든 라이트에서 오는 직접 조명을 계산합니다.
태양 그림자 처리는 조금 다르게 들어가는데, 우선 캐스케이드 섀도 맵(CSM)을 확인해서 유효하면 그 값을 사용합니다. CSM으로는 커버되지 않는 영역이라면, 2차 섀도 맵을 확인하고, 그마저도 범위를 벗어나면 해당 픽셀은 완전히 그림자라고 가정합니다.
간접 조명은 프로브 조도 캐시를 사용해서 더합니다.
- CSM은 화면에 직접 보이는 픽셀을 기준으로 최적화되어 있기 때문에, 프러스텀 안에 있지만 화면에는 가려진 지점에서는 섀도 값이 부정확할 수 있습니다.
- 섀도 맵 범위 밖에서의 선택은, "완전 조명"보다는 "완전 그림자"가 누출을 막는 데 더 낫다고 판단해서, 보수적인 쪽을 택했습니다.

프로브 볼륨 쪽과 다른 점은, 여기서는 방사휘도를 그대로 저장하지 않고 구면조화(Spherical Harmonics, SH)로 인코딩해서 저장한다는 것입니다.[SH 개념: https://en.wikipedia.org/wiki/Spherical_harmonics]
이렇게 하면 디노이징 단계에서 픽셀 단위로 전체 방향성 방사휘도를 재구성할 수 있고, 그 결과를 이후 패스에서 유연하게 다시 사용할 수 있습니다.
또한 히트 거리를 따로 함께 출력해서, 디노이징 후에 ray-traced ambient occlusion(RTAO)을 같이 얻도록 구성했습니다.
- 방사휘도는 이전 프레임의 노출 값으로 한 번 스케일해서, 밝은 영역에서도 정밀도를 조금 더 확보합니다.
- SH 계수는 YCoCg 색공간으로 패킹하고, 최대 크기(magnitude)를 16비트로 따로 저장해서, 전체 64비트 인코딩으로 표현하고 있습니다.

이제 라이팅 계산은 끝났고, 남은 일은 이 노이즈 많은 결과를 디노이저에 통과시켜서 "게임에 넣을 수 있는 그림"으로 만드는 것입니다.
여기서 사용하는 디노이저는 NVIDIA NRD를 기반으로 Snowdrop 엔진에서 튜닝한 버전을 다시 한 번 수정한 형태입니다.[NRD 레포: https://github.com/NVIDIARTX/NRD]
이 부분만 따로 떼어도 한 세션이 나올 정도라, 오늘은 전체 구조 정도만 말씀드리겠습니다.
크게 보면 여러 단계의 공간(spatial) 필터와 시간(temporal) 필터가 조합된 구조이고, 플랫폼·장면별로 파라미터를 많이 손봤습니다.

디노이징이 끝나면, 쿼터 해상도 결과를 풀 해상도로 업스케일해야 합니다.
SH 계수 자체를 업스케일한 뒤, 풀 해상도 노멀을 사용해 픽셀 단위에서 간접 조명을 다시 평가합니다.
업스케일링은 깊이 차이를 가중치에 반영하는 형태의 바이래터럴(bilateral) 필터 구조를 사용하고 있습니다.
간단하게 의사코드로 보면 다음과 같습니다.
- float4 denoisingDepth = GatherDepth(uv);
- float4 depthWeights = 1.f / (0.1 + abs(denoisingDepth - fullResDepth));
- weights *= depthWeights * depthWeights;

이제 SH를 썼을 때 실제로 어떤 수준의 디테일을 얻을 수 있는지 예제를 하나 보겠습니다.
지금 보시는 이미지는 입력 방사휘도입니다.

다음은 필터링된 조도(irradiance) 결과입니다. 방향성 정보는 많이 줄고, 대신 노이즈가 안정적으로 정리된 상태입니다.

여기에 SH를 활용하면, 방향성을 훨씬 더 잘 살리면서도 안정적인 결과를 만들 수 있습니다.
결과적으로 디테일이 살아나고, 조명 방향도 더 자연스럽게 느껴집니다.

여기까지가 디퓨즈 간접 조명이고, 이제부터는 간접 스페큘러 조명 파트로 넘어가겠습니다.

먼저 개발 배경부터 간단히 공유드리겠습니다.
이 스페큘러 패스는 원래 AC 섀도우 출시 이후에 추가할 계획이었습니다. 그런데 게임이 한 차례 연기되면서, 그 사이에 이 기능을 미리 넣어보자 하는 방향으로 바뀌었습니다.
전체 개발 일정은 3개월이었고, 첫 번째 이터레이션은 약 3주 만에 돌아가기 시작했습니다.
이런 배경 때문에, 처음에는 "최대한 빨리, 최대한 보기 좋게"에 초점을 맞췄고, 알고리즘적 최적화는 후순위로 밀려 있었습니다. 또 데이터 파이프라인을 바꾸는 건 금지였기 때문에, 기존 데이터를 그대로 둔 상태에서 동작하게 만드는 것도 큰 제약이었습니다.
알고리즘 구조 자체는 디퓨즈와 매우 비슷하지만, 스페큘러 로브의 특성 때문에 별도의 튜닝과 변경 사항이 많이 들어갔습니다. 특히 거울에 가까운 하이라이트에서는, 디퓨즈에서 사용한 단순화나 최적화가 그대로 적용되지 않았습니다.

픽셀 단위 스페큘러 패스는 쿼터, 하프, 풀 해상도를 모두 지원합니다. 실제로는 품질 대비 비용 비율이 가장 좋았던 하프 해상도로 출시했습니다.
레이 방향은 GGX 기반 가시 노멀 분포 함수(visible normal distribution function, VNDF)를 사용해서 중요도 샘플링합니다. 관련 이론은 Microfacet BRDF / GGX 자료를 보시면 잘 정리되어 있습니다.[예: https://learnopengl.com/PBR/Lighting]
그다음에는 표면 러프니스(roughness)에 따라 레이 길이를 조절합니다. 표면이 매끄럽고 거울에 가까울수록, 더 멀리까지 레이를 쏘게 됩니다.
이 패스에서도, 가능한 경우에는 레이 캐스팅과 라이팅 자체를 건너뛰도록 최적화를 해 두었습니다. 앞에서 설명드렸듯, 디퓨즈 패스에서 이미 픽셀 단위 SH 방사휘도 근사를 계산해 놓았기 때문에, 조건이 맞으면 이 정보를 그대로 사용해서 해당 방향의 간접 스페큘러를 빠르게 얻을 수 있습니다.
구체적으로는, 러프니스가 높은 표면과, 거울 반사 방향에서 멀리 벗어난 레이 방향에 이 최적화를 적용합니다. 이 구간에서는 기여도가 작기 때문에, SH 평가만으로도 충분히 자연스럽게 보입니다.
이 최적화 덕분에, 캐스팅과 라이팅 비용을 약 20~30% 정도 줄일 수 있었습니다.
하프 해상도 설정은 가로만 절반이고, 세로는 풀 해상도를 유지합니다.
물 표면의 경우 디퓨즈는 평가하지 않고 스페큘러만 계산하지만, 대신 러프니스와 노멀을 조절해서 앨리어싱을 줄이는 쪽으로 튜닝했습니다. 여기에는 월드 공간에서 픽셀 투영 크기를 고려하는 로직이 들어가 있습니다.
레이마칭 자체는 선형 스텝(linear step)과 이분 탐색(binary search), 시컨트(secant)를 조합해 사용하는 구조입니다.

식생에 대해서 디퓨즈 패스에서 사용하던 불투명 근사(opaque approximation)는, 스페큘러 조명에서는 문제가 됩니다.
디퓨즈에서는 평균 불투명도로 기하를 두껍게 근사해도 큰 티가 나지 않지만, 강한 반사가 있는 표면에서는 그 차이가 그대로 드러나기 때문입니다.
지금 슬라이드에 보이는 것처럼, 나뭇잎 덩어리가 너무 두꺼운 덩어리처럼 보이거나, 반사 안에 이상하게 과한 차폐가 생기는 식입니다. 불투명 근사 자체에 대한 자세한 이야기는 뒤 챕터에서 다시 다루겠습니다.

이 문제를 해결하기 위해, 스페큘러 패스에서는 알파 테스트 텍스처를 직접 샘플링하는 방식으로 전환했습니다.
다만 비용을 통제하기 위해 any-hit 셰이더 콜 수는 최대 4회로 제한했습니다.
디퓨즈와 스페큘러는 같은 BVH를 공유하지만, 디퓨즈에서 쓰던 불투명 근사는 스페큘러 패스에는 맞지 않기 때문에, 스페큘러를 켜는 경우 디퓨즈 레이 캐스팅 쪽 근사 방식도 함께 손봐야 했습니다.

또 하나 눈에 띄는 문제는, 고반사 표면에서 BVH용 머티리얼 근사가 그 자체로 드러난다는 점입니다.
디퓨즈나 고 러프니스 상태에서는 크게 거슬리지 않지만, 거울에 가까운 반사에서는 BVH와 래스터 버전 머티리얼 차이가 그대로 보입니다.

이를 완화하기 위해, 가능한 경우에는 스페큘러 히트 지점을 다시 화면으로 재투영해서, 고해상도 G-버퍼에서 머티리얼 정보를 가져오는 방식을 사용합니다.
다만 이때 BVH 머티리얼과 재투영된 머티리얼 차이가 너무 커지면, 프레임 간에 튀는 현상이 생기기 때문에, 차이가 허용 범위 안에 있을 때만 이 값을 사용합니다.
고사양 플랫폼에서는 이 단계에서 추가 섀도 레이도 함께 쏴서, 스페큘러에서의 태양 그림자 퀄리티를 좀 더 끌어올리고 있습니다.

셰이딩 단계에서는, 스페큘러 항까지 포함한 풀 BRDF 평가를 실제로 수행합니다.
고사양 PC 플랫폼에서는 앞에서 말씀드린 섀도 레이 결과를 태양 그림자 평가에도 활용하고 있습니다.
한 가지 중요한 포인트는, 온스크린 히트 여부에 따라 프레임 간 차이가 너무 커지지 않도록 조명 값 자체는 재투영하지 않고, 머티리얼 정보만 재투영한다는 점입니다. 조명까지 그대로 들고 오면, 스페큘러가 프레임 간에 갑자기 바뀌는 경우가 생기기 쉽습니다.

라이팅 평가 비용의 상당 부분은 프로브 보간에서 발생합니다.
현재 구조에서는 한 픽셀에 대해 8개의 프로브를 모두 가져오고, 각 프로브에 대한 가중치를 계산한 뒤, 최종 값을 인터폴레이션해야 합니다.
이걸 줄이기 위해, 특정 조건에서는 가장 가까운 프로브 하나만 사용하는 최적화를 넣었습니다.
해당 프로브의 가중치가 충분히 크다면 그 하나만 사용하고, 아니라면 기존처럼 8개 보간으로 되돌아가는 방식입니다.
이 최적화만으로도 보간 비용을 절반 이하로 줄이면서, 실제 시각적인 차이는 거의 느껴지지 않았습니다.
다만 이런 구조를 쓰다 보면, 특히 물처럼 강하게 반사되는 표면에서는 BVH에 없는 원거리 지오메트리가 더 눈에 띄게 빠지는 문제가 있습니다.
그래서 하늘 근사 텍스처 대신, 동적인 폴백 큐브맵(fallback cubemap)을 추가로 사용하고 있습니다. 이 큐브맵 안에는 지형, 하늘, 구름, 원거리 오브젝트를 함께 담고 있고, 매 프레임 큐브맵의 한 페이스씩을 갱신합니다.

지금 보시는 예시처럼, 이 큐브맵을 사용하면 산과 나무 같은 원거리 오브젝트가 반사 안에 다시 등장합니다.
또 화면 공간 트레이싱 설정도 같이 조정해서, BVH 바깥에 있는 원거리 기하를 조금 더 잘 회수하도록 튜닝했습니다.
- 재투영은 대략 다음과 같은 형태를 사용합니다.
- float3 warpedDirection = normalize((rayOrigin + rayDirection * fakeHitDistance) - cubemapPosition);
- 스페큘러 반사의 경우, 일반적으로는 화면 공간 레이마칭 길이를 줄여서 아티팩트를 줄이고, 물 표면처럼 레이 시작점이 BVH 경계 가까이에 있는 경우에는 오히려 레이마칭 길이를 늘려서 더 많은 원거리 기하를 끌어오는 식으로 조정했습니다.

디퓨즈 간접 조명에서는 최적화를 위해 포그(fog)를 아예 평가하지 않습니다.
대신 스페큘러 패스에서 간단한 포그를 추가해서, 실제 게임에서 특히 수평선과 원거리 환경이 더 자연스럽게 보이도록 합니다.
왼쪽 그림이 포그를 적용하지 않은 버전, 오른쪽이 포그를 더한 버전입니다. 수평선 근처를 보시면 차이가 분명하게 느껴집니다.

스페큘러 패스의 출력 방사휘도는 디퓨즈보다 훨씬 노이즈가 많고, 필터링도 까다롭습니다. 이 문제를 줄이기 위해 몇 가지 기법을 조합해 사용했습니다.
첫 번째는 NVIDIA NRD 문서에서 소개하는 BRDF 디모듈레이션(demodulation)입니다.[https://github.com/NVIDIARTX/NRD/blob/master/README.md]
간단히 말해, 방사휘도에서 BRDF를 분리해서, BRDF가 빠진 상태로만 필터링을 하고, 디노이징 후에 다시 BRDF를 곱해 주는 방식입니다.
SSR(Screen Space Reflection)이나 로컬 큐브맵을 사용할 때 흔히 쓰는 split-sum 근사와 같은 아이디어입니다.
이 방식은 구현이 간단하지만, 슬라이드에서 보시다시피 글랜싱 앵글(grazing angle)에서 과도하게 밝아지는 문제가 생길 수 있습니다.

그래서 다음으로 시도한 방법은, 레이 트레이싱 단계에서 BRDF와 PDF를 모두 포함한 "정확한" 평가를 수행하되, 디노이징 전에 미리 적분된 BRDF(pre-integrated BRDF)로 한 번 나눠(demodulate) 주었다가, 디노이징이 끝난 뒤 다시 곱해 주는 방식입니다.
이렇게 하면 BRDF 자체는 정확하게 적용되면서도, 디노이징 과정에서 생기는 노이즈를 상당히 줄일 수 있습니다.
슬라이드에서도 보이듯이, 결과가 훨씬 더 자연스럽고, 특히 하이라이트 주변에서 밝기가 안정적으로 유지됩니다.

또 하나는 디노이징을 수행하는 "공간" 자체를 압축해서 사용하는 방법입니다.
물리적으로 완전히 타당한 방식은 아니지만, 특히 실내가 밝은 실외를 반사하는 장면처럼 다이내믹 레인지가 큰 상황에서 노이즈를 크게 줄여 주는 효과가 있습니다.
우리는 지수 기반의 압축 함수를 사용했습니다.
- compressedRadiance = radiance * pow(maxRadiance, exp - 1.0); // exp = 0.625
- inverse = compradiance * pow(maxCompRadiance, 1.0/exp – 1.0);
흔히 쓰이는 Reinhard 커브 x/(x+1) 같은 경우에는 하이라이트를 너무 강하게 눌러 버려서, 반사가 죽어 보이는 문제가 있었기 때문에 사용하지 않았습니다.

마지막으로, 스페큘러 BRDF에 좀 더 잘 맞도록 디노이저 로직 자체를 일부 특화하고, 공간 필터링 패스 중 하나에는 파이어플라이 제거(firefly removal)를 추가했습니다.

여기 보이는 예시는 파이어플라이 제거 전/후 결과입니다.
구현은 비교적 단순한데, 주변 평균 대비 휘도가 지나치게 높은 픽셀을 찾아서, 일정 임계값 안으로 클램핑하는 방식으로 동작합니다.

이제부터는 실제 프로덕션에서 부딪혔던 도전 과제들을 정리해서 말씀드리겠습니다.

먼저 알파 테스트 머티리얼 쪽입니다.
알파 테스트 머티리얼은 BVH를 탐색하면서 알파 텍스처를 계속 로드하고 테스트해야 하기 때문에, 구조적으로 비용이 큽니다.
특히 나뭇잎처럼 많은 리프가 한 곳에 몰려 있는 경우, 레이마다 통과하는 픽셀 수가 크게 달라져서 컴퓨트 그룹 내에서 타임 다이버전스가 심해집니다.
지금 슬라이드에서 기준으로 쓰고 있는 참조 비용은, any-hit 콜을 제한하지 않고, 히트든 미스든 결론이 날 때까지 텍스처를 계속 읽어들이는 버전입니다.
아시다시피, 이런 방식은 울창한 숲에서는 실시간으로 쓰기 어려울 정도로 비쌉니다.

첫 번째로 시도한 최적화는 any-hit 콜 수를 제한하는 것이었습니다.
이렇게 하면 컴퓨트 그룹 안에서의 타임 다이버전스는 줄지만, 그만큼 과도한 차폐가 추가되어 바이어스가 생깁니다. 구현은 쉽지만, 퀄리티 측면에서는 타협이 필요한 방식입니다.

실제로 테스트해 보니, any-hit 콜을 최대 4회로 제한하는 것이 참조 결과와의 차이는 적으면서도, 비용을 안정적으로 줄일 수 있는 지점이라는 결론이 나왔습니다.
스페큘러 RTGI는 목표 플랫폼에서 이 정도 비용을 감당할 수 있었기 때문에, 이 설정을 그대로 사용했습니다.

두 번째로 시도한 방법은, 노이즈 텍스처를 이용한 디더링(dithering) 기반 알파 테스트입니다.
평균 불투명도(average opacity)를 미리 계산해 두고, 실제 레이 트레이싱 중에는 그 값과 노이즈를 조합해서 히트 여부를 결정하는 방식입니다.
BLAS에서 텍스처를 직접 접근하기 어려운 엔진에서는 꽤 유용할 수 있는 아이디어입니다.
하지만 저희 환경에서는 이 방식이 성능 이득을 주지 못했고, 어떤 장면에서는 오히려 실제 알파 테스트보다 더 비싼 경우도 있었습니다.

물론 이 방식에서도 any-hit 콜 수를 제한하면 성능은 좋아지지만, 그만큼 차폐 바이어스가 더해지는 문제는 그대로 남습니다.

그래서 최종적으로 선택한 것은 "불투명 근사(opaque approximation)"였습니다.
평균 불투명도에 맞춰 삼각형을 스케일링해서 두껍게 만들고, 완전히 불투명한 기하로 처리하는 방식입니다.
이렇게 하면 레이 트레이싱 중에는 완전 불투명 메시처럼 간단히 볼 수 있어서, 트래버설 비용이 크게 줄어듭니다. 동시에, 실제 알파 테스트를 한 참조 결과와도 꽤 비슷한 결과를 얻을 수 있었습니다.
도입된 바이어스를 정량적으로 완벽하게 분석하지는 않았지만, 눈으로 보기에는 충분히 만족스러웠습니다.

이 슬라이드는 참조 결과와 불투명 근사 결과를 나란히 비교한 예시입니다.

여기는 any-hit 콜을 2번까지 허용한 알파 테스트 텍스처 버전입니다.

그리고 이것이 최종적으로 선택한 완전 불투명 근사 버전입니다.

다음 과제는 반투명 머티리얼입니다.
AC 섀도우 월드에는 종이문, 창호, 얇은 나뭇잎과 초목 같은 반투명 재질이 아주 많이 등장합니다.

이 슬라이드부터는 우리가 반투명을 지원하기 위해 실제로 어떤 단계를 추가했는지, 순서대로 살펴보겠습니다.
처음 화면은 상당히 어둡게 보일 텐데요, 작은 개구부를 통해서만 직접광이 들어오다 보니, 2차 히트에서 직접광을 잘 찾지 못하는 상황입니다.
창문과 문을 통해 들어와야 할 간접 조명이 대부분 빠져 있어서, 실내가 실제보다 훨씬 어둡게 보입니다.

첫 번째 단계로, 2차 히트 지점의 직접 조명을 개선하기 위해 백페이스 노멀(back-facing normal)을 허용하는 기능을 넣었습니다.
여기서는 얇은 반투명(thin translucency)만을 다룬다고 가정하기 때문에, 뒷면 노멀은 앞면 노멀을 뒤집은 값으로 보고 처리합니다.
반투명 팩터와 알베도 역시 이 조명 기여도에 영향을 주게 됩니다.

두 번째 단계는 2차 히트에서의 간접 조명입니다.
여기서는 앞·뒤 두 면에 대해 각각 따로 조도를 평가합니다. 8개의 프로브를 법선 방향에 따라 앞면/뒷면으로 나누고, 각 쪽에 대해 조도를 계산한 뒤, 두 값을 합산하는 식입니다.

세 번째 단계는, 레이가 반투명 표면에서 시작하는 경우입니다.
이때는 2차 레이를 쏠 때 어느 쪽으로 갈지 확률적으로 결정합니다. 확률은 반투명 팩터에 맞춰 조정하고, 뒷면을 선택한 경우 화면 공간 트레이싱은 건너뛰고 바로 BVH로 들어갑니다.
또한 새 PDF를 고려해서 결과를 다시 스케일링해야 하고, 뒷면으로 간 레이에 대해서는 머티리얼 파라미터도 바뀌어야 합니다.
마지막으로, 내부 표현에서는 항상 앞면 기준으로 레이 방향을 인코딩하기 때문에, 실제로는 뒤쪽으로 던진 레이를 나중에 다시 뒤집어 주는 처리가 들어갑니다.

이 단계를 모두 합친 결과가 바로 이 슬라이드에 보이는, 얇은 반투명(thin translucency)을 포함한 최종 조명입니다.

이제 잘 알려진 문제인 "광 누출(light leak)"을 어떻게 다뤘는지에 대해 이야기해 보겠습니다.
실시간 GI 솔루션에서는 어느 정도의 누출이 필연적으로 발생하는데, 여기서는 직접 조명과 간접 조명으로 나누어 그 원인을 정리해 봅니다.

먼저 직접광입니다.
여기서의 주요 문제는 누락된 섀도 정보입니다. CSM 범위 안에서도 누출이 생기는 경우가 있고, 이것은 CSM 구조와 필터링 방식의 한계와도 연결됩니다.

이 문제를 줄이기 위해, 우리는 2차 섀도 맵(secondary shadow map)을 추가로 사용했습니다.
이 맵은 CSM이 커버하지 못하는 범위에 대한 섀도 정보를 보완하는 역할을 합니다.

2차 섀도 맵은 한 번에 전부 업데이트하지 않고, 16프레임에 걸쳐 타임 슬라이싱 방식으로 조금씩 갱신합니다. 이렇게 하면 프레임마다의 비용은 낮게 유지하면서도, 전체 정보를 꾸준히 최신 상태로 유지할 수 있습니다.

프로브 라이팅 패스에서는 필터링 바이어스를 좀 더 공격적으로 적용해서, 누출을 추가로 줄입니다.
여기서 사용하는 필터링 바이어스는, 디퍼드 라이팅에서 CSM에 적용하는 것과 같은 계통이지만, 프로브에서는 그것보다 조금 더 강하게 쓰고 있습니다.

간접 조명, 특히 스페큘러 측면에서도 누출 문제가 있습니다.
프로브 볼륨과 섀도 시스템이 커버하지 못하는 영역에서는, 반사 안에 들어가면 안 되는 빛이 들어가는 경우가 생깁니다.

고사양 플랫폼에서는 이 문제를 줄이기 위해, 2차 섀도 맵 범위를 벗어나는 스페큘러 반사에 대해 별도의 섀도 레이를 추가로 쏩니다.
추가 비용은 PC에서는 약 0.1ms, PS5 Pro에서는 0.3~0.4ms 정도입니다.

가장 크게 남아 있는 문제는, 아예 시스템에 들어오면 안 되는 빛이 간접 조명에 섞여 버리는 케이스입니다.
2차 히트 지점에서 프로브 볼륨으로 간접 조명을 계산할 때, 주변 8개의 프로브를 모두 가져다가 쓰는데, 실내·실외를 가르는 벽 근처에서는 일부 프로브가 실외 조명을 잔뜩 가져와 버립니다.

이 문제를 줄이기 위해, 먼저 샘플링 위치를 오프셋하는 방식을 도입했습니다.
DDGI Resampling 논문에서 제안한 것과 비슷하게, 레이 방향을 따라 히트 지점에서 뒤쪽으로 샘플링 위치를 이동시키는 방식입니다. 우리는 이 오프셋을 추적 거리의 절반까지로 제한했습니다.
다만 레이가 벽과 거의 평행하게 날아가는 케이스가 많아서, 이 오프셋만으로는 항상 충분히 벽을 벗어나지 못하는 경우도 있습니다.

오프셋된 위치를 기준으로 각 프로브에 대한 가중치를 구성합니다.
먼저 방향 가중치(direction weight)를 계산하는데, 단순한 내적(dot product)을 그대로 쓰면, 어떤 경우에는 모든 프로브가 거의 0에 가까워지는 문제가 생깁니다.
그래서 우리는 Sloan 등(2011)의 Wrap Shading 방식을 참고해서, 랩 셰이딩(wrap shading)을 사용하고 있습니다.[논문 참고: https://dl.acm.org/doi/10.1145/2018323.2018331]
가시성은프로브 VSM을 사용해서 평가합니다.
마지막으로, 일정 임계값(0.2)보다 작은 가중치는 잘라내고, 트릴리니어 가중치와 곱해서 최종 가중치를 만들었습니다.

그럼에도 불구하고, 실내·실외 사이의 조도 차이가 워낙 크기 때문에, 이 가중치만으로는 실외 조명을 완전히 걸러내기 어렵습니다.

그래서 우리는 기존 베이크드 GI에서 사용하던 평면(plane) 리스트를 재사용하기로 했습니다.
이 리스트를 이용해, 실내와 실외를 더 잘 나누고, 프로브의 사용 범위를 제한하는 식으로 누출을 줄였습니다.

슬라이드에는 나오지 않았지만, GI 블로커(GI blocker) 메시도 함께 사용합니다.
이 메시들은 맞은 모든 레이에 대해 조명을 "죽이는" 역할을 합니다. 동굴처럼 아주 어두운 환경에서는, 이렇게 과감하게 막아 주는 편이 누출을 줄이는 데 훨씬 도움이 됩니다.
물론 이 방식은 거짓 차폐를 만들어내기도 하지만, 실제 플레이 느낌을 우선시해서 이런 환경에서는 GI 블로커를 쓰기로 했습니다.
각 프로브는 실내 또는 실외로 분류되고, 프로브 볼륨을 사용할 때는 먼저 히트 지점이 실내인지 실외인지 분류한 뒤, 동일한 분류를 가진 프로브만 사용합니다.

프로브는 기본적으로 하나의 시점에서만 장면을 평가하기 때문에, 프로브 범위 안의 "미세 가시성(micro-visibility)"을 모두 포착하지 못합니다.

이 문제를 완화하기 위해, 레이 추적 거리와 프로브 범위를 비교해서, 레이 길이가 프로브 범위보다 충분히 짧다면 "프로브 평가에서 놓친 미세 차폐가 있다"고 가정합니다.
이때 거리 비율을 계산해서, 사전 정의한 범위에 따라 추가 AO를 적용하는 방식으로, 누락된 마이크로 오클루전을 보완합니다.

이제 오클루전(occlusion) 전체를 어떻게 구성했는지 간단히 정리해 보겠습니다.

먼저 SH 기반 디퓨즈 GI 결과를 보시면, 큰 스케일의 차폐뿐 아니라 근접한 미세 차폐도 상당 부분 빠져 있는 것을 볼 수 있습니다.

첫 번째로, 앞에서 설명한 프로브 AO를 더합니다.
이는 레이 길이와 프로브 간 간격의 관계를 이용해서, 프로브 레벨에서 놓친 차폐를 일부 복원해 주는 역할을 합니다.

두 번째로, 디노이저에서 나온 ray-traced ambient occlusion(RTAO)을 추가합니다.
이 값은 SSAO와 비슷한 성격을 가지지만, 레이 트레이싱 기반이라 GI와 더 자연스럽게 어울립니다.

세 번째로는 GTAO(Ground Truth Ambient Occlusion) 포스트 이펙트를 한 번 더 거칩니다.[논문: https://www.activision.com/cdn/research/Practical-Real-Time-Strategies-for-Accurate-Indirect-Occlusion.pdf]
우리 GTAO는 필터링 과정에서 사라진 마이크로 디테일과, BVH에 없는 오브젝트 때문에 빠진 AO를 다시 살려내는 데 초점을 맞추고 있습니다.

이 화면은 추가 오클루전이 전혀 없는 상태입니다.

여기에 프로브 AO, RTAO, GTAO를 모두 더한 상태입니다.
특히 접촉면(contact)이나 코너, 실내 구조물 주변에서 차이가 크게 느껴집니다.

그리고 이것이 최종 GI 결과에 오클루전까지 합쳐진 모습입니다.

다음은 노이즈와 디노이징 전략에 대해 정리한 파트입니다.

반투명 머티리얼이 불투명 머티리얼 위에 얹혀 있을 때, 디노이징 과정에서 색이 번지는 문제를 막기 위해 머티리얼 타입을 담은 마스크를 추가했습니다.
이 마스크는 공간 필터링 패스에서 이웃 샘플을 고를 때 사용되며, 머티리얼 ID에 따라 가중치를 조정하거나 샘플 선택 방식을 달리하는 식으로 활용됩니다.

레이 길이를 제한하지 않고 끝까지 쏘게 두면, 스페큘러 쪽 노이즈가 감당하기 힘들 정도로 커집니다.

그래서 일정 거리 이상에서는 레이를 더 이상 쏘지 않고, 대신 프로브 방사휘도 캐시를 폴백으로 사용하는 구조입니다.
이 방식은 성능과 노이즈 모두에 상당히 도움이 되지만, 정확도 측면에서는 어느 정도의 누출과 디테일 손실이 생깁니다.
또, 프로브 간 간격이 약 2m 정도이기 때문에, 가장 가까운 프로브 하나만 사용하는 경우에는 일부 세부 디테일이 사라질 수 있습니다.

디소클루전(disocclusion)도 큰 문제였습니다.
우리 엔진에서는 모션 벡터가 2D라, 깊이 정보 없이 화면 좌표만 들고 다닙니다. 그래서 재투영된 픽셀이 진짜 맞는 픽셀인지 검증하기가 어렵습니다. 그 결과, 특히 움직이는 캐릭터 주변에서 디소클루전 픽셀이 많이 생기고, 이 픽셀들에는 템포럴 필터를 거의 사용할 수 없어 노이즈가 크게 남습니다.
이 문제를 줄이기 위해, 디소클루전 픽셀만 따로 모아서 추가 공간 필터링 패스를 한 번 더 돌려 주는 방식을 사용했습니다.

바람에 흔들리는 키 큰 풀(blades of grass)은 노멀 변화도 크고 디소클루전도 강하기 때문에, 일반 디노이저 설정만으로는 깔끔하게 정리하기 어렵습니다.
그래서 이 쪽은 별도로 디노이저 파라미터를 튜닝해서, 움직임을 살리면서도 노이즈를 최소화하는 방향을 찾았습니다.


이제 BVH 구조와 메모리 사용량을 간단히 정리해 보겠습니다.
런타임에는 약 2,000개의 BLAS와 30,000개의 TLAS 인스턴스가 로드되어 있고, 이들이 합쳐서 대략 320MB 정도의 메모리를 사용합니다.
지금 보시는 수치는 Xbox Series X에서 도시(scene)를 기준으로 측정한 값입니다.

프로브 쪽에서 가장 큰 버퍼는 프로브 G-버퍼로, 약 55MB를 사용합니다.
각 픽셀에 대해 다양한 머티리얼 속성을 표현하기 위해 14바이트를 할당하고 있고, 바이리니어 필터링을 쓰는 일부 버퍼는 한 픽셀짜리 보더도 포함하고 있습니다.
프로브 관련 버퍼 전체를 합치면, 1만 개 프로브 기준으로 약 73MB 정도가 됩니다.


이 그래프에서는 GPU 타이밍을 큐별로 나눠서 보여주고 있습니다.
- 청록색은 async 큐에서 돌아가는 패스,
- 초록색은 그래픽 큐에서 돌아가는 패스,
- 주황색은 단일 그래픽 큐로만 구성했을 때의 타이밍입니다.
프로브 업데이트 쪽 타이밍을 간단히 정리하면 다음과 같습니다.
- 콘솔 기준 30fps 퀄리티 모드에서 프로파일링했습니다.
- G-버퍼 업데이트 패스에서는 전체 프로브의 약 16%를 레이 트레이싱합니다.
- Xbox Series S에서는 같은 비용을 유지하기 위해 업데이트 빈도를 절반으로 줄였습니다.
- PC에서는 프레임당 512개의 프로브만 업데이트하지만, 프레임레이트가 더 높기 때문에 이 정도면 충분합니다.

이 그래프는 쿼터 해상도에서 레이 트레이싱하는 픽셀 단위 디퓨즈 GI의 GPU 타이밍을 보여줍니다.
콘솔 환경에서는 내부 렌더 해상도 1440p 기준이고, Xbox Series S만 900p로 낮춰서 비슷한 비용을 맞추고 있습니다.

마지막으로, 하프 해상도로 레이 트레이싱하는 RT 스페큘러의 GPU 타이밍입니다.
- 스페큘러 RT는 PS5 Pro의 30fps 퀄리티 모드와 PC에서만 제공됩니다.
- PS5 Pro 기준으로는 하프 해상도에서도 비용이 꽤 높은 편이지만, 쿼터 해상도에서의 품질이 만족스럽지 않았고, 목표 프레임레이트와 해상도도 이미 만족하고 있었기 때문에 하프 해상도를 그대로 유지했습니다.

이제 전체 작업을 마무리하면서, 이번 프로젝트에서 겪은 주요 도전 과제와 배운 점들을 정리해 보겠습니다.
- 베이크드 GI와 RTGI, 두 가지 서로 다른 룩에 대해 콘텐츠를 모두 검토해야 했습니다. 사실상 두 솔루션 각각을 위한 데이터가 필요했고, 이로 인한 아트·레벨 팀의 부담도 컸습니다.
- 단순화된 RT 머티리얼로의 수동 변환은 많은 인력이 필요한 작업이었습니다. 이 과정은 앞으로 더 자동화할 수 있기를 바라고 있습니다.
- BVH에서 빠진 에셋 때문에 생기는 문제들이 자주 나왔습니다. 누락된 오브젝트가 그림자·GI 양쪽에 모두 영향을 주다 보니, 디버깅도 쉽지 않았습니다.
- BVH 경계 근처와 플레이어 발밑에서 동일한 에셋 품질을 유지해야 한다는 것도 어려운 과제였습니다. 장기적으로는 이 부분을 조정해 성능을 더 끌어올리고 싶습니다.
- 다음 타이틀에서는 디소클루전 처리에 도움이 되도록, 2D가 아닌 3D 모션 벡터를 사용하는 것을 목표로 하고 있습니다.

또 다른 관점에서 정리해 보면, 다음과 같은 한계점들도 있습니다.
- 현재 사용 중인 균일 프로브 분포는, 더 큰 범위나 더 촘촘한 커버리지가 필요할 때 스케일업 비용이 너무 큽니다. 간격은 이미 다소 넓고, 프로브 버퍼도 작기 때문에 지금 구조로는 쉽게 확장하기 어렵습니다.
- 이 알고리즘 특성상, 어느 정도의 누출은 피할 수 없습니다. 특히 가시성 표현이 중요한 변수로 작용합니다.
- 실제 월드와 단순화된 지오메트리 사이의 차이가 클수록, 차폐 오류가 크게 발생합니다. 오프셋만으로는 해결되지 않는 케이스도 많고, 실제로 발견한 최악의 경우에는 두 표현 사이 차이가 50cm에 달했습니다. 앞으로는 단순화 지오메트리에 더 강력한 제약을 두는 방향이 필요합니다.
- 일부 프레임 업스케일러와 조합했을 때, GI 노이즈 패턴이 더 눈에 띄게 드러나는 문제도 있었습니다. 노이즈가 남아 있는 GI 결과를 강하게 업스케일링하는 것은 항상 까다로운 문제입니다.

그래서 다음 타이틀을 준비하면서는 다음과 같은 방향을 잡고 있습니다.
- RTGI를 지원하는 전체 파이프라인을 단순화하고, 특히 머티리얼 단순화 과정을 더 많이 자동화하는 것.
- AC 섀도우에서는 메모리 제약으로 Xbox Series S에서 RTGI를 지원하지 못했지만, 다음 타이틀에서는 모든 플랫폼에서 RTGI를 지원하는 것을 목표로 하고 있습니다.
- 누출을 줄이기 위한 다양한 솔루션 후보들이 있고, 이들 중 어느 것을 선택하더라도 비용과 퀄리티 사이의 균형이 핵심 과제가 될 것입니다.
- 디노이징 쪽은 아직 충분히 파지 못한 영역이라, 앞으로 더 많은 시간을 투자해 개선해 나갈 계획입니다.

마지막으로, 이번 작업을 통해 어떤 시각적 차이를 얻었는지 몇 가지 샷을 보면서 정리하고 발표를 마치겠습니다.
이전 방식에 비해 디테일은 훨씬 풍부해졌고, 조명 값이 번지거나 뭉개져 보이는 현상은 줄어들었습니다.

이 샷에서도 마찬가지로, 디테일은 늘어나고 스머어링은 줄어든 것을 확인할 수 있습니다.

또 다른 예시입니다. 디테일은 더 풍부해졌고, 그림자가 묽게 퍼져 보이는 현상도 크게 줄었습니다.

다른 장면에서도 같은 경향을 볼 수 있습니다. 네거티브 스페이스나 실내 구조물의 깊이감이 훨씬 잘 살아납니다.

마지막 샷에서도 마찬가지로, 조명 디테일은 늘어나고 번짐은 줄어든 결과를 확인할 수 있습니다.

마무리로, 이 슬라이드는 프로브 텍셀 컨볼루션 결과를 기록할 때 사용하는 코드 예시입니다.
하드웨어 바이리니어 필터링을 안전하게 사용하기 위해 경계 텍셀을 어떻게 추가하고 있는지 보여주고 있고, 관심 있으신 분들은 발표 자료를 참고해서 구현에 응용하실 수 있을 것 같습니다.
End.
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [INDEX] SIGGRAPH 2025 ADAVANCED REALTIME RENDERING (0) | 2026.01.08 |
|---|---|
| [발표 번역 1부] Siggraph 2025 Ray tracing the world of Assessin’s Creed Shadow. (1) | 2026.01.07 |
| [번역] Unity에서 스킨드 메시의 GPU Driven 렌더링 구현 (0) | 2026.01.03 |
| [발표 번역] SIGGRAPH 2025 idTech8에서의 글로벌 일루미네이션 (0) | 2026.01.02 |
| [번역] Bindless 에 관한 간략한 논의: UE5에서의 Bindless (1) | 2026.01.02 |