Perf profiling and some low hanging perf improvements in text rendering

This commit is contained in:
Will Temple
2026-05-16 19:07:44 -04:00
parent 4347232b98
commit e5ae067bc2
13 changed files with 1010 additions and 117 deletions

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

@@ -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,
(
LayoutSnapshot {
scene,
interaction_tree: InteractionTree {
root: interaction_root,
},
root_min_size,
},
root_min_size,
}
stats,
)
}
#[allow(clippy::too_many_arguments)]
@@ -2903,7 +2972,7 @@ mod tests {
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Text(text) => Some(text.text.as_str()),
DisplayItem::Text(text) => Some(text.text.as_ref()),
_ => None,
})
.collect()

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

@@ -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;
let local_x = (glyph.position.x * scale_factor).round() as i32;
let local_y = (glyph.position.y * scale_factor).round() as i32;
let rect = PixelRect {
left: local_x + image.placement.left,
top: local_y - image.placement.top,
right: local_x + image.placement.left + width,
bottom: local_y - image.placement.top + height,
};
if pixel_clip.is_some_and(|clip| !pixel_rects_intersect(rect, clip)) {
continue;
}
glyphs.push(PreparedGlyphBitmap {
rect: PixelRect {
left: local_x + image.placement.left,
top: local_y - image.placement.top,
right: local_x + image.placement.left + width,
bottom: local_y - image.placement.top + height,
},
rect,
cache_key,
color: resolve_glyph_color(glyph.color, text.default_color),
});
@@ -1046,6 +1100,13 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
let mut glyphs = Vec::new();
for run in buffer.layout_runs() {
if let Some(clip) = raster_clip {
let line_top = run.line_top.floor() as i32;
let line_bottom = (run.line_top + run.line_height).ceil() as i32;
if line_bottom < clip.top || line_top > clip.bottom {
continue;
}
}
for glyph in run.glyphs {
let physical = glyph.physical((0.0, run.line_y), 1.0);
let Some(image) = self
@@ -2246,15 +2307,6 @@ fn expand_rect(rect: Rect, inset: f32) -> Rect {
)
}
fn rect_to_pixel_rect(rect: Rect) -> PixelRect {
PixelRect {
left: rect.origin.x.floor() as i32,
top: rect.origin.y.floor() as i32,
right: (rect.origin.x + rect.size.width).ceil() as i32,
bottom: (rect.origin.y + rect.size.height).ceil() as i32,
}
}
fn scale_rect_to_pixel_rect(rect: Rect, scale: f32) -> PixelRect {
PixelRect {
left: (rect.origin.x * scale).floor() as i32,
@@ -2464,7 +2516,7 @@ fn clip_visible_glyphs(text: &PreparedText, clip: Option<Rect>) -> &[GlyphInstan
let local_bottom = local_top + clip.size.height;
// First line whose bottom edge reaches or passes the clip top.
let start = lines.partition_point(|l| l.rect.origin.y + l.rect.size.height < local_top);
let start = lines.partition_point(|l| l.rect.origin.y + l.rect.size.height <= local_top);
// First line whose top edge is strictly past the clip bottom.
let end = lines.partition_point(|l| l.rect.origin.y <= local_bottom);
@@ -2483,7 +2535,7 @@ fn text_texture_key(
scale_factor: f32,
) -> TextTextureKey {
TextTextureKey {
text: text.text.clone(),
layout_key: text.layout_cache_key(),
bounds: text.bounds.map(|bounds| {
(
(bounds.width * scale_factor).ceil() as i32,
@@ -2500,18 +2552,6 @@ fn text_texture_key(
text.default_color.b,
text.default_color.a,
),
glyphs: text
.glyphs
.iter()
.map(|glyph| TextTextureGlyph {
// Glyph positions are already LOCAL; no subtraction needed.
local_x_bits: glyph.position.x.to_bits(),
local_y_bits: glyph.position.y.to_bits(),
advance_bits: glyph.advance.to_bits(),
color: (glyph.color.r, glyph.color.g, glyph.color.b, glyph.color.a),
cache_key: glyph.cache_key,
})
.collect(),
}
}
@@ -2531,11 +2571,14 @@ fn scale_glyph_cache_key(cache_key: CacheKey, scale_factor: f32) -> CacheKey {
mod tests {
use super::{
ActiveClip, AtlasRect, GlyphVertexContext, PixelRect, blend_rgba, build_text_vertices,
build_vertices, clip_rect_array, push_clip_state, push_glyph_vertices, rounded_clip_arrays,
scale_glyph_cache_key, text_texture_key,
build_vertices, clip_rect_array, clip_visible_glyphs, push_clip_state, push_glyph_vertices,
rounded_clip_arrays, scale_glyph_cache_key, text_texture_key,
};
use cosmic_text::{CacheKey, CacheKeyFlags, SubpixelBin, fontdb};
use ruin_ui::{ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
use ruin_ui::{
ClipRegion, Color, Point, PreparedText, Rect, SceneSnapshot, TextStyle, TextSystem,
TextWrap, UiSize,
};
#[test]
fn quad_scenes_expand_to_six_vertices_per_quad() {
@@ -2613,6 +2656,20 @@ mod tests {
assert_ne!(first, different_pixel);
}
#[test]
fn clip_visible_glyphs_returns_only_lines_intersecting_clip() {
let mut text_system = TextSystem::new();
let style = TextStyle::new(16.0, Color::rgb(0xEE, 0xEE, 0xEE))
.with_wrap(TextWrap::None)
.with_line_height(20.0);
let text = text_system.prepare("abcd\nefgh", Point::new(10.0, 20.0), &style);
let visible = clip_visible_glyphs(&text, Some(Rect::new(10.0, 40.0, 100.0, 20.0)));
assert_eq!(visible.len(), 4);
assert_eq!(visible[0].text_start, 5);
assert_eq!(visible[3].text_end, 9);
}
#[test]
fn scale_glyph_cache_key_scales_font_size_only() {
let key = CacheKey {