proof_engine/render/ui_layer.rs
1//! Screen-space UI layer — bypasses the 3D camera and renders in pixel coordinates.
2//!
3//! The UI layer renders AFTER the 3D scene and post-processing but BEFORE the
4//! final composite. UI elements are pixel-perfect, unaffected by bloom or
5//! distortion, and positioned in screen coordinates: (0,0) = top-left.
6//!
7//! # Architecture
8//!
9//! ```text
10//! 3D scene → PostFx (bloom, CA, grain) → UI Layer (ortho, no FX) → screen
11//! ```
12//!
13//! The UI layer collects draw commands each frame via `UiLayer::draw_*` methods,
14//! then flushes them all in one pass via `UiLayerRenderer`.
15
16use glam::{Vec2, Vec3, Vec4, Mat4};
17use std::collections::VecDeque;
18
19// ── Draw Commands ───────────────────────────────────────────────────────────
20
21/// A single UI draw command, queued and executed in order.
22#[derive(Clone, Debug)]
23pub enum UiDrawCommand {
24 /// A cloud somebody else owns, drawn at an offset.
25 ///
26 /// The ordinary `Particles` command takes a `Vec`, which means a caller
27 /// with a *cached* cloud has to clone it every frame to hand it over. A
28 /// static background of a hundred and forty thousand particles is seven
29 /// megabytes of allocation and copy per frame producing an identical
30 /// result — which is not a rendering cost, it is a memcpy the renderer
31 /// never asked for.
32 ///
33 /// This takes a shared handle instead, so the caller keeps its cloud and
34 /// passing it costs a reference count. The offset is applied while the
35 /// instances are built, which is a pass the renderer was making anyway.
36 SharedParticles {
37 particles: std::sync::Arc<Vec<UiParticle>>,
38 dx: f32,
39 dy: f32,
40 },
41 Text {
42 text: String,
43 x: f32,
44 y: f32,
45 scale: f32,
46 color: Vec4,
47 emission: f32,
48 alignment: TextAlign,
49 },
50 Rect {
51 x: f32,
52 y: f32,
53 w: f32,
54 h: f32,
55 color: Vec4,
56 filled: bool,
57 },
58 Panel {
59 x: f32,
60 y: f32,
61 w: f32,
62 h: f32,
63 border: BorderStyle,
64 fill_color: Vec4,
65 border_color: Vec4,
66 },
67 Bar {
68 x: f32,
69 y: f32,
70 w: f32,
71 h: f32,
72 fill_pct: f32,
73 fill_color: Vec4,
74 bg_color: Vec4,
75 ghost_pct: Option<f32>,
76 ghost_color: Vec4,
77 },
78 Sprite {
79 lines: Vec<String>,
80 x: f32,
81 y: f32,
82 color: Vec4,
83 },
84 /// A cloud of independently placed glyphs.
85 ///
86 /// Text is the wrong shape for this: a figure built out of particles has no
87 /// baseline, no advance width and no string, and routing it through
88 /// `Text` costs one `String` allocation per particle per frame. This is one
89 /// command for the whole cloud, and it exposes the per-instance rotation
90 /// and glow the glyph pipeline already supports.
91 Particles(Vec<UiParticle>),
92}
93
94/// One glyph in a particle cloud, placed by its centre.
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub struct UiParticle {
97 /// Centre of the glyph, in screen pixels.
98 pub x: f32,
99 pub y: f32,
100 /// Width and height of the glyph, in screen pixels.
101 pub w: f32,
102 pub h: f32,
103 pub ch: char,
104 /// Radians. Tumbling debris is the main use.
105 pub rotation: f32,
106 pub color: Vec4,
107 pub emission: f32,
108 /// Bloom radius for this particle alone.
109 pub glow: f32,
110}
111
112impl UiParticle {
113 /// A particle with no rotation and no glow.
114 pub fn new(x: f32, y: f32, w: f32, h: f32, ch: char, color: Vec4) -> UiParticle {
115 UiParticle {
116 x,
117 y,
118 w,
119 h,
120 ch,
121 rotation: 0.0,
122 color,
123 emission: 0.0,
124 glow: 0.0,
125 }
126 }
127}
128
129/// Text alignment.
130#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
131pub enum TextAlign {
132 #[default]
133 Left,
134 Center,
135 Right,
136}
137
138/// Border drawing styles for panels.
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum BorderStyle {
141 /// Single line: ┌─┐│└─┘
142 Single,
143 /// Double line: ╔═╗║╚═╝
144 Double,
145 /// Rounded corners: ╭─╮│╰─╯
146 Rounded,
147 /// Heavy line: ┏━┓┃┗━┛
148 Heavy,
149 /// Dashed: ┌╌┐╎└╌┘
150 Dashed,
151}
152
153impl BorderStyle {
154 /// Get the 8 border characters: [top-left, top, top-right, left, right, bottom-left, bottom, bottom-right]
155 pub fn chars(&self) -> [char; 8] {
156 match self {
157 BorderStyle::Single => ['┌', '─', '┐', '│', '│', '└', '─', '┘'],
158 BorderStyle::Double => ['╔', '═', '╗', '║', '║', '╚', '═', '╝'],
159 BorderStyle::Rounded => ['╭', '─', '╮', '│', '│', '╰', '─', '╯'],
160 BorderStyle::Heavy => ['┏', '━', '┓', '┃', '┃', '┗', '━', '┛'],
161 BorderStyle::Dashed => ['┌', '╌', '┐', '╎', '╎', '└', '╌', '┘'],
162 }
163 }
164}
165
166// ── UiPass ──────────────────────────────────────────────────────────────────
167
168/// Which of the two screen-space passes a command is painted in.
169///
170/// Everything in this layer used to be painted after post-processing, straight
171/// onto the finished frame. That is right for a HUD, which has to stay sharp,
172/// and wrong for everything else: a game that draws its figures, rooms and
173/// effects as clouds of screen-space particles was getting no bloom, no
174/// tonemap, no halation and no grade on any of them. The whole picture went
175/// to the screen raw, and the only things the post-processing ever touched
176/// were a few background glyphs in the 3D scene.
177///
178/// So there are two passes now. `World` is painted into the HDR scene buffer
179/// before post-processing, in the same space as the 3D scene, and everything
180/// downstream (bloom, light shafts, flare, tonemap, grade, grain) applies to
181/// it. `Hud` is painted after, straight to the screen, and stays crisp.
182///
183/// By default particle clouds and filled rectangles go to `World`, since a
184/// filled rectangle is what a game lays down as the ground under its matter
185/// and it has to stay under it; text, outlines, bars and sprites go to
186/// `Hud`. A panel is split: its fill goes to `World` and its border to
187/// `Hud`. [`UiLayer::begin_world`], [`UiLayer::begin_hud`] and
188/// [`UiLayer::end_pass`] override all of that for a run of commands.
189#[derive(Clone, Copy, Debug, PartialEq, Eq)]
190pub enum UiPass {
191 /// Into the scene buffer, before post-processing. Blooms, grades, shakes.
192 World,
193 /// Onto the finished frame, after post-processing. Sharp and stable.
194 Hud,
195}
196
197impl UiDrawCommand {
198 /// The pass a command lands in when nothing overrides it.
199 pub fn default_pass(&self) -> UiPass {
200 match self {
201 UiDrawCommand::Particles(_) | UiDrawCommand::SharedParticles { .. } => UiPass::World,
202 UiDrawCommand::Rect { filled: true, .. } => UiPass::World,
203 // A panel's fill is routed to the world by the renderer; the
204 // command's own pass is where its border goes.
205 _ => UiPass::Hud,
206 }
207 }
208}
209
210// ── UiLayer ─────────────────────────────────────────────────────────────────
211
212/// The screen-space UI layer. Collects draw commands each frame, then renders
213/// them all in a single pass with an orthographic projection.
214pub struct UiLayer {
215 /// Screen dimensions (updated on resize).
216 pub screen_width: f32,
217 pub screen_height: f32,
218 /// Character cell dimensions in screen pixels.
219 pub char_width: f32,
220 pub char_height: f32,
221 /// Queued draw commands for this frame.
222 draw_queue: Vec<UiDrawCommand>,
223 /// The pass each queued command paints in, parallel to `draw_queue`.
224 passes: Vec<UiPass>,
225 /// Whether that pass was forced by the caller rather than defaulted,
226 /// parallel to `draw_queue`. A forced pass is honoured whole; a defaulted
227 /// one lets the renderer split a panel between the two.
228 forced: Vec<bool>,
229 /// An override for every command pushed while it is set.
230 forced_pass: Option<UiPass>,
231 /// Whether the UI layer is enabled.
232 pub enabled: bool,
233}
234
235impl UiLayer {
236 pub fn new(screen_width: f32, screen_height: f32) -> Self {
237 Self {
238 screen_width,
239 screen_height,
240 char_width: 10.0,
241 char_height: 18.0,
242 draw_queue: Vec::with_capacity(256),
243 passes: Vec::with_capacity(256),
244 forced: Vec::with_capacity(256),
245 forced_pass: None,
246 enabled: true,
247 }
248 }
249
250 /// Queue a command in the forced pass if one is set, else its default.
251 fn push(&mut self, cmd: UiDrawCommand) {
252 let pass = self.forced_pass.unwrap_or_else(|| cmd.default_pass());
253 self.draw_queue.push(cmd);
254 self.passes.push(pass);
255 self.forced.push(self.forced_pass.is_some());
256 }
257
258 /// Route everything pushed from here to [`UiPass::World`], until
259 /// [`end_pass`](Self::end_pass). Text drawn this way blooms and grades
260 /// with the scene, which is what a title or a floating damage number
261 /// wants.
262 pub fn begin_world(&mut self) {
263 self.forced_pass = Some(UiPass::World);
264 }
265
266 /// Route everything pushed from here to [`UiPass::Hud`], until
267 /// [`end_pass`](Self::end_pass). A particle cloud drawn this way stays
268 /// sharp and unshaken, which is what a health bar built of matter wants.
269 pub fn begin_hud(&mut self) {
270 self.forced_pass = Some(UiPass::Hud);
271 }
272
273 /// Back to routing each command by its default pass.
274 pub fn end_pass(&mut self) {
275 self.forced_pass = None;
276 }
277
278 /// The pass currently forced, if any.
279 pub fn forced_pass(&self) -> Option<UiPass> {
280 self.forced_pass
281 }
282
283 /// The pass of the `i`th queued command.
284 pub fn pass_of(&self, i: usize) -> UiPass {
285 self.passes.get(i).copied().unwrap_or(UiPass::Hud)
286 }
287
288 /// Whether the `i`th command's pass was forced by the caller.
289 pub fn pass_forced(&self, i: usize) -> bool {
290 self.forced.get(i).copied().unwrap_or(false)
291 }
292
293 /// The pass of every queued command, parallel to [`draw_queue`](Self::draw_queue).
294 pub fn passes(&self) -> &[UiPass] {
295 &self.passes
296 }
297
298 /// How many queued commands paint in `pass`.
299 pub fn count_in(&self, pass: UiPass) -> usize {
300 self.passes.iter().filter(|p| **p == pass).count()
301 }
302
303 /// The projection for the world pass.
304 ///
305 /// The same as the HUD's. The world pass is drawn into the scene
306 /// framebuffer and the composite copies that to the screen without a
307 /// flip, so the glyph shader's own flip is the only one on either path.
308 /// Verified by capturing a frame with the mirror of this: the whole
309 /// arena came out upside down.
310 pub fn world_projection(&self) -> Mat4 {
311 self.projection()
312 }
313
314 /// Update screen dimensions (call on resize).
315 pub fn resize(&mut self, width: f32, height: f32) {
316 self.screen_width = width;
317 self.screen_height = height;
318 }
319
320 /// Set the character cell size in screen pixels.
321 pub fn set_char_size(&mut self, width: f32, height: f32) {
322 self.char_width = width;
323 self.char_height = height;
324 }
325
326 /// Clear all queued commands. Call at the start of each frame.
327 pub fn begin_frame(&mut self) {
328 self.draw_queue.clear();
329 self.passes.clear();
330 self.forced.clear();
331 self.forced_pass = None;
332 }
333
334 /// Get the orthographic projection matrix for this UI layer.
335 /// Maps (0,0) at top-left to (screen_width, screen_height) at bottom-right.
336 pub fn projection(&self) -> Mat4 {
337 // Screen-space UI is authored with y=0 at the top, but this pass draws
338 // straight to the default framebuffer *after* post-processing has
339 // composited, and that content arrives already flipped relative to the
340 // FBO passes. Projecting y=0 to the bottom therefore lands it at the
341 // top on screen.
342 //
343 // Verified against the window decorations: get this backwards and the
344 // entire interface renders upside down while the title bar stays
345 // upright.
346 Mat4::orthographic_rh_gl(
347 0.0,
348 self.screen_width,
349 0.0,
350 self.screen_height,
351 -1.0,
352 1.0,
353 )
354 }
355
356 /// Get the draw queue for rendering.
357 pub fn draw_queue(&self) -> &[UiDrawCommand] {
358 &self.draw_queue
359 }
360
361 /// Number of pending draw commands.
362 pub fn command_count(&self) -> usize {
363 self.draw_queue.len()
364 }
365
366 // ── Drawing API ─────────────────────────────────────────────────────────
367
368 /// Draw text at screen coordinates.
369 pub fn draw_text(&mut self, x: f32, y: f32, text: &str, scale: f32, color: Vec4) {
370 self.push(UiDrawCommand::Text {
371 text: text.to_string(),
372 x, y, scale,
373 color,
374 emission: 0.0,
375 alignment: TextAlign::Left,
376 });
377 }
378
379 /// Draw a cloud of glyphs as one command.
380 ///
381 /// Empty clouds are dropped rather than queued, so a figure that is fully
382 /// clipped or faded costs nothing downstream.
383 /// Draw a cloud the caller keeps, shifted by `dx`, `dy`.
384 ///
385 /// For anything cached across frames. See
386 /// [`UiDrawCommand::SharedParticles`].
387 pub fn draw_particles_shared(
388 &mut self,
389 particles: std::sync::Arc<Vec<UiParticle>>,
390 dx: f32,
391 dy: f32,
392 ) {
393 if particles.is_empty() {
394 return;
395 }
396 self.draw_queue
397 .push(UiDrawCommand::SharedParticles { particles, dx, dy });
398 }
399
400 pub fn draw_particles(&mut self, particles: Vec<UiParticle>) {
401 if particles.is_empty() {
402 return;
403 }
404 self.push(UiDrawCommand::Particles(particles));
405 }
406
407 /// Draw text with emission (for bloom-capable UI text).
408 pub fn draw_text_glowing(&mut self, x: f32, y: f32, text: &str, scale: f32, color: Vec4, emission: f32) {
409 self.push(UiDrawCommand::Text {
410 text: text.to_string(),
411 x, y, scale,
412 color,
413 emission,
414 alignment: TextAlign::Left,
415 });
416 }
417
418 /// Draw text with alignment.
419 pub fn draw_text_aligned(&mut self, x: f32, y: f32, text: &str, scale: f32, color: Vec4, align: TextAlign) {
420 self.push(UiDrawCommand::Text {
421 text: text.to_string(),
422 x, y, scale,
423 color,
424 emission: 0.0,
425 alignment: align,
426 });
427 }
428
429 /// Draw centered text (centers horizontally at the given y).
430 pub fn draw_centered_text(&mut self, y: f32, text: &str, scale: f32, color: Vec4) {
431 self.draw_text_aligned(self.screen_width / 2.0, y, text, scale, color, TextAlign::Center);
432 }
433
434 /// Draw word-wrapped text within a max width (in pixels).
435 pub fn draw_wrapped_text(&mut self, x: f32, y: f32, max_width: f32, text: &str, scale: f32, color: Vec4) {
436 let char_w = self.char_width * scale;
437 let max_chars = (max_width / char_w.max(1.0)) as usize;
438 let lines = wrap_text_ui(text, max_chars);
439 let line_h = self.char_height * scale;
440 for (i, line) in lines.iter().enumerate() {
441 self.draw_text(x, y + i as f32 * line_h, line, scale, color);
442 }
443 }
444
445 /// Measure text dimensions in screen pixels.
446 pub fn measure_text(&self, text: &str, scale: f32) -> (f32, f32) {
447 let lines: Vec<&str> = text.lines().collect();
448 let max_cols = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
449 let width = max_cols as f32 * self.char_width * scale;
450 let height = lines.len() as f32 * self.char_height * scale;
451 (width, height)
452 }
453
454 /// Draw a filled or outlined rectangle.
455 pub fn draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Vec4, filled: bool) {
456 self.push(UiDrawCommand::Rect {
457 x, y, w, h, color, filled,
458 });
459 }
460
461 /// Draw a panel with a border and optional fill.
462 pub fn draw_panel(
463 &mut self,
464 x: f32,
465 y: f32,
466 w: f32,
467 h: f32,
468 border: BorderStyle,
469 fill_color: Vec4,
470 border_color: Vec4,
471 ) {
472 self.push(UiDrawCommand::Panel {
473 x, y, w, h, border, fill_color, border_color,
474 });
475 }
476
477 /// Draw a progress bar using █ and ░ characters.
478 pub fn draw_bar(
479 &mut self,
480 x: f32,
481 y: f32,
482 w: f32,
483 h: f32,
484 fill_pct: f32,
485 fill_color: Vec4,
486 bg_color: Vec4,
487 ) {
488 self.push(UiDrawCommand::Bar {
489 x, y, w, h,
490 fill_pct: fill_pct.clamp(0.0, 1.0),
491 fill_color,
492 bg_color,
493 ghost_pct: None,
494 ghost_color: Vec4::ZERO,
495 });
496 }
497
498 /// Draw a progress bar with a ghost bar (recent damage indicator).
499 pub fn draw_bar_with_ghost(
500 &mut self,
501 x: f32,
502 y: f32,
503 w: f32,
504 h: f32,
505 fill_pct: f32,
506 fill_color: Vec4,
507 bg_color: Vec4,
508 ghost_pct: f32,
509 ghost_color: Vec4,
510 ) {
511 self.push(UiDrawCommand::Bar {
512 x, y, w, h,
513 fill_pct: fill_pct.clamp(0.0, 1.0),
514 fill_color,
515 bg_color,
516 ghost_pct: Some(ghost_pct.clamp(0.0, 1.0)),
517 ghost_color,
518 });
519 }
520
521 /// Draw multi-line ASCII art sprite.
522 pub fn draw_sprite(&mut self, x: f32, y: f32, lines: &[&str], color: Vec4) {
523 self.push(UiDrawCommand::Sprite {
524 lines: lines.iter().map(|s| s.to_string()).collect(),
525 x, y, color,
526 });
527 }
528}
529
530// ── Word wrapping for UI ────────────────────────────────────────────────────
531
532fn wrap_text_ui(text: &str, max_chars: usize) -> Vec<String> {
533 if max_chars == 0 {
534 return vec![text.to_string()];
535 }
536 let mut lines = Vec::new();
537 for paragraph in text.split('\n') {
538 if paragraph.is_empty() {
539 lines.push(String::new());
540 continue;
541 }
542 let words: Vec<&str> = paragraph.split_whitespace().collect();
543 let mut line = String::new();
544 for word in words {
545 if line.is_empty() {
546 if word.len() > max_chars {
547 let mut w = word;
548 while w.len() > max_chars {
549 lines.push(w[..max_chars].to_string());
550 w = &w[max_chars..];
551 }
552 line = w.to_string();
553 } else {
554 line = word.to_string();
555 }
556 } else if line.len() + 1 + word.len() <= max_chars {
557 line.push(' ');
558 line.push_str(word);
559 } else {
560 lines.push(std::mem::take(&mut line));
561 line = word.to_string();
562 }
563 }
564 if !line.is_empty() {
565 lines.push(line);
566 }
567 }
568 if lines.is_empty() {
569 lines.push(String::new());
570 }
571 lines
572}
573
574// ── Tests ───────────────────────────────────────────────────────────────────
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn ui_layer_projection_is_orthographic() {
582 let ui = UiLayer::new(1280.0, 800.0);
583 let proj = ui.projection();
584 // Top-left (0,0) should map to (-1, 1) in clip space.
585 let tl = proj * Vec4::new(0.0, 0.0, 0.0, 1.0);
586 assert!((tl.x / tl.w - (-1.0)).abs() < 0.01);
587 assert!((tl.y / tl.w - 1.0).abs() < 0.01);
588 }
589
590 #[test]
591 fn ui_layer_draw_and_clear() {
592 let mut ui = UiLayer::new(1280.0, 800.0);
593 ui.draw_text(0.0, 0.0, "Hello", 1.0, Vec4::ONE);
594 assert_eq!(ui.command_count(), 1);
595 ui.begin_frame();
596 assert_eq!(ui.command_count(), 0);
597 }
598
599 #[test]
600 fn measure_text_single_line() {
601 let ui = UiLayer::new(1280.0, 800.0);
602 let (w, h) = ui.measure_text("Hello", 1.0);
603 assert_eq!(w, 5.0 * ui.char_width);
604 assert_eq!(h, ui.char_height);
605 }
606
607 #[test]
608 fn measure_text_multi_line() {
609 let ui = UiLayer::new(1280.0, 800.0);
610 let (_, h) = ui.measure_text("Line1\nLine2\nLine3", 1.0);
611 assert_eq!(h, 3.0 * ui.char_height);
612 }
613
614 #[test]
615 fn border_style_chars() {
616 let chars = BorderStyle::Single.chars();
617 assert_eq!(chars[0], '┌');
618 assert_eq!(chars[7], '┘');
619 }
620
621 #[test]
622 fn wrap_text_ui_basic() {
623 let lines = wrap_text_ui("Hello world foo bar", 10);
624 for l in &lines {
625 assert!(l.len() <= 10, "Line too long: '{}'", l);
626 }
627 }
628
629 #[test]
630 fn bar_pct_clamped() {
631 let mut ui = UiLayer::new(1280.0, 800.0);
632 ui.draw_bar(0.0, 0.0, 100.0, 10.0, 1.5, Vec4::ONE, Vec4::ZERO);
633 if let UiDrawCommand::Bar { fill_pct, .. } = &ui.draw_queue()[0] {
634 assert_eq!(*fill_pct, 1.0);
635 }
636 }
637}