From bc2880d41ca100e4e27a0d26a23f0e1a92041594 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Thu, 26 Mar 2026 17:21:44 +0900 Subject: [PATCH] feat(renderer): add stencil optimization, half-res SSGI, bilateral bloom Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/voltex_renderer/src/bilateral_bloom.rs | 52 ++++++++++++++ crates/voltex_renderer/src/half_res_ssgi.rs | 60 ++++++++++++++++ crates/voltex_renderer/src/lib.rs | 4 ++ crates/voltex_renderer/src/stencil_opt.rs | 70 +++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 crates/voltex_renderer/src/bilateral_bloom.rs create mode 100644 crates/voltex_renderer/src/half_res_ssgi.rs create mode 100644 crates/voltex_renderer/src/stencil_opt.rs diff --git a/crates/voltex_renderer/src/bilateral_bloom.rs b/crates/voltex_renderer/src/bilateral_bloom.rs new file mode 100644 index 0000000..31810d5 --- /dev/null +++ b/crates/voltex_renderer/src/bilateral_bloom.rs @@ -0,0 +1,52 @@ +/// Bilateral bloom weight: attenuates blur across brightness edges. +pub fn bilateral_bloom_weight( + center_luminance: f32, + sample_luminance: f32, + spatial_weight: f32, + sigma_luminance: f32, +) -> f32 { + let lum_diff = (center_luminance - sample_luminance).abs(); + let lum_weight = (-lum_diff * lum_diff / (2.0 * sigma_luminance * sigma_luminance)).exp(); + spatial_weight * lum_weight +} + +/// Calculate luminance from RGB. +pub fn luminance(r: f32, g: f32, b: f32) -> f32 { + 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +/// 5-tap Gaussian weights for 1D bloom blur. +pub fn gaussian_5tap() -> [f32; 5] { + // Sigma ≈ 1.4 + [0.0625, 0.25, 0.375, 0.25, 0.0625] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bilateral_bloom_same_luminance() { + let w = bilateral_bloom_weight(0.5, 0.5, 1.0, 0.1); + assert!((w - 1.0).abs() < 0.01); // same lum → full weight + } + + #[test] + fn test_bilateral_bloom_edge() { + let w = bilateral_bloom_weight(0.1, 0.9, 1.0, 0.1); + assert!(w < 0.01); // large lum diff → near zero + } + + #[test] + fn test_luminance_white() { + let l = luminance(1.0, 1.0, 1.0); + assert!((l - 1.0).abs() < 0.01); + } + + #[test] + fn test_gaussian_5tap_sum() { + let g = gaussian_5tap(); + let sum: f32 = g.iter().sum(); + assert!((sum - 1.0).abs() < 0.01); + } +} diff --git a/crates/voltex_renderer/src/half_res_ssgi.rs b/crates/voltex_renderer/src/half_res_ssgi.rs new file mode 100644 index 0000000..a102a96 --- /dev/null +++ b/crates/voltex_renderer/src/half_res_ssgi.rs @@ -0,0 +1,60 @@ +/// Calculate half-resolution dimensions (rounded up). +pub fn half_resolution(width: u32, height: u32) -> (u32, u32) { + ((width + 1) / 2, (height + 1) / 2) +} + +/// Bilinear upscale weight calculation for a 2x2 tap pattern. +pub fn bilinear_weights(frac_x: f32, frac_y: f32) -> [f32; 4] { + let w00 = (1.0 - frac_x) * (1.0 - frac_y); + let w10 = frac_x * (1.0 - frac_y); + let w01 = (1.0 - frac_x) * frac_y; + let w11 = frac_x * frac_y; + [w00, w10, w01, w11] +} + +/// Depth-aware upscale: reject samples with large depth discontinuity. +pub fn depth_aware_weight(center_depth: f32, sample_depth: f32, threshold: f32) -> f32 { + if (center_depth - sample_depth).abs() > threshold { 0.0 } else { 1.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_half_resolution() { + assert_eq!(half_resolution(1920, 1080), (960, 540)); + assert_eq!(half_resolution(1921, 1081), (961, 541)); + assert_eq!(half_resolution(1, 1), (1, 1)); + } + + #[test] + fn test_bilinear_weights_center() { + let w = bilinear_weights(0.5, 0.5); + for &wi in &w { assert!((wi - 0.25).abs() < 1e-6); } + } + + #[test] + fn test_bilinear_weights_corner() { + let w = bilinear_weights(0.0, 0.0); + assert!((w[0] - 1.0).abs() < 1e-6); // top-left = full weight + assert!((w[1] - 0.0).abs() < 1e-6); + } + + #[test] + fn test_bilinear_weights_sum_to_one() { + let w = bilinear_weights(0.3, 0.7); + let sum: f32 = w.iter().sum(); + assert!((sum - 1.0).abs() < 1e-6); + } + + #[test] + fn test_depth_aware_accept() { + assert!((depth_aware_weight(0.5, 0.51, 0.1) - 1.0).abs() < 1e-6); + } + + #[test] + fn test_depth_aware_reject() { + assert!((depth_aware_weight(0.5, 0.9, 0.1) - 0.0).abs() < 1e-6); + } +} diff --git a/crates/voltex_renderer/src/lib.rs b/crates/voltex_renderer/src/lib.rs index 0b5c4be..dd2d452 100644 --- a/crates/voltex_renderer/src/lib.rs +++ b/crates/voltex_renderer/src/lib.rs @@ -93,6 +93,10 @@ pub use bilateral_blur::BilateralBlur; pub use temporal_accum::TemporalAccumulation; pub use taa::Taa; pub use ssr::Ssr; +pub mod stencil_opt; +pub mod half_res_ssgi; +pub mod bilateral_bloom; + pub use png::parse_png; pub use jpg::parse_jpg; pub use gltf::{parse_gltf, GltfData, GltfMesh, GltfMaterial}; diff --git a/crates/voltex_renderer/src/stencil_opt.rs b/crates/voltex_renderer/src/stencil_opt.rs new file mode 100644 index 0000000..301b2cf --- /dev/null +++ b/crates/voltex_renderer/src/stencil_opt.rs @@ -0,0 +1,70 @@ +/// Stencil state for marking pixels inside a light volume. +pub fn light_volume_stencil_mark() -> wgpu::DepthStencilState { + wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::Always, + stencil: wgpu::StencilState { + front: wgpu::StencilFaceState { + compare: wgpu::CompareFunction::Always, + fail_op: wgpu::StencilOperation::Keep, + depth_fail_op: wgpu::StencilOperation::IncrementWrap, + pass_op: wgpu::StencilOperation::Keep, + }, + back: wgpu::StencilFaceState { + compare: wgpu::CompareFunction::Always, + fail_op: wgpu::StencilOperation::Keep, + depth_fail_op: wgpu::StencilOperation::DecrementWrap, + pass_op: wgpu::StencilOperation::Keep, + }, + read_mask: 0xFF, + write_mask: 0xFF, + }, + bias: wgpu::DepthBiasState::default(), + } +} + +/// Stencil state for rendering only where stencil != 0 (inside light volume). +pub fn light_volume_stencil_test() -> wgpu::DepthStencilState { + wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::Always, + stencil: wgpu::StencilState { + front: wgpu::StencilFaceState { + compare: wgpu::CompareFunction::NotEqual, + fail_op: wgpu::StencilOperation::Keep, + depth_fail_op: wgpu::StencilOperation::Keep, + pass_op: wgpu::StencilOperation::Keep, + }, + back: wgpu::StencilFaceState { + compare: wgpu::CompareFunction::NotEqual, + fail_op: wgpu::StencilOperation::Keep, + depth_fail_op: wgpu::StencilOperation::Keep, + pass_op: wgpu::StencilOperation::Keep, + }, + read_mask: 0xFF, + write_mask: 0x00, + }, + bias: wgpu::DepthBiasState::default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mark_stencil_format() { + let state = light_volume_stencil_mark(); + assert_eq!(state.format, wgpu::TextureFormat::Depth24PlusStencil8); + assert!(!state.depth_write_enabled); + } + + #[test] + fn test_test_stencil_compare() { + let state = light_volume_stencil_test(); + assert_eq!(state.stencil.front.compare, wgpu::CompareFunction::NotEqual); + assert_eq!(state.stencil.write_mask, 0x00); // read-only + } +}