iris: a measurement is a mode on the painter, not a discarded draw

Painter::draw_twice(child, first, |used| second) becomes Painter::measure
plus an ordinary draw. Iris's objection was the shape it forced on the
caller rather than the two draws themselves: the arithmetic that picks
the real region had to happen inside a closure, and anything it wanted to
keep came back out through a captured &mut. LazySpan::place was the only
caller, and it now reads as the three statements it is.

DrawMode::Measure is that draw with everything it writes switched off --
no arena slot, no mask, no move slot, nothing left in `active`, nothing
marked dirty. Only the returned Size survives, and the widget is left
exactly as it was, so the real draw that follows is an ordinary first
draw or redraw. That last part is load-bearing: a measurement that left
an ActiveData behind would let the following draw hit draw_inner's
"already at this region" fast path and return having drawn nothing.

A measurement also does not consume a redraw mark, since it is not the
redraw the mark asked for, and it takes none of the fast paths, since
"already drawn here" cannot report a size.

Every Painter method that writes now returns early on the mode -- a
widget's own draw never checks, which is the point. A debug_assert at the
end of draw_inner catches one that forgot, because the failure otherwise
is a single leaked primitive per measured widget per frame, which a
screen redrawn every frame turns into an arena that grows without bound.

What this is worth, and what it is not. The amplification it applies to,
measured on a streamed frame: 1,083 Widget::draw calls over 113 distinct
widgets, with the worst drawn 11 times at nesting depth 7-8 -- it is not
two draws but two to the power of how many measuring ancestors a widget
has. Only the writes go away; the walk and the region arithmetic still
happen 11 times, and removing those needs a size answerable without a
draw, which LAYOUT.md section 5 rules out. Streamed frame p50 1.39ms ->
1.22ms, p99 4.75ms -> 3.58ms. The upload numbers do not move, because
slot recycling had already made the discarded writes free in arena terms.

Also extracts move_slot_for from draw_inner, since measuring must not
allocate one and the reuse-in-place rule wanted saying once.

Verified: run-tests.sh, iris's suite, clippy and rustfmt clean, and the
headless phone render is byte-identical to the previous commit's on the
real GPU (Venus, RX 7900 XT -- checked, not llvmpipe).
This commit is contained in:
iris committed 2026-09-09 11:51:33 -04:00
1 parent 4fb369fdd0
commit 2540f6517c
4 files changed
+204 -75

No files matched your search

+81 -26
View File
@@ -1,10 +1,11 @@
use crate::{
Color, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle,
UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
Color, DrawMode, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData,
TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
render::{
Drawn, GlyphPrimitive, IMAGE_BINDING, Mask, MaskIdx, MoveIdx, NOT_DRAWN, Primitive,
PrimitiveHandle, PrimitiveInst, RectPrimitive,
},
ui::render_state::Retained,
util::Vec2,
};
@@ -36,9 +37,23 @@ pub struct Painter<'a> {
pub(super) children: Vec<WidgetId>,
pub layer: usize,
pub(super) id: WidgetId,
/// Whether this draw produces what goes on screen or only a size --
/// see [`crate::DrawMode`]. Inherited by every child this widget
/// draws, so one `measure` at the top makes the whole subtree
/// write-free.
pub(super) mode: DrawMode,
}
impl<'a> Painter<'a> {
/// True while this draw is only being asked how big the widget would
/// be. **Every method here that writes anything must return early on
/// it** -- a widget's own `draw` never has to check, which is the
/// point: measuring is a property of the painter, not something each
/// widget re-implements.
pub fn measuring(&self) -> bool {
self.mode == DrawMode::Measure
}
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
self.write_primitive(primitive, region, Drawn::Yes);
}
@@ -74,6 +89,9 @@ impl<'a> Painter<'a> {
region: UiRegion,
drawn: Drawn,
) -> u32 {
if self.measuring() {
return u32::MAX;
}
let inst = PrimitiveInst {
id: self.id,
primitive,
@@ -139,6 +157,13 @@ impl<'a> Painter<'a> {
/// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) {
// Clipping changes no widget's reported size, so a measurement
// skips it whole -- not just the shape primitive, but the mask
// slot and its refs, which would otherwise be a leaked slot per
// masked widget per measured frame.
if self.measuring() {
return;
}
let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No);
self.set_mask_to(shape);
}
@@ -151,6 +176,12 @@ impl<'a> Painter<'a> {
/// with no radius argument anywhere that could fall out of step with
/// the one being drawn.
pub fn set_mask_to_widget<W: ?Sized>(&mut self, shape: &StrongWidget<W>) {
// Same as `set_mask`, and doubly so: a measurement leaves nothing
// in `active`, so the shape widget has drawn no primitive to
// point at and this would panic on its own message.
if self.measuring() {
return;
}
let slot = self.state.first_primitive(shape.id()).unwrap_or_else(|| {
panic!(
"'{}' was given as a mask's shape but drew no primitive, so there is nothing to \
@@ -246,14 +277,47 @@ impl<'a> Painter<'a> {
Some(self.id),
self.move_slot.idx() as u32,
self.mask,
Default::default(),
self.mode,
Retained::default(),
self.rsc,
);
self.state
.active
.get(&id.id())
.map(|a| a.size)
.unwrap_or_default()
)
}
/// Ask `widget` how big it would be in `region`, **writing nothing**
/// -- see [`DrawMode::Measure`]. For the container that cannot choose
/// what to offer a child without already knowing the child's size:
/// measure, work out the real region, then draw it for real.
///
/// ```ignore
/// let used = painter.measure(&child, generous);
/// painter.widget_within(&child, self.box_for(used));
/// ```
///
/// This replaced a `draw_twice(child, first, |used| second)`, which
/// made the same two draws but had the caller express the second
/// region as a closure returning it -- so the interesting arithmetic
/// happened inside a callback and anything it wanted to keep had to
/// be written out through a captured `&mut`. Two statements say the
/// same thing in the order it happens (CODE_RULES' "compose
/// linearly"), and the measurement costs no arena slot now rather
/// than allocating one and freeing it.
///
/// The measured widget is left exactly as it was -- not in `active`
/// if it was not there before, and untouched if it was -- so the draw
/// that follows is an ordinary one and cannot be short-circuited by
/// the measurement having "already drawn" it at that region.
pub fn measure<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
self.state.draw_inner(
self.layer,
id.id(),
region.within(&self.region),
Some(self.id),
self.move_slot.idx() as u32,
self.mask,
DrawMode::Measure,
Retained::default(),
self.rsc,
)
}
/// Move an already-drawn child from wherever it currently sits to
@@ -266,27 +330,15 @@ impl<'a> Painter<'a> {
/// (which detects that from the stored region) does the right thing
/// instead.
pub fn reposition<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
// Moves an *already-drawn* child, of which a measurement has
// none.
if self.measuring() {
return;
}
let region = region.within(&self.region);
self.state.reposition(id.id(), region, self.rsc);
}
/// Draw `child` at a provisional region to learn its size under one
/// axis's worth of assumption, discard everything it wrote, then draw
/// it again at the region that assumption produced. For the rare
/// parent that cannot pick an offered size without already knowing the
/// answer. Twice the cost of one `draw`; every other case in this file
/// avoids it.
pub fn draw_twice<W: ?Sized>(
&mut self,
id: &StrongWidget<W>,
first: UiRegion,
second: impl FnOnce(Size) -> UiRegion,
) -> Size {
let used = self.widget_within(id, first);
let region = second(used);
self.widget_within(id, region)
}
pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) {
self.textures.push(handle.clone());
self.write_image(handle.image_index(), region.within(&self.region));
@@ -306,6 +358,9 @@ impl<'a> Painter<'a> {
/// the layer's one instanced draw, so it goes through
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
if self.measuring() {
return;
}
let h = match self.take_recycled(IMAGE_BINDING, Drawn::Yes) {
Some(h) => {
self.state.primitives.recycle_image(