2 Commits

Author SHA1 Message Date
Will Temple
e5ae067bc2 Perf profiling and some low hanging perf improvements in text rendering 2026-05-16 19:07:44 -04:00
Will Temple
4347232b98 Clippy clean 2026-05-16 16:59:09 -04:00
24 changed files with 1143 additions and 215 deletions

16
Cargo.lock generated
View File

@@ -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"

View File

@@ -1,3 +1,18 @@
[workspace]
resolver = "3"
members = ["lib/*", "examples/*"]
default-members = [
"lib/reactivity",
"lib/reactivity_parsing",
"lib/ruin_app",
"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",
]

View 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.

View File

@@ -177,7 +177,7 @@ mod tests {
assert_eq!(state.expression.get(), "4");
assert_eq!(state.result.get(), "");
assert_eq!(state.is_error.get(), false);
assert!(!state.is_error.get());
assert_eq!(state.history.get().len(), 1);
assert_eq!(state.history.get()[0].expression, "2+2");
assert_eq!(state.history.get()[0].result, "4");

View File

@@ -43,10 +43,10 @@ pub struct CalcState {
impl CalcState {
pub fn new() -> Self {
Self {
expression: use_signal(|| String::new()),
result: use_signal(|| String::new()),
expression: use_signal(String::new),
result: use_signal(String::new),
is_error: use_signal(|| false),
history: use_signal(|| Vec::new()),
history: use_signal(Vec::new),
mode: use_signal(|| CalcMode::Basic),
fresh: use_signal(|| false),
history_scroll: use_signal(|| 10_000.0_f32),

View 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" }

View 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
View 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
View 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)]
}

View File

@@ -66,6 +66,13 @@ struct JsonParser {
next_id: u64,
}
struct ReuseContext<'a, 'count> {
old_text: &'a str,
new_text: &'a str,
delta: &'a EditDelta,
reused: &'count mut usize,
}
struct Cursor<'a> {
text: &'a str,
offset: usize,
@@ -114,7 +121,7 @@ async fn main() {
let parse_state = cell_in(&reactor, initial_snapshot);
let last_delta = cell_in(&reactor, None::<EditDelta>);
let _parser_subscription = {
{
let rope = rope.clone();
let parser = Rc::clone(&parser);
let parse_state = parse_state.clone();
@@ -283,15 +290,18 @@ impl JsonParser {
let mut root_anchor = previous.root_anchor;
root_anchor.map_through(delta);
let mut reused = 0usize;
let mut reuse = ReuseContext {
old_text: &previous.text,
new_text: &text,
delta,
reused: &mut reused,
};
let root = self.reuse_node(
&parsed,
Some(&previous.root),
previous.root_anchor.byte,
root_anchor.byte,
&previous.text,
&text,
delta,
&mut reused,
&mut reuse,
);
Ok(ParseSnapshot {
text,
@@ -322,21 +332,18 @@ impl JsonParser {
old: Option<&AstNode>,
old_parent_start: usize,
new_parent_start: usize,
old_text: &str,
new_text: &str,
delta: &EditDelta,
reused: &mut usize,
reuse: &mut ReuseContext<'_, '_>,
) -> AstNode {
let parsed_local = localize(parsed.range, new_parent_start);
if let Some(old) = old
&& old.kind == parsed.kind
&& map_range(absolute_range(old, old_parent_start), delta) == parsed.range
&& old_text.get(
&& map_range(absolute_range(old, old_parent_start), reuse.delta) == parsed.range
&& reuse.old_text.get(
absolute_range(old, old_parent_start).start
..absolute_range(old, old_parent_start).end,
) == new_text.get(parsed.range.start..parsed.range.end)
) == reuse.new_text.get(parsed.range.start..parsed.range.end)
{
*reused += count_nodes(old);
*reuse.reused += count_nodes(old);
return AstNode {
id: old.id,
kind: old.kind,
@@ -359,10 +366,7 @@ impl JsonParser {
previous_child,
next_old_parent_start,
parsed.range.start,
old_text,
new_text,
delta,
reused,
reuse,
)
})
.collect();

View File

@@ -38,6 +38,8 @@ pub struct ParseError {
pub message: String,
}
pub type ParserResult<C, T, D> = Result<ParseResult<C, T, D>, ParseError>;
impl ParseError {
pub fn new(message: impl Into<String>) -> Self {
Self {
@@ -66,7 +68,7 @@ pub trait IncrementalParser {
cursor: RopeCursor,
fragments: &[SyntaxFragment<Self::Checkpoint, Self::Tree, Self::Diagnostic>],
invalidation: ParseInvalidation<Self::Checkpoint>,
) -> Result<ParseResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>, ParseError>;
) -> ParserResult<Self::Checkpoint, Self::Tree, Self::Diagnostic>;
}
#[cfg(test)]

View File

@@ -1,6 +1,7 @@
use std::cell::{Cell, RefCell};
use std::cmp::min;
use std::collections::BTreeSet;
use std::fmt;
use std::rc::Rc;
use ruin_reactivity::{Event, Reactor, Source, event_in, source_in};
@@ -139,10 +140,6 @@ impl ReactiveRope {
self.len_bytes() == 0
}
pub fn to_string(&self) -> String {
self.slice(TextRange::new(0, self.len_bytes()))
}
pub fn point_at(&self, offset: usize) -> TextPoint {
self.inner.structure.observe();
let offset = offset.min(self.len_bytes());
@@ -416,6 +413,12 @@ impl ReactiveRope {
}
}
impl fmt::Display for ReactiveRope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.slice(TextRange::new(0, self.len_bytes())))
}
}
impl RopeChunk {
pub fn text(&self) -> &str {
&self.text

View File

@@ -323,6 +323,13 @@ pub struct TokenTreeParser {
next_id: u64,
}
struct ReuseContext<'a, 'count> {
old_text: &'a str,
new_text: &'a str,
delta: &'a EditDelta,
reused_nodes: &'count mut usize,
}
impl TokenTreeParser {
pub fn new() -> Self {
Self::default()
@@ -358,15 +365,18 @@ impl TokenTreeParser {
let mut root_anchor = previous.root_anchor;
root_anchor.map_through(delta);
let mut reused_nodes = 0usize;
let mut reuse = ReuseContext {
old_text: &previous.text,
new_text: &text,
delta,
reused_nodes: &mut reused_nodes,
};
let root = self.reuse_node(
&raw.root,
Some(&previous.root),
previous.root_anchor.byte,
root_anchor.byte,
&previous.text,
&text,
delta,
&mut reused_nodes,
&mut reuse,
);
let total_nodes = count_nodes(&root);
@@ -403,16 +413,20 @@ impl TokenTreeParser {
old: Option<&TokenTreeNode>,
old_parent_start: usize,
new_parent_start: usize,
old_text: &str,
new_text: &str,
delta: &EditDelta,
reused_nodes: &mut usize,
reuse: &mut ReuseContext<'_, '_>,
) -> TokenTreeNode {
let parsed_local = localize(parsed.range, new_parent_start);
if let Some(old) = old
.filter(|old| can_reuse_node(old, old_parent_start, parsed, old_text, new_text, delta))
{
*reused_nodes += count_nodes(old);
if let Some(old) = old.filter(|old| {
can_reuse_node(
old,
old_parent_start,
parsed,
reuse.old_text,
reuse.new_text,
reuse.delta,
)
}) {
*reuse.reused_nodes += count_nodes(old);
return TokenTreeNode {
id: old.id,
local_range: parsed_local,
@@ -432,9 +446,8 @@ impl TokenTreeParser {
.children
.get(index)
.filter(|candidate| !used[index] && can_descend_into_node(candidate, child))
.map(|candidate| {
.inspect(|_| {
used[index] = true;
candidate
})
.or_else(|| {
old.children.iter().enumerate().find_map(
@@ -455,10 +468,7 @@ impl TokenTreeParser {
candidate,
absolute_range(old, old_parent_start).start,
parsed.range.start,
old_text,
new_text,
delta,
reused_nodes,
reuse,
)
})
.collect()
@@ -467,16 +477,7 @@ impl TokenTreeParser {
.children
.iter()
.map(|child| {
self.reuse_node(
child,
None,
old_parent_start,
parsed.range.start,
old_text,
new_text,
delta,
reused_nodes,
)
self.reuse_node(child, None, old_parent_start, parsed.range.start, reuse)
})
.collect()
};
@@ -545,13 +546,13 @@ impl<'a> Cursor<'a> {
break;
}
if let Some(close) = terminator {
if self.peek_char() == Some(close) {
if let Some(close) = terminator
&& self.peek_char() == Some(close)
{
self.bump_char();
terminated = true;
break;
}
}
let Some(ch) = self.peek_char() else {
break;
@@ -1376,7 +1377,7 @@ mod tests {
.collect()
}
fn find_list_child<'a>(node: &'a TokenTreeNode, delimiter: DelimiterKind) -> &'a TokenTreeNode {
fn find_list_child(node: &TokenTreeNode, delimiter: DelimiterKind) -> &TokenTreeNode {
node.children
.iter()
.find(|child| {

View File

@@ -201,7 +201,7 @@ pub(crate) fn sync_primary_selection(
#[cfg(not(target_os = "linux"))]
{
let _ = (window, interaction_tree, selection);
return Ok(());
Ok(())
}
#[cfg(target_os = "linux")]

View File

@@ -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()

View File

@@ -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};

View File

@@ -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,

View File

@@ -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)),

View File

@@ -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);

View File

@@ -4,6 +4,12 @@ version = "0.1.0"
edition = "2024"
[dependencies]
ruin_ui = { path = "../ui" }
ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" }
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
tracing = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2"
objc2 = "0.6.4"
objc2-core-foundation = "0.3.2"
@@ -11,7 +17,3 @@ objc2-foundation = "0.3.2"
objc2-quartz-core = "0.3.2"
raw-window-handle = "0.6"
raw-window-metal = "1.1.0"
ruin_ui = { path = "../ui" }
ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" }
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
tracing = "0.1"

View File

@@ -2,6 +2,8 @@
//!
//! Native backend implemented on top of Cocoa/AppKit runtime messaging.
#![cfg(target_os = "macos")]
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use std::ffi::{CStr, CString, c_void};
@@ -353,9 +355,7 @@ impl ResizePipeline {
}
fn note_presented_scene(&mut self, scene_viewport: UiSize) -> Option<WindowConfigured> {
let Some(presented_configuration) = self.active_resize else {
return None;
};
let presented_configuration = self.active_resize?;
if presented_configuration.actual_inner_size != scene_viewport {
return None;
}
@@ -365,9 +365,7 @@ impl ResizePipeline {
self.next_resize = None;
}
let Some(next_resize) = self.next_resize.take() else {
return None;
};
let next_resize = self.next_resize.take()?;
self.active_resize = Some(next_resize);
Some(next_resize)
}

View File

@@ -4,12 +4,14 @@ version = "0.1.0"
edition = "2024"
[dependencies]
libc = "0.2"
raw-window-handle = "0.6"
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
ruin_ui = { path = "../ui" }
ruin_ui_renderer_wgpu = { path = "../ui_renderer_wgpu" }
tracing = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
raw-window-handle = "0.6"
wayland-backend = { version = "0.3", features = ["client_system"] }
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] }

View File

@@ -1,3 +1,5 @@
#![cfg(target_os = "linux")]
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::error::Error;

View File

@@ -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
@@ -1163,10 +1224,12 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
glyph_rect,
atlas_glyph.atlas_rect,
clip_rect,
GlyphVertexContext {
logical_size,
self.scale_factor,
resolved_color,
active_clip,
scale_factor: self.scale_factor,
color: resolved_color,
clip: active_clip,
},
);
}
}
@@ -1888,34 +1951,40 @@ fn build_textured_vertices(
])
}
#[allow(dead_code)]
#[derive(Clone, Copy)]
struct GlyphVertexContext {
logical_size: UiSize,
scale_factor: f32,
color: Color,
clip: ActiveClip,
}
#[allow(dead_code)]
fn push_glyph_vertices(
vertices: &mut Vec<TextVertex>,
glyph_rect: PixelRect,
atlas_rect: AtlasRect,
clip_rect: Option<PixelRect>,
logical_size: UiSize,
scale_factor: f32,
color: Color,
clip: ActiveClip,
context: GlyphVertexContext,
) {
let Some((dest_rect, uv_rect)) = clipped_glyph_quad(glyph_rect, atlas_rect, clip_rect) else {
return;
};
let scale_factor = scale_factor.max(1.0);
let scale_factor = context.scale_factor.max(1.0);
let logical_left = dest_rect.left as f32 / scale_factor;
let logical_top = dest_rect.top as f32 / scale_factor;
let logical_right = dest_rect.right as f32 / scale_factor;
let logical_bottom = dest_rect.bottom as f32 / scale_factor;
let left = to_ndc_x(logical_left, logical_size.width.max(1.0));
let right = to_ndc_x(logical_right, logical_size.width.max(1.0));
let top = to_ndc_y(logical_top, logical_size.height.max(1.0));
let bottom = to_ndc_y(logical_bottom, logical_size.height.max(1.0));
let left = to_ndc_x(logical_left, context.logical_size.width.max(1.0));
let right = to_ndc_x(logical_right, context.logical_size.width.max(1.0));
let top = to_ndc_y(logical_top, context.logical_size.height.max(1.0));
let bottom = to_ndc_y(logical_bottom, context.logical_size.height.max(1.0));
let color = color_to_f32(color);
let clip_rect = clip_rect_array(clip);
let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip);
let color = color_to_f32(context.color);
let clip_rect = clip_rect_array(context.clip);
let (rounded_clip_rect, clip_params) = rounded_clip_arrays(context.clip);
vertices.extend_from_slice(&[
TextVertex {
position: [left, top],
@@ -2238,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,
@@ -2335,9 +2395,7 @@ fn text_raster_clip(
let text_bounds = Rect::new(text.origin.x, text.origin.y, bounds.width, bounds.height);
absolute_clip = intersect_rects(absolute_clip, Some(text_bounds));
}
if absolute_clip.is_none() {
return None;
}
absolute_clip?;
Some(absolute_clip.map(|rect| {
Rect::new(
@@ -2458,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);
@@ -2477,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,
@@ -2494,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(),
}
}
@@ -2524,12 +2570,15 @@ fn scale_glyph_cache_key(cache_key: CacheKey, scale_factor: f32) -> CacheKey {
#[cfg(test)]
mod tests {
use super::{
ActiveClip, AtlasRect, 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,
ActiveClip, AtlasRect, GlyphVertexContext, PixelRect, blend_rgba, build_text_vertices,
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() {
@@ -2607,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 {
@@ -2648,10 +2711,12 @@ mod tests {
height: 20,
},
None,
UiSize::new(100.0, 100.0),
2.0,
Color::rgb(0xFF, 0xFF, 0xFF),
ActiveClip::default(),
GlyphVertexContext {
logical_size: UiSize::new(100.0, 100.0),
scale_factor: 2.0,
color: Color::rgb(0xFF, 0xFF, 0xFF),
clip: ActiveClip::default(),
},
);
assert_eq!(vertices.len(), 6);