Perf profiling and some low hanging perf improvements in text rendering
This commit is contained in:
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
94
docs/performance-profiling.md
Normal file
94
docs/performance-profiling.md
Normal file
@@ -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.
|
||||
8
examples/perf_scenarios/Cargo.toml
Normal file
8
examples/perf_scenarios/Cargo.toml
Normal file
@@ -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" }
|
||||
366
examples/perf_scenarios/src/main.rs
Normal file
366
examples/perf_scenarios/src/main.rs
Normal file
@@ -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<PathBuf>,
|
||||
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> {
|
||||
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<Item = String>) -> io::Result<Self> {
|
||||
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::<u64>().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::<usize>().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 <name> [--frames <n>] [--output <path>] [--text-kb <n>]"
|
||||
);
|
||||
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<const N: usize>(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
|
||||
}
|
||||
8
lib/perf/Cargo.toml
Normal file
8
lib/perf/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "ruin_perf"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
226
lib/perf/src/lib.rs
Normal file
226
lib/perf/src/lib.rs
Normal file
@@ -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<String>, 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<f64>,
|
||||
}
|
||||
|
||||
impl SampleSeries {
|
||||
pub fn new(name: impl Into<String>, unit: impl Into<String>) -> 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::<f64>();
|
||||
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<SampleSummary>,
|
||||
pub counters: BTreeMap<String, f64>,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PerfReportBuilder {
|
||||
metadata: RunMetadata,
|
||||
series: BTreeMap<String, SampleSeries>,
|
||||
counters: BTreeMap<String, f64>,
|
||||
notes: Vec<String>,
|
||||
}
|
||||
|
||||
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<String>, value: f64) {
|
||||
self.record(name, "ms", value);
|
||||
}
|
||||
|
||||
pub fn record(&mut self, name: impl Into<String>, unit: impl Into<String>, 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<String>, value: impl Into<f64>) {
|
||||
*self.counters.entry(name.into()).or_insert(0.0) += value.into();
|
||||
}
|
||||
|
||||
pub fn set_counter(&mut self, name: impl Into<String>, value: impl Into<f64>) {
|
||||
self.counters.insert(name.into(), value.into());
|
||||
}
|
||||
|
||||
pub fn note(&mut self, note: impl Into<String>) {
|
||||
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<Path>) -> 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)]
|
||||
}
|
||||
@@ -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<u32>);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
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()
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -189,7 +189,7 @@ pub struct PreparedTextLine {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PreparedText {
|
||||
pub element_id: Option<ElementId>,
|
||||
pub text: String,
|
||||
pub text: Arc<str>,
|
||||
pub origin: Point,
|
||||
pub bounds: Option<UiSize>,
|
||||
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<str>,
|
||||
pub cache_key: u64,
|
||||
pub lines: Vec<PreparedTextLine>,
|
||||
pub glyphs: Vec<GlyphInstance>,
|
||||
/// 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<ElementId>,
|
||||
text: String,
|
||||
origin: Point,
|
||||
bounds: Option<UiSize>,
|
||||
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<str> = 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,
|
||||
|
||||
@@ -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<UiSize>,
|
||||
) -> 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)),
|
||||
|
||||
@@ -244,6 +244,7 @@ enum ElementContent {
|
||||
pub(crate) struct TextNode {
|
||||
pub spans: Vec<TextSpan>,
|
||||
pub style: TextStyle,
|
||||
content_hash: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -281,12 +282,14 @@ impl Element {
|
||||
}
|
||||
|
||||
pub fn spans(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self {
|
||||
let spans = spans.into_iter().collect::<Vec<_>>();
|
||||
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<H: Hasher>(&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<H: Hasher>(&self, state: &mut H) {
|
||||
self.offset_y.to_bits().hash(state);
|
||||
|
||||
@@ -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<TextTextureGlyph>,
|
||||
}
|
||||
|
||||
#[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<CacheKey>,
|
||||
}
|
||||
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
);
|
||||
}
|
||||
|
||||
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<f32> {
|
||||
}
|
||||
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<f32> {
|
||||
text: &PreparedText,
|
||||
raster_clip: Option<Rect>,
|
||||
) -> Option<RasterizedText> {
|
||||
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<f32> {
|
||||
&mut self,
|
||||
text: &PreparedText,
|
||||
raster_clip: Option<Rect>,
|
||||
scale_factor: f32,
|
||||
) -> Option<RasterizedText> {
|
||||
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<f32> {
|
||||
}
|
||||
|
||||
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<PreparedGlyphBitmap> {
|
||||
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<Rect>,
|
||||
pixel_clip: Option<PixelRect>,
|
||||
scale_factor: f32,
|
||||
) -> Vec<PreparedGlyphBitmap> {
|
||||
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<f32> {
|
||||
}
|
||||
|
||||
// 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;
|
||||
glyphs.push(PreparedGlyphBitmap {
|
||||
rect: PixelRect {
|
||||
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,
|
||||
cache_key,
|
||||
color: resolve_glyph_color(glyph.color, text.default_color),
|
||||
});
|
||||
@@ -1046,6 +1100,13 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
|
||||
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<Rect>) -> &[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 {
|
||||
|
||||
Reference in New Issue
Block a user