Early in Mirador's development, I integrated egui as a quick solution for debug interfaces and UI prototyping. However, as the project evolved, I made the decision to remove egui entirely and build a custom UI system from scratch. This post explores the reasoning behind this choice and the benefits it has brought to the project.
When I first started working on Mirador, egui seemed like the perfect solution. It's a well-established immediate-mode GUI library for Rust with excellent documentation and community support. The integration was straightforward:
pub struct EguiRenderer {
state: State,
renderer: Renderer,
frame_started: bool,
}
The system worked well for basic debug interfaces, providing sliders, labels, and panels with minimal setup. It handled input processing, text rendering, and GPU resource management automatically.
egui offered several advantages that made it appealing for rapid prototyping: 1. Immediate-mode paradigm: No complex state management required 2. Built-in widgets: Sliders, buttons, text inputs, and more out of the box 3. Cross-platform: Works on all platforms that wgpu supports 4. Active development: Regular updates and bug fixes 5. Good documentation: Clear examples and API documentation For a project in its early stages, these benefits allowed me to focus on core game mechanics rather than UI implementation details.
As Mirador's scope expanded, several limitations of the egui integration became apparent:
egui's immediate-mode approach, while convenient, introduced performance overhead that became noticeable in a real-time game environment. Each frame required:
This overhead was acceptable for debug interfaces but problematic for game UI that needs to be responsive and efficient.
While egui provides good default styling, customizing the appearance to match Mirador's visual identity proved challenging. The theming system, while functional, didn't provide the level of control needed for a cohesive game experience.
The integration between egui and wgpu required careful management of:
This complexity grew as the game's rendering pipeline became more sophisticated.
After several months of development, I reached a critical decision point. The game needed more sophisticated UI features that would be difficult to implement with egui's constraints. Rather than fighting against the library's design, I decided to build a custom system that would be tailored specifically to Mirador's needs.
1. Performance: A custom system could be optimized specifically for game UI patterns 2. Integration: Direct integration with the game's rendering pipeline 3. Flexibility: Complete control over appearance and behavior 4. Simplicity: Remove the complexity of managing two different UI paradigms
The new system started with a simple but powerful foundation: the ButtonManager
struct that coordinates all UI functionality.
pub struct ButtonManager {
pub buttons: HashMap<String, Button>,
pub button_order: Vec<String>,
pub text_renderer: TextRenderer,
pub rectangle_renderer: RectangleRenderer,
pub icon_renderer: IconRenderer,
pub window_size: PhysicalSize<u32>,
pub mouse_position: (f32, f32),
pub mouse_pressed: bool,
pub just_clicked: Option<String>,
pub container_rect: Option<Rectangle>,
pub last_mouse_position: (f32, f32),
pub last_mouse_pressed: bool,
}
This design separates rendering concerns while maintaining tight integration with the game's systems.
One of the first improvements was implementing a flexible positioning system using anchor points:
#[derive(Debug, Clone)]
pub struct ButtonPosition {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub anchor: ButtonAnchor,
}
#[derive(Debug, Clone, Default)]
pub enum ButtonAnchor {
TopLeft,
#[default]
Center,
}
This makes layout calculations more intuitive - positioning a button at screen center doesn't require manual offset calculations.
The custom system implements smooth, anti-aliased rounded corners using Signed Distance Functions (SDFs):
fn sdf_rounded_rect(p: vec2<f32>, size: vec2<f32>, radius: f32) -> f32 {
let half_size = size * 0.5;
let d = abs(p - half_size) - half_size + radius;
return length(max(d, vec2<f32>(0.0))) + min(max(d.x, d.y), 0.0) - radius;
}
This provides resolution-independent rendering that scales well across different display densities.
The first major test of the custom system was implementing the pause menu. This relatively simple interface demonstrated the system's capabilities:
pub fn create_menu_buttons(button_manager: &mut ButtonManager, window_size: PhysicalSize<u32>) {
let reference_height = 1080.0;
let scale = (window_size.height as f32 / reference_height).clamp(0.7, 2.0);
let button_width = (window_size.width as f32 <em> 0.38 </em> scale).clamp(180.0, 600.0);
let button_height = (window_size.height as f32 <em> 0.09 </em> scale).clamp(32.0, 140.0);
let button_spacing = (window_size.height as f32 <em> 0.015 </em> scale).clamp(2.0, 24.0);
let total_height = button_height <em> 5.0 + button_spacing </em> 4.0;
let center_x = window_size.width as f32 / 2.0;
let start_y = (window_size.height as f32 - total_height) / 2.0;
}
The responsive design ensures the menu looks appropriate across different screen sizes while maintaining usability.
The real test came with the upgrade menu, which required more complex functionality:
The custom system handled these requirements elegantly:
pub fn create_upgrade_buttons(&mut self, upgrades: &[Upgrade], upgrade_manager: &UpgradeManager) {
let window_size = self.button_manager.window_size;
let reference_height = 1080.0;
let scale = (window_size.height as f32 / reference_height).clamp(0.7, 2.0);
let button_width = (window_size.width as f32 <em> 0.25 </em> scale).clamp(150.0, 400.0);
let button_height = (window_size.height as f32 <em> 0.15 </em> scale).clamp(80.0, 200.0);
let button_spacing = (window_size.height as f32 <em> 0.02 </em> scale).clamp(4.0, 32.0);
for (i, upgrade) in upgrades.iter().enumerate() {
let (level_text, tooltip_text) = upgrade_manager.get_upgrade_display_info(upgrade);
let mut button_style = create_upgrade_button_style();
button_style.spacing = ButtonSpacing::Tall(0.15);
let button = Button::new(&format!("upgrade_{}", i), &upgrade.name)
.with_style(button_style)
.with_level_text()
.with_tooltip_text()
.with_position(
ButtonPosition::new(center_x, y(i), button_width, button_height)
.with_anchor(ButtonAnchor::Center),
);
self.button_manager.add_button(button);
}
}
The custom system provides significant performance benefits: 1. Reduced overhead: No unnecessary tessellation or texture updates 2. Efficient batching: UI elements are batched by type for optimal GPU utilization 3. State change detection: Only updates when mouse position or press state actually changes 4. Memory efficiency: Direct control over resource allocation and cleanup
The custom system provides complete control over every aspect of the UI:
Direct integration with the game's rendering pipeline eliminates the complexity of managing two different systems:
The system can implement game-specific features that would be difficult with a general-purpose UI library:
The custom system started with basic button functionality and gradually added features. This incremental approach allowed for testing and refinement at each step.
The modular architecture (ButtonManager, TextRenderer, RectangleRenderer, IconRenderer) makes the system maintainable and extensible.
Building with performance in mind from the start ensures the system can handle complex UI requirements without impacting game performance.
The custom UI system provides a solid foundation for future development: 1. More widget types: Sliders, text inputs, scrollable lists 2. Animation system: Smooth transitions and effects 3. Layout management: More sophisticated positioning and sizing 4. Accessibility: Support for screen readers and keyboard navigation
Removing egui and building a custom UI system was a significant undertaking, but it has proven to be the right decision for Mirador. The custom system provides better performance, tighter integration, and more flexibility than the egui integration could have offered. The key insight is that while general-purpose libraries like egui are excellent for many applications, game development often benefits from specialized solutions that are tailored to specific requirements. The custom UI system is now a core strength of Mirador, providing a solid foundation for future development while maintaining the performance and flexibility needed for a real-time game. This experience reinforces the importance of being willing to replace third-party solutions when they no longer serve the project's needs. Sometimes the best long-term solution is to invest in building exactly what you need, even if it requires more initial effort.