Files
llimphi/llimphi-3d/src/hud.rs
T
Sergio ccab39f140 refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:40:00 +00:00

307 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `Hud` — un pase **screen-space** mínimo: dibuja rectángulos de color plano
//! (con alpha) directamente en NDC, *después* del pase 3D, sobre el mismo
//! target. Es la pieza que faltaba para un **HUD / mira (crosshair)** en primera
//! persona: el contenido vello del árbol Llimphi queda **debajo** del canvas GPU
//! full-screen, así que cualquier overlay que deba ir *encima* del ray-march
//! tiene que pintarse en GPU en la misma closure `gpu_paint_with`, y eso es
//! justo lo que hace [`Hud::render`].
//!
//! Deliberadamente tonto: sin texturas, sin bind groups, sin depth. Geometría
//! en CPU → un vertex buffer dinámico → un draw. Suficiente para miras, barras,
//! marcos y **texto** ([`HudQuad::text`], fuente bitmap 5×7 = un quad por píxel
//! encendido, sin salir del pipeline de quads).
/// Un rectángulo de HUD en **pixels** (origen arriba-izquierda, como la
/// pantalla), color RGBA lineal `0..1`.
#[derive(Debug, Clone, Copy)]
pub struct HudQuad {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub color: [f32; 4],
}
impl HudQuad {
/// Una **mira (crosshair)** centrada en un viewport `(w, h)`: dos barras
/// (horizontal + vertical) de brazo `arm` y grosor `th` pixels.
pub fn crosshair(viewport: (u32, u32), arm: f32, th: f32, color: [f32; 4]) -> [HudQuad; 2] {
let cx = viewport.0 as f32 * 0.5;
let cy = viewport.1 as f32 * 0.5;
[
HudQuad { x: cx - arm, y: cy - th * 0.5, w: arm * 2.0, h: th, color },
HudQuad { x: cx - th * 0.5, y: cy - arm, w: th, h: arm * 2.0, color },
]
}
/// Emite los quads de una cadena con la **fuente bitmap 5×7** embebida
/// ([`glyph`]): origen arriba-izquierda en `(x, y)` pixels, cada píxel de
/// glifo mide `px` pixels de lado y los caracteres avanzan `6·px` (5 de ancho
/// + 1 de espacio). Sólo ASCII; las minúsculas se dibujan en mayúscula y los
/// caracteres desconocidos quedan en blanco. Se mantiene dentro del pipeline
/// tonto del HUD (un quad por píxel encendido, sin texturas).
pub fn text(s: &str, x: f32, y: f32, px: f32, color: [f32; 4]) -> Vec<HudQuad> {
let mut out = Vec::new();
let mut cx = x;
for ch in s.chars() {
if ch != ' ' {
let g = glyph(ch);
for (r, row) in g.iter().enumerate() {
for c in 0..5u32 {
if row & (1 << (4 - c)) != 0 {
out.push(HudQuad {
x: cx + c as f32 * px,
y: y + r as f32 * px,
w: px,
h: px,
color,
});
}
}
}
}
cx += 6.0 * px;
}
out
}
/// Ancho en pixels que ocuparía `s` con [`text`](Self::text) a tamaño `px`
/// (útil para dimensionar un panel de fondo antes de dibujar el texto).
pub fn text_width(s: &str, px: f32) -> f32 {
s.chars().count() as f32 * 6.0 * px
}
}
/// Mapa de un carácter a su bitmap **5×7**: 7 filas, cada `u8` con los 5 bits
/// bajos = columnas de izquierda (bit 4) a derecha (bit 0). Cubre `0-9`, `A-Z`
/// y puntuación común; lo desconocido devuelve un glifo en blanco. Las filas se
/// escriben en binario para que la forma sea legible en el código.
fn glyph(c: char) -> [u8; 7] {
match c.to_ascii_uppercase() {
'0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
'1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
'2' => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
'3' => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
'4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
'5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
'6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
'7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
'8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
'9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
'A' => [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
'B' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
'C' => [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
'D' => [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
'E' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
'F' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
'G' => [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
'H' => [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
'I' => [0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
'J' => [0b00111, 0b00010, 0b00010, 0b00010, 0b10010, 0b10010, 0b01100],
'K' => [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
'L' => [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
'M' => [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
'N' => [0b10001, 0b11001, 0b10101, 0b10101, 0b10011, 0b10001, 0b10001],
'O' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
'P' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
'Q' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101],
'R' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
'S' => [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
'T' => [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
'U' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
'V' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
'W' => [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
'X' => [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
'Y' => [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
'Z' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
':' => [0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000],
'.' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100],
',' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100, 0b01000],
'-' => [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
'+' => [0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000],
'/' => [0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000],
'(' => [0b00110, 0b01000, 0b01000, 0b01000, 0b01000, 0b01000, 0b00110],
')' => [0b01100, 0b00010, 0b00010, 0b00010, 0b00010, 0b00010, 0b01100],
'%' => [0b11001, 0b11001, 0b00010, 0b00100, 0b01000, 0b10011, 0b10011],
_ => [0; 7],
}
}
/// Tamaño de un vértice del HUD: `pos: vec2<f32>` + `color: vec4<f32>`.
const VSIZE: usize = 2 * 4 + 4 * 4;
/// Renderer de overlay screen-space. Cachea pipeline + un vertex buffer
/// dinámico que crece según haga falta.
pub struct Hud {
pipeline: wgpu::RenderPipeline,
vbuf: wgpu::Buffer,
cap: u64,
}
impl Hud {
/// Crea el HUD para el `color_format` del target (el de la intermedia del
/// frame). No toca depth: dibuja siempre encima.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-3d-hud-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-hud-pl"),
bind_group_layouts: &[],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-hud-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: VSIZE as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 8,
shader_location: 1,
},
],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
// Sin depth: el HUD va siempre encima del 3D.
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
let cap = (64 * 6 * VSIZE) as u64; // ~64 quads sin recrear
let vbuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-hud-vbuf"),
size: cap,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self { pipeline, vbuf, cap }
}
/// Dibuja `quads` sobre `target` (color `LoadOp::Load`, sin depth). Firma
/// compatible con la closure `gpu_paint_with`: llamar *después* del pase 3D.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
quads: &[HudQuad],
) {
if w == 0 || h == 0 || quads.is_empty() {
return;
}
// Geometría en CPU: 2 triángulos (6 vértices) por quad, en NDC. El eje Y
// de pantalla va hacia abajo; NDC hacia arriba → `1 - 2·y/h`.
let (fw, fh) = (w as f32, h as f32);
let mut bytes = Vec::with_capacity(quads.len() * 6 * VSIZE);
let mut vert = |x_px: f32, y_px: f32, c: [f32; 4]| {
let ndc_x = x_px / fw * 2.0 - 1.0;
let ndc_y = 1.0 - y_px / fh * 2.0;
bytes.extend_from_slice(&ndc_x.to_ne_bytes());
bytes.extend_from_slice(&ndc_y.to_ne_bytes());
for ch in c {
bytes.extend_from_slice(&ch.to_ne_bytes());
}
};
for q in quads {
let (x0, y0, x1, y1) = (q.x, q.y, q.x + q.w, q.y + q.h);
vert(x0, y0, q.color);
vert(x1, y0, q.color);
vert(x1, y1, q.color);
vert(x0, y0, q.color);
vert(x1, y1, q.color);
vert(x0, y1, q.color);
}
// Crecer el buffer si hiciera falta (raro: la mira son 2 quads).
if bytes.len() as u64 > self.cap {
self.cap = (bytes.len() as u64).next_power_of_two();
self.vbuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-hud-vbuf"),
size: self.cap,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
}
queue.write_buffer(&self.vbuf, 0, &bytes);
let count = (quads.len() * 6) as u32;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-3d-hud-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_vertex_buffer(0, self.vbuf.slice(..bytes.len() as u64));
pass.draw(0..count, 0..1);
}
}
const WGSL: &str = r#"
struct VIn {
@location(0) pos: vec2<f32>,
@location(1) color: vec4<f32>,
};
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec4<f32>,
};
@vertex
fn vs(in: VIn) -> VOut {
var out: VOut;
out.clip = vec4<f32>(in.pos, 0.0, 1.0);
out.color = in.color;
return out;
}
@fragment
fn fs(in: VOut) -> @location(0) vec4<f32> {
return in.color;
}
"#;