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

⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc llimphi-3d/src/postfx.rs Rust · 606 siq'ikuna · 22.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director 333dca1d5 · 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. Acá 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 dibujá.
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 `target`, preservando lo que haya
342    /// debajo (`LoadOp::Load`). Requiere [`Self::prepare`] + el pase de
343    /// [`Self::scene_pass`] ya soltado.
344    pub fn resolve(&self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView) {
345        let scene_bg = match self.scene.as_ref() {
346            Some(s) => &s.tex_bg,
347            None => return,
348        };
349        let bloom = self.bloom.as_ref().unwrap();
350
351        // --- Bright-pass + blur: escena → textura de bloom (media res). ---
352        {
353            let mut bp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
354                label: Some("llimphi-3d-postfx-bright"),
355                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
356                    view: &bloom.color_view,
357                    resolve_target: None,
358                    depth_slice: None,
359                    ops: wgpu::Operations {
360                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
361                        store: wgpu::StoreOp::Store,
362                    },
363                })],
364                depth_stencil_attachment: None,
365                timestamp_writes: None,
366                occlusion_query_set: None,
367            });
368            bp.set_pipeline(&self.bright_pipeline);
369            bp.set_bind_group(0, scene_bg, &[]);
370            bp.set_bind_group(1, &self.bright_uniform_bg, &[]);
371            bp.draw(0..3, 0..1);
372        }
373
374        // --- Blit final: escena (SSAA) + bloom → target real (preserva debajo). ---
375        {
376            let mut blit = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
377                label: Some("llimphi-3d-postfx-blit"),
378                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
379                    view: target,
380                    resolve_target: None,
381                    depth_slice: None,
382                    ops: wgpu::Operations {
383                        load: wgpu::LoadOp::Load,
384                        store: wgpu::StoreOp::Store,
385                    },
386                })],
387                depth_stencil_attachment: None,
388                timestamp_writes: None,
389                occlusion_query_set: None,
390            });
391            blit.set_pipeline(&self.blit_pipeline);
392            blit.set_bind_group(0, scene_bg, &[]);
393            blit.set_bind_group(1, &bloom.tex_bg, &[]);
394            blit.set_bind_group(2, &self.blit_uniform_bg, &[]);
395            blit.draw(0..3, 0..1);
396        }
397    }
398
399    fn upload_uniforms(&self, queue: &wgpu::Queue) {
400        // bright: (threshold, knee, radius, _)
401        let mut b = Vec::with_capacity(16);
402        for v in [
403            self.cfg.bloom_threshold,
404            self.cfg.bloom_knee.max(1e-4),
405            self.cfg.bloom_radius,
406            0.0,
407        ] {
408            b.extend_from_slice(&v.to_ne_bytes());
409        }
410        queue.write_buffer(&self.bright_uniform, 0, &b);
411        // blit: (bloom_strength, _, _, _)
412        let mut s = Vec::with_capacity(16);
413        for v in [self.cfg.bloom_strength, 0.0, 0.0, 0.0] {
414            s.extend_from_slice(&v.to_ne_bytes());
415        }
416        queue.write_buffer(&self.blit_uniform, 0, &s);
417    }
418
419    fn ensure_scene(&mut self, device: &wgpu::Device, w: u32, h: u32) {
420        if matches!(&self.scene, Some(s) if s.w == w && s.h == h) {
421            return;
422        }
423        let color = device.create_texture(&wgpu::TextureDescriptor {
424            label: Some("llimphi-3d-postfx-scene-color"),
425            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
426            mip_level_count: 1,
427            sample_count: 1,
428            dimension: wgpu::TextureDimension::D2,
429            format: wgpu::TextureFormat::Rgba8Unorm,
430            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
431            view_formats: &[],
432        });
433        let color_view = color.create_view(&Default::default());
434        let depth = device.create_texture(&wgpu::TextureDescriptor {
435            label: Some("llimphi-3d-postfx-scene-depth"),
436            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
437            mip_level_count: 1,
438            sample_count: 1,
439            dimension: wgpu::TextureDimension::D2,
440            format: DEPTH_FORMAT,
441            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
442            view_formats: &[],
443        });
444        let depth_view = depth.create_view(&Default::default());
445        let tex_bg = self.make_tex_bg(device, &color_view, "llimphi-3d-postfx-scene-bg");
446        self.scene = Some(SceneTarget { w, h, color_view, depth_view, tex_bg });
447    }
448
449    fn ensure_bloom(&mut self, device: &wgpu::Device, w: u32, h: u32) {
450        if matches!(&self.bloom, Some(b) if b.w == w && b.h == h) {
451            return;
452        }
453        let color = device.create_texture(&wgpu::TextureDescriptor {
454            label: Some("llimphi-3d-postfx-bloom-color"),
455            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
456            mip_level_count: 1,
457            sample_count: 1,
458            dimension: wgpu::TextureDimension::D2,
459            format: wgpu::TextureFormat::Rgba8Unorm,
460            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
461            view_formats: &[],
462        });
463        let color_view = color.create_view(&Default::default());
464        let tex_bg = self.make_tex_bg(device, &color_view, "llimphi-3d-postfx-bloom-bg");
465        self.bloom = Some(BloomTarget { w, h, color_view, tex_bg });
466    }
467
468    fn make_tex_bg(
469        &self,
470        device: &wgpu::Device,
471        view: &wgpu::TextureView,
472        label: &str,
473    ) -> wgpu::BindGroup {
474        device.create_bind_group(&wgpu::BindGroupDescriptor {
475            label: Some(label),
476            layout: &self.tex_layout,
477            entries: &[
478                wgpu::BindGroupEntry {
479                    binding: 0,
480                    resource: wgpu::BindingResource::TextureView(view),
481                },
482                wgpu::BindGroupEntry {
483                    binding: 1,
484                    resource: wgpu::BindingResource::Sampler(&self.sampler),
485                },
486            ],
487        })
488    }
489}
490
491/// Bright-pass + blur 5×5 de la escena → textura de bloom (media res). En una
492/// pasada: downsample bilinear + extracción de lo brillante + suavizado.
493/// `u.params = (threshold, knee, radius, _)`.
494pub const BRIGHT_WGSL: &str = r#"
495@group(0) @binding(0) var tex: texture_2d<f32>;
496@group(0) @binding(1) var samp: sampler;
497struct U { params: vec4<f32> };
498@group(1) @binding(0) var<uniform> u: U;
499
500struct VOut {
501    @builtin(position) clip: vec4<f32>,
502    @location(0) uv: vec2<f32>,
503};
504
505@vertex
506fn vs(@builtin(vertex_index) vi: u32) -> VOut {
507    var p = array<vec2<f32>, 3>(
508        vec2<f32>(-1.0, -1.0),
509        vec2<f32>( 3.0, -1.0),
510        vec2<f32>(-1.0,  3.0),
511    );
512    var o: VOut;
513    let c = p[vi];
514    o.clip = vec4<f32>(c, 0.0, 1.0);
515    o.uv = vec2<f32>(c.x * 0.5 + 0.5, 1.0 - (c.y * 0.5 + 0.5));
516    return o;
517}
518
519@fragment
520fn fs(in: VOut) -> @location(0) vec4<f32> {
521    let dim = vec2<f32>(textureDimensions(tex, 0));
522    let texel = 1.0 / dim;
523    let thresh = u.params.x;
524    let knee = u.params.y;
525    let radius = u.params.z;
526    var acc = vec3<f32>(0.0);
527    for (var j = -2; j <= 2; j = j + 1) {
528        for (var i = -2; i <= 2; i = i + 1) {
529            let o = vec2<f32>(f32(i), f32(j)) * texel * radius;
530            let c = textureSample(tex, samp, in.uv + o).rgb;
531            let l = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
532            // Bright-pass suave: lo que supera el umbral sangra según el knee.
533            let k = clamp((l - thresh) / knee, 0.0, 1.0);
534            acc += c * k;
535        }
536    }
537    return vec4<f32>(acc / 25.0, 1.0);
538}
539"#;
540
541/// Blit de bajada SSAA + suma de bloom: triángulo fullscreen que muestrea la
542/// escena supersampleada (filtro lineal = antialiasing) y le suma el bloom.
543/// `u.params.x = bloom_strength`.
544pub const BLIT_WGSL: &str = r#"
545@group(0) @binding(0) var tex: texture_2d<f32>;
546@group(0) @binding(1) var samp: sampler;
547@group(1) @binding(0) var bloom: texture_2d<f32>;
548@group(1) @binding(1) var bsamp: sampler;
549struct U { params: vec4<f32> };
550@group(2) @binding(0) var<uniform> u: U;
551
552struct VOut {
553    @builtin(position) clip: vec4<f32>,
554    @location(0) uv: vec2<f32>,
555};
556
557@vertex
558fn vs(@builtin(vertex_index) vi: u32) -> VOut {
559    var p = array<vec2<f32>, 3>(
560        vec2<f32>(-1.0, -1.0),
561        vec2<f32>( 3.0, -1.0),
562        vec2<f32>(-1.0,  3.0),
563    );
564    var o: VOut;
565    let c = p[vi];
566    o.clip = vec4<f32>(c, 0.0, 1.0);
567    o.uv = vec2<f32>(c.x * 0.5 + 0.5, 1.0 - (c.y * 0.5 + 0.5));
568    return o;
569}
570
571@fragment
572fn fs(in: VOut) -> @location(0) vec4<f32> {
573    let s = textureSample(tex, samp, in.uv).rgb;
574    let b = textureSample(bloom, bsamp, in.uv).rgb;
575    return vec4<f32>(s + b * u.params.x, 1.0);
576}
577"#;
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    /// Los shaders WGSL del post-proceso parsean y validan sin GPU (naga).
584    /// Ataja errores que de otro modo sólo saldrían al crear el pipeline.
585    #[test]
586    fn shaders_wgsl_validan() {
587        for (nombre, src) in [("BRIGHT_WGSL", BRIGHT_WGSL), ("BLIT_WGSL", BLIT_WGSL)] {
588            let module = naga::front::wgsl::parse_str(src)
589                .unwrap_or_else(|e| panic!("{nombre} no parsea: {e:?}"));
590            naga::valid::Validator::new(
591                naga::valid::ValidationFlags::all(),
592                naga::valid::Capabilities::all(),
593            )
594            .validate(&module)
595            .unwrap_or_else(|e| panic!("{nombre} no valida: {e:?}"));
596        }
597    }
598
599    #[test]
600    fn config_default_replica_supay() {
601        let c = PostFxConfig::default();
602        assert_eq!(c.supersample, 2);
603        assert!((c.bloom_strength - 0.85).abs() < 1e-6);
604        assert!((c.bloom_threshold - 0.62).abs() < 1e-6);
605    }
606}