tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc
widgets/terminal/src/cell_pipeline.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//! 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}