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, 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    /// Accumulated glyph instances for the current frame.
24    instances: Vec<GlyphInstance>,
25    /// Rect instances (quads without texture — solid color).
26    rect_instances: Vec<RectInstance>,
27}
28
29/// A solid-color rectangle instance for panel backgrounds and bars.
30#[repr(C)]
31#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
32pub struct RectInstance {
33    pub position: [f32; 2],
34    pub size: [f32; 2],
35    pub color: [f32; 4],
36}
37
38impl UiLayerRenderer {
39    pub fn new() -> Self {
40        Self {
41            instances: Vec::with_capacity(2048),
42            rect_instances: Vec::with_capacity(256),
43        }
44    }
45
46    /// Clear instance buffers. Call at the start of each frame.
47    pub fn begin(&mut self) {
48        self.instances.clear();
49        self.rect_instances.clear();
50    }
51
52    /// Process all draw commands from the UI layer and build instance buffers.
53    pub fn build_instances(&mut self, ui: &UiLayer, atlas: &FontAtlas) {
54        self.begin();
55
56        if !ui.enabled {
57            return;
58        }
59
60        for cmd in ui.draw_queue() {
61            match cmd {
62                UiDrawCommand::Text { text, x, y, scale, color, emission, alignment } => {
63                    self.build_text_instances(
64                        text, *x, *y, *scale, *color, *emission, *alignment, ui, atlas,
65                    );
66                }
67                UiDrawCommand::Rect { x, y, w, h, color, filled } => {
68                    if *filled {
69                        self.rect_instances.push(RectInstance {
70                            position: [*x, *y],
71                            size: [*w, *h],
72                            color: color.to_array(),
73                        });
74                    } else {
75                        self.build_rect_outline(*x, *y, *w, *h, *color, ui, atlas);
76                    }
77                }
78                UiDrawCommand::Panel { x, y, w, h, border, fill_color, border_color } => {
79                    self.build_panel(*x, *y, *w, *h, *border, *fill_color, *border_color, ui, atlas);
80                }
81                UiDrawCommand::Bar { x, y, w, h, fill_pct, fill_color, bg_color, ghost_pct, ghost_color } => {
82                    self.build_bar(*x, *y, *w, *h, *fill_pct, *fill_color, *bg_color, *ghost_pct, *ghost_color, ui, atlas);
83                }
84                UiDrawCommand::Sprite { lines, x, y, color } => {
85                    self.build_sprite(lines, *x, *y, *color, ui, atlas);
86                }
87            }
88        }
89    }
90
91    /// Get the glyph instances for GPU upload.
92    pub fn glyph_instances(&self) -> &[GlyphInstance] {
93        &self.instances
94    }
95
96    /// Get glyph instance data as raw bytes for GPU upload.
97    pub fn glyph_bytes(&self) -> &[u8] {
98        bytemuck::cast_slice(&self.instances)
99    }
100
101    /// Get rect instances for GPU upload.
102    pub fn rect_instances(&self) -> &[RectInstance] {
103        &self.rect_instances
104    }
105
106    /// Get rect instance data as raw bytes.
107    pub fn rect_bytes(&self) -> &[u8] {
108        bytemuck::cast_slice(&self.rect_instances)
109    }
110
111    /// Total glyph count.
112    pub fn glyph_count(&self) -> usize {
113        self.instances.len()
114    }
115
116    /// Total rect count.
117    pub fn rect_count(&self) -> usize {
118        self.rect_instances.len()
119    }
120
121    // ── Private instance builders ───────────────────────────────────────────
122
123    fn build_text_instances(
124        &mut self,
125        text: &str,
126        x: f32,
127        y: f32,
128        scale: f32,
129        color: Vec4,
130        emission: f32,
131        alignment: TextAlign,
132        ui: &UiLayer,
133        atlas: &FontAtlas,
134    ) {
135        let char_w = ui.char_width * scale;
136        let char_h = ui.char_height * scale;
137        let text_width = text.chars().count() as f32 * char_w;
138
139        let start_x = match alignment {
140            TextAlign::Left => x,
141            TextAlign::Center => x - text_width * 0.5,
142            TextAlign::Right => x - text_width,
143        };
144
145        for (i, ch) in text.chars().enumerate() {
146            if ch == ' ' {
147                continue;
148            }
149            let uv = atlas.uv_for(ch);
150            let px = start_x + i as f32 * char_w + char_w * 0.5;
151            let py = y + char_h * 0.5;
152
153            self.instances.push(GlyphInstance {
154                position: [px, py, 0.0],
155                scale: [char_w, char_h],
156                rotation: 0.0,
157                color: color.to_array(),
158                emission,
159                glow_color: [color.x, color.y, color.z],
160                glow_radius: 0.0,
161                uv_offset: uv.offset(),
162                uv_size: uv.size(),
163                _pad: [0.0; 2],
164            });
165        }
166    }
167
168    fn build_rect_outline(
169        &mut self,
170        x: f32,
171        y: f32,
172        w: f32,
173        h: f32,
174        color: Vec4,
175        ui: &UiLayer,
176        atlas: &FontAtlas,
177    ) {
178        // Draw rectangle outline using box-drawing characters.
179        let char_w = ui.char_width;
180        let char_h = ui.char_height;
181        let cols = (w / char_w).ceil() as usize;
182        let rows = (h / char_h).ceil() as usize;
183
184        if cols < 2 || rows < 2 {
185            return;
186        }
187
188        let border = BorderStyle::Single;
189        let chars = border.chars();
190
191        // Top row
192        self.push_char(x, y, chars[0], color, atlas, ui);
193        for c in 1..cols - 1 {
194            self.push_char(x + c as f32 * char_w, y, chars[1], color, atlas, ui);
195        }
196        self.push_char(x + (cols - 1) as f32 * char_w, y, chars[2], color, atlas, ui);
197
198        // Middle rows
199        for r in 1..rows - 1 {
200            let ry = y + r as f32 * char_h;
201            self.push_char(x, ry, chars[3], color, atlas, ui);
202            self.push_char(x + (cols - 1) as f32 * char_w, ry, chars[4], color, atlas, ui);
203        }
204
205        // Bottom row
206        let by = y + (rows - 1) as f32 * char_h;
207        self.push_char(x, by, chars[5], color, atlas, ui);
208        for c in 1..cols - 1 {
209            self.push_char(x + c as f32 * char_w, by, chars[6], color, atlas, ui);
210        }
211        self.push_char(x + (cols - 1) as f32 * char_w, by, chars[7], color, atlas, ui);
212    }
213
214    fn build_panel(
215        &mut self,
216        x: f32,
217        y: f32,
218        w: f32,
219        h: f32,
220        border: BorderStyle,
221        fill_color: Vec4,
222        border_color: Vec4,
223        ui: &UiLayer,
224        atlas: &FontAtlas,
225    ) {
226        let char_w = ui.char_width;
227        let char_h = ui.char_height;
228        let cols = (w / char_w).ceil() as usize;
229        let rows = (h / char_h).ceil() as usize;
230
231        if cols < 2 || rows < 2 {
232            return;
233        }
234
235        // Fill background
236        if fill_color.w > 0.0 {
237            self.rect_instances.push(RectInstance {
238                position: [x + char_w, y + char_h],
239                size: [w - char_w * 2.0, h - char_h * 2.0],
240                color: fill_color.to_array(),
241            });
242        }
243
244        let chars = border.chars();
245
246        // Top row
247        self.push_char(x, y, chars[0], border_color, atlas, ui);
248        for c in 1..cols - 1 {
249            self.push_char(x + c as f32 * char_w, y, chars[1], border_color, atlas, ui);
250        }
251        self.push_char(x + (cols - 1) as f32 * char_w, y, chars[2], border_color, atlas, ui);
252
253        // Side borders
254        for r in 1..rows - 1 {
255            let ry = y + r as f32 * char_h;
256            self.push_char(x, ry, chars[3], border_color, atlas, ui);
257            self.push_char(x + (cols - 1) as f32 * char_w, ry, chars[4], border_color, atlas, ui);
258        }
259
260        // Bottom row
261        let by = y + (rows - 1) as f32 * char_h;
262        self.push_char(x, by, chars[5], border_color, atlas, ui);
263        for c in 1..cols - 1 {
264            self.push_char(x + c as f32 * char_w, by, chars[6], border_color, atlas, ui);
265        }
266        self.push_char(x + (cols - 1) as f32 * char_w, by, chars[7], border_color, atlas, ui);
267    }
268
269    fn build_bar(
270        &mut self,
271        x: f32,
272        y: f32,
273        w: f32,
274        h: f32,
275        fill_pct: f32,
276        fill_color: Vec4,
277        bg_color: Vec4,
278        ghost_pct: Option<f32>,
279        ghost_color: Vec4,
280        ui: &UiLayer,
281        atlas: &FontAtlas,
282    ) {
283        let char_w = ui.char_width;
284        let total_chars = (w / char_w).floor() as usize;
285        if total_chars == 0 {
286            return;
287        }
288
289        let filled_chars = (fill_pct * total_chars as f32).round() as usize;
290        let ghost_chars = ghost_pct
291            .map(|g| (g * total_chars as f32).round() as usize)
292            .unwrap_or(0);
293
294        for i in 0..total_chars {
295            let cx = x + i as f32 * char_w;
296            let (ch, color) = if i < filled_chars {
297                ('█', fill_color)
298            } else if i < ghost_chars {
299                ('█', ghost_color)
300            } else {
301                ('░', bg_color)
302            };
303            self.push_char(cx, y, ch, color, atlas, ui);
304        }
305    }
306
307    fn build_sprite(
308        &mut self,
309        lines: &[String],
310        x: f32,
311        y: f32,
312        color: Vec4,
313        ui: &UiLayer,
314        atlas: &FontAtlas,
315    ) {
316        let char_w = ui.char_width;
317        let char_h = ui.char_height;
318
319        for (row, line) in lines.iter().enumerate() {
320            let ly = y + row as f32 * char_h;
321            for (col, ch) in line.chars().enumerate() {
322                if ch == ' ' {
323                    continue;
324                }
325                let cx = x + col as f32 * char_w;
326                self.push_char(cx, ly, ch, color, atlas, ui);
327            }
328        }
329    }
330
331    /// Push a single character glyph at screen-pixel coordinates.
332    fn push_char(
333        &mut self,
334        x: f32,
335        y: f32,
336        ch: char,
337        color: Vec4,
338        atlas: &FontAtlas,
339        ui: &UiLayer,
340    ) {
341        let uv = atlas.uv_for(ch);
342        let char_w = ui.char_width;
343        let char_h = ui.char_height;
344
345        self.instances.push(GlyphInstance {
346            position: [x + char_w * 0.5, y + char_h * 0.5, 0.0],
347            scale: [char_w, char_h],
348            rotation: 0.0,
349            color: color.to_array(),
350            emission: 0.0,
351            glow_color: [0.0, 0.0, 0.0],
352            glow_radius: 0.0,
353            uv_offset: uv.offset(),
354            uv_size: uv.size(),
355            _pad: [0.0; 2],
356        });
357    }
358}
359
360impl Default for UiLayerRenderer {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366// ── UI Vertex Shader (embedded) ─────────────────────────────────────────────
367
368/// Vertex shader for UI layer — same as glyph.vert but without Y-flip
369/// (the ortho projection handles orientation correctly).
370pub const UI_VERT_SRC: &str = r#"
371#version 330 core
372
373layout(location = 0) in vec2  v_pos;
374layout(location = 1) in vec2  v_uv;
375
376layout(location = 2)  in vec3  i_position;
377layout(location = 3)  in vec2  i_scale;
378layout(location = 4)  in float i_rotation;
379layout(location = 5)  in vec4  i_color;
380layout(location = 6)  in float i_emission;
381layout(location = 7)  in vec3  i_glow_color;
382layout(location = 8)  in float i_glow_radius;
383layout(location = 9)  in vec2  i_uv_offset;
384layout(location = 10) in vec2  i_uv_size;
385
386uniform mat4 u_view_proj;
387
388out vec2  f_uv;
389out vec4  f_color;
390out float f_emission;
391
392void main() {
393    float c = cos(i_rotation);
394    float s = sin(i_rotation);
395    vec2 rotated = vec2(
396        v_pos.x * c - v_pos.y * s,
397        v_pos.x * s + v_pos.y * c
398    ) * i_scale;
399
400    gl_Position = u_view_proj * vec4(i_position + vec3(rotated, 0.0), 1.0);
401
402    f_uv       = i_uv_offset + v_uv * i_uv_size;
403    f_color    = i_color;
404    f_emission = i_emission;
405}
406"#;
407
408/// Fragment shader for UI layer — simple textured quad, no post-processing
409/// unless emission > 0.
410pub const UI_FRAG_SRC: &str = r#"
411#version 330 core
412
413in vec2  f_uv;
414in vec4  f_color;
415in float f_emission;
416
417uniform sampler2D u_atlas;
418
419layout(location = 0) out vec4 o_color;
420
421void main() {
422    float alpha = texture(u_atlas, f_uv).r;
423    if (alpha < 0.05) discard;
424    o_color = vec4(f_color.rgb, alpha * f_color.a);
425}
426"#;
427
428/// Vertex shader for solid-color rectangles.
429pub const RECT_VERT_SRC: &str = r#"
430#version 330 core
431
432layout(location = 0) in vec2 v_pos;       // [0,1] unit quad
433
434layout(location = 1) in vec2 i_position;   // top-left corner
435layout(location = 2) in vec2 i_size;       // width, height
436layout(location = 3) in vec4 i_color;
437
438uniform mat4 u_projection;
439
440out vec4 f_color;
441
442void main() {
443    vec2 world = i_position + v_pos * i_size;
444    gl_Position = u_projection * vec4(world, 0.0, 1.0);
445    f_color = i_color;
446}
447"#;
448
449/// Fragment shader for solid-color rectangles.
450pub const RECT_FRAG_SRC: &str = r#"
451#version 330 core
452
453in vec4 f_color;
454
455layout(location = 0) out vec4 o_color;
456
457void main() {
458    o_color = f_color;
459}
460"#;
461
462// ── Tests ───────────────────────────────────────────────────────────────────
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::glyph::atlas::FontAtlas;
468
469    #[test]
470    fn renderer_builds_text_instances() {
471        let mut renderer = UiLayerRenderer::new();
472        let mut ui = UiLayer::new(1280.0, 800.0);
473        ui.draw_text(10.0, 20.0, "Hi", 1.0, Vec4::ONE);
474
475        // We can't build instances without a real FontAtlas in unit tests,
476        // but we can verify the renderer initializes correctly.
477        assert_eq!(renderer.glyph_count(), 0);
478        renderer.begin();
479        assert_eq!(renderer.glyph_count(), 0);
480    }
481
482    #[test]
483    fn rect_instance_size() {
484        assert_eq!(
485            std::mem::size_of::<RectInstance>(),
486            4 * 8, // 2+2+4 floats = 8 floats = 32 bytes
487        );
488    }
489}