Skip to main content

proof_engine/render/
ui_layer_renderer.rs

1//! UI layer renderer — executes `UiDrawCommand`s with a separate shader program,
2//! orthographic projection, and no post-processing.
3//!
4//! Uses the same instanced glyph rendering approach as the 3D pass but with:
5//!   - Orthographic projection: (0,0) = top-left, (W,H) = bottom-right
6//!   - Depth test disabled
7//!   - Alpha blending enabled for semi-transparent panels
8//!   - Optional SDF path for razor-sharp text at all scales
9
10use glam::{Vec2, Vec3, Vec4, Mat4};
11
12use super::ui_layer::{UiLayer, UiDrawCommand, UiParticle, UiPass, TextAlign, BorderStyle};
13use crate::glyph::batch::GlyphInstance;
14use crate::glyph::atlas::FontAtlas;
15
16// ── UiLayerRenderer ─────────────────────────────────────────────────────────
17
18/// Renderer for the screen-space UI layer.
19///
20/// Holds CPU-side instance buffers and converts `UiDrawCommand`s into
21/// `GlyphInstance`s positioned in screen-pixel coordinates.
22pub struct UiLayerRenderer {
23    /// Scratch: the instances of the command currently being built. Moved
24    /// into `hud` or `world` as each command finishes.
25    instances: Vec<GlyphInstance>,
26    /// Instances for the HUD pass, painted after post-processing.
27    hud: Vec<GlyphInstance>,
28    /// Instances for the world pass, painted into the scene buffer before
29    /// post-processing. See [`UiPass`].
30    world: Vec<GlyphInstance>,
31    /// Rect instances (quads without texture — solid color).
32    rect_instances: Vec<RectInstance>,
33}
34
35/// A solid-color rectangle instance for panel backgrounds and bars.
36#[repr(C)]
37#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
38pub struct RectInstance {
39    pub position: [f32; 2],
40    pub size: [f32; 2],
41    pub color: [f32; 4],
42}
43
44impl UiLayerRenderer {
45    pub fn new() -> Self {
46        Self {
47            instances: Vec::with_capacity(2048),
48            hud: Vec::with_capacity(2048),
49            world: Vec::with_capacity(1 << 16),
50            rect_instances: Vec::with_capacity(256),
51        }
52    }
53
54    /// Clear instance buffers. Call at the start of each frame.
55    pub fn begin(&mut self) {
56        self.instances.clear();
57        self.hud.clear();
58        self.world.clear();
59        self.rect_instances.clear();
60    }
61
62    /// Process all draw commands from the UI layer and build instance buffers.
63    pub fn build_instances(&mut self, ui: &UiLayer, atlas: &FontAtlas) {
64        self.begin();
65
66        if !ui.enabled {
67            return;
68        }
69
70        for (i, cmd) in ui.draw_queue().iter().enumerate() {
71            match cmd {
72                UiDrawCommand::Text { text, x, y, scale, color, emission, alignment } => {
73                    self.build_text_instances(
74                        text, *x, *y, *scale, *color, *emission, *alignment, ui, atlas,
75                    );
76                }
77                UiDrawCommand::Rect { x, y, w, h, color, filled } => {
78                    if *filled {
79                        self.push_filled_rect(*x, *y, *w, *h, *color, atlas);
80                    } else {
81                        self.build_rect_outline(*x, *y, *w, *h, *color, ui, atlas);
82                    }
83                }
84                UiDrawCommand::Panel { x, y, w, h, border, fill_color, border_color } => {
85                    // A defaulted panel is split: the fill is ground and goes
86                    // under the matter in the world pass, the border is
87                    // interface and stays sharp in the HUD. A forced panel
88                    // goes whole wherever it was sent.
89                    let split = !ui.pass_forced(i);
90                    let fill = if split {
91                        Vec4::new(fill_color.x, fill_color.y, fill_color.z, 0.0)
92                    } else {
93                        *fill_color
94                    };
95                    if split && fill_color.w > 0.0 {
96                        self.build_panel(*x, *y, *w, *h, *border, *fill_color, Vec4::ZERO, ui, atlas);
97                        self.world.extend(self.instances.drain(..));
98                    }
99                    self.build_panel(*x, *y, *w, *h, *border, fill, *border_color, ui, atlas);
100                }
101                UiDrawCommand::Bar { x, y, w, h, fill_pct, fill_color, bg_color, ghost_pct, ghost_color } => {
102                    self.build_bar(*x, *y, *w, *h, *fill_pct, *fill_color, *bg_color, *ghost_pct, *ghost_color, ui, atlas);
103                }
104                UiDrawCommand::Sprite { lines, x, y, color } => {
105                    self.build_sprite(lines, *x, *y, *color, ui, atlas);
106                }
107                UiDrawCommand::Particles(particles) => {
108                    self.build_particle_instances(particles, 0.0, 0.0, atlas);
109                }
110                UiDrawCommand::SharedParticles { particles, dx, dy } => {
111                    self.build_particle_instances(particles, *dx, *dy, atlas);
112                }
113            }
114            // Each command lands whole in one pass or the other.
115            match ui.pass_of(i) {
116                UiPass::World => self.world.extend(self.instances.drain(..)),
117                UiPass::Hud => self.hud.extend(self.instances.drain(..)),
118            }
119        }
120    }
121
122    /// The HUD-pass glyph instances for GPU upload.
123    pub fn glyph_instances(&self) -> &[GlyphInstance] {
124        &self.hud
125    }
126
127    /// HUD-pass instance data as raw bytes for GPU upload.
128    pub fn glyph_bytes(&self) -> &[u8] {
129        bytemuck::cast_slice(&self.hud)
130    }
131
132    /// The world-pass glyph instances for GPU upload.
133    pub fn world_instances(&self) -> &[GlyphInstance] {
134        &self.world
135    }
136
137    /// World-pass instance data as raw bytes for GPU upload.
138    pub fn world_bytes(&self) -> &[u8] {
139        bytemuck::cast_slice(&self.world)
140    }
141
142    /// World-pass glyph count.
143    pub fn world_count(&self) -> usize {
144        self.world.len()
145    }
146
147    /// Get rect instances for GPU upload.
148    pub fn rect_instances(&self) -> &[RectInstance] {
149        &self.rect_instances
150    }
151
152    /// Get rect instance data as raw bytes.
153    pub fn rect_bytes(&self) -> &[u8] {
154        bytemuck::cast_slice(&self.rect_instances)
155    }
156
157    /// HUD-pass glyph count.
158    pub fn glyph_count(&self) -> usize {
159        self.hud.len()
160    }
161
162    /// Total rect count.
163    pub fn rect_count(&self) -> usize {
164        self.rect_instances.len()
165    }
166
167    // ── Private instance builders ───────────────────────────────────────────
168
169    fn build_text_instances(
170        &mut self,
171        text: &str,
172        x: f32,
173        y: f32,
174        scale: f32,
175        color: Vec4,
176        emission: f32,
177        alignment: TextAlign,
178        ui: &UiLayer,
179        atlas: &FontAtlas,
180    ) {
181        let char_w = ui.char_width * scale;
182        let char_h = ui.char_height * scale;
183        let text_width = text.chars().count() as f32 * char_w;
184
185        let start_x = match alignment {
186            TextAlign::Left => x,
187            TextAlign::Center => x - text_width * 0.5,
188            TextAlign::Right => x - text_width,
189        };
190
191        for (i, ch) in text.chars().enumerate() {
192            if ch == ' ' {
193                continue;
194            }
195            let uv = atlas.uv_for(ch);
196            let px = start_x + i as f32 * char_w + char_w * 0.5;
197            let py = y + char_h * 0.5;
198
199            self.instances.push(GlyphInstance {
200                position: [px, py, 0.0],
201                scale: [char_w, char_h],
202                rotation: 0.0,
203                color: color.to_array(),
204                emission,
205                glow_color: [color.x, color.y, color.z],
206                glow_radius: 0.0,
207                uv_offset: uv.offset(),
208                uv_size: uv.size(),
209                _pad: [0.0; 2],
210            });
211        }
212    }
213
214
215    /// Emit one glyph instance per particle in a cloud.
216    ///
217    /// Particles are placed by their centre and carry their own rotation and
218    /// glow, which is what separates a cloud of matter from a run of text.
219    fn build_particle_instances(
220        &mut self,
221        particles: &[UiParticle],
222        dx: f32,
223        dy: f32,
224        atlas: &FontAtlas,
225    ) {
226        self.instances.reserve(particles.len());
227        for p in particles {
228            if p.color.w <= 0.0 || p.w <= 0.0 || p.h <= 0.0 {
229                continue;
230            }
231            if !(p.x.is_finite() && p.y.is_finite() && p.rotation.is_finite()) {
232                continue;
233            }
234            let uv = atlas.uv_for(p.ch);
235            self.instances.push(GlyphInstance {
236                position: [p.x + dx, p.y + dy, 0.0],
237                scale: [p.w, p.h],
238                rotation: p.rotation,
239                color: p.color.to_array(),
240                emission: p.emission,
241                glow_color: [p.color.x, p.color.y, p.color.z],
242                glow_radius: p.glow,
243                uv_offset: uv.offset(),
244                uv_size: uv.size(),
245                _pad: [0.0; 2],
246            });
247        }
248    }
249
250    /// Emit a filled rectangle as a single stretched block glyph.
251    ///
252    /// The UI pass draws one instanced glyph batch, so rectangles have to go
253    /// through the same path. Pushing them to a separate buffer meant filled
254    /// rects and panel fills were built every frame and never drawn, because
255    /// nothing uploaded that buffer.
256    fn push_filled_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Vec4, atlas: &FontAtlas) {
257        if w <= 0.0 || h <= 0.0 || color.w <= 0.0 {
258            return;
259        }
260        // Sample a single texel from deep inside the full block rather than
261        // stretching the whole glyph. A distance field stretched across a
262        // panel-sized quad turns its edge falloff into a wide gradient, which
263        // reads as a blurry smear; a zero-area UV at the centre is uniformly
264        // "inside" and therefore solid at any size.
265        let uv = atlas.uv_for('\u{2588}'); // FULL BLOCK
266        let cu = (uv.u0 + uv.u1) * 0.5;
267        let cv = (uv.v0 + uv.v1) * 0.5;
268        self.instances.push(GlyphInstance {
269            position: [x + w * 0.5, y + h * 0.5, 0.0],
270            scale: [w, h],
271            rotation: 0.0,
272            color: color.to_array(),
273            emission: 0.0,
274            glow_color: [color.x, color.y, color.z],
275            glow_radius: 0.0,
276            uv_offset: [cu, cv],
277            uv_size: [0.0, 0.0],
278            // A fill is ground or a panel, not matter: it casts no shadow.
279            // The glyph shader reads this as the instance's flags.
280            _pad: [1.0, 0.0],
281        });
282    }
283
284    fn build_rect_outline(
285        &mut self,
286        x: f32,
287        y: f32,
288        w: f32,
289        h: f32,
290        color: Vec4,
291        ui: &UiLayer,
292        atlas: &FontAtlas,
293    ) {
294        // Draw rectangle outline using box-drawing characters.
295        let char_w = ui.char_width;
296        let char_h = ui.char_height;
297        let cols = (w / char_w).ceil() as usize;
298        let rows = (h / char_h).ceil() as usize;
299
300        if cols < 2 || rows < 2 {
301            return;
302        }
303
304        let border = BorderStyle::Single;
305        let chars = border.chars();
306
307        // Top row
308        self.push_char(x, y, chars[0], color, atlas, ui);
309        for c in 1..cols - 1 {
310            self.push_char(x + c as f32 * char_w, y, chars[1], color, atlas, ui);
311        }
312        self.push_char(x + (cols - 1) as f32 * char_w, y, chars[2], color, atlas, ui);
313
314        // Middle rows
315        for r in 1..rows - 1 {
316            let ry = y + r as f32 * char_h;
317            self.push_char(x, ry, chars[3], color, atlas, ui);
318            self.push_char(x + (cols - 1) as f32 * char_w, ry, chars[4], color, atlas, ui);
319        }
320
321        // Bottom row
322        let by = y + (rows - 1) as f32 * char_h;
323        self.push_char(x, by, chars[5], color, atlas, ui);
324        for c in 1..cols - 1 {
325            self.push_char(x + c as f32 * char_w, by, chars[6], color, atlas, ui);
326        }
327        self.push_char(x + (cols - 1) as f32 * char_w, by, chars[7], color, atlas, ui);
328    }
329
330    fn build_panel(
331        &mut self,
332        x: f32,
333        y: f32,
334        w: f32,
335        h: f32,
336        border: BorderStyle,
337        fill_color: Vec4,
338        border_color: Vec4,
339        ui: &UiLayer,
340        atlas: &FontAtlas,
341    ) {
342        let char_w = ui.char_width;
343        let char_h = ui.char_height;
344        let cols = (w / char_w).ceil() as usize;
345        let rows = (h / char_h).ceil() as usize;
346
347        if cols < 2 || rows < 2 {
348            return;
349        }
350
351        // Fill background
352        if fill_color.w > 0.0 {
353            self.push_filled_rect(
354                x + char_w,
355                y + char_h,
356                w - char_w * 2.0,
357                h - char_h * 2.0,
358                fill_color,
359                atlas,
360            );
361        }
362
363        let chars = border.chars();
364
365        // Top row
366        self.push_char(x, y, chars[0], border_color, atlas, ui);
367        for c in 1..cols - 1 {
368            self.push_char(x + c as f32 * char_w, y, chars[1], border_color, atlas, ui);
369        }
370        self.push_char(x + (cols - 1) as f32 * char_w, y, chars[2], border_color, atlas, ui);
371
372        // Side borders
373        for r in 1..rows - 1 {
374            let ry = y + r as f32 * char_h;
375            self.push_char(x, ry, chars[3], border_color, atlas, ui);
376            self.push_char(x + (cols - 1) as f32 * char_w, ry, chars[4], border_color, atlas, ui);
377        }
378
379        // Bottom row
380        let by = y + (rows - 1) as f32 * char_h;
381        self.push_char(x, by, chars[5], border_color, atlas, ui);
382        for c in 1..cols - 1 {
383            self.push_char(x + c as f32 * char_w, by, chars[6], border_color, atlas, ui);
384        }
385        self.push_char(x + (cols - 1) as f32 * char_w, by, chars[7], border_color, atlas, ui);
386    }
387
388    fn build_bar(
389        &mut self,
390        x: f32,
391        y: f32,
392        w: f32,
393        h: f32,
394        fill_pct: f32,
395        fill_color: Vec4,
396        bg_color: Vec4,
397        ghost_pct: Option<f32>,
398        ghost_color: Vec4,
399        ui: &UiLayer,
400        atlas: &FontAtlas,
401    ) {
402        let char_w = ui.char_width;
403        let total_chars = (w / char_w).floor() as usize;
404        if total_chars == 0 {
405            return;
406        }
407
408        let filled_chars = (fill_pct * total_chars as f32).round() as usize;
409        let ghost_chars = ghost_pct
410            .map(|g| (g * total_chars as f32).round() as usize)
411            .unwrap_or(0);
412
413        for i in 0..total_chars {
414            let cx = x + i as f32 * char_w;
415            let (ch, color) = if i < filled_chars {
416                ('█', fill_color)
417            } else if i < ghost_chars {
418                ('█', ghost_color)
419            } else {
420                ('░', bg_color)
421            };
422            self.push_char(cx, y, ch, color, atlas, ui);
423        }
424    }
425
426    fn build_sprite(
427        &mut self,
428        lines: &[String],
429        x: f32,
430        y: f32,
431        color: Vec4,
432        ui: &UiLayer,
433        atlas: &FontAtlas,
434    ) {
435        let char_w = ui.char_width;
436        let char_h = ui.char_height;
437
438        for (row, line) in lines.iter().enumerate() {
439            let ly = y + row as f32 * char_h;
440            for (col, ch) in line.chars().enumerate() {
441                if ch == ' ' {
442                    continue;
443                }
444                let cx = x + col as f32 * char_w;
445                self.push_char(cx, ly, ch, color, atlas, ui);
446            }
447        }
448    }
449
450    /// Push a single character glyph at screen-pixel coordinates.
451    fn push_char(
452        &mut self,
453        x: f32,
454        y: f32,
455        ch: char,
456        color: Vec4,
457        atlas: &FontAtlas,
458        ui: &UiLayer,
459    ) {
460        let uv = atlas.uv_for(ch);
461        let char_w = ui.char_width;
462        let char_h = ui.char_height;
463
464        self.instances.push(GlyphInstance {
465            position: [x + char_w * 0.5, y + char_h * 0.5, 0.0],
466            scale: [char_w, char_h],
467            rotation: 0.0,
468            color: color.to_array(),
469            emission: 0.0,
470            glow_color: [0.0, 0.0, 0.0],
471            glow_radius: 0.0,
472            uv_offset: uv.offset(),
473            uv_size: uv.size(),
474            _pad: [0.0; 2],
475        });
476    }
477}
478
479impl Default for UiLayerRenderer {
480    fn default() -> Self {
481        Self::new()
482    }
483}
484
485// ── UI Vertex Shader (embedded) ─────────────────────────────────────────────
486
487/// Vertex shader for UI layer — same as glyph.vert but without Y-flip
488/// (the ortho projection handles orientation correctly).
489pub const UI_VERT_SRC: &str = r#"
490#version 330 core
491
492layout(location = 0) in vec2  v_pos;
493layout(location = 1) in vec2  v_uv;
494
495layout(location = 2)  in vec3  i_position;
496layout(location = 3)  in vec2  i_scale;
497layout(location = 4)  in float i_rotation;
498layout(location = 5)  in vec4  i_color;
499layout(location = 6)  in float i_emission;
500layout(location = 7)  in vec3  i_glow_color;
501layout(location = 8)  in float i_glow_radius;
502layout(location = 9)  in vec2  i_uv_offset;
503layout(location = 10) in vec2  i_uv_size;
504
505uniform mat4 u_view_proj;
506
507out vec2  f_uv;
508out vec4  f_color;
509out float f_emission;
510
511void main() {
512    float c = cos(i_rotation);
513    float s = sin(i_rotation);
514    vec2 rotated = vec2(
515        v_pos.x * c - v_pos.y * s,
516        v_pos.x * s + v_pos.y * c
517    ) * i_scale;
518
519    gl_Position = u_view_proj * vec4(i_position + vec3(rotated, 0.0), 1.0);
520
521    f_uv       = i_uv_offset + v_uv * i_uv_size;
522    f_color    = i_color;
523    f_emission = i_emission;
524}
525"#;
526
527/// Fragment shader for UI layer — simple textured quad, no post-processing
528/// unless emission > 0.
529pub const UI_FRAG_SRC: &str = r#"
530#version 330 core
531
532in vec2  f_uv;
533in vec4  f_color;
534in float f_emission;
535
536uniform sampler2D u_atlas;
537
538layout(location = 0) out vec4 o_color;
539
540void main() {
541    float alpha = texture(u_atlas, f_uv).r;
542    if (alpha < 0.05) discard;
543    o_color = vec4(f_color.rgb, alpha * f_color.a);
544}
545"#;
546
547/// Vertex shader for solid-color rectangles.
548pub const RECT_VERT_SRC: &str = r#"
549#version 330 core
550
551layout(location = 0) in vec2 v_pos;       // [0,1] unit quad
552
553layout(location = 1) in vec2 i_position;   // top-left corner
554layout(location = 2) in vec2 i_size;       // width, height
555layout(location = 3) in vec4 i_color;
556
557uniform mat4 u_projection;
558
559out vec4 f_color;
560
561void main() {
562    vec2 world = i_position + v_pos * i_size;
563    gl_Position = u_projection * vec4(world, 0.0, 1.0);
564    f_color = i_color;
565}
566"#;
567
568/// Fragment shader for solid-color rectangles.
569pub const RECT_FRAG_SRC: &str = r#"
570#version 330 core
571
572in vec4 f_color;
573
574layout(location = 0) out vec4 o_color;
575
576void main() {
577    o_color = f_color;
578}
579"#;
580
581// ── Tests ───────────────────────────────────────────────────────────────────
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::glyph::atlas::FontAtlas;
587
588    #[test]
589    fn renderer_builds_text_instances() {
590        let mut renderer = UiLayerRenderer::new();
591        let mut ui = UiLayer::new(1280.0, 800.0);
592        ui.draw_text(10.0, 20.0, "Hi", 1.0, Vec4::ONE);
593
594        // We can't build instances without a real FontAtlas in unit tests,
595        // but we can verify the renderer initializes correctly.
596        assert_eq!(renderer.glyph_count(), 0);
597        renderer.begin();
598        assert_eq!(renderer.glyph_count(), 0);
599    }
600
601    #[test]
602    fn rect_instance_size() {
603        assert_eq!(
604            std::mem::size_of::<RectInstance>(),
605            4 * 8, // 2+2+4 floats = 8 floats = 32 bytes
606        );
607    }
608}