use wgpu::TextureFormat;
pub const BLUR_WGSL: &str = include_str!("blur.wgsl");
#[must_use]
pub fn gaussian_taps(sigma: f32) -> [f32; 5] {
let s = sigma.max(1e-3);
let mut w = [0.0f32; 5];
let mut sum = 0.0f32;
for (i, wi) in w.iter_mut().enumerate() {
let x = i as f32;
let g = (-(x * x) / (2.0 * s * s)).exp();
*wi = g;
sum += if i == 0 { g } else { 2.0 * g };
}
let inv = 1.0 / sum;
for wi in &mut w {
*wi *= inv;
}
w
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct BlurUniforms {
texel: [f32; 4],
w0123: [f32; 4],
w4: [f32; 4],
}
pub struct GaussianBlur {
pipeline: wgpu::RenderPipeline,
bgl: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
u_h: wgpu::Buffer,
u_v: wgpu::Buffer,
format: TextureFormat,
tex_a: Option<wgpu::TextureView>,
tex_b: Option<wgpu::TextureView>,
size: (u32, u32),
sigma: f32,
}
impl GaussianBlur {
pub fn new(device: &wgpu::Device, format: TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("l0_gaussian_blur"),
source: wgpu::ShaderSource::Wgsl(BLUR_WGSL.into()),
});
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("l0_blur_bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
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,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("l0_blur_pipeline"),
layout: Some(&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("l0_blur_pll"),
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
})),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("blur_vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("blur_fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("l0_blur_sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let mkbuf = |label: &str| device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: std::mem::size_of::<BlurUniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
pipeline,
bgl,
sampler,
u_h: mkbuf("l0_blur_u_h"),
u_v: mkbuf("l0_blur_u_v"),
format,
tex_a: None,
tex_b: None,
size: (0, 0),
sigma: 0.0,
}
}
pub fn ensure(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, w: u32, h: u32, sigma: f32) {
let w = w.max(1);
let h = h.max(1);
if self.size == (w, h) && (self.sigma - sigma).abs() < 1e-6 && self.tex_a.is_some() {
return;
}
if self.size != (w, h) || self.tex_a.is_none() {
let mk = |label: &str| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
self.tex_a = Some(mk("l0_blur_tex_a"));
self.tex_b = Some(mk("l0_blur_tex_b"));
self.size = (w, h);
}
let taps = gaussian_taps(sigma);
let texel = [1.0 / w as f32, 1.0 / h as f32];
let w0123 = [taps[0], taps[1], taps[2], taps[3]];
let w4 = [taps[4], 0.0, 0.0, 0.0];
queue.write_buffer(&self.u_h, 0, bytemuck::bytes_of(&BlurUniforms {
texel: [texel[0], texel[1], 1.0, 0.0],
w0123,
w4,
}));
queue.write_buffer(&self.u_v, 0, bytemuck::bytes_of(&BlurUniforms {
texel: [texel[0], texel[1], 0.0, 1.0],
w0123,
w4,
}));
self.sigma = sigma;
}
pub fn blur<'a>(
&'a self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
src: &wgpu::TextureView,
) -> Option<&'a wgpu::TextureView> {
let (tex_a, tex_b) = (self.tex_a.as_ref()?, self.tex_b.as_ref()?);
self.pass(device, encoder, "l0_blur_h", &self.u_h, src, tex_a);
self.pass(device, encoder, "l0_blur_v", &self.u_v, tex_a, tex_b);
Some(tex_b)
}
fn pass(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
label: &str,
uniform: &wgpu::Buffer,
src: &wgpu::TextureView,
dst: &wgpu::TextureView,
) {
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(label),
layout: &self.bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler) },
],
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: dst,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store },
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
rp.set_pipeline(&self.pipeline);
rp.set_bind_group(0, &bind, &[]);
rp.draw(0..3, 0..1);
}
#[must_use]
pub fn size(&self) -> (u32, u32) {
self.size
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gaussian_kernel_is_normalised() {
for &sigma in &[0.5f32, 1.0, 2.0, 4.0, 8.0] {
let w = gaussian_taps(sigma);
let sum = w[0] + 2.0 * (w[1] + w[2] + w[3] + w[4]);
assert!((sum - 1.0).abs() < 1e-5, "sigma={sigma}: kernel sums to {sum}, not 1");
}
}
#[test]
fn gaussian_kernel_falls_off_and_widens_with_sigma() {
let tight = gaussian_taps(0.8);
let wide = gaussian_taps(4.0);
for i in 1..5 {
assert!(tight[i] <= tight[i - 1], "tap {i} not falling off");
assert!(wide[i] <= wide[i - 1], "tap {i} not falling off");
}
assert!(wide[0] < tight[0], "wider sigma keeps less weight in the centre");
assert!(wide[4] > tight[4], "wider sigma spreads more weight to the edge tap");
}
#[test]
fn blur_shader_has_separable_entry_points() {
assert!(BLUR_WGSL.contains("fn blur_vs"));
assert!(BLUR_WGSL.contains("fn blur_fs"));
assert!(BLUR_WGSL.contains("texel.zw"), "the per-axis step rides texel.zw");
}
fn headless_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::default();
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
force_fallback_adapter: false,
compatible_surface: None,
}))
.ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("l0-blur-smoke"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::downlevel_defaults(),
memory_hints: wgpu::MemoryHints::default(),
experimental_features: wgpu::ExperimentalFeatures::disabled(),
trace: wgpu::Trace::Off,
}))
.ok()
}
#[test]
fn gaussian_blur_builds_pipeline_and_records_passes_on_device() {
let Some((device, queue)) = headless_device() else {
eprintln!("[blur] no GPU adapter — skipping device smoke test");
return;
};
let format = TextureFormat::Rgba16Float; let (w, h) = (64u32, 48u32);
let src = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("l0-blur-smoke-src"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default());
let mut blur = GaussianBlur::new(&device, format);
assert_eq!(blur.size(), (0, 0), "no targets before ensure");
blur.ensure(&device, &queue, w, h, 3.0);
assert_eq!(blur.size(), (w, h));
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("l0-blur-smoke-enc"),
});
let out = blur.blur(&device, &mut enc, &src);
assert!(out.is_some(), "blur returns the result view after ensure");
queue.submit(Some(enc.finish())); device.poll(wgpu::PollType::wait_indefinitely()).ok();
}
}