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,
|
GBUFFER_POSITION_FORMAT, GBUFFER_NORMAL_FORMAT, GBUFFER_ALBEDO_FORMAT, GBUFFER_MATERIAL_FORMAT,
|
||||||
};
|
};
|
||||||
use crate::light::CameraUniform;
|
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).
|
/// 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 {
|
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),
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
count: None,
|
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,
|
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