tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Sutichaykuna

⎇ a3c2ea9ffa5a97812e9ec043374b756586210bfb llimphi-surface/src/lib.rs Rust · 455 siq'ikuna · 16.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · 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.
10//!
11//! El crate provee:
12//!
13//! - [`ExternalSurface`]: dueño de la textura + render pipeline + bind
14//!   group. `upload(rgba, w, h)` sube bytes y recrea la textura si
15//!   `w`/`h` cambiaron.
16//! - [`ExternalSurface::view`]: helper que construye un [`View`] con
17//!   `gpu_paint_with` ya conectado. La app sólo elige el `Style` del
18//!   nodo (qué porción del layout ocupa).
19//!
20//! ## Diseño
21//!
22//! El pipeline es un textured-quad clásico: dos triángulos cubren el
23//! rect destino, el fragment shader samplea la textura externa con
24//! sampler bilineal. Las coordenadas NDC del quad se computan en GPU
25//! a partir de `(rect, viewport)` que viajan por uniform — por eso
26//! el callback necesita el `viewport` que `llimphi-ui` empezó a
27//! propagar en `GpuPaintFn`.
28//!
29//! La textura intermedia donde Llimphi pinta vello es `Rgba8Unorm`
30//! (ver `llimphi-hal::INTERMEDIATE_FORMAT`). El pipeline emite
31//! `Rgba8Unorm` también — el target del render pass es esa misma
32//! intermedia con `LoadOp::Load`, así el fondo vello queda preservado.
33
34use std::sync::Arc;
35
36use llimphi_hal::wgpu;
37use llimphi_ui::{PaintRect, View};
38use parking_lot::Mutex;
39
40const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
41const SOURCE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
42
43struct Inner {
44    device: wgpu::Device,
45    queue: wgpu::Queue,
46    pipeline: wgpu::RenderPipeline,
47    bgl: wgpu::BindGroupLayout,
48    sampler: wgpu::Sampler,
49    uniforms: wgpu::Buffer,
50    // Textura + bind group recreados cuando cambia (w, h) del frame de
51    // entrada. Empieza en (1, 1) con un pixel transparente para que el
52    // pipeline funcione antes del primer `upload`.
53    tex: wgpu::Texture,
54    bind_group: wgpu::BindGroup,
55    tex_size: (u32, u32),
56}
57
58/// Superficie externa: textura GPU + pipeline que la blittea al rect
59/// que ocupe en el árbol Llimphi. Clonar es barato (Arc interno).
60#[derive(Clone)]
61pub struct ExternalSurface {
62    inner: Arc<Mutex<Inner>>,
63}
64
65impl ExternalSurface {
66    /// Construye la surface usando el `Device`/`Queue` del Hal de la app.
67    /// La textura arranca en 1×1 transparente; el primer
68    /// [`Self::upload`] la redimensiona al tamaño real del frame.
69    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
70        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
71            label: Some("llimphi-surface-bgl"),
72            entries: &[
73                wgpu::BindGroupLayoutEntry {
74                    binding: 0,
75                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
76                    ty: wgpu::BindingType::Buffer {
77                        ty: wgpu::BufferBindingType::Uniform,
78                        has_dynamic_offset: false,
79                        min_binding_size: None,
80                    },
81                    count: None,
82                },
83                wgpu::BindGroupLayoutEntry {
84                    binding: 1,
85                    visibility: wgpu::ShaderStages::FRAGMENT,
86                    ty: wgpu::BindingType::Texture {
87                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
88                        view_dimension: wgpu::TextureViewDimension::D2,
89                        multisampled: false,
90                    },
91                    count: None,
92                },
93                wgpu::BindGroupLayoutEntry {
94                    binding: 2,
95                    visibility: wgpu::ShaderStages::FRAGMENT,
96                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
97                    count: None,
98                },
99            ],
100        });
101
102        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
103            label: Some("llimphi-surface-pl"),
104            bind_group_layouts: &[&bgl],
105            push_constant_ranges: &[],
106        });
107
108        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
109            label: Some("llimphi-surface-shader"),
110            source: wgpu::ShaderSource::Wgsl(WGSL.into()),
111        });
112
113        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
114            label: Some("llimphi-surface-pipe"),
115            layout: Some(&pipeline_layout),
116            vertex: wgpu::VertexState {
117                module: &shader,
118                entry_point: Some("vs"),
119                compilation_options: Default::default(),
120                buffers: &[],
121            },
122            primitive: wgpu::PrimitiveState {
123                topology: wgpu::PrimitiveTopology::TriangleList,
124                ..Default::default()
125            },
126            depth_stencil: None,
127            multisample: wgpu::MultisampleState::default(),
128            fragment: Some(wgpu::FragmentState {
129                module: &shader,
130                entry_point: Some("fs"),
131                compilation_options: Default::default(),
132                targets: &[Some(wgpu::ColorTargetState {
133                    format: TARGET_FORMAT,
134                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
135                    write_mask: wgpu::ColorWrites::ALL,
136                })],
137            }),
138            multiview: None,
139            cache: None,
140        });
141
142        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
143            label: Some("llimphi-surface-sampler"),
144            address_mode_u: wgpu::AddressMode::ClampToEdge,
145            address_mode_v: wgpu::AddressMode::ClampToEdge,
146            address_mode_w: wgpu::AddressMode::ClampToEdge,
147            mag_filter: wgpu::FilterMode::Linear,
148            min_filter: wgpu::FilterMode::Linear,
149            mipmap_filter: wgpu::FilterMode::Nearest,
150            ..Default::default()
151        });
152
153        // Uniforms: 12 floats — rect destino (x, y, w, h) + viewport
154        // (vw, vh, _, _) + src_uv (u0, v0, du, dv): el sub-rectángulo de la
155        // textura a muestrear, en UV 0..1. `blit` lo deja en (0,0,1,1)
156        // (textura entera); `blit_layout` lo usa para crop/zoom/pan (V2).
157        let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
158            label: Some("llimphi-surface-uniforms"),
159            size: 48,
160            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
161            mapped_at_creation: false,
162        });
163
164        let (tex, bind_group) =
165            make_texture_and_bg(device, queue, &bgl, &uniforms, &sampler, 1, 1, &[0, 0, 0, 0]);
166
167        Self {
168            inner: Arc::new(Mutex::new(Inner {
169                device: device.clone(),
170                queue: queue.clone(),
171                pipeline,
172                bgl,
173                sampler,
174                uniforms,
175                tex,
176                bind_group,
177                tex_size: (1, 1),
178            })),
179        }
180    }
181
182    /// Sube `rgba` (8 bits por canal, premultiplicado o no — el blend
183    /// usa straight alpha) como nuevo contenido de la surface. Si
184    /// `(width, height)` difiere del tamaño actual, recrea la textura
185    /// y el bind group. `rgba.len()` debe ser exactamente
186    /// `width * height * 4`.
187    pub fn upload(&self, rgba: &[u8], width: u32, height: u32) {
188        let mut inner = self.inner.lock();
189        debug_assert_eq!(rgba.len(), (width as usize) * (height as usize) * 4);
190        if inner.tex_size != (width, height) {
191            let (tex, bg) = make_texture_and_bg(
192                &inner.device,
193                &inner.queue,
194                &inner.bgl,
195                &inner.uniforms,
196                &inner.sampler,
197                width,
198                height,
199                rgba,
200            );
201            inner.tex = tex;
202            inner.bind_group = bg;
203            inner.tex_size = (width, height);
204        } else {
205            inner.queue.write_texture(
206                wgpu::TexelCopyTextureInfo {
207                    texture: &inner.tex,
208                    mip_level: 0,
209                    origin: wgpu::Origin3d::ZERO,
210                    aspect: wgpu::TextureAspect::All,
211                },
212                rgba,
213                wgpu::TexelCopyBufferLayout {
214                    offset: 0,
215                    bytes_per_row: Some(width * 4),
216                    rows_per_image: Some(height),
217                },
218                wgpu::Extent3d {
219                    width,
220                    height,
221                    depth_or_array_layers: 1,
222                },
223            );
224        }
225    }
226
227    /// Tamaño actual de la textura interna (último upload o (1,1) si
228    /// nunca se subió nada).
229    pub fn size(&self) -> (u32, u32) {
230        self.inner.lock().tex_size
231    }
232
233    /// Encola el draw del quad que pinta la surface en `dst_view` dentro
234    /// de `rect`, escalando la textura para cubrir el rect entero.
235    /// Llamado típicamente desde el callback de `View::gpu_paint_with`.
236    pub fn blit(
237        &self,
238        queue: &wgpu::Queue,
239        encoder: &mut wgpu::CommandEncoder,
240        dst_view: &wgpu::TextureView,
241        rect: PaintRect,
242        viewport: (u32, u32),
243    ) {
244        // Comportamiento clásico: la textura entera estirada al rect, recortada
245        // al propio rect. `blit_layout` con `src_uv` = textura completa y el
246        // `clip` = el mismo rect.
247        self.blit_layout(
248            queue,
249            encoder,
250            dst_view,
251            [rect.x, rect.y, rect.w, rect.h],
252            [0.0, 0.0, 1.0, 1.0],
253            [rect.x, rect.y, rect.w, rect.h],
254            viewport,
255        );
256    }
257
258    /// Blit con control de **origen y destino** (V2: aspect/crop/zoom/pan).
259    /// `dst` es el rectángulo (px de viewport) donde dibujar — puede exceder el
260    /// `clip`; `src_uv` (`u0, v0, du, dv` en 0..1) es el sub-rectángulo de la
261    /// textura a muestrear (crop); `clip` (px de viewport) acota el dibujado con
262    /// un scissor — típicamente el rect del canvas, así el sobrante de
263    /// Fill/zoom/pan no pinta fuera de su área. El `dst`/`src_uv` los calcula
264    /// `media_core::viewport::compute_layout`.
265    pub fn blit_layout(
266        &self,
267        queue: &wgpu::Queue,
268        encoder: &mut wgpu::CommandEncoder,
269        dst_view: &wgpu::TextureView,
270        dst: [f32; 4],
271        src_uv: [f32; 4],
272        clip: [f32; 4],
273        viewport: (u32, u32),
274    ) {
275        let inner = self.inner.lock();
276        let uniforms = [
277            dst[0],
278            dst[1],
279            dst[2],
280            dst[3],
281            viewport.0 as f32,
282            viewport.1 as f32,
283            0.0,
284            0.0,
285            src_uv[0],
286            src_uv[1],
287            src_uv[2],
288            src_uv[3],
289        ];
290        let mut bytes = [0u8; 48];
291        for (i, v) in uniforms.iter().enumerate() {
292            bytes[i * 4..(i + 1) * 4].copy_from_slice(&v.to_ne_bytes());
293        }
294        queue.write_buffer(&inner.uniforms, 0, &bytes);
295
296        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
297            label: Some("llimphi-surface-pass"),
298            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
299                view: dst_view,
300                resolve_target: None,
301                depth_slice: None,
302                ops: wgpu::Operations {
303                    load: wgpu::LoadOp::Load,
304                    store: wgpu::StoreOp::Store,
305                },
306            })],
307            depth_stencil_attachment: None,
308            timestamp_writes: None,
309            occlusion_query_set: None,
310        });
311        pass.set_pipeline(&inner.pipeline);
312        pass.set_bind_group(0, &inner.bind_group, &[]);
313        // Scissor = clip ∩ viewport (en px enteros). Evita que el sobrante de
314        // Fill/zoom/pan pinte fuera del área del canvas.
315        let (vw, vh) = (viewport.0 as f32, viewport.1 as f32);
316        let x0 = clip[0].max(0.0).floor();
317        let y0 = clip[1].max(0.0).floor();
318        let x1 = (clip[0] + clip[2]).min(vw).ceil();
319        let y1 = (clip[1] + clip[3]).min(vh).ceil();
320        if x1 > x0 && y1 > y0 {
321            pass.set_scissor_rect(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32);
322        }
323        pass.draw(0..6, 0..1);
324    }
325
326    /// Construye un `View` cuyo `gpu_paint_with` blittea la surface al
327    /// rect que le asigne el layout. La app sólo escoge el `Style`
328    /// (tamaño, flex_grow…). El `Msg` está libre — la View no emite
329    /// eventos por sí sola.
330    pub fn view<Msg>(&self, style: llimphi_ui::llimphi_layout::taffy::Style) -> View<Msg>
331    where
332        Msg: Clone + Send + Sync + 'static,
333    {
334        let this = self.clone();
335        View::new(style).gpu_paint_with(move |_device, queue, encoder, view, rect, viewport| {
336            this.blit(queue, encoder, view, rect, viewport);
337        })
338    }
339}
340
341fn make_texture_and_bg(
342    device: &wgpu::Device,
343    queue: &wgpu::Queue,
344    bgl: &wgpu::BindGroupLayout,
345    uniforms: &wgpu::Buffer,
346    sampler: &wgpu::Sampler,
347    width: u32,
348    height: u32,
349    initial_rgba: &[u8],
350) -> (wgpu::Texture, wgpu::BindGroup) {
351    let tex = device.create_texture(&wgpu::TextureDescriptor {
352        label: Some("llimphi-surface-tex"),
353        size: wgpu::Extent3d {
354            width,
355            height,
356            depth_or_array_layers: 1,
357        },
358        mip_level_count: 1,
359        sample_count: 1,
360        dimension: wgpu::TextureDimension::D2,
361        format: SOURCE_FORMAT,
362        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
363        view_formats: &[],
364    });
365    queue.write_texture(
366        wgpu::TexelCopyTextureInfo {
367            texture: &tex,
368            mip_level: 0,
369            origin: wgpu::Origin3d::ZERO,
370            aspect: wgpu::TextureAspect::All,
371        },
372        initial_rgba,
373        wgpu::TexelCopyBufferLayout {
374            offset: 0,
375            bytes_per_row: Some(width * 4),
376            rows_per_image: Some(height),
377        },
378        wgpu::Extent3d {
379            width,
380            height,
381            depth_or_array_layers: 1,
382        },
383    );
384    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
385    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
386        label: Some("llimphi-surface-bg"),
387        layout: bgl,
388        entries: &[
389            wgpu::BindGroupEntry {
390                binding: 0,
391                resource: uniforms.as_entire_binding(),
392            },
393            wgpu::BindGroupEntry {
394                binding: 1,
395                resource: wgpu::BindingResource::TextureView(&view),
396            },
397            wgpu::BindGroupEntry {
398                binding: 2,
399                resource: wgpu::BindingResource::Sampler(sampler),
400            },
401        ],
402    });
403    (tex, bind_group)
404}
405
406const WGSL: &str = r#"
407struct Uniforms {
408    rect: vec4<f32>,     // x, y, w, h del rect destino en pixels del frame
409    viewport: vec4<f32>, // vw, vh, _, _
410    src_uv: vec4<f32>,   // u0, v0, du, dv: sub-rect de textura a muestrear (UV)
411};
412
413@group(0) @binding(0) var<uniform> u: Uniforms;
414@group(0) @binding(1) var tex: texture_2d<f32>;
415@group(0) @binding(2) var samp: sampler;
416
417struct V2F {
418    @builtin(position) pos: vec4<f32>,
419    @location(0) uv: vec2<f32>,
420};
421
422@vertex
423fn vs(@builtin(vertex_index) vid: u32) -> V2F {
424    // Dos triángulos en UV-space, recorridos CCW.
425    var uvs = array<vec2<f32>, 6>(
426        vec2<f32>(0.0, 0.0),
427        vec2<f32>(1.0, 0.0),
428        vec2<f32>(1.0, 1.0),
429        vec2<f32>(0.0, 0.0),
430        vec2<f32>(1.0, 1.0),
431        vec2<f32>(0.0, 1.0),
432    );
433    // `q` recorre el quad destino (0..1); el muestreo va al sub-rect `src_uv`.
434    let q = uvs[vid];
435
436    let px = u.rect.x + q.x * u.rect.z;
437    let py = u.rect.y + q.y * u.rect.w;
438
439    // NDC: x ∈ [-1, 1] sin flip, y flipeado (en pantalla y-down).
440    let ndc = vec2<f32>(
441        px / u.viewport.x * 2.0 - 1.0,
442        1.0 - py / u.viewport.y * 2.0,
443    );
444
445    var out: V2F;
446    out.pos = vec4<f32>(ndc, 0.0, 1.0);
447    out.uv = u.src_uv.xy + q * u.src_uv.zw;
448    return out;
449}
450
451@fragment
452fn fs(in: V2F) -> @location(0) vec4<f32> {
453    return textureSample(tex, samp, in.uv);
454}
455"#;