diff --git a/docs/superpowers/specs/2026-03-26-auto-exposure-design.md b/docs/superpowers/specs/2026-03-26-auto-exposure-design.md new file mode 100644 index 0000000..6041610 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-auto-exposure-design.md @@ -0,0 +1,116 @@ +# Auto Exposure Design + +## Overview + +컴퓨트 셰이더로 HDR 타겟의 평균 log-luminance를 계산하고, 시간 적응형 노출 조절을 톤맵에 적용한다. + +## Scope + +- 컴퓨트 셰이더: HDR 텍스처 → log-luminance 평균 (2-pass 다운샘플) +- AutoExposure 구조체: 노출 계산 + 시간 보간 +- tonemap_shader 수정: exposure uniform 적용 + +## AutoExposure + +```rust +pub struct AutoExposure { + reduce_pipeline: wgpu::ComputePipeline, + reduce_bind_group_layout: wgpu::BindGroupLayout, + luminance_buffer: wgpu::Buffer, // 최종 평균 luminance (f32 1개) + staging_buffer: wgpu::Buffer, // GPU→CPU readback + pub exposure: f32, + pub min_exposure: f32, + pub max_exposure: f32, + pub adaptation_speed: f32, + pub key_value: f32, // 목표 밝기 (0.18 기본) +} +``` + +## 알고리즘 + +### Pass 1: Log-Luminance 합산 (Compute) + +HDR 텍스처의 모든 픽셀에 대해: +``` +luminance = 0.2126*R + 0.7152*G + 0.0722*B +log_lum = log(max(luminance, 0.0001)) +``` + +워크그룹 내 shared memory로 합산, atomicAdd로 글로벌 버퍼에 누적. + +단순화: **단일 컴퓨트 디스패치**로 전체 텍스처를 16x16 워크그룹으로 처리. 각 워크그룹이 자기 영역의 합을 atomic으로 글로벌 버퍼에 더함. + +### Pass 2: CPU에서 평균 계산 + 노출 결정 + +```rust +let avg_log_lum = total_log_lum / pixel_count; +let avg_lum = exp(avg_log_lum); +let target_exposure = key_value / avg_lum; +let clamped = target_exposure.clamp(min_exposure, max_exposure); +// 시간 적응 (부드러운 전환) +exposure = lerp(exposure, clamped, 1.0 - exp(-dt * adaptation_speed)); +``` + +### Tonemap 수정 + +기존 tonemap_shader에 exposure 곱하기: +```wgsl +// 기존: let color = hdr_color; +// 변경: +let exposed = hdr_color * exposure; +// 그 다음 ACES tonemap +``` + +tonemap uniform에 `exposure: f32` 필드 추가. + +## Compute Shader (auto_exposure.wgsl) + +```wgsl +@group(0) @binding(0) var hdr_texture: texture_2d; +@group(0) @binding(1) var result: array>; +// result[0] = sum of log_lum * 1000 (fixed point) +// result[1] = pixel count + +@compute @workgroup_size(16, 16) +fn main(@builtin(global_invocation_id) gid: vec3) { + let dims = textureDimensions(hdr_texture); + if (gid.x >= dims.x || gid.y >= dims.y) { return; } + + let color = textureLoad(hdr_texture, vec2(gid.xy), 0); + let lum = 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b; + let log_lum = log(max(lum, 0.0001)); + + // Fixed-point: multiply by 1000 and cast to i32, then atomic add + let fixed = i32(log_lum * 1000.0); + atomicAdd(&result[0], u32(bitcast(fixed))); + atomicAdd(&result[1], 1u); +} +``` + +CPU에서 readback 후: +```rust +let sum_fixed = i32::from_le_bytes(data[0..4]); +let count = u32::from_le_bytes(data[4..8]); +let avg_log_lum = (sum_fixed as f32 / 1000.0) / count as f32; +``` + +## GPU→CPU Readback + +- luminance_buffer: STORAGE | COPY_SRC +- staging_buffer: MAP_READ | COPY_DST +- encoder.copy_buffer_to_buffer → staging +- staging.slice(..).map_async(MapMode::Read) → 다음 프레임에 읽기 (1프레임 지연 허용) + +1프레임 지연은 auto exposure에서 자연스러움 (사람 눈도 적응에 시간 걸림). + +## File Structure + +- `crates/voltex_renderer/src/auto_exposure.rs` — AutoExposure 구조체 +- `crates/voltex_renderer/src/auto_exposure.wgsl` — 컴퓨트 셰이더 +- `crates/voltex_renderer/src/lib.rs` — 모듈 추가 + +## Testing + +- exposure 계산 로직 (순수 수학): avg_lum → target_exposure → clamp → lerp +- min/max clamp 검증 +- adaptation lerp 검증 (dt=0 → 변화 없음, dt=큰값 → 목표에 수렴)