refactor examples

This commit is contained in:
Jérôme Bousquié
2026-09-25 10:19:24 +02:00
parent ab3f056dbb
commit 35aeb769a8
37 changed files with 3430 additions and 457 deletions
+793
View File
@@ -0,0 +1,793 @@
//! # 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);
}
}