-
Screen Space Global Illumination개인 프로젝트 2025. 7. 5. 22:37

개요
이 글에서는 Direct3D 11 / 12로 구현된 샘플을 통해 실시간으로 전역 조명 (Global Illumination, 이하 GI)을 근사하는 기법 중 하나인 Screen Space Global Illumination (이하 SSGI)에 대해서 알아보도록 하겠습니다.
SSGI 란?
SSGI는 이름에서 알 수 있듯 화면에 렌더링된 정보 (깊이, 색 등)를 통해 실시간으로 전역 조명을 계산합니다.
물체에 의한 빛의 차폐를 계산하는 Screen Space Ambient Occlusion (이하 SSAO)과 거의 유사하며 여타 화면 공간 기법들과 같이 장면의 복잡도에 영향을 받지 않는다는 장점과 화면 밖의 정보가 없어 이로 인한 아티팩트가 발생한다는 단점을 공통으로 가지고 있습니다.
이 때문인지 언리얼 엔진에서는 SSGI를 단독 GI 솔루션으로 사용하기보다 미리 계산된 라이트맵이나 다른 GI 기법과 함께 보조적으로 활용할 것을 권장하고 있습니다. 본 글의 샘플 구현 또한 이전에 구현해 본 GI 기법과 동시에 사용할 수 있도록 구현하였습니다.
구현 방식
SSGI는 화면의 한 점에서 일정 범위 내의 픽셀들을 샘플링하여 전역 조명을 계산합니다. 이런 샘플링 방식은 SSAO와 매우 유사하여 SSAO를 구현할 때 SSGI 계산과 동시에 처리할 수 있습니다.
SSAO는 품질을 높이기 위해 발전하면서 여러 기법이 소개되었고 각 기법에 따라 픽셀을 샘플링하는 방식에 차이가 있습니다. 본 구현에서는 ‘Screen Space Indirect Lighting with Visibility Bitmask’ 에서 소개된 샘플링 방식에 따라 SSGI를 구현하였습니다.
이 방식의 핵심은 Visibility Bitmask라는 개념입니다. 노멀 방향을 기준으로 반구를 균일한 각도의 여러 구역으로 분할한 뒤, 각 구역의 가시성 (차폐/비차폐 상태)을 비트 마스크로 표현합니다. 이를 통해 기존의 기법이 얇은 표면 주변에서 과도하게 어두워지거나, 표면 뒤를 통과하는 빛을 제대로 반영하지 못하는 한계를 개선할 수 있습니다.


전체 알고리즘은 논문에서 다음과 같은 수도코드 형식으로 제시되고 있습니다.

본 구현 역시 논문에서 제시된 수도코드를 AO 부분을 제외하고 그대로 따라 구현하였으니 참고하시기 바랍니다. 이제부터 실제 코드를 통해 SSGI 구현 과정을 살펴보겠습니다.
간접광 계산
간접광을 계산하는 컴퓨트 셰이더의 코드를 수도코드와 함께 하나씩 살펴보겠습니다.
메인 함수를 살펴보기 전 필요한 변수들부터 살펴보겠습니다.

Texture2D SceneColor : register( t0 ); // 씬 색상 Texture2D ViewSpaceDistance : register( t1 ); // 카메라 공간에서의 깊이 Texture2D WorldNormal : register( t2 ); // 월드 노멀 SamplerState BlackBorderSampler : register( s0 ); RWTexture2D<float4> SSGI : register( u0 ); // 출력 텍스쳐 float Thickness; // t : 두께 float ViewSpaceRadius; // 카메라 공간에서의 반지름 uint NumSlices; // 레이 마칭을 수행할 방향의 수 uint NumSteps; // 레이 마칭에서 광선이 전진하는 횟수 float2 ScreenSize; // 화면 크기 float2 InvScreenSize; // 화면 크기의 역수 값 float ColorIntensity; // GI 강도 static const uint BitmaskSize = 32; / Nb : 비트 마스크 크기이제 메인 함수를 보겠습니다. 먼저 출력 gi 변수를 0으로 초기화하고 현재 uv 좌표를 계산합니다.
[numthreads(8, 8, 1)] void main( uint3 DTid : SV_DispatchThreadID ) { float3 gi = (float3)0.f; float2 uv = ( DTid.xy + 0.5f ) * InvScreenSize;그리고 uv가 1보다 작은지 검사하여 유효한 인덱스임을 확인한 후 해당 인덱스에서 카메라 공간을 기준으로 한 위치와 노멀, 이미지 공간에서의 반지름 $r$을 계산합니다.

[branch] if ( all( uv < 1.f ) ) { float viewSpaceDistance = ViewSpaceDistance.SampleLevel( BlackBorderSampler, uv, 0 ).x; float3 viewPosition = GetViewPosition( uv, viewSpaceDistance ); float3 viewNormal = GetViewNormal( uv ); float halfWidth = ScreenSize.x * 0.5f; float radiusInPixel = ViewSpaceRadius * ProjectionMatrix[0][0] * halfWidth / viewPosition.z;이미지 공간에서의 반지름 $r$을 계산하는 과정은 복잡해 보일 수 있지만, 실제로는 카메라 공간의 좌표를 투영 공간 (즉, -1 ~ 1 사이의 좌표)으로 변환한 뒤, 여기에 이미지 크기를 곱하는 방식입니다.
이제 반지름 $r$이 1보다 큰 경우 (1보다 커야 샘플링할 픽셀이 존재합니다.) 레이마칭을 진행하며 GI를 계산합니다.

[branch] if ( radiusInPixel > 1.f ) { float stepSizeInPixel = DetermineStepSizeInPixel( radiusInPixel ); // 광선의 전진 거리를 결정합니다. uint numSteps = radiusInPixel / stepSizeInPixel; float2 stepSizeInUV = stepSizeInPixel * InvScreenSize; [loop] for ( int slice = 0; slice < NumSlices; ++slice ) { float t = ( float( slice ) + noise.x ) / float( NumSlices ); // 노이즈 값을 이용해 방향에 무작위 오프셋을 적용합니다. float theta = 2.f * PI * t; float2 dir = float2( cos( theta ), sin( theta ) ); float2 deltaUV = stepSizeInUV * normalize( dir ); gi += GenerateGI( uv, deltaUV, viewPosition, viewNormal, numSteps, noise.y ); } }GI를 계산하는 핵심 구현은 GenerateGI 함수에 구현되어 있습니다. 전체 코드는 다음과 같습니다.
float3 GenerateGI( float2 uv, float2 deltaUV, float3 p, float3 np, uint numSteps, float stepOffset ) { const float invHalfPI = 1.f / ( 0.5f * PI ); float2 sampleUV = uv + stepOffset * deltaUV; float3 viewVec = normalize( p ); float3 deltaThickness = viewVec * Thickness; float3 normal = -viewVec; float3 gi = (float3)0.f; uint bi = 0; [loop] for ( uint step = 0; step < numSteps; ++step ) { if ( any( sampleUV < 0.f ) || any( sampleUV > 1.f ) ) { break; } float viewSpaceDistance = ViewSpaceDistance.SampleLevel( BlackBorderSampler, sampleUV, 0 ).x; float3 sf = GetViewPosition( sampleUV, viewSpaceDistance ); float3 sb = sf + deltaThickness; float tf = acos( saturate( dot( normal, sf - p ) ) ); float tb = acos( saturate( dot( normal, sb - p ) ) ); float tmin = min( tf, tb ); float tmax = max( tf, tb ); uint a = floor( tmin * invHalfPI * BitmaskSize ); uint b = floor( ( tmax - tmin ) * invHalfPI * BitmaskSize ); uint bj = ( ( 1 << b ) - 1 ) << a; float3 cj = SceneColor.SampleLevel( BlackBorderSampler, sampleUV, 0 ).xyz; float3 nj = GetViewNormal( sampleUV ); float3 lj = normalize( sf - p ); gi += float( countbits( bj & ( ~bi ) ) ) / BitmaskSize * cj * saturate( dot( np, lj ) ) * saturate( dot( nj, -lj ) ); bi |= bj; sampleUV += deltaUV; } return gi; }여기서 Visibility Bitmask 계산 과정을 발췌해 살펴보겠습니다.
먼저 현재 카메라 공간의 위치 $s_f$ 에서 일정한 두께 $t$ 만큼 이동한 위치 $s_b$를 구합니다.

float3 sf = GetViewPosition( sampleUV, viewSpaceDistance ); float3 sb = sf + deltaThickness; // float3 deltaThickness = viewVec * Thickness;그리고 각 위치가 XY평면과 이루는 각도를 계산합니다.

float tf = acos( saturate( dot( normal, sf - p ) ) ); float tb = acos( saturate( dot( normal, sb - p ) ) ); float tmin = min( tf, tb ); float tmax = max( tf, tb );앞에서 계산한 0 ~ $\frac{\pi}{2}$범위의 각도를 0 ~ $N_b$ 범위의 값으로 매핑합니다.

uint a = floor( tmin * invHalfPI * BitmaskSize ); uint b = floor( ( tmax - tmin ) * invHalfPI * BitmaskSize ); // delta0 ~ $N_b$ 범위의 값으로 매핑된 a와 b통해서 다음과 같이 비트 마스크를 계산합니다.

uint bj = ( ( 1 << b ) - 1 ) << a;이 비트 마스크는 차폐된 범위를 나타내기 때문에 GI 계산에서 이미 차폐된 방향의 빛의 잘못된 기여를 제거해 줍니다. 이를 이용해 GI를 계산하는 코드는 다음과 같습니다.

gi += float( countbits( bj & ( ~bi ) ) ) / BitmaskSize * cj * saturate( dot( np, lj ) ) * saturate( dot( nj, -lj ) );여기서 $b_{i}$는 이전 레이 마칭 단계까지 차폐된 각도를 나타내는 비트 마스크입니다. 이 비트 마스크에 NOT 연산 (~)을 적용하면 차폐되지 않은 각도를 나타낼 수 있습니다. 이후 차폐되지 않은 각도와 현재 레이 마칭 단계에서 새롭게 차폐된 각도 간의 공통된 비트 개수를 세어 이를 바탕으로 현재 샘플의 GI기여도를 계산합니다. 이와 같은 과정으로 다음과 같은 장면을 얻을 수 있습니다.

계산된 GI만 따로 보면 다음과 같습니다.

노이즈 제거 (Denoising)
논문에 소개된 알고리즘을 그대로 구현한 결과, 노이즈가 많이 발생하여 현재 상태로는 품질이 만족스럽지 않습니다. 따라서 이러한 노이즈를 제거할 수 있는 후처리 단계가 추가로 필요합니다. 본 구현에서는 두 가지 방법을 적용하여 노이즈를 제거하였습니다.
쌍방 필터 (Bilateral filter)
계산된 GI 결과에서 일부 픽셀에는 간접광이 계산되어 있지만, 일부 픽셀은 간접광이 전혀 계산되지 않아 검은색으로 나타나는 현상이 관찰됩니다. 이를 통해 한 가지 인사이트를 얻을 수 있는데 노이즈를 제거하려면 검은색으로 비어 있는 픽셀을 채워줄 필요가 있다는 것입니다. 즉, 주변 픽셀의 값을 활용해 빈 영역을 자연스럽게 보완함으로써 전체 이미지의 품질을 높일 수 있을 것 같습니다.
여기서는 가우시안 블러를 적용하여 비어 있는 픽셀을 주변 픽셀의 값으로 채워주도록 하겠습니다.
[numthreads(8, 8, 1)] void main( uint3 DTid : SV_DispatchThreadID ) { float2 uv = ( DTid.xy + 0.5f ) * InvScreenSize; [branch] if ( all( uv < 1.f ) ) { float totalWeight = 1.f; float sigma = 0.5f * float( KernelRadius ); float3 color = SSGI.SampleLevel( BlackBorderSampler, uv, 0 ).rgb; [loop] for ( int y = -KernelRadius; y <= KernelRadius; ++y ) { [loop] for ( int x = -KernelRadius; x <= KernelRadius; ++x ) { if ( ( x == 0 ) && ( y == 0 ) ) { continue; } float2 sampleUV = ( DTid.xy + float2( x, y ) + 0.5f ) * InvScreenSize; float w = Gaussian( x, sigma ) * Gaussian( y, sigma ); float3 sampleColor = SSGI.SampleLevel( BlackBorderSampler, sampleUV, 0 ).rgb; color += sampleColor * w; totalWeight += w; } } color /= totalWeight; DenoisedSSGI[DTid.xy] = float4( color, 1.f ); } }핵심 코드는 가우시안 커널의 가중치를 계산하는 Gaussian 함수입니다. 이 함수는 가우스 함수를 기반으로 하며, 가우스 함수는 다음과 같이 정의됩니다.
$$f(x) = \frac{1}{\sigma \sqrt{2\pi}} \exp\left( -\frac{(x-\mu)^2}{2\sigma^2} \right)$$
여기서 $\frac{1}{\sigma \sqrt{2\pi}}$는 곡선의 최고점 (정규화 상수)이지만, 중심 픽셀의 가중치를 1로 둘 것이므로 생략할 수 있습니다. 또한 $\mu$는 곡선의 중심 위치로 가우시안 블러에서는 항상 0으로 설정할 수 있습니다. 따라서 최종적으로 사용할 함수는 다음과 같이 간략화됩니다.
$$f(x) = \exp\left( -\frac{x^2}{2\sigma^2} \right)$$
이 함수를 기반으로 다음과 같이 Gaussian 함수를 구현할 수 있습니다.
float Sqr( float x ) { return x * x; } float Gaussian( float x, float sigma ) { return exp( -Sqr( x / sigma ) ); }가우시안 블러를 적용한 GI결과는 다음과 같습니다.

적용 전과 비교했을 때, 가우시안 블러를 적용한 후 노이즈가 감소하며 이미지 품질이 개선된 것을 확인할 수 있습니다. 그러나 구와 배경의 경계 부분을 자세히 보면, 색상이 섞이면서 경계가 흐릿해지는 현상이 나타났습니다.
기하적으로 서로 분리된 영역의 간접광이 서로에게 영향을 주지 않도록 하기 위해서는, 가우시안 블러에 새로운 가중치를 적용할 필요가 있습니다. 이를 위해 픽셀 간의 깊이 차이를 추가적인 가중치로 활용하여 분리된 영역 간의 간섭을 줄일 수 있습니다. 이러한 필터를 쌍방 필터라고 합니다.
쌍방 필터는 이미지의 노이즈를 제거하면서도 경계를 보존하는 특징을 가진 필터입니다. 일반적인 가우시안 블러는 픽셀 간의 거리만을 고려해 값을 평균화하기 때문에 경계가 흐려지는 단점이 있습니다. 반면, 쌍방 필터는 두 픽셀 간의 거리뿐 아니라, 픽셀 값(명암, 색상, 깊이)의 차이까지 함께 고려하여 가중치를 계산합니다.
쌍방 필터로 변경된 코드는 다음과 같습니다.
// 깊이 값의 차이를 추가적인 가중치로 활용 float sampleLinearDepth = ViewSpaceDistance.SampleLevel( BlackBorderSampler, sampleUV, 0 ).x; float depthWeight = max( 0.f, 1.f - abs( sampleLinearDepth - linearDepth ) ); float w = Gaussian( x, sigma ) * Gaussian( y, sigma ) * depthWeight;결과적으로 다음과 같이 경계가 보존된 이미지를 얻을 수 있습니다.

쌍방 필터가 적용된 결과 장면은 다음과 같습니다.

시간 필터 (Temporal Filter)
쌍방 필터를 적용해 노이즈는 상당 부분 개선되었지만, 낮은 샘플링 횟수로 인한 얼룩과 같은 아티팩트가 여전히 남아있습니다. 따라서 샘플링 횟수를 늘릴 필요가 있지만, 단순히 샘플링 횟수를 무작정 증가시키는 것으로는 성능 대비 품질 향상에 한계가 있습니다.
그래서 여러 프레임의 픽셀 값을 활용하여 노이즈를 개선하는 시간 필터를 추가로 적용하였습니다. 기본적인 구조는 전에 소개하였던 Temporal Anti-Aliasing과 동일합니다. TAA에서 지터링을 줬던것 처럼 SSGI에서도 간접광 계산시 프레임 변수를 통해 프레임마다 다른 오프셋을 주어 간접광을 계산하도록 변경합니다.
float2 noise = SpatioTemporalNoise( DTid.xy, FrameCount );랜덤한 오프셋을 적용하면 매 프레임마다 서로 다른 샘플을 사용해 간접광을 계산할 수 있으므로, 부족한 샘플링 횟수를 효과적으로 보완할 수 있습니다.
이제 노이즈 제거 단계에서 현재 프레임과 이전 프레임의 GI 계산 결과를 결합합니다.
// 쌍방 필터 적용 이후 float2 velocity = VelocityTex.SampleLevel( BlackBorderSampler, uv, 0 ); float2 previousUV = uv - velocity; float3 prevColor = PrevSSGI.SampleLevel( BlackBorderSampler, previousUV, 0 ).rgb; float prevLinearDepth = PrevViewSpaceDistance.SampleLevel( BlackBorderSampler, previousUV, 0 ).x; float w = exp( -abs( prevLinearDepth - linearDepth ) / 10 ); color = lerp( color, prevColor, 0.9 * w );실시간 화면에 대응하기 위하여 가속도 버퍼를 사용합니다. 객체의 위치가 이동하는 경우, 이 버퍼를 활용해 샘플링 위치를 재계산할 수 있습니다. 또한 고스팅 현상을 방지하기 위해 깊이 값을 함께 사용합니다. 이전 프레임과 비교하여 깊이 값이 차이가 크게 나타나면, 해당 위치의 이전 프레임 데이터를 더 이상 유효하지 않은 것으로 판단하고 폐기합니다.
시간 필터까지 적용한 최종 SSGI 결과는 다음과 같습니다.

최종 합성
마지막으로 최종 SSGI 결과를 장면에 합성합니다.
본 샘플은 포워드 렌더러만 구현되어 있어 표면의 재질 정보를 얻기 위해 물체를 다시 렌더링해야 했습니다. 이를 위해 다음과 같이 렌더 상태를 설정하였습니다.
std::optional<DrawSnapshot> ForwardRendererCompositeSSGIPassProcessor::ProcessInternal( const PrimitiveSubMesh& subMesh, const PassShader& passShader ) { DepthStencilOption depthStencilOption; depthStencilOption.m_depth.m_depthFunc = agl::ComparisonFunc::LessEqual; depthStencilOption.m_depth.m_writeDepth = false; BlendOption blendOption; blendOption.m_renderTarget[0].m_blendEnable = true; blendOption.m_renderTarget[0].m_srcBlend = agl::Blend::One; blendOption.m_renderTarget[0].m_destBlend = agl::Blend::One; blendOption.m_renderTarget[0].m_blendOp = agl::BlendOp::Add; blendOption.m_renderTarget[0].m_srcBlendAlpha = agl::Blend::Zero; blendOption.m_renderTarget[0].m_destBlendAlpha = agl::Blend::One; blendOption.m_renderTarget[0].m_blendOpAlpha = agl::BlendOp::Add; PassRenderOption passRenderOption = { .m_blendOption = &blendOption, .m_depthStencilOption = &depthStencilOption, }; return BuildDrawSnapshot( subMesh, passShader, passRenderOption, VertexStreamLayoutType::Default ); }Early-Z 를 통해 보이는 픽셀만 계산되도록 깊이 검사를 LessEqual로 설정하고 SSGI 조명의 결과를 장면에 합성하기 위해 블렌드 상태를 Add로 설정하였습니다.
이제 물체를 다시 렌더링합니다. 여기에 사용된 픽셀 셰이더 코드는 다음과 같습니다.
float4 main( PS_INPUT input ) : SV_Target0 { float2 screenUV = ( input.projectionPos.xy / input.projectionPos.w ) * float2( 0.5f, -0.5f ) + 0.5f; // SSGI 결과 float4 ssgiColor = MoveLinearSpace( SSGITex.Sample( SSGITexSampler, screenUV ) ); // Diffuse 재질 float4 diffuseColor = (float4)0.f; #if UseDiffuseTexture == 1 #if SupportsBindless == 1 diffuseColor = MoveLinearSpace( Tex2D[DiffuseTex].Sample( Samplers[DiffuseTexSampler], input.texcoord ) ); #else diffuseColor = MoveLinearSpace( DiffuseTex.Sample( DiffuseTexSampler, input.texcoord ) ); #endif #else diffuseColor = MoveLinearSpace( Diffuse ); #endif // 조명 결과를 반영 diffuseColor *= ssgiColor; return float4( diffuseColor.rgb * diffuseColor.a, 1.f ); }앞서 설명한 모든 과정을 적용한 장면은 다음과 같습니다.

마치며
지금까지 SSGI에 대해 알아보았습니다. 동적인 장면에서 사용하려면 좋은 노이즈 제거 방식이 필수라고 생각되었습니다. 또한 SSGI 계산 시 텍스쳐를 무작위 오프셋으로 샘플링하는 경우 성능 저하가 심해서 이를 해결하기 위해 샘플링할 텍스쳐의 크기를 줄이는 등의 최적화가 필요해 보입니다. 하지만 SSGI 계산은 SSAO 계산과 동시에 진행할 수 있어 일정 부분 연산 비용을 숨기면서 GI를 계산할 수 있다는 점이 매력적인 부분으로 생각됩니다.
준비한 내용은 여기까지입니다. 마지막으로 예제 프로그램의 Github 링크를 첨부합니다. 세부 코드를 확인하고 싶으신 분은 참고 바랍니다.
https://github.com/xtozero/SSR/tree/ssgi
GitHub - xtozero/SSR: Screen Space Reflection
Screen Space Reflection. Contribute to xtozero/SSR development by creating an account on GitHub.
github.com
연관된 파일은 다음과 같습니다.
- cpp
- Source/RenderCore/Private/Renderer/SSGIRendering.cpp : SSGI 렌더링을 위한 cpp 코드 모음
- 셰이더
- Source/Shaders/Private/SSGI/CS_SSGI.fx : SSGI 계산
- Source/Shaders/Private/SSGI/CS_DenoiseSSGI.fx : SSGI 노이즈 제거
- Source/Shaders/Private/SSGI/PS_SSGIComposite.fx : SSGI 합성
Reference
- SSGI with Deinterleaved Rendering (Visibility Bitmask 기법을 처음 알게 된 글)
- A Comparative Study of Screen-Space Ambient Occlusion Methods (여러 SSAO 기법에 대한 간략한 소개)
- Screen Space Indirect Lighting with Visibility Bitmask
- Unity Biliteral Filter
'개인 프로젝트' 카테고리의 다른 글
Render Graph (0) 2025.10.17 Async Compute (0) 2025.10.06 PSO Cache (2) 2025.04.26 Mesh Shader (2) 2025.02.25 HLSL 2021 (2) 2024.12.15 - cpp