nigig-org/PHASE3_RENDERGRAPH_SUMMARY.md
andodeki 0d43d61049 refactor(map): implement trait-based render graph (Phase 3)
- Define RenderPass trait with execute(), z_order(), zoom gating
- Implement 5 concrete passes: Background, Fill, Stroke, Poi, Label
- Create RenderContext to pass state to passes
- Refactor view.rs draw_walk() to use render_graph.execute()
- Reduce draw_walk() from 153 lines to 50 lines (-67%)
- Remove all hardcoded if-statements for pass execution
- Add 13 integration tests for render graph
- Enable true extensibility: new passes require only trait implementation

Benefits:
- Extensible: add custom passes without modifying view.rs
- Maintainable: each pass is self-contained
- Testable: passes can be tested independently
- Clean: view.rs reduced by 150 lines

Architecture:
  NigigMapView.draw_walk()
    -> creates RenderContext
    -> render_graph.execute(&mut ctx)
       -> iterates passes in z_order
       -> calls pass.execute(ctx) for each enabled pass

This completes Phase 3 of the Makepad codebase improvement plan.
2026-07-27 16:17:51 +00:00

10 KiB

Phase 3: True Render Graph - Complete

Summary

Successfully refactored the map renderer from hardcoded if-statements to a trait-based render graph system. This enables true extensibility without modifying view.rs.

Changes Made

1. Created Trait-Based Render Pass System

File: crates/apps/map/src/render_graph.rs

  • Defined RenderPass trait with methods:

    • pass_type() - Returns the pass type
    • z_order() - Returns draw order (lower = drawn first)
    • is_enabled() - Whether pass is active
    • min_zoom() / max_zoom() - Zoom range gating
    • should_execute(zoom) - Combined check
    • execute(ctx) - Actual rendering logic
  • Implemented 5 concrete pass structs:

    • BackgroundPass (z_order: 0)
    • FillPass (z_order: 100)
    • StrokePass (z_order: 200)
    • PoiPass (z_order: 300, min_zoom: 13.0)
    • LabelPass (z_order: 400, min_zoom: 13.0)
  • Created RenderContext struct to pass all necessary state to passes:

    • cx: &mut Cx2d - Makepad drawing context
    • cache: &TileCache - Tile cache
    • draw_tiles: &[TileKey] - Tiles to draw
    • entries: &[(TileKey, &TileEntry, f32)] - Pre-fetched entries with scales
    • view_zoom: f64 - Current zoom level
    • map_offset: Vec2f - Map offset for rendering
    • rect: Rect - Viewport rectangle
    • draw_bg, draw_map, draw_poi, draw_label - Drawing primitives
    • label_state: &mut LabelState - Label collision state
    • render_scratch: &mut RenderScratch - Scratch buffers
  • Implemented RenderGraph struct with methods:

    • new() - Creates graph with default passes
    • add_pass(pass) - Adds a custom pass
    • remove_pass(pass_type) - Removes a pass
    • get_pass(pass_type) - Gets a pass by type
    • execute(ctx) - Executes all enabled passes in z_order
    • total_tiles_drawn() / total_features_drawn() - Stats

2. Refactored view.rs

File: crates/apps/map/src/view.rs

  • Removed ~150 lines of hardcoded if-statements from draw_walk()
  • Replaced with single call to render_graph.execute(&mut ctx)
  • Reduced draw_walk() from ~153 lines to ~50 lines (67% reduction)

Before:

fn draw_walk(&mut self, cx: &mut Cx2d, ...) -> DrawStep {
    // ... setup code ...
    
    // Background pass
    if self.render_graph.pass(PassType::Background).map_or(true, |p| p.should_execute(view_zoom)) {
        // 5 lines of drawing code
    }
    
    // Fill pass
    if self.render_graph.pass(PassType::Fill).map_or(true, |p| p.should_execute(view_zoom)) {
        // 15 lines of drawing code
    }
    
    // Stroke pass
    if self.render_graph.pass(PassType::Stroke).map_or(true, |p| p.should_execute(view_zoom)) {
        // 15 lines of drawing code
    }
    
    // POI pass
    if self.render_graph.pass(PassType::Poi).map_or(true, |p| p.should_execute(view_zoom)) {
        // 25 lines of drawing code
    }
    
    // Label pass
    if self.render_graph.pass(PassType::Label).map_or(true, |p| p.should_execute(view_zoom)) {
        // 10 lines of drawing code
    }
    
    // ... cleanup code ...
}

After:

fn draw_walk(&mut self, cx: &mut Cx2d, ...) -> DrawStep {
    // ... setup code ...
    
    let mut ctx = super::render_graph::RenderContext {
        cx,
        cache: &self.cache,
        draw_tiles: &draw_tiles,
        entries: &entries,
        view_zoom,
        map_offset,
        rect,
        draw_bg: &mut self.draw_bg,
        draw_map: &mut self.draw_map,
        draw_poi: &mut self.draw_poi,
        draw_label: &mut self.draw_label,
        label_state: &mut self.label_state,
        render_scratch: &mut self.render,
    };
    
    self.render_graph.execute(&mut ctx);
    
    // ... cleanup code ...
}

3. Created Comprehensive Tests

File: crates/apps/map/tests/render_graph_tests.rs

  • 13 integration tests covering:
    • Default pass creation and ordering
    • Pass type properties
    • Zoom gating behavior
    • Enable/disable functionality
    • Pass retrieval and removal
    • Custom pass ordering
    • Stats collection
    • Zoom range customization
    • Pass type names and z_orders

Benefits

1. Extensibility

Adding a new render pass now requires only:

struct MyCustomPass;
impl RenderPass for MyCustomPass {
    fn pass_type(&self) -> PassType { PassType::Debug }
    fn z_order(&self) -> i32 { 500 }
    fn is_enabled(&self) -> bool { true }
    fn min_zoom(&self) -> f64 { 0.0 }
    fn max_zoom(&self) -> f64 { 30.0 }
    fn execute(&self, ctx: &mut RenderContext) -> PassStats {
        // Custom rendering logic
        PassStats::default()
    }
}

// Add to graph
graph.add_pass(Box::new(MyCustomPass));

No modifications to view.rs required!

2. Maintainability

  • Each pass is self-contained in its own struct
  • Logic is localized to the pass implementation
  • Easier to understand and modify individual passes

3. Testability

  • Each pass can be tested independently
  • Render graph behavior can be tested without full UI
  • Mock passes can be created for testing

4. Performance

  • Passes are sorted by z_order once at creation
  • Zoom gating is checked per-pass, not globally
  • Pre-fetched entries are reused across all passes

Architecture Diagram

┌─────────────────────────────────────────────────────────────┐
│                        NigigMapView                         │
│                                                             │
│  ┌──────────────────────────────────────────────────────┐  │
│  │  draw_walk()                                          │  │
│  │                                                        │  │
│  │  1. Setup (viewport, tiles, entries)                  │  │
│  │  2. Create RenderContext                              │  │
│  │  3. render_graph.execute(&mut ctx)  ─────────────┐   │  │
│  │  4. Cleanup                                        │   │  │
│  └────────────────────────────────────────────────────┼───┘  │
│                                                       │      │
│  ┌────────────────────────────────────────────────────┼───┐  │
│  │  RenderGraph                                        │   │  │
│  │                                                      │   │  │
│  │  passes: Vec<Box<dyn RenderPass>>                   │   │  │
│  │    ├── BackgroundPass (z_order: 0)                  │   │  │
│  │    ├── FillPass (z_order: 100)                      │   │  │
│  │    ├── StrokePass (z_order: 200)                    │   │  │
│  │    ├── PoiPass (z_order: 300, min_zoom: 13.0)       │   │  │
│  │    └── LabelPass (z_order: 400, min_zoom: 13.0)     │   │  │
│  │                                                      │   │  │
│  │  execute(ctx) {                                      │   │  │
│  │    for pass in passes (sorted by z_order) {         │   │  │
│  │      if pass.should_execute(ctx.view_zoom) {        │   │  │
│  │        pass.execute(ctx);  ◄────────────────────────┘   │  │
│  │      }                                                  │  │
│  │    }                                                    │  │
│  │  }                                                      │  │
│  └─────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

Metrics

Metric Before After Change
view.rs lines 1180 ~1030 -150 lines (-13%)
draw_walk() lines 153 50 -103 lines (-67%)
Hardcoded if-statements 5 0 -5 (100% removed)
Extensibility Poor (modify view.rs) Excellent (implement trait) Major improvement
Testability Poor (monolithic) Good (modular) Major improvement

Testing

Unit Tests (in render_graph.rs)

  • Pass creation and properties
  • Zoom gating logic
  • Pass ordering
  • Enable/disable behavior

Integration Tests (in tests/render_graph_tests.rs)

  • 13 comprehensive tests
  • Covers all public API
  • Tests custom pass creation
  • Validates z_order sorting

Future Enhancements

1. Pass Configuration API

Currently passes are created with default settings. Future work could add:

graph.configure_pass(PassType::Poi, |pass| {
    pass.min_zoom = 10.0;
    pass.enabled = false;
});

2. Pass Dependencies

Some passes might depend on others (e.g., labels might need fill pass to complete first):

trait RenderPass {
    fn dependencies(&self) -> Vec<PassType> { vec![] }
}

3. Conditional Pass Execution

Allow passes to skip execution based on runtime conditions:

trait RenderPass {
    fn should_skip(&self, ctx: &RenderContext) -> bool { false }
}

4. Pass Profiling

Add timing information to PassStats:

pub struct PassStats {
    pub pass_type: Option<PassType>,
    pub tiles_drawn: usize,
    pub features_drawn: usize,
    pub execution_time_us: u64,  // New field
    pub skipped: bool,
    pub reason: Option<SkipReason>,
}

Conclusion

Phase 3 successfully transformed the render graph from a configuration-based system with hardcoded execution to a true trait-based system with extensible passes. The code is now:

  • More maintainable: Each pass is self-contained
  • More extensible: New passes require only trait implementation
  • More testable: Passes can be tested independently
  • Cleaner: view.rs reduced by 150 lines

This sets the foundation for future enhancements like custom passes, pass dependencies, and advanced profiling.

Next Steps

  1. Commit and push the Phase 3 changes
  2. Test the implementation with the full UI
  3. Document the new API for users
  4. Consider Phase 4 enhancements (pass configuration API, profiling)