//! # Bloom Post-Process (Étape 23) //! //! Defines `BloomConfig` (public user-facing configuration) and the internal `BloomPipeline` //! (GPU resources: half-res textures, blur/composite pipelines, bind groups). The bloom effect //! is a 4-pass post-process that operates on the HDR texture before tone mapping: //! //! 1. **Threshold** (full → half res): extract pixels above a luminance threshold (soft-knee). //! 2. **Blur H** (half res): horizontal separable Gaussian (9 taps). //! 3. **Blur V** (half res): vertical separable Gaussian (9 taps). //! 4. **Composite** (full res): `HDR += bloom × intensity`. //! //! The bloom is **opt-in** (`AppBuilder::with_bloom`) and only active when HDR is also enabled. //! Without HDR, the values are already clamped to [0,1] and there is nothing "bright" to bloom. use wgpu::{ BindGroup, BindGroupLayout, Buffer, BufferUsages, RenderPipeline, Sampler, Texture, TextureUsages, TextureView, }; /// User-facing bloom configuration (Étape 23). /// /// Passed to `AppBuilder::with_bloom(config)` to enable the bloom post-process. /// Can be updated at runtime via `App::set_bloom_config`. #[derive(Debug, Clone)] pub struct BloomConfig { /// Luminance threshold (in linear HDR units). Pixels above this contribute to bloom. /// Default: 1.0 (only overbright areas — emissives > 1.0, specular highlights). pub threshold: f32, /// Soft-knee width for the threshold ramp. Larger = smoother transition. /// Default: 0.5. pub knee: f32, /// Bloom intensity (multiplier on the blurred result before adding to HDR). /// Default: 0.8. pub intensity: f32, /// Blur radius in pixels (at half resolution). Larger = wider glow. /// Default: 4.0. pub radius: f32, } impl Default for BloomConfig { fn default() -> Self { Self { threshold: 1.0, knee: 0.5, intensity: 0.8, radius: 4.0, } } } /// Internal bloom pipeline state. Allocated when bloom + HDR are both active. /// Recreated on resize. pub(crate) struct BloomPipeline { bright_texture: Texture, bright_view: TextureView, blur_texture: Texture, blur_view: TextureView, composite_texture: Texture, composite_view: TextureView, sampler: Sampler, threshold_pipeline: RenderPipeline, blur_pipeline: RenderPipeline, composite_pipeline: RenderPipeline, threshold_bg: BindGroup, blur_bg_h: BindGroup, blur_bg_v: BindGroup, composite_bg: BindGroup, threshold_uniform: Buffer, blur_uniform_h: Buffer, blur_uniform_v: Buffer, composite_uniform: Buffer, threshold_layout: BindGroupLayout, blur_layout: BindGroupLayout, composite_layout: BindGroupLayout, half_w: u32, half_h: u32, width: u32, height: u32, } impl BloomPipeline { pub fn new(device: &wgpu::Device, width: u32, height: u32, hdr_view: &TextureView) -> Self { let half_w = (width / 2).max(1); let half_h = (height / 2).max(1); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("bloom sampler"), mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, mipmap_filter: wgpu::MipmapFilterMode::Nearest, address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, ..Default::default() }); let (bright_texture, bright_view) = create_bloom_texture(device, half_w, half_h, "bloom bright"); let (blur_texture, blur_view) = create_bloom_texture(device, half_w, half_h, "bloom blur"); let (composite_texture, composite_view) = create_bloom_texture(device, width, height, "bloom composite"); // Bind group layouts. let threshold_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("bloom threshold 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 blur_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("bloom 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 composite_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("bloom composite 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, }, wgpu::BindGroupLayoutEntry { binding: 3, 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: 4, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, ], }); // Pipeline layouts. let threshold_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("bloom threshold pl"), bind_group_layouts: &[Some(&threshold_layout)], ..Default::default() }); let blur_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("bloom blur pl"), bind_group_layouts: &[Some(&blur_layout)], ..Default::default() }); let composite_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("bloom composite pl"), bind_group_layouts: &[Some(&composite_layout)], ..Default::default() }); // Shader modules. let threshold_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("bloom threshold"), source: wgpu::ShaderSource::Wgsl( crate::utils::conf::BLOOM_THRESHOLD_SHADER.into(), ), }); let blur_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("bloom blur"), source: wgpu::ShaderSource::Wgsl( crate::utils::conf::BLOOM_BLUR_SHADER.into(), ), }); let composite_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("bloom composite"), source: wgpu::ShaderSource::Wgsl( crate::utils::conf::BLOOM_COMPOSITE_SHADER.into(), ), }); // Shared fragment target state (all 3 passes output to Rgba16Float). let fragment_targets = &[Some(wgpu::ColorTargetState { format: wgpu::TextureFormat::Rgba16Float, blend: Some(wgpu::BlendState::REPLACE), write_mask: wgpu::ColorWrites::ALL, })]; // Threshold pipeline. let threshold_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("bloom threshold pipeline"), layout: Some(&threshold_pl), vertex: wgpu::VertexState { module: &threshold_module, entry_point: Some("vs_main"), buffers: &[], compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { module: &threshold_module, entry_point: Some("fs_main"), compilation_options: Default::default(), targets: fragment_targets, }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, depth_stencil: None, multisample: Default::default(), multiview_mask: None, cache: None, }); // Blur pipeline. let blur_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("bloom blur pipeline"), layout: Some(&blur_pl), vertex: wgpu::VertexState { module: &blur_module, entry_point: Some("vs_main"), buffers: &[], compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { module: &blur_module, entry_point: Some("fs_main"), compilation_options: Default::default(), targets: fragment_targets, }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, depth_stencil: None, multisample: Default::default(), multiview_mask: None, cache: None, }); // Composite pipeline. let composite_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("bloom composite pipeline"), layout: Some(&composite_pl), vertex: wgpu::VertexState { module: &composite_module, entry_point: Some("vs_main"), buffers: &[], compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { module: &composite_module, entry_point: Some("fs_main"), compilation_options: Default::default(), targets: fragment_targets, }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, depth_stencil: None, multisample: Default::default(), multiview_mask: None, cache: None, }); // Uniform buffers (32 bytes each — WGSL uniform alignment requires padding; // vec2 has align 8, vec3 has align 16, so structs are larger than their field sum). let threshold_uniform = device.create_buffer(&wgpu::BufferDescriptor { label: Some("bloom threshold uniform"), size: 32, usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, mapped_at_creation: false, }); let blur_uniform_h = device.create_buffer(&wgpu::BufferDescriptor { label: Some("bloom blur H uniform"), size: 32, usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, mapped_at_creation: false, }); let blur_uniform_v = device.create_buffer(&wgpu::BufferDescriptor { label: Some("bloom blur V uniform"), size: 32, usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, mapped_at_creation: false, }); let composite_uniform = device.create_buffer(&wgpu::BufferDescriptor { label: Some("bloom composite uniform"), size: 32, usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, mapped_at_creation: false, }); // Bind groups. let threshold_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("bloom threshold bg"), layout: &threshold_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: threshold_uniform.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(hdr_view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&sampler), }, ], }); let blur_bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("bloom blur bg H"), layout: &blur_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: blur_uniform_h.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&bright_view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&sampler), }, ], }); let blur_bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("bloom blur bg V"), layout: &blur_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: blur_uniform_v.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&blur_view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&sampler), }, ], }); let composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("bloom composite bg"), layout: &composite_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: composite_uniform.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(hdr_view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&sampler), }, wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&bright_view), }, wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&sampler), }, ], }); Self { bright_texture, bright_view, blur_texture, blur_view, composite_texture, composite_view, sampler, threshold_pipeline, blur_pipeline, composite_pipeline, threshold_bg, blur_bg_h, blur_bg_v, composite_bg, threshold_uniform, blur_uniform_h, blur_uniform_v, composite_uniform, threshold_layout, blur_layout, composite_layout, half_w, half_h, width, height, } } pub fn resize( &mut self, device: &wgpu::Device, width: u32, height: u32, hdr_view: &TextureView, ) { let half_w = (width / 2).max(1); let half_h = (height / 2).max(1); let (bright_texture, bright_view) = create_bloom_texture(device, half_w, half_h, "bloom bright"); let (blur_texture, blur_view) = create_bloom_texture(device, half_w, half_h, "bloom blur"); let (composite_texture, composite_view) = create_bloom_texture(device, width, height, "bloom composite"); self.threshold_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("bloom threshold bg"), layout: &self.threshold_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: self.threshold_uniform.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(hdr_view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler), }, ], }); self.blur_bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("bloom blur bg H"), layout: &self.blur_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: self.blur_uniform_h.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&bright_view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler), }, ], }); self.blur_bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("bloom blur bg V"), layout: &self.blur_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: self.blur_uniform_v.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&blur_view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler), }, ], }); self.composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("bloom composite bg"), layout: &self.composite_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: self.composite_uniform.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(hdr_view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler), }, wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&bright_view), }, wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.sampler), }, ], }); self.bright_texture = bright_texture; self.bright_view = bright_view; self.blur_texture = blur_texture; self.blur_view = blur_view; self.composite_texture = composite_texture; self.composite_view = composite_view; self.half_w = half_w; self.half_h = half_h; self.width = width; self.height = height; } #[allow(dead_code)] pub fn composite_view(&self) -> &TextureView { &self.composite_view } pub fn composite_texture(&self) -> &Texture { &self.composite_texture } pub fn record_passes( &self, encoder: &mut wgpu::CommandEncoder, queue: &wgpu::Queue, config: &BloomConfig, ) { let threshold_data = [config.threshold, config.knee, 0.0, 0.0]; queue.write_buffer( &self.threshold_uniform, 0, bytemuck::cast_slice(&threshold_data), ); let blur_h_data = [1.0 / self.half_w as f32, 0.0, config.radius, 0.0]; queue.write_buffer(&self.blur_uniform_h, 0, bytemuck::cast_slice(&blur_h_data)); let blur_v_data = [0.0, 1.0 / self.half_h as f32, config.radius, 0.0]; queue.write_buffer(&self.blur_uniform_v, 0, bytemuck::cast_slice(&blur_v_data)); let composite_data = [config.intensity, 0.0, 0.0, 0.0]; queue.write_buffer( &self.composite_uniform, 0, bytemuck::cast_slice(&composite_data), ); // Pass 1: Threshold (HDR full → bright half) { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("bloom threshold"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &self.bright_view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, ..Default::default() }); pass.set_viewport( 0.0, 0.0, self.half_w as f32, self.half_h as f32, 0.0, 1.0, ); pass.set_pipeline(&self.threshold_pipeline); pass.set_bind_group(0, &self.threshold_bg, &[]); pass.draw(0..3, 0..1); } // Pass 2: Blur H (bright half → blur half) { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("bloom blur H"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &self.blur_view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, ..Default::default() }); pass.set_viewport( 0.0, 0.0, self.half_w as f32, self.half_h as f32, 0.0, 1.0, ); pass.set_pipeline(&self.blur_pipeline); pass.set_bind_group(0, &self.blur_bg_h, &[]); pass.draw(0..3, 0..1); } // Pass 3: Blur V (blur half → bright half) { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("bloom blur V"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &self.bright_view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, ..Default::default() }); pass.set_viewport( 0.0, 0.0, self.half_w as f32, self.half_h as f32, 0.0, 1.0, ); pass.set_pipeline(&self.blur_pipeline); pass.set_bind_group(0, &self.blur_bg_v, &[]); pass.draw(0..3, 0..1); } // Pass 4: Composite (HDR full + bright half → composite full) { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("bloom composite"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &self.composite_view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, ..Default::default() }); pass.set_viewport( 0.0, 0.0, self.width as f32, self.height as f32, 0.0, 1.0, ); pass.set_pipeline(&self.composite_pipeline); pass.set_bind_group(0, &self.composite_bg, &[]); pass.draw(0..3, 0..1); } } } fn create_bloom_texture( device: &wgpu::Device, width: u32, height: u32, label: &str, ) -> (Texture, TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some(label), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba16Float, usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&Default::default()); (texture, view) } #[cfg(test)] mod tests { use super::*; #[test] fn bloom_config_default() { let cfg = BloomConfig::default(); assert_eq!(cfg.threshold, 1.0); assert_eq!(cfg.knee, 0.5); assert_eq!(cfg.intensity, 0.8); assert_eq!(cfg.radius, 4.0); } #[test] fn bloom_config_clone() { let cfg = BloomConfig { threshold: 2.0, knee: 1.0, intensity: 1.5, radius: 6.0, }; let cloned = cfg.clone(); assert_eq!(cloned.threshold, 2.0); assert_eq!(cloned.intensity, 1.5); } }