use std::env; use std::io; use std::path::PathBuf; use std::time::Instant; use ruin_perf::{PerfReportBuilder, RunMetadata}; use ruin_ui::{ AlignItems, Color, Edges, Element, FlexDirection, LayoutCache, LayoutFrameStats, ScrollbarStyle, TextStyle, TextSystem, UiSize, layout_snapshot_with_cache_and_stats, }; const DEFAULT_FRAMES: u64 = 300; #[derive(Clone, Debug)] struct Config { scenario: Scenario, frames: u64, output: Option, text_kb: usize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Scenario { Counter, LargeScrollList, Calculator, ResizeConfigure, LargeTextBody, } impl Scenario { const ALL: &'static [Self] = &[ Self::Counter, Self::LargeScrollList, Self::Calculator, Self::ResizeConfigure, Self::LargeTextBody, ]; const fn name(self) -> &'static str { match self { Self::Counter => "counter", Self::LargeScrollList => "large-scroll-list", Self::Calculator => "calculator", Self::ResizeConfigure => "resize-configure", Self::LargeTextBody => "large-text-body", } } fn parse(value: &str) -> Option { Self::ALL .iter() .copied() .find(|scenario| scenario.name() == value) } } fn main() -> io::Result<()> { let config = Config::parse(env::args().skip(1))?; let report = run_scenario(&config); let output = config .output .clone() .unwrap_or_else(|| report.default_output_path()); report.write_json(&output)?; report.print_summary(); println!(); println!("wrote {}", output.display()); Ok(()) } impl Config { fn parse(mut args: impl Iterator) -> io::Result { let mut scenario = Scenario::LargeScrollList; let mut frames = DEFAULT_FRAMES; let mut output = None; let mut text_kb = 256usize; while let Some(arg) = args.next() { match arg.as_str() { "--scenario" => { let value = args.next().ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "--scenario needs a value") })?; scenario = Scenario::parse(&value).ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, format!("unknown scenario '{value}'"), ) })?; } "--frames" => { let value = args.next().ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "--frames needs a value") })?; frames = value.parse::().map_err(|error| { io::Error::new( io::ErrorKind::InvalidInput, format!("invalid frame count '{value}': {error}"), ) })?; } "--output" => { let value = args.next().ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "--output needs a value") })?; output = Some(PathBuf::from(value)); } "--text-kb" => { let value = args.next().ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "--text-kb needs a value") })?; text_kb = value.parse::().map_err(|error| { io::Error::new( io::ErrorKind::InvalidInput, format!("invalid text size '{value}': {error}"), ) })?; } "--list" => { for scenario in Scenario::ALL { println!("{}", scenario.name()); } std::process::exit(0); } "--help" | "-h" => { print_help(); std::process::exit(0); } other => { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("unknown argument '{other}'"), )); } } } Ok(Self { scenario, frames, output, text_kb, }) } } fn print_help() { println!( "ruin_perf_scenarios --scenario [--frames ] [--output ] [--text-kb ]" ); println!(); println!("scenarios:"); for scenario in Scenario::ALL { println!(" {}", scenario.name()); } } fn run_scenario(config: &Config) -> ruin_perf::PerfReport { let mut report = PerfReportBuilder::new(RunMetadata::new(config.scenario.name(), config.frames)); let mut text_system = TextSystem::new(); let mut layout_cache = LayoutCache::new(); report.note("headless layout/app-build scenario; renderer/platform timings are not included"); if config.scenario == Scenario::LargeTextBody { report.set_counter("text_body_kb", config.text_kb as f64); } for frame in 0..config.frames { let build_started = Instant::now(); let (tree, viewport) = match config.scenario { Scenario::Counter => counter_tree(frame), Scenario::LargeScrollList => large_scroll_list_tree(frame), Scenario::Calculator => calculator_tree(frame), Scenario::ResizeConfigure => resize_configure_tree(frame), Scenario::LargeTextBody => large_text_body_tree(frame, config.text_kb), }; let build_ms = build_started.elapsed().as_secs_f64() * 1_000.0; let (_snapshot, stats) = layout_snapshot_with_cache_and_stats( frame + 1, viewport, &tree, &mut text_system, &mut layout_cache, ); report.record_ms("app_build", build_ms); record_layout_stats(&mut report, stats); } report.finish() } fn record_layout_stats(report: &mut PerfReportBuilder, stats: LayoutFrameStats) { report.record_ms("layout_total", stats.total_ms); report.record_ms("layout_intrinsic", stats.intrinsic_ms); report.record_ms("text_prepare", stats.text_prepare_ms); report.record_ms("text_cache_miss", stats.text_miss_ms); report.record("layout_nodes", "count", stats.nodes as f64); report.record("scene_items", "count", stats.scene_items as f64); report.record("text_glyphs", "count", stats.text_output_glyphs as f64); report.add_counter("layout_cache_hits", stats.layout_cache_hits as f64); report.add_counter("layout_cache_misses", stats.layout_cache_misses as f64); report.add_counter("intrinsic_cache_hits", stats.intrinsic_cache_hits as f64); report.add_counter("viewport_culled", stats.viewport_culled as f64); report.add_counter("text_cache_hits", stats.text_cache_hits as f64); report.add_counter("text_cache_misses", stats.text_cache_misses as f64); report.add_counter("text_requests", stats.text_requests as f64); } fn text_style(size: f32) -> TextStyle { TextStyle::new(size, Color::rgb(0x20, 0x20, 0x20)) } fn counter_tree(frame: u64) -> (Element, UiSize) { let count = frame as i64; let root = Element::column() .gap(12.0) .padding(Edges::all(24.0)) .align_items(AlignItems::Center) .child(Element::text("Counter", text_style(28.0))) .child(Element::text(format!("count = {count}"), text_style(18.0))) .child(Element::text( format!("double = {}", count * 2), text_style(18.0), )) .child(button_row(["-1", "Reset", "+1"])); (root, UiSize::new(480.0, 320.0)) } fn large_scroll_list_tree(frame: u64) -> (Element, UiSize) { let item_count = 1_000usize; let item_height = 28.0; let viewport = UiSize::new(480.0, 640.0); let max_offset = (item_count as f32 * item_height - viewport.height).max(0.0); let offset = ((frame as f32 * 19.0) % max_offset.max(1.0)).round(); let mut list = Element::column().gap(0.0); for index in 0..item_count { list = list.child(Element::new().height(item_height).child(Element::text( format!("list item {index:04}"), text_style(14.0), ))); } let root = Element::new().child( Element::scroll_box(offset) .width(viewport.width) .height(viewport.height) .scrollbar_style(ScrollbarStyle::default()) .child(list), ); (root, viewport) } fn calculator_tree(frame: u64) -> (Element, UiSize) { let expression = match frame % 6 { 0 => "2", 1 => "2+", 2 => "2+2", 3 => "4", 4 => "4*8", _ => "32", }; let history_len = (frame as usize / 12).min(40); let mut history = Element::column().gap(4.0); for index in 0..history_len { history = history.child(Element::text( format!("{} + {} = {}", index, index + 1, index * 2 + 1), text_style(12.0), )); } let root = Element::column() .gap(10.0) .padding(Edges::all(20.0)) .child(Element::text("Calculator", text_style(28.0))) .child(display_panel(expression)) .child(button_grid()) .child(Element::scroll_box(10_000.0).height(180.0).child(history)); (root, UiSize::new(420.0, 720.0)) } fn resize_configure_tree(frame: u64) -> (Element, UiSize) { let width = 360.0 + (frame % 90) as f32 * 4.0; let height = 280.0 + (frame % 60) as f32 * 3.0; let mut content = Element::column().gap(8.0).padding(Edges::all(16.0)); for index in 0..40 { content = content.child(Element::text( format!("resize row {index:02}: {width:.0} x {height:.0}"), text_style(14.0), )); } (content, UiSize::new(width, height)) } fn large_text_body_tree(frame: u64, text_kb: usize) -> (Element, UiSize) { let viewport = UiSize::new(720.0, 720.0); let text_width = 680.0; let line_height = 18.0; let text = large_text_body(text_kb); let estimated_line_count = (text.len() as f32 / 72.0).ceil(); let estimated_height = (estimated_line_count * line_height).max(viewport.height); let max_offset = (estimated_height - viewport.height).max(0.0); let offset = ((frame as f32 * 127.0) % max_offset.max(1.0)).round(); let style = text_style(14.0) .with_line_height(line_height) .with_bounds(UiSize::new(text_width, estimated_height)); let root = Element::new().child( Element::scroll_box(offset) .width(viewport.width) .height(viewport.height) .child( Element::new() .width(text_width) .child(Element::paragraph(text, style)), ), ); (root, viewport) } fn large_text_body(text_kb: usize) -> String { let target_bytes = text_kb.saturating_mul(1024).max(1); let paragraph = "RUIN large text profiling paragraph: the quick brown fox jumps over the lazy dog. This line is intentionally ordinary prose with enough words to exercise wrapping, glyph collection, hit-test geometry, and cache behavior.\n"; let mut text = String::with_capacity(target_bytes + paragraph.len()); while text.len() < target_bytes { text.push_str(paragraph); } text.truncate(target_bytes); text } fn display_panel(expression: &str) -> Element { Element::new() .padding(Edges::all(12.0)) .border(1.0, Color::rgb(0x80, 0x80, 0x80)) .child(Element::text(expression, text_style(24.0))) } fn button_grid() -> Element { let rows = [ ["7", "8", "9", "/"], ["4", "5", "6", "*"], ["1", "2", "3", "-"], ["0", ".", "=", "+"], ]; let mut grid = Element::column().gap(6.0); for row in rows { grid = grid.child(button_row(row)); } grid } fn button_row(labels: [&str; N]) -> Element { let mut row = Element::new().direction(FlexDirection::Row).gap(6.0); for label in labels { row = row.child( Element::new() .width(76.0) .height(36.0) .border(1.0, Color::rgb(0x90, 0x90, 0x90)) .child(Element::text(label, text_style(16.0))), ); } row }