diff --git a/crates/voltex_renderer/src/deferred_pipeline.rs b/crates/voltex_renderer/src/deferred_pipeline.rs index b8a0e72..34b0d02 100644 --- a/crates/voltex_renderer/src/deferred_pipeline.rs +++ b/crates/voltex_renderer/src/deferred_pipeline.rs @@ -5,6 +5,7 @@ use crate::gbuffer::{ GBUFFER_POSITION_FORMAT, GBUFFER_NORMAL_FORMAT, GBUFFER_ALBEDO_FORMAT, GBUFFER_MATERIAL_FORMAT, }; use crate::light::CameraUniform; +use crate::ssgi::{SsgiUniform, SSGI_OUTPUT_FORMAT}; /// Bind group layout for the G-Buffer pass camera uniform (dynamic offset, group 0). pub fn gbuffer_camera_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { @@ -251,6 +252,24 @@ pub fn lighting_shadow_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGro ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + // binding 5: SSGI output texture (Rgba16Float → filterable) + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // binding 6: filtering sampler for SSGI texture + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, ], }) } @@ -312,3 +331,167 @@ pub fn create_lighting_pipeline( cache: None, }) } + +// ── SSGI pipeline ───────────────────────────────────────────────────────────── + +/// Bind group layout for the SSGI pass G-Buffer inputs (group 0). +/// position is non-filterable (Rgba32Float); normal and albedo are filterable. +/// Sampler is NonFiltering (required when any texture is non-filterable). +pub fn ssgi_gbuffer_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("SSGI GBuffer Bind Group Layout"), + entries: &[ + // binding 0: position texture (Rgba32Float → non-filterable) + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // binding 1: normal texture (filterable) + 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, + }, + // binding 2: albedo texture (filterable) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // binding 3: non-filtering sampler + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + ], + }) +} + +/// Bind group layout for the SSGI pass uniform data (group 1). +/// Contains: SsgiUniform buffer, kernel buffer, noise texture (Rgba32Float), noise sampler. +pub fn ssgi_data_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("SSGI Data Bind Group Layout"), + entries: &[ + // binding 0: SsgiUniform + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new( + std::mem::size_of::() as u64, + ), + }, + count: None, + }, + // binding 1: hemisphere kernel (array, 64>) + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new( + (64 * std::mem::size_of::<[f32; 4]>()) as u64, + ), + }, + count: None, + }, + // binding 2: noise texture (Rgba32Float → non-filterable) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // binding 3: non-filtering sampler for noise + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + ], + }) +} + +/// Create the SSGI render pipeline. +/// Draws a fullscreen triangle and writes to a single SSGI_OUTPUT_FORMAT color target. +pub fn create_ssgi_pipeline( + device: &wgpu::Device, + gbuffer_layout: &wgpu::BindGroupLayout, + data_layout: &wgpu::BindGroupLayout, +) -> wgpu::RenderPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("SSGI Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("ssgi_shader.wgsl").into()), + }); + + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("SSGI Pipeline Layout"), + bind_group_layouts: &[gbuffer_layout, data_layout], + immediate_size: 0, + }); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("SSGI Pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[FullscreenVertex::LAYOUT], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: SSGI_OUTPUT_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) +} diff --git a/crates/voltex_renderer/src/ssgi_shader.wgsl b/crates/voltex_renderer/src/ssgi_shader.wgsl new file mode 100644 index 0000000..f6eb5b2 --- /dev/null +++ b/crates/voltex_renderer/src/ssgi_shader.wgsl @@ -0,0 +1,149 @@ +// SSGI (Screen-Space Global Illumination) pass shader. +// Reads the G-Buffer and computes per-pixel ambient occlusion + indirect color bleeding. +// Output: vec4(ao, indirect_r, indirect_g, indirect_b) + +// ── Group 0: G-Buffer inputs ────────────────────────────────────────────────── + +@group(0) @binding(0) var t_position: texture_2d; +@group(0) @binding(1) var t_normal: texture_2d; +@group(0) @binding(2) var t_albedo: texture_2d; +@group(0) @binding(3) var s_gbuffer: sampler; + +// ── Group 1: SSGI data ──────────────────────────────────────────────────────── + +struct SsgiUniform { + projection: mat4x4, + view: mat4x4, + radius: f32, + bias: f32, + intensity: f32, + indirect_strength: f32, +}; + +struct SsgiKernel { + samples: array, 64>, +}; + +@group(1) @binding(0) var ssgi: SsgiUniform; +@group(1) @binding(1) var kernel: SsgiKernel; +@group(1) @binding(2) var t_noise: texture_2d; +@group(1) @binding(3) var s_noise: sampler; + +// ── Vertex stage ────────────────────────────────────────────────────────────── + +struct VertexInput { + @location(0) position: vec2, +}; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(v: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.clip_position = vec4(v.position, 0.0, 1.0); + out.uv = vec2(v.position.x * 0.5 + 0.5, 1.0 - (v.position.y * 0.5 + 0.5)); + return out; +} + +// ── Fragment stage ──────────────────────────────────────────────────────────── + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let uv = in.uv; + + // Read world-space position from G-Buffer. + let world_pos = textureSample(t_position, s_gbuffer, uv).xyz; + + // Background pixel: return full AO, no indirect light. + if length(world_pos) < 0.001 { + return vec4(1.0, 0.0, 0.0, 0.0); + } + + // World-space normal (stored as [0,1] encoded, decode back to [-1,1]). + let normal_enc = textureSample(t_normal, s_gbuffer, uv).rgb; + let N_world = normalize(normal_enc * 2.0 - 1.0); + + // Albedo for color bleeding. + let albedo = textureSample(t_albedo, s_gbuffer, uv).rgb; + + // Convert world-space position and normal to view space. + let view_pos4 = ssgi.view * vec4(world_pos, 1.0); + let frag_pos_view = view_pos4.xyz; + + // Normal matrix = transpose(inverse(view)) ≈ mat3(view) for orthonormal view matrix. + let N_view = normalize((ssgi.view * vec4(N_world, 0.0)).xyz); + + // ── TBN from noise ──────────────────────────────────────────────────────── + // Sample a random rotation vector from the noise texture (tiled 4×4 across screen). + let tex_size = vec2(textureDimensions(t_position)); + let noise_uv = uv * tex_size / 4.0; // tile the 4x4 noise over the screen + let rand_vec = textureSample(t_noise, s_noise, noise_uv).xy; + let rand_dir = normalize(vec3(rand_vec, 0.0)); + + // Gram-Schmidt orthogonalization to build TBN in view space. + let tangent = normalize(rand_dir - N_view * dot(rand_dir, N_view)); + let bitangent = cross(N_view, tangent); + // TBN matrix columns: tangent, bitangent, normal. + + // ── 64-sample hemisphere loop ───────────────────────────────────────────── + var occlusion = 0.0; + var indirect_rgb = vec3(0.0); + + for (var i = 0u; i < 64u; i++) { + // Transform kernel sample from tangent space to view space. + let s = kernel.samples[i].xyz; + let sample_vs = tangent * s.x + + bitangent * s.y + + N_view * s.z; + + // Offset from current view-space fragment position. + let sample_pos = frag_pos_view + sample_vs * ssgi.radius; + + // Project sample to get screen UV. + let offset4 = ssgi.projection * vec4(sample_pos, 1.0); + let offset_ndc = offset4.xyz / offset4.w; + let sample_uv = vec2( + offset_ndc.x * 0.5 + 0.5, + 1.0 - (offset_ndc.y * 0.5 + 0.5), + ); + + // Clamp to [0,1] to avoid sampling outside the texture. + if sample_uv.x < 0.0 || sample_uv.x > 1.0 + || sample_uv.y < 0.0 || sample_uv.y > 1.0 { + continue; + } + + // Actual geometry depth at the projected UV. + let scene_pos_world = textureSample(t_position, s_gbuffer, sample_uv).xyz; + let scene_pos_view = (ssgi.view * vec4(scene_pos_world, 1.0)).xyz; + let scene_depth = scene_pos_view.z; + + // Range check: ignore samples that are too far away (avoid halo artefacts). + let range_check = smoothstep(0.0, 1.0, ssgi.radius / abs(frag_pos_view.z - scene_depth)); + + // Occlusion: geometry behind the sample blocks light. + let occluded = select(0.0, 1.0, scene_depth >= sample_pos.z + ssgi.bias); + occlusion += occluded * range_check; + + // Color bleeding: gather albedo from occluding surfaces. + let neighbor_albedo = textureSample(t_albedo, s_gbuffer, sample_uv).rgb; + indirect_rgb += neighbor_albedo * occluded * range_check; + } + + // Normalize by sample count. + let inv_samples = 1.0 / 64.0; + occlusion *= inv_samples; + indirect_rgb *= inv_samples; + + // AO: how unoccluded the fragment is (1 = fully lit, 0 = fully occluded). + let ao = 1.0 - occlusion * ssgi.intensity; + let ao_clamped = clamp(ao, 0.0, 1.0); + + // Indirect light contribution scaled by indirect_strength and albedo. + let indirect = indirect_rgb * ssgi.indirect_strength * albedo; + + return vec4(ao_clamped, indirect.r, indirect.g, indirect.b); +}