tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-3d/src/postfx.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
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}