Skip to main content

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}