Skip to main content

zenthra_render/
blur_pipeline.rs

1use bytemuck::{Pod, Zeroable};
2use wgpu::util::DeviceExt;
3
4// ─────────────────────────────────────────────────────────────────────────────
5// Uniform that drives every blur pass
6// ─────────────────────────────────────────────────────────────────────────────
7#[repr(C)]
8#[derive(Copy, Clone, Pod, Zeroable)]
9pub struct BlurUniforms {
10    pub texel_size: [f32; 2], // 1/src_w, 1/src_h
11    pub offset:     f32,      // Kawase sample offset (radius-scaled)
12    pub direction:  f32,      // box blur: 0.0=horizontal 1.0=vertical
13}
14
15// ─────────────────────────────────────────────────────────────────────────────
16// A single pipeline object that can run all three blur entry-points:
17//   downsample / upsample / box
18// The caller selects the entry-point via `pass_kind`.
19// ─────────────────────────────────────────────────────────────────────────────
20pub enum BlurPassKind {
21    Downsample,
22    Upsample,
23    Box { horizontal: bool },
24}
25
26pub struct BlurPipeline {
27    pub downsample_pipeline: wgpu::RenderPipeline,
28    pub upsample_pipeline:   wgpu::RenderPipeline,
29    pub box_pipeline:        wgpu::RenderPipeline,
30    pub uniform_bgl:         wgpu::BindGroupLayout,
31    pub texture_bgl:         wgpu::BindGroupLayout,
32    pub sampler:             wgpu::Sampler,
33}
34
35impl BlurPipeline {
36    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
37        let shader = device.create_shader_module(wgpu::include_wgsl!("shaders/blur.wgsl"));
38
39        // ── Bind group layouts ────────────────────────────────────────────────
40        let uniform_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
41            label: Some("Blur Uniform BGL"),
42            entries: &[wgpu::BindGroupLayoutEntry {
43                binding: 0,
44                visibility: wgpu::ShaderStages::FRAGMENT,
45                ty: wgpu::BindingType::Buffer {
46                    ty: wgpu::BufferBindingType::Uniform,
47                    has_dynamic_offset: false,
48                    min_binding_size: None,
49                },
50                count: None,
51            }],
52        });
53
54        let texture_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
55            label: Some("Blur Texture BGL"),
56            entries: &[
57                wgpu::BindGroupLayoutEntry {
58                    binding: 0,
59                    visibility: wgpu::ShaderStages::FRAGMENT,
60                    ty: wgpu::BindingType::Texture {
61                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
62                        view_dimension: wgpu::TextureViewDimension::D2,
63                        multisampled: false,
64                    },
65                    count: None,
66                },
67                wgpu::BindGroupLayoutEntry {
68                    binding: 1,
69                    visibility: wgpu::ShaderStages::FRAGMENT,
70                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
71                    count: None,
72                },
73            ],
74        });
75
76        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
77            label: Some("Blur Pipeline Layout"),
78            bind_group_layouts: &[Some(&uniform_bgl), Some(&texture_bgl)],
79            immediate_size: 0,
80        });
81
82        // Shared blend: opaque (blur output is always opaque)
83        let target = wgpu::ColorTargetState {
84            format,
85            blend: None, // fully replace
86            write_mask: wgpu::ColorWrites::ALL,
87        };
88
89        let mk_pipeline = |label: &str, entry: &str| {
90            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
91                label: Some(label),
92                layout: Some(&layout),
93                vertex: wgpu::VertexState {
94                    module: &shader,
95                    entry_point: Some("vs_main"),
96                    buffers: &[],
97                    compilation_options: Default::default(),
98                },
99                fragment: Some(wgpu::FragmentState {
100                    module: &shader,
101                    entry_point: Some(entry),
102                    targets: &[Some(target.clone())],
103                    compilation_options: Default::default(),
104                }),
105                primitive: wgpu::PrimitiveState::default(),
106                depth_stencil: None,
107                multisample: wgpu::MultisampleState::default(),
108                multiview_mask: None,
109                cache: None,
110            })
111        };
112
113        let downsample_pipeline = mk_pipeline("Blur Downsample Pipeline", "fs_downsample");
114        let upsample_pipeline   = mk_pipeline("Blur Upsample Pipeline",   "fs_upsample");
115        let box_pipeline        = mk_pipeline("Blur Box Pipeline",         "fs_box");
116
117        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
118            label: Some("Blur Sampler"),
119            address_mode_u: wgpu::AddressMode::ClampToEdge,
120            address_mode_v: wgpu::AddressMode::ClampToEdge,
121            mag_filter: wgpu::FilterMode::Linear,
122            min_filter: wgpu::FilterMode::Linear,
123            ..Default::default()
124        });
125
126        Self {
127            downsample_pipeline,
128            upsample_pipeline,
129            box_pipeline,
130            uniform_bgl,
131            texture_bgl,
132            sampler,
133        }
134    }
135
136    /// Run one blur pass.
137    ///
138    /// * `src_view`  — texture view to read from
139    /// * `dst_view`  — texture view to write into (must NOT be the same texture)
140    /// * `src_w/h`   — pixel dimensions of the SOURCE texture
141    /// * `offset`    — Kawase offset (typically `(pass + 0.5) * radius / num_passes`)
142    /// * `kind`      — which pass to run
143    pub fn run_pass(
144        &self,
145        device: &wgpu::Device,
146        encoder: &mut wgpu::CommandEncoder,
147        src_view: &wgpu::TextureView,
148        dst_view: &wgpu::TextureView,
149        src_w: u32,
150        src_h: u32,
151        offset: f32,
152        kind: BlurPassKind,
153    ) {
154        let direction = match &kind {
155            BlurPassKind::Box { horizontal } => if *horizontal { 0.0 } else { 1.0 },
156            _ => 0.0,
157        };
158
159        let uniforms = BlurUniforms {
160            texel_size: [1.0 / src_w as f32, 1.0 / src_h as f32],
161            offset,
162            direction,
163        };
164
165        let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
166            label: Some("Blur Pass Uniform"),
167            contents: bytemuck::bytes_of(&uniforms),
168            usage: wgpu::BufferUsages::UNIFORM,
169        });
170
171        let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
172            label: Some("Blur Pass Uniform BG"),
173            layout: &self.uniform_bgl,
174            entries: &[wgpu::BindGroupEntry {
175                binding: 0,
176                resource: uniform_buf.as_entire_binding(),
177            }],
178        });
179
180        let texture_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
181            label: Some("Blur Pass Texture BG"),
182            layout: &self.texture_bgl,
183            entries: &[
184                wgpu::BindGroupEntry {
185                    binding: 0,
186                    resource: wgpu::BindingResource::TextureView(src_view),
187                },
188                wgpu::BindGroupEntry {
189                    binding: 1,
190                    resource: wgpu::BindingResource::Sampler(&self.sampler),
191                },
192            ],
193        });
194
195        let pipeline = match kind {
196            BlurPassKind::Downsample   => &self.downsample_pipeline,
197            BlurPassKind::Upsample     => &self.upsample_pipeline,
198            BlurPassKind::Box { .. }   => &self.box_pipeline,
199        };
200
201        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
202            label: Some("Blur Pass"),
203            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
204                view: dst_view,
205                resolve_target: None,
206                ops: wgpu::Operations {
207                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
208                    store: wgpu::StoreOp::Store,
209                },
210                depth_slice: None,
211            })],
212            depth_stencil_attachment: None,
213            timestamp_writes: None,
214            occlusion_query_set: None,
215            multiview_mask: None,
216        });
217
218        pass.set_pipeline(pipeline);
219        pass.set_bind_group(0, &uniform_bg, &[]);
220        pass.set_bind_group(1, &texture_bg, &[]);
221        pass.draw(0..3, 0..1);
222    }
223}
224
225// ─────────────────────────────────────────────────────────────────────────────
226// Scratch textures used during a blur sequence.
227// Two ping-pong textures at full resolution + four downsampled mips.
228// Allocated once and re-created only on window resize.
229// ─────────────────────────────────────────────────────────────────────────────
230pub struct BlurScratch {
231    pub width:    u32,
232    pub height:   u32,
233    pub format:   wgpu::TextureFormat,
234    // Full-resolution ping-pong pair used to snapshot scene + composite
235    pub full_a:   wgpu::Texture,
236    pub full_a_v: wgpu::TextureView,
237    pub full_b:   wgpu::Texture,
238    pub full_b_v: wgpu::TextureView,
239    // 4 mip levels for the Kawase pyramid (half, quarter, eighth, sixteenth)
240    pub mips:     [wgpu::Texture; 4],
241    pub mips_v:   [wgpu::TextureView; 4],
242    pub mip_w:    [u32; 4],
243    pub mip_h:    [u32; 4],
244}
245
246impl BlurScratch {
247    pub fn new(device: &wgpu::Device, width: u32, height: u32, format: wgpu::TextureFormat) -> Self {
248        let mk = |w: u32, h: u32, label: &str| {
249            device.create_texture(&wgpu::TextureDescriptor {
250                label: Some(label),
251                size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
252                mip_level_count: 1,
253                sample_count: 1,
254                dimension: wgpu::TextureDimension::D2,
255                format,
256                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
257                    | wgpu::TextureUsages::TEXTURE_BINDING
258                    | wgpu::TextureUsages::COPY_SRC
259                    | wgpu::TextureUsages::COPY_DST,
260                view_formats: &[],
261            })
262        };
263
264        let full_a = mk(width, height, "Blur Full A");
265        let full_a_v = full_a.create_view(&wgpu::TextureViewDescriptor::default());
266        let full_b = mk(width, height, "Blur Full B");
267        let full_b_v = full_b.create_view(&wgpu::TextureViewDescriptor::default());
268
269        let mut mip_w = [0u32; 4];
270        let mut mip_h = [0u32; 4];
271        let mut mips_raw: Vec<wgpu::Texture> = Vec::with_capacity(4);
272        for i in 0..4 {
273            let scale = 2u32.pow(i as u32 + 1);
274            let w = (width / scale).max(1);
275            let h = (height / scale).max(1);
276            mip_w[i] = w;
277            mip_h[i] = h;
278            mips_raw.push(mk(w, h, &format!("Blur Mip {}", i)));
279        }
280
281        // SAFETY: exactly 4 elements
282        let mips: [wgpu::Texture; 4] = mips_raw.try_into().unwrap();
283        let mips_v: [wgpu::TextureView; 4] = std::array::from_fn(|i| {
284            mips[i].create_view(&wgpu::TextureViewDescriptor::default())
285        });
286
287        Self { width, height, format, full_a, full_a_v, full_b, full_b_v, mips, mips_v, mip_w, mip_h }
288    }
289
290    /// Returns true if the scratch buffers need to be recreated.
291    pub fn needs_resize(&self, width: u32, height: u32) -> bool {
292        self.width != width || self.height != height
293    }
294}
295
296// ─────────────────────────────────────────────────────────────────────────────
297// High-level helper: run the full Dual Kawase blur on a region.
298//
299// Reads from `scene_view` (the offscreen render target snapshot),
300// writes the blurred result into `out_view`.
301// ─────────────────────────────────────────────────────────────────────────────
302pub fn run_kawase_blur(
303    pipeline: &BlurPipeline,
304    scratch: &BlurScratch,
305    device: &wgpu::Device,
306    encoder: &mut wgpu::CommandEncoder,
307    scene_view: &wgpu::TextureView, // full-res source
308    out_view:   &wgpu::TextureView, // full-res output
309    radius:     f32,                // blur radius in pixels (logical)
310) {
311    if radius <= 0.0 {
312        // Just copy scene_view to out_view if radius is 0
313        pipeline.run_pass(
314            device, encoder,
315            scene_view, out_view,
316            scratch.width, scratch.height,
317            0.0, BlurPassKind::Upsample,
318        );
319        return;
320    }
321
322    // Dynamically choose pass count based on radius size.
323    // Small blurs require fewer downsamples to avoid looking instantly fully blurred.
324    if radius < 5.0 {
325        // Full resolution single-pass blur (0 downsampling). Very crisp and subtle!
326        pipeline.run_pass(
327            device, encoder,
328            scene_view, out_view,
329            scratch.width, scratch.height,
330            radius, BlurPassKind::Downsample,
331        );
332        return;
333    }
334
335    let num_passes = if radius < 12.0 {
336        1usize
337    } else if radius < 25.0 {
338        2usize
339    } else if radius < 40.0 {
340        3usize
341    } else {
342        4usize
343    };
344
345    // ── 1. Downsample chain ───────────────────────────────────────────────
346    // Pass 0: scene → mip[0]  (half size)
347    {
348        let offset = 0.5 * radius / num_passes as f32;
349        pipeline.run_pass(
350            device, encoder,
351            scene_view, &scratch.mips_v[0],
352            scratch.width, scratch.height,
353            offset, BlurPassKind::Downsample,
354        );
355    }
356    // Passes 1 to num_passes-1: mip[i-1] → mip[i]
357    for i in 1..num_passes {
358        let offset = (i as f32 + 0.5) * radius / num_passes as f32;
359        pipeline.run_pass(
360            device, encoder,
361            &scratch.mips_v[i - 1], &scratch.mips_v[i],
362            scratch.mip_w[i - 1], scratch.mip_h[i - 1],
363            offset, BlurPassKind::Downsample,
364        );
365    }
366
367    // ── 2. Upsample chain ─────────────────────────────────────────────────
368    // Passes num_passes-2 down to 0: mip[i+1] → mip[i]
369    for i in (0..num_passes - 1).rev() {
370        let offset = (i as f32 + 0.5) * radius / num_passes as f32;
371        pipeline.run_pass(
372            device, encoder,
373            &scratch.mips_v[i + 1], &scratch.mips_v[i],
374            scratch.mip_w[i + 1], scratch.mip_h[i + 1],
375            offset, BlurPassKind::Upsample,
376        );
377    }
378
379    // ── 3. Final upsample mip[0] → out_view (full res) ───────────────────
380    {
381        let offset = 0.5 * radius / num_passes as f32;
382        pipeline.run_pass(
383            device, encoder,
384            &scratch.mips_v[0], out_view,
385            scratch.mip_w[0], scratch.mip_h[0],
386            offset, BlurPassKind::Upsample,
387        );
388    }
389}
390
391pub fn run_box_blur(
392    pipeline: &BlurPipeline,
393    scratch: &BlurScratch,
394    device: &wgpu::Device,
395    encoder: &mut wgpu::CommandEncoder,
396    scene_view: &wgpu::TextureView, // full-res source
397    out_view:   &wgpu::TextureView, // full-res output
398    radius:     f32,                // blur radius in pixels (logical)
399) {
400    // Pass 1: Horizontal box blur (scene -> mip[0])
401    pipeline.run_pass(
402        device, encoder,
403        scene_view, &scratch.mips_v[0],
404        scratch.width, scratch.height,
405        radius, BlurPassKind::Box { horizontal: true },
406    );
407    // Pass 2: Vertical box blur (mip[0] -> out_view)
408    pipeline.run_pass(
409        device, encoder,
410        &scratch.mips_v[0], out_view,
411        scratch.mip_w[0], scratch.mip_h[0],
412        radius, BlurPassKind::Box { horizontal: false },
413    );
414}