tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ a3c2ea9ffa5a97812e9ec043374b756586210bfb
llimphi-surface/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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"#;