Back to Mirador Blog

Removing egui - Building a Custom UI System

2025-07-25
7 min read
Development
miradorgame-developmentrust

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.

The Initial egui Integration

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.

Why egui Was Initially Attractive

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.

The Growing Pains

As Mirador's scope expanded, several limitations of the egui integration became apparent:

Performance Overhead

egui's immediate-mode approach, while convenient, introduced performance overhead that became noticeable in a real-time game environment. Each frame required:

  • Complete UI tessellation
  • Texture atlas updates
  • Complex input processing
  • Multiple render pass transitions

This overhead was acceptable for debug interfaces but problematic for game UI that needs to be responsive and efficient.

Limited Customization

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.

Integration Complexity

The integration between egui and wgpu required careful management of:

  • Resource synchronization
  • Render pass ordering
  • Memory allocation patterns
  • State management

This complexity grew as the game's rendering pipeline became more sophisticated.

The Decision to Build Custom

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.

Key Motivations

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

Building the Custom Button System

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.

Positioning System

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.

Rounded Corners with SDFs

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 Pause Menu Implementation

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 Upgrade Menu Challenge

The real test came with the upgrade menu, which required more complex functionality:

  • Multi-text display (name, level, description)
  • Icon rendering
  • Dynamic button creation
  • Complex layout management

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); } }

Performance Improvements

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

Benefits of the Custom Approach

Complete Control

The custom system provides complete control over every aspect of the UI:

  • Visual appearance and animations
  • Input handling and response
  • Performance optimization
  • Integration with game systems

Better Integration

Direct integration with the game's rendering pipeline eliminates the complexity of managing two different systems:

  • Shared resource management
  • Consistent rendering patterns
  • Unified state management
  • Simplified debugging

Game-Specific Features

The system can implement game-specific features that would be difficult with a general-purpose UI library:

  • Hover effects that scale with game time
  • Audio integration for button interactions
  • Dynamic content updates based on game state
  • Responsive layouts that adapt to different screen sizes

Lessons Learned

Start Simple, Build Up

The custom system started with basic button functionality and gradually added features. This incremental approach allowed for testing and refinement at each step.

Separation of Concerns

The modular architecture (ButtonManager, TextRenderer, RectangleRenderer, IconRenderer) makes the system maintainable and extensible.

Performance First

Building with performance in mind from the start ensures the system can handle complex UI requirements without impacting game performance.

Future Directions

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

Conclusion

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.