Optimize tessellation of filled circles (#1616)

When painting a scatter plot we sometimes want to paint hundreds of thousands of points (filled circles) on screen every frame.

In this PR the font texture atlas is pre-populated with some filled circled of various radii. These are then used when painting (small) filled circled, which means A LOT less triangles and vertices are generated for them.

In a new benchmark we can see a 10x speedup in circle tessellation, but the the real benefit comes in the painting of these circles: since we generate a lot less vertices, the backend painter has less to do.

In a real-life scenario with a lot of things being painted (including around 100k points) I saw tessellation go from 35ms -> 7ms and painting go from 45ms -> 1ms. This means the total frame time went from 80ms to 8ms, or a 10x speedup.
This commit is contained in:
Emil Ernerfeldt 2022-05-10 19:31:19 +02:00 committed by GitHub
parent 28efc0e1c8
commit 7b18fab7a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 232 additions and 23 deletions

View file

@ -5,7 +5,9 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui-w
## Unreleased
* Add `*_released` & `*_clicked` methods for `PointerState`.
* Add `*_released` & `*_clicked` methods for `PointerState` ([#1582](https://github.com/emilk/egui/pull/1582)).
* Optimize painting of filled circles (e.g. for scatter plots) by 10x or more ([#1616](https://github.com/emilk/egui/pull/1616)).
## 0.18.1 - 2022-05-01
* Change `Shape::Callback` from `&dyn Any` to `&mut dyn Any` to support more backends.

View file

@ -830,14 +830,17 @@ impl Context {
let pixels_per_point = self.pixels_per_point();
let tessellation_options = *self.tessellation_options();
let font_image_size = self.fonts().font_image_size();
let texture_atlas = self.fonts().texture_atlas();
let font_tex_size = texture_atlas.lock().size();
let prepared_discs = texture_atlas.lock().prepared_discs();
let paint_stats = PaintStats::from_shapes(&shapes);
let clipped_primitives = tessellator::tessellate_shapes(
pixels_per_point,
tessellation_options,
font_tex_size,
prepared_discs,
shapes,
font_image_size,
);
self.write().paint_stats = paint_stats.with_clipped_primitives(&clipped_primitives);
clipped_primitives

View file

@ -142,6 +142,7 @@ impl Widget for &mut epaint::TessellationOptions {
feathering,
feathering_size_in_pixels,
coarse_tessellation_culling,
prerasterized_discs,
round_text_to_pixels,
debug_paint_clip_rects,
debug_paint_text_rects,
@ -158,6 +159,8 @@ impl Widget for &mut epaint::TessellationOptions {
.text("Feathering size in pixels");
ui.add_enabled(*feathering, feathering_slider);
ui.checkbox(prerasterized_discs, "Speed up filled circles with pre-rasterization");
ui.add(
crate::widgets::Slider::new(bezier_tolerance, 0.0001..=10.0)
.logarithmic(true)

View file

@ -124,8 +124,13 @@ pub fn criterion_benchmark(c: &mut Criterion) {
let galley = fonts.layout(LOREM_IPSUM_LONG.to_owned(), font_id, color, wrap_width);
let font_image_size = fonts.font_image_size();
let mut tessellator =
egui::epaint::Tessellator::new(1.0, Default::default(), font_image_size);
let prepared_discs = fonts.texture_atlas().lock().prepared_discs();
let mut tessellator = egui::epaint::Tessellator::new(
1.0,
Default::default(),
font_image_size,
prepared_discs,
);
let mut mesh = egui::epaint::Mesh::default();
let text_shape = TextShape::new(egui::Pos2::ZERO, galley);
c.bench_function("tessellate_text", |b| {

View file

@ -158,6 +158,20 @@ impl View for MiscDemoWindow {
painter.line_segment([c, c + r * Vec2::angled(TAU * 3.0 / 8.0)], stroke);
});
});
CollapsingHeader::new("Many circles of different sizes")
.default_open(false)
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for i in 0..100 {
let r = i as f32 * 0.5;
let size = Vec2::splat(2.0 * r + 5.0);
let (rect, _response) = ui.allocate_at_least(size, Sense::hover());
ui.painter()
.circle_filled(rect.center(), r, ui.visuals().text_color());
}
});
});
}
}

View file

@ -3,6 +3,7 @@ All notable changes to the epaint crate will be documented in this file.
## Unreleased
* Optimize tessellation of filled circles by 10x or more ([#1616](https://github.com/emilk/egui/pull/1616)).
## 0.18.1 - 2022-05-01

View file

@ -1,6 +1,6 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use epaint::{pos2, Color32, Shape, Stroke};
use epaint::*;
fn single_dashed_lines(c: &mut Criterion) {
c.bench_function("single_dashed_lines", move |b| {
@ -39,5 +39,43 @@ fn many_dashed_lines(c: &mut Criterion) {
});
}
criterion_group!(benches, single_dashed_lines, many_dashed_lines);
fn tessellate_circles(c: &mut Criterion) {
c.bench_function("tessellate_circles_100k", move |b| {
let radii: [f32; 10] = [1.0, 2.0, 3.6, 4.0, 5.7, 8.0, 10.0, 13.0, 15.0, 17.0];
let mut clipped_shapes = vec![];
for r in radii {
for _ in 0..10_000 {
let clip_rect = Rect::from_min_size(Pos2::ZERO, Vec2::splat(1024.0));
let shape = Shape::circle_filled(Pos2::new(10.0, 10.0), r, Color32::WHITE);
clipped_shapes.push(ClippedShape(clip_rect, shape));
}
}
assert_eq!(clipped_shapes.len(), 100_000);
let pixels_per_point = 2.0;
let options = TessellationOptions::default();
let atlas = TextureAtlas::new([4096, 256]);
let font_tex_size = atlas.size();
let prepared_discs = atlas.prepared_discs();
b.iter(|| {
let clipped_primitive = tessellate_shapes(
pixels_per_point,
options,
font_tex_size,
prepared_discs.clone(),
clipped_shapes.clone(),
);
black_box(clipped_primitive);
});
});
}
criterion_group!(
benches,
single_dashed_lines,
many_dashed_lines,
tessellate_circles
);
criterion_main!(benches);

View file

@ -73,6 +73,7 @@ impl Shadow {
..Default::default()
},
font_tex_size,
vec![],
);
let mut mesh = Mesh::default();
tessellator.tessellate_rect(&rect, &mut mesh);

View file

@ -5,6 +5,7 @@
#![allow(clippy::identity_op)]
use crate::texture_atlas::PreparedDisc;
use crate::*;
use emath::*;
@ -618,6 +619,10 @@ pub struct TessellationOptions {
/// This likely makes
pub coarse_tessellation_culling: bool,
/// If `true`, small filled circled will be optimized by using pre-rasterized circled
/// from the font atlas.
pub prerasterized_discs: bool,
/// If `true` (default) align text to mesh grid.
/// This makes the text sharper on most platforms.
pub round_text_to_pixels: bool,
@ -644,6 +649,7 @@ impl Default for TessellationOptions {
feathering: true,
feathering_size_in_pixels: 1.0,
coarse_tessellation_culling: true,
prerasterized_discs: true,
round_text_to_pixels: true,
debug_paint_text_rects: false,
debug_paint_clip_rects: false,
@ -968,6 +974,8 @@ pub struct Tessellator {
pixels_per_point: f32,
options: TessellationOptions,
font_tex_size: [usize; 2],
/// See [`TextureAtlas::prepared_discs`].
prepared_discs: Vec<PreparedDisc>,
/// size of feathering in points. normally the size of a physical pixel. 0.0 if disabled
feathering: f32,
/// Only used for culling
@ -980,10 +988,12 @@ impl Tessellator {
/// Create a new [`Tessellator`].
///
/// * `font_tex_size`: size of the font texture. Required to normalize glyph uv rectangles when tessellating text.
/// * `prepared_discs`: What [`TextureAtlas::prepared_discs`] returns. Can safely be set to an empty vec.
pub fn new(
pixels_per_point: f32,
options: TessellationOptions,
font_tex_size: [usize; 2],
prepared_discs: Vec<PreparedDisc>,
) -> Self {
let feathering = if options.feathering {
let pixel_size = 1.0 / pixels_per_point;
@ -995,6 +1005,7 @@ impl Tessellator {
pixels_per_point,
options,
font_tex_size,
prepared_discs,
feathering,
clip_rect: Rect::EVERYTHING,
scratchpad_points: Default::default(),
@ -1137,7 +1148,7 @@ impl Tessellator {
let CircleShape {
center,
radius,
fill,
mut fill,
stroke,
} = shape;
@ -1154,6 +1165,30 @@ impl Tessellator {
return;
}
if self.options.prerasterized_discs && fill != Color32::TRANSPARENT {
let radius_px = radius * self.pixels_per_point;
// strike the right balance between some circles becoming too blurry, and some too sharp.
let cutoff_radius = radius_px * 2.0_f32.powf(0.25);
// Find the right disc radius for a crisp edge:
// TODO: perhaps we can do something faster than this linear search.
for disc in &self.prepared_discs {
if cutoff_radius <= disc.r {
let side = radius_px * disc.w / (self.pixels_per_point * disc.r);
let rect = Rect::from_center_size(center, Vec2::splat(side));
out.add_rect_with_uv(rect, disc.uv, fill);
if stroke.is_empty() {
return; // we are done
} else {
// we still need to do the stroke
fill = Color32::TRANSPARENT; // don't fill again below
break;
}
}
}
}
self.scratchpad_path.clear();
self.scratchpad_path.add_circle(center, radius);
self.scratchpad_path.fill(self.feathering, fill, out);
@ -1476,7 +1511,8 @@ impl Tessellator {
/// * `pixels_per_point`: number of physical pixels to each logical point
/// * `options`: tessellation quality
/// * `shapes`: what to tessellate
/// * `font_tex_size`: size of the font texture (required to normalize glyph uv rectangles)
/// * `font_tex_size`: size of the font texture. Required to normalize glyph uv rectangles when tessellating text.
/// * `prepared_discs`: What [`TextureAtlas::prepared_discs`] returns. Can safely be set to an empty vec.
///
/// The implementation uses a [`Tessellator`].
///
@ -1485,10 +1521,12 @@ impl Tessellator {
pub fn tessellate_shapes(
pixels_per_point: f32,
options: TessellationOptions,
shapes: Vec<ClippedShape>,
font_tex_size: [usize; 2],
prepared_discs: Vec<PreparedDisc>,
shapes: Vec<ClippedShape>,
) -> Vec<ClippedPrimitive> {
let mut tessellator = Tessellator::new(pixels_per_point, options, font_tex_size);
let mut tessellator =
Tessellator::new(pixels_per_point, options, font_tex_size, prepared_discs);
let mut clipped_primitives: Vec<ClippedPrimitive> = Vec::default();
@ -1562,6 +1600,15 @@ fn test_tessellator() {
let shape = Shape::Vec(shapes);
let clipped_shapes = vec![ClippedShape(rect, shape)];
let primitives = tessellate_shapes(1.0, Default::default(), clipped_shapes, [100, 100]);
let font_tex_size = [1024, 1024]; // unused
let prepared_discs = vec![]; // unused
let primitives = tessellate_shapes(
1.0,
Default::default(),
font_tex_size,
prepared_discs,
clipped_shapes,
);
assert_eq!(primitives.len(), 2);
}

View file

@ -394,6 +394,12 @@ impl Fonts {
self.lock().fonts.max_texture_side
}
/// The font atlas.
/// Pass this to [`crate::Tessellator`].
pub fn texture_atlas(&self) -> Arc<Mutex<TextureAtlas>> {
self.lock().fonts.atlas.clone()
}
/// Current size of the font image.
/// Pass this to [`crate::Tessellator`].
pub fn font_image_size(&self) -> [usize; 2] {
@ -535,14 +541,7 @@ impl FontsImpl {
let texture_width = max_texture_side.at_most(8 * 1024);
let initial_height = 64;
let mut atlas = TextureAtlas::new([texture_width, initial_height]);
{
// Make the top left pixel fully white:
let (pos, image) = atlas.allocate((1, 1));
assert_eq!(pos, (0, 0));
image[pos] = 1.0;
}
let atlas = TextureAtlas::new([texture_width, initial_height]);
let atlas = Arc::new(Mutex::new(atlas));

View file

@ -401,7 +401,7 @@ pub struct Glyph {
pub pos: Pos2,
/// Advance width and font row height.
pub size: Vec2,
/// Position of the glyph in the font texture.
/// Position of the glyph in the font texture, in texels.
pub uv_rect: UvRect,
/// Index into [`LayoutJob::sections`]. Decides color etc.
pub section_index: u32,

View file

@ -1,6 +1,8 @@
use emath::{remap_clamp, Rect};
use crate::{FontImage, ImageDelta};
#[derive(Clone, Copy, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Rectu {
/// inclusive
min_x: usize,
@ -27,6 +29,26 @@ impl Rectu {
};
}
#[derive(Copy, Clone, Debug)]
struct PrerasterizedDisc {
r: f32,
uv: Rectu,
}
/// A pre-rasterized disc (filled circle), somewhere in the texture atlas.
#[derive(Copy, Clone, Debug)]
pub struct PreparedDisc {
/// The radius of this disc in texels.
pub r: f32,
/// Width in texels.
pub w: f32,
/// Where in the texture atlas the disc is.
/// Normalized in 0-1 range.
pub uv: Rect,
}
/// Contains font data in an atlas, where each character occupied a small rectangle.
///
/// More characters can be added, possibly expanding the texture.
@ -42,24 +64,98 @@ pub struct TextureAtlas {
/// Set when someone requested more space than was available.
overflowed: bool,
/// pre-rasterized discs of radii `2^i`, where `i` is the index.
discs: Vec<PrerasterizedDisc>,
}
impl TextureAtlas {
pub fn new(size: [usize; 2]) -> Self {
assert!(size[0] >= 1024, "Tiny texture atlas");
Self {
let mut atlas = Self {
image: FontImage::new(size),
dirty: Rectu::EVERYTHING,
cursor: (0, 0),
row_height: 0,
overflowed: false,
discs: vec![], // will be filled in below
};
// Make the top left pixel fully white for `WHITE_UV`, i.e. painting something with solid color:
let (pos, image) = atlas.allocate((1, 1));
assert_eq!(pos, (0, 0));
image[pos] = 1.0;
// Allocate a series of anti-aliased discs used to render small filled circles:
// TODO: these circles can be packed A LOT better.
// In fact, the whole texture atlas could be packed a lot better.
// for r in [1, 2, 4, 8, 16, 32, 64] {
// let w = 2 * r + 3;
// let hw = w as i32 / 2;
const LARGEST_CIRCLE_RADIUS: f32 = 64.0;
for i in 0.. {
let r = 2.0_f32.powf(i as f32 / 2.0 - 1.0);
if r > LARGEST_CIRCLE_RADIUS {
break;
}
let hw = (r + 0.5).ceil() as i32;
let w = (2 * hw + 1) as usize;
let ((x, y), image) = atlas.allocate((w, w));
for dx in -hw..=hw {
for dy in -hw..=hw {
let distance_to_center = ((dx * dx + dy * dy) as f32).sqrt();
let coverage = remap_clamp(
distance_to_center,
(r as f32 - 0.5)..=(r as f32 + 0.5),
1.0..=0.0,
);
image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] =
coverage;
}
}
atlas.discs.push(PrerasterizedDisc {
r,
uv: Rectu {
min_x: x,
min_y: y,
max_x: x + w,
max_y: y + w,
},
});
}
atlas
}
pub fn size(&self) -> [usize; 2] {
self.image.size
}
/// Returns the locations and sizes of pre-rasterized discs (filled circles) in this atlas.
pub fn prepared_discs(&self) -> Vec<PreparedDisc> {
let size = self.size();
let inv_w = 1.0 / size[0] as f32;
let inv_h = 1.0 / size[1] as f32;
self.discs
.iter()
.map(|disc| {
let r = disc.r;
let Rectu {
min_x,
min_y,
max_x,
max_y,
} = disc.uv;
let w = max_x - min_x;
let uv = Rect::from_min_max(
emath::pos2(min_x as f32 * inv_w, min_y as f32 * inv_h),
emath::pos2(max_x as f32 * inv_w, max_y as f32 * inv_h),
);
PreparedDisc { r, w: w as f32, uv }
})
.collect()
}
fn max_height(&self) -> usize {
// the initial width is likely the max texture side size
self.image.width()