From e5ae067bc26143b8bf593273e684031ff799f78c Mon Sep 17 00:00:00 2001 From: Will Temple Date: Sat, 16 May 2026 19:07:44 -0400 Subject: [PATCH] Perf profiling and some low hanging perf improvements in text rendering --- Cargo.lock | 16 ++ Cargo.toml | 2 + docs/performance-profiling.md | 94 +++++++ examples/perf_scenarios/Cargo.toml | 8 + examples/perf_scenarios/src/main.rs | 366 ++++++++++++++++++++++++++++ lib/perf/Cargo.toml | 8 + lib/perf/src/lib.rs | 226 +++++++++++++++++ lib/ui/src/layout.rs | 133 +++++++--- lib/ui/src/lib.rs | 5 +- lib/ui/src/scene.rs | 46 +++- lib/ui/src/text.rs | 9 +- lib/ui/src/tree.rs | 17 +- lib/ui_renderer_wgpu/src/lib.rs | 197 +++++++++------ 13 files changed, 1010 insertions(+), 117 deletions(-) create mode 100644 docs/performance-profiling.md create mode 100644 examples/perf_scenarios/Cargo.toml create mode 100644 examples/perf_scenarios/src/main.rs create mode 100644 lib/perf/Cargo.toml create mode 100644 lib/perf/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0564855..09db767 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1934,6 +1934,22 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "ruin_perf" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "ruin_perf_scenarios" +version = "0.1.0" +dependencies = [ + "ruin_perf", + "ruin_ui", +] + [[package]] name = "ruin_reactivity" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9ec0189..43ccbe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,11 @@ default-members = [ "lib/ruin_app_proc_macros", "lib/runtime", "lib/runtime_proc_macros", + "lib/perf", "lib/ui", "lib/ui_platform_macos", "lib/ui_platform_wayland", "lib/ui_renderer_wgpu", "examples/calculator", + "examples/perf_scenarios", ] diff --git a/docs/performance-profiling.md b/docs/performance-profiling.md new file mode 100644 index 0000000..698b1d3 --- /dev/null +++ b/docs/performance-profiling.md @@ -0,0 +1,94 @@ +# Performance profiling workflow + +RUIN performance investigations should start with reproducible reports before +deep profiler sessions. The first-pass tooling is intentionally headless and +focused on high-signal app-build, layout, text, scene, and cache counters. + +## Run a scenario + +Use the `ruin_perf_scenarios` package in release mode: + +```bash +cargo run -p ruin_perf_scenarios --release -- \ + --scenario large-scroll-list \ + --frames 300 \ + --output target/ruin-perf/large-scroll-list.json +``` + +List available scenarios: + +```bash +cargo run -p ruin_perf_scenarios -- --list +``` + +Current scenarios: + +| Scenario | What it stresses | +| --- | --- | +| `counter` | tiny reactive-style app tree rebuild and layout baseline | +| `large-scroll-list` | scroll culling, text cache reuse, layout cache behavior | +| `calculator` | realistic small app tree with display, button grid, and history | +| `resize-configure` | repeated viewport-size changes and layout reflow | +| `large-text-body` | huge wrapped text body, text layout cache behavior, scroll clipping | + +The large text scenario accepts a body-size option: + +```bash +cargo run -p ruin_perf_scenarios --release -- \ + --scenario large-text-body \ + --frames 100 \ + --text-kb 1024 +``` + +Each run writes JSON under `target/ruin-perf/` by default and prints a compact +summary table to stdout. + +## Interpreting the report + +Start with the timing rows: + +- `app_build`: time spent building the deterministic app tree for the frame. +- `layout_total`: full layout snapshot time. +- `layout_intrinsic`: intrinsic measurement work inside layout. +- `text_prepare` / `text_cache_miss`: text shaping/cache miss work. + +Then inspect counters: + +- High `layout_cache_misses` with low `layout_cache_hits` means the tree or + layout constraints are changing in a way that defeats caching. +- High `viewport_culled` with low `scene_items` is expected for large scroll + lists and means scene culling is working. +- High `text_cache_misses` or `text_cache_miss` time usually means text content, + bounds, style, or cache keys are changing too much. +- Large `scene_items` or `text_glyphs` for tiny workloads suggests excessive UI + tree expansion or missing culling. + +## Investigation loop + +1. Reproduce the suspected degenerate workload with the closest scenario. +2. Run it in release mode and keep the JSON report. +3. Compare the stdout summary with a known-good run or a smaller frame count. +4. If one phase dominates, use an OS profiler on that scenario: + - macOS: Instruments Time Profiler / Allocations / Metal System Trace. + - Linux: `perf`, `samply`, `heaptrack`, or RenderDoc as appropriate. +5. After identifying a concrete hot path, add or update a narrow Criterion + benchmark before optimizing it. + +## Validation + +The scenario runner is part of the default workspace. The usual repository +validation should keep it healthy: + +```bash +cargo test +cargo clippy --all-targets -- -D warnings +cargo clippy --release --all-targets -- -D warnings +``` + +## Current limitations + +This first pass does not automate GUI input, GPU frame capture, or platform +presentation traces. Renderer frame counters are exposed for platform-backed +runs, but the headless scenario reports focus on app/layout/text degeneracy +first. Platform-backed resize/presentation report generation should be added +after these reports are useful enough to guide concrete optimization work. diff --git a/examples/perf_scenarios/Cargo.toml b/examples/perf_scenarios/Cargo.toml new file mode 100644 index 0000000..fbcf17d --- /dev/null +++ b/examples/perf_scenarios/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "ruin_perf_scenarios" +version = "0.1.0" +edition = "2024" + +[dependencies] +ruin_perf = { path = "../../lib/perf" } +ruin_ui = { path = "../../lib/ui" } diff --git a/examples/perf_scenarios/src/main.rs b/examples/perf_scenarios/src/main.rs new file mode 100644 index 0000000..01e377c --- /dev/null +++ b/examples/perf_scenarios/src/main.rs @@ -0,0 +1,366 @@ +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 +} diff --git a/lib/perf/Cargo.toml b/lib/perf/Cargo.toml new file mode 100644 index 0000000..e715fe5 --- /dev/null +++ b/lib/perf/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "ruin_perf" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/lib/perf/src/lib.rs b/lib/perf/src/lib.rs new file mode 100644 index 0000000..4e2e300 --- /dev/null +++ b/lib/perf/src/lib.rs @@ -0,0 +1,226 @@ +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RunMetadata { + pub scenario: String, + pub frames: u64, + pub profile: String, + pub target_os: String, + pub target_arch: String, + pub started_unix_ms: u128, +} + +impl RunMetadata { + pub fn new(scenario: impl Into, frames: u64) -> Self { + Self { + scenario: scenario.into(), + frames, + profile: if cfg!(debug_assertions) { + "debug".into() + } else { + "release".into() + }, + target_os: std::env::consts::OS.into(), + target_arch: std::env::consts::ARCH.into(), + started_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_millis(), + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct SampleSeries { + pub name: String, + pub unit: String, + pub samples: Vec, +} + +impl SampleSeries { + pub fn new(name: impl Into, unit: impl Into) -> Self { + Self { + name: name.into(), + unit: unit.into(), + samples: Vec::new(), + } + } + + pub fn push(&mut self, sample: f64) { + if sample.is_finite() { + self.samples.push(sample); + } + } + + pub fn summary(&self) -> SampleSummary { + SampleSummary::from_samples(&self.name, &self.unit, &self.samples) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SampleSummary { + pub name: String, + pub unit: String, + pub count: usize, + pub avg: f64, + pub p50: f64, + pub p95: f64, + pub max: f64, +} + +impl SampleSummary { + fn from_samples(name: &str, unit: &str, samples: &[f64]) -> Self { + if samples.is_empty() { + return Self { + name: name.into(), + unit: unit.into(), + count: 0, + avg: 0.0, + p50: 0.0, + p95: 0.0, + max: 0.0, + }; + } + + let mut sorted = samples.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + let sum = sorted.iter().sum::(); + Self { + name: name.into(), + unit: unit.into(), + count: sorted.len(), + avg: sum / sorted.len() as f64, + p50: percentile(&sorted, 0.50), + p95: percentile(&sorted, 0.95), + max: *sorted.last().unwrap_or(&0.0), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PerfReport { + pub metadata: RunMetadata, + pub summaries: Vec, + pub counters: BTreeMap, + pub notes: Vec, +} + +#[derive(Clone, Debug)] +pub struct PerfReportBuilder { + metadata: RunMetadata, + series: BTreeMap, + counters: BTreeMap, + notes: Vec, +} + +impl PerfReportBuilder { + pub fn new(metadata: RunMetadata) -> Self { + Self { + metadata, + series: BTreeMap::new(), + counters: BTreeMap::new(), + notes: Vec::new(), + } + } + + pub fn record_ms(&mut self, name: impl Into, value: f64) { + self.record(name, "ms", value); + } + + pub fn record(&mut self, name: impl Into, unit: impl Into, value: f64) { + let name = name.into(); + let unit = unit.into(); + self.series + .entry(name.clone()) + .or_insert_with(|| SampleSeries::new(name, unit)) + .push(value); + } + + pub fn add_counter(&mut self, name: impl Into, value: impl Into) { + *self.counters.entry(name.into()).or_insert(0.0) += value.into(); + } + + pub fn set_counter(&mut self, name: impl Into, value: impl Into) { + self.counters.insert(name.into(), value.into()); + } + + pub fn note(&mut self, note: impl Into) { + self.notes.push(note.into()); + } + + pub fn finish(self) -> PerfReport { + PerfReport { + metadata: self.metadata, + summaries: self.series.values().map(SampleSeries::summary).collect(), + counters: self.counters, + notes: self.notes, + } + } +} + +impl PerfReport { + pub fn write_json(&self, path: impl AsRef) -> io::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?; + fs::write(path, json)?; + Ok(()) + } + + pub fn default_output_path(&self) -> PathBuf { + PathBuf::from("target") + .join("ruin-perf") + .join(format!("{}.json", self.metadata.scenario)) + } + + pub fn print_summary(&self) { + println!( + "scenario={} frames={} profile={} target={}/{}", + self.metadata.scenario, + self.metadata.frames, + self.metadata.profile, + self.metadata.target_os, + self.metadata.target_arch + ); + println!( + "{:<32} {:>8} {:>10} {:>10} {:>10} {:>10}", + "metric", "count", "avg", "p50", "p95", "max" + ); + for summary in &self.summaries { + println!( + "{:<32} {:>8} {:>10.3} {:>10.3} {:>10.3} {:>10.3} {}", + summary.name, + summary.count, + summary.avg, + summary.p50, + summary.p95, + summary.max, + summary.unit + ); + } + if !self.counters.is_empty() { + println!(); + println!("{:<32} {:>12}", "counter", "value"); + for (name, value) in &self.counters { + println!("{name:<32} {value:>12.3}"); + } + } + } +} + +fn percentile(sorted: &[f64], percentile: f64) -> f64 { + if sorted.is_empty() { + return 0.0; + } + let index = ((sorted.len() - 1) as f64 * percentile).round() as usize; + sorted[index.min(sorted.len() - 1)] +} diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index 2ca0106..d497251 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -36,6 +36,35 @@ pub struct LayoutSnapshot { pub root_min_size: UiSize, } +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct LayoutFrameStats { + pub total_ms: f64, + pub nodes: usize, + pub text_nodes: usize, + pub container_nodes: usize, + pub background_quads: usize, + pub intrinsic_calls: usize, + pub intrinsic_size_calls: usize, + pub intrinsic_text_calls: usize, + pub intrinsic_container_calls: usize, + pub intrinsic_ms: f64, + pub text_prepare_calls: usize, + pub text_prepare_ms: f64, + pub viewport_culled: usize, + pub layout_cache_hits: usize, + pub layout_cache_misses: usize, + pub intrinsic_cache_hits: usize, + pub text_requests: u32, + pub text_cache_hits: u32, + pub text_cache_misses: u32, + pub text_output_glyphs: u32, + pub text_family_resolve_ms: f64, + pub text_buffer_build_ms: f64, + pub text_glyph_collect_ms: f64, + pub text_miss_ms: f64, + pub scene_items: usize, +} + #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct LayoutPath(Vec); @@ -194,6 +223,16 @@ pub fn layout_snapshot_with_cache( text_system: &mut TextSystem, layout_cache: &mut LayoutCache, ) -> LayoutSnapshot { + layout_snapshot_with_cache_and_stats(version, logical_size, root, text_system, layout_cache).0 +} + +pub fn layout_snapshot_with_cache_and_stats( + version: u64, + logical_size: UiSize, + root: &Element, + text_system: &mut TextSystem, + layout_cache: &mut LayoutCache, +) -> (LayoutSnapshot, LayoutFrameStats) { let layout_started = Instant::now(); let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG); let mut perf_stats = LayoutPerfStats::new(perf_enabled); @@ -215,48 +254,78 @@ pub fn layout_snapshot_with_cache( None, ); let text_stats = text_system.take_frame_stats(); + let stats = LayoutFrameStats { + total_ms: layout_started.elapsed().as_secs_f64() * 1_000.0, + nodes: perf_stats.nodes, + text_nodes: perf_stats.text_nodes, + container_nodes: perf_stats.container_nodes, + background_quads: perf_stats.background_quads, + intrinsic_calls: perf_stats.intrinsic_calls, + intrinsic_size_calls: perf_stats.intrinsic_size_calls, + intrinsic_text_calls: perf_stats.intrinsic_text_calls, + intrinsic_container_calls: perf_stats.intrinsic_container_calls, + intrinsic_ms: perf_stats.intrinsic_ms, + text_prepare_calls: perf_stats.text_prepare_calls, + text_prepare_ms: perf_stats.text_prepare_ms, + viewport_culled: perf_stats.viewport_culled, + layout_cache_hits: perf_stats.layout_cache_hits, + layout_cache_misses: perf_stats.layout_cache_misses, + intrinsic_cache_hits: perf_stats.intrinsic_cache_hits, + text_requests: text_stats.requests, + text_cache_hits: text_stats.cache_hits, + text_cache_misses: text_stats.cache_misses, + text_output_glyphs: text_stats.output_glyphs, + text_family_resolve_ms: text_stats.family_resolve_ms, + text_buffer_build_ms: text_stats.buffer_build_ms, + text_glyph_collect_ms: text_stats.glyph_collect_ms, + text_miss_ms: text_stats.miss_ms, + scene_items: scene.items.len(), + }; if perf_stats.enabled { tracing::debug!( target: "ruin_ui::layout_perf", scene_version = version, width = logical_size.width, height = logical_size.height, - total_ms = layout_started.elapsed().as_secs_f64() * 1_000.0, - nodes = perf_stats.nodes, - text_nodes = perf_stats.text_nodes, - container_nodes = perf_stats.container_nodes, - background_quads = perf_stats.background_quads, - intrinsic_calls = perf_stats.intrinsic_calls, - intrinsic_size_calls = perf_stats.intrinsic_size_calls, - intrinsic_text_calls = perf_stats.intrinsic_text_calls, - intrinsic_container_calls = perf_stats.intrinsic_container_calls, - intrinsic_ms = perf_stats.intrinsic_ms, - text_prepare_calls = perf_stats.text_prepare_calls, - text_prepare_ms = perf_stats.text_prepare_ms, - viewport_culled = perf_stats.viewport_culled, - layout_cache_hits = perf_stats.layout_cache_hits, - layout_cache_misses = perf_stats.layout_cache_misses, - intrinsic_cache_hits = perf_stats.intrinsic_cache_hits, - text_requests = text_stats.requests, - text_cache_hits = text_stats.cache_hits, - text_cache_misses = text_stats.cache_misses, - text_output_glyphs = text_stats.output_glyphs, - text_family_resolve_ms = text_stats.family_resolve_ms, - text_buffer_build_ms = text_stats.buffer_build_ms, - text_glyph_collect_ms = text_stats.glyph_collect_ms, - text_miss_ms = text_stats.miss_ms, - scene_items = scene.items.len(), + total_ms = stats.total_ms, + nodes = stats.nodes, + text_nodes = stats.text_nodes, + container_nodes = stats.container_nodes, + background_quads = stats.background_quads, + intrinsic_calls = stats.intrinsic_calls, + intrinsic_size_calls = stats.intrinsic_size_calls, + intrinsic_text_calls = stats.intrinsic_text_calls, + intrinsic_container_calls = stats.intrinsic_container_calls, + intrinsic_ms = stats.intrinsic_ms, + text_prepare_calls = stats.text_prepare_calls, + text_prepare_ms = stats.text_prepare_ms, + viewport_culled = stats.viewport_culled, + layout_cache_hits = stats.layout_cache_hits, + layout_cache_misses = stats.layout_cache_misses, + intrinsic_cache_hits = stats.intrinsic_cache_hits, + text_requests = stats.text_requests, + text_cache_hits = stats.text_cache_hits, + text_cache_misses = stats.text_cache_misses, + text_output_glyphs = stats.text_output_glyphs, + text_family_resolve_ms = stats.text_family_resolve_ms, + text_buffer_build_ms = stats.text_buffer_build_ms, + text_glyph_collect_ms = stats.text_glyph_collect_ms, + text_miss_ms = stats.text_miss_ms, + scene_items = stats.scene_items, "layout snapshot perf" ); } let root_min_size = element_min_size(root); - LayoutSnapshot { - scene, - interaction_tree: InteractionTree { - root: interaction_root, + ( + LayoutSnapshot { + scene, + interaction_tree: InteractionTree { + root: interaction_root, + }, + root_min_size, }, - root_min_size, - } + stats, + ) } #[allow(clippy::too_many_arguments)] @@ -2903,7 +2972,7 @@ mod tests { .items .iter() .filter_map(|item| match item { - DisplayItem::Text(text) => Some(text.text.as_str()), + DisplayItem::Text(text) => Some(text.text.as_ref()), _ => None, }) .collect() diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index 171909c..46ed72d 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -31,8 +31,9 @@ pub use interaction::{ }; pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers}; pub use layout::{ - HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics, - TextHitTarget, element_min_size, layout_snapshot, layout_snapshot_with_cache, + HitTarget, InteractionTree, LayoutCache, LayoutFrameStats, LayoutNode, LayoutPath, + LayoutSnapshot, ScrollMetrics, TextHitTarget, element_min_size, layout_snapshot, + layout_snapshot_with_cache, layout_snapshot_with_cache_and_stats, layout_snapshot_with_text_system, }; pub use layout::{layout_scene, layout_scene_with_text_system}; diff --git a/lib/ui/src/scene.rs b/lib/ui/src/scene.rs index 1becf27..c370dfe 100644 --- a/lib/ui/src/scene.rs +++ b/lib/ui/src/scene.rs @@ -189,7 +189,7 @@ pub struct PreparedTextLine { #[derive(Clone, Debug, PartialEq)] pub struct PreparedText { pub element_id: Option, - pub text: String, + pub text: Arc, pub origin: Point, pub bounds: Option, pub font_size: f32, @@ -206,6 +206,8 @@ pub struct PreparedText { /// Add `origin` to convert to absolute window coords. #[derive(Clone, Debug, PartialEq)] pub struct TextLayoutData { + pub text: Arc, + pub cache_key: u64, pub lines: Vec, pub glyphs: Vec, /// Measured (unclamped) size of the laid-out text. @@ -225,7 +227,6 @@ impl PreparedText { #[allow(clippy::too_many_arguments)] pub(crate) fn from_layout( element_id: Option, - text: String, origin: Point, bounds: Option, font_size: f32, @@ -237,7 +238,7 @@ impl PreparedText { ) -> Self { Self { element_id, - text, + text: layout.text.clone(), origin, bounds, font_size, @@ -256,6 +257,10 @@ impl PreparedText { Arc::as_ptr(&self.layout) } + pub fn layout_cache_key(&self) -> u64 { + self.layout.cache_key + } + /// Create a monospace `PreparedText` without going through the text system. /// Used for testing and low-level terminal-style rendering. pub fn monospace( @@ -265,7 +270,7 @@ impl PreparedText { advance: f32, color: Color, ) -> Self { - let text = text.into(); + let text: Arc = Arc::from(text.into()); // Glyphs are stored in LOCAL (origin-relative) coordinates. let mut local_x = 0.0f32; let mut glyphs = Vec::with_capacity(text.chars().count()); @@ -293,7 +298,7 @@ impl PreparedText { let size = UiSize::new(local_x, font_size); Self { element_id: None, - text, + text: text.clone(), origin, bounds: None, font_size, @@ -302,6 +307,8 @@ impl PreparedText { selectable: true, selection_style: TextSelectionStyle::DEFAULT, layout: Arc::new(TextLayoutData { + text: text.clone(), + cache_key: monospace_text_cache_key(&text, font_size, advance, color), lines: vec![line], glyphs, size, @@ -466,7 +473,10 @@ impl PreparedText { if range.is_empty() { return; } - for glyph in Arc::make_mut(&mut self.layout).glyphs.iter_mut() { + let layout = Arc::make_mut(&mut self.layout); + layout.cache_key = + selected_text_cache_key(layout.cache_key, range.start, range.end, selected_color); + for glyph in layout.glyphs.iter_mut() { if glyph.text_end > range.start && glyph.text_start < range.end { glyph.color = selected_color; } @@ -583,6 +593,28 @@ impl PreparedText { } } +fn monospace_text_cache_key(text: &str, font_size: f32, advance: f32, color: Color) -> u64 { + use std::hash::{DefaultHasher, Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + font_size.to_bits().hash(&mut hasher); + advance.to_bits().hash(&mut hasher); + color.hash(&mut hasher); + hasher.finish() +} + +fn selected_text_cache_key(base: u64, start: usize, end: usize, color: Color) -> u64 { + use std::hash::{DefaultHasher, Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + base.hash(&mut hasher); + start.hash(&mut hasher); + end.hash(&mut hasher); + color.hash(&mut hasher); + hasher.finish() +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum WordClass { Word, @@ -886,6 +918,8 @@ mod tests { } let orig_size = text.layout.size; text.layout = Arc::new(TextLayoutData { + text: text.text.clone(), + cache_key: text.layout.cache_key, lines, glyphs, size: orig_size, diff --git a/lib/ui/src/text.rs b/lib/ui/src/text.rs index 55bd11b..180ff2a 100644 --- a/lib/ui/src/text.rs +++ b/lib/ui/src/text.rs @@ -269,7 +269,7 @@ pub struct TextSystem { } #[derive(Clone, Copy, Debug, Default)] -pub(crate) struct TextFrameStats { +pub struct TextFrameStats { pub requests: u32, pub cache_hits: u32, pub cache_misses: u32, @@ -341,7 +341,6 @@ impl TextSystem { bounds: Option, ) -> PreparedText { let bounds = bounds.or(style.bounds); - let text = combined_text(spans); // Use the cached Arc directly — glyphs are in local (origin-0) coords. let layout_data = self.layout( spans, @@ -352,7 +351,6 @@ impl TextSystem { PreparedText::from_layout( None, - text, origin, bounds, style.font_size, @@ -476,7 +474,8 @@ impl TextSystem { } self.frame_stats.buffer_build_ms += buffer_build_started.elapsed().as_secs_f64() * 1_000.0; - let line_starts = line_start_offsets(&combined_text(spans)); + let text = combined_text(spans); + let line_starts = line_start_offsets(&text); let mut measured_width: f32 = 0.0; let mut measured_height: f32 = 0.0; let mut lines = Vec::new(); @@ -527,6 +526,8 @@ impl TextSystem { glyph_collect_started.elapsed().as_secs_f64() * 1_000.0; let layout = Arc::new(TextLayoutData { + text: Arc::from(text), + cache_key, lines, glyphs, size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)), diff --git a/lib/ui/src/tree.rs b/lib/ui/src/tree.rs index 2848a44..335ea95 100644 --- a/lib/ui/src/tree.rs +++ b/lib/ui/src/tree.rs @@ -244,6 +244,7 @@ enum ElementContent { pub(crate) struct TextNode { pub spans: Vec, pub style: TextStyle, + content_hash: u64, } #[derive(Clone, Debug, PartialEq)] @@ -281,12 +282,14 @@ impl Element { } pub fn spans(spans: impl IntoIterator, style: TextStyle) -> Self { + let spans = spans.into_iter().collect::>(); Self { id: None, style: Style::default(), children: Vec::new(), content: ElementContent::Text(TextNode { - spans: spans.into_iter().collect(), + content_hash: text_node_content_hash(&spans, &style), + spans, style, }), } @@ -599,8 +602,7 @@ impl Hash for Style { impl Hash for TextNode { fn hash(&self, state: &mut H) { - self.spans.hash(state); - self.style.hash(state); + self.content_hash.hash(state); } } @@ -611,6 +613,15 @@ impl Hash for ImageNode { } } +fn text_node_content_hash(spans: &[TextSpan], style: &TextStyle) -> u64 { + use std::hash::DefaultHasher; + + let mut hasher = DefaultHasher::new(); + spans.hash(&mut hasher); + style.hash(&mut hasher); + hasher.finish() +} + impl Hash for ScrollBoxNode { fn hash(&self, state: &mut H) { self.offset_y.to_bits().hash(state); diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index f4e171e..3903ddd 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -169,22 +169,12 @@ struct UploadedAtlasText { #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct TextTextureKey { - text: String, + layout_key: u64, bounds: Option<(i32, i32)>, clip: Option<(i32, i32, i32, i32)>, font_size_bits: u32, line_height_bits: u32, color: (u8, u8, u8, u8), - glyphs: Vec, -} - -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -struct TextTextureGlyph { - local_x_bits: u32, - local_y_bits: u32, - advance_bits: u32, - color: (u8, u8, u8, u8), - cache_key: Option, } const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 11] = [ @@ -315,6 +305,21 @@ pub enum RenderError { Validation, } +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RendererFrameStats { + pub scene_version: u64, + pub scene_items: usize, + pub quad_vertices: usize, + pub image_batches: usize, + pub atlas_text_vertices: u32, + pub fallback_text_batches: usize, + pub fallback_text_vertices: u32, + pub atlas_glyphs_total: u32, + pub atlas_glyphs_clip_culled: u32, + pub text_prepare_ms: f64, + pub render_ms: f64, +} + pub struct WgpuSceneRenderer { surface: wgpu::Surface<'static>, device: wgpu::Device, @@ -334,6 +339,7 @@ pub struct WgpuSceneRenderer { glyph_atlas: GlyphAtlas, scale_factor: f32, base_color: Color, + last_frame_stats: RendererFrameStats, } const MAX_TEXT_CACHE_ENTRIES: usize = 64; @@ -729,6 +735,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { glyph_atlas, scale_factor: 1.0, base_color, + last_frame_stats: RendererFrameStats::default(), }) } @@ -758,6 +765,10 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { ); } + pub fn last_frame_stats(&self) -> RendererFrameStats { + self.last_frame_stats + } + pub fn render(&mut self, scene: &SceneSnapshot) -> Result<(), RenderError> { self.render_with_logical_size(scene, scene.logical_size) } @@ -885,19 +896,34 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { } self.queue.submit([encoder.finish()]); frame.present(); - trace!( - target: "ruin_ui_renderer_wgpu::perf", - scene_version = scene.version, - quad_vertices = vertices.len(), - image_batches = uploaded_images.len(), - atlas_text_vertices = uploaded_atlas_text + self.last_frame_stats = RendererFrameStats { + scene_version: scene.version, + scene_items: scene.items.len(), + quad_vertices: vertices.len(), + image_batches: uploaded_images.len(), + atlas_text_vertices: uploaded_atlas_text .as_ref() .map_or(0_u32, |text| text.vertex_count), - fallback_text_batches = uploaded_texts.len(), - atlas_glyphs_total = atlas_perf.glyphs_total, - atlas_glyphs_clip_culled = atlas_perf.glyphs_clip_culled, - text_prepare_ms = text_prepare_ms, - render_ms = render_start.elapsed().as_secs_f64() * 1_000.0, + fallback_text_batches: uploaded_texts.len(), + fallback_text_vertices: uploaded_texts.iter().map(|text| text.vertex_count).sum(), + atlas_glyphs_total: atlas_perf.glyphs_total, + atlas_glyphs_clip_culled: atlas_perf.glyphs_clip_culled, + text_prepare_ms, + render_ms: render_start.elapsed().as_secs_f64() * 1_000.0, + }; + trace!( + target: "ruin_ui_renderer_wgpu::perf", + scene_version = self.last_frame_stats.scene_version, + scene_items = self.last_frame_stats.scene_items, + quad_vertices = self.last_frame_stats.quad_vertices, + image_batches = self.last_frame_stats.image_batches, + atlas_text_vertices = self.last_frame_stats.atlas_text_vertices, + fallback_text_batches = self.last_frame_stats.fallback_text_batches, + fallback_text_vertices = self.last_frame_stats.fallback_text_vertices, + atlas_glyphs_total = self.last_frame_stats.atlas_glyphs_total, + atlas_glyphs_clip_culled = self.last_frame_stats.atlas_glyphs_clip_culled, + text_prepare_ms = self.last_frame_stats.text_prepare_ms, + render_ms = self.last_frame_stats.render_ms, "rendered scene" ); Ok(()) @@ -908,10 +934,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { text: &PreparedText, raster_clip: Option, ) -> Option { - if (self.scale_factor - 1.0).abs() <= f32::EPSILON - && text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) - { - return self.rasterize_prepared_text(text, raster_clip); + if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) { + return self.rasterize_prepared_text(text, raster_clip, self.scale_factor); } let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(clip, self.scale_factor)); @@ -953,16 +977,18 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { &mut self, text: &PreparedText, raster_clip: Option, + scale_factor: f32, ) -> Option { - let glyphs = self.collect_prepared_glyphs(text); + let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(clip, scale_factor)); + let glyphs = self.collect_prepared_glyphs(text, raster_clip, clip, scale_factor); let clip = raster_clip - .map(rect_to_pixel_rect) + .map(|clip| scale_rect_to_pixel_rect(clip, scale_factor)) .or_else(|| { text.bounds.map(|bounds| PixelRect { left: 0, top: 0, - right: bounds.width.ceil() as i32, - bottom: bounds.height.ceil() as i32, + right: (bounds.width * scale_factor).ceil() as i32, + bottom: (bounds.height * scale_factor).ceil() as i32, }) }) .or_else(|| glyph_union_prepared(&glyphs))?; @@ -983,17 +1009,41 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { } Some(RasterizedText { - origin_offset: Point::new(clip.left as f32, clip.top as f32), - draw_size: UiSize::new(clip.width() as f32, clip.height() as f32), + origin_offset: Point::new( + clip.left as f32 / scale_factor, + clip.top as f32 / scale_factor, + ), + draw_size: UiSize::new( + clip.width() as f32 / scale_factor, + clip.height() as f32 / scale_factor, + ), texture_size: UiSize::new(clip.width() as f32, clip.height() as f32), pixels, }) } - fn collect_prepared_glyphs(&mut self, text: &PreparedText) -> Vec { - let mut glyphs = Vec::with_capacity(text.glyphs.len()); - for glyph in &text.glyphs { - let Some(cache_key) = glyph.cache_key else { + fn collect_prepared_glyphs( + &mut self, + text: &PreparedText, + logical_clip: Option, + pixel_clip: Option, + scale_factor: f32, + ) -> Vec { + let absolute_clip = logical_clip.map(|clip| { + Rect::new( + text.origin.x + clip.origin.x, + text.origin.y + clip.origin.y, + clip.size.width, + clip.size.height, + ) + }); + let visible_glyphs = clip_visible_glyphs(text, absolute_clip); + let mut glyphs = Vec::with_capacity(visible_glyphs.len()); + for glyph in visible_glyphs { + let Some(cache_key) = glyph + .cache_key + .map(|cache_key| scale_glyph_cache_key(cache_key, scale_factor)) + else { continue; }; let Some(image) = self @@ -1010,15 +1060,19 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { } // Glyph positions are LOCAL (origin-relative); no subtraction needed. - let local_x = glyph.position.x.round() as i32; - let local_y = glyph.position.y.round() as i32; + let local_x = (glyph.position.x * scale_factor).round() as i32; + let local_y = (glyph.position.y * scale_factor).round() as i32; + let rect = PixelRect { + left: local_x + image.placement.left, + top: local_y - image.placement.top, + right: local_x + image.placement.left + width, + bottom: local_y - image.placement.top + height, + }; + if pixel_clip.is_some_and(|clip| !pixel_rects_intersect(rect, clip)) { + continue; + } glyphs.push(PreparedGlyphBitmap { - rect: PixelRect { - left: local_x + image.placement.left, - top: local_y - image.placement.top, - right: local_x + image.placement.left + width, - bottom: local_y - image.placement.top + height, - }, + rect, cache_key, color: resolve_glyph_color(glyph.color, text.default_color), }); @@ -1046,6 +1100,13 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { let mut glyphs = Vec::new(); for run in buffer.layout_runs() { + if let Some(clip) = raster_clip { + let line_top = run.line_top.floor() as i32; + let line_bottom = (run.line_top + run.line_height).ceil() as i32; + if line_bottom < clip.top || line_top > clip.bottom { + continue; + } + } for glyph in run.glyphs { let physical = glyph.physical((0.0, run.line_y), 1.0); let Some(image) = self @@ -2246,15 +2307,6 @@ fn expand_rect(rect: Rect, inset: f32) -> Rect { ) } -fn rect_to_pixel_rect(rect: Rect) -> PixelRect { - PixelRect { - left: rect.origin.x.floor() as i32, - top: rect.origin.y.floor() as i32, - right: (rect.origin.x + rect.size.width).ceil() as i32, - bottom: (rect.origin.y + rect.size.height).ceil() as i32, - } -} - fn scale_rect_to_pixel_rect(rect: Rect, scale: f32) -> PixelRect { PixelRect { left: (rect.origin.x * scale).floor() as i32, @@ -2464,7 +2516,7 @@ fn clip_visible_glyphs(text: &PreparedText, clip: Option) -> &[GlyphInstan let local_bottom = local_top + clip.size.height; // First line whose bottom edge reaches or passes the clip top. - let start = lines.partition_point(|l| l.rect.origin.y + l.rect.size.height < local_top); + let start = lines.partition_point(|l| l.rect.origin.y + l.rect.size.height <= local_top); // First line whose top edge is strictly past the clip bottom. let end = lines.partition_point(|l| l.rect.origin.y <= local_bottom); @@ -2483,7 +2535,7 @@ fn text_texture_key( scale_factor: f32, ) -> TextTextureKey { TextTextureKey { - text: text.text.clone(), + layout_key: text.layout_cache_key(), bounds: text.bounds.map(|bounds| { ( (bounds.width * scale_factor).ceil() as i32, @@ -2500,18 +2552,6 @@ fn text_texture_key( text.default_color.b, text.default_color.a, ), - glyphs: text - .glyphs - .iter() - .map(|glyph| TextTextureGlyph { - // Glyph positions are already LOCAL; no subtraction needed. - local_x_bits: glyph.position.x.to_bits(), - local_y_bits: glyph.position.y.to_bits(), - advance_bits: glyph.advance.to_bits(), - color: (glyph.color.r, glyph.color.g, glyph.color.b, glyph.color.a), - cache_key: glyph.cache_key, - }) - .collect(), } } @@ -2531,11 +2571,14 @@ fn scale_glyph_cache_key(cache_key: CacheKey, scale_factor: f32) -> CacheKey { mod tests { use super::{ ActiveClip, AtlasRect, GlyphVertexContext, PixelRect, blend_rgba, build_text_vertices, - build_vertices, clip_rect_array, push_clip_state, push_glyph_vertices, rounded_clip_arrays, - scale_glyph_cache_key, text_texture_key, + build_vertices, clip_rect_array, clip_visible_glyphs, push_clip_state, push_glyph_vertices, + rounded_clip_arrays, scale_glyph_cache_key, text_texture_key, }; use cosmic_text::{CacheKey, CacheKeyFlags, SubpixelBin, fontdb}; - use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize}; + use ruin_ui::{ + ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, TextStyle, TextSystem, + TextWrap, UiSize, + }; #[test] fn quad_scenes_expand_to_six_vertices_per_quad() { @@ -2613,6 +2656,20 @@ mod tests { assert_ne!(first, different_pixel); } + #[test] + fn clip_visible_glyphs_returns_only_lines_intersecting_clip() { + let mut text_system = TextSystem::new(); + let style = TextStyle::new(16.0, Color::rgb(0xEE, 0xEE, 0xEE)) + .with_wrap(TextWrap::None) + .with_line_height(20.0); + let text = text_system.prepare("abcd\nefgh", Point::new(10.0, 20.0), &style); + let visible = clip_visible_glyphs(&text, Some(Rect::new(10.0, 40.0, 100.0, 20.0))); + + assert_eq!(visible.len(), 4); + assert_eq!(visible[0].text_start, 5); + assert_eq!(visible[3].text_end, 9); + } + #[test] fn scale_glyph_cache_key_scales_font_size_only() { let key = CacheKey {