gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main llimphi-3d/src/postfx.rs Rust · 653 siq'ikuna · 25.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! `PostFx` — **antialiasing por supersampling (SSAA) + bloom** genéricos como
2//! pase de post-proceso reutilizable sobre cualquier render 3D wgpu.
3//!
4//! Cosechado del renderer 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde
5//! estos efectos nacieron pegados a tipos del juego. Aquí viven en su forma
6//! agnóstica: nada sabe de paredes, sprites ni WAD. Cualquier escena que se
7//! pueda dibujar en un pase wgpu (las mallas de [`Renderer3d`](crate::Renderer3d),
8//! los voxels de [`VoxelRenderer`](crate::VoxelRenderer), una carta 3D de cosmos,
9//! una galería de nahual) obtiene AA + glow «gratis» envolviendo su dibujo en
10//! [`PostFx::render_with`].
11//!
12//! ## Flujo (igual que el de supay, sin sus tipos)
13//!
14//! 1. La escena se rinde a un color intermedio a `supersample`× la resolución
15//!    de salida (4× de fragmentos con factor 2) con su propio depth.
16//! 2. **Bright-pass + blur 5×5** de ese color → textura de bloom a media res
17//!    (extrae lo que supera el umbral de luminancia y lo suaviza).
18//! 3. **Blit final**: baja la escena al target real con filtro lineal (eso es
19//!    el antialiasing) y le suma el bloom (eso es el glow). Compone con
20//!    `LoadOp::Load`, así no pisa la UI vello que ya esté debajo.
21//!
22//! ## Uso
23//!
24//! ```ignore
25//! let mut fx = PostFx::new(&device, color_format);
26//! // ... en gpu_paint_with(dev, q, enc, view, rect, _vp):
27//! fx.render_with(dev, q, enc, view, (w, h), wgpu::Color::BLACK, |pass| {
28//!     // `pass` ya está a supersample× con color+depth; sólo dibuja.
29//!     renderer3d.upload(q, w as f32 / h as f32, &camera); // antes del pase
30//!     renderer3d.draw(pass);
31//! });
32//! ```
33
34use crate::scene::DEPTH_FORMAT;
35
36/// Parámetros del post-proceso. Los defaults replican el «look» de supay
37/// (supersample 2×, glow suave). Ajustables por escena: una carta astral quiere
38/// poco bloom, un FPS infernal mucho.
39#[derive(Clone, Copy, Debug)]
40pub struct PostFxConfig {
41    /// Factor de supersampling por eje. 2 = rinde a 2× y promedia al bajar
42    /// (antialiasing 4×). 1 = sin SSAA (sólo bloom). Útil bajarlo en GPUs flojas.
43    pub supersample: u32,
44    /// Cuánto se suma el bloom al blit final (0 = sin glow).
45    pub bloom_strength: f32,
46    /// Umbral de luminancia del bright-pass: por encima de esto un píxel sangra.
47    pub bloom_threshold: f32,
48    /// Rampa del bright-pass (knee): ancho de la transición sobre el umbral.
49    pub bloom_knee: f32,
50    /// Radio del blur del bright-pass, en téxeles de la textura de bloom.
51    pub bloom_radius: f32,
52}
53
54impl Default for PostFxConfig {
55    fn default() -> Self {
56        Self {
57            supersample: 2,
58            bloom_strength: 0.85,
59            bloom_threshold: 0.62,
60            bloom_knee: 0.38,
61            bloom_radius: 2.5,
62        }
63    }
64}
65
66/// Target intermedio de la escena (color supersampleado + depth) más el bind
67/// group que expone su color como textura para el bright-pass y el blit.
68struct SceneTarget {
69    w: u32,
70    h: u32,
71    color_view: wgpu::TextureView,
72    depth_view: wgpu::TextureView,
73    tex_bg: wgpu::BindGroup,
74}
75
76/// Target de bloom (media resolución): color + bind group para sumarlo en el blit.
77struct BloomTarget {
78    w: u32,
79    h: u32,
80    color_view: wgpu::TextureView,
81    tex_bg: wgpu::BindGroup,
82}
83
84/// Pase de post-proceso reutilizable: SSAA + bloom sobre cualquier render wgpu.
85/// Cachea pipelines/sampler/layouts y recrea los targets al cambiar de tamaño.
86pub struct PostFx {
87    cfg: PostFxConfig,
88    output_format: wgpu::TextureFormat,
89    tex_layout: wgpu::BindGroupLayout,
90    sampler: wgpu::Sampler,
91    /// Bright-pass + blur: escena → bloom. group0 = escena, group1 = uniform.
92    bright_pipeline: wgpu::RenderPipeline,
93    /// Blit final: escena + bloom → target. group0 = escena, 1 = bloom, 2 = uniform.
94    blit_pipeline: wgpu::RenderPipeline,
95    bright_uniform: wgpu::Buffer,
96    bright_uniform_bg: wgpu::BindGroup,
97    blit_uniform: wgpu::Buffer,
98    blit_uniform_bg: wgpu::BindGroup,
99    scene: Option<SceneTarget>,
100    bloom: Option<BloomTarget>,
101}
102
103impl PostFx {
104    /// Crea el post-proceso para el `output_format` del target final (el de la
105    /// textura intermedia del frame: `Rgba8Unorm` headless, el de la surface en
106    /// vivo). Config por defecto.
107    pub fn new(device: &wgpu::Device, output_format: wgpu::TextureFormat) -> Self {
108        Self::with_config(device, output_format, PostFxConfig::default())
109    }
110
111    /// Igual que [`Self::new`] con una [`PostFxConfig`] explícita.
112    pub fn with_config(
113        device: &wgpu::Device,
114        output_format: wgpu::TextureFormat,
115        cfg: PostFxConfig,
116    ) -> Self {
117        // Layout textura+sampler (compartido por escena y bloom como entradas).
118        let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
119            label: Some("llimphi-3d-postfx-tex-layout"),
120            entries: &[
121                wgpu::BindGroupLayoutEntry {
122                    binding: 0,
123                    visibility: wgpu::ShaderStages::FRAGMENT,
124                    ty: wgpu::BindingType::Texture {
125                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
126                        view_dimension: wgpu::TextureViewDimension::D2,
127                        multisampled: false,
128                    },
129                    count: None,
130                },
131                wgpu::BindGroupLayoutEntry {
132                    binding: 1,
133                    visibility: wgpu::ShaderStages::FRAGMENT,
134                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
135                    count: None,
136                },
137            ],
138        });
139
140        let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
141            label: Some("llimphi-3d-postfx-uniform-layout"),
142            entries: &[wgpu::BindGroupLayoutEntry {
143                binding: 0,
144                visibility: wgpu::ShaderStages::FRAGMENT,
145                ty: wgpu::BindingType::Buffer {
146                    ty: wgpu::BufferBindingType::Uniform,
147                    has_dynamic_offset: false,
148                    min_binding_size: None,
149                },
150                count: None,
151            }],
152        });
153
154        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
155            label: Some("llimphi-3d-postfx-sampler"),
156            address_mode_u: wgpu::AddressMode::ClampToEdge,
157            address_mode_v: wgpu::AddressMode::ClampToEdge,
158            address_mode_w: wgpu::AddressMode::ClampToEdge,
159            mag_filter: wgpu::FilterMode::Linear,
160            min_filter: wgpu::FilterMode::Linear,
161            mipmap_filter: wgpu::MipmapFilterMode::Linear,
162            ..Default::default()
163        });
164
165        // Helper para pipelines fullscreen (triángulo cubre-pantalla, sin VBO).
166        let make_fullscreen = |layouts: &[Option<&wgpu::BindGroupLayout>],
167                               wgsl: &str,
168                               label: &str| {
169            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
170                label: Some(label),
171                source: wgpu::ShaderSource::Wgsl(wgsl.into()),
172            });
173            let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
174                label: Some(label),
175                bind_group_layouts: layouts,
176                immediate_size: 0,
177            });
178            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
179                label: Some(label),
180                layout: Some(&pl),
181                vertex: wgpu::VertexState {
182                    module: &shader,
183                    entry_point: Some("vs"),
184                    compilation_options: Default::default(),
185                    buffers: &[],
186                },
187                primitive: wgpu::PrimitiveState {
188                    cull_mode: None,
189                    ..Default::default()
190                },
191                depth_stencil: None,
192                multisample: Default::default(),
193                fragment: Some(wgpu::FragmentState {
194                    module: &shader,
195                    entry_point: Some("fs"),
196                    compilation_options: Default::default(),
197                    targets: &[Some(wgpu::ColorTargetState {
198                        format: output_format,
199                        blend: None,
200                        write_mask: wgpu::ColorWrites::ALL,
201                    })],
202                }),
203                multiview_mask: None,
204                cache: None,
205            })
206        };
207
208        let bright_pipeline = make_fullscreen(
209            &[Some(&tex_layout), Some(&uniform_layout)],
210            BRIGHT_WGSL,
211            "llimphi-3d-postfx-bright",
212        );
213        let blit_pipeline = make_fullscreen(
214            &[Some(&tex_layout), Some(&tex_layout), Some(&uniform_layout)],
215            BLIT_WGSL,
216            "llimphi-3d-postfx-blit",
217        );
218
219        let mk_uniform = |label: &str| {
220            device.create_buffer(&wgpu::BufferDescriptor {
221                label: Some(label),
222                size: 16, // un vec4<f32>
223                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
224                mapped_at_creation: false,
225            })
226        };
227        let bright_uniform = mk_uniform("llimphi-3d-postfx-bright-u");
228        let blit_uniform = mk_uniform("llimphi-3d-postfx-blit-u");
229        let mk_uniform_bg = |buf: &wgpu::Buffer, label: &str| {
230            device.create_bind_group(&wgpu::BindGroupDescriptor {
231                label: Some(label),
232                layout: &uniform_layout,
233                entries: &[wgpu::BindGroupEntry {
234                    binding: 0,
235                    resource: buf.as_entire_binding(),
236                }],
237            })
238        };
239        let bright_uniform_bg = mk_uniform_bg(&bright_uniform, "llimphi-3d-postfx-bright-u-bg");
240        let blit_uniform_bg = mk_uniform_bg(&blit_uniform, "llimphi-3d-postfx-blit-u-bg");
241
242        Self {
243            cfg,
244            output_format,
245            tex_layout,
246            sampler,
247            bright_pipeline,
248            blit_pipeline,
249            bright_uniform,
250            bright_uniform_bg,
251            blit_uniform,
252            blit_uniform_bg,
253            scene: None,
254            bloom: None,
255        }
256    }
257
258    /// Config actual (lectura).
259    pub fn config(&self) -> PostFxConfig {
260        self.cfg
261    }
262
263    /// Cambia la config (toma efecto el próximo frame). Si cambia `supersample`
264    /// los targets se recrean solos al verificar el tamaño.
265    pub fn set_config(&mut self, cfg: PostFxConfig) {
266        self.cfg = cfg;
267    }
268
269    /// Formato del target final con el que se creó.
270    pub fn output_format(&self) -> wgpu::TextureFormat {
271        self.output_format
272    }
273
274    /// Tamaño supersampleado al que el llamador debe rendir su escena, dado el
275    /// `output_size` final. (= `output × supersample`.) Útil para el aspect/UI.
276    pub fn scene_size(&self, output_size: (u32, u32)) -> (u32, u32) {
277        let ss = self.cfg.supersample.max(1);
278        (output_size.0 * ss, output_size.1 * ss)
279    }
280
281    /// Asegura los targets internos para una salida `output_size` y sube los
282    /// uniforms del frame. **Llamar antes** de [`Self::scene_pass`] y
283    /// [`Self::resolve`] con el mismo `output_size`.
284    pub fn prepare(
285        &mut self,
286        device: &wgpu::Device,
287        queue: &wgpu::Queue,
288        output_size: (u32, u32),
289    ) {
290        let (ow, oh) = output_size;
291        if ow == 0 || oh == 0 {
292            return;
293        }
294        let (rw, rh) = self.scene_size(output_size);
295        self.ensure_scene(device, rw, rh);
296        self.ensure_bloom(device, (ow / 2).max(1), (oh / 2).max(1));
297        self.upload_uniforms(queue);
298    }
299
300    /// Abre el pase de escena a resolución supersampleada (color limpiado a
301    /// `clear` + depth limpiado a lejano) y devuelve el [`wgpu::RenderPass`] para
302    /// que el llamador setee pipeline(s) y dibuje. Requiere [`Self::prepare`]
303    /// previo en el mismo frame. Al soltar el pase, llamar [`Self::resolve`].
304    ///
305    /// El depth es `Depth32Float` (igual que [`Renderer3d`](crate::Renderer3d) y
306    /// [`Scene3d`](crate::Scene3d)), así sus pipelines encajan sin cambios.
307    pub fn scene_pass<'p>(
308        &'p self,
309        encoder: &'p mut wgpu::CommandEncoder,
310        clear: wgpu::Color,
311    ) -> wgpu::RenderPass<'p> {
312        let scene = self
313            .scene
314            .as_ref()
315            .expect("PostFx::scene_pass sin prepare() previo");
316        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
317            label: Some("llimphi-3d-postfx-scene"),
318            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
319                view: &scene.color_view,
320                resolve_target: None,
321                depth_slice: None,
322                ops: wgpu::Operations {
323                    load: wgpu::LoadOp::Clear(clear),
324                    store: wgpu::StoreOp::Store,
325                },
326            })],
327            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
328                view: &scene.depth_view,
329                depth_ops: Some(wgpu::Operations {
330                    load: wgpu::LoadOp::Clear(1.0),
331                    store: wgpu::StoreOp::Store,
332                }),
333                stencil_ops: None,
334            }),
335            timestamp_writes: None,
336            occlusion_query_set: None,
337            multiview_mask: None,
338        })
339    }
340
341    /// Resuelve la escena ya dibujada: bright-pass + blur → bloom, luego blit
342    /// (bajada SSAA + suma de bloom) sobre **todo** `target`, preservando lo
343    /// que haya debajo (`LoadOp::Load`). Requiere [`Self::prepare`] + el pase
344    /// de [`Self::scene_pass`] ya soltado.
345    pub fn resolve(&self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView) {
346        self.resolve_impl(encoder, target, None);
347    }
348
349    /// Como [`Self::resolve`] pero **confina** el blit final a la sub-región
350    /// `rect = (x, y, w, h)` (px del target, esquina sup-izq) vía
351    /// `set_viewport` + `set_scissor_rect`. Es lo que permite montar el
352    /// post-proceso en un **panel** de una UI sin pisar el chrome alrededor:
353    /// la escena supersampleada se baja ajustada al rect y el resto del
354    /// `target` queda intacto (`LoadOp::Load`). `viewport` = tamaño del
355    /// `target` completo (para clampear el scissor). El bright-pass sigue
356    /// siendo offscreen a media res (no necesita confinarse).
357    pub fn resolve_in(
358        &self,
359        encoder: &mut wgpu::CommandEncoder,
360        target: &wgpu::TextureView,
361        rect: (f32, f32, f32, f32),
362        viewport: (u32, u32),
363    ) {
364        self.resolve_impl(encoder, target, Some((rect, viewport)));
365    }
366
367    fn resolve_impl(
368        &self,
369        encoder: &mut wgpu::CommandEncoder,
370        target: &wgpu::TextureView,
371        confine: Option<((f32, f32, f32, f32), (u32, u32))>,
372    ) {
373        let scene_bg = match self.scene.as_ref() {
374            Some(s) => &s.tex_bg,
375            None => return,
376        };
377        let bloom = self.bloom.as_ref().unwrap();
378
379        // --- Bright-pass + blur: escena → textura de bloom (media res). ---
380        {
381            let mut bp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
382                label: Some("llimphi-3d-postfx-bright"),
383                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
384                    view: &bloom.color_view,
385                    resolve_target: None,
386                    depth_slice: None,
387                    ops: wgpu::Operations {
388                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
389                        store: wgpu::StoreOp::Store,
390                    },
391                })],
392                depth_stencil_attachment: None,
393                timestamp_writes: None,
394                occlusion_query_set: None,
395                multiview_mask: None,
396            });
397            bp.set_pipeline(&self.bright_pipeline);
398            bp.set_bind_group(0, scene_bg, &[]);
399            bp.set_bind_group(1, &self.bright_uniform_bg, &[]);
400            bp.draw(0..3, 0..1);
401        }
402
403        // --- Blit final: escena (SSAA) + bloom → target real (preserva debajo). ---
404        {
405            let mut blit = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
406                label: Some("llimphi-3d-postfx-blit"),
407                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
408                    view: target,
409                    resolve_target: None,
410                    depth_slice: None,
411                    ops: wgpu::Operations {
412                        load: wgpu::LoadOp::Load,
413                        store: wgpu::StoreOp::Store,
414                    },
415                })],
416                depth_stencil_attachment: None,
417                timestamp_writes: None,
418                occlusion_query_set: None,
419                multiview_mask: None,
420            });
421            // Confinar al rect: el viewport mapea NDC→rect (la escena se ajusta
422            // al panel) y el scissor recorta el triángulo fullscreen, que de
423            // otro modo desbordaría el rect y pisaría el chrome.
424            if let Some(((rx, ry, rw, rh), (vw, vh))) = confine {
425                if rw < 1.0 || rh < 1.0 {
426                    return;
427                }
428                blit.set_viewport(rx, ry, rw, rh, 0.0, 1.0);
429                let sx = rx.max(0.0);
430                let sy = ry.max(0.0);
431                let sw = (rw.min(vw as f32 - sx)).max(0.0) as u32;
432                let sh = (rh.min(vh as f32 - sy)).max(0.0) as u32;
433                if sw == 0 || sh == 0 {
434                    return;
435                }
436                blit.set_scissor_rect(sx as u32, sy as u32, sw, sh);
437            }
438            blit.set_pipeline(&self.blit_pipeline);
439            blit.set_bind_group(0, scene_bg, &[]);
440            blit.set_bind_group(1, &bloom.tex_bg, &[]);
441            blit.set_bind_group(2, &self.blit_uniform_bg, &[]);
442            blit.draw(0..3, 0..1);
443        }
444    }
445
446    fn upload_uniforms(&self, queue: &wgpu::Queue) {
447        // bright: (threshold, knee, radius, _)
448        let mut b = Vec::with_capacity(16);
449        for v in [
450            self.cfg.bloom_threshold,
451            self.cfg.bloom_knee.max(1e-4),
452            self.cfg.bloom_radius,
453            0.0,
454        ] {
455            b.extend_from_slice(&v.to_ne_bytes());
456        }
457        queue.write_buffer(&self.bright_uniform, 0, &b);
458        // blit: (bloom_strength, _, _, _)
459        let mut s = Vec::with_capacity(16);
460        for v in [self.cfg.bloom_strength, 0.0, 0.0, 0.0] {
461            s.extend_from_slice(&v.to_ne_bytes());
462        }
463        queue.write_buffer(&self.blit_uniform, 0, &s);
464    }
465
466    fn ensure_scene(&mut self, device: &wgpu::Device, w: u32, h: u32) {
467        if matches!(&self.scene, Some(s) if s.w == w && s.h == h) {
468            return;
469        }
470        let color = device.create_texture(&wgpu::TextureDescriptor {
471            label: Some("llimphi-3d-postfx-scene-color"),
472            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
473            mip_level_count: 1,
474            sample_count: 1,
475            dimension: wgpu::TextureDimension::D2,
476            format: wgpu::TextureFormat::Rgba8Unorm,
477            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
478            view_formats: &[],
479        });
480        let color_view = color.create_view(&Default::default());
481        let depth = device.create_texture(&wgpu::TextureDescriptor {
482            label: Some("llimphi-3d-postfx-scene-depth"),
483            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
484            mip_level_count: 1,
485            sample_count: 1,
486            dimension: wgpu::TextureDimension::D2,
487            format: DEPTH_FORMAT,
488            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
489            view_formats: &[],
490        });
491        let depth_view = depth.create_view(&Default::default());
492        let tex_bg = self.make_tex_bg(device, &color_view, "llimphi-3d-postfx-scene-bg");
493        self.scene = Some(SceneTarget { w, h, color_view, depth_view, tex_bg });
494    }
495
496    fn ensure_bloom(&mut self, device: &wgpu::Device, w: u32, h: u32) {
497        if matches!(&self.bloom, Some(b) if b.w == w && b.h == h) {
498            return;
499        }
500        let color = device.create_texture(&wgpu::TextureDescriptor {
501            label: Some("llimphi-3d-postfx-bloom-color"),
502            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
503            mip_level_count: 1,
504            sample_count: 1,
505            dimension: wgpu::TextureDimension::D2,
506            format: wgpu::TextureFormat::Rgba8Unorm,
507            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
508            view_formats: &[],
509        });
510        let color_view = color.create_view(&Default::default());
511        let tex_bg = self.make_tex_bg(device, &color_view, "llimphi-3d-postfx-bloom-bg");
512        self.bloom = Some(BloomTarget { w, h, color_view, tex_bg });
513    }
514
515    fn make_tex_bg(
516        &self,
517        device: &wgpu::Device,
518        view: &wgpu::TextureView,
519        label: &str,
520    ) -> wgpu::BindGroup {
521        device.create_bind_group(&wgpu::BindGroupDescriptor {
522            label: Some(label),
523            layout: &self.tex_layout,
524            entries: &[
525                wgpu::BindGroupEntry {
526                    binding: 0,
527                    resource: wgpu::BindingResource::TextureView(view),
528                },
529                wgpu::BindGroupEntry {
530                    binding: 1,
531                    resource: wgpu::BindingResource::Sampler(&self.sampler),
532                },
533            ],
534        })
535    }
536}
537
538/// Bright-pass + blur 5×5 de la escena → textura de bloom (media res). En una
539/// pasada: downsample bilinear + extracción de lo brillante + suavizado.
540/// `u.params = (threshold, knee, radius, _)`.
541pub const BRIGHT_WGSL: &str = r#"
542@group(0) @binding(0) var tex: texture_2d<f32>;
543@group(0) @binding(1) var samp: sampler;
544struct U { params: vec4<f32> };
545@group(1) @binding(0) var<uniform> u: U;
546
547struct VOut {
548    @builtin(position) clip: vec4<f32>,
549    @location(0) uv: vec2<f32>,
550};
551
552@vertex
553fn vs(@builtin(vertex_index) vi: u32) -> VOut {
554    var p = array<vec2<f32>, 3>(
555        vec2<f32>(-1.0, -1.0),
556        vec2<f32>( 3.0, -1.0),
557        vec2<f32>(-1.0,  3.0),
558    );
559    var o: VOut;
560    let c = p[vi];
561    o.clip = vec4<f32>(c, 0.0, 1.0);
562    o.uv = vec2<f32>(c.x * 0.5 + 0.5, 1.0 - (c.y * 0.5 + 0.5));
563    return o;
564}
565
566@fragment
567fn fs(in: VOut) -> @location(0) vec4<f32> {
568    let dim = vec2<f32>(textureDimensions(tex, 0));
569    let texel = 1.0 / dim;
570    let thresh = u.params.x;
571    let knee = u.params.y;
572    let radius = u.params.z;
573    var acc = vec3<f32>(0.0);
574    for (var j = -2; j <= 2; j = j + 1) {
575        for (var i = -2; i <= 2; i = i + 1) {
576            let o = vec2<f32>(f32(i), f32(j)) * texel * radius;
577            let c = textureSample(tex, samp, in.uv + o).rgb;
578            let l = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
579            // Bright-pass suave: lo que supera el umbral sangra según el knee.
580            let k = clamp((l - thresh) / knee, 0.0, 1.0);
581            acc += c * k;
582        }
583    }
584    return vec4<f32>(acc / 25.0, 1.0);
585}
586"#;
587
588/// Blit de bajada SSAA + suma de bloom: triángulo fullscreen que muestrea la
589/// escena supersampleada (filtro lineal = antialiasing) y le suma el bloom.
590/// `u.params.x = bloom_strength`.
591pub const BLIT_WGSL: &str = r#"
592@group(0) @binding(0) var tex: texture_2d<f32>;
593@group(0) @binding(1) var samp: sampler;
594@group(1) @binding(0) var bloom: texture_2d<f32>;
595@group(1) @binding(1) var bsamp: sampler;
596struct U { params: vec4<f32> };
597@group(2) @binding(0) var<uniform> u: U;
598
599struct VOut {
600    @builtin(position) clip: vec4<f32>,
601    @location(0) uv: vec2<f32>,
602};
603
604@vertex
605fn vs(@builtin(vertex_index) vi: u32) -> VOut {
606    var p = array<vec2<f32>, 3>(
607        vec2<f32>(-1.0, -1.0),
608        vec2<f32>( 3.0, -1.0),
609        vec2<f32>(-1.0,  3.0),
610    );
611    var o: VOut;
612    let c = p[vi];
613    o.clip = vec4<f32>(c, 0.0, 1.0);
614    o.uv = vec2<f32>(c.x * 0.5 + 0.5, 1.0 - (c.y * 0.5 + 0.5));
615    return o;
616}
617
618@fragment
619fn fs(in: VOut) -> @location(0) vec4<f32> {
620    let s = textureSample(tex, samp, in.uv).rgb;
621    let b = textureSample(bloom, bsamp, in.uv).rgb;
622    return vec4<f32>(s + b * u.params.x, 1.0);
623}
624"#;
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    /// Los shaders WGSL del post-proceso parsean y validan sin GPU (naga).
631    /// Ataja errores que de otro modo sólo saldrían al crear el pipeline.
632    #[test]
633    fn shaders_wgsl_validan() {
634        for (nombre, src) in [("BRIGHT_WGSL", BRIGHT_WGSL), ("BLIT_WGSL", BLIT_WGSL)] {
635            let module = naga::front::wgsl::parse_str(src)
636                .unwrap_or_else(|e| panic!("{nombre} no parsea: {e:?}"));
637            naga::valid::Validator::new(
638                naga::valid::ValidationFlags::all(),
639                naga::valid::Capabilities::all(),
640            )
641            .validate(&module)
642            .unwrap_or_else(|e| panic!("{nombre} no valida: {e:?}"));
643        }
644    }
645
646    #[test]
647    fn config_default_replica_supay() {
648        let c = PostFxConfig::default();
649        assert_eq!(c.supersample, 2);
650        assert!((c.bloom_strength - 0.85).abs() < 1e-6);
651        assert!((c.bloom_threshold - 0.62).abs() < 1e-6);
652    }
653}