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

203 lines
6.3 KiB
Markdown

# 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)
```rust
#[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
```rust
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 방향) 내 랜덤 분포. 중심 가까이에 더 많은 샘플 (코사인 가중):
```rust
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 방향을 랜덤화하여 밴딩 방지.
```rust
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
### 셰이더 변경
```wgsl
// 기존
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 확인