From b8334ea361b1c771c1fef99cb7b94a8b146ca3b0 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 13:55:09 +0900 Subject: [PATCH] docs: add Phase 7-4 post processing status, spec, plan, and deferred items Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/DEFERRED.md | 8 + docs/STATUS.md | 13 +- .../2026-03-25-phase7-4-post-processing.md | 722 ++++++++++++++++++ .../2026-03-25-phase7-4-post-processing.md | 219 ++++++ 4 files changed, 959 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-25-phase7-4-post-processing.md create mode 100644 docs/superpowers/specs/2026-03-25-phase7-4-post-processing.md diff --git a/docs/DEFERRED.md b/docs/DEFERRED.md index 02ce0f3..df31b45 100644 --- a/docs/DEFERRED.md +++ b/docs/DEFERRED.md @@ -62,6 +62,14 @@ - **raycast_all (다중 hit)** — 가장 가까운 hit만 반환. - **BVH 조기 종료 최적화** — 모든 리프 검사 후 최소 t 선택. front-to-back 순회 미구현. +## Phase 7-4 + +- **TAA** — Temporal Anti-Aliasing 미구현. Motion vector 필요. +- **SSR** — Screen-Space Reflections 미구현. +- **Motion Blur, DOF** — 미구현. +- **Auto Exposure** — 고정 exposure만. 적응형 노출 미구현. +- **Bilateral Bloom Blur** — 단순 box/tent filter. Kawase blur 미적용. + ## Phase 7-3 - **RT Reflections** — 미구현. BLAS/TLAS 인프라 재사용 가능. diff --git a/docs/STATUS.md b/docs/STATUS.md index 5f35b33..48b492a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -113,6 +113,13 @@ - voltex_renderer: Lighting pass RT shadow integration - deferred_demo updated with hardware RT shadows (4-pass: GBuffer → SSGI → RT Shadow → Lighting) +### Phase 7-4: Post Processing (HDR + Bloom + ACES) +- voltex_renderer: HdrTarget (Rgba16Float), Lighting → HDR output +- voltex_renderer: BloomResources (5-level mip chain, downsample/upsample) +- voltex_renderer: Bloom shader (bright extract + tent filter upsample) +- voltex_renderer: Tonemap shader (ACES filmic + bloom merge + gamma) +- deferred_demo updated with full post-processing (7 passes) + ## Crate 구조 ``` @@ -126,7 +133,7 @@ crates/ └── voltex_audio — AudioClip, WAV parser, mixing, WASAPI backend, AudioSystem, MixGroup, spatial ``` -## 테스트: 206개 전부 통과 +## 테스트: 213개 전부 통과 - voltex_asset: 14 - voltex_audio: 35 (audio_clip 2 + wav 5 + mixing 11 + audio_system 2 + spatial 8 + mix_group 7) @@ -134,7 +141,7 @@ crates/ - voltex_math: 37 (29 + AABB 6 + Ray 2) - voltex_physics: 52 (collider 2 + narrow 11 + bvh 5 + collision 7 + rigid_body 3 + integrator 3 + solver 5 + ray 10 + raycast 6) - voltex_platform: 3 -- voltex_renderer: 26 (20 + SSGI 3 + RT 3) +- voltex_renderer: 33 (20 + SSGI 3 + RT 3 + bloom 3 + tonemap 4) ## Examples (11개) @@ -150,7 +157,7 @@ crates/ - audio_demo — 사인파 오디오 재생 - deferred_demo — 디퍼드 렌더링 + 다중 포인트 라이트 -## 다음: Phase 7-4 (포스트 프로세싱) — Stretch Goal +## 다음: Phase 8 (AI, 네트워킹, 스크립팅, 에디터) — Stretch Goal 스펙 참조: `docs/superpowers/specs/2026-03-24-voltex-engine-design.md` diff --git a/docs/superpowers/plans/2026-03-25-phase7-4-post-processing.md b/docs/superpowers/plans/2026-03-25-phase7-4-post-processing.md new file mode 100644 index 0000000..4d11ff7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase7-4-post-processing.md @@ -0,0 +1,722 @@ +# Phase 7-4: HDR + Bloom + ACES Tonemap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** HDR 파이프라인으로 전환하고 Bloom + ACES 톤매핑으로 AAA 비주얼 품질 달성 + +**Architecture:** Lighting Pass 출력을 Rgba16Float HDR 텍스처로 변경. Bloom 패스(bright extract + 5-level downsample/upsample chain). Tonemap 패스(HDR + Bloom → ACES + 감마 → surface). 총 9 bloom sub-passes + 1 tonemap pass 추가. + +**Tech Stack:** Rust, wgpu 28.0, WGSL + +**Spec:** `docs/superpowers/specs/2026-03-25-phase7-4-post-processing.md` + +--- + +## File Structure + +### 새 파일 +- `crates/voltex_renderer/src/hdr.rs` — HdrTarget (Create) +- `crates/voltex_renderer/src/bloom.rs` — BloomResources, BloomUniform, mip chain (Create) +- `crates/voltex_renderer/src/bloom_shader.wgsl` — downsample (bright extract) + upsample (Create) +- `crates/voltex_renderer/src/tonemap.rs` — TonemapUniform (Create) +- `crates/voltex_renderer/src/tonemap_shader.wgsl` — ACES + bloom merge + gamma (Create) + +### 수정 파일 +- `crates/voltex_renderer/src/deferred_pipeline.rs` — bloom/tonemap 파이프라인 추가 (Modify) +- `crates/voltex_renderer/src/deferred_lighting.wgsl` — Reinhard+감마 제거 (Modify) +- `crates/voltex_renderer/src/lib.rs` — 새 모듈 등록 (Modify) +- `examples/deferred_demo/src/main.rs` — HDR+Bloom+Tonemap 통합 (Modify) + +--- + +## Task 1: HdrTarget + Bloom/Tonemap 리소스 + +**Files:** +- Create: `crates/voltex_renderer/src/hdr.rs` +- Create: `crates/voltex_renderer/src/bloom.rs` +- Create: `crates/voltex_renderer/src/tonemap.rs` +- Modify: `crates/voltex_renderer/src/lib.rs` + +- [ ] **Step 1: hdr.rs 작성** + +```rust +// crates/voltex_renderer/src/hdr.rs +pub const HDR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; + +pub struct HdrTarget { + pub view: wgpu::TextureView, + pub width: u32, + pub height: u32, +} + +impl HdrTarget { + pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { + let view = create_hdr_texture(device, width, height); + Self { view, width, height } + } + + pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) { + self.view = create_hdr_texture(device, width, height); + self.width = width; + self.height = height; + } +} + +fn create_hdr_texture(device: &wgpu::Device, w: u32, h: u32) -> wgpu::TextureView { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("HDR Target"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: HDR_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + tex.create_view(&wgpu::TextureViewDescriptor::default()) +} +``` + +- [ ] **Step 2: bloom.rs 작성** + +```rust +// crates/voltex_renderer/src/bloom.rs +use bytemuck::{Pod, Zeroable}; +use wgpu::util::DeviceExt; + +pub const BLOOM_MIP_COUNT: usize = 5; + +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct BloomUniform { + pub threshold: f32, + pub soft_threshold: f32, + pub _padding: [f32; 2], +} + +impl Default for BloomUniform { + fn default() -> Self { + Self { + threshold: 1.0, + soft_threshold: 0.5, + _padding: [0.0; 2], + } + } +} + +pub struct BloomResources { + pub mip_views: Vec, + pub uniform_buffer: wgpu::Buffer, + pub intensity: f32, +} + +impl BloomResources { + pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { + let mip_views = create_mip_chain(device, width, height); + let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Bloom Uniform"), + contents: bytemuck::bytes_of(&BloomUniform::default()), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + Self { mip_views, uniform_buffer, intensity: 0.5 } + } + + pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) { + self.mip_views = create_mip_chain(device, width, height); + } +} + +fn create_mip_chain(device: &wgpu::Device, width: u32, height: u32) -> Vec { + let sizes = mip_sizes(width, height); + sizes.iter().enumerate().map(|(i, (w, h))| { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some(&format!("Bloom Mip {}", i)), + size: wgpu::Extent3d { width: *w, height: *h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: crate::hdr::HDR_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + tex.create_view(&wgpu::TextureViewDescriptor::default()) + }).collect() +} + +/// Calculate mip chain sizes (5 levels, each half of previous). +pub fn mip_sizes(width: u32, height: u32) -> Vec<(u32, u32)> { + let mut sizes = Vec::with_capacity(BLOOM_MIP_COUNT); + let mut w = (width / 2).max(1); + let mut h = (height / 2).max(1); + for _ in 0..BLOOM_MIP_COUNT { + sizes.push((w, h)); + w = (w / 2).max(1); + h = (h / 2).max(1); + } + sizes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mip_sizes() { + let sizes = mip_sizes(1920, 1080); + assert_eq!(sizes.len(), 5); + assert_eq!(sizes[0], (960, 540)); + assert_eq!(sizes[1], (480, 270)); + assert_eq!(sizes[2], (240, 135)); + assert_eq!(sizes[3], (120, 67)); + assert_eq!(sizes[4], (60, 33)); + } + + #[test] + fn test_mip_sizes_small() { + let sizes = mip_sizes(64, 64); + assert_eq!(sizes[0], (32, 32)); + assert_eq!(sizes[4], (2, 2)); + } + + #[test] + fn test_bloom_uniform_default() { + let u = BloomUniform::default(); + assert!((u.threshold - 1.0).abs() < 1e-5); + } +} +``` + +- [ ] **Step 3: tonemap.rs 작성** + +```rust +// crates/voltex_renderer/src/tonemap.rs +use bytemuck::{Pod, Zeroable}; + +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct TonemapUniform { + pub bloom_intensity: f32, + pub exposure: f32, + pub _padding: [f32; 2], +} + +impl Default for TonemapUniform { + fn default() -> Self { + Self { + bloom_intensity: 0.5, + exposure: 1.0, + _padding: [0.0; 2], + } + } +} + +/// ACES tonemap (CPU version for testing). +pub fn aces_tonemap(x: f32) -> f32 { + let a = 2.51; + let b = 0.03; + let c = 2.43; + let d = 0.59; + let e = 0.14; + ((x * (a * x + b)) / (x * (c * x + d) + e)).clamp(0.0, 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_aces_zero() { + assert!((aces_tonemap(0.0)).abs() < 1e-5); + } + + #[test] + fn test_aces_one() { + let v = aces_tonemap(1.0); + assert!(v > 0.5 && v < 0.7, "aces(1.0) = {}, expected ~0.59", v); + } + + #[test] + fn test_aces_high() { + let v = aces_tonemap(10.0); + assert!(v > 0.95, "aces(10.0) = {}, expected ~1.0", v); + } + + #[test] + fn test_tonemap_uniform_default() { + let u = TonemapUniform::default(); + assert!((u.exposure - 1.0).abs() < 1e-5); + assert!((u.bloom_intensity - 0.5).abs() < 1e-5); + } +} +``` + +- [ ] **Step 4: lib.rs에 모듈 등록** + +```rust +pub mod hdr; +pub mod bloom; +pub mod tonemap; + +pub use hdr::{HdrTarget, HDR_FORMAT}; +pub use bloom::{BloomResources, BloomUniform, mip_sizes, BLOOM_MIP_COUNT}; +pub use tonemap::{TonemapUniform, aces_tonemap}; +``` + +- [ ] **Step 5: 빌드 + 테스트** + +Run: `cargo test -p voltex_renderer` +Expected: 기존 26 + 7 = 33 PASS + +- [ ] **Step 6: 커밋** + +```bash +git add crates/voltex_renderer/src/hdr.rs crates/voltex_renderer/src/bloom.rs crates/voltex_renderer/src/tonemap.rs crates/voltex_renderer/src/lib.rs +git commit -m "feat(renderer): add HDR target, Bloom resources, and ACES tonemap" +``` + +--- + +## Task 2: Bloom + Tonemap 셰이더 + +**Files:** +- Create: `crates/voltex_renderer/src/bloom_shader.wgsl` +- Create: `crates/voltex_renderer/src/tonemap_shader.wgsl` + +- [ ] **Step 1: bloom_shader.wgsl 작성** + +Single shader with two entry points: downsample (bright extract on first pass) and upsample. + +```wgsl +// Bloom shader: downsample with bright extract + upsample with additive blend + +struct BloomUniform { + threshold: f32, + soft_threshold: f32, +}; + +@group(0) @binding(0) var t_input: texture_2d; +@group(0) @binding(1) var s_input: sampler; +@group(0) @binding(2) var bloom: BloomUniform; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(@location(0) position: vec2) -> VertexOutput { + var out: VertexOutput; + out.clip_position = vec4(position, 0.0, 1.0); + out.uv = vec2(position.x * 0.5 + 0.5, 1.0 - (position.y * 0.5 + 0.5)); + return out; +} + +// Downsample with optional bright extract (first pass uses threshold, subsequent passes threshold=0) +@fragment +fn fs_downsample(in: VertexOutput) -> @location(0) vec4 { + let texel_size = 1.0 / vec2(textureDimensions(t_input)); + + // 4-sample box filter with offset for better quality + let a = textureSample(t_input, s_input, in.uv + texel_size * vec2(-1.0, -1.0)).rgb; + let b = textureSample(t_input, s_input, in.uv + texel_size * vec2( 1.0, -1.0)).rgb; + let c = textureSample(t_input, s_input, in.uv + texel_size * vec2(-1.0, 1.0)).rgb; + let d = textureSample(t_input, s_input, in.uv + texel_size * vec2( 1.0, 1.0)).rgb; + let center = textureSample(t_input, s_input, in.uv).rgb; + + var color = (a + b + c + d + center * 4.0) / 8.0; + + // Bright extract (only effective when threshold > 0) + if bloom.threshold > 0.0 { + let luminance = dot(color, vec3(0.2126, 0.7152, 0.0722)); + let contribution = max(luminance - bloom.soft_threshold, 0.0) / max(luminance, 0.0001); + color = color * contribution; + } + + return vec4(color, 1.0); +} + +// Upsample with bilinear + additive blend (reads from lower mip) +@fragment +fn fs_upsample(in: VertexOutput) -> @location(0) vec4 { + let texel_size = 1.0 / vec2(textureDimensions(t_input)); + + // 9-tap tent filter for smooth upsample + var color = textureSample(t_input, s_input, in.uv).rgb * 4.0; + color += textureSample(t_input, s_input, in.uv + vec2( texel_size.x, 0.0)).rgb * 2.0; + color += textureSample(t_input, s_input, in.uv + vec2(-texel_size.x, 0.0)).rgb * 2.0; + color += textureSample(t_input, s_input, in.uv + vec2(0.0, texel_size.y)).rgb * 2.0; + color += textureSample(t_input, s_input, in.uv + vec2(0.0, -texel_size.y)).rgb * 2.0; + color += textureSample(t_input, s_input, in.uv + vec2( texel_size.x, texel_size.y)).rgb; + color += textureSample(t_input, s_input, in.uv + vec2(-texel_size.x, texel_size.y)).rgb; + color += textureSample(t_input, s_input, in.uv + vec2( texel_size.x, -texel_size.y)).rgb; + color += textureSample(t_input, s_input, in.uv + vec2(-texel_size.x, -texel_size.y)).rgb; + color /= 16.0; + + return vec4(color, 1.0); +} +``` + +- [ ] **Step 2: tonemap_shader.wgsl 작성** + +```wgsl +// Tonemap pass: combine HDR + Bloom, apply ACES tonemap + gamma + +struct TonemapUniform { + bloom_intensity: f32, + exposure: f32, +}; + +@group(0) @binding(0) var t_hdr: texture_2d; +@group(0) @binding(1) var t_bloom: texture_2d; +@group(0) @binding(2) var s_sampler: sampler; +@group(0) @binding(3) var tonemap: TonemapUniform; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(@location(0) position: vec2) -> VertexOutput { + var out: VertexOutput; + out.clip_position = vec4(position, 0.0, 1.0); + out.uv = vec2(position.x * 0.5 + 0.5, 1.0 - (position.y * 0.5 + 0.5)); + return out; +} + +fn aces_tonemap(x: vec3) -> vec3 { + let a = 2.51; + let b = 0.03; + let c = 2.43; + let d = 0.59; + let e = 0.14; + return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3(0.0), vec3(1.0)); +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let hdr_color = textureSample(t_hdr, s_sampler, in.uv).rgb; + let bloom_color = textureSample(t_bloom, s_sampler, in.uv).rgb; + + // Combine HDR + Bloom + let combined = hdr_color + bloom_color * tonemap.bloom_intensity; + + // Apply exposure + let exposed = combined * tonemap.exposure; + + // ACES tonemap + let tonemapped = aces_tonemap(exposed); + + // Gamma correction (linear → sRGB) + let gamma = pow(tonemapped, vec3(1.0 / 2.2)); + + return vec4(gamma, 1.0); +} +``` + +- [ ] **Step 3: 커밋** + +```bash +git add crates/voltex_renderer/src/bloom_shader.wgsl crates/voltex_renderer/src/tonemap_shader.wgsl +git commit -m "feat(renderer): add bloom and tonemap shaders" +``` + +--- + +## Task 3: Bloom + Tonemap 파이프라인 + Lighting HDR 전환 + +**Files:** +- Modify: `crates/voltex_renderer/src/deferred_pipeline.rs` +- Modify: `crates/voltex_renderer/src/deferred_lighting.wgsl` + +- [ ] **Step 1: deferred_pipeline.rs에 bloom/tonemap 파이프라인 함수 추가** + +```rust +use crate::hdr::HDR_FORMAT; +use crate::fullscreen_quad::FullscreenVertex; + +/// Bloom pass bind group layout (shared for downsample and upsample). +/// Group 0: input texture + sampler + BloomUniform +pub fn bloom_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Bloom BGL"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }) +} + +/// Create bloom downsample pipeline (entry point: fs_downsample). +pub fn create_bloom_downsample_pipeline( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, +) -> wgpu::RenderPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Bloom Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("bloom_shader.wgsl").into()), + }); + let pipe_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Bloom Downsample Layout"), + bind_group_layouts: &[layout], + immediate_size: 0, + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Bloom Downsample"), + layout: Some(&pipe_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[FullscreenVertex::LAYOUT], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_downsample"), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) +} + +/// Create bloom upsample pipeline (entry point: fs_upsample). +/// Output blends additively with the render target. +pub fn create_bloom_upsample_pipeline( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, +) -> wgpu::RenderPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Bloom Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("bloom_shader.wgsl").into()), + }); + let pipe_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Bloom Upsample Layout"), + bind_group_layouts: &[layout], + immediate_size: 0, + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Bloom Upsample"), + layout: Some(&pipe_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[FullscreenVertex::LAYOUT], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_upsample"), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent::REPLACE, + }), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) +} + +/// Tonemap pass bind group layout. +pub fn tonemap_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Tonemap BGL"), + entries: &[ + wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false }, count: None }, + wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false }, count: None }, + wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None }, + wgpu::BindGroupLayoutEntry { binding: 3, visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None }, + ], + }) +} + +/// Create tonemap pipeline. +pub fn create_tonemap_pipeline( + device: &wgpu::Device, + surface_format: wgpu::TextureFormat, + layout: &wgpu::BindGroupLayout, +) -> wgpu::RenderPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Tonemap Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("tonemap_shader.wgsl").into()), + }); + let pipe_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Tonemap Layout"), + bind_group_layouts: &[layout], + immediate_size: 0, + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Tonemap Pipeline"), + layout: Some(&pipe_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[FullscreenVertex::LAYOUT], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) +} +``` + +- [ ] **Step 2: Lighting pipeline의 target format을 HDR로 변경** + +In `create_lighting_pipeline`, change the `surface_format` parameter usage: the function should accept `HDR_FORMAT` instead for the color target. The caller passes HDR_FORMAT. + +Actually, the function already takes `surface_format` as parameter. The caller (deferred_demo) will just pass `HDR_FORMAT` instead of the surface format. + +- [ ] **Step 3: deferred_lighting.wgsl에서 Reinhard + 감마 제거** + +Remove the last 4 lines before `return`: +```wgsl +// REMOVE these lines: +// color = color / (color + vec3(1.0)); // Reinhard +// color = pow(color, vec3(1.0 / 2.2)); // Gamma +``` + +The output is now raw HDR linear color. + +- [ ] **Step 4: 빌드 확인** + +Run: `cargo build -p voltex_renderer` +Expected: 컴파일 성공 + +- [ ] **Step 5: 커밋** + +```bash +git add crates/voltex_renderer/src/deferred_pipeline.rs crates/voltex_renderer/src/deferred_lighting.wgsl +git commit -m "feat(renderer): add bloom/tonemap pipelines and convert lighting to HDR output" +``` + +--- + +## Task 4: deferred_demo에 HDR + Bloom + Tonemap 통합 + +**Files:** +- Modify: `examples/deferred_demo/src/main.rs` + +이 태스크는 opus 모델로 실행. + +변경사항: +1. HdrTarget 생성 (full resolution) +2. BloomResources 생성 +3. Bloom 파이프라인 2개 (downsample + upsample) + bind group layout +4. Tonemap 파이프라인 + bind group layout + TonemapUniform buffer +5. Lighting Pass를 HDR texture에 렌더 (surface 대신) +6. Bloom Pass: 9 sub-passes (1 bright extract downsample + 4 downsample + 4 upsample) +7. Tonemap Pass: HDR + bloom mip[0] → ACES + gamma → surface +8. 리사이즈 시 HDR + Bloom 재생성 +9. Bloom/Tonemap bind groups 생성 (각 sub-pass마다 다른 입출력) + +- [ ] **Step 1: deferred_demo 수정** + +- [ ] **Step 2: 빌드 확인** + +Run: `cargo build --bin deferred_demo` + +- [ ] **Step 3: 커밋** + +```bash +git add examples/deferred_demo/src/main.rs +git commit -m "feat(renderer): add HDR + Bloom + ACES tonemap to deferred_demo" +``` + +--- + +## Task 5: 문서 업데이트 + +**Files:** +- Modify: `docs/STATUS.md` +- Modify: `docs/DEFERRED.md` + +- [ ] **Step 1: STATUS.md에 Phase 7-4 추가** + +```markdown +### Phase 7-4: Post Processing (HDR + Bloom + ACES) +- voltex_renderer: HdrTarget (Rgba16Float), Lighting → HDR output +- voltex_renderer: BloomResources (5-level mip chain, downsample/upsample) +- voltex_renderer: Bloom shader (bright extract + tent filter upsample) +- voltex_renderer: Tonemap shader (ACES filmic + bloom merge + gamma) +- deferred_demo updated with full post-processing pipeline (6 passes) +``` + +- [ ] **Step 2: DEFERRED.md에 Phase 7-4 미뤄진 항목** + +```markdown +## Phase 7-4 + +- **TAA** — Temporal Anti-Aliasing 미구현. Motion vector 필요. +- **SSR** — Screen-Space Reflections 미구현. +- **Motion Blur, DOF** — 미구현. +- **Auto Exposure** — 고정 exposure만. 적응형 노출 미구현. +- **Bilateral Bloom Blur** — 단순 box/tent filter. Kawase blur 미적용. +``` + +- [ ] **Step 3: 커밋** + +```bash +git add docs/STATUS.md docs/DEFERRED.md +git commit -m "docs: add Phase 7-4 post processing status and deferred items" +``` diff --git a/docs/superpowers/specs/2026-03-25-phase7-4-post-processing.md b/docs/superpowers/specs/2026-03-25-phase7-4-post-processing.md new file mode 100644 index 0000000..d0a9407 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-phase7-4-post-processing.md @@ -0,0 +1,219 @@ +# Phase 7-4: HDR + Bloom + ACES Tonemap — Design Spec + +## Overview + +디퍼드 파이프라인을 HDR로 전환하고, Bloom과 ACES 톤매핑 포스트 프로세싱을 추가한다. + +## Scope + +- HDR 렌더 타겟 (Rgba16Float) +- Lighting Pass HDR 출력 (Reinhard/감마 제거) +- Bloom (bright extract + downsample chain + upsample blend) +- ACES 톤매핑 + bloom 합성 + 감마 보정 +- deferred_demo에 HDR+Bloom+Tonemap 통합 + +## Out of Scope + +- TAA (Temporal Anti-Aliasing) +- SSR (Screen-Space Reflections) +- Motion Blur, Depth of Field +- Adaptive exposure / auto-exposure +- Filmic 등 다른 톤매핑 커브 + +## Render Pass Flow (최종) + +``` +Pass 1: G-Buffer (변경 없음) +Pass 2: SSGI (변경 없음) +Pass 3: RT Shadow (변경 없음) +Pass 4: Lighting → HDR texture (Rgba16Float) — 톤매핑/감마 제거 +Pass 5: Bloom (NEW) — bright extract → downsample chain → upsample chain +Pass 6: Tonemap (NEW) — HDR + Bloom → ACES tonemap + gamma → surface +``` + +## Module Structure + +### 새 파일 +- `crates/voltex_renderer/src/hdr.rs` — HdrTarget (Create) +- `crates/voltex_renderer/src/bloom.rs` — BloomResources, mip chain 생성 (Create) +- `crates/voltex_renderer/src/bloom_shader.wgsl` — downsample + upsample 셰이더 (Create) +- `crates/voltex_renderer/src/tonemap.rs` — TonemapUniform (Create) +- `crates/voltex_renderer/src/tonemap_shader.wgsl` — ACES + bloom merge + gamma (Create) + +### 수정 파일 +- `crates/voltex_renderer/src/deferred_pipeline.rs` — bloom/tonemap 파이프라인 추가 (Modify) +- `crates/voltex_renderer/src/deferred_lighting.wgsl` — Reinhard+감마 제거 (Modify) +- `crates/voltex_renderer/src/lib.rs` — 새 모듈 등록 (Modify) +- `examples/deferred_demo/src/main.rs` — HDR+Bloom+Tonemap 통합 (Modify) + +## Types + +### HdrTarget + +```rust +pub const HDR_FORMAT: TextureFormat = TextureFormat::Rgba16Float; + +pub struct HdrTarget { + pub view: TextureView, + pub width: u32, + pub height: u32, +} +``` +- `new(device, width, height)` — Rgba16Float, RENDER_ATTACHMENT | TEXTURE_BINDING +- `resize(device, width, height)` + +### BloomResources + +```rust +pub struct BloomResources { + pub mip_views: Vec, // 5 levels + pub threshold: f32, + pub intensity: f32, + pub bloom_uniform: Buffer, +} +``` + +**Mip chain**: 5단계, 각 레벨은 이전의 1/2 크기. +- Level 0: width/2 x height/2 +- Level 1: width/4 x height/4 +- Level 2: width/8 x height/8 +- Level 3: width/16 x height/16 +- Level 4: width/32 x height/32 + +각 mip은 Rgba16Float, RENDER_ATTACHMENT | TEXTURE_BINDING. + +### BloomUniform + +```rust +#[repr(C)] +pub struct BloomUniform { + pub threshold: f32, + pub soft_threshold: f32, // threshold - knee + pub _padding: [f32; 2], +} +``` + +### TonemapUniform + +```rust +#[repr(C)] +pub struct TonemapUniform { + pub bloom_intensity: f32, + pub exposure: f32, + pub _padding: [f32; 2], +} +``` + +## Bloom Algorithm + +### Pass 5a: Bright Extract + First Downsample + +입력: HDR 텍스처 (full res) +출력: Bloom mip 0 (half res) + +```wgsl +let color = textureSample(hdr, sampler, uv); +let luminance = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); +let contribution = max(luminance - threshold, 0.0); +let bloom_color = color.rgb * (contribution / (luminance + 0.0001)); +// 2x2 box filter (downsample) +output = bloom_color; +``` + +### Pass 5b: Downsample Chain (4회) + +mip N → mip N+1: 텍스처 샘플링으로 자연스럽게 2x 다운샘플. 13-tap box filter로 품질 향상. + +```wgsl +// 13-tap downsample (Karis average 스타일) +let a = textureSample(src, s, uv + vec2(-2, -2) * texel); +let b = textureSample(src, s, uv + vec2( 0, -2) * texel); +// ... (center + 4 corners + 4 edges + 4 diagonal) +output = weighted_average; +``` + +### Pass 5c: Upsample Chain (4회) + +mip N+1 → mip N: bilinear 업샘플 + additive blend with current level. + +```wgsl +let upsampled = textureSample(lower_mip, s, uv); +let current = textureSample(current_mip, s, uv); +output = current + upsampled; +``` + +### 최종 bloom = mip 0 (half res) + +## Tonemap Pass (Pass 6) + +입력: HDR 텍스처 + Bloom 텍스처 (mip 0) +출력: surface texture (sRGB) + +```wgsl +let hdr_color = textureSample(t_hdr, s, uv).rgb; +let bloom_color = textureSample(t_bloom, s, uv).rgb; +let combined = hdr_color + bloom_color * bloom_intensity; +let exposed = combined * exposure; +let tonemapped = aces_tonemap(exposed); +let gamma = pow(tonemapped, vec3(1.0 / 2.2)); +output = vec4(gamma, 1.0); +``` + +### ACES Filmic Tonemap + +```wgsl +fn aces_tonemap(x: vec3) -> vec3 { + let a = 2.51; + let b = 0.03; + let c = 2.43; + let d = 0.59; + let e = 0.14; + return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3(0.0), vec3(1.0)); +} +``` + +## Lighting Pass 수정 + +현재 `deferred_lighting.wgsl` 끝부분: +```wgsl +// Reinhard tone mapping +color = color / (color + vec3(1.0)); +// Gamma correction +color = pow(color, vec3(1.0 / 2.2)); +``` + +변경: 이 두 줄을 **제거**. raw HDR 값을 그대로 출력. + +Lighting pipeline의 color target format을 surface format → HDR_FORMAT(Rgba16Float)으로 변경. + +## Bind Group Details + +### Bloom Downsample/Upsample (각 패스) +- Group 0: 입력 텍스처 + sampler + BloomUniform (downsample 시) +단일 파이프라인을 재사용, 입력/출력만 변경. + +### Tonemap +- Group 0: HDR 텍스처 + bloom 텍스처 + sampler + TonemapUniform + +## Simplified Bloom (구현 단순화) + +복잡한 13-tap 대신, 첫 구현은: +1. Bright extract → mip 0 (half res, single fullscreen pass) +2. Downsample: 순차 4회 (bilinear sampler가 자동 2x2 평균) +3. Upsample: 순차 4회 (additive blend) +4. 총 9 fullscreen passes (1 extract + 4 down + 4 up) + +단일 셰이더 + 다른 바인드 그룹으로 반복 실행. + +## Test Plan + +### bloom.rs +- mip_sizes: 입력 크기에서 5단계 크기 계산 +- BloomResources 생성 + +### tonemap.rs +- ACES tonemap: 0→0, 1→~0.59, large→~1.0 + +### 통합 (수동) +- deferred_demo: bloom ON/OFF 비교 (밝은 라이트 주변 glow) +- exposure 조절 (어두움/밝음)