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",
|
"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]]
|
[[package]]
|
||||||
name = "ruin_reactivity"
|
name = "ruin_reactivity"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ default-members = [
|
|||||||
"lib/ruin_app_proc_macros",
|
"lib/ruin_app_proc_macros",
|
||||||
"lib/runtime",
|
"lib/runtime",
|
||||||
"lib/runtime_proc_macros",
|
"lib/runtime_proc_macros",
|
||||||
|
"lib/perf",
|
||||||
"lib/ui",
|
"lib/ui",
|
||||||
"lib/ui_platform_macos",
|
"lib/ui_platform_macos",
|
||||||
"lib/ui_platform_wayland",
|
"lib/ui_platform_wayland",
|
||||||
"lib/ui_renderer_wgpu",
|
"lib/ui_renderer_wgpu",
|
||||||
"examples/calculator",
|
"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,
|
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)]
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
pub struct LayoutPath(Vec<u32>);
|
pub struct LayoutPath(Vec<u32>);
|
||||||
|
|
||||||
@@ -194,6 +223,16 @@ pub fn layout_snapshot_with_cache(
|
|||||||
text_system: &mut TextSystem,
|
text_system: &mut TextSystem,
|
||||||
layout_cache: &mut LayoutCache,
|
layout_cache: &mut LayoutCache,
|
||||||
) -> LayoutSnapshot {
|
) -> 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 layout_started = Instant::now();
|
||||||
let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG);
|
let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG);
|
||||||
let mut perf_stats = LayoutPerfStats::new(perf_enabled);
|
let mut perf_stats = LayoutPerfStats::new(perf_enabled);
|
||||||
@@ -215,48 +254,78 @@ pub fn layout_snapshot_with_cache(
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let text_stats = text_system.take_frame_stats();
|
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 {
|
if perf_stats.enabled {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: "ruin_ui::layout_perf",
|
target: "ruin_ui::layout_perf",
|
||||||
scene_version = version,
|
scene_version = version,
|
||||||
width = logical_size.width,
|
width = logical_size.width,
|
||||||
height = logical_size.height,
|
height = logical_size.height,
|
||||||
total_ms = layout_started.elapsed().as_secs_f64() * 1_000.0,
|
total_ms = stats.total_ms,
|
||||||
nodes = perf_stats.nodes,
|
nodes = stats.nodes,
|
||||||
text_nodes = perf_stats.text_nodes,
|
text_nodes = stats.text_nodes,
|
||||||
container_nodes = perf_stats.container_nodes,
|
container_nodes = stats.container_nodes,
|
||||||
background_quads = perf_stats.background_quads,
|
background_quads = stats.background_quads,
|
||||||
intrinsic_calls = perf_stats.intrinsic_calls,
|
intrinsic_calls = stats.intrinsic_calls,
|
||||||
intrinsic_size_calls = perf_stats.intrinsic_size_calls,
|
intrinsic_size_calls = stats.intrinsic_size_calls,
|
||||||
intrinsic_text_calls = perf_stats.intrinsic_text_calls,
|
intrinsic_text_calls = stats.intrinsic_text_calls,
|
||||||
intrinsic_container_calls = perf_stats.intrinsic_container_calls,
|
intrinsic_container_calls = stats.intrinsic_container_calls,
|
||||||
intrinsic_ms = perf_stats.intrinsic_ms,
|
intrinsic_ms = stats.intrinsic_ms,
|
||||||
text_prepare_calls = perf_stats.text_prepare_calls,
|
text_prepare_calls = stats.text_prepare_calls,
|
||||||
text_prepare_ms = perf_stats.text_prepare_ms,
|
text_prepare_ms = stats.text_prepare_ms,
|
||||||
viewport_culled = perf_stats.viewport_culled,
|
viewport_culled = stats.viewport_culled,
|
||||||
layout_cache_hits = perf_stats.layout_cache_hits,
|
layout_cache_hits = stats.layout_cache_hits,
|
||||||
layout_cache_misses = perf_stats.layout_cache_misses,
|
layout_cache_misses = stats.layout_cache_misses,
|
||||||
intrinsic_cache_hits = perf_stats.intrinsic_cache_hits,
|
intrinsic_cache_hits = stats.intrinsic_cache_hits,
|
||||||
text_requests = text_stats.requests,
|
text_requests = stats.text_requests,
|
||||||
text_cache_hits = text_stats.cache_hits,
|
text_cache_hits = stats.text_cache_hits,
|
||||||
text_cache_misses = text_stats.cache_misses,
|
text_cache_misses = stats.text_cache_misses,
|
||||||
text_output_glyphs = text_stats.output_glyphs,
|
text_output_glyphs = stats.text_output_glyphs,
|
||||||
text_family_resolve_ms = text_stats.family_resolve_ms,
|
text_family_resolve_ms = stats.text_family_resolve_ms,
|
||||||
text_buffer_build_ms = text_stats.buffer_build_ms,
|
text_buffer_build_ms = stats.text_buffer_build_ms,
|
||||||
text_glyph_collect_ms = text_stats.glyph_collect_ms,
|
text_glyph_collect_ms = stats.text_glyph_collect_ms,
|
||||||
text_miss_ms = text_stats.miss_ms,
|
text_miss_ms = stats.text_miss_ms,
|
||||||
scene_items = scene.items.len(),
|
scene_items = stats.scene_items,
|
||||||
"layout snapshot perf"
|
"layout snapshot perf"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let root_min_size = element_min_size(root);
|
let root_min_size = element_min_size(root);
|
||||||
LayoutSnapshot {
|
(
|
||||||
scene,
|
LayoutSnapshot {
|
||||||
interaction_tree: InteractionTree {
|
scene,
|
||||||
root: interaction_root,
|
interaction_tree: InteractionTree {
|
||||||
|
root: interaction_root,
|
||||||
|
},
|
||||||
|
root_min_size,
|
||||||
},
|
},
|
||||||
root_min_size,
|
stats,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -2903,7 +2972,7 @@ mod tests {
|
|||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| match item {
|
.filter_map(|item| match item {
|
||||||
DisplayItem::Text(text) => Some(text.text.as_str()),
|
DisplayItem::Text(text) => Some(text.text.as_ref()),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ pub use interaction::{
|
|||||||
};
|
};
|
||||||
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
|
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
|
||||||
pub use layout::{
|
pub use layout::{
|
||||||
HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
|
HitTarget, InteractionTree, LayoutCache, LayoutFrameStats, LayoutNode, LayoutPath,
|
||||||
TextHitTarget, element_min_size, layout_snapshot, layout_snapshot_with_cache,
|
LayoutSnapshot, ScrollMetrics, TextHitTarget, element_min_size, layout_snapshot,
|
||||||
|
layout_snapshot_with_cache, layout_snapshot_with_cache_and_stats,
|
||||||
layout_snapshot_with_text_system,
|
layout_snapshot_with_text_system,
|
||||||
};
|
};
|
||||||
pub use layout::{layout_scene, layout_scene_with_text_system};
|
pub use layout::{layout_scene, layout_scene_with_text_system};
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ pub struct PreparedTextLine {
|
|||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct PreparedText {
|
pub struct PreparedText {
|
||||||
pub element_id: Option<ElementId>,
|
pub element_id: Option<ElementId>,
|
||||||
pub text: String,
|
pub text: Arc<str>,
|
||||||
pub origin: Point,
|
pub origin: Point,
|
||||||
pub bounds: Option<UiSize>,
|
pub bounds: Option<UiSize>,
|
||||||
pub font_size: f32,
|
pub font_size: f32,
|
||||||
@@ -206,6 +206,8 @@ pub struct PreparedText {
|
|||||||
/// Add `origin` to convert to absolute window coords.
|
/// Add `origin` to convert to absolute window coords.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct TextLayoutData {
|
pub struct TextLayoutData {
|
||||||
|
pub text: Arc<str>,
|
||||||
|
pub cache_key: u64,
|
||||||
pub lines: Vec<PreparedTextLine>,
|
pub lines: Vec<PreparedTextLine>,
|
||||||
pub glyphs: Vec<GlyphInstance>,
|
pub glyphs: Vec<GlyphInstance>,
|
||||||
/// Measured (unclamped) size of the laid-out text.
|
/// Measured (unclamped) size of the laid-out text.
|
||||||
@@ -225,7 +227,6 @@ impl PreparedText {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn from_layout(
|
pub(crate) fn from_layout(
|
||||||
element_id: Option<ElementId>,
|
element_id: Option<ElementId>,
|
||||||
text: String,
|
|
||||||
origin: Point,
|
origin: Point,
|
||||||
bounds: Option<UiSize>,
|
bounds: Option<UiSize>,
|
||||||
font_size: f32,
|
font_size: f32,
|
||||||
@@ -237,7 +238,7 @@ impl PreparedText {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
element_id,
|
element_id,
|
||||||
text,
|
text: layout.text.clone(),
|
||||||
origin,
|
origin,
|
||||||
bounds,
|
bounds,
|
||||||
font_size,
|
font_size,
|
||||||
@@ -256,6 +257,10 @@ impl PreparedText {
|
|||||||
Arc::as_ptr(&self.layout)
|
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.
|
/// Create a monospace `PreparedText` without going through the text system.
|
||||||
/// Used for testing and low-level terminal-style rendering.
|
/// Used for testing and low-level terminal-style rendering.
|
||||||
pub fn monospace(
|
pub fn monospace(
|
||||||
@@ -265,7 +270,7 @@ impl PreparedText {
|
|||||||
advance: f32,
|
advance: f32,
|
||||||
color: Color,
|
color: Color,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let text = text.into();
|
let text: Arc<str> = Arc::from(text.into());
|
||||||
// Glyphs are stored in LOCAL (origin-relative) coordinates.
|
// Glyphs are stored in LOCAL (origin-relative) coordinates.
|
||||||
let mut local_x = 0.0f32;
|
let mut local_x = 0.0f32;
|
||||||
let mut glyphs = Vec::with_capacity(text.chars().count());
|
let mut glyphs = Vec::with_capacity(text.chars().count());
|
||||||
@@ -293,7 +298,7 @@ impl PreparedText {
|
|||||||
let size = UiSize::new(local_x, font_size);
|
let size = UiSize::new(local_x, font_size);
|
||||||
Self {
|
Self {
|
||||||
element_id: None,
|
element_id: None,
|
||||||
text,
|
text: text.clone(),
|
||||||
origin,
|
origin,
|
||||||
bounds: None,
|
bounds: None,
|
||||||
font_size,
|
font_size,
|
||||||
@@ -302,6 +307,8 @@ impl PreparedText {
|
|||||||
selectable: true,
|
selectable: true,
|
||||||
selection_style: TextSelectionStyle::DEFAULT,
|
selection_style: TextSelectionStyle::DEFAULT,
|
||||||
layout: Arc::new(TextLayoutData {
|
layout: Arc::new(TextLayoutData {
|
||||||
|
text: text.clone(),
|
||||||
|
cache_key: monospace_text_cache_key(&text, font_size, advance, color),
|
||||||
lines: vec![line],
|
lines: vec![line],
|
||||||
glyphs,
|
glyphs,
|
||||||
size,
|
size,
|
||||||
@@ -466,7 +473,10 @@ impl PreparedText {
|
|||||||
if range.is_empty() {
|
if range.is_empty() {
|
||||||
return;
|
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 {
|
if glyph.text_end > range.start && glyph.text_start < range.end {
|
||||||
glyph.color = selected_color;
|
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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum WordClass {
|
enum WordClass {
|
||||||
Word,
|
Word,
|
||||||
@@ -886,6 +918,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let orig_size = text.layout.size;
|
let orig_size = text.layout.size;
|
||||||
text.layout = Arc::new(TextLayoutData {
|
text.layout = Arc::new(TextLayoutData {
|
||||||
|
text: text.text.clone(),
|
||||||
|
cache_key: text.layout.cache_key,
|
||||||
lines,
|
lines,
|
||||||
glyphs,
|
glyphs,
|
||||||
size: orig_size,
|
size: orig_size,
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ pub struct TextSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
pub(crate) struct TextFrameStats {
|
pub struct TextFrameStats {
|
||||||
pub requests: u32,
|
pub requests: u32,
|
||||||
pub cache_hits: u32,
|
pub cache_hits: u32,
|
||||||
pub cache_misses: u32,
|
pub cache_misses: u32,
|
||||||
@@ -341,7 +341,6 @@ impl TextSystem {
|
|||||||
bounds: Option<UiSize>,
|
bounds: Option<UiSize>,
|
||||||
) -> PreparedText {
|
) -> PreparedText {
|
||||||
let bounds = bounds.or(style.bounds);
|
let bounds = bounds.or(style.bounds);
|
||||||
let text = combined_text(spans);
|
|
||||||
// Use the cached Arc directly — glyphs are in local (origin-0) coords.
|
// Use the cached Arc directly — glyphs are in local (origin-0) coords.
|
||||||
let layout_data = self.layout(
|
let layout_data = self.layout(
|
||||||
spans,
|
spans,
|
||||||
@@ -352,7 +351,6 @@ impl TextSystem {
|
|||||||
|
|
||||||
PreparedText::from_layout(
|
PreparedText::from_layout(
|
||||||
None,
|
None,
|
||||||
text,
|
|
||||||
origin,
|
origin,
|
||||||
bounds,
|
bounds,
|
||||||
style.font_size,
|
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;
|
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_width: f32 = 0.0;
|
||||||
let mut measured_height: f32 = 0.0;
|
let mut measured_height: f32 = 0.0;
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
@@ -527,6 +526,8 @@ impl TextSystem {
|
|||||||
glyph_collect_started.elapsed().as_secs_f64() * 1_000.0;
|
glyph_collect_started.elapsed().as_secs_f64() * 1_000.0;
|
||||||
|
|
||||||
let layout = Arc::new(TextLayoutData {
|
let layout = Arc::new(TextLayoutData {
|
||||||
|
text: Arc::from(text),
|
||||||
|
cache_key,
|
||||||
lines,
|
lines,
|
||||||
glyphs,
|
glyphs,
|
||||||
size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)),
|
size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)),
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ enum ElementContent {
|
|||||||
pub(crate) struct TextNode {
|
pub(crate) struct TextNode {
|
||||||
pub spans: Vec<TextSpan>,
|
pub spans: Vec<TextSpan>,
|
||||||
pub style: TextStyle,
|
pub style: TextStyle,
|
||||||
|
content_hash: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
@@ -281,12 +282,14 @@ impl Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn spans(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self {
|
pub fn spans(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self {
|
||||||
|
let spans = spans.into_iter().collect::<Vec<_>>();
|
||||||
Self {
|
Self {
|
||||||
id: None,
|
id: None,
|
||||||
style: Style::default(),
|
style: Style::default(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
content: ElementContent::Text(TextNode {
|
content: ElementContent::Text(TextNode {
|
||||||
spans: spans.into_iter().collect(),
|
content_hash: text_node_content_hash(&spans, &style),
|
||||||
|
spans,
|
||||||
style,
|
style,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -599,8 +602,7 @@ impl Hash for Style {
|
|||||||
|
|
||||||
impl Hash for TextNode {
|
impl Hash for TextNode {
|
||||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
self.spans.hash(state);
|
self.content_hash.hash(state);
|
||||||
self.style.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 {
|
impl Hash for ScrollBoxNode {
|
||||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
self.offset_y.to_bits().hash(state);
|
self.offset_y.to_bits().hash(state);
|
||||||
|
|||||||
@@ -169,22 +169,12 @@ struct UploadedAtlasText {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
struct TextTextureKey {
|
struct TextTextureKey {
|
||||||
text: String,
|
layout_key: u64,
|
||||||
bounds: Option<(i32, i32)>,
|
bounds: Option<(i32, i32)>,
|
||||||
clip: Option<(i32, i32, i32, i32)>,
|
clip: Option<(i32, i32, i32, i32)>,
|
||||||
font_size_bits: u32,
|
font_size_bits: u32,
|
||||||
line_height_bits: u32,
|
line_height_bits: u32,
|
||||||
color: (u8, u8, u8, u8),
|
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] = [
|
const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 11] = [
|
||||||
@@ -315,6 +305,21 @@ pub enum RenderError {
|
|||||||
Validation,
|
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 {
|
pub struct WgpuSceneRenderer {
|
||||||
surface: wgpu::Surface<'static>,
|
surface: wgpu::Surface<'static>,
|
||||||
device: wgpu::Device,
|
device: wgpu::Device,
|
||||||
@@ -334,6 +339,7 @@ pub struct WgpuSceneRenderer {
|
|||||||
glyph_atlas: GlyphAtlas,
|
glyph_atlas: GlyphAtlas,
|
||||||
scale_factor: f32,
|
scale_factor: f32,
|
||||||
base_color: Color,
|
base_color: Color,
|
||||||
|
last_frame_stats: RendererFrameStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_TEXT_CACHE_ENTRIES: usize = 64;
|
const MAX_TEXT_CACHE_ENTRIES: usize = 64;
|
||||||
@@ -729,6 +735,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
glyph_atlas,
|
glyph_atlas,
|
||||||
scale_factor: 1.0,
|
scale_factor: 1.0,
|
||||||
base_color,
|
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> {
|
pub fn render(&mut self, scene: &SceneSnapshot) -> Result<(), RenderError> {
|
||||||
self.render_with_logical_size(scene, scene.logical_size)
|
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()]);
|
self.queue.submit([encoder.finish()]);
|
||||||
frame.present();
|
frame.present();
|
||||||
trace!(
|
self.last_frame_stats = RendererFrameStats {
|
||||||
target: "ruin_ui_renderer_wgpu::perf",
|
scene_version: scene.version,
|
||||||
scene_version = scene.version,
|
scene_items: scene.items.len(),
|
||||||
quad_vertices = vertices.len(),
|
quad_vertices: vertices.len(),
|
||||||
image_batches = uploaded_images.len(),
|
image_batches: uploaded_images.len(),
|
||||||
atlas_text_vertices = uploaded_atlas_text
|
atlas_text_vertices: uploaded_atlas_text
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(0_u32, |text| text.vertex_count),
|
.map_or(0_u32, |text| text.vertex_count),
|
||||||
fallback_text_batches = uploaded_texts.len(),
|
fallback_text_batches: uploaded_texts.len(),
|
||||||
atlas_glyphs_total = atlas_perf.glyphs_total,
|
fallback_text_vertices: uploaded_texts.iter().map(|text| text.vertex_count).sum(),
|
||||||
atlas_glyphs_clip_culled = atlas_perf.glyphs_clip_culled,
|
atlas_glyphs_total: atlas_perf.glyphs_total,
|
||||||
text_prepare_ms = text_prepare_ms,
|
atlas_glyphs_clip_culled: atlas_perf.glyphs_clip_culled,
|
||||||
render_ms = render_start.elapsed().as_secs_f64() * 1_000.0,
|
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"
|
"rendered scene"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -908,10 +934,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
text: &PreparedText,
|
text: &PreparedText,
|
||||||
raster_clip: Option<Rect>,
|
raster_clip: Option<Rect>,
|
||||||
) -> Option<RasterizedText> {
|
) -> Option<RasterizedText> {
|
||||||
if (self.scale_factor - 1.0).abs() <= f32::EPSILON
|
if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) {
|
||||||
&& text.glyphs.iter().all(|glyph| glyph.cache_key.is_some())
|
return self.rasterize_prepared_text(text, raster_clip, self.scale_factor);
|
||||||
{
|
|
||||||
return self.rasterize_prepared_text(text, raster_clip);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let clip = raster_clip.map(|clip| scale_rect_to_pixel_rect(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,
|
&mut self,
|
||||||
text: &PreparedText,
|
text: &PreparedText,
|
||||||
raster_clip: Option<Rect>,
|
raster_clip: Option<Rect>,
|
||||||
|
scale_factor: f32,
|
||||||
) -> Option<RasterizedText> {
|
) -> 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
|
let clip = raster_clip
|
||||||
.map(rect_to_pixel_rect)
|
.map(|clip| scale_rect_to_pixel_rect(clip, scale_factor))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
text.bounds.map(|bounds| PixelRect {
|
text.bounds.map(|bounds| PixelRect {
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
right: bounds.width.ceil() as i32,
|
right: (bounds.width * scale_factor).ceil() as i32,
|
||||||
bottom: bounds.height.ceil() as i32,
|
bottom: (bounds.height * scale_factor).ceil() as i32,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.or_else(|| glyph_union_prepared(&glyphs))?;
|
.or_else(|| glyph_union_prepared(&glyphs))?;
|
||||||
@@ -983,17 +1009,41 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Some(RasterizedText {
|
Some(RasterizedText {
|
||||||
origin_offset: Point::new(clip.left as f32, clip.top as f32),
|
origin_offset: Point::new(
|
||||||
draw_size: UiSize::new(clip.width() as f32, clip.height() as f32),
|
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),
|
texture_size: UiSize::new(clip.width() as f32, clip.height() as f32),
|
||||||
pixels,
|
pixels,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_prepared_glyphs(&mut self, text: &PreparedText) -> Vec<PreparedGlyphBitmap> {
|
fn collect_prepared_glyphs(
|
||||||
let mut glyphs = Vec::with_capacity(text.glyphs.len());
|
&mut self,
|
||||||
for glyph in &text.glyphs {
|
text: &PreparedText,
|
||||||
let Some(cache_key) = glyph.cache_key else {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(image) = self
|
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.
|
// Glyph positions are LOCAL (origin-relative); no subtraction needed.
|
||||||
let local_x = glyph.position.x.round() as i32;
|
let local_x = (glyph.position.x * scale_factor).round() as i32;
|
||||||
let local_y = glyph.position.y.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 {
|
glyphs.push(PreparedGlyphBitmap {
|
||||||
rect: PixelRect {
|
rect,
|
||||||
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,
|
|
||||||
},
|
|
||||||
cache_key,
|
cache_key,
|
||||||
color: resolve_glyph_color(glyph.color, text.default_color),
|
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();
|
let mut glyphs = Vec::new();
|
||||||
for run in buffer.layout_runs() {
|
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 {
|
for glyph in run.glyphs {
|
||||||
let physical = glyph.physical((0.0, run.line_y), 1.0);
|
let physical = glyph.physical((0.0, run.line_y), 1.0);
|
||||||
let Some(image) = self
|
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 {
|
fn scale_rect_to_pixel_rect(rect: Rect, scale: f32) -> PixelRect {
|
||||||
PixelRect {
|
PixelRect {
|
||||||
left: (rect.origin.x * scale).floor() as i32,
|
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;
|
let local_bottom = local_top + clip.size.height;
|
||||||
|
|
||||||
// First line whose bottom edge reaches or passes the clip top.
|
// 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.
|
// First line whose top edge is strictly past the clip bottom.
|
||||||
let end = lines.partition_point(|l| l.rect.origin.y <= local_bottom);
|
let end = lines.partition_point(|l| l.rect.origin.y <= local_bottom);
|
||||||
|
|
||||||
@@ -2483,7 +2535,7 @@ fn text_texture_key(
|
|||||||
scale_factor: f32,
|
scale_factor: f32,
|
||||||
) -> TextTextureKey {
|
) -> TextTextureKey {
|
||||||
TextTextureKey {
|
TextTextureKey {
|
||||||
text: text.text.clone(),
|
layout_key: text.layout_cache_key(),
|
||||||
bounds: text.bounds.map(|bounds| {
|
bounds: text.bounds.map(|bounds| {
|
||||||
(
|
(
|
||||||
(bounds.width * scale_factor).ceil() as i32,
|
(bounds.width * scale_factor).ceil() as i32,
|
||||||
@@ -2500,18 +2552,6 @@ fn text_texture_key(
|
|||||||
text.default_color.b,
|
text.default_color.b,
|
||||||
text.default_color.a,
|
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 {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ActiveClip, AtlasRect, GlyphVertexContext, PixelRect, blend_rgba, build_text_vertices,
|
ActiveClip, AtlasRect, GlyphVertexContext, PixelRect, blend_rgba, build_text_vertices,
|
||||||
build_vertices, clip_rect_array, push_clip_state, push_glyph_vertices, rounded_clip_arrays,
|
build_vertices, clip_rect_array, clip_visible_glyphs, push_clip_state, push_glyph_vertices,
|
||||||
scale_glyph_cache_key, text_texture_key,
|
rounded_clip_arrays, scale_glyph_cache_key, text_texture_key,
|
||||||
};
|
};
|
||||||
use cosmic_text::{CacheKey, CacheKeyFlags, SubpixelBin, fontdb};
|
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]
|
#[test]
|
||||||
fn quad_scenes_expand_to_six_vertices_per_quad() {
|
fn quad_scenes_expand_to_six_vertices_per_quad() {
|
||||||
@@ -2613,6 +2656,20 @@ mod tests {
|
|||||||
assert_ne!(first, different_pixel);
|
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]
|
#[test]
|
||||||
fn scale_glyph_cache_key_scales_font_size_only() {
|
fn scale_glyph_cache_key_scales_font_size_only() {
|
||||||
let key = CacheKey {
|
let key = CacheKey {
|
||||||
|
|||||||
Reference in New Issue
Block a user