From 6c938999e4aacd932c2ea97e7083878a7e9ef6df Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 13:47:19 +0900 Subject: [PATCH] feat(renderer): add HDR target, Bloom resources, and ACES tonemap - Add hdr.rs with HdrTarget (Rgba16Float render target) and HDR_FORMAT constant - Add bloom.rs with BloomResources (5-level mip chain), BloomUniform, and mip_sizes() - Add tonemap.rs with TonemapUniform and CPU-side aces_tonemap() for testing - Export all new types from lib.rs - 33 tests passing (26 existing + 3 bloom + 4 tonemap) Co-Authored-By: Claude Sonnet 4.6 --- crates/voltex_renderer/src/bloom.rs | 145 ++++++++++++++++++++++++++ crates/voltex_renderer/src/hdr.rs | 43 ++++++++ crates/voltex_renderer/src/lib.rs | 6 ++ crates/voltex_renderer/src/tonemap.rs | 71 +++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 crates/voltex_renderer/src/bloom.rs create mode 100644 crates/voltex_renderer/src/hdr.rs create mode 100644 crates/voltex_renderer/src/tonemap.rs diff --git a/crates/voltex_renderer/src/bloom.rs b/crates/voltex_renderer/src/bloom.rs new file mode 100644 index 0000000..3ef3643 --- /dev/null +++ b/crates/voltex_renderer/src/bloom.rs @@ -0,0 +1,145 @@ +use bytemuck::{Pod, Zeroable}; +use crate::hdr::HDR_FORMAT; + +/// Number of bloom mip levels (downsample + upsample chain). +pub const BLOOM_MIP_COUNT: usize = 5; + +/// Uniform buffer for the bloom pass. +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct BloomUniform { + /// Luminance threshold above which a pixel contributes to bloom. + pub threshold: f32, + /// Soft knee for the threshold. + 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], + } + } +} + +/// GPU resources for the bloom pass (mip chain + uniform buffer). +pub struct BloomResources { + /// One `TextureView` per mip level (5 levels). + pub mip_views: Vec, + pub uniform_buffer: wgpu::Buffer, + /// Blend intensity applied during the tonemap pass. + pub intensity: f32, +} + +impl BloomResources { + pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { + let mip_views = create_mip_views(device, width, height); + + let uniform = BloomUniform::default(); + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Bloom Uniform Buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + // Initialize with defaults via a write at construction time would require a queue; + // callers may write the buffer themselves. The buffer is left zero-initialised here. + + Self { + mip_views, + uniform_buffer, + intensity: 0.5, + } + } + + /// Recreate the mip-chain textures when the window is resized. + pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) { + self.mip_views = create_mip_views(device, width, height); + } +} + +// ── Public helpers ──────────────────────────────────────────────────────────── + +/// Return the (width, height) of each bloom mip level. +/// +/// Level 0 = width/2 × height/2; each subsequent level halves again. +/// Sizes are clamped to a minimum of 1. +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 +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +fn create_mip_views(device: &wgpu::Device, width: u32, height: u32) -> Vec { + let sizes = mip_sizes(width, height); + sizes + .into_iter() + .enumerate() + .map(|(i, (w, h))| { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(&format!("Bloom Mip {} Texture", 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: HDR_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + texture.create_view(&wgpu::TextureViewDescriptor::default()) + }) + .collect() +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mip_sizes_1920_1080() { + let sizes = mip_sizes(1920, 1080); + assert_eq!(sizes.len(), BLOOM_MIP_COUNT); + 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 mip_sizes_64_64() { + let sizes = mip_sizes(64, 64); + assert_eq!(sizes.len(), BLOOM_MIP_COUNT); + assert_eq!(sizes[0], (32, 32)); + assert_eq!(sizes[1], (16, 16)); + assert_eq!(sizes[2], (8, 8)); + assert_eq!(sizes[3], (4, 4)); + assert_eq!(sizes[4], (2, 2)); + } + + #[test] + fn bloom_uniform_default() { + let u = BloomUniform::default(); + assert!((u.threshold - 1.0).abs() < f32::EPSILON); + assert!((u.soft_threshold - 0.5).abs() < f32::EPSILON); + assert_eq!(u._padding, [0.0f32; 2]); + } +} diff --git a/crates/voltex_renderer/src/hdr.rs b/crates/voltex_renderer/src/hdr.rs new file mode 100644 index 0000000..02d7b39 --- /dev/null +++ b/crates/voltex_renderer/src/hdr.rs @@ -0,0 +1,43 @@ +/// Texture format used for HDR render targets. +pub const HDR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; + +/// An HDR render target (Rgba16Float) used as the output of the lighting pass. +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_view(device, width, height); + Self { view, width, height } + } + + /// Recreate the HDR texture when the window is resized. + pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) { + self.view = create_hdr_view(device, width, height); + self.width = width; + self.height = height; + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn create_hdr_view(device: &wgpu::Device, width: u32, height: u32) -> wgpu::TextureView { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("HDR Target Texture"), + size: wgpu::Extent3d { + width, + height, + 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: &[], + }); + texture.create_view(&wgpu::TextureViewDescriptor::default()) +} diff --git a/crates/voltex_renderer/src/lib.rs b/crates/voltex_renderer/src/lib.rs index f21c01b..3e78c31 100644 --- a/crates/voltex_renderer/src/lib.rs +++ b/crates/voltex_renderer/src/lib.rs @@ -19,6 +19,9 @@ pub mod deferred_pipeline; pub mod ssgi; pub mod rt_accel; pub mod rt_shadow; +pub mod hdr; +pub mod bloom; +pub mod tonemap; pub use gpu::{GpuContext, DEPTH_FORMAT}; pub use light::{CameraUniform, LightUniform, LightData, LightsUniform, MAX_LIGHTS, LIGHT_DIRECTIONAL, LIGHT_POINT, LIGHT_SPOT}; @@ -43,3 +46,6 @@ pub use deferred_pipeline::{ pub use ssgi::{SsgiResources, SsgiUniform, SSGI_OUTPUT_FORMAT}; pub use rt_accel::{RtAccel, RtInstance, BlasMeshData, mat4_to_tlas_transform}; pub use rt_shadow::{RtShadowResources, RtShadowUniform, RT_SHADOW_FORMAT}; +pub use hdr::{HdrTarget, HDR_FORMAT}; +pub use bloom::{BloomResources, BloomUniform, mip_sizes, BLOOM_MIP_COUNT}; +pub use tonemap::{TonemapUniform, aces_tonemap}; diff --git a/crates/voltex_renderer/src/tonemap.rs b/crates/voltex_renderer/src/tonemap.rs new file mode 100644 index 0000000..23fbb76 --- /dev/null +++ b/crates/voltex_renderer/src/tonemap.rs @@ -0,0 +1,71 @@ +use bytemuck::{Pod, Zeroable}; + +/// Uniform buffer for the tonemap pass. +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct TonemapUniform { + /// Bloom contribution weight. + pub bloom_intensity: f32, + /// Pre-tonemap exposure multiplier. + 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], + } + } +} + +/// CPU implementation of the ACES filmic tonemap curve (for testing / CPU-side work). +/// +/// Formula: clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0, 1) +pub fn aces_tonemap(x: f32) -> f32 { + let num = x * (2.51 * x + 0.03); + let den = x * (2.43 * x + 0.59) + 0.14; + (num / den).clamp(0.0, 1.0) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aces_zero() { + // aces(0) should be ≈ 0 + assert!(aces_tonemap(0.0).abs() < 1e-5, "aces(0) = {}", aces_tonemap(0.0)); + } + + #[test] + fn aces_one() { + // aces(1) ≈ 0.80 with the standard formula + // clamp((1*(2.51+0.03))/(1*(2.43+0.59)+0.14), 0, 1) = 2.54/3.16 ≈ 0.8038 + let v = aces_tonemap(1.0); + assert!( + (v - 0.8038).abs() < 0.001, + "aces(1) = {}, expected ≈ 0.8038", + v + ); + } + + #[test] + fn aces_large() { + // aces(10) should be very close to 1.0 (saturated) + let v = aces_tonemap(10.0); + assert!(v > 0.999, "aces(10) = {}, expected ≈ 1.0", v); + } + + #[test] + fn tonemap_uniform_default() { + let u = TonemapUniform::default(); + assert!((u.bloom_intensity - 0.5).abs() < f32::EPSILON); + assert!((u.exposure - 1.0).abs() < f32::EPSILON); + assert_eq!(u._padding, [0.0f32; 2]); + } +}