diff --git a/crates/voltex_renderer/src/lib.rs b/crates/voltex_renderer/src/lib.rs index 28f3b99..556a712 100644 --- a/crates/voltex_renderer/src/lib.rs +++ b/crates/voltex_renderer/src/lib.rs @@ -16,6 +16,7 @@ pub mod ibl; pub mod gbuffer; pub mod fullscreen_quad; pub mod deferred_pipeline; +pub mod ssgi; pub use gpu::{GpuContext, DEPTH_FORMAT}; pub use light::{CameraUniform, LightUniform, LightData, LightsUniform, MAX_LIGHTS, LIGHT_DIRECTIONAL, LIGHT_POINT, LIGHT_SPOT}; @@ -34,4 +35,6 @@ pub use deferred_pipeline::{ create_gbuffer_pipeline, create_lighting_pipeline, gbuffer_camera_bind_group_layout, lighting_gbuffer_bind_group_layout, lighting_lights_bind_group_layout, lighting_shadow_bind_group_layout, + ssgi_gbuffer_bind_group_layout, ssgi_data_bind_group_layout, create_ssgi_pipeline, }; +pub use ssgi::{SsgiResources, SsgiUniform, SSGI_OUTPUT_FORMAT}; diff --git a/crates/voltex_renderer/src/ssgi.rs b/crates/voltex_renderer/src/ssgi.rs new file mode 100644 index 0000000..604731c --- /dev/null +++ b/crates/voltex_renderer/src/ssgi.rs @@ -0,0 +1,317 @@ +use bytemuck::{Pod, Zeroable}; + +pub const SSGI_OUTPUT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; +pub const SSGI_KERNEL_SIZE: usize = 64; + +const NOISE_TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; +const NOISE_DIM: u32 = 4; // 4x4 = 16 noise samples + +/// Uniform buffer for the SSGI pass. +/// +/// projection and view are column-major 4x4 matrices stored as flat [f32; 16]. +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct SsgiUniform { + pub projection: [f32; 16], + pub view: [f32; 16], + pub radius: f32, + pub bias: f32, + pub intensity: f32, + pub indirect_strength: f32, +} + +impl Default for SsgiUniform { + fn default() -> Self { + // Identity matrices for projection and view. + #[rustfmt::skip] + let identity = [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0_f32, + ]; + Self { + projection: identity, + view: identity, + radius: 0.5, + bias: 0.025, + intensity: 1.5, + indirect_strength: 0.5, + } + } +} + +/// GPU resources needed for one SSGI pass. +pub struct SsgiResources { + pub output_view: wgpu::TextureView, + pub kernel_buffer: wgpu::Buffer, + pub noise_view: wgpu::TextureView, + pub noise_sampler: wgpu::Sampler, + pub uniform_buffer: wgpu::Buffer, + pub width: u32, + pub height: u32, +} + +impl SsgiResources { + pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, width: u32, height: u32) -> Self { + let output_view = create_ssgi_output(device, width, height); + + // Hemisphere kernel buffer. + let kernel_data = generate_kernel(SSGI_KERNEL_SIZE); + // Flatten [f32; 4] array to bytes. + let kernel_bytes: Vec = kernel_data + .iter() + .flat_map(|v| { + v.iter() + .flat_map(|f| f.to_ne_bytes()) + .collect::>() + }) + .collect(); + let kernel_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("SSGI Kernel Buffer"), + size: kernel_bytes.len() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&kernel_buffer, 0, &kernel_bytes); + + // Noise texture. + let (noise_view, noise_sampler) = create_noise_texture(device, queue); + + // Uniform buffer with default values. + let uniform = SsgiUniform::default(); + let uniform_bytes = bytemuck::bytes_of(&uniform); + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("SSGI Uniform Buffer"), + size: uniform_bytes.len() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&uniform_buffer, 0, uniform_bytes); + + Self { + output_view, + kernel_buffer, + noise_view, + noise_sampler, + uniform_buffer, + width, + height, + } + } + + /// Recreate only the output texture when the window is resized. + pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) { + self.output_view = create_ssgi_output(device, width, height); + self.width = width; + self.height = height; + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Simple deterministic hash → [0, 1) float. +pub fn pseudo_random(seed: u32) -> f32 { + let mut x = seed; + x = x.wrapping_add(0x9e3779b9); + x = x.wrapping_mul(0x6c62272e); + x ^= x >> 16; + x = x.wrapping_mul(0x45d9f3b); + x ^= x >> 16; + (x as f32) / (u32::MAX as f32) +} + +/// Generate `count` hemisphere samples (z >= 0) biased towards center. +/// Each sample is stored as [x, y, z, 0.0] (w=0 unused / padding). +pub fn generate_kernel(count: usize) -> Vec<[f32; 4]> { + let mut samples = Vec::with_capacity(count); + let mut seed = 0u32; + + for i in 0..count { + // Random point on hemisphere: z in [0, 1], xy distributed on disk. + let r1 = pseudo_random(seed); + seed = seed.wrapping_add(1); + let r2 = pseudo_random(seed); + seed = seed.wrapping_add(1); + + // Spherical coordinates: phi in [0, 2π), cos_theta in [0, 1] (upper hemisphere). + let phi = r1 * 2.0 * std::f32::consts::PI; + let cos_theta = r2; + let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt(); + + let x = phi.cos() * sin_theta; + let y = phi.sin() * sin_theta; + let z = cos_theta; // z >= 0 (hemisphere) + + // Accelerating interpolation — scale samples closer to origin. + let scale = (i + 1) as f32 / count as f32; + let scale = lerp(0.1, 1.0, scale * scale); + + samples.push([x * scale, y * scale, z * scale, 0.0]); + } + + samples +} + +/// Generate 16 (4×4) random rotation vectors for SSGI noise. +/// Each entry is [cos(angle), sin(angle), 0.0, 0.0] (xy rotation in tangent space). +pub fn generate_noise_data() -> Vec<[f32; 4]> { + let mut data = Vec::with_capacity(16); + let mut seed = 1337u32; + + for _ in 0..16 { + let angle = pseudo_random(seed) * 2.0 * std::f32::consts::PI; + seed = seed.wrapping_add(7); + // Normalize: (cos, sin) is already unit length. + data.push([angle.cos(), angle.sin(), 0.0, 0.0]); + } + + data +} + +fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + t * (b - a) +} + +/// Create the SSGI output render target (RGBA16Float, screen-sized). +pub fn create_ssgi_output(device: &wgpu::Device, width: u32, height: u32) -> wgpu::TextureView { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("SSGI Output Texture"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: SSGI_OUTPUT_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + texture.create_view(&wgpu::TextureViewDescriptor::default()) +} + +/// Create the 4×4 SSGI noise texture (Rgba32Float) and its sampler. +pub fn create_noise_texture( + device: &wgpu::Device, + queue: &wgpu::Queue, +) -> (wgpu::TextureView, wgpu::Sampler) { + let noise_data = generate_noise_data(); + // Flatten [f32; 4] → bytes. + let noise_bytes: Vec = noise_data + .iter() + .flat_map(|v| v.iter().flat_map(|f| f.to_ne_bytes()).collect::>()) + .collect(); + + let extent = wgpu::Extent3d { + width: NOISE_DIM, + height: NOISE_DIM, + depth_or_array_layers: 1, + }; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("SSGI Noise Texture"), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: NOISE_TEXTURE_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + // Each texel is 4 × 4 bytes = 16 bytes (Rgba32Float). + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &noise_bytes, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(NOISE_DIM * 16), // 4 components × 4 bytes × NOISE_DIM + rows_per_image: Some(NOISE_DIM), + }, + extent, + ); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + // Wrap/repeat so the noise tiles across the screen. + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("SSGI Noise Sampler"), + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + address_mode_w: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, + ..Default::default() + }); + + (view, sampler) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_kernel_hemisphere() { + let kernel = generate_kernel(SSGI_KERNEL_SIZE); + assert_eq!(kernel.len(), SSGI_KERNEL_SIZE); + for sample in &kernel { + // z component must be >= 0 (upper hemisphere). + assert!( + sample[2] >= 0.0, + "kernel sample z={} is negative", + sample[2] + ); + // w component is always 0 (padding). + assert_eq!(sample[3], 0.0); + // Sample must be within the unit sphere (scale <= 1). + let len = (sample[0] * sample[0] + + sample[1] * sample[1] + + sample[2] * sample[2]) + .sqrt(); + assert!(len <= 1.0 + 1e-5, "sample length {} > 1.0", len); + } + } + + #[test] + fn test_noise_data() { + let noise = generate_noise_data(); + assert_eq!(noise.len(), 16); + for entry in &noise { + // xy should form a unit vector (cos²+sin²=1). + let len = (entry[0] * entry[0] + entry[1] * entry[1]).sqrt(); + assert!( + (len - 1.0).abs() < 1e-5, + "noise xy length {} != 1.0", + len + ); + // z and w are always 0. + assert_eq!(entry[2], 0.0); + assert_eq!(entry[3], 0.0); + } + } + + #[test] + fn test_uniform_defaults() { + let u = SsgiUniform::default(); + assert!((u.radius - 0.5).abs() < f32::EPSILON); + assert!((u.bias - 0.025).abs() < f32::EPSILON); + assert!((u.intensity - 1.5).abs() < f32::EPSILON); + assert!((u.indirect_strength - 0.5).abs() < f32::EPSILON); + // Identity diagonal elements. + assert_eq!(u.projection[0], 1.0); + assert_eq!(u.projection[5], 1.0); + assert_eq!(u.projection[10], 1.0); + assert_eq!(u.projection[15], 1.0); + } +}