feat(renderer): add SSGI shader and pipeline for screen-space GI
Adds ssgi_shader.wgsl (fullscreen pass: view-space TBN from noise, 64-sample hemisphere loop with occlusion + color bleeding, outputs vec4(ao, indirect_rgb)). Adds ssgi_gbuffer_bind_group_layout, ssgi_data_bind_group_layout, and create_ssgi_pipeline to deferred_pipeline.rs. Exports all three from lib.rs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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::<SsgiUniform>() as u64,
|
||||
),
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
// binding 1: hemisphere kernel (array<vec4<f32>, 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,
|
||||
})
|
||||
}
|
||||
|
||||
149
crates/voltex_renderer/src/ssgi_shader.wgsl
Normal file
149
crates/voltex_renderer/src/ssgi_shader.wgsl
Normal file
@@ -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<f32>;
|
||||
@group(0) @binding(1) var t_normal: texture_2d<f32>;
|
||||
@group(0) @binding(2) var t_albedo: texture_2d<f32>;
|
||||
@group(0) @binding(3) var s_gbuffer: sampler;
|
||||
|
||||
// ── Group 1: SSGI data ────────────────────────────────────────────────────────
|
||||
|
||||
struct SsgiUniform {
|
||||
projection: mat4x4<f32>,
|
||||
view: mat4x4<f32>,
|
||||
radius: f32,
|
||||
bias: f32,
|
||||
intensity: f32,
|
||||
indirect_strength: f32,
|
||||
};
|
||||
|
||||
struct SsgiKernel {
|
||||
samples: array<vec4<f32>, 64>,
|
||||
};
|
||||
|
||||
@group(1) @binding(0) var<uniform> ssgi: SsgiUniform;
|
||||
@group(1) @binding(1) var<uniform> kernel: SsgiKernel;
|
||||
@group(1) @binding(2) var t_noise: texture_2d<f32>;
|
||||
@group(1) @binding(3) var s_noise: sampler;
|
||||
|
||||
// ── Vertex stage ──────────────────────────────────────────────────────────────
|
||||
|
||||
struct VertexInput {
|
||||
@location(0) position: vec2<f32>,
|
||||
};
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(v: VertexInput) -> VertexOutput {
|
||||
var out: VertexOutput;
|
||||
out.clip_position = vec4<f32>(v.position, 0.0, 1.0);
|
||||
out.uv = vec2<f32>(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<f32> {
|
||||
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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(sample_pos, 1.0);
|
||||
let offset_ndc = offset4.xyz / offset4.w;
|
||||
let sample_uv = vec2<f32>(
|
||||
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<f32>(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<f32>(ao_clamped, indirect.r, indirect.g, indirect.b);
|
||||
}
|
||||
Reference in New Issue
Block a user