tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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"#;