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:
2026-03-25 12:05:02 +09:00
parent 3d0657885b
commit eea6194d86
2 changed files with 332 additions and 0 deletions

View File

@@ -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,
})
}

View 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);
}