//! llimphi-surface — superficies externas dentro del bucle Elm. //! //! Un `ExternalSurface` es una textura RGBA8 que vive en GPU y se pinta //! sobre un rect del frame Llimphi cada vez que la app lo expone vía //! `View::gpu_paint_with`. La fuente de bytes corre afuera del bucle //! Elm: un decoder de video, un capture de cámara, un raster de PDF, //! una textura raw producida por otro motor — cualquier productor que //! genere RGBA puede empujar frames con [`ExternalSurface::upload`] y //! ver el resultado en la próxima pasada de raster. Un decoder de video //! tiene además [`ExternalSurface::upload_yuv420p`], que le ahorra la //! conversión a RGBA (la hace el shader) y baja a 1,5 bytes por píxel lo //! que cruza el pipe y el `write_texture`. //! //! El crate provee: //! //! - [`ExternalSurface`]: dueño de la textura + render pipeline + bind //! group. `upload(rgba, w, h)` sube bytes y recrea la textura si //! `w`/`h` cambiaron. //! - [`ExternalSurface::view`]: helper que construye un [`View`] con //! `gpu_paint_with` ya conectado. La app sólo elige el `Style` del //! nodo (qué porción del layout ocupa). //! //! ## Diseño //! //! El pipeline es un textured-quad clásico: dos triángulos cubren el //! rect destino, el fragment shader samplea la textura externa con //! sampler bilineal. Las coordenadas NDC del quad se computan en GPU //! a partir de `(rect, viewport)` que viajan por uniform — por eso //! el callback necesita el `viewport` que `llimphi-ui` empezó a //! propagar en `GpuPaintFn`. //! //! La textura intermedia donde Llimphi pinta vello es `Rgba8Unorm` //! (ver `llimphi-hal::INTERMEDIATE_FORMAT`). El pipeline emite //! `Rgba8Unorm` también — el target del render pass es esa misma //! intermedia con `LoadOp::Load`, así el fondo vello queda preservado. use std::sync::Arc; use llimphi_hal::wgpu; use llimphi_ui::{PaintRect, View}; use parking_lot::Mutex; const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; const SOURCE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// Los planos de un cuadro YUV son de un canal, 8 bits. const PLANO_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm; /// Matriz de conversión YUV→RGB. La eligen las características del material, no /// el gusto: usar la que no es tiñe la imagen (los verdes y los tonos de piel /// son lo primero que se nota). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum EspacioColor { /// SD (y el default histórico de mucho material). `kr=0.299, kb=0.114`. Bt601, /// HD — lo normal en 720p para arriba. `kr=0.2126, kb=0.0722`. Bt709, } impl EspacioColor { /// `(kr, kb)` de la matriz. fn coeficientes(self) -> (f32, f32) { match self { EspacioColor::Bt601 => (0.299, 0.114), EspacioColor::Bt709 => (0.2126, 0.0722), } } } /// Rango de los valores: el video suele venir "limitado" (luma 16..235) y hay /// que expandirlo, o los negros salen grises y los blancos apagados. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RangoColor { /// 16..235 luma, 16..240 croma. Lo habitual en material de cámara/emisión. Limitado, /// 0..255 en todos los canales. Completo, } /// Qué tiene subido la surface ahora mismo — decide con qué pipeline se pinta. #[derive(Clone, Copy, PartialEq, Eq)] enum Modo { Rgba, Yuv420p, } struct Inner { device: wgpu::Device, queue: wgpu::Queue, pipeline: wgpu::RenderPipeline, bgl: wgpu::BindGroupLayout, sampler: wgpu::Sampler, uniforms: wgpu::Buffer, // Textura + bind group recreados cuando cambia (w, h) del frame de // entrada. Empieza en (1, 1) con un pixel transparente para que el // pipeline funcione antes del primer `upload`. tex: wgpu::Texture, bind_group: wgpu::BindGroup, tex_size: (u32, u32), // Camino YUV: pipeline propio (tres planos de un canal en vez de una RGBA) // y sus recursos, creados perezosamente en el primer `upload_yuv420p`. Una // app que sólo sube RGBA no paga nada de esto. pipeline_yuv: Option, bgl_yuv: wgpu::BindGroupLayout, planos: Option, modo: Modo, espacio: EspacioColor, rango: RangoColor, } /// Las tres texturas de un cuadro YUV planar + su bind group. struct PlanosYuv { y: wgpu::Texture, u: wgpu::Texture, v: wgpu::Texture, bind_group: wgpu::BindGroup, /// Tamaño del plano de luma. El de croma es la mitad, redondeando arriba. size: (u32, u32), } /// Superficie externa: textura GPU + pipeline que la blittea al rect /// que ocupe en el árbol Llimphi. Clonar es barato (Arc interno). #[derive(Clone)] pub struct ExternalSurface { inner: Arc>, } impl ExternalSurface { /// Construye la surface usando el `Device`/`Queue` del Hal de la app. /// La textura arranca en 1×1 transparente; el primer /// [`Self::upload`] la redimensiona al tamaño real del frame. pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("llimphi-surface-bgl"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, ], }); let pipeline = make_pipeline(device, &bgl, WGSL, "llimphi-surface-pipe"); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("llimphi-surface-sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, mipmap_filter: wgpu::MipmapFilterMode::Nearest, ..Default::default() }); // Uniforms: 16 floats — rect destino (x, y, w, h) + viewport // (vw, vh, _, _) + src_uv (u0, v0, du, dv): el sub-rectángulo de la // textura a muestrear, en UV 0..1 (`blit` lo deja en (0,0,1,1) —textura // entera—; `blit_layout` lo usa para crop/zoom/pan, V2) + los // parámetros de conversión YUV (kr, kb, rango-limitado, libre), que // sólo lee el shader del camino YUV. let uniforms = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-surface-uniforms"), size: 64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let (tex, bind_group) = make_texture_and_bg(device, queue, &bgl, &uniforms, &sampler, 1, 1, &[0, 0, 0, 0]); // Layout del camino YUV: mismo uniform y sampler, pero tres texturas de // un canal (Y, U, V) en vez de una RGBA. let bgl_yuv = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("llimphi-surface-bgl-yuv"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 3, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 4, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, ], }); Self { inner: Arc::new(Mutex::new(Inner { device: device.clone(), queue: queue.clone(), pipeline, bgl, sampler, uniforms, tex, bind_group, tex_size: (1, 1), pipeline_yuv: None, bgl_yuv, planos: None, modo: Modo::Rgba, espacio: EspacioColor::Bt709, rango: RangoColor::Limitado, })), } } /// Sube `rgba` (8 bits por canal, premultiplicado o no — el blend /// usa straight alpha) como nuevo contenido de la surface. Si /// `(width, height)` difiere del tamaño actual, recrea la textura /// y el bind group. `rgba.len()` debe ser exactamente /// `width * height * 4`. pub fn upload(&self, rgba: &[u8], width: u32, height: u32) { let mut inner = self.inner.lock(); inner.modo = Modo::Rgba; debug_assert_eq!(rgba.len(), (width as usize) * (height as usize) * 4); if inner.tex_size != (width, height) { let (tex, bg) = make_texture_and_bg( &inner.device, &inner.queue, &inner.bgl, &inner.uniforms, &inner.sampler, width, height, rgba, ); inner.tex = tex; inner.bind_group = bg; inner.tex_size = (width, height); } else { inner.queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &inner.tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, rgba, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(width * 4), rows_per_image: Some(height), }, wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, ); } } /// Sube un cuadro **YUV 4:2:0 planar** (el `yuv420p` de ffmpeg, que es lo /// que el decoder ya tiene en la mano) y deja que la conversión a RGB la /// haga el fragment shader. /// /// Es el camino barato para video, y la diferencia no es marginal: pedirle /// RGBA a ffmpeg le cuesta un ~29 % más de CPU —la conversión por software, /// medida— y multiplica por 2,67 los bytes que cruzan el pipe y el /// `write_texture` (4 bytes por píxel contra 1,5). En 1080p a 24 fps son /// 192 MB/s contra 72. La conversión en GPU, en cambio, es gratis: tres /// multiplicaciones por píxel dentro de un shader que ya se estaba /// ejecutando, y el sampler bilineal regala el upsampling del croma. /// /// `planos` es el layout de ffmpeg: primero Y (`w*h`), después U y después /// V (cada uno `ceil(w/2)*ceil(h/2)`). pub fn upload_yuv420p( &self, planos: &[u8], width: u32, height: u32, espacio: EspacioColor, rango: RangoColor, ) { let (cw, ch) = ((width + 1) / 2, (height + 1) / 2); let (ly, lc) = ((width as usize) * (height as usize), (cw as usize) * (ch as usize)); debug_assert_eq!(planos.len(), ly + lc * 2, "yuv420p mal dimensionado"); if planos.len() < ly + lc * 2 || width == 0 || height == 0 { return; } let mut inner = self.inner.lock(); inner.modo = Modo::Yuv420p; inner.espacio = espacio; inner.rango = rango; let hay = matches!(&inner.planos, Some(p) if p.size == (width, height)); if !hay { let crear = |w: u32, h: u32, label: &str| { inner.device.create_texture(&wgpu::TextureDescriptor { label: Some(label), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: PLANO_FORMAT, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }) }; let ty = crear(width, height, "llimphi-surface-plano-y"); let tu = crear(cw, ch, "llimphi-surface-plano-u"); let tv = crear(cw, ch, "llimphi-surface-plano-v"); let (vy, vu, vv) = ( ty.create_view(&Default::default()), tu.create_view(&Default::default()), tv.create_view(&Default::default()), ); let bind_group = inner.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-surface-bg-yuv"), layout: &inner.bgl_yuv, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: inner.uniforms.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&inner.sampler), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&vy), }, wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&vu), }, wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&vv), }, ], }); inner.planos = Some(PlanosYuv { y: ty, u: tu, v: tv, bind_group, size: (width, height), }); } if inner.pipeline_yuv.is_none() { inner.pipeline_yuv = Some(make_pipeline( &inner.device, &inner.bgl_yuv, WGSL_YUV, "llimphi-surface-pipe-yuv", )); } let planos_ref = inner.planos.as_ref().expect("recién creados"); let subir = |tex: &wgpu::Texture, datos: &[u8], w: u32, h: u32| { inner.queue.write_texture( wgpu::TexelCopyTextureInfo { texture: tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, datos, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w), rows_per_image: Some(h), }, wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1, }, ); }; subir(&planos_ref.y, &planos[..ly], width, height); subir(&planos_ref.u, &planos[ly..ly + lc], cw, ch); subir(&planos_ref.v, &planos[ly + lc..ly + lc * 2], cw, ch); } /// Tamaño actual de la textura interna (último upload o (1,1) si /// nunca se subió nada). pub fn size(&self) -> (u32, u32) { let inner = self.inner.lock(); match inner.modo { Modo::Rgba => inner.tex_size, Modo::Yuv420p => inner.planos.as_ref().map(|p| p.size).unwrap_or((1, 1)), } } /// Encola el draw del quad que pinta la surface en `dst_view` dentro /// de `rect`, escalando la textura para cubrir el rect entero. /// Llamado típicamente desde el callback de `View::gpu_paint_with`. pub fn blit( &self, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, dst_view: &wgpu::TextureView, rect: PaintRect, viewport: (u32, u32), ) { // Comportamiento clásico: la textura entera estirada al rect, recortada // al propio rect. `blit_layout` con `src_uv` = textura completa y el // `clip` = el mismo rect. self.blit_layout( queue, encoder, dst_view, [rect.x, rect.y, rect.w, rect.h], [0.0, 0.0, 1.0, 1.0], [rect.x, rect.y, rect.w, rect.h], viewport, ); } /// Blit con control de **origen y destino** (V2: aspect/crop/zoom/pan). /// `dst` es el rectángulo (px de viewport) donde dibujar — puede exceder el /// `clip`; `src_uv` (`u0, v0, du, dv` en 0..1) es el sub-rectángulo de la /// textura a muestrear (crop); `clip` (px de viewport) acota el dibujado con /// un scissor — típicamente el rect del canvas, así el sobrante de /// Fill/zoom/pan no pinta fuera de su área. El `dst`/`src_uv` los calcula /// `media_core::viewport::compute_layout`. pub fn blit_layout( &self, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, dst_view: &wgpu::TextureView, dst: [f32; 4], src_uv: [f32; 4], clip: [f32; 4], viewport: (u32, u32), ) { let inner = self.inner.lock(); let (kr, kb) = inner.espacio.coeficientes(); let limitado = matches!(inner.rango, RangoColor::Limitado) as u32 as f32; let uniforms = [ dst[0], dst[1], dst[2], dst[3], viewport.0 as f32, viewport.1 as f32, 0.0, 0.0, src_uv[0], src_uv[1], src_uv[2], src_uv[3], kr, kb, limitado, 0.0, ]; let mut bytes = [0u8; 64]; for (i, v) in uniforms.iter().enumerate() { bytes[i * 4..(i + 1) * 4].copy_from_slice(&v.to_ne_bytes()); } queue.write_buffer(&inner.uniforms, 0, &bytes); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("llimphi-surface-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: dst_view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); // El camino YUV sólo entra si hay planos subidos Y su pipeline forjado; // si algo falta, se cae al RGBA de siempre en vez de no pintar nada. let yuv = (inner.modo == Modo::Yuv420p) .then(|| inner.pipeline_yuv.as_ref().zip(inner.planos.as_ref())) .flatten(); match yuv { Some((pipe, planos)) => { pass.set_pipeline(pipe); pass.set_bind_group(0, &planos.bind_group, &[]); } None => { pass.set_pipeline(&inner.pipeline); pass.set_bind_group(0, &inner.bind_group, &[]); } } // Scissor = clip ∩ viewport (en px enteros). Evita que el sobrante de // Fill/zoom/pan pinte fuera del área del canvas. let (vw, vh) = (viewport.0 as f32, viewport.1 as f32); let x0 = clip[0].max(0.0).floor(); let y0 = clip[1].max(0.0).floor(); let x1 = (clip[0] + clip[2]).min(vw).ceil(); let y1 = (clip[1] + clip[3]).min(vh).ceil(); if x1 > x0 && y1 > y0 { pass.set_scissor_rect(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32); } pass.draw(0..6, 0..1); } /// Construye un `View` cuyo `gpu_paint_with` blittea la surface al /// rect que le asigne el layout. La app sólo escoge el `Style` /// (tamaño, flex_grow…). El `Msg` está libre — la View no emite /// eventos por sí sola. pub fn view(&self, style: llimphi_ui::llimphi_layout::taffy::Style) -> View where Msg: Clone + Send + Sync + 'static, { let this = self.clone(); View::new(style).gpu_paint_with(move |_device, queue, encoder, view, rect, viewport| { this.blit(queue, encoder, view, rect, viewport); }) } } /// Pipeline de textured-quad. Los dos caminos (RGBA y YUV) son idénticos salvo /// el bind group layout y el fragment shader, así que comparten esta forja. fn make_pipeline( device: &wgpu::Device, bgl: &wgpu::BindGroupLayout, wgsl: &str, label: &str, ) -> wgpu::RenderPipeline { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some(label), source: wgpu::ShaderSource::Wgsl(wgsl.into()), }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some(label), bind_group_layouts: &[Some(bgl)], immediate_size: 0, }); device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[], }, primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: TARGET_FORMAT, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), multiview_mask: None, cache: None, }) } fn make_texture_and_bg( device: &wgpu::Device, queue: &wgpu::Queue, bgl: &wgpu::BindGroupLayout, uniforms: &wgpu::Buffer, sampler: &wgpu::Sampler, width: u32, height: u32, initial_rgba: &[u8], ) -> (wgpu::Texture, wgpu::BindGroup) { let tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-surface-tex"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: SOURCE_FORMAT, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, initial_rgba, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(width * 4), rows_per_image: Some(height), }, wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, ); let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-surface-bg"), layout: bgl, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: uniforms.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(sampler), }, ], }); (tex, bind_group) } const WGSL: &str = r#" struct Uniforms { rect: vec4, // x, y, w, h del rect destino en pixels del frame viewport: vec4, // vw, vh, _, _ src_uv: vec4, // u0, v0, du, dv: sub-rect de textura a muestrear (UV) }; @group(0) @binding(0) var u: Uniforms; @group(0) @binding(1) var tex: texture_2d; @group(0) @binding(2) var samp: sampler; struct V2F { @builtin(position) pos: vec4, @location(0) uv: vec2, }; @vertex fn vs(@builtin(vertex_index) vid: u32) -> V2F { // Dos triángulos en UV-space, recorridos CCW. var uvs = array, 6>( vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0), ); // `q` recorre el quad destino (0..1); el muestreo va al sub-rect `src_uv`. let q = uvs[vid]; let px = u.rect.x + q.x * u.rect.z; let py = u.rect.y + q.y * u.rect.w; // NDC: x ∈ [-1, 1] sin flip, y flipeado (en pantalla y-down). let ndc = vec2( px / u.viewport.x * 2.0 - 1.0, 1.0 - py / u.viewport.y * 2.0, ); var out: V2F; out.pos = vec4(ndc, 0.0, 1.0); out.uv = u.src_uv.xy + q * u.src_uv.zw; return out; } @fragment fn fs(in: V2F) -> @location(0) vec4 { return textureSample(tex, samp, in.uv); } "#; /// Igual que [`WGSL`] pero muestreando tres planos (Y, U, V) y convirtiendo a /// RGB acá — el trabajo que si no hace ffmpeg por software, cuadro a cuadro. /// /// El vértice es idéntico; sólo cambia el fragment. El croma está a media /// resolución y se muestrea con las MISMAS UV: el sampler bilineal lo interpola /// solo, que es exactamente el upsampling que se quiere. const WGSL_YUV: &str = r#" struct Uniforms { rect: vec4, viewport: vec4, src_uv: vec4, // kr, kb de la matriz; z = 1 si el rango es limitado (16..235); w libre. yuv: vec4, }; @group(0) @binding(0) var u: Uniforms; @group(0) @binding(1) var samp: sampler; @group(0) @binding(2) var tex_y: texture_2d; @group(0) @binding(3) var tex_u: texture_2d; @group(0) @binding(4) var tex_v: texture_2d; struct V2F { @builtin(position) pos: vec4, @location(0) uv: vec2, }; @vertex fn vs(@builtin(vertex_index) vid: u32) -> V2F { var uvs = array, 6>( vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0), ); let q = uvs[vid]; let px = u.rect.x + q.x * u.rect.z; let py = u.rect.y + q.y * u.rect.w; let ndc = vec2( px / u.viewport.x * 2.0 - 1.0, 1.0 - py / u.viewport.y * 2.0, ); var out: V2F; out.pos = vec4(ndc, 0.0, 1.0); out.uv = u.src_uv.xy + q * u.src_uv.zw; return out; } @fragment fn fs(in: V2F) -> @location(0) vec4 { var y = textureSample(tex_y, samp, in.uv).r; var cb = textureSample(tex_u, samp, in.uv).r - 0.5; var cr = textureSample(tex_v, samp, in.uv).r - 0.5; // Rango limitado: luma 16..235 y croma 16..240 sobre 255. Sin expandir, // los negros salen grises y los blancos apagados. if (u.yuv.z > 0.5) { y = (y - 16.0 / 255.0) * (255.0 / 219.0); cb = cb * (255.0 / 224.0); cr = cr * (255.0 / 224.0); } let kr = u.yuv.x; let kb = u.yuv.y; let kg = 1.0 - kr - kb; let r = y + 2.0 * (1.0 - kr) * cr; let b = y + 2.0 * (1.0 - kb) * cb; let g = y - (2.0 * (1.0 - kr) * kr / kg) * cr - (2.0 * (1.0 - kb) * kb / kg) * cb; return vec4(clamp(vec3(r, g, b), vec3(0.0), vec3(1.0)), 1.0); } "#;