tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Sutichaykuna

⎇ main llimphi-surface/src/lib.rs Rust · 820 siq'ikuna · 29.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1//! llimphi-surface — superficies externas dentro del bucle Elm.
2//!
3//! Un `ExternalSurface` es una textura RGBA8 que vive en GPU y se pinta
4//! sobre un rect del frame Llimphi cada vez que la app lo expone vía
5//! `View::gpu_paint_with`. La fuente de bytes corre afuera del bucle
6//! Elm: un decoder de video, un capture de cámara, un raster de PDF,
7//! una textura raw producida por otro motor — cualquier productor que
8//! genere RGBA puede empujar frames con [`ExternalSurface::upload`] y
9//! ver el resultado en la próxima pasada de raster. Un decoder de video
10//! tiene además [`ExternalSurface::upload_yuv420p`], que le ahorra la
11//! conversión a RGBA (la hace el shader) y baja a 1,5 bytes por píxel lo
12//! que cruza el pipe y el `write_texture`.
13//!
14//! El crate provee:
15//!
16//! - [`ExternalSurface`]: dueño de la textura + render pipeline + bind
17//!   group. `upload(rgba, w, h)` sube bytes y recrea la textura si
18//!   `w`/`h` cambiaron.
19//! - [`ExternalSurface::view`]: helper que construye un [`View`] con
20//!   `gpu_paint_with` ya conectado. La app sólo elige el `Style` del
21//!   nodo (qué porción del layout ocupa).
22//!
23//! ## Diseño
24//!
25//! El pipeline es un textured-quad clásico: dos triángulos cubren el
26//! rect destino, el fragment shader samplea la textura externa con
27//! sampler bilineal. Las coordenadas NDC del quad se computan en GPU
28//! a partir de `(rect, viewport)` que viajan por uniform — por eso
29//! el callback necesita el `viewport` que `llimphi-ui` empezó a
30//! propagar en `GpuPaintFn`.
31//!
32//! La textura intermedia donde Llimphi pinta vello es `Rgba8Unorm`
33//! (ver `llimphi-hal::INTERMEDIATE_FORMAT`). El pipeline emite
34//! `Rgba8Unorm` también — el target del render pass es esa misma
35//! intermedia con `LoadOp::Load`, así el fondo vello queda preservado.
36
37use std::sync::Arc;
38
39use llimphi_hal::wgpu;
40use llimphi_ui::{PaintRect, View};
41use parking_lot::Mutex;
42
43const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
44const SOURCE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
45/// Los planos de un cuadro YUV son de un canal, 8 bits.
46const PLANO_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
47
48/// Matriz de conversión YUV→RGB. La eligen las características del material, no
49/// el gusto: usar la que no es tiñe la imagen (los verdes y los tonos de piel
50/// son lo primero que se nota).
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum EspacioColor {
53    /// SD (y el default histórico de mucho material). `kr=0.299, kb=0.114`.
54    Bt601,
55    /// HD — lo normal en 720p para arriba. `kr=0.2126, kb=0.0722`.
56    Bt709,
57}
58
59impl EspacioColor {
60    /// `(kr, kb)` de la matriz.
61    fn coeficientes(self) -> (f32, f32) {
62        match self {
63            EspacioColor::Bt601 => (0.299, 0.114),
64            EspacioColor::Bt709 => (0.2126, 0.0722),
65        }
66    }
67}
68
69/// Rango de los valores: el video suele venir "limitado" (luma 16..235) y hay
70/// que expandirlo, o los negros salen grises y los blancos apagados.
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum RangoColor {
73    /// 16..235 luma, 16..240 croma. Lo habitual en material de cámara/emisión.
74    Limitado,
75    /// 0..255 en todos los canales.
76    Completo,
77}
78
79/// Qué tiene subido la surface ahora mismo — decide con qué pipeline se pinta.
80#[derive(Clone, Copy, PartialEq, Eq)]
81enum Modo {
82    Rgba,
83    Yuv420p,
84}
85
86struct Inner {
87    device: wgpu::Device,
88    queue: wgpu::Queue,
89    pipeline: wgpu::RenderPipeline,
90    bgl: wgpu::BindGroupLayout,
91    sampler: wgpu::Sampler,
92    uniforms: wgpu::Buffer,
93    // Textura + bind group recreados cuando cambia (w, h) del frame de
94    // entrada. Empieza en (1, 1) con un pixel transparente para que el
95    // pipeline funcione antes del primer `upload`.
96    tex: wgpu::Texture,
97    bind_group: wgpu::BindGroup,
98    tex_size: (u32, u32),
99    // Camino YUV: pipeline propio (tres planos de un canal en vez de una RGBA)
100    // y sus recursos, creados perezosamente en el primer `upload_yuv420p`. Una
101    // app que sólo sube RGBA no paga nada de esto.
102    pipeline_yuv: Option<wgpu::RenderPipeline>,
103    bgl_yuv: wgpu::BindGroupLayout,
104    planos: Option<PlanosYuv>,
105    modo: Modo,
106    espacio: EspacioColor,
107    rango: RangoColor,
108}
109
110/// Las tres texturas de un cuadro YUV planar + su bind group.
111struct PlanosYuv {
112    y: wgpu::Texture,
113    u: wgpu::Texture,
114    v: wgpu::Texture,
115    bind_group: wgpu::BindGroup,
116    /// Tamaño del plano de luma. El de croma es la mitad, redondeando arriba.
117    size: (u32, u32),
118}
119
120/// Superficie externa: textura GPU + pipeline que la blittea al rect
121/// que ocupe en el árbol Llimphi. Clonar es barato (Arc interno).
122#[derive(Clone)]
123pub struct ExternalSurface {
124    inner: Arc<Mutex<Inner>>,
125}
126
127impl ExternalSurface {
128    /// Construye la surface usando el `Device`/`Queue` del Hal de la app.
129    /// La textura arranca en 1×1 transparente; el primer
130    /// [`Self::upload`] la redimensiona al tamaño real del frame.
131    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
132        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
133            label: Some("llimphi-surface-bgl"),
134            entries: &[
135                wgpu::BindGroupLayoutEntry {
136                    binding: 0,
137                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
138                    ty: wgpu::BindingType::Buffer {
139                        ty: wgpu::BufferBindingType::Uniform,
140                        has_dynamic_offset: false,
141                        min_binding_size: None,
142                    },
143                    count: None,
144                },
145                wgpu::BindGroupLayoutEntry {
146                    binding: 1,
147                    visibility: wgpu::ShaderStages::FRAGMENT,
148                    ty: wgpu::BindingType::Texture {
149                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
150                        view_dimension: wgpu::TextureViewDimension::D2,
151                        multisampled: false,
152                    },
153                    count: None,
154                },
155                wgpu::BindGroupLayoutEntry {
156                    binding: 2,
157                    visibility: wgpu::ShaderStages::FRAGMENT,
158                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
159                    count: None,
160                },
161            ],
162        });
163
164        let pipeline = make_pipeline(device, &bgl, WGSL, "llimphi-surface-pipe");
165
166        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
167            label: Some("llimphi-surface-sampler"),
168            address_mode_u: wgpu::AddressMode::ClampToEdge,
169            address_mode_v: wgpu::AddressMode::ClampToEdge,
170            address_mode_w: wgpu::AddressMode::ClampToEdge,
171            mag_filter: wgpu::FilterMode::Linear,
172            min_filter: wgpu::FilterMode::Linear,
173            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
174            ..Default::default()
175        });
176
177        // Uniforms: 16 floats — rect destino (x, y, w, h) + viewport
178        // (vw, vh, _, _) + src_uv (u0, v0, du, dv): el sub-rectángulo de la
179        // textura a muestrear, en UV 0..1 (`blit` lo deja en (0,0,1,1) —textura
180        // entera—; `blit_layout` lo usa para crop/zoom/pan, V2) + los
181        // parámetros de conversión YUV (kr, kb, rango-limitado, libre), que
182        // sólo lee el shader del camino YUV.
183        let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
184            label: Some("llimphi-surface-uniforms"),
185            size: 64,
186            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
187            mapped_at_creation: false,
188        });
189
190        let (tex, bind_group) =
191            make_texture_and_bg(device, queue, &bgl, &uniforms, &sampler, 1, 1, &[0, 0, 0, 0]);
192
193        // Layout del camino YUV: mismo uniform y sampler, pero tres texturas de
194        // un canal (Y, U, V) en vez de una RGBA.
195        let bgl_yuv = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
196            label: Some("llimphi-surface-bgl-yuv"),
197            entries: &[
198                wgpu::BindGroupLayoutEntry {
199                    binding: 0,
200                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
201                    ty: wgpu::BindingType::Buffer {
202                        ty: wgpu::BufferBindingType::Uniform,
203                        has_dynamic_offset: false,
204                        min_binding_size: None,
205                    },
206                    count: None,
207                },
208                wgpu::BindGroupLayoutEntry {
209                    binding: 1,
210                    visibility: wgpu::ShaderStages::FRAGMENT,
211                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
212                    count: None,
213                },
214                wgpu::BindGroupLayoutEntry {
215                    binding: 2,
216                    visibility: wgpu::ShaderStages::FRAGMENT,
217                    ty: wgpu::BindingType::Texture {
218                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
219                        view_dimension: wgpu::TextureViewDimension::D2,
220                        multisampled: false,
221                    },
222                    count: None,
223                },
224                wgpu::BindGroupLayoutEntry {
225                    binding: 3,
226                    visibility: wgpu::ShaderStages::FRAGMENT,
227                    ty: wgpu::BindingType::Texture {
228                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
229                        view_dimension: wgpu::TextureViewDimension::D2,
230                        multisampled: false,
231                    },
232                    count: None,
233                },
234                wgpu::BindGroupLayoutEntry {
235                    binding: 4,
236                    visibility: wgpu::ShaderStages::FRAGMENT,
237                    ty: wgpu::BindingType::Texture {
238                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
239                        view_dimension: wgpu::TextureViewDimension::D2,
240                        multisampled: false,
241                    },
242                    count: None,
243                },
244            ],
245        });
246
247        Self {
248            inner: Arc::new(Mutex::new(Inner {
249                device: device.clone(),
250                queue: queue.clone(),
251                pipeline,
252                bgl,
253                sampler,
254                uniforms,
255                tex,
256                bind_group,
257                tex_size: (1, 1),
258                pipeline_yuv: None,
259                bgl_yuv,
260                planos: None,
261                modo: Modo::Rgba,
262                espacio: EspacioColor::Bt709,
263                rango: RangoColor::Limitado,
264            })),
265        }
266    }
267
268    /// Sube `rgba` (8 bits por canal, premultiplicado o no — el blend
269    /// usa straight alpha) como nuevo contenido de la surface. Si
270    /// `(width, height)` difiere del tamaño actual, recrea la textura
271    /// y el bind group. `rgba.len()` debe ser exactamente
272    /// `width * height * 4`.
273    pub fn upload(&self, rgba: &[u8], width: u32, height: u32) {
274        let mut inner = self.inner.lock();
275        inner.modo = Modo::Rgba;
276        debug_assert_eq!(rgba.len(), (width as usize) * (height as usize) * 4);
277        if inner.tex_size != (width, height) {
278            let (tex, bg) = make_texture_and_bg(
279                &inner.device,
280                &inner.queue,
281                &inner.bgl,
282                &inner.uniforms,
283                &inner.sampler,
284                width,
285                height,
286                rgba,
287            );
288            inner.tex = tex;
289            inner.bind_group = bg;
290            inner.tex_size = (width, height);
291        } else {
292            inner.queue.write_texture(
293                wgpu::TexelCopyTextureInfo {
294                    texture: &inner.tex,
295                    mip_level: 0,
296                    origin: wgpu::Origin3d::ZERO,
297                    aspect: wgpu::TextureAspect::All,
298                },
299                rgba,
300                wgpu::TexelCopyBufferLayout {
301                    offset: 0,
302                    bytes_per_row: Some(width * 4),
303                    rows_per_image: Some(height),
304                },
305                wgpu::Extent3d {
306                    width,
307                    height,
308                    depth_or_array_layers: 1,
309                },
310            );
311        }
312    }
313
314    /// Sube un cuadro **YUV 4:2:0 planar** (el `yuv420p` de ffmpeg, que es lo
315    /// que el decoder ya tiene en la mano) y deja que la conversión a RGB la
316    /// haga el fragment shader.
317    ///
318    /// Es el camino barato para video, y la diferencia no es marginal: pedirle
319    /// RGBA a ffmpeg le cuesta un ~29 % más de CPU —la conversión por software,
320    /// medida— y multiplica por 2,67 los bytes que cruzan el pipe y el
321    /// `write_texture` (4 bytes por píxel contra 1,5). En 1080p a 24 fps son
322    /// 192 MB/s contra 72. La conversión en GPU, en cambio, es gratis: tres
323    /// multiplicaciones por píxel dentro de un shader que ya se estaba
324    /// ejecutando, y el sampler bilineal regala el upsampling del croma.
325    ///
326    /// `planos` es el layout de ffmpeg: primero Y (`w*h`), después U y después
327    /// V (cada uno `ceil(w/2)*ceil(h/2)`).
328    pub fn upload_yuv420p(
329        &self,
330        planos: &[u8],
331        width: u32,
332        height: u32,
333        espacio: EspacioColor,
334        rango: RangoColor,
335    ) {
336        let (cw, ch) = ((width + 1) / 2, (height + 1) / 2);
337        let (ly, lc) = ((width as usize) * (height as usize), (cw as usize) * (ch as usize));
338        debug_assert_eq!(planos.len(), ly + lc * 2, "yuv420p mal dimensionado");
339        if planos.len() < ly + lc * 2 || width == 0 || height == 0 {
340            return;
341        }
342        let mut inner = self.inner.lock();
343        inner.modo = Modo::Yuv420p;
344        inner.espacio = espacio;
345        inner.rango = rango;
346
347        let hay = matches!(&inner.planos, Some(p) if p.size == (width, height));
348        if !hay {
349            let crear = |w: u32, h: u32, label: &str| {
350                inner.device.create_texture(&wgpu::TextureDescriptor {
351                    label: Some(label),
352                    size: wgpu::Extent3d {
353                        width: w,
354                        height: h,
355                        depth_or_array_layers: 1,
356                    },
357                    mip_level_count: 1,
358                    sample_count: 1,
359                    dimension: wgpu::TextureDimension::D2,
360                    format: PLANO_FORMAT,
361                    usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
362                    view_formats: &[],
363                })
364            };
365            let ty = crear(width, height, "llimphi-surface-plano-y");
366            let tu = crear(cw, ch, "llimphi-surface-plano-u");
367            let tv = crear(cw, ch, "llimphi-surface-plano-v");
368            let (vy, vu, vv) = (
369                ty.create_view(&Default::default()),
370                tu.create_view(&Default::default()),
371                tv.create_view(&Default::default()),
372            );
373            let bind_group = inner.device.create_bind_group(&wgpu::BindGroupDescriptor {
374                label: Some("llimphi-surface-bg-yuv"),
375                layout: &inner.bgl_yuv,
376                entries: &[
377                    wgpu::BindGroupEntry {
378                        binding: 0,
379                        resource: inner.uniforms.as_entire_binding(),
380                    },
381                    wgpu::BindGroupEntry {
382                        binding: 1,
383                        resource: wgpu::BindingResource::Sampler(&inner.sampler),
384                    },
385                    wgpu::BindGroupEntry {
386                        binding: 2,
387                        resource: wgpu::BindingResource::TextureView(&vy),
388                    },
389                    wgpu::BindGroupEntry {
390                        binding: 3,
391                        resource: wgpu::BindingResource::TextureView(&vu),
392                    },
393                    wgpu::BindGroupEntry {
394                        binding: 4,
395                        resource: wgpu::BindingResource::TextureView(&vv),
396                    },
397                ],
398            });
399            inner.planos = Some(PlanosYuv {
400                y: ty,
401                u: tu,
402                v: tv,
403                bind_group,
404                size: (width, height),
405            });
406        }
407        if inner.pipeline_yuv.is_none() {
408            inner.pipeline_yuv = Some(make_pipeline(
409                &inner.device,
410                &inner.bgl_yuv,
411                WGSL_YUV,
412                "llimphi-surface-pipe-yuv",
413            ));
414        }
415
416        let planos_ref = inner.planos.as_ref().expect("recién creados");
417        let subir = |tex: &wgpu::Texture, datos: &[u8], w: u32, h: u32| {
418            inner.queue.write_texture(
419                wgpu::TexelCopyTextureInfo {
420                    texture: tex,
421                    mip_level: 0,
422                    origin: wgpu::Origin3d::ZERO,
423                    aspect: wgpu::TextureAspect::All,
424                },
425                datos,
426                wgpu::TexelCopyBufferLayout {
427                    offset: 0,
428                    bytes_per_row: Some(w),
429                    rows_per_image: Some(h),
430                },
431                wgpu::Extent3d {
432                    width: w,
433                    height: h,
434                    depth_or_array_layers: 1,
435                },
436            );
437        };
438        subir(&planos_ref.y, &planos[..ly], width, height);
439        subir(&planos_ref.u, &planos[ly..ly + lc], cw, ch);
440        subir(&planos_ref.v, &planos[ly + lc..ly + lc * 2], cw, ch);
441    }
442
443    /// Tamaño actual de la textura interna (último upload o (1,1) si
444    /// nunca se subió nada).
445    pub fn size(&self) -> (u32, u32) {
446        let inner = self.inner.lock();
447        match inner.modo {
448            Modo::Rgba => inner.tex_size,
449            Modo::Yuv420p => inner.planos.as_ref().map(|p| p.size).unwrap_or((1, 1)),
450        }
451    }
452
453    /// Encola el draw del quad que pinta la surface en `dst_view` dentro
454    /// de `rect`, escalando la textura para cubrir el rect entero.
455    /// Llamado típicamente desde el callback de `View::gpu_paint_with`.
456    pub fn blit(
457        &self,
458        queue: &wgpu::Queue,
459        encoder: &mut wgpu::CommandEncoder,
460        dst_view: &wgpu::TextureView,
461        rect: PaintRect,
462        viewport: (u32, u32),
463    ) {
464        // Comportamiento clásico: la textura entera estirada al rect, recortada
465        // al propio rect. `blit_layout` con `src_uv` = textura completa y el
466        // `clip` = el mismo rect.
467        self.blit_layout(
468            queue,
469            encoder,
470            dst_view,
471            [rect.x, rect.y, rect.w, rect.h],
472            [0.0, 0.0, 1.0, 1.0],
473            [rect.x, rect.y, rect.w, rect.h],
474            viewport,
475        );
476    }
477
478    /// Blit con control de **origen y destino** (V2: aspect/crop/zoom/pan).
479    /// `dst` es el rectángulo (px de viewport) donde dibujar — puede exceder el
480    /// `clip`; `src_uv` (`u0, v0, du, dv` en 0..1) es el sub-rectángulo de la
481    /// textura a muestrear (crop); `clip` (px de viewport) acota el dibujado con
482    /// un scissor — típicamente el rect del canvas, así el sobrante de
483    /// Fill/zoom/pan no pinta fuera de su área. El `dst`/`src_uv` los calcula
484    /// `media_core::viewport::compute_layout`.
485    pub fn blit_layout(
486        &self,
487        queue: &wgpu::Queue,
488        encoder: &mut wgpu::CommandEncoder,
489        dst_view: &wgpu::TextureView,
490        dst: [f32; 4],
491        src_uv: [f32; 4],
492        clip: [f32; 4],
493        viewport: (u32, u32),
494    ) {
495        let inner = self.inner.lock();
496        let (kr, kb) = inner.espacio.coeficientes();
497        let limitado = matches!(inner.rango, RangoColor::Limitado) as u32 as f32;
498        let uniforms = [
499            dst[0],
500            dst[1],
501            dst[2],
502            dst[3],
503            viewport.0 as f32,
504            viewport.1 as f32,
505            0.0,
506            0.0,
507            src_uv[0],
508            src_uv[1],
509            src_uv[2],
510            src_uv[3],
511            kr,
512            kb,
513            limitado,
514            0.0,
515        ];
516        let mut bytes = [0u8; 64];
517        for (i, v) in uniforms.iter().enumerate() {
518            bytes[i * 4..(i + 1) * 4].copy_from_slice(&v.to_ne_bytes());
519        }
520        queue.write_buffer(&inner.uniforms, 0, &bytes);
521
522        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
523            label: Some("llimphi-surface-pass"),
524            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
525                view: dst_view,
526                resolve_target: None,
527                depth_slice: None,
528                ops: wgpu::Operations {
529                    load: wgpu::LoadOp::Load,
530                    store: wgpu::StoreOp::Store,
531                },
532            })],
533            depth_stencil_attachment: None,
534            timestamp_writes: None,
535            occlusion_query_set: None,
536            multiview_mask: None,
537        });
538        // El camino YUV sólo entra si hay planos subidos Y su pipeline forjado;
539        // si algo falta, se cae al RGBA de siempre en vez de no pintar nada.
540        let yuv = (inner.modo == Modo::Yuv420p)
541            .then(|| inner.pipeline_yuv.as_ref().zip(inner.planos.as_ref()))
542            .flatten();
543        match yuv {
544            Some((pipe, planos)) => {
545                pass.set_pipeline(pipe);
546                pass.set_bind_group(0, &planos.bind_group, &[]);
547            }
548            None => {
549                pass.set_pipeline(&inner.pipeline);
550                pass.set_bind_group(0, &inner.bind_group, &[]);
551            }
552        }
553        // Scissor = clip ∩ viewport (en px enteros). Evita que el sobrante de
554        // Fill/zoom/pan pinte fuera del área del canvas.
555        let (vw, vh) = (viewport.0 as f32, viewport.1 as f32);
556        let x0 = clip[0].max(0.0).floor();
557        let y0 = clip[1].max(0.0).floor();
558        let x1 = (clip[0] + clip[2]).min(vw).ceil();
559        let y1 = (clip[1] + clip[3]).min(vh).ceil();
560        if x1 > x0 && y1 > y0 {
561            pass.set_scissor_rect(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32);
562        }
563        pass.draw(0..6, 0..1);
564    }
565
566    /// Construye un `View` cuyo `gpu_paint_with` blittea la surface al
567    /// rect que le asigne el layout. La app sólo escoge el `Style`
568    /// (tamaño, flex_grow…). El `Msg` está libre — la View no emite
569    /// eventos por sí sola.
570    pub fn view<Msg>(&self, style: llimphi_ui::llimphi_layout::taffy::Style) -> View<Msg>
571    where
572        Msg: Clone + Send + Sync + 'static,
573    {
574        let this = self.clone();
575        View::new(style).gpu_paint_with(move |_device, queue, encoder, view, rect, viewport| {
576            this.blit(queue, encoder, view, rect, viewport);
577        })
578    }
579}
580
581/// Pipeline de textured-quad. Los dos caminos (RGBA y YUV) son idénticos salvo
582/// el bind group layout y el fragment shader, así que comparten esta forja.
583fn make_pipeline(
584    device: &wgpu::Device,
585    bgl: &wgpu::BindGroupLayout,
586    wgsl: &str,
587    label: &str,
588) -> wgpu::RenderPipeline {
589    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
590        label: Some(label),
591        source: wgpu::ShaderSource::Wgsl(wgsl.into()),
592    });
593    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
594        label: Some(label),
595        bind_group_layouts: &[Some(bgl)],
596        immediate_size: 0,
597    });
598    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
599        label: Some(label),
600        layout: Some(&pipeline_layout),
601        vertex: wgpu::VertexState {
602            module: &shader,
603            entry_point: Some("vs"),
604            compilation_options: Default::default(),
605            buffers: &[],
606        },
607        primitive: wgpu::PrimitiveState {
608            topology: wgpu::PrimitiveTopology::TriangleList,
609            ..Default::default()
610        },
611        depth_stencil: None,
612        multisample: wgpu::MultisampleState::default(),
613        fragment: Some(wgpu::FragmentState {
614            module: &shader,
615            entry_point: Some("fs"),
616            compilation_options: Default::default(),
617            targets: &[Some(wgpu::ColorTargetState {
618                format: TARGET_FORMAT,
619                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
620                write_mask: wgpu::ColorWrites::ALL,
621            })],
622        }),
623        multiview_mask: None,
624        cache: None,
625    })
626}
627
628fn make_texture_and_bg(
629    device: &wgpu::Device,
630    queue: &wgpu::Queue,
631    bgl: &wgpu::BindGroupLayout,
632    uniforms: &wgpu::Buffer,
633    sampler: &wgpu::Sampler,
634    width: u32,
635    height: u32,
636    initial_rgba: &[u8],
637) -> (wgpu::Texture, wgpu::BindGroup) {
638    let tex = device.create_texture(&wgpu::TextureDescriptor {
639        label: Some("llimphi-surface-tex"),
640        size: wgpu::Extent3d {
641            width,
642            height,
643            depth_or_array_layers: 1,
644        },
645        mip_level_count: 1,
646        sample_count: 1,
647        dimension: wgpu::TextureDimension::D2,
648        format: SOURCE_FORMAT,
649        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
650        view_formats: &[],
651    });
652    queue.write_texture(
653        wgpu::TexelCopyTextureInfo {
654            texture: &tex,
655            mip_level: 0,
656            origin: wgpu::Origin3d::ZERO,
657            aspect: wgpu::TextureAspect::All,
658        },
659        initial_rgba,
660        wgpu::TexelCopyBufferLayout {
661            offset: 0,
662            bytes_per_row: Some(width * 4),
663            rows_per_image: Some(height),
664        },
665        wgpu::Extent3d {
666            width,
667            height,
668            depth_or_array_layers: 1,
669        },
670    );
671    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
672    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
673        label: Some("llimphi-surface-bg"),
674        layout: bgl,
675        entries: &[
676            wgpu::BindGroupEntry {
677                binding: 0,
678                resource: uniforms.as_entire_binding(),
679            },
680            wgpu::BindGroupEntry {
681                binding: 1,
682                resource: wgpu::BindingResource::TextureView(&view),
683            },
684            wgpu::BindGroupEntry {
685                binding: 2,
686                resource: wgpu::BindingResource::Sampler(sampler),
687            },
688        ],
689    });
690    (tex, bind_group)
691}
692
693const WGSL: &str = r#"
694struct Uniforms {
695    rect: vec4<f32>,     // x, y, w, h del rect destino en pixels del frame
696    viewport: vec4<f32>, // vw, vh, _, _
697    src_uv: vec4<f32>,   // u0, v0, du, dv: sub-rect de textura a muestrear (UV)
698};
699
700@group(0) @binding(0) var<uniform> u: Uniforms;
701@group(0) @binding(1) var tex: texture_2d<f32>;
702@group(0) @binding(2) var samp: sampler;
703
704struct V2F {
705    @builtin(position) pos: vec4<f32>,
706    @location(0) uv: vec2<f32>,
707};
708
709@vertex
710fn vs(@builtin(vertex_index) vid: u32) -> V2F {
711    // Dos triángulos en UV-space, recorridos CCW.
712    var uvs = array<vec2<f32>, 6>(
713        vec2<f32>(0.0, 0.0),
714        vec2<f32>(1.0, 0.0),
715        vec2<f32>(1.0, 1.0),
716        vec2<f32>(0.0, 0.0),
717        vec2<f32>(1.0, 1.0),
718        vec2<f32>(0.0, 1.0),
719    );
720    // `q` recorre el quad destino (0..1); el muestreo va al sub-rect `src_uv`.
721    let q = uvs[vid];
722
723    let px = u.rect.x + q.x * u.rect.z;
724    let py = u.rect.y + q.y * u.rect.w;
725
726    // NDC: x ∈ [-1, 1] sin flip, y flipeado (en pantalla y-down).
727    let ndc = vec2<f32>(
728        px / u.viewport.x * 2.0 - 1.0,
729        1.0 - py / u.viewport.y * 2.0,
730    );
731
732    var out: V2F;
733    out.pos = vec4<f32>(ndc, 0.0, 1.0);
734    out.uv = u.src_uv.xy + q * u.src_uv.zw;
735    return out;
736}
737
738@fragment
739fn fs(in: V2F) -> @location(0) vec4<f32> {
740    return textureSample(tex, samp, in.uv);
741}
742"#;
743
744/// Igual que [`WGSL`] pero muestreando tres planos (Y, U, V) y convirtiendo a
745/// RGB acá — el trabajo que si no hace ffmpeg por software, cuadro a cuadro.
746///
747/// El vértice es idéntico; sólo cambia el fragment. El croma está a media
748/// resolución y se muestrea con las MISMAS UV: el sampler bilineal lo interpola
749/// solo, que es exactamente el upsampling que se quiere.
750const WGSL_YUV: &str = r#"
751struct Uniforms {
752    rect: vec4<f32>,
753    viewport: vec4<f32>,
754    src_uv: vec4<f32>,
755    // kr, kb de la matriz; z = 1 si el rango es limitado (16..235); w libre.
756    yuv: vec4<f32>,
757};
758
759@group(0) @binding(0) var<uniform> u: Uniforms;
760@group(0) @binding(1) var samp: sampler;
761@group(0) @binding(2) var tex_y: texture_2d<f32>;
762@group(0) @binding(3) var tex_u: texture_2d<f32>;
763@group(0) @binding(4) var tex_v: texture_2d<f32>;
764
765struct V2F {
766    @builtin(position) pos: vec4<f32>,
767    @location(0) uv: vec2<f32>,
768};
769
770@vertex
771fn vs(@builtin(vertex_index) vid: u32) -> V2F {
772    var uvs = array<vec2<f32>, 6>(
773        vec2<f32>(0.0, 0.0),
774        vec2<f32>(1.0, 0.0),
775        vec2<f32>(1.0, 1.0),
776        vec2<f32>(0.0, 0.0),
777        vec2<f32>(1.0, 1.0),
778        vec2<f32>(0.0, 1.0),
779    );
780    let q = uvs[vid];
781
782    let px = u.rect.x + q.x * u.rect.z;
783    let py = u.rect.y + q.y * u.rect.w;
784
785    let ndc = vec2<f32>(
786        px / u.viewport.x * 2.0 - 1.0,
787        1.0 - py / u.viewport.y * 2.0,
788    );
789
790    var out: V2F;
791    out.pos = vec4<f32>(ndc, 0.0, 1.0);
792    out.uv = u.src_uv.xy + q * u.src_uv.zw;
793    return out;
794}
795
796@fragment
797fn fs(in: V2F) -> @location(0) vec4<f32> {
798    var y = textureSample(tex_y, samp, in.uv).r;
799    var cb = textureSample(tex_u, samp, in.uv).r - 0.5;
800    var cr = textureSample(tex_v, samp, in.uv).r - 0.5;
801
802    // Rango limitado: luma 16..235 y croma 16..240 sobre 255. Sin expandir,
803    // los negros salen grises y los blancos apagados.
804    if (u.yuv.z > 0.5) {
805        y = (y - 16.0 / 255.0) * (255.0 / 219.0);
806        cb = cb * (255.0 / 224.0);
807        cr = cr * (255.0 / 224.0);
808    }
809
810    let kr = u.yuv.x;
811    let kb = u.yuv.y;
812    let kg = 1.0 - kr - kb;
813
814    let r = y + 2.0 * (1.0 - kr) * cr;
815    let b = y + 2.0 * (1.0 - kb) * cb;
816    let g = y - (2.0 * (1.0 - kr) * kr / kg) * cr - (2.0 * (1.0 - kb) * kb / kg) * cb;
817
818    return vec4<f32>(clamp(vec3<f32>(r, g, b), vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
819}
820"#;