Skip to main content

dreamwell_gpu/
post.rs

1//! Post-processing chain — HDR framebuffer, bloom, tonemapping.
2//!
3//! Zero-overhead bypass: when `is_enabled() == false`, rendering targets the surface
4//! directly. No HDR allocation, no bloom passes, no tonemap pass.
5
6use dreamwell_engine::material::TonemapOperator;
7
8use crate::formats::DEPTH_FORMAT;
9use crate::shader;
10
11pub const HDR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
12
13const BLOOM_SHADER_SRC: &str = include_str!("../shaders/bloom.wgsl");
14const TONEMAP_SHADER_SRC: &str = include_str!("../shaders/tonemap.wgsl");
15
16// ---------------------------------------------------------------------------
17// Uniform structs (GPU-uploadable)
18// ---------------------------------------------------------------------------
19
20#[repr(C)]
21#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
22struct BloomUniforms {
23    threshold: f32,
24    intensity: f32,
25    texel_size: [f32; 2],
26}
27
28#[repr(C)]
29#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
30struct TonemapUniforms {
31    exposure: f32,
32    bloom_intensity: f32,
33    tonemap_operator: u32,
34    dof_enabled: u32,
35    fog_enabled: u32,
36    // WGSL: _pad: vec3<f32> has alignment 16 → placed at offset 32 in std140.
37    // 12 bytes implicit padding (offset 20→32) + 12 bytes vec3<f32> + 4 bytes struct tail.
38    _pad_align: [f32; 3], // 12B gap for vec3 alignment (offset 20→32)
39    _pad: [f32; 3],       // matches WGSL vec3<f32> at offset 32 (12B)
40    _pad_tail: f32,       // struct alignment padding to 48 (WGSL rounds to 16)
41}
42// Total: 48 bytes = 5*4 + 12 + 12 + 4 = 48 ✓
43
44// ---------------------------------------------------------------------------
45// PostProcessConfig
46// ---------------------------------------------------------------------------
47
48/// Post-processing configuration.
49#[derive(Debug, Clone)]
50pub struct PostProcessConfig {
51    pub bloom_enabled: bool,
52    pub bloom_threshold: f32,
53    pub bloom_intensity: f32,
54    pub tonemap_enabled: bool,
55    pub tonemap_operator: TonemapOperator,
56    pub exposure: f32,
57}
58
59impl Default for PostProcessConfig {
60    fn default() -> Self {
61        Self {
62            bloom_enabled: false,
63            bloom_threshold: 1.0,
64            bloom_intensity: 0.5,
65            tonemap_enabled: false,
66            tonemap_operator: TonemapOperator::AcesFilmic,
67            exposure: 1.0,
68        }
69    }
70}
71
72impl PostProcessConfig {
73    /// PBR-optimized configuration: HDR + bloom + ACES filmic tonemapping.
74    /// Suitable as the default when SceneDreamMode is PbrDefault or PbrLightweight.
75    pub fn for_pbr() -> Self {
76        Self {
77            bloom_enabled: true,
78            bloom_threshold: 1.0,
79            bloom_intensity: 0.04,
80            tonemap_enabled: true,
81            tonemap_operator: TonemapOperator::AcesFilmic,
82            exposure: 1.0,
83        }
84    }
85
86    /// Demo-friendly configuration: subtle bloom, restrained exposure.
87    /// Avoids the washed-out look on bright scenes.
88    pub fn for_demo() -> Self {
89        Self {
90            bloom_enabled: true,
91            bloom_threshold: 1.5,
92            bloom_intensity: 0.02,
93            tonemap_enabled: true,
94            tonemap_operator: TonemapOperator::AcesFilmic,
95            exposure: 0.8,
96        }
97    }
98}
99
100// ---------------------------------------------------------------------------
101// BloomMip (internal)
102// ---------------------------------------------------------------------------
103
104struct BloomMip {
105    /// Retained for GPU resource lifetime — dropping frees the underlying allocation.
106    _texture: wgpu::Texture,
107    view: wgpu::TextureView,
108    width: u32,
109    height: u32,
110}
111
112// ---------------------------------------------------------------------------
113// PostProcessStack
114// ---------------------------------------------------------------------------
115
116/// Post-processing stack — HDR framebuffer, bloom mip chain, tonemapping.
117pub struct PostProcessStack {
118    config: PostProcessConfig,
119    // HDR scene target
120    hdr_texture: Option<wgpu::Texture>,
121    hdr_view: Option<wgpu::TextureView>,
122    hdr_depth_texture: Option<wgpu::Texture>,
123    hdr_depth_view: Option<wgpu::TextureView>,
124    width: u32,
125    height: u32,
126    // Bloom (2-level mip chain)
127    bloom_mips: [Option<BloomMip>; 2],
128    bloom_downsample_pipeline: Option<wgpu::RenderPipeline>,
129    bloom_upsample_pipeline: Option<wgpu::RenderPipeline>,
130    bloom_bgl: Option<wgpu::BindGroupLayout>,
131    bloom_uniform_buf: Option<wgpu::Buffer>,
132    // Tonemap
133    tonemap_pipeline: Option<wgpu::RenderPipeline>,
134    tonemap_bgl: Option<wgpu::BindGroupLayout>,
135    tonemap_uniform_buf: Option<wgpu::Buffer>,
136    // Shared
137    sampler: Option<wgpu::Sampler>,
138    black_texture: Option<(wgpu::Texture, wgpu::TextureView)>,
139    surface_format: wgpu::TextureFormat,
140    initialized: bool,
141    // Bind group caches (invalidated on resize)
142    cached_tonemap_bg: Option<wgpu::BindGroup>,
143    cached_bloom_bgs: [Option<wgpu::BindGroup>; 3],
144}
145
146impl PostProcessStack {
147    /// Create a new uninitialized post-processing stack.
148    pub fn new(surface_format: wgpu::TextureFormat) -> Self {
149        Self {
150            config: PostProcessConfig::default(),
151            hdr_texture: None,
152            hdr_view: None,
153            hdr_depth_texture: None,
154            hdr_depth_view: None,
155            width: 0,
156            height: 0,
157            bloom_mips: [None, None],
158            bloom_downsample_pipeline: None,
159            bloom_upsample_pipeline: None,
160            bloom_bgl: None,
161            bloom_uniform_buf: None,
162            tonemap_pipeline: None,
163            tonemap_bgl: None,
164            tonemap_uniform_buf: None,
165            sampler: None,
166            black_texture: None,
167            surface_format,
168            initialized: false,
169            cached_tonemap_bg: None,
170            cached_bloom_bgs: [None, None, None],
171        }
172    }
173
174    /// Current configuration.
175    pub fn config(&self) -> &PostProcessConfig {
176        &self.config
177    }
178
179    /// Update configuration.
180    pub fn set_config(&mut self, config: PostProcessConfig) {
181        self.config = config;
182    }
183
184    /// Whether any post-processing is active.
185    pub fn is_enabled(&self) -> bool {
186        self.config.bloom_enabled || self.config.tonemap_enabled
187    }
188
189    // -----------------------------------------------------------------------
190    // Resource allocation
191    // -----------------------------------------------------------------------
192
193    /// Allocate (or reallocate) GPU resources for the given framebuffer dimensions.
194    pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) {
195        if width == 0 || height == 0 {
196            return;
197        }
198        if self.initialized && self.width == width && self.height == height {
199            return;
200        }
201
202        // Invalidate cached bind groups.
203        self.cached_tonemap_bg = None;
204        self.cached_bloom_bgs = [None, None, None];
205
206        // Drop old resources.
207        self.hdr_texture = None;
208        self.hdr_view = None;
209        self.hdr_depth_texture = None;
210        self.hdr_depth_view = None;
211        self.bloom_mips = [None, None];
212
213        self.width = width;
214        self.height = height;
215
216        // HDR color target.
217        let hdr_tex = device.create_texture(&wgpu::TextureDescriptor {
218            label: Some("post_hdr_color"),
219            size: wgpu::Extent3d {
220                width,
221                height,
222                depth_or_array_layers: 1,
223            },
224            mip_level_count: 1,
225            sample_count: 1,
226            dimension: wgpu::TextureDimension::D2,
227            format: HDR_FORMAT,
228            // TD-006: COPY_DST allows TAA/Dream TSR resolved output to be copied back to HDR.
229            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
230                | wgpu::TextureUsages::TEXTURE_BINDING
231                | wgpu::TextureUsages::COPY_DST,
232            view_formats: &[],
233        });
234        self.hdr_view = Some(hdr_tex.create_view(&wgpu::TextureViewDescriptor::default()));
235        self.hdr_texture = Some(hdr_tex);
236
237        // HDR depth target.
238        // FORMAT CONTRACT: Uses DEPTH_FORMAT (Depth32Float) from crate::formats.
239        // All passes that reference this depth buffer MUST use the same format:
240        //   - Main render pass depth attachment (fabric frame.rs)
241        //   - Hi-Z downsample input (hiz.rs)
242        //   - Screen-space effect depth reads (ssao, ssr, dof, ssgi, taa)
243        //   - RT shadow/GI depth texture binding (rt_shadows.rs, rt_gi.rs)
244        // Mismatched formats cause wgpu validation errors at render pass creation.
245        let depth_tex = device.create_texture(&wgpu::TextureDescriptor {
246            label: Some("post_hdr_depth"),
247            size: wgpu::Extent3d {
248                width,
249                height,
250                depth_or_array_layers: 1,
251            },
252            mip_level_count: 1,
253            sample_count: 1,
254            dimension: wgpu::TextureDimension::D2,
255            format: DEPTH_FORMAT,
256            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
257            view_formats: &[],
258        });
259        self.hdr_depth_view = Some(depth_tex.create_view(&wgpu::TextureViewDescriptor::default()));
260        self.hdr_depth_texture = Some(depth_tex);
261
262        // Bloom mip chain: half-res and quarter-res.
263        // .max(1) on results prevents zero-dimension textures at very small framebuffer sizes.
264        let mip_sizes = [
265            ((width / 2).max(1), (height / 2).max(1)),
266            ((width / 4).max(1), (height / 4).max(1)),
267        ];
268        for (i, &(mw, mh)) in mip_sizes.iter().enumerate() {
269            let tex = device.create_texture(&wgpu::TextureDescriptor {
270                label: Some(&format!("post_bloom_mip{i}")),
271                size: wgpu::Extent3d {
272                    width: mw,
273                    height: mh,
274                    depth_or_array_layers: 1,
275                },
276                mip_level_count: 1,
277                sample_count: 1,
278                dimension: wgpu::TextureDimension::D2,
279                format: HDR_FORMAT,
280                usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
281                view_formats: &[],
282            });
283            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
284            self.bloom_mips[i] = Some(BloomMip {
285                _texture: tex,
286                view,
287                width: mw,
288                height: mh,
289            });
290        }
291
292        // Shared sampler (create once).
293        if self.sampler.is_none() {
294            self.sampler = Some(device.create_sampler(&wgpu::SamplerDescriptor {
295                label: Some("post_sampler"),
296                address_mode_u: wgpu::AddressMode::ClampToEdge,
297                address_mode_v: wgpu::AddressMode::ClampToEdge,
298                address_mode_w: wgpu::AddressMode::ClampToEdge,
299                mag_filter: wgpu::FilterMode::Linear,
300                min_filter: wgpu::FilterMode::Linear,
301                mipmap_filter: wgpu::MipmapFilterMode::Linear,
302                ..Default::default()
303            }));
304        }
305
306        // 1x1 black fallback for bloom (create once).
307        if self.black_texture.is_none() {
308            let tex = device.create_texture(&wgpu::TextureDescriptor {
309                label: Some("post_black_1x1"),
310                size: wgpu::Extent3d {
311                    width: 1,
312                    height: 1,
313                    depth_or_array_layers: 1,
314                },
315                mip_level_count: 1,
316                sample_count: 1,
317                dimension: wgpu::TextureDimension::D2,
318                format: HDR_FORMAT,
319                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
320                view_formats: &[],
321            });
322            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
323            self.black_texture = Some((tex, view));
324        }
325
326        self.initialized = true;
327    }
328
329    // -----------------------------------------------------------------------
330    // Pipeline creation
331    // -----------------------------------------------------------------------
332
333    /// Create pipelines and bind group layouts if they do not exist yet.
334    pub fn ensure_pipelines(&mut self, device: &wgpu::Device) {
335        if self.bloom_downsample_pipeline.is_some() {
336            return;
337        }
338
339        // --- Bloom BGL ---
340        let bloom_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
341            label: Some("post_bloom_bgl"),
342            entries: &[
343                wgpu::BindGroupLayoutEntry {
344                    binding: 0,
345                    visibility: wgpu::ShaderStages::FRAGMENT,
346                    ty: wgpu::BindingType::Texture {
347                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
348                        view_dimension: wgpu::TextureViewDimension::D2,
349                        multisampled: false,
350                    },
351                    count: None,
352                },
353                wgpu::BindGroupLayoutEntry {
354                    binding: 1,
355                    visibility: wgpu::ShaderStages::FRAGMENT,
356                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
357                    count: None,
358                },
359                wgpu::BindGroupLayoutEntry {
360                    binding: 2,
361                    visibility: wgpu::ShaderStages::FRAGMENT,
362                    ty: wgpu::BindingType::Buffer {
363                        ty: wgpu::BufferBindingType::Uniform,
364                        has_dynamic_offset: false,
365                        min_binding_size: None,
366                    },
367                    count: None,
368                },
369            ],
370        });
371
372        let bloom_shader = shader::load_wgsl(device, "bloom", BLOOM_SHADER_SRC).0;
373
374        let bloom_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
375            label: Some("post_bloom_pipeline_layout"),
376            bind_group_layouts: &[Some(&bloom_bgl)],
377            immediate_size: 0,
378        });
379
380        // Bloom downsample pipeline.
381        self.bloom_downsample_pipeline = Some(device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
382            label: Some("post_bloom_downsample"),
383            layout: Some(&bloom_pipeline_layout),
384            vertex: wgpu::VertexState {
385                module: &bloom_shader,
386                entry_point: Some("vs_main"),
387                buffers: &[],
388                compilation_options: Default::default(),
389            },
390            fragment: Some(wgpu::FragmentState {
391                module: &bloom_shader,
392                entry_point: Some("fs_downsample"),
393                targets: &[Some(wgpu::ColorTargetState {
394                    format: HDR_FORMAT,
395                    blend: None,
396                    write_mask: wgpu::ColorWrites::ALL,
397                })],
398                compilation_options: Default::default(),
399            }),
400            primitive: wgpu::PrimitiveState {
401                topology: wgpu::PrimitiveTopology::TriangleList,
402                ..Default::default()
403            },
404            depth_stencil: None,
405            multisample: wgpu::MultisampleState::default(),
406            multiview_mask: None,
407            cache: None,
408        }));
409
410        // Bloom upsample pipeline (additive blend: src One, dst One).
411        self.bloom_upsample_pipeline = Some(device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
412            label: Some("post_bloom_upsample"),
413            layout: Some(&bloom_pipeline_layout),
414            vertex: wgpu::VertexState {
415                module: &bloom_shader,
416                entry_point: Some("vs_main"),
417                buffers: &[],
418                compilation_options: Default::default(),
419            },
420            fragment: Some(wgpu::FragmentState {
421                module: &bloom_shader,
422                entry_point: Some("fs_upsample"),
423                targets: &[Some(wgpu::ColorTargetState {
424                    format: HDR_FORMAT,
425                    blend: Some(wgpu::BlendState {
426                        color: wgpu::BlendComponent {
427                            src_factor: wgpu::BlendFactor::One,
428                            dst_factor: wgpu::BlendFactor::One,
429                            operation: wgpu::BlendOperation::Add,
430                        },
431                        alpha: wgpu::BlendComponent {
432                            src_factor: wgpu::BlendFactor::One,
433                            dst_factor: wgpu::BlendFactor::One,
434                            operation: wgpu::BlendOperation::Add,
435                        },
436                    }),
437                    write_mask: wgpu::ColorWrites::ALL,
438                })],
439                compilation_options: Default::default(),
440            }),
441            primitive: wgpu::PrimitiveState {
442                topology: wgpu::PrimitiveTopology::TriangleList,
443                ..Default::default()
444            },
445            depth_stencil: None,
446            multisample: wgpu::MultisampleState::default(),
447            multiview_mask: None,
448            cache: None,
449        }));
450
451        // Bloom uniform buffer.
452        self.bloom_uniform_buf = Some(shader::create_uniform_buffer(
453            device,
454            "post_bloom_uniforms",
455            &BloomUniforms {
456                threshold: self.config.bloom_threshold,
457                intensity: self.config.bloom_intensity,
458                texel_size: [0.0, 0.0],
459            },
460        ));
461
462        self.bloom_bgl = Some(bloom_bgl);
463
464        // --- Tonemap BGL ---
465        let tonemap_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
466            label: Some("post_tonemap_bgl"),
467            entries: &[
468                wgpu::BindGroupLayoutEntry {
469                    binding: 0,
470                    visibility: wgpu::ShaderStages::FRAGMENT,
471                    ty: wgpu::BindingType::Texture {
472                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
473                        view_dimension: wgpu::TextureViewDimension::D2,
474                        multisampled: false,
475                    },
476                    count: None,
477                },
478                wgpu::BindGroupLayoutEntry {
479                    binding: 1,
480                    visibility: wgpu::ShaderStages::FRAGMENT,
481                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
482                    count: None,
483                },
484                wgpu::BindGroupLayoutEntry {
485                    binding: 2,
486                    visibility: wgpu::ShaderStages::FRAGMENT,
487                    ty: wgpu::BindingType::Buffer {
488                        ty: wgpu::BufferBindingType::Uniform,
489                        has_dynamic_offset: false,
490                        min_binding_size: None,
491                    },
492                    count: None,
493                },
494                wgpu::BindGroupLayoutEntry {
495                    binding: 3,
496                    visibility: wgpu::ShaderStages::FRAGMENT,
497                    ty: wgpu::BindingType::Texture {
498                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
499                        view_dimension: wgpu::TextureViewDimension::D2,
500                        multisampled: false,
501                    },
502                    count: None,
503                },
504                // TD-004: DOF texture (binding 4)
505                wgpu::BindGroupLayoutEntry {
506                    binding: 4,
507                    visibility: wgpu::ShaderStages::FRAGMENT,
508                    ty: wgpu::BindingType::Texture {
509                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
510                        view_dimension: wgpu::TextureViewDimension::D2,
511                        multisampled: false,
512                    },
513                    count: None,
514                },
515                // TD-008: Volumetric fog texture (binding 5)
516                wgpu::BindGroupLayoutEntry {
517                    binding: 5,
518                    visibility: wgpu::ShaderStages::FRAGMENT,
519                    ty: wgpu::BindingType::Texture {
520                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
521                        view_dimension: wgpu::TextureViewDimension::D2,
522                        multisampled: false,
523                    },
524                    count: None,
525                },
526            ],
527        });
528
529        let tonemap_shader = shader::load_wgsl(device, "tonemap", TONEMAP_SHADER_SRC).0;
530
531        let tonemap_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
532            label: Some("post_tonemap_pipeline_layout"),
533            bind_group_layouts: &[Some(&tonemap_bgl)],
534            immediate_size: 0,
535        });
536
537        self.tonemap_pipeline = Some(device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
538            label: Some("post_tonemap"),
539            layout: Some(&tonemap_pipeline_layout),
540            vertex: wgpu::VertexState {
541                module: &tonemap_shader,
542                entry_point: Some("vs_main"),
543                buffers: &[],
544                compilation_options: Default::default(),
545            },
546            fragment: Some(wgpu::FragmentState {
547                module: &tonemap_shader,
548                entry_point: Some("fs_main"),
549                targets: &[Some(wgpu::ColorTargetState {
550                    format: self.surface_format,
551                    blend: None,
552                    write_mask: wgpu::ColorWrites::ALL,
553                })],
554                compilation_options: Default::default(),
555            }),
556            primitive: wgpu::PrimitiveState {
557                topology: wgpu::PrimitiveTopology::TriangleList,
558                ..Default::default()
559            },
560            depth_stencil: None,
561            multisample: wgpu::MultisampleState::default(),
562            multiview_mask: None,
563            cache: None,
564        }));
565
566        // Tonemap uniform buffer.
567        self.tonemap_uniform_buf = Some(shader::create_uniform_buffer(
568            device,
569            "post_tonemap_uniforms",
570            &TonemapUniforms {
571                exposure: self.config.exposure,
572                bloom_intensity: self.config.bloom_intensity,
573                tonemap_operator: tonemap_operator_to_u32(self.config.tonemap_operator),
574                dof_enabled: 0,
575                fog_enabled: 0,
576                _pad_align: [0.0; 3],
577                _pad: [0.0; 3],
578                _pad_tail: 0.0,
579            },
580        ));
581
582        self.tonemap_bgl = Some(tonemap_bgl);
583    }
584
585    // -----------------------------------------------------------------------
586    // Accessors
587    // -----------------------------------------------------------------------
588
589    /// HDR color view for the main-pass render target.
590    pub fn hdr_view(&self) -> Option<&wgpu::TextureView> {
591        self.hdr_view.as_ref()
592    }
593
594    /// TD-006: HDR texture for TAA/Dream TSR output swap (copy-to-texture target).
595    pub fn hdr_texture(&self) -> Option<&wgpu::Texture> {
596        self.hdr_texture.as_ref()
597    }
598
599    /// HDR depth view for the main-pass depth attachment.
600    pub fn hdr_depth_view(&self) -> Option<&wgpu::TextureView> {
601        self.hdr_depth_view.as_ref()
602    }
603
604    // -----------------------------------------------------------------------
605    // Bloom pass
606    // -----------------------------------------------------------------------
607
608    /// Execute the bloom downsample + upsample passes.
609    ///
610    /// Expects `resize` and `ensure_pipelines` to have been called.
611    pub fn run_bloom(&self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder) {
612        let (Some(bloom_bgl), Some(sampler), Some(bloom_buf)) =
613            (&self.bloom_bgl, &self.sampler, &self.bloom_uniform_buf)
614        else {
615            return;
616        };
617        let (Some(downsample_pipe), Some(upsample_pipe)) =
618            (&self.bloom_downsample_pipeline, &self.bloom_upsample_pipeline)
619        else {
620            return;
621        };
622        let (Some(hdr_view), Some(mip0), Some(mip1)) = (&self.hdr_view, &self.bloom_mips[0], &self.bloom_mips[1])
623        else {
624            return;
625        };
626
627        // -- Pass 1: Downsample HDR -> mip[0] --
628        let texel_size_0 = [1.0 / mip0.width as f32, 1.0 / mip0.height as f32];
629        shader::update_uniform_buffer(
630            queue,
631            bloom_buf,
632            &BloomUniforms {
633                threshold: self.config.bloom_threshold,
634                intensity: self.config.bloom_intensity,
635                texel_size: texel_size_0,
636            },
637        );
638
639        let bg_down0 = create_bloom_bind_group(device, bloom_bgl, hdr_view, sampler, bloom_buf);
640        {
641            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
642                label: Some("bloom_downsample_0"),
643                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
644                    view: &mip0.view,
645                    resolve_target: None,
646                    ops: wgpu::Operations {
647                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
648                        store: wgpu::StoreOp::Store,
649                    },
650                    depth_slice: None,
651                })],
652                depth_stencil_attachment: None,
653                timestamp_writes: None,
654                occlusion_query_set: None,
655                multiview_mask: None,
656            });
657            pass.set_pipeline(downsample_pipe);
658            pass.set_bind_group(0, &bg_down0, &[]);
659            pass.draw(0..3, 0..1);
660        }
661
662        // -- Pass 2: Downsample mip[0] -> mip[1] --
663        let texel_size_1 = [1.0 / mip1.width as f32, 1.0 / mip1.height as f32];
664        shader::update_uniform_buffer(
665            queue,
666            bloom_buf,
667            &BloomUniforms {
668                threshold: 0.0, // already thresholded
669                intensity: self.config.bloom_intensity,
670                texel_size: texel_size_1,
671            },
672        );
673
674        let bg_down1 = create_bloom_bind_group(device, bloom_bgl, &mip0.view, sampler, bloom_buf);
675        {
676            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
677                label: Some("bloom_downsample_1"),
678                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
679                    view: &mip1.view,
680                    resolve_target: None,
681                    ops: wgpu::Operations {
682                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
683                        store: wgpu::StoreOp::Store,
684                    },
685                    depth_slice: None,
686                })],
687                depth_stencil_attachment: None,
688                timestamp_writes: None,
689                occlusion_query_set: None,
690                multiview_mask: None,
691            });
692            pass.set_pipeline(downsample_pipe);
693            pass.set_bind_group(0, &bg_down1, &[]);
694            pass.draw(0..3, 0..1);
695        }
696
697        // -- Pass 3: Upsample mip[1] -> mip[0] (additive) --
698        shader::update_uniform_buffer(
699            queue,
700            bloom_buf,
701            &BloomUniforms {
702                threshold: 0.0,
703                intensity: self.config.bloom_intensity,
704                texel_size: texel_size_0,
705            },
706        );
707
708        let bg_up = create_bloom_bind_group(device, bloom_bgl, &mip1.view, sampler, bloom_buf);
709        {
710            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
711                label: Some("bloom_upsample"),
712                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
713                    view: &mip0.view,
714                    resolve_target: None,
715                    ops: wgpu::Operations {
716                        load: wgpu::LoadOp::Load,
717                        store: wgpu::StoreOp::Store,
718                    },
719                    depth_slice: None,
720                })],
721                depth_stencil_attachment: None,
722                timestamp_writes: None,
723                occlusion_query_set: None,
724                multiview_mask: None,
725            });
726            pass.set_pipeline(upsample_pipe);
727            pass.set_bind_group(0, &bg_up, &[]);
728            pass.draw(0..3, 0..1);
729        }
730    }
731
732    // -----------------------------------------------------------------------
733    // Tonemap pass
734    // -----------------------------------------------------------------------
735
736    /// Execute the tonemapping pass, writing to the surface view.
737    ///
738    /// Expects `resize` and `ensure_pipelines` to have been called.
739    pub fn run_tonemap(
740        &self,
741        device: &wgpu::Device,
742        queue: &wgpu::Queue,
743        encoder: &mut wgpu::CommandEncoder,
744        surface_view: &wgpu::TextureView,
745    ) {
746        let (Some(tonemap_bgl), Some(sampler), Some(tonemap_buf)) =
747            (&self.tonemap_bgl, &self.sampler, &self.tonemap_uniform_buf)
748        else {
749            return;
750        };
751        let (Some(tonemap_pipe), Some(hdr_view)) = (&self.tonemap_pipeline, &self.hdr_view) else {
752            return;
753        };
754
755        // Update tonemap uniforms (TD-004 + TD-008: DOF + fog flags).
756        shader::update_uniform_buffer(
757            queue,
758            tonemap_buf,
759            &TonemapUniforms {
760                exposure: self.config.exposure,
761                bloom_intensity: if self.config.bloom_enabled {
762                    self.config.bloom_intensity
763                } else {
764                    0.0
765                },
766                tonemap_operator: tonemap_operator_to_u32(self.config.tonemap_operator),
767                dof_enabled: 0, // DOF compositing controlled by caller via config
768                fog_enabled: 0, // Fog compositing controlled by caller via config
769                _pad_align: [0.0; 3],
770                _pad: [0.0; 3],
771                _pad_tail: 0.0,
772            },
773        );
774
775        // Bloom source: mip[0] if bloom ran, else 1x1 black fallback.
776        let bloom_view = if self.config.bloom_enabled {
777            self.bloom_mips[0].as_ref().map(|m| &m.view)
778        } else {
779            None
780        };
781        let bloom_view = bloom_view
782            .or_else(|| self.black_texture.as_ref().map(|(_, v)| v))
783            .expect("black_texture should be initialized after resize");
784
785        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
786            label: Some("post_tonemap_bg"),
787            layout: tonemap_bgl,
788            entries: &[
789                wgpu::BindGroupEntry {
790                    binding: 0,
791                    resource: wgpu::BindingResource::TextureView(hdr_view),
792                },
793                wgpu::BindGroupEntry {
794                    binding: 1,
795                    resource: wgpu::BindingResource::Sampler(sampler),
796                },
797                wgpu::BindGroupEntry {
798                    binding: 2,
799                    resource: tonemap_buf.as_entire_binding(),
800                },
801                wgpu::BindGroupEntry {
802                    binding: 3,
803                    resource: wgpu::BindingResource::TextureView(bloom_view),
804                },
805                // TD-004: DOF fallback (1x1 black = no DOF)
806                wgpu::BindGroupEntry {
807                    binding: 4,
808                    resource: wgpu::BindingResource::TextureView(
809                        self.black_texture
810                            .as_ref()
811                            .map(|(_, v)| v)
812                            .expect("black_texture should be initialized after resize"),
813                    ),
814                },
815                // TD-008: Volumetric fog fallback (1x1 black = no fog)
816                wgpu::BindGroupEntry {
817                    binding: 5,
818                    resource: wgpu::BindingResource::TextureView(
819                        self.black_texture
820                            .as_ref()
821                            .map(|(_, v)| v)
822                            .expect("black_texture should be initialized after resize"),
823                    ),
824                },
825            ],
826        });
827
828        {
829            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
830                label: Some("tonemap"),
831                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
832                    view: surface_view,
833                    resolve_target: None,
834                    ops: wgpu::Operations {
835                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
836                        store: wgpu::StoreOp::Store,
837                    },
838                    depth_slice: None,
839                })],
840                depth_stencil_attachment: None,
841                timestamp_writes: None,
842                occlusion_query_set: None,
843                multiview_mask: None,
844            });
845            pass.set_pipeline(tonemap_pipe);
846            pass.set_bind_group(0, &bind_group, &[]);
847            pass.draw(0..3, 0..1);
848        }
849    }
850
851    // -----------------------------------------------------------------------
852    // Cleanup
853    // -----------------------------------------------------------------------
854
855    /// Destroy all GPU resources held by this stack.
856    pub fn destroy(&mut self) {
857        self.hdr_texture = None;
858        self.hdr_view = None;
859        self.hdr_depth_texture = None;
860        self.hdr_depth_view = None;
861        self.bloom_mips = [None, None];
862        self.bloom_downsample_pipeline = None;
863        self.bloom_upsample_pipeline = None;
864        self.bloom_bgl = None;
865        self.bloom_uniform_buf = None;
866        self.tonemap_pipeline = None;
867        self.tonemap_bgl = None;
868        self.tonemap_uniform_buf = None;
869        self.sampler = None;
870        self.black_texture = None;
871        self.initialized = false;
872        self.width = 0;
873        self.height = 0;
874    }
875}
876
877// ---------------------------------------------------------------------------
878// Free-standing helpers
879// ---------------------------------------------------------------------------
880
881fn create_bloom_bind_group(
882    device: &wgpu::Device,
883    layout: &wgpu::BindGroupLayout,
884    src_view: &wgpu::TextureView,
885    sampler: &wgpu::Sampler,
886    uniform_buf: &wgpu::Buffer,
887) -> wgpu::BindGroup {
888    device.create_bind_group(&wgpu::BindGroupDescriptor {
889        label: Some("post_bloom_bg"),
890        layout,
891        entries: &[
892            wgpu::BindGroupEntry {
893                binding: 0,
894                resource: wgpu::BindingResource::TextureView(src_view),
895            },
896            wgpu::BindGroupEntry {
897                binding: 1,
898                resource: wgpu::BindingResource::Sampler(sampler),
899            },
900            wgpu::BindGroupEntry {
901                binding: 2,
902                resource: uniform_buf.as_entire_binding(),
903            },
904        ],
905    })
906}
907
908fn tonemap_operator_to_u32(op: TonemapOperator) -> u32 {
909    match op {
910        TonemapOperator::AcesFilmic => 0,
911        TonemapOperator::Uncharted2 => 1,
912        TonemapOperator::Reinhard => 2,
913        TonemapOperator::None => 3,
914    }
915}
916
917// ---------------------------------------------------------------------------
918// Tests
919// ---------------------------------------------------------------------------
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    #[test]
926    fn default_config_disabled() {
927        let config = PostProcessConfig::default();
928        assert!(!config.bloom_enabled);
929        assert!(!config.tonemap_enabled);
930        assert_eq!(config.bloom_threshold, 1.0);
931        assert_eq!(config.bloom_intensity, 0.5);
932        assert_eq!(config.exposure, 1.0);
933    }
934
935    #[test]
936    fn config_with_bloom_enabled() {
937        let mut stack = PostProcessStack::new(wgpu::TextureFormat::Bgra8UnormSrgb);
938        stack.set_config(PostProcessConfig {
939            bloom_enabled: true,
940            ..Default::default()
941        });
942        assert!(stack.is_enabled());
943    }
944
945    #[test]
946    fn config_with_tonemap_enabled() {
947        let mut stack = PostProcessStack::new(wgpu::TextureFormat::Bgra8UnormSrgb);
948        stack.set_config(PostProcessConfig {
949            tonemap_enabled: true,
950            ..Default::default()
951        });
952        assert!(stack.is_enabled());
953    }
954
955    #[test]
956    fn hdr_format_is_rgba16float() {
957        assert_eq!(HDR_FORMAT, wgpu::TextureFormat::Rgba16Float);
958    }
959
960    #[test]
961    fn for_pbr_config() {
962        let config = PostProcessConfig::for_pbr();
963        assert!(config.bloom_enabled);
964        assert!(config.tonemap_enabled);
965        assert_eq!(config.bloom_threshold, 1.0);
966        assert_eq!(config.bloom_intensity, 0.04);
967        assert_eq!(config.exposure, 1.0);
968    }
969
970    #[test]
971    fn new_not_initialized() {
972        let stack = PostProcessStack::new(wgpu::TextureFormat::Bgra8UnormSrgb);
973        assert!(!stack.initialized);
974        assert!(!stack.is_enabled());
975        assert!(stack.hdr_view().is_none());
976        assert!(stack.hdr_depth_view().is_none());
977    }
978}