역자의 말: 요즘은 컨설팅 업무가 줄어서 메이즈라인 자체 개발을 하고 있습니다만 사실 그다지 대단한걸 하지 않기 때문에 언리얼엔진이 업그레이드 됬다고 해도 살펴볼 기회는 많지 않더군요. 언리얼엔진 5.8 업데이트 이슈도 봐야하지만 그것 말고도 먼저 봐야 할 어플리케이션 개발이슈도 많고 해서 꽤나 오랫동안 번역에 의존 해서 공유 합니다. 루멘 미니에 대한 글이 있어 공유 해 보려 합니다.
저자: wlxklyh
이 글에서는 UE5.8의 Lumen Lite에서 새로 들어온, 혹은 더 눈에 띄게 정리된 “가벼운 Final Gather” 경로인 IrradianceFieldGather를 살펴봅니다. 핵심은 간단합니다. 이 경로는 사실상 DDGI(Dynamic Diffuse Global Illumination) 방식으로 동작합니다.
또한 엔진 안에 마우스 픽킹 + ShaderPrint 기반의 디버그 도구를 붙여 보았습니다. 화면의 임의 픽셀을 찍으면, 해당 픽셀이 보간에 사용하는 8개의 probe를 하이라이트하고, 각 probe의 가시성/가중치를 시각화합니다. 덕분에 특정 픽셀의 GI 문제가 정말 probe 때문인지, 아니면 다른 원인인지 훨씬 빠르게 판단할 수 있습니다.

1. UE5.8 Lumen Lite의 핵심 최적화: Final Gather 교체
Lumen의 전체 파이프라인은 크게 두 단계로 볼 수 있습니다.
- Lumen Scene
- 월드를 surface cache / mesh card 형태로 추적 가능한 데이터로 구성합니다.
- Final Gather
- 화면상의 픽셀마다 간접광을 모읍니다.
UE5.8에서는 Final Gather를 선택 가능한 전략으로 분리했습니다. 관련 스위치는 다음과 같습니다.
// Engine/Source/Runtime/Renderer/Private/Lumen/Lumen.cpp:55
static TAutoConsoleVariable CVarLumenFinalGatherMethod(
TEXT("r.Lumen.FinalGatherMethod"),
1,
TEXT("Lumen Final Gather Method\n")
TEXT("0 - Irradiance Field Gather - places World Space Radiance Cache probes around pixels, ")
TEXT("pre-calculates their irradiance, and interpolates to pixels with probe occlusion. ")
TEXT("Faster but lower quality GI. Targeted at mid range PC and Switch 2.\n")
TEXT("1 - Screen Probe Gather - traces from Screen Probes placed on pixels ... ")
TEXT("Higher quality GI that can scale from consoles to enterprise."),
ECVF_Scalability | ECVF_RenderThreadSafe);
표로 정리하면 다음과 같습니다.

표
핵심은 이겁니다.
Lite 버전은 “픽셀마다 직접 추적하고 시간축으로 denoise하는 방식”을 “월드 공간에서 미리 적분된 irradiance를 싸게 보간하는 방식”으로 바꿉니다.
즉, 화면의 각 픽셀이 더 이상 직접 광선을 쏘지 않습니다. 대신 주변의 월드 공간 probe 8개에서 이미 계산된 irradiance를 가져와 삼선형 보간합니다. 이게 Lumen Lite가 저렴해지는 가장 큰 이유입니다.
반대로 말하면, 품질과 정확도는 거의 전적으로 이 월드 공간 probe 볼륨의 품질에 달려 있습니다.
ScreenProbeGather: r.Lumen.FinalGatherMethod 1

IrradianceFieldGather: r.Lumen.FinalGatherMethod 0

아직 원인을 추적 중인 artifact

2. IrradianceFieldGather의 정체: 사실상 DDGI
2.1 파이프라인은 네 단계
입구는 FDeferredShadingSceneRenderer::RenderLumenIrradianceFieldGather입니다. 파일은 LumenIrradianceFieldGather.cpp입니다.
전체 흐름은 대략 네 단계로 나눌 수 있습니다.
- DownsampleDepthAndNormalCS
- 선택적으로 깊이와 노멀을 1/2 해상도로 다운샘플합니다. 관련 값은 InterpolateDownsampleFactor입니다.
- MarkProbesUsedByGBufferCS
- GBuffer를 기준으로 “이번 프레임에 어떤 probe가 사용될지” 표시합니다. 이 정보는 Radiance Cache 업데이트 예산을 정하는 데 사용됩니다.
- UpdateRadianceCaches전체 Lumen과 Lite 경로의 차이는 사실상 아래 플래그 하나로 요약됩니다.
- Lumen의 월드 공간 Radiance Cache 기반을 재사용합니다. 여기서 probe를 추적하고, 각 probe의 irradiance와 **occlusion(깊이 평균/제곱 평균)**을 계산합니다.
// LumenIrradianceFieldGather.cpp SetupRadianceCacheInputs()
Parameters.CalculateIrradiance = 1; // Lite 경로에서는 Radiance Cache가 irradiance를 추가로 계산
Parameters.IrradianceProbeResolution = GLumenIrradianceFieldProbeIrradianceResolution; // 기본 6
Parameters.OcclusionProbeResolution = GLumenIrradianceFieldProbeOcclusionResolution; // 기본 16
- InterpolateIrradianceProbesToPixelsCS
- 화면의 각 픽셀이 자신이 속한 clipmap cell의 8개 코너 probe를 찾고, 그 결과를 가중 보간해 DiffuseIndirect를 만듭니다.
probe 볼륨의 형태는 SetupRadianceCacheInputs에서 결정됩니다. 기본값 기준으로는 다음과 같습니다.
- clipmap 수: NumClipmaps = 4
- 각 레이어의 그리드 해상도: GridResolution = 64
- probe 방향 해상도: ProbeResolution = 16
- 즉, probe 하나당 16² = 256개의 추적 ray
- irradiance atlas: 6×6
- occlusion atlas: 16×16
2.2 DDGI 논문과 거의 1:1로 대응된다
IrradianceFieldGather는 Majercik et al. 2019의 Dynamic Diffuse Global Illumination with Ray-Traced Irradiance Fields를 엔진에 맞게 구현한 형태에 가깝습니다.
코드와 DDGI의 대표 개념을 대조해 보면 거의 그대로 맞아떨어집니다.

DDGI 개념 표
물론 UE는 표준 DDGI 위에 몇 가지 엔진 친화적인 확장을 얹었습니다.
- 고정 그리드 대신 clipmap 사용
- 카메라 중심의 다중 해상도 구조라서 큰 월드나 사실상 무한 월드에 대응하기 좋습니다.
- Adaptive probe placement
- r.Lumen.IrradianceFieldGather.AdaptivePlacement를 통해 보간이 실패하는 구간에 probe를 보강할 수 있습니다.
- Lumen Radiance Cache 기반
- probe 추적이 별도의 RT 시스템이 아니라 Lumen의 software/hardware ray tracing, mesh card, distance field 기반 위에서 돌아갑니다.
그래서 한 문장으로 정리하면 이렇습니다.
IrradianceFieldGather는 “Lumen Radiance Cache 위에 얹힌 clipmap 기반 DDGI”입니다.
이 관점으로 보면 디버깅 포인트도 명확해집니다. DDGI 계열에서 흔히 보이는 artifact, 예를 들어 light leaking, probe self-occlusion, interpolation failure로 인한 회색/검은 영역 등이 이 경로에서도 그대로 나타날 수 있습니다.
r.Lumen.RadianceCache.Visualize 1을 켜면 전체 probe 구를 시각화할 수 있습니다. “월드 공간에 probe 볼륨이 깔려 있다”는 감각을 얻기 좋습니다. 아래 이미지의 녹색/회색 의미는 아직 더 확인이 필요하지만, 적어도 단순 irradiance 표시만은 아닌 것으로 보입니다.

3. 왜 probe 단위 디버거가 필요한가
DDGI 계열의 GI 문제는 최종 화면만 봐서는 원인을 추적하기 어렵습니다.
예를 들어 다음과 같은 증상이 있습니다.
- Light leaking
- 벽 뒤나 코너처럼 어두워야 할 곳에 빛이 새어 나옵니다. Chebyshev visibility가 막지 못한 것인지, 아니면 probe 자체가 벽 반대편에 배치된 것인지 구분해야 합니다.
- 회색으로 뜨거나 블록처럼 보이는 영역
- 특정 표면의 GI가 지나치게 어둡거나 뭉개져 보입니다. 8개 probe 중 대부분이 invalid/occluded 판정을 받아 한두 개 probe만 보간을 지배하고 있을 수 있습니다.
- 깜빡임
- probe가 어떤 프레임에는 유효하고 다음 프레임에는 무효가 되거나, adaptive placement가 프레임마다 흔들릴 수 있습니다.
- 전체가 검게 빠지는 interpolation miss
- 8개 probe가 모두 차폐체 반대편에 있거나, 총 가중치가 너무 낮아 fallback으로 빠질 수 있습니다.
이 모든 문제는 결국 InterpolateIrradianceFromProbe 안의 “probe별 가중치 계산”과 연결됩니다.
최종 화면은 이미 섞인 결과만 보여 줍니다. 우리가 보고 싶은 것은 다음 정보입니다.
- 이 픽셀이 실제로 사용한 8개 probe가 어디에 있는지
- 각 probe의 삼선형 가중치 / 노멀 가중치 / visibility 가중치 / 유효성이 각각 얼마인지
- 어떤 항목 때문에 특정 probe의 기여도가 거의 사라졌는지
따라서 목표는 명확합니다.
문제가 있는 픽셀을 마우스로 찍으면, 그 픽셀의 8-probe 보간 과정을 그대로 펼쳐서 보여 주는 디버거가 필요합니다.
4. 디버그 방법
4.1 기본 아이디어
UE에는 ShaderPrint가 있습니다. compute shader 안에서 선, 구, 십자, 텍스트를 직접 화면에 그릴 수 있는 GPU 디버그 시스템입니다. 게다가 매 프레임 마우스의 뷰포트 픽셀 좌표가 View.CursorPosition으로 shader에 전달됩니다.
이 기능을 이용하면 필요한 요소가 거의 다 갖춰집니다.
- 픽셀 선택
- View.CursorPosition * View.ViewResolutionFraction을 사용하면 마우스 위치에 해당하는 SvPosition을 얻을 수 있습니다. 별도의 screen-to-world ray picking을 직접 구현할 필요가 없습니다.
- 시각화
- AddSphereTWS, AddLineTWS, AddCrossTWS, Print를 이용해 월드 공간과 화면에 바로 디버그 정보를 그릴 수 있습니다.
그래서 구조는 단순합니다.
마우스가 가리키는 픽셀 하나만 처리하는 1-thread debug compute pass를 만들고, InterpolateIrradianceProbesToPixelsCS의 8-probe 보간 과정을 항목별로 복제합니다.
다만 실제 조명 결과를 누적하는 대신, 각 probe를 그려 주고 가중치 세부 정보를 출력합니다.
4.2 사용법
r.Lumen.FinalGatherMethod 0 :: IrradianceFieldGather 경로 사용
r.ShaderPrint 1 :: ShaderPrint 활성화
r.Lumen.IrradianceFieldGather.Debug.ProbeInterpolation 1 :: 디버그 도구 활성화
r.Lumen.IrradianceFieldGather.Debug.ProbeRadiusScale 8 :: probe 구 크기 조절, 기본값 8
이후 마우스를 뷰포트의 임의 픽셀 위에 올리면 됩니다.
화면에는 다음 정보가 표시됩니다.
- 흰색 십자
- 현재 shading pixel의 월드 위치
- 파란색 선
- 해당 픽셀의 노멀
- 노란색 십자즉, normal bias와 view bias가 적용된 샘플 위치
- 실제로 보간에 사용되는 biased sample position
- 8개의 구구의 색과 크기는 정규화된 최종 가중치를 나타냅니다. 빨간색은 기여가 거의 없고, 초록색은 지배적인 probe입니다.
- 보간에 참여하는 8개 코너 probe
- 샘플 위치에서 probe로 이어지는 선
- 색은 Chebyshev visibility를 나타냅니다. 초록색은 거의 완전 가시, 빨간색은 visibility test에서 많이 깎인 상태입니다. light leaking이나 벽 통과 문제를 확인하는 데 특히 유용합니다.
- 빨간색 십자(구 없음)
- 해당 그리드 코너의 probe가 아직 할당되지 않은 상태입니다. coverage hole을 의심할 수 있습니다.
- 좌상단 텍스트 패널
- Clipmap, TotalWeight, INTERPOLATION MISS 여부, 그리고 각 probe별 n idx valid tri chebyshev weight% 정보가 표시됩니다.
아래 이미지는 normal bias가 적용된 샘플 위치에서 8개의 probe로 초록색 선이 이어지는 모습입니다. 즉, 해당 픽셀은 8개의 probe를 정상적으로 사용해 보간하고 있습니다.

다음 이미지는 두 개의 선이 빨간색으로 표시되는 예입니다. 이 경우 두 probe는 현재 샘플 위치에 대해 occluded 판정을 받아, 보간 기여도가 크게 줄어듭니다.

4.3 코드 분석: 시각화 값은 어디서 오는가
이 도구의 가중치 계산은 LumenIrradianceFieldInterpolation.ush의 InterpolateIrradianceFromProbe를 거의 그대로 따라갑니다. 아래는 각 시각화 요소가 어떤 계산과 대응되는지 정리한 내용입니다.
(a) 노멀 방향 가중치: WrapShading
이 값은 probe 구의 기본 밝기와 관련이 있습니다.
// 픽셀 노멀이 probe 방향을 향할수록 가중치가 커집니다.
// +0.2는 반대 방향에도 약간의 여유를 주는 항입니다.
float WrapShading = (dot(normalize(ProbeTranslatedWorldPosition - TranslatedPixelPosition),
WorldSpaceNormal) + 1.0f) * .5f;
Weight *= WrapShading * WrapShading + .2f;
(b) Chebyshev visibility 가중치: ChebyshevWeight
선의 빨강/초록 색상은 이 값과 직접 대응됩니다. DDGI에서 light leaking을 막는 핵심 항목입니다.
// occlusion atlas에서 "샘플 위치 → probe" 방향으로
// (mean, meanSq) = 거리의 1차/2차 moment를 샘플링합니다.
float2 MeanAndMeanSq = SampleProbeOcclusion(ProbeIndex, -SamplePositionToProbe);
float ChebyshevWeight = 1.0f;
if (DistanceToProbe > MeanAndMeanSq.x)
{
// 샘플이 probe가 본 평균 깊이보다 멀다면, 중간에 가려졌을 가능성이 있습니다.
float Variance = abs(Square(MeanAndMeanSq.x) - MeanAndMeanSq.y);
// Chebyshev inequality 기반 visibility 추정.
// variance가 클수록 더 관대하고, 작을수록 더 강하게 차단합니다.
float VisibilityWeight = Variance / (Variance + Square(DistanceToProbe - MeanAndMeanSq.x));
ChebyshevWeight = max(VisibilityWeight * VisibilityWeight * VisibilityWeight, 0.0f);
}
Weight *= max(ChebyshevWeight, 0.05f);
이 방식은 variance shadow map이나 DDGI의 moment-based visibility와 같은 계열입니다.
probe는 자신이 본 깊이의 평균과 제곱 평균을 occlusion atlas에 저장합니다. 보간 시점에 샘플 위치가 probe 기준 평균 깊이보다 훨씬 멀고, variance가 작다면 “중간에 뭔가에 가려졌을 가능성이 높다”고 판단합니다. 그래서 해당 probe의 가중치를 거의 0에 가깝게 낮춥니다.
즉, 디버그 뷰에서 빨간 선은 이 visibility 항목이 강하게 작동하고 있다는 뜻입니다.
(c) probe 유효성: ProbeValid
텍스트 패널의 valid 열, 그리고 빨간색 십자 표시와 연결됩니다.
float ProbeValid = SampleProbeValid(ProbeIndex);
Weight *= max(Weight * ProbeValid, MinWeight);
(d) 삼선형 기하 가중치: TrilinearWeight
cell 내부에서 샘플 위치가 어디에 있는지에 따라 결정되는 기본 보간 가중치입니다.
float3 TrilinearWeights = max(select(ProbeOffset > 0, LerpAlphas, 1 - LerpAlphas), .001f);
float TrilinearWeight = TrilinearWeights.x * TrilinearWeights.y * TrilinearWeights.z;
Weight *= TrilinearWeight;
최종적으로는 위 항목들이 모두 곱해집니다. 그리고 일정 임계값보다 낮은 가중치는 “weight crush” 단계에서 한 번 더 눌립니다. irradiance는 sqrt 공간에서 누적한 뒤 다시 제곱해 되돌립니다. 지각적으로 더 자연스러운 보간을 위한 작은 트릭입니다.
// SampleIrradianceCacheInterpolated
OutIrradiance += sqrt(SampleIrradiance) * Weight;
...
Irradiance = Square(Irradiance * InvTotalWeight);
// 총 가중치가 너무 낮으면 interpolation miss로 처리됩니다.
if (TotalWeight < MinWeight * 8 * 1.5f)
{
OutInterpolation.bSuccess = false;
}
또 하나 반드시 맞춰야 하는 부분은 샘플 위치의 bias입니다. 이걸 맞추지 않으면 디버그 도구에서 표시되는 probe 위치와 실제 보간이 어긋납니다.
// GetIrradianceFieldInterpolatedLighting
float ViewBias = lerp(0.0f, .8f, saturate(CameraDistance / CellSize));
float3 BiasOffset = (MaterialWorldNormal * .2f + V * ViewBias) * (.75f * CellSize * ProbeOcclusionBias);
float3 TranslatedSamplePosition = TranslatedWorldPosition + BiasOffset;
이 bias는 normal bias와 view bias를 섞은 형태입니다. DDGI에서 흔히 쓰는 방식으로, self-occlusion을 줄이기 위한 목적입니다. 디버그 뷰의 노란색 십자가 바로 이 biased sample position입니다.
도구 코드 상단에는 “반드시 InterpolateIrradianceFromProbe와 동기화되어야 한다”는 주석을 달아 두는 것이 좋습니다. 또한 clipmap dither를 0으로 고정하면 마우스 픽킹 결과가 프레임마다 흔들리지 않아 디버깅하기 편합니다.
4.4 렌더링 파이프라인에 붙이기
새 shader 파일은 다음 위치에 추가합니다.
Engine/Shaders/Private/Lumen/LumenIrradianceFieldDebug.usf
entry point는 단일 스레드 compute shader인 IrradianceFieldDebugCS입니다. 구조는 대략 아래와 같습니다.
[numthreads(1, 1, 1)]
void IrradianceFieldDebugCS(uint3 DispatchThreadId : SV_DispatchThreadID)
{
FShaderPrintContext Context = InitShaderPrintContext(true, float2(0.05f, 0.05f));
// 1) 마우스 픽셀 해석
// 마우스 좌표가 없으면 뷰포트 중심으로 fallback
int2 CursorSvPosition = View.CursorPosition.x >= 0
? int2(floor(View.CursorPosition * View.ViewResolutionFraction))
: int2(View.ViewRectMinAndSize.xy + View.ViewRectMinAndSize.zw / 2);
// 2) GBuffer에서 월드 위치와 노멀 읽기
FLumenMaterialData Material = ReadMaterialData((uint2)CursorSvPosition, ScreenUV);
float3 TranslatedWorldPosition = GetTranslatedWorldPositionFromScreenUV(ScreenUV, Material.SceneDepth);
// 3) clipmap 선택, bias 적용, 8-probe 루프를 실제 보간 코드와 동일하게 복제
// 각 probe의 tri / wrap / chebyshev / valid 값을 계산하고
// AddSphereTWS / AddLineTWS / Print로 시각화
...
}
C++ 쪽에서는 LumenIrradianceFieldGather.cpp에 최소한 다음 요소를 추가하면 됩니다.
- cvar 2개
- r.Lumen.IrradianceFieldGather.Debug.ProbeInterpolation
- r.Lumen.IrradianceFieldGather.Debug.ProbeRadiusScale
- global shader 클래스 FIrradianceFieldDebugCS
- 기존 interpolation pass에서 사용하는 FRadianceCacheInterpolationParameters 재사용
- ProbeValid
- ProbeOcclusionBias
- ShaderPrint::FShaderParameters
- interpolation pass 뒤에 1×1×1 dispatch 추가
예시는 다음과 같습니다.
if (GLumenIrradianceFieldDebugProbeInterpolation != 0)
{
ShaderPrint::SetEnabled(true);
ShaderPrint::RequestSpaceForLines(8 * 1024u);
ShaderPrint::RequestSpaceForCharacters(4 * 1024u);
FIrradianceFieldDebugCS::FParameters PassParameters = GraphBuilder.AllocParameters<...>();
PassParameters->RadianceCacheParameters = RadianceCacheParameters;
PassParameters->ProbeValid = LumenRadianceCache::GetProbeValidSRV(GraphBuilder, ProbeValid);
PassParameters->ProbeOcclusionBias = LumenIrradianceFieldGather::GetProbeOcclusionBias();
PassParameters->DebugProbeRadiusScale = GLumenIrradianceFieldDebugProbeRadiusScale;
ShaderPrint::SetParameters(GraphBuilder, View.ShaderPrintData, PassParameters->ShaderPrintUniformBuffer);
FComputeShaderUtils::AddPass(
GraphBuilder,
RDG_EVENT_NAME("IrradianceFieldGather::DebugProbeInterpolation"),
View.ShaderMap->GetShader(),
PassParameters,
FIntVector(1, 1, 1));
}
여기서 중요한 점은 debug pass가 interpolation pass와 같은 RadianceCacheParameters를 재사용한다는 것입니다.
이 구조체 안에는 irradiance atlas, occlusion atlas, indirection texture, 각 clipmap의 corner/cell size 등이 이미 모두 들어 있습니다. 따라서 debug pass가 이 데이터를 그대로 샘플링하면, 실제 보간 pass가 보는 데이터와 완전히 동일한 데이터를 기준으로 디버깅할 수 있습니다.
부록: 관련 cvar 정리
:: Lite / DDGI 경로 활성화
r.Lumen.FinalGatherMethod 0
:: 이 디버그 도구
r.Lumen.IrradianceFieldGather.Debug.ProbeInterpolation 1
r.Lumen.IrradianceFieldGather.Debug.ProbeRadiusScale 8
r.ShaderPrint 1
:: probe 볼륨 형태 관련 옵션
r.Lumen.IrradianceFieldGather.NumClipmaps 4
r.Lumen.IrradianceFieldGather.GridResolution 64
r.Lumen.IrradianceFieldGather.ClipmapWorldExtent 5000
r.Lumen.IrradianceFieldGather.AdaptivePlacement 1
r.Lumen.IrradianceFieldGather.ProbeOcclusionBias 0.8
:: 엔진 기본 probe 시각화
r.Lumen.RadianceCache.Visualize 1 :: 1 = Radiance/Irradiance, 2 = SkyVisibility
r.Lumen.RadianceCache.VisualizeRadiusScale 2
r.Lumen.RadianceCache.VisualizeClipmapIndex -1
관련 소스 파일은 다음과 같습니다.
- Engine/Source/Runtime/Renderer/Private/Lumen/Lumen.cpp
- r.Lumen.FinalGatherMethod
- Engine/Source/Runtime/Renderer/Private/Lumen/LumenIrradianceFieldGather.cpp
- 파이프라인 및 디버그 도구 C++ 코드
- Engine/Shaders/Private/Lumen/LumenIrradianceFieldGather.usf
- InterpolateIrradianceProbesToPixelsCS
- Engine/Shaders/Private/Lumen/LumenIrradianceFieldInterpolation.ush
- 8-probe 보간, 가중치, Chebyshev visibility
- Engine/Shaders/Private/Lumen/LumenRadianceCacheInterpolation.ush
- Radiance Cache 및 occlusion atlas
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [공유] [EGSR 2026] Frequency-Aware Spatio-Angular Gaussians for Efficient Global Illumination Precomputation and Real-Time Rendering (0) | 2026.07.09 |
|---|---|
| [번역] 고급 API 성능: 셰이더 (1) | 2026.07.09 |
| [공유] 시그라프 2026 제네레이티브 AI 라이브 프리젠테이션 스케줄 (0) | 2026.07.08 |
| [공유] HONAMI ANIMATION SYSTEM for Unity. (0) | 2026.07.07 |
| [공유] DreamShader 프레임워크 for UnrealEngine (0) | 2026.07.06 |