Files
game_engine/docs/superpowers/specs/2026-03-25-phase7-2-ssgi.md
2026-03-25 13:25:11 +09:00

6.3 KiB

Phase 7-2: SSGI (Screen-Space Global Illumination) — Design Spec

Overview

디퍼드 파이프라인에 SSGI 포스트 프로세싱 패스를 추가한다. SSAO 확장형으로, 반구 샘플링을 통해 Ambient Occlusion과 Color Bleeding(간접광)을 동시에 계산한다.

Scope

  • SSGI 리소스 (반구 커널, 4x4 노이즈 텍스처, 출력 텍스처)
  • SSGI 풀스크린 셰이더 (AO + indirect color 계산)
  • SSGI 파이프라인 + 바인드 그룹 레이아웃
  • Lighting Pass 수정 (SSGI 결과를 ambient에 적용)
  • deferred_demo에 SSGI 통합

Out of Scope

  • 블러 패스 (노이즈 제거용 bilateral blur — 추후 추가)
  • 반해상도 렌더링 (성능 최적화)
  • 시간적 누적 (temporal accumulation)
  • Light Probes

Render Pass Flow (디퍼드 확장)

Pass 1: G-Buffer (기존, 변경 없음)
Pass 2: SSGI Pass (NEW) → Rgba16Float 출력
Pass 3: Lighting Pass (수정) → SSGI 텍스처 읽어서 ambient에 적용

Module Structure

새 파일

  • crates/voltex_renderer/src/ssgi.rs — SsgiResources, SsgiUniform, 커널/노이즈 생성
  • crates/voltex_renderer/src/ssgi_shader.wgsl — SSGI 풀스크린 셰이더

수정 파일

  • crates/voltex_renderer/src/deferred_pipeline.rs — SSGI 파이프라인 + 바인드 그룹 레이아웃 추가
  • crates/voltex_renderer/src/deferred_lighting.wgsl — SSGI 결과 적용
  • crates/voltex_renderer/src/lib.rs — ssgi 모듈 등록
  • examples/deferred_demo/src/main.rs — SSGI 패스 추가

Types

SsgiUniform (128 bytes)

#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct SsgiUniform {
    pub projection: [f32; 16],       // view → clip
    pub view: [f32; 16],             // world → view
    pub radius: f32,                  // 샘플링 반경 (기본 0.5)
    pub bias: f32,                    // depth 바이어스 (기본 0.025)
    pub intensity: f32,               // AO 강도 (기본 1.0)
    pub indirect_strength: f32,       // color bleeding 강도 (기본 0.5)
}

SsgiResources

pub struct SsgiResources {
    pub output_view: TextureView,     // Rgba16Float — R=AO, G=indirect_r, B=indirect_g, A=indirect_b
    pub kernel_buffer: Buffer,        // 64 * vec4 = 1024 bytes (반구 샘플)
    pub noise_view: TextureView,      // 4x4 Rgba16Float (랜덤 회전 벡터)
    pub uniform_buffer: Buffer,       // SsgiUniform
    pub width: u32,
    pub height: u32,
}
  • new(device, width, height) — 리소스 생성, 커널/노이즈 초기화
  • resize(device, width, height) — 출력 텍스처 재생성

반구 커널 생성

64개 샘플, 반구(+z 방향) 내 랜덤 분포. 중심 가까이에 더 많은 샘플 (코사인 가중):

fn generate_kernel(count: usize) -> Vec<[f32; 4]> {
    // 의사 랜덤 (시드 고정)
    // 각 샘플: normalize(random_in_hemisphere) * lerp(0.1, 1.0, scale^2)
    // scale = i / count
}

4x4 노이즈 텍스처

16개 랜덤 회전 벡터 (xy 평면). TBN 구성 시 tangent 방향을 랜덤화하여 밴딩 방지.

fn generate_noise() -> Vec<[f32; 4]> {
    // 16개 vec4(random_x, random_y, 0.0, 0.0)
}

SSGI Shader (ssgi_shader.wgsl)

바인드 그룹

Group 0: G-Buffer

  • binding 0: position texture (Float, non-filterable)
  • binding 1: normal texture (Float, filterable)
  • binding 2: albedo texture (Float, filterable)
  • binding 3: sampler (NonFiltering)

Group 1: SSGI Data

  • binding 0: SsgiUniform
  • binding 1: kernel buffer (storage or uniform, 64 * vec4)
  • binding 2: noise texture
  • binding 3: noise sampler

알고리즘

@fragment
fn fs_main(uv):
    world_pos = sample(t_position, uv)
    if length(world_pos) < 0.001: discard (background)

    normal = sample(t_normal, uv)

    // View space conversion
    view_pos = (ssgi.view * vec4(world_pos, 1.0)).xyz
    view_normal = normalize((ssgi.view * vec4(normal, 0.0)).xyz)

    // Random rotation from noise (4x4 tiling)
    noise_uv = uv * vec2(width/4.0, height/4.0)
    random_vec = sample(t_noise, noise_uv).xyz

    // Construct TBN in view space
    tangent = normalize(random_vec - view_normal * dot(random_vec, view_normal))
    bitangent = cross(view_normal, tangent)
    TBN = mat3x3(tangent, bitangent, view_normal)

    occlusion = 0.0
    indirect = vec3(0.0)

    for i in 0..64:
        // Sample position in view space
        sample_offset = TBN * kernel[i].xyz * ssgi.radius
        sample_pos = view_pos + sample_offset

        // Project to screen
        clip = ssgi.projection * vec4(sample_pos, 1.0)
        screen_uv = clip.xy / clip.w * 0.5 + 0.5
        screen_uv.y = 1.0 - screen_uv.y

        // Read actual depth at that screen position
        sample_world_pos = sample(t_position, screen_uv).xyz
        sample_view_pos = (ssgi.view * vec4(sample_world_pos, 1.0)).xyz

        // Occlusion check
        range_check = smoothstep(0.0, 1.0, ssgi.radius / abs(view_pos.z - sample_view_pos.z))
        if sample_view_pos.z >= sample_pos.z + ssgi.bias:
            occlusion += range_check
            // Color bleeding: read albedo at occluder position
            sample_albedo = sample(t_albedo, screen_uv).rgb
            indirect += sample_albedo * range_check

    ao = 1.0 - (occlusion / 64.0) * ssgi.intensity
    indirect = indirect / 64.0 * ssgi.indirect_strength

    return vec4(ao, indirect)

Lighting Pass 수정

바인드 그룹 변경

기존 Group 2 (Shadow+IBL, 5 bindings)에 SSGI 출력 추가:

  • binding 5: SSGI output texture (Float, filterable)
  • binding 6: SSGI sampler

셰이더 변경

// 기존
let ambient = (diffuse_ibl + specular_ibl) * ao;

// 변경
let ssgi_data = textureSample(t_ssgi, s_ssgi, in.uv);
let ssgi_ao = ssgi_data.r;
let indirect_light = ssgi_data.gba;
let ambient = (diffuse_ibl + specular_ibl) * ao * ssgi_ao + indirect_light;

Bind Group Constraint (max 4)

SSGI Pass: 2 groups (0: G-Buffer, 1: SSGI data) — OK

Lighting Pass: 기존 3 groups. Group 2에 SSGI binding 추가 (5,6) — 같은 그룹 내 binding 추가이므로 group 수 변화 없음. OK.

Test Plan

ssgi.rs

  • generate_kernel: 64개 샘플, 모두 반구 내 (z >= 0), 정규화됨
  • generate_noise: 16개 벡터
  • SsgiResources 생성/리사이즈

통합 (수동)

  • deferred_demo에서 SSGI ON/OFF 비교
  • 구석/틈에서 AO 어두워짐 확인
  • 밝은 물체 근처에서 color bleeding 확인