tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc widgets/terminal/src/cell_pipeline.rs Rust · 574 siq'ikuna · 21.6 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//! Pipeline GPU para la grilla de celdas del modo TUI (Fase 4 del SDD-TERMINAL).
2//!
3//! Define las **estructuras POD** (instance + uniforms), el **shader WGSL**
4//! y el **pipeline wgpu** (`CellPipeline`) que las consume. El wireado al
5//! `generic_grid_panel` vía `gpu_paint_with` va en el commit siguiente —
6//! acá ya se valida que el shader compila en un device headless.
7//!
8//! ## Layouts
9//!
10//! - **Instance** (32 B): `[cell_x: f32, cell_y: f32, uv_x: f32, uv_y: f32,
11//!   uv_w: f32, uv_h: f32, fg_rgba: u32, bg_rgba: u32]`.
12//!   Una por celda visible; el vertex stage emite los 4 corners (TriangleStrip).
13//! - **Uniforms** (32 B): `[viewport_w: f32, viewport_h: f32, cell_w: f32,
14//!   cell_h: f32, atlas_w: f32, atlas_h: f32, _pad: [f32; 2]]`.
15//!
16//! El fragment samplea el atlas grayscale en `uv`; alpha = cobertura;
17//! out = mix(bg, fg, alpha). Blending estándar `OVER` por encima.
18//!
19//! ## Por qué quads instanciados
20//!
21//! Una grilla de 100×40 = 4000 celdas; en vello eso son ~4000 Views + 4000
22//! draws + el shaping de cada char. Con quads instanciados es UNA draw call
23//! de 4000 instancias y la GPU pinta todo en paralelo. Igual de simple
24//! para 200×80 (16k celdas) — patrón ya validado en `GpuPipelines.rects`.
25
26/// Una celda lista para dibujar. **POD, repr(C)** — `as_bytes` la serializa
27/// a una secuencia plana de `f32`/`u32` little-endian para el buffer GPU.
28#[derive(Debug, Clone, Copy, PartialEq)]
29#[repr(C)]
30pub struct CellInstance {
31    /// Posición (px) de la esquina superior-izquierda de la celda en
32    /// viewport coords.
33    pub cell_x: f32,
34    pub cell_y: f32,
35    /// Coords UV (px) del glifo en la textura del atlas. El shader las
36    /// divide por `atlas_size` para obtener UVs normalizadas 0..1.
37    pub uv_x: f32,
38    pub uv_y: f32,
39    pub uv_w: f32,
40    pub uv_h: f32,
41    /// Color foreground del glifo, RGBA8 empacado little-endian
42    /// (`r | g<<8 | b<<16 | a<<24`).
43    pub fg_rgba: u32,
44    /// Color background de la celda, RGBA8 empacado.
45    pub bg_rgba: u32,
46}
47
48impl CellInstance {
49    /// Tamaño en bytes del layout — debe coincidir con `array_stride` del
50    /// pipeline en wgpu. Compile-time const para que el caller arme el
51    /// vertex layout sin recalcular.
52    pub const SIZE: usize = 32;
53
54    /// Serializa a 32 bytes little-endian para `Queue::write_buffer`.
55    pub fn as_bytes(&self) -> [u8; Self::SIZE] {
56        let mut out = [0u8; Self::SIZE];
57        out[0..4].copy_from_slice(&self.cell_x.to_le_bytes());
58        out[4..8].copy_from_slice(&self.cell_y.to_le_bytes());
59        out[8..12].copy_from_slice(&self.uv_x.to_le_bytes());
60        out[12..16].copy_from_slice(&self.uv_y.to_le_bytes());
61        out[16..20].copy_from_slice(&self.uv_w.to_le_bytes());
62        out[20..24].copy_from_slice(&self.uv_h.to_le_bytes());
63        out[24..28].copy_from_slice(&self.fg_rgba.to_le_bytes());
64        out[28..32].copy_from_slice(&self.bg_rgba.to_le_bytes());
65        out
66    }
67}
68
69/// Empaca un color `(r, g, b, a)` en un `u32` RGBA little-endian que el
70/// shader lee como `vec4<u32>` y normaliza a `vec4<f32>(r,g,b,a)/255`.
71pub fn pack_rgba(r: u8, g: u8, b: u8, a: u8) -> u32 {
72    (r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | ((a as u32) << 24)
73}
74
75/// Serializa un slice de instancias a un buffer de bytes contiguo. Útil
76/// para `Queue::write_buffer`.
77pub fn instances_to_bytes(cells: &[CellInstance]) -> Vec<u8> {
78    let mut out = Vec::with_capacity(cells.len() * CellInstance::SIZE);
79    for c in cells {
80        out.extend_from_slice(&c.as_bytes());
81    }
82    out
83}
84
85/// Uniforms del pipeline (un único buffer por draw). **POD, repr(C)**.
86#[derive(Debug, Clone, Copy)]
87#[repr(C)]
88pub struct CellUniforms {
89    pub viewport_w: f32,
90    pub viewport_h: f32,
91    pub cell_w: f32,
92    pub cell_h: f32,
93    pub atlas_w: f32,
94    pub atlas_h: f32,
95    pub _pad0: f32,
96    pub _pad1: f32,
97}
98
99impl CellUniforms {
100    /// 32 B — el bind group binding debe tener `min_binding_size = Some(32)`.
101    pub const SIZE: usize = 32;
102
103    pub fn as_bytes(&self) -> [u8; Self::SIZE] {
104        let mut out = [0u8; Self::SIZE];
105        let fields = [
106            self.viewport_w,
107            self.viewport_h,
108            self.cell_w,
109            self.cell_h,
110            self.atlas_w,
111            self.atlas_h,
112            self._pad0,
113            self._pad1,
114        ];
115        for (i, v) in fields.iter().enumerate() {
116            out[i * 4..(i + 1) * 4].copy_from_slice(&v.to_le_bytes());
117        }
118        out
119    }
120}
121
122/// El shader WGSL del pipeline. Vertex stage usa `vertex_index` (0..4) para
123/// emitir los corners del quad como TriangleStrip. Fragment samplea el atlas
124/// grayscale y combina fg/bg por cobertura.
125pub const CELL_WGSL: &str = r#"
126struct Uniforms {
127    viewport_size: vec2<f32>,
128    cell_size: vec2<f32>,
129    atlas_size: vec2<f32>,
130    _pad: vec2<f32>,
131};
132
133@group(0) @binding(0) var<uniform> u: Uniforms;
134@group(0) @binding(1) var atlas_tex: texture_2d<f32>;
135@group(0) @binding(2) var atlas_samp: sampler;
136
137struct VsIn {
138    @builtin(vertex_index) vi: u32,
139    @location(0) cell_xy: vec2<f32>,
140    @location(1) uv_rect: vec4<f32>,
141    @location(2) fg_rgba: u32,
142    @location(3) bg_rgba: u32,
143};
144
145struct VsOut {
146    @builtin(position) pos: vec4<f32>,
147    @location(0) uv: vec2<f32>,
148    @location(1) fg: vec4<f32>,
149    @location(2) bg: vec4<f32>,
150};
151
152fn unpack_rgba(c: u32) -> vec4<f32> {
153    let r = f32(c & 0xFFu) / 255.0;
154    let g = f32((c >> 8u) & 0xFFu) / 255.0;
155    let b = f32((c >> 16u) & 0xFFu) / 255.0;
156    let a = f32((c >> 24u) & 0xFFu) / 255.0;
157    return vec4<f32>(r, g, b, a);
158}
159
160@vertex
161fn vs_cell(in: VsIn) -> VsOut {
162    // 4 corners del quad, TriangleStrip: (0,0) (1,0) (0,1) (1,1).
163    let corner = vec2<f32>(f32(in.vi & 1u), f32((in.vi >> 1u) & 1u));
164    let pixel_pos = in.cell_xy + corner * u.cell_size;
165    // px → NDC: x in [-1,1], y in [1,-1] (y invertido para alinear con la
166    // convención px-origin-top-left de viewport).
167    let ndc = vec2<f32>(
168        (pixel_pos.x / u.viewport_size.x) * 2.0 - 1.0,
169        1.0 - (pixel_pos.y / u.viewport_size.y) * 2.0,
170    );
171    var out: VsOut;
172    out.pos = vec4<f32>(ndc, 0.0, 1.0);
173    // UV en pixels → UV normalizadas del atlas.
174    let uv_px = in.uv_rect.xy + corner * in.uv_rect.zw;
175    out.uv = uv_px / u.atlas_size;
176    out.fg = unpack_rgba(in.fg_rgba);
177    out.bg = unpack_rgba(in.bg_rgba);
178    return out;
179}
180
181@fragment
182fn fs_cell(in: VsOut) -> @location(0) vec4<f32> {
183    // Atlas grayscale: la cobertura del glifo está en el canal R (la
184    // textura R8Unorm devuelve (R, 0, 0, 1)).
185    let cov = textureSample(atlas_tex, atlas_samp, in.uv).r;
186    // Mezcla bg → fg por cobertura. Pre-multiplica alpha del fg para
187    // que cubrir 100% rinda fg.a (no 1.0).
188    let rgb = mix(in.bg.rgb, in.fg.rgb, cov * in.fg.a);
189    let a = max(in.bg.a, cov * in.fg.a);
190    return vec4<f32>(rgb, a);
191}
192"#;
193
194use llimphi_hal::wgpu;
195
196/// Pipeline wgpu del cell renderer — compila el shader y arma el bind
197/// group layout. Una sola instancia por proceso (o por `color_format`); el
198/// `draw` la consume con un atlas + instancias frescas por frame.
199///
200/// El atlas se sube aparte (su propio `wgpu::Texture` con format
201/// `R8Unorm`), y entra al bind group por la `binding=1`. Reusar el mismo
202/// atlas entre frames es OK — sólo se actualiza con `Queue::write_texture`
203/// cuando aparecen glifos nuevos (el `GlyphAtlas::take_dirty` lo señala).
204pub struct CellPipeline {
205    pub pipeline: wgpu::RenderPipeline,
206    pub bind_layout: wgpu::BindGroupLayout,
207    pub sampler: wgpu::Sampler,
208}
209
210impl CellPipeline {
211    /// Compila el shader WGSL y construye el pipeline para escribir al
212    /// `color_format` dado (típicamente `Rgba8Unorm`, el de la intermedia
213    /// del `WinitSurface`).
214    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
215        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
216            label: Some("llimphi-widget-terminal-cell-shader"),
217            source: wgpu::ShaderSource::Wgsl(CELL_WGSL.into()),
218        });
219
220        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
221            label: Some("llimphi-widget-terminal-cell-bgl"),
222            entries: &[
223                // 0: uniforms (32 B).
224                wgpu::BindGroupLayoutEntry {
225                    binding: 0,
226                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
227                    ty: wgpu::BindingType::Buffer {
228                        ty: wgpu::BufferBindingType::Uniform,
229                        has_dynamic_offset: false,
230                        min_binding_size: None,
231                    },
232                    count: None,
233                },
234                // 1: atlas texture (R8Unorm).
235                wgpu::BindGroupLayoutEntry {
236                    binding: 1,
237                    visibility: wgpu::ShaderStages::FRAGMENT,
238                    ty: wgpu::BindingType::Texture {
239                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
240                        view_dimension: wgpu::TextureViewDimension::D2,
241                        multisampled: false,
242                    },
243                    count: None,
244                },
245                // 2: sampler.
246                wgpu::BindGroupLayoutEntry {
247                    binding: 2,
248                    visibility: wgpu::ShaderStages::FRAGMENT,
249                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
250                    count: None,
251                },
252            ],
253        });
254
255        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
256            label: Some("llimphi-widget-terminal-cell-pl"),
257            bind_group_layouts: &[&bind_layout],
258            push_constant_ranges: &[],
259        });
260
261        let color_targets = [Some(wgpu::ColorTargetState {
262            format: color_format,
263            blend: Some(wgpu::BlendState::ALPHA_BLENDING),
264            write_mask: wgpu::ColorWrites::ALL,
265        })];
266
267        // Instance buffer: 32 B / instancia, 4 attributes (vec2 + vec4 + u32 + u32).
268        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
269            label: Some("llimphi-widget-terminal-cell-pipeline"),
270            layout: Some(&pipeline_layout),
271            vertex: wgpu::VertexState {
272                module: &shader,
273                entry_point: Some("vs_cell"),
274                compilation_options: Default::default(),
275                buffers: &[wgpu::VertexBufferLayout {
276                    array_stride: CellInstance::SIZE as u64,
277                    step_mode: wgpu::VertexStepMode::Instance,
278                    attributes: &[
279                        // cell_xy
280                        wgpu::VertexAttribute {
281                            format: wgpu::VertexFormat::Float32x2,
282                            offset: 0,
283                            shader_location: 0,
284                        },
285                        // uv_rect (vec4: uv_x, uv_y, uv_w, uv_h)
286                        wgpu::VertexAttribute {
287                            format: wgpu::VertexFormat::Float32x4,
288                            offset: 8,
289                            shader_location: 1,
290                        },
291                        // fg_rgba
292                        wgpu::VertexAttribute {
293                            format: wgpu::VertexFormat::Uint32,
294                            offset: 24,
295                            shader_location: 2,
296                        },
297                        // bg_rgba
298                        wgpu::VertexAttribute {
299                            format: wgpu::VertexFormat::Uint32,
300                            offset: 28,
301                            shader_location: 3,
302                        },
303                    ],
304                }],
305            },
306            fragment: Some(wgpu::FragmentState {
307                module: &shader,
308                entry_point: Some("fs_cell"),
309                compilation_options: Default::default(),
310                targets: &color_targets,
311            }),
312            primitive: wgpu::PrimitiveState {
313                topology: wgpu::PrimitiveTopology::TriangleStrip,
314                strip_index_format: None,
315                front_face: wgpu::FrontFace::Ccw,
316                cull_mode: None,
317                unclipped_depth: false,
318                polygon_mode: wgpu::PolygonMode::Fill,
319                conservative: false,
320            },
321            depth_stencil: None,
322            multisample: wgpu::MultisampleState::default(),
323            multiview: None,
324            cache: None,
325        });
326
327        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
328            label: Some("llimphi-widget-terminal-cell-sampler"),
329            address_mode_u: wgpu::AddressMode::ClampToEdge,
330            address_mode_v: wgpu::AddressMode::ClampToEdge,
331            address_mode_w: wgpu::AddressMode::ClampToEdge,
332            mag_filter: wgpu::FilterMode::Linear,
333            min_filter: wgpu::FilterMode::Linear,
334            mipmap_filter: wgpu::FilterMode::Nearest,
335            ..Default::default()
336        });
337
338        Self {
339            pipeline,
340            bind_layout,
341            sampler,
342        }
343    }
344
345    /// Helper: crea una textura `R8Unorm` del tamaño del atlas, sube los
346    /// bytes y devuelve `(textura, view)`. El caller la mantiene viva
347    /// entre frames y la pasa a `draw`. Sólo re-crear si las dimensiones
348    /// del atlas cambian (p. ej. tras `GlyphAtlas::grow`).
349    pub fn create_atlas_texture(
350        device: &wgpu::Device,
351        queue: &wgpu::Queue,
352        atlas_pixels: &[u8],
353        atlas_size: (u32, u32),
354    ) -> (wgpu::Texture, wgpu::TextureView) {
355        let (w, h) = atlas_size;
356        let tex = device.create_texture(&wgpu::TextureDescriptor {
357            label: Some("llimphi-widget-terminal-atlas"),
358            size: wgpu::Extent3d {
359                width: w,
360                height: h,
361                depth_or_array_layers: 1,
362            },
363            mip_level_count: 1,
364            sample_count: 1,
365            dimension: wgpu::TextureDimension::D2,
366            format: wgpu::TextureFormat::R8Unorm,
367            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
368            view_formats: &[],
369        });
370        queue.write_texture(
371            wgpu::TexelCopyTextureInfo {
372                texture: &tex,
373                mip_level: 0,
374                origin: wgpu::Origin3d::ZERO,
375                aspect: wgpu::TextureAspect::All,
376            },
377            atlas_pixels,
378            wgpu::TexelCopyBufferLayout {
379                offset: 0,
380                bytes_per_row: Some(w),
381                rows_per_image: Some(h),
382            },
383            wgpu::Extent3d {
384                width: w,
385                height: h,
386                depth_or_array_layers: 1,
387            },
388        );
389        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
390        (tex, view)
391    }
392
393    /// Dibuja las celdas en `target_view`. **No** limpia el target (load:
394    /// Load) — el caller decide la pasada previa (vello + selección). El
395    /// blending alpha mezcla los glifos sobre lo que ya hay (la
396    /// "pre-pasada vello" del SDD).
397    pub fn draw(
398        &self,
399        device: &wgpu::Device,
400        queue: &wgpu::Queue,
401        encoder: &mut wgpu::CommandEncoder,
402        target_view: &wgpu::TextureView,
403        atlas_view: &wgpu::TextureView,
404        cells: &[CellInstance],
405        uniforms: CellUniforms,
406    ) {
407        if cells.is_empty() {
408            return;
409        }
410        // Uniforms.
411        let u_bytes = uniforms.as_bytes();
412        let u_buf = device.create_buffer(&wgpu::BufferDescriptor {
413            label: Some("llimphi-widget-terminal-cell-u"),
414            size: CellUniforms::SIZE as u64,
415            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
416            mapped_at_creation: false,
417        });
418        queue.write_buffer(&u_buf, 0, &u_bytes);
419
420        // Instance buffer.
421        let inst_bytes = instances_to_bytes(cells);
422        let inst_buf = device.create_buffer(&wgpu::BufferDescriptor {
423            label: Some("llimphi-widget-terminal-cell-inst"),
424            size: inst_bytes.len() as u64,
425            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
426            mapped_at_creation: false,
427        });
428        queue.write_buffer(&inst_buf, 0, &inst_bytes);
429
430        // Bind group: uniforms + atlas + sampler.
431        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
432            label: Some("llimphi-widget-terminal-cell-bg"),
433            layout: &self.bind_layout,
434            entries: &[
435                wgpu::BindGroupEntry {
436                    binding: 0,
437                    resource: u_buf.as_entire_binding(),
438                },
439                wgpu::BindGroupEntry {
440                    binding: 1,
441                    resource: wgpu::BindingResource::TextureView(atlas_view),
442                },
443                wgpu::BindGroupEntry {
444                    binding: 2,
445                    resource: wgpu::BindingResource::Sampler(&self.sampler),
446                },
447            ],
448        });
449
450        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
451            label: Some("llimphi-widget-terminal-cell-pass"),
452            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
453                view: target_view,
454                resolve_target: None,
455                depth_slice: None,
456                ops: wgpu::Operations {
457                    load: wgpu::LoadOp::Load,
458                    store: wgpu::StoreOp::Store,
459                },
460            })],
461            depth_stencil_attachment: None,
462            timestamp_writes: None,
463            occlusion_query_set: None,
464        });
465        pass.set_pipeline(&self.pipeline);
466        pass.set_bind_group(0, &bind_group, &[]);
467        pass.set_vertex_buffer(0, inst_buf.slice(..));
468        pass.draw(0..4, 0..cells.len() as u32);
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn cell_instance_size_es_32_bytes() {
478        // El pipeline asume `array_stride = 32`; un cambio acá rompería el
479        // vertex layout silenciosamente. Tener el chequeo en test fija el
480        // contrato.
481        assert_eq!(CellInstance::SIZE, 32);
482        assert_eq!(std::mem::size_of::<CellInstance>(), 32);
483    }
484
485    #[test]
486    fn cell_uniforms_size_es_32_bytes() {
487        assert_eq!(CellUniforms::SIZE, 32);
488        assert_eq!(std::mem::size_of::<CellUniforms>(), 32);
489    }
490
491    #[test]
492    fn as_bytes_de_instance_es_round_trip_de_f32_u32() {
493        let c = CellInstance {
494            cell_x: 12.5,
495            cell_y: 24.0,
496            uv_x: 100.0,
497            uv_y: 200.0,
498            uv_w: 8.0,
499            uv_h: 16.0,
500            fg_rgba: 0xFF1122EE,
501            bg_rgba: 0xAABBCCDD,
502        };
503        let b = c.as_bytes();
504        assert_eq!(b.len(), 32);
505        // Re-leemos cada campo del array byte little-endian.
506        assert_eq!(f32::from_le_bytes(b[0..4].try_into().unwrap()), 12.5);
507        assert_eq!(f32::from_le_bytes(b[4..8].try_into().unwrap()), 24.0);
508        assert_eq!(f32::from_le_bytes(b[8..12].try_into().unwrap()), 100.0);
509        assert_eq!(f32::from_le_bytes(b[12..16].try_into().unwrap()), 200.0);
510        assert_eq!(f32::from_le_bytes(b[16..20].try_into().unwrap()), 8.0);
511        assert_eq!(f32::from_le_bytes(b[20..24].try_into().unwrap()), 16.0);
512        assert_eq!(u32::from_le_bytes(b[24..28].try_into().unwrap()), 0xFF1122EE);
513        assert_eq!(u32::from_le_bytes(b[28..32].try_into().unwrap()), 0xAABBCCDD);
514    }
515
516    #[test]
517    fn pack_rgba_es_little_endian() {
518        assert_eq!(pack_rgba(0x11, 0x22, 0x33, 0xFF), 0xFF332211);
519        assert_eq!(pack_rgba(0, 0, 0, 0), 0);
520        assert_eq!(pack_rgba(255, 255, 255, 255), 0xFFFFFFFF);
521    }
522
523    #[test]
524    fn instances_to_bytes_concatena_correctamente() {
525        let cs = vec![
526            CellInstance {
527                cell_x: 0.0, cell_y: 0.0, uv_x: 0.0, uv_y: 0.0,
528                uv_w: 0.0, uv_h: 0.0, fg_rgba: 0x12345678, bg_rgba: 0,
529            },
530            CellInstance {
531                cell_x: 1.0, cell_y: 2.0, uv_x: 3.0, uv_y: 4.0,
532                uv_w: 5.0, uv_h: 6.0, fg_rgba: 0xCAFEBABE, bg_rgba: 0xDEADBEEF,
533            },
534        ];
535        let b = instances_to_bytes(&cs);
536        assert_eq!(b.len(), 64);
537        // Segunda instancia arranca en byte 32.
538        assert_eq!(f32::from_le_bytes(b[32..36].try_into().unwrap()), 1.0);
539        assert_eq!(u32::from_le_bytes(b[56..60].try_into().unwrap()), 0xCAFEBABE);
540        assert_eq!(u32::from_le_bytes(b[60..64].try_into().unwrap()), 0xDEADBEEF);
541    }
542
543    #[test]
544    fn uniforms_as_bytes_pone_dims_en_orden() {
545        let u = CellUniforms {
546            viewport_w: 800.0,
547            viewport_h: 600.0,
548            cell_w: 8.0,
549            cell_h: 16.0,
550            atlas_w: 512.0,
551            atlas_h: 256.0,
552            _pad0: 0.0,
553            _pad1: 0.0,
554        };
555        let b = u.as_bytes();
556        assert_eq!(f32::from_le_bytes(b[0..4].try_into().unwrap()), 800.0);
557        assert_eq!(f32::from_le_bytes(b[12..16].try_into().unwrap()), 16.0); // cell_h
558        assert_eq!(f32::from_le_bytes(b[16..20].try_into().unwrap()), 512.0); // atlas_w
559        assert_eq!(f32::from_le_bytes(b[20..24].try_into().unwrap()), 256.0); // atlas_h
560    }
561
562    #[test]
563    fn wgsl_shader_no_es_vacio_y_define_entry_points() {
564        // Smoke check: la string del shader existe y declara las dos
565        // entry points que el pipeline va a referenciar. La validación
566        // sintáctica WGSL ocurre cuando `device.create_shader_module` la
567        // compile en el commit de pipeline.
568        assert!(CELL_WGSL.contains("@vertex"));
569        assert!(CELL_WGSL.contains("@fragment"));
570        assert!(CELL_WGSL.contains("vs_cell"));
571        assert!(CELL_WGSL.contains("fs_cell"));
572        assert!(CELL_WGSL.len() > 200, "shader sospechosamente corto");
573    }
574}