tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! `Hud` — un pase **screen-space** mínimo: dibuja rectángulos de color plano
2//! (con alpha) directamente en NDC, *después* del pase 3D, sobre el mismo
3//! target. Es la pieza que faltaba para un **HUD / mira (crosshair)** en primera
4//! persona: el contenido vello del árbol Llimphi queda **debajo** del canvas GPU
5//! full-screen, así que cualquier overlay que deba ir *encima* del ray-march
6//! tiene que pintarse en GPU en la misma closure `gpu_paint_with`, y eso es
7//! justo lo que hace [`Hud::render`].
8//!
9//! Deliberadamente tonto: sin texturas, sin bind groups, sin depth. Geometría
10//! en CPU → un vertex buffer dinámico → un draw. Suficiente para miras, barras,
11//! marcos y **texto** ([`HudQuad::text`], fuente bitmap 5×7 = un quad por píxel
12//! encendido, sin salir del pipeline de quads).
13
14/// Un rectángulo de HUD en **pixels** (origen arriba-izquierda, como la
15/// pantalla), color RGBA lineal `0..1`.
16#[derive(Debug, Clone, Copy)]
17pub struct HudQuad {
18 pub x: f32,
19 pub y: f32,
20 pub w: f32,
21 pub h: f32,
22 pub color: [f32; 4],
23}
24
25impl HudQuad {
26 /// Una **mira (crosshair)** centrada en un viewport `(w, h)`: dos barras
27 /// (horizontal + vertical) de brazo `arm` y grosor `th` pixels.
28 pub fn crosshair(viewport: (u32, u32), arm: f32, th: f32, color: [f32; 4]) -> [HudQuad; 2] {
29 let cx = viewport.0 as f32 * 0.5;
30 let cy = viewport.1 as f32 * 0.5;
31 [
32 HudQuad { x: cx - arm, y: cy - th * 0.5, w: arm * 2.0, h: th, color },
33 HudQuad { x: cx - th * 0.5, y: cy - arm, w: th, h: arm * 2.0, color },
34 ]
35 }
36
37 /// Emite los quads de una cadena con la **fuente bitmap 5×7** embebida
38 /// ([`glyph`]): origen arriba-izquierda en `(x, y)` pixels, cada píxel de
39 /// glifo mide `px` pixels de lado y los caracteres avanzan `6·px` (5 de ancho
40 /// + 1 de espacio). Sólo ASCII; las minúsculas se dibujan en mayúscula y los
41 /// caracteres desconocidos quedan en blanco. Se mantiene dentro del pipeline
42 /// tonto del HUD (un quad por píxel encendido, sin texturas).
43 pub fn text(s: &str, x: f32, y: f32, px: f32, color: [f32; 4]) -> Vec<HudQuad> {
44 let mut out = Vec::new();
45 let mut cx = x;
46 for ch in s.chars() {
47 if ch != ' ' {
48 let g = glyph(ch);
49 for (r, row) in g.iter().enumerate() {
50 for c in 0..5u32 {
51 if row & (1 << (4 - c)) != 0 {
52 out.push(HudQuad {
53 x: cx + c as f32 * px,
54 y: y + r as f32 * px,
55 w: px,
56 h: px,
57 color,
58 });
59 }
60 }
61 }
62 }
63 cx += 6.0 * px;
64 }
65 out
66 }
67
68 /// Ancho en pixels que ocuparía `s` con [`text`](Self::text) a tamaño `px`
69 /// (útil para dimensionar un panel de fondo antes de dibujar el texto).
70 pub fn text_width(s: &str, px: f32) -> f32 {
71 s.chars().count() as f32 * 6.0 * px
72 }
73}
74
75/// Mapa de un carácter a su bitmap **5×7**: 7 filas, cada `u8` con los 5 bits
76/// bajos = columnas de izquierda (bit 4) a derecha (bit 0). Cubre `0-9`, `A-Z`
77/// y puntuación común; lo desconocido devuelve un glifo en blanco. Las filas se
78/// escriben en binario para que la forma sea legible en el código.
79fn glyph(c: char) -> [u8; 7] {
80 match c.to_ascii_uppercase() {
81 '0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
82 '1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
83 '2' => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
84 '3' => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
85 '4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
86 '5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
87 '6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
88 '7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
89 '8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
90 '9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
91 'A' => [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
92 'B' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
93 'C' => [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
94 'D' => [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
95 'E' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
96 'F' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
97 'G' => [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
98 'H' => [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
99 'I' => [0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
100 'J' => [0b00111, 0b00010, 0b00010, 0b00010, 0b10010, 0b10010, 0b01100],
101 'K' => [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
102 'L' => [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
103 'M' => [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
104 'N' => [0b10001, 0b11001, 0b10101, 0b10101, 0b10011, 0b10001, 0b10001],
105 'O' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
106 'P' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
107 'Q' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101],
108 'R' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
109 'S' => [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
110 'T' => [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
111 'U' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
112 'V' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
113 'W' => [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
114 'X' => [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
115 'Y' => [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
116 'Z' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
117 ':' => [0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000],
118 '.' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100],
119 ',' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100, 0b01000],
120 '-' => [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
121 '+' => [0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000],
122 '/' => [0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000],
123 '(' => [0b00110, 0b01000, 0b01000, 0b01000, 0b01000, 0b01000, 0b00110],
124 ')' => [0b01100, 0b00010, 0b00010, 0b00010, 0b00010, 0b00010, 0b01100],
125 '%' => [0b11001, 0b11001, 0b00010, 0b00100, 0b01000, 0b10011, 0b10011],
126 _ => [0; 7],
127 }
128}
129
130/// Tamaño de un vértice del HUD: `pos: vec2<f32>` + `color: vec4<f32>`.
131const VSIZE: usize = 2 * 4 + 4 * 4;
132
133/// Renderer de overlay screen-space. Cachea pipeline + un vertex buffer
134/// dinámico que crece según haga falta.
135pub struct Hud {
136 pipeline: wgpu::RenderPipeline,
137 vbuf: wgpu::Buffer,
138 cap: u64,
139}
140
141impl Hud {
142 /// Crea el HUD para el `color_format` del target (el de la intermedia del
143 /// frame). No toca depth: dibuja siempre encima.
144 pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
145 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
146 label: Some("llimphi-3d-hud-shader"),
147 source: wgpu::ShaderSource::Wgsl(WGSL.into()),
148 });
149
150 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
151 label: Some("llimphi-3d-hud-pl"),
152 bind_group_layouts: &[],
153 immediate_size: 0,
154 });
155
156 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
157 label: Some("llimphi-3d-hud-pipeline"),
158 layout: Some(&pipeline_layout),
159 vertex: wgpu::VertexState {
160 module: &shader,
161 entry_point: Some("vs"),
162 compilation_options: Default::default(),
163 buffers: &[wgpu::VertexBufferLayout {
164 array_stride: VSIZE as u64,
165 step_mode: wgpu::VertexStepMode::Vertex,
166 attributes: &[
167 wgpu::VertexAttribute {
168 format: wgpu::VertexFormat::Float32x2,
169 offset: 0,
170 shader_location: 0,
171 },
172 wgpu::VertexAttribute {
173 format: wgpu::VertexFormat::Float32x4,
174 offset: 8,
175 shader_location: 1,
176 },
177 ],
178 }],
179 },
180 primitive: wgpu::PrimitiveState {
181 topology: wgpu::PrimitiveTopology::TriangleList,
182 ..Default::default()
183 },
184 // Sin depth: el HUD va siempre encima del 3D.
185 depth_stencil: None,
186 multisample: wgpu::MultisampleState::default(),
187 fragment: Some(wgpu::FragmentState {
188 module: &shader,
189 entry_point: Some("fs"),
190 compilation_options: Default::default(),
191 targets: &[Some(wgpu::ColorTargetState {
192 format: color_format,
193 blend: Some(wgpu::BlendState::ALPHA_BLENDING),
194 write_mask: wgpu::ColorWrites::ALL,
195 })],
196 }),
197 multiview_mask: None,
198 cache: None,
199 });
200
201 let cap = (64 * 6 * VSIZE) as u64; // ~64 quads sin recrear
202 let vbuf = device.create_buffer(&wgpu::BufferDescriptor {
203 label: Some("llimphi-3d-hud-vbuf"),
204 size: cap,
205 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
206 mapped_at_creation: false,
207 });
208
209 Self { pipeline, vbuf, cap }
210 }
211
212 /// Dibuja `quads` sobre `target` (color `LoadOp::Load`, sin depth). Firma
213 /// compatible con la closure `gpu_paint_with`: llamar *después* del pase 3D.
214 pub fn render(
215 &mut self,
216 device: &wgpu::Device,
217 queue: &wgpu::Queue,
218 encoder: &mut wgpu::CommandEncoder,
219 target: &wgpu::TextureView,
220 (w, h): (u32, u32),
221 quads: &[HudQuad],
222 ) {
223 if w == 0 || h == 0 || quads.is_empty() {
224 return;
225 }
226
227 // Geometría en CPU: 2 triángulos (6 vértices) por quad, en NDC. El eje Y
228 // de pantalla va hacia abajo; NDC hacia arriba → `1 - 2·y/h`.
229 let (fw, fh) = (w as f32, h as f32);
230 let mut bytes = Vec::with_capacity(quads.len() * 6 * VSIZE);
231 let mut vert = |x_px: f32, y_px: f32, c: [f32; 4]| {
232 let ndc_x = x_px / fw * 2.0 - 1.0;
233 let ndc_y = 1.0 - y_px / fh * 2.0;
234 bytes.extend_from_slice(&ndc_x.to_ne_bytes());
235 bytes.extend_from_slice(&ndc_y.to_ne_bytes());
236 for ch in c {
237 bytes.extend_from_slice(&ch.to_ne_bytes());
238 }
239 };
240 for q in quads {
241 let (x0, y0, x1, y1) = (q.x, q.y, q.x + q.w, q.y + q.h);
242 vert(x0, y0, q.color);
243 vert(x1, y0, q.color);
244 vert(x1, y1, q.color);
245 vert(x0, y0, q.color);
246 vert(x1, y1, q.color);
247 vert(x0, y1, q.color);
248 }
249
250 // Crecer el buffer si hiciera falta (raro: la mira son 2 quads).
251 if bytes.len() as u64 > self.cap {
252 self.cap = (bytes.len() as u64).next_power_of_two();
253 self.vbuf = device.create_buffer(&wgpu::BufferDescriptor {
254 label: Some("llimphi-3d-hud-vbuf"),
255 size: self.cap,
256 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
257 mapped_at_creation: false,
258 });
259 }
260 queue.write_buffer(&self.vbuf, 0, &bytes);
261
262 let count = (quads.len() * 6) as u32;
263 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
264 label: Some("llimphi-3d-hud-pass"),
265 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
266 view: target,
267 resolve_target: None,
268 depth_slice: None,
269 ops: wgpu::Operations {
270 load: wgpu::LoadOp::Load,
271 store: wgpu::StoreOp::Store,
272 },
273 })],
274 depth_stencil_attachment: None,
275 timestamp_writes: None,
276 occlusion_query_set: None,
277 multiview_mask: None,
278 });
279 pass.set_pipeline(&self.pipeline);
280 pass.set_vertex_buffer(0, self.vbuf.slice(..bytes.len() as u64));
281 pass.draw(0..count, 0..1);
282 }
283}
284
285const WGSL: &str = r#"
286struct VIn {
287 @location(0) pos: vec2<f32>,
288 @location(1) color: vec4<f32>,
289};
290struct VOut {
291 @builtin(position) clip: vec4<f32>,
292 @location(0) color: vec4<f32>,
293};
294
295@vertex
296fn vs(in: VIn) -> VOut {
297 var out: VOut;
298 out.clip = vec4<f32>(in.pos, 0.0, 1.0);
299 out.color = in.color;
300 return out;
301}
302
303@fragment
304fn fs(in: VOut) -> @location(0) vec4<f32> {
305 return in.color;
306}
307"#;