Shape a text once per width, not once per ask
A container measures a child by drawing it in a box it may not keep, so one layout asks a text for a dozen widths and comes back to widths it has already had -- the hottest text in the depth-8 tree draws 32 times. Each ask re-ran the shaper, because the two caches in front of it held one entry each and a trial width alternating with a final width evicts the answer about to be wanted again. `perf record` put 63% of a resize frame in text and 0.9% in `draw_inner`. So keep more than one: a bounded store of shapings on `TextData`, keyed by the text, the attrs and the width, holding the parley layout and the glyphs placed from it. Bounding the store rather than each buffer is what keeps it a fixed cost -- +4 MB on a tree of 4,000 texts, which is 19 MB less than the code before #16 holds after the same resizes. `TextBuffer` now holds the glyphs of the shaping it is drawn as, which is where `TextView::tex` was. That leaves one place to invalidate rather than two, so the `MutDetect` flags on a view's text and attrs have no reader and go, along with the `buf.changed = true` after every edit. On a 40-row tree of distinct random paragraphs, 500 resize frames: 124.2M instructions per frame before, 17.7M after, and 45.9M when the width never repeats. The five reference renders and the resize render are byte-identical, and the 100-seed sweep passes. `tests/revision_cost.rs` is that tree, written in the API subset `43ce8c7` shares so the same source measures the code this replaced. Report the worst frame and p99 beside the median, since a stutter is what somebody sees. Count glyph placements, and count a text render per ask rather than per shaping, so the store cannot hide how many times a layout drew the same text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f1a47e9b7b
commit
e5f8b6b244
7 files changed
+321
-43
No files matched your search
@@ -54,10 +54,11 @@ pub(crate) enum Counter {
|
||||
TextRenders,
|
||||
TextShapeHits,
|
||||
TextShapes,
|
||||
GlyphPlacements,
|
||||
}
|
||||
|
||||
impl Counter {
|
||||
const COUNT: usize = Self::TextShapes as usize + 1;
|
||||
const COUNT: usize = Self::GlyphPlacements as usize + 1;
|
||||
|
||||
const NAMES: [&'static str; Self::COUNT] = [
|
||||
"updates",
|
||||
@@ -89,6 +90,7 @@ impl Counter {
|
||||
"text renders",
|
||||
"text shape hits",
|
||||
"text shapes",
|
||||
"glyph placements",
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+96
-13
@@ -7,7 +7,10 @@ use parley::{
|
||||
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout,
|
||||
LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
||||
};
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
};
|
||||
use swash::{
|
||||
FontRef,
|
||||
scale::{Render, ScaleContext, Source, StrikeWith},
|
||||
@@ -19,8 +22,31 @@ pub struct TextData {
|
||||
pub layout_ctx: LayoutContext<UiColor>,
|
||||
scale_ctx: ScaleContext,
|
||||
pub atlas: GlyphAtlas,
|
||||
spare: VecDeque<Shaping>,
|
||||
}
|
||||
|
||||
/// One shaping of some text, and the glyphs placed from it. A buffer holds
|
||||
/// the one it is drawn as; these are the ones it had before, kept because a
|
||||
/// container measures a child by drawing it in a box it may not keep, and so
|
||||
/// comes back to widths it has already asked for.
|
||||
struct Shaping {
|
||||
/// A shaping is a function of these three and nothing else, so no widget
|
||||
/// or buffer identity is involved and two texts of the same words share
|
||||
/// an answer.
|
||||
text: String,
|
||||
key: LayoutKey,
|
||||
layout: Layout<UiColor>,
|
||||
placed: Option<RenderedText>,
|
||||
}
|
||||
|
||||
/// How many to keep. Bounding the whole cache rather than each buffer is
|
||||
/// what makes this a fixed cost instead of one a tree of ten thousand texts
|
||||
/// pays ten thousand times; the re-asks come from laying out one subtree, so
|
||||
/// they are close together and few are needed. On `tests/revision_cost.rs`
|
||||
/// under `SWEEP=1`, the case that cannot hit across frames, 32 is not enough
|
||||
/// (6.6 ms) and 64 is (4.4 ms).
|
||||
const SPARE_SHAPINGS: usize = 128;
|
||||
|
||||
impl Default for TextData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -28,6 +54,7 @@ impl Default for TextData {
|
||||
layout_ctx: LayoutContext::new(),
|
||||
scale_ctx: ScaleContext::new(),
|
||||
atlas: GlyphAtlas::default(),
|
||||
spare: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +110,9 @@ pub struct TextBuffer {
|
||||
text: String,
|
||||
layout: Layout<UiColor>,
|
||||
layout_key: Option<LayoutKey>,
|
||||
/// The glyphs placed from `layout`, so drawing this text again at the
|
||||
/// width it already has places them once.
|
||||
placed: Option<RenderedText>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
@@ -97,6 +127,7 @@ impl TextBuffer {
|
||||
text: text.into(),
|
||||
layout: Layout::new(),
|
||||
layout_key: None,
|
||||
placed: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,15 +152,28 @@ impl TextBuffer {
|
||||
if text != self.text {
|
||||
self.text = text;
|
||||
self.layout_key = None;
|
||||
self.placed = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidates the layout and returns the underlying string for editing.
|
||||
pub fn edit(&mut self) -> &mut String {
|
||||
self.layout_key = None;
|
||||
self.placed = None;
|
||||
&mut self.text
|
||||
}
|
||||
|
||||
/// The glyphs of the shaping it is drawn as, once they are placed.
|
||||
pub fn rendered(&self) -> Option<&RenderedText> {
|
||||
self.placed.as_ref()
|
||||
}
|
||||
|
||||
/// The width its shaping wraps at, and `None` where it does not wrap or
|
||||
/// has not been shaped.
|
||||
pub fn wrap_width(&self) -> Option<f32> {
|
||||
self.layout_key.as_ref()?.max_width
|
||||
}
|
||||
|
||||
pub fn size(&self) -> Vec2 {
|
||||
Vec2::new(self.layout.width(), self.layout.height())
|
||||
}
|
||||
@@ -144,6 +188,23 @@ impl TextBuffer {
|
||||
diag::bump(Counter::TextShapeHits);
|
||||
return;
|
||||
}
|
||||
let kept = data.take_shaping(&self.text, &layout_key);
|
||||
if let Some(key) = self.layout_key.take() {
|
||||
data.keep_shaping(Shaping {
|
||||
text: self.text.clone(),
|
||||
key,
|
||||
layout: std::mem::replace(&mut self.layout, Layout::new()),
|
||||
placed: self.placed.take(),
|
||||
});
|
||||
}
|
||||
if let Some(shaping) = kept {
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
diag::bump(Counter::TextShapeHits);
|
||||
self.layout = shaping.layout;
|
||||
self.placed = shaping.placed;
|
||||
self.layout_key = Some(shaping.key);
|
||||
return;
|
||||
}
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
diag::bump(Counter::TextShapes);
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
@@ -273,26 +334,48 @@ pub struct RenderedText {
|
||||
}
|
||||
|
||||
impl TextData {
|
||||
pub fn render(
|
||||
/// The shaping for this text at this width, taken out of what is kept.
|
||||
fn take_shaping(&mut self, text: &str, key: &LayoutKey) -> Option<Shaping> {
|
||||
// From the newest, since a re-ask is usually of something recent.
|
||||
let at = self
|
||||
.spare
|
||||
.iter()
|
||||
.rposition(|spare| spare.key == *key && spare.text == text)?;
|
||||
self.spare.remove(at)
|
||||
}
|
||||
|
||||
fn keep_shaping(&mut self, shaping: Shaping) {
|
||||
if self.spare.len() >= SPARE_SHAPINGS {
|
||||
self.spare.pop_front();
|
||||
}
|
||||
self.spare.push_back(shaping);
|
||||
}
|
||||
|
||||
pub fn render<'b>(
|
||||
&mut self,
|
||||
buffer: &mut TextBuffer,
|
||||
buffer: &'b mut TextBuffer,
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
) -> RenderedText {
|
||||
) -> &'b RenderedText {
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
diag::bump(Counter::TextRenders);
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
let _render = diag::timer(TimerKind::TextRender);
|
||||
buffer.shape(self, attrs, width);
|
||||
let glyphs = {
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
let _place = diag::timer(TimerKind::GlyphPlacement);
|
||||
self.place(buffer)
|
||||
let placed = match buffer.placed.take() {
|
||||
Some(placed) => placed,
|
||||
None => {
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
diag::bump(Counter::GlyphPlacements);
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
let _place = diag::timer(TimerKind::GlyphPlacement);
|
||||
RenderedText {
|
||||
glyphs: self.place(buffer),
|
||||
size: buffer.size(),
|
||||
color: attrs.color,
|
||||
}
|
||||
}
|
||||
};
|
||||
RenderedText {
|
||||
glyphs,
|
||||
size: buffer.size(),
|
||||
color: attrs.color,
|
||||
}
|
||||
buffer.placed.insert(placed)
|
||||
}
|
||||
}
|
||||
@@ -239,12 +239,12 @@ impl<'a> Painter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_text(
|
||||
pub fn render_text<'b>(
|
||||
&mut self,
|
||||
buffer: &mut TextBuffer,
|
||||
buffer: &'b mut TextBuffer,
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
) -> RenderedText {
|
||||
) -> &'b RenderedText {
|
||||
#[cfg(feature = "layout-diagnostics")]
|
||||
diag::render_text(self.id, self.rsc.widgets().label(self.id), width);
|
||||
let ui = self.rsc.ui_mut();
|
||||
|
||||
Reference in new issue
Block a user