gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

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

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · 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::FilterMode::Linear,
162            ..Default::default()
163        });
164
165        // Helper para pipelines fullscreen (triángulo cubre-pantalla, sin VBO).
166        let make_fullscreen = |layouts: &[&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                push_constant_ranges: &[],
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: None,
204                cache: None,
205            })
206        };
207
208        let bright_pipeline = make_fullscreen(
209            &[&tex_layout, &uniform_layout],
210            BRIGHT_WGSL,
211            "llimphi-3d-postfx-bright",
212        );
213        let blit_pipeline = make_fullscreen(
214            &[&tex_layout, &tex_layout, &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        })
338    }
339
340    /// Resuelve la escena ya dibujada: bright-pass + blur → bloom, luego blit
341    /// (bajada SSAA + suma de bloom) sobre **todo** `target`, preservando lo
342    /// que haya debajo (`LoadOp::Load`). Requiere [`Self::prepare`] + el pase
343    /// de [`Self::scene_pass`] ya soltado.
344    pub fn resolve(&self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView) {
345        self.resolve_impl(encoder, target, None);
346    }
347
348    /// Como [`Self::resolve`] pero **confina** el blit final a la sub-región
349    /// `rect = (x, y, w, h)` (px del target, esquina sup-izq) vía
350    /// `set_viewport` + `set_scissor_rect`. Es lo que permite montar el
351    /// post-proceso en un **panel** de una UI sin pisar el chrome alrededor:
352    /// la escena supersampleada se baja ajustada al rect y el resto del
353    /// `target` queda intacto (`LoadOp::Load`). `viewport` = tamaño del
354    /// `target` completo (para clampear el scissor). El bright-pass sigue
355    /// siendo offscreen a media res (no necesita confinarse).
356    pub fn resolve_in(
357        &self,
358        encoder: &mut wgpu::CommandEncoder,
359        target: &wgpu::TextureView,
360        rect: (f32, f32, f32, f32),
361        viewport: (u32, u32),
362    ) {
363        self.resolve_impl(encoder, target, Some((rect, viewport)));
364    }
365
366    fn resolve_impl(
367        &self,
368        encoder: &mut wgpu::CommandEncoder,
369        target: &wgpu::TextureView,
370        confine: Option<((f32, f32, f32, f32), (u32, u32))>,
371    ) {
372        let scene_bg = match self.scene.as_ref() {
373            Some(s) => &s.tex_bg,
374            None => return,
375        };
376        let bloom = self.bloom.as_ref().unwrap();
377
378        // --- Bright-pass + blur: escena → textura de bloom (media res). ---
379        {
380            let mut bp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
381                label: Some("llimphi-3d-postfx-bright"),
382                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
383                    view: &bloom.color_view,
384                    resolve_target: None,
385                    depth_slice: None,
386                    ops: wgpu::Operations {
387                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
388                        store: wgpu::StoreOp::Store,
389                    },
390                })],
391                depth_stencil_attachment: None,
392                timestamp_writes: None,
393                occlusion_query_set: None,
394            });
395            bp.set_pipeline(&self.bright_pipeline);
396            bp.set_bind_group(0, scene_bg, &[]);
397            bp.set_bind_group(1, &self.bright_uniform_bg, &[]);
398            bp.draw(0..3, 0..1);
399        }
400
401        // --- Blit final: escena (SSAA) + bloom → target real (preserva debajo). ---
402        {
403            let mut blit = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
404                label: Some("llimphi-3d-postfx-blit"),
405                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
406                    view: target,
407                    resolve_target: None,
408                    depth_slice: None,
409                    ops: wgpu::Operations {
410                        load: wgpu::LoadOp::Load,
411                        store: wgpu::StoreOp::Store,
412                    },
413                })],
414                depth_stencil_attachment: None,
415                timestamp_writes: None,
416                occlusion_query_set: None,
417            });
418            // Confinar al rect: el viewport mapea NDC→rect (la escena se ajusta
419            // al panel) y el scissor recorta el triángulo fullscreen, que de
420            // otro modo desbordaría el rect y pisaría el chrome.
421            if let Some(((rx, ry, rw, rh), (vw, vh))) = confine {
422                if rw < 1.0 || rh < 1.0 {
423                    return;
424                }
425                blit.set_viewport(rx, ry, rw, rh, 0.0, 1.0);
426                let sx = rx.max(0.0);
427                let sy = ry.max(0.0);
428                let sw = (rw.min(vw as f32 - sx)).max(0.0) as u32;
429                let sh = (rh.min(vh as f32 - sy)).max(0.0) as u32;
430                if sw == 0 || sh == 0 {
431                    return;
432                }
433                blit.set_scissor_rect(sx as u32, sy as u32, sw, sh);
434            }
435            blit.set_pipeline(&self.blit_pipeline);
436            blit.set_bind_group(0, scene_bg, &[]);
437            blit.set_bind_group(1, &bloom.tex_bg, &[]);
438            blit.set_bind_group(2, &self.blit_uniform_bg, &[]);
439            blit.draw(0..3, 0..1);
440        }
441    }
442
443    fn upload_uniforms(&self, queue: &wgpu::Queue) {
444        // bright: (threshold, knee, radius, _)
445        let mut b = Vec::with_capacity(16);
446        for v in [
447            self.cfg.bloom_threshold,
448            self.cfg.bloom_knee.max(1e-4),
449            self.cfg.bloom_radius,
450            0.0,
451        ] {
452            b.extend_from_slice(&v.to_ne_bytes());
453        }
454        queue.write_buffer(&self.bright_uniform, 0, &b);
455        // blit: (bloom_strength, _, _, _)
456        let mut s = Vec::with_capacity(16);
457        for v in [self.cfg.bloom_strength, 0.0, 0.0, 0.0] {
458            s.extend_from_slice(&v.to_ne_bytes());
459        }
460        queue.write_buffer(&self.blit_uniform, 0, &s);
461    }
462
463    fn ensure_scene(&mut self, device: &wgpu::Device, w: u32, h: u32) {
464        if matches!(&self.scene, Some(s) if s.w == w && s.h == h) {
465            return;
466        }
467        let color = device.create_texture(&wgpu::TextureDescriptor {
468            label: Some("llimphi-3d-postfx-scene-color"),
469            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
470            mip_level_count: 1,
471            sample_count: 1,
472            dimension: wgpu::TextureDimension::D2,
473            format: wgpu::TextureFormat::Rgba8Unorm,
474            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
475            view_formats: &[],
476        });
477        let color_view = color.create_view(&Default::default());
478        let depth = device.create_texture(&wgpu::TextureDescriptor {
479            label: Some("llimphi-3d-postfx-scene-depth"),
480            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
481            mip_level_count: 1,
482            sample_count: 1,
483            dimension: wgpu::TextureDimension::D2,
484            format: DEPTH_FORMAT,
485            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
486            view_formats: &[],
487        });
488        let depth_view = depth.create_view(&Default::default());
489        let tex_bg = self.make_tex_bg(device, &color_view, "llimphi-3d-postfx-scene-bg");
490        self.scene = Some(SceneTarget { w, h, color_view, depth_view, tex_bg });
491    }
492
493    fn ensure_bloom(&mut self, device: &wgpu::Device, w: u32, h: u32) {
494        if matches!(&self.bloom, Some(b) if b.w == w && b.h == h) {
495            return;
496        }
497        let color = device.create_texture(&wgpu::TextureDescriptor {
498            label: Some("llimphi-3d-postfx-bloom-color"),
499            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
500            mip_level_count: 1,
501            sample_count: 1,
502            dimension: wgpu::TextureDimension::D2,
503            format: wgpu::TextureFormat::Rgba8Unorm,
504            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
505            view_formats: &[],
506        });
507        let color_view = color.create_view(&Default::default());
508        let tex_bg = self.make_tex_bg(device, &color_view, "llimphi-3d-postfx-bloom-bg");
509        self.bloom = Some(BloomTarget { w, h, color_view, tex_bg });
510    }
511
512    fn make_tex_bg(
513        &self,
514        device: &wgpu::Device,
515        view: &wgpu::TextureView,
516        label: &str,
517    ) -> wgpu::BindGroup {
518        device.create_bind_group(&wgpu::BindGroupDescriptor {
519            label: Some(label),
520            layout: &self.tex_layout,
521            entries: &[
522                wgpu::BindGroupEntry {
523                    binding: 0,
524                    resource: wgpu::BindingResource::TextureView(view),
525                },
526                wgpu::BindGroupEntry {
527                    binding: 1,
528                    resource: wgpu::BindingResource::Sampler(&self.sampler),
529                },
530            ],
531        })
532    }
533}
534
535/// Bright-pass + blur 5×5 de la escena → textura de bloom (media res). En una
536/// pasada: downsample bilinear + extracción de lo brillante + suavizado.
537/// `u.params = (threshold, knee, radius, _)`.
538pub const BRIGHT_WGSL: &str = r#"
539@group(0) @binding(0) var tex: texture_2d<f32>;
540@group(0) @binding(1) var samp: sampler;
541struct U { params: vec4<f32> };
542@group(1) @binding(0) var<uniform> u: U;
543
544struct VOut {
545    @builtin(position) clip: vec4<f32>,
546    @location(0) uv: vec2<f32>,
547};
548
549@vertex
550fn vs(@builtin(vertex_index) vi: u32) -> VOut {
551    var p = array<vec2<f32>, 3>(
552        vec2<f32>(-1.0, -1.0),
553        vec2<f32>( 3.0, -1.0),
554        vec2<f32>(-1.0,  3.0),
555    );
556    var o: VOut;
557    let c = p[vi];
558    o.clip = vec4<f32>(c, 0.0, 1.0);
559    o.uv = vec2<f32>(c.x * 0.5 + 0.5, 1.0 - (c.y * 0.5 + 0.5));
560    return o;
561}
562
563@fragment
564fn fs(in: VOut) -> @location(0) vec4<f32> {
565    let dim = vec2<f32>(textureDimensions(tex, 0));
566    let texel = 1.0 / dim;
567    let thresh = u.params.x;
568    let knee = u.params.y;
569    let radius = u.params.z;
570    var acc = vec3<f32>(0.0);
571    for (var j = -2; j <= 2; j = j + 1) {
572        for (var i = -2; i <= 2; i = i + 1) {
573            let o = vec2<f32>(f32(i), f32(j)) * texel * radius;
574            let c = textureSample(tex, samp, in.uv + o).rgb;
575            let l = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
576            // Bright-pass suave: lo que supera el umbral sangra según el knee.
577            let k = clamp((l - thresh) / knee, 0.0, 1.0);
578            acc += c * k;
579        }
580    }
581    return vec4<f32>(acc / 25.0, 1.0);
582}
583"#;
584
585/// Blit de bajada SSAA + suma de bloom: triángulo fullscreen que muestrea la
586/// escena supersampleada (filtro lineal = antialiasing) y le suma el bloom.
587/// `u.params.x = bloom_strength`.
588pub const BLIT_WGSL: &str = r#"
589@group(0) @binding(0) var tex: texture_2d<f32>;
590@group(0) @binding(1) var samp: sampler;
591@group(1) @binding(0) var bloom: texture_2d<f32>;
592@group(1) @binding(1) var bsamp: sampler;
593struct U { params: vec4<f32> };
594@group(2) @binding(0) var<uniform> u: U;
595
596struct VOut {
597    @builtin(position) clip: vec4<f32>,
598    @location(0) uv: vec2<f32>,
599};
600
601@vertex
602fn vs(@builtin(vertex_index) vi: u32) -> VOut {
603    var p = array<vec2<f32>, 3>(
604        vec2<f32>(-1.0, -1.0),
605        vec2<f32>( 3.0, -1.0),
606        vec2<f32>(-1.0,  3.0),
607    );
608    var o: VOut;
609    let c = p[vi];
610    o.clip = vec4<f32>(c, 0.0, 1.0);
611    o.uv = vec2<f32>(c.x * 0.5 + 0.5, 1.0 - (c.y * 0.5 + 0.5));
612    return o;
613}
614
615@fragment
616fn fs(in: VOut) -> @location(0) vec4<f32> {
617    let s = textureSample(tex, samp, in.uv).rgb;
618    let b = textureSample(bloom, bsamp, in.uv).rgb;
619    return vec4<f32>(s + b * u.params.x, 1.0);
620}
621"#;
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    /// Los shaders WGSL del post-proceso parsean y validan sin GPU (naga).
628    /// Ataja errores que de otro modo sólo saldrían al crear el pipeline.
629    #[test]
630    fn shaders_wgsl_validan() {
631        for (nombre, src) in [("BRIGHT_WGSL", BRIGHT_WGSL), ("BLIT_WGSL", BLIT_WGSL)] {
632            let module = naga::front::wgsl::parse_str(src)
633                .unwrap_or_else(|e| panic!("{nombre} no parsea: {e:?}"));
634            naga::valid::Validator::new(
635                naga::valid::ValidationFlags::all(),
636                naga::valid::Capabilities::all(),
637            )
638            .validate(&module)
639            .unwrap_or_else(|e| panic!("{nombre} no valida: {e:?}"));
640        }
641    }
642
643    #[test]
644    fn config_default_replica_supay() {
645        let c = PostFxConfig::default();
646        assert_eq!(c.supersample, 2);
647        assert!((c.bloom_strength - 0.85).abs() < 1e-6);
648        assert!((c.bloom_threshold - 0.62).abs() < 1e-6);
649    }
650}