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:
parent
28efc0e1c8
commit
7b18fab7a4
12 changed files with 232 additions and 23 deletions
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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| {
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -73,6 +73,7 @@ impl Shadow {
|
|||
..Default::default()
|
||||
},
|
||||
font_tex_size,
|
||||
vec![],
|
||||
);
|
||||
let mut mesh = Mesh::default();
|
||||
tessellator.tessellate_rect(&rect, &mut mesh);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in a new issue