Skip to content

Commit ab7d768

Browse files
lucasmerlinclaude
andauthored
Allow transforming after shapes were tessellated and rounded (#8587)
Via a new `TransformedShape`. When the transform is applied before rounding during an animation, it introduces a ugly snap once the transform ends: https://github.com/user-attachments/assets/1e75cd21-4bc4-4869-b3fa-cde749991518 When the transform is applied after rounding it looks way smoother: https://github.com/user-attachments/assets/ccba1562-66dc-4b93-b187-53928510c0b8 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 160090b commit ab7d768

6 files changed

Lines changed: 176 additions & 44 deletions

File tree

‎crates/egui/src/context.rs‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3530,6 +3530,25 @@ impl Context {
35303530
}
35313531
}
35323532

3533+
/// Transform all the graphics at the given layer, but only after they have been tessellated and
3534+
/// snapped to the pixel grid.
3535+
///
3536+
/// Unlike [`Self::transform_layer_shapes`], the snapping happens in the layer's own
3537+
/// coordinates, so the rendering converges on the untransformed one.
3538+
/// Use this for an animation that ends at [`TSTransform::IDENTITY`], such as a popup scaling
3539+
/// into place: it doesn't end with a jump of up to a pixel.
3540+
/// See [`epaint::ClippedShape::transform_after_tessellation`] for the trade-off.
3541+
///
3542+
/// This only applies to the existing graphics at the layer, not to graphics added later, so
3543+
/// call it once the layer is complete — [`crate::Plugin::on_end_pass`] is a good place.
3544+
///
3545+
/// Interaction is unaffected: the layer keeps its own input coordinates.
3546+
pub fn transform_layer_shapes_after_rounding(&self, layer_id: LayerId, transform: TSTransform) {
3547+
if transform != TSTransform::IDENTITY {
3548+
self.graphics_mut(|g| g.entry(layer_id).transform_after_rounding(transform));
3549+
}
3550+
}
3551+
35333552
/// Top-most layer at the given position.
35343553
pub fn layer_id_at(&self, pos: Pos2) -> Option<LayerId> {
35353554
self.memory(|mem| mem.layer_id_at(pos))
@@ -4822,15 +4841,10 @@ fn warn_if_rect_changes_id(
48224841
.map(|w| w.id.short_debug_format())
48234842
.collect::<Vec<_>>(),
48244843
);
4825-
out_shapes.push(ClippedShape {
4826-
clip_rect: Rect::EVERYTHING,
4827-
shape: epaint::Shape::rect_stroke(
4828-
rect,
4829-
0,
4830-
(2.0, Color32::RED),
4831-
StrokeKind::Outside,
4832-
),
4833-
});
4844+
out_shapes.push(ClippedShape::new(
4845+
Rect::EVERYTHING,
4846+
epaint::Shape::rect_stroke(rect, 0, (2.0, Color32::RED), StrokeKind::Outside),
4847+
));
48344848
}
48354849
}
48364850
}

‎crates/egui/src/layers.rs‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,15 +126,15 @@ impl PaintList {
126126
#[inline(always)]
127127
pub fn add(&mut self, clip_rect: Rect, shape: Shape) -> ShapeIdx {
128128
let idx = self.next_idx();
129-
self.0.push(ClippedShape { clip_rect, shape });
129+
self.0.push(ClippedShape::new(clip_rect, shape));
130130
idx
131131
}
132132

133133
pub fn extend<I: IntoIterator<Item = Shape>>(&mut self, clip_rect: Rect, shapes: I) {
134134
self.0.extend(
135135
shapes
136136
.into_iter()
137-
.map(|shape| ClippedShape { clip_rect, shape }),
137+
.map(|shape| ClippedShape::new(clip_rect, shape)),
138138
);
139139
}
140140

@@ -152,7 +152,7 @@ impl PaintList {
152152
return;
153153
}
154154

155-
self.0[idx.0] = ClippedShape { clip_rect, shape };
155+
self.0[idx.0] = ClippedShape::new(clip_rect, shape);
156156
}
157157

158158
/// Set the given shape to be empty (a `Shape::Noop`).
@@ -168,17 +168,40 @@ impl PaintList {
168168

169169
/// Transform each [`Shape`] and clip rectangle by this much, in-place
170170
pub fn transform(&mut self, transform: TSTransform) {
171-
for ClippedShape { clip_rect, shape } in &mut self.0 {
172-
*clip_rect = transform.mul_rect(*clip_rect);
173-
shape.transform(transform);
174-
}
171+
let end = self.next_idx();
172+
self.transform_range(ShapeIdx(0), end, transform);
175173
}
176174

177175
/// Transform each [`Shape`] and clip rectangle in range by this much, in-place
178176
pub fn transform_range(&mut self, start: ShapeIdx, end: ShapeIdx, transform: TSTransform) {
179-
for ClippedShape { clip_rect, shape } in &mut self.0[start.0..end.0] {
180-
*clip_rect = transform.mul_rect(*clip_rect);
181-
shape.transform(transform);
177+
for clipped_shape in &mut self.0[start.0..end.0] {
178+
clipped_shape.transform(transform);
179+
}
180+
}
181+
182+
/// Transform each [`Shape`] and clip rectangle by this much, in-place, but only after the
183+
/// shapes have been tessellated and snapped to the pixel grid.
184+
///
185+
/// See [`ClippedShape::transform_after_tessellation`] for which of the two you want.
186+
pub fn transform_after_rounding(&mut self, transform: TSTransform) {
187+
let end = self.next_idx();
188+
self.transform_after_rounding_range(ShapeIdx(0), end, transform);
189+
}
190+
191+
/// Transform each [`Shape`] and clip rectangle in range by this much, in-place, but only
192+
/// after the shapes have been tessellated and snapped to the pixel grid.
193+
///
194+
/// See [`ClippedShape::transform_after_tessellation`] for which of the two you want.
195+
pub fn transform_after_rounding_range(
196+
&mut self,
197+
start: ShapeIdx,
198+
end: ShapeIdx,
199+
transform: TSTransform,
200+
) {
201+
for clipped_shape in &mut self.0[start.0..end.0] {
202+
clipped_shape.clip_rect = transform.mul_rect(clipped_shape.clip_rect);
203+
clipped_shape.transform_after_tessellation =
204+
transform * clipped_shape.transform_after_tessellation;
182205
}
183206
}
184207

‎crates/egui_kittest/src/lib.rs‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ use core::{
3838
};
3939

4040
use egui::{
41-
Color32, Key, Modifiers, PointerButton, Pos2, Rect, RepaintCause, Shape, Vec2, ViewportId,
41+
Color32, Key, Modifiers, PointerButton, Pos2, Rect, RepaintCause, Vec2, ViewportId,
4242
epaint::{ClippedShape, RectShape},
4343
style::ScrollAnimation,
4444
};
@@ -61,15 +61,14 @@ fn push_cursor_shape(ctx: &egui::Context, shapes: &mut Vec<ClippedShape>) {
6161
mouse_pos + egui::vec2(8.0, 16.0),
6262
];
6363

64-
shapes.push(ClippedShape {
65-
clip_rect: ctx.content_rect(),
66-
shape: egui::epaint::PathShape::convex_polygon(
64+
shapes.push(ClippedShape::new(
65+
ctx.content_rect(),
66+
egui::epaint::PathShape::convex_polygon(
6767
triangle,
6868
Color32::WHITE,
6969
egui::Stroke::new(1.0, Color32::BLACK),
70-
)
71-
.into(),
72-
});
70+
),
71+
));
7372
}
7473

7574
#[derive(Debug, Clone)]
@@ -817,10 +816,10 @@ impl<'a, State> Harness<'a, State> {
817816
self.last_render = None;
818817
}
819818

820-
self.output.shapes.push(ClippedShape {
821-
clip_rect: Rect::EVERYTHING,
822-
shape: Shape::Rect(RectShape::filled(rect, 0.0, Color32::MAGENTA)),
823-
});
819+
self.output.shapes.push(ClippedShape::new(
820+
Rect::EVERYTHING,
821+
RectShape::filled(rect, 0.0, Color32::MAGENTA),
822+
));
824823
}
825824

826825
/// Should every step be rendered?

‎crates/epaint/benches/benchmark.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ fn tessellate_circles(c: &mut Criterion) {
5555
for _ in 0..10_000 {
5656
let clip_rect = Rect::from_min_size(Pos2::ZERO, Vec2::splat(1024.0));
5757
let shape = Shape::circle_filled(Pos2::new(10.0, 10.0), r, Color32::WHITE);
58-
clipped_shapes.push(ClippedShape { clip_rect, shape });
58+
clipped_shapes.push(ClippedShape::new(clip_rect, shape));
5959
}
6060
}
6161
assert_eq!(

‎crates/epaint/src/lib.rs‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,17 +129,46 @@ pub struct ClippedShape {
129129

130130
/// The shape
131131
pub shape: Shape,
132+
133+
/// Applied to the tessellated result of `shape`, _after_ it has been snapped to the pixel grid.
134+
///
135+
/// [`emath::TSTransform::IDENTITY`] means "no transform", which is what you want most of the
136+
/// time.
137+
///
138+
/// Snapping in the shape's own coordinates makes the rendering converge on the untransformed
139+
/// one, so this suits a transform that animates towards [`emath::TSTransform::IDENTITY`], such
140+
/// as a popup scaling into place: it doesn't end with a jump of up to a pixel. The cost is
141+
/// that the rendering is resampled, and so slightly blurrier, while the transform is not the
142+
/// identity. For a lasting transform, such as a pan/zoom canvas, transform the shape itself.
143+
///
144+
/// Note that `clip_rect` is in the space this transform maps _to_.
145+
pub transform_after_tessellation: emath::TSTransform,
132146
}
133147

134148
impl ClippedShape {
149+
/// A shape with no [`Self::transform_after_tessellation`].
150+
#[inline]
151+
pub fn new(clip_rect: emath::Rect, shape: impl Into<Shape>) -> Self {
152+
Self {
153+
clip_rect,
154+
shape: shape.into(),
155+
transform_after_tessellation: emath::TSTransform::IDENTITY,
156+
}
157+
}
158+
135159
/// Transform (move/scale) the shape in-place.
136160
///
137161
/// If using a [`PaintCallback`], note that only the rect is scaled as opposed
138162
/// to other shapes where the stroke is also scaled.
139163
pub fn transform(&mut self, transform: emath::TSTransform) {
140-
let Self { clip_rect, shape } = self;
141-
*clip_rect = transform * *clip_rect;
142-
shape.transform(transform);
164+
self.clip_rect = transform * self.clip_rect;
165+
if self.transform_after_tessellation == emath::TSTransform::IDENTITY {
166+
self.shape.transform(transform);
167+
} else {
168+
// The shape is tessellated in its own coordinate space, so the new transform has to
169+
// apply to the finished rendering too, or the two would fight over the pixel grid.
170+
self.transform_after_tessellation = transform * self.transform_after_tessellation;
171+
}
143172
}
144173
}
145174

‎crates/epaint/src/tessellator.rs‎

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
#![expect(clippy::identity_op)]
77

88
use emath::{
9-
GuiRounding as _, NumExt as _, Pos2, Rangef, Rect, Rot2, Vec2, fast_midpoint, pos2, remap, vec2,
9+
GuiRounding as _, NumExt as _, Pos2, Rangef, Rect, Rot2, TSTransform, Vec2, fast_midpoint,
10+
pos2, remap, vec2,
1011
};
1112

1213
use crate::{
@@ -1477,20 +1478,36 @@ impl Tessellator {
14771478
clipped_shape: ClippedShape,
14781479
out_primitives: &mut Vec<ClippedPrimitive>,
14791480
) {
1480-
let ClippedShape { clip_rect, shape } = clipped_shape;
1481+
let ClippedShape {
1482+
clip_rect,
1483+
shape,
1484+
transform_after_tessellation: transform,
1485+
} = clipped_shape;
14811486

14821487
if !clip_rect.is_positive() {
14831488
return; // skip empty clip rectangles
14841489
}
14851490

1491+
if !transform.is_valid() {
1492+
return;
1493+
}
1494+
14861495
if let Shape::Vec(shapes) = shape {
14871496
for shape in shapes {
1488-
self.tessellate_clipped_shape(ClippedShape { clip_rect, shape }, out_primitives);
1497+
self.tessellate_clipped_shape(
1498+
ClippedShape {
1499+
clip_rect,
1500+
shape,
1501+
transform_after_tessellation: transform,
1502+
},
1503+
out_primitives,
1504+
);
14891505
}
14901506
return;
14911507
}
14921508

1493-
if let Shape::Callback(callback) = shape {
1509+
if let Shape::Callback(mut callback) = shape {
1510+
callback.rect = transform * callback.rect;
14941511
out_primitives.push(ClippedPrimitive {
14951512
clip_rect,
14961513
primitive: Primitive::Callback(callback),
@@ -1522,8 +1539,21 @@ impl Tessellator {
15221539
let out = out_primitives.last_mut().unwrap();
15231540

15241541
if let Primitive::Mesh(out_mesh) = &mut out.primitive {
1525-
self.clip_rect = clip_rect;
1526-
self.tessellate_shape(shape, out_mesh);
1542+
if transform == TSTransform::IDENTITY {
1543+
self.clip_rect = clip_rect;
1544+
self.tessellate_shape(shape, out_mesh);
1545+
} else {
1546+
// Tessellate in the shape's own coordinate space, so that everything lands on the
1547+
// pixel grid there, and only then move the finished vertices into place.
1548+
// Culling has to happen in that same space, so map the clip rect back into it:
1549+
self.clip_rect = transform.inverse() * clip_rect;
1550+
1551+
let vertex_start = out_mesh.vertices.len();
1552+
self.tessellate_shape(shape, out_mesh);
1553+
for vertex in &mut out_mesh.vertices[vertex_start..] {
1554+
vertex.pos = transform * vertex.pos;
1555+
}
1556+
}
15271557
} else {
15281558
unreachable!();
15291559
}
@@ -2593,10 +2623,7 @@ fn test_tessellator() {
25932623
shapes.push(Shape::mesh(mesh));
25942624

25952625
let shape = Shape::Vec(shapes);
2596-
let clipped_shapes = vec![ClippedShape {
2597-
clip_rect: rect,
2598-
shape,
2599-
}];
2626+
let clipped_shapes = vec![ClippedShape::new(rect, shape)];
26002627

26012628
let font_tex_size = [1024, 1024]; // unused
26022629
let prepared_discs = vec![]; // unused
@@ -2607,6 +2634,46 @@ fn test_tessellator() {
26072634
assert_eq!(primitives.len(), 2);
26082635
}
26092636

2637+
#[test]
2638+
fn transform_before_and_after_rounding() {
2639+
use crate::*;
2640+
2641+
fn tessellated_bounds(clipped_shape: ClippedShape) -> Rect {
2642+
let options = TessellationOptions {
2643+
feathering: false,
2644+
..Default::default()
2645+
};
2646+
let primitives = Tessellator::new(1.0, options, [1024, 1024], vec![])
2647+
.tessellate_shapes(vec![clipped_shape]);
2648+
assert_eq!(primitives.len(), 1);
2649+
let Primitive::Mesh(mesh) = &primitives[0].primitive else {
2650+
panic!("expected a mesh");
2651+
};
2652+
mesh.calc_bounds()
2653+
}
2654+
2655+
// Off the pixel grid, so the tessellator has to round it:
2656+
let rect = Rect::from_min_max(pos2(0.5, 0.5), pos2(10.5, 10.5));
2657+
let shape = Shape::rect_filled(rect, 0, Color32::WHITE);
2658+
let transform = TSTransform::from_scaling(3.0);
2659+
2660+
// Transform first, then round: [1.5, 31.5] rounds to [2, 32].
2661+
let mut immediate = ClippedShape::new(Rect::EVERYTHING, shape.clone());
2662+
immediate.transform(transform);
2663+
assert_eq!(
2664+
tessellated_bounds(immediate),
2665+
Rect::from_min_max(pos2(2.0, 2.0), pos2(32.0, 32.0))
2666+
);
2667+
2668+
// Round first, then transform: [0.5, 10.5] rounds to [1, 11], which scales to [3, 33].
2669+
let mut after_rounding = ClippedShape::new(Rect::EVERYTHING, shape);
2670+
after_rounding.transform_after_tessellation = transform;
2671+
assert_eq!(
2672+
tessellated_bounds(after_rounding),
2673+
Rect::from_min_max(pos2(3.0, 3.0), pos2(33.0, 33.0))
2674+
);
2675+
}
2676+
26102677
#[test]
26112678
fn path_bounding_box() {
26122679
use crate::*;

0 commit comments

Comments
 (0)