Files
deckcraft/crates/ui-egui/src/textures.rs
T

495 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Rendered slides as GPU textures: the canvas slide and thumbnails, cached by the slide's and its
//! master's `Arc` identity (an unchanged slide keeps its `Arc`, so it never re-renders).
//!
//! The canvas texture covers the slide plus whatever of the slide's drawing reaches off it and is
//! in view (PowerPoint's Normal view shows off-slide objects; thumbnails clip to the slide).
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_geom::Rect;
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
use deckcraft_render::{Image, Region, RenderOpts};
use egui::{ColorImage, TextureFilter, TextureHandle, TextureOptions, TextureWrapMode};
#[derive(Clone, PartialEq)]
struct Key {
slide: usize,
master: usize,
size: (u32, u32),
edit: bool,
extra: u64,
/// The pasteboard region the slide's drawing calls for (canvas only).
want: Region,
editing_text: Option<ShapeId>,
}
struct Entry {
key: Key,
tex: TextureHandle,
/// The region the texture covers (at least `key.want`).
region: Region,
// Keep the Arcs alive so their addresses can't be reused while cached.
_slide: Arc<Slide>,
_master: Option<Arc<Master>>,
stamp: u64,
}
#[derive(Default)]
pub struct Textures {
canvas: Option<Entry>,
/// How far the canvas slide's shapes reach (from their boxes), for the slide and master `Arc`s
/// held (kept alive so their addresses can't be reused).
estimate: Option<(Arc<Slide>, Option<Arc<Master>>, Rect)>,
/// How far the last canvas render of a slide (by index) actually drew: text spilling out of
/// its box shows there.
drawn: Option<(usize, Rect)>,
/// Canvas renders so far (tests, performance checks).
pub canvas_renders: u64,
thumbs: HashMap<usize, Entry>,
/// Thumbnails actually rendered, over the life of this cache. The number *held*
/// ([`Textures::thumb_count`]) cannot see a pane that renders the same rows over and over --
/// the cache thrash behind #969 -- because the count settles while the work does not.
thumb_renders: u64,
/// SmartArt gallery pictures by layout, style and colours (and, for hover previews, the
/// outline's hash).
smartart: HashMap<String, TextureHandle>,
clock: u64,
pub last_render_ms: f64,
}
impl Textures {
/// SmartArt gallery pictures cached so far (tests).
pub fn smartart_cached(&self) -> usize {
self.smartart.len()
}
/// A SmartArt layout drawn small for a gallery (`nodes` for a preview of the selected
/// graphic's own outline), cached; `look` is the layout kind, style and colours; drawn with the active presentation's theme.
pub fn smartart(
&mut self,
ctx: &egui::Context,
p: &deckcraft_model::Presentation,
slide: usize,
(kind, style, colors): (&str, &str, &str),
nodes: Option<&[deckcraft_model::DiagramNode]>,
width_px: u32,
) -> Option<TextureHandle> {
let mut key = format!("{kind}|{style}|{colors}|{width_px}");
if let Some(n) = nodes {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for x in n {
(x.text.as_str(), x.level).hash(&mut h);
}
key.push_str(&format!("|{:x}", h.finish()));
}
if let Some(t) = self.smartart.get(&key) {
return Some(t.clone());
}
let mut s = p.slides.get(slide).or_else(|| p.slides.first())?.as_ref().clone();
let g = deckcraft_engine::cmd::smartart::preview(kind, style, colors, nodes);
let id = g.id;
s.shapes = vec![g];
let img = deckcraft_render::render_shape(p, &s, id, width_px as f64 / 336.0)?;
let tex = upload(ctx, format!("smartart-{key}"), to_color_image(&img, false));
if self.smartart.len() > 300 {
self.smartart.clear();
}
self.smartart.insert(key, tex.clone());
Some(tex)
}
}
/// Snap `rect` onto the physical pixel grid.
///
/// The editor draws the slide as a texture of device pixels. A rect that starts or ends
/// between pixels is sampled with a linear filter, so every glyph edge is a blend of two
/// texels and small text looks soft. On a Retina display that offset is half a point, which
/// is the whole edge of an editing-size glyph. The slide show draws much larger glyphs, so
/// the same blend is not visible there (#184).
pub fn device_pixel_rect(rect: egui::Rect, ppp: f32) -> egui::Rect {
if !ppp.is_finite() || ppp <= 0.0 || !rect.is_finite() {
return rect;
}
let snap = |v: f32| {
let p = v * ppp;
if p.is_finite() { p.round() / ppp } else { v }
};
let min = egui::pos2(snap(rect.min.x), snap(rect.min.y));
let step = 1.0 / ppp;
let max = egui::pos2(snap(rect.max.x).max(min.x + step), snap(rect.max.y).max(min.y + step));
egui::Rect::from_min_max(min, max)
}
/// Editor slide textures. Nearest magnification keeps a texel that is stretched by a
/// fraction of a pixel from blurring the glyph; minification still blends when many texels
/// share a screen pixel.
pub fn editor_texture_options() -> TextureOptions {
TextureOptions {
magnification: TextureFilter::Nearest,
minification: TextureFilter::Linear,
wrap_mode: TextureWrapMode::ClampToEdge,
mipmap_mode: None,
}
}
pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage {
let w = img.width as usize;
let h = img.height as usize;
let mut px = Vec::with_capacity(w * h);
for c in img.pixels.as_chunks::<4>().0 {
let (r, g, b, a) = (c[0], c[1], c[2], c[3]);
if grayscale {
let y = ((r as u32 * 77 + g as u32 * 150 + b as u32 * 29) >> 8) as u8;
px.push(egui::Color32::from_rgba_premultiplied(y, y, y, a));
} else {
px.push(egui::Color32::from_rgba_premultiplied(r, g, b, a));
}
}
px.resize(w * h, egui::Color32::TRANSPARENT);
ColorImage::new([w, h], px)
}
/// The largest texture side the renderer (GPU or software fallback) takes; egui panics on
/// a bigger texture.
pub fn max_side(ctx: &egui::Context) -> u32 {
ctx.input(|i| i.max_texture_side).clamp(1, u32::MAX as usize) as u32
}
/// `(w, h)` scaled down, keeping its shape, so neither side exceeds `max` (and none is 0).
/// Render sizes go through this so a huge display, DPI or slide shape doesn't render a
/// picture the GPU can't hold.
pub fn fit_size((w, h): (u32, u32), max: u32) -> (u32, u32) {
let (w, h, max) = (w.max(1), h.max(1), max.max(1));
let big = w.max(h);
if big <= max {
return (w, h);
}
let k = max as f64 / big as f64;
(((w as f64 * k).floor() as u32).clamp(1, max), ((h as f64 * k).floor() as u32).clamp(1, max))
}
/// `img` shrunk by whole-pixel box averaging until both sides fit `max`.
pub fn shrink_to(img: ColorImage, max: u32) -> ColorImage {
let [w, h] = img.size;
let max = max.max(1) as usize;
if w <= max && h <= max {
return img;
}
let f = w.max(h).div_ceil(max);
let (nw, nh) = (w.div_ceil(f).max(1), h.div_ceil(f).max(1));
let mut px = Vec::with_capacity(nw * nh);
for y in 0..nh {
for x in 0..nw {
let mut acc = [0u32; 4];
let mut n = 0u32;
for sy in y * f..((y + 1) * f).min(h) {
for sx in x * f..((x + 1) * f).min(w) {
if let Some(c) = img.pixels.get(sy * w + sx) {
for (a, v) in acc.iter_mut().zip(c.to_array()) {
*a += v as u32;
}
n += 1;
}
}
}
let n = n.max(1);
px.push(egui::Color32::from_rgba_premultiplied((acc[0] / n) as u8, (acc[1] / n) as u8, (acc[2] / n) as u8, (acc[3] / n) as u8));
}
}
ColorImage::new([nw, nh], px)
}
/// Upload a texture, shrunk to the renderer's limit if need be.
pub fn upload(ctx: &egui::Context, name: impl Into<String>, img: ColorImage) -> TextureHandle {
ctx.load_texture(name, shrink_to(img, max_side(ctx)), TextureOptions::LINEAR)
}
/// Replace a texture's picture, shrunk to the renderer's limit if need be.
pub fn replace(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) {
tex.set(shrink_to(img, max_side(ctx)), TextureOptions::LINEAR);
}
/// [`upload`] for editor slide pictures, drawn on the device pixel grid with
/// [`editor_texture_options`] (#184).
pub fn upload_editor(ctx: &egui::Context, name: impl Into<String>, img: ColorImage) -> TextureHandle {
ctx.load_texture(name, shrink_to(img, max_side(ctx)), editor_texture_options())
}
/// [`replace`] for editor slide pictures (#184).
pub fn replace_editor(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) {
tex.set(shrink_to(img, max_side(ctx)), editor_texture_options());
}
fn master_of(p: &Presentation, slide: &Slide) -> Option<Arc<Master>> {
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
}
fn same(a: &Option<Arc<Master>>, b: &Option<Arc<Master>>) -> bool {
match (a, b) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
}
}
/// Mix the preview playhead into the canvas cache key. Zero means "no preview", so a playhead of
/// 0s still misses the at-rest texture (entrances start hidden).
fn anim_key(anim: Option<(&deckcraft_anim::Timeline, f64)>) -> u64 {
let Some((_, t)) = anim else { return 0 };
let ms = if t.is_finite() { (t * 1000.0).clamp(0.0, 1.0e12) as u64 } else { 0 };
ms.wrapping_add(1).wrapping_mul(0x9E37_79B9_7F4A_7C15)
}
fn threads() -> u16 {
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }
}
impl Textures {
/// The canvas texture for slide `index`, the slide being `size` pixels, and the region it
/// covers: the slide, grown over the pasteboard to show what reaches off the slide within
/// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the
/// GIF frames. `editing_text` replaces its placeholder prompt with gray empty list markers.
#[allow(clippy::too_many_arguments)]
pub fn slide(
&mut self,
ctx: &egui::Context,
p: &Presentation,
index: usize,
size: (u32, u32),
visible: Region,
edit: bool,
extra: u64,
grayscale: bool,
gif_times: &[(ShapeId, f64)],
editing_text: Option<ShapeId>,
anim: Option<(&deckcraft_anim::Timeline, f64)>,
) -> Option<(TextureHandle, Region)> {
let slide = p.slides.get(index)?.clone();
let master = master_of(p, &slide);
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
let estimate = match &self.estimate {
Some((s, m, r)) if Arc::ptr_eq(s, &slide) && same(m, &master) => *r,
_ => {
let r = deckcraft_render::slide_extent(p, index);
self.estimate = Some((slide.clone(), master.clone(), r));
r
}
};
let reach = match self.drawn {
Some((i, d)) if i == index => estimate.union(d),
_ => estimate,
};
// The GPU's (or software fallback's) texture limit: a larger canvas texture panics in egui.
let max_side = max_side(ctx);
let want = fit_texture(deckcraft_render::pasteboard_region(size, scale, reach, visible), visible, max_side);
let key = Key {
slide: Arc::as_ptr(&slide) as usize,
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
size,
edit,
extra: extra ^ grayscale as u64 ^ anim_key(anim),
want,
editing_text,
};
if let Some(e) = &self.canvas
&& e.key == key
{
return Some((e.tex.clone(), e.region));
}
let t0 = crate::now_ms();
let render = |region: Region| {
let state_of = |id: ShapeId| anim.map(|(tl, time)| crate::preview::shape_state_at(tl, id, time)).unwrap_or_default();
let state: Option<&dyn Fn(ShapeId) -> deckcraft_render::ShapeState> = if anim.is_some() { Some(&state_of) } else { None };
let opts = RenderOpts {
scale,
edit,
gif_times,
threads: threads(),
offset: region.offset(),
size: Some(region.size()),
state,
editing_text,
..Default::default()
};
deckcraft_render::render_slide_drawn(p, index, &opts)
};
let (mut img, drawn) = render(want);
self.canvas_renders += 1;
let mut region = want;
// Text spilling out of its box can reach further than the boxes said. Once drawn, the
// region it calls for is known (and is next frame's key): render again only if it shows
// more than this render covered.
let key =
Key { want: fit_texture(deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), visible, max_side), ..key };
if !region.contains(&key.want) {
region = key.want;
img = render(region).0;
self.canvas_renders += 1;
}
self.drawn = Some((index, drawn));
self.last_render_ms = crate::now_ms() - t0;
let ci = to_color_image(&img, grayscale);
let tex = match self.canvas.take() {
Some(mut e) => {
replace_editor(ctx, &mut e.tex, ci);
e.tex
}
None => upload_editor(ctx, "slide-canvas", ci),
};
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
Some((tex, region))
}
/// Cached thumbnails (bounded; soak tests check it).
pub fn thumb_count(&self) -> usize {
self.thumbs.len()
}
/// How many thumbnails this cache has rendered, ever. A pane that is done asks for no more:
/// the number stops climbing, which is what tells a thrashing pane from a settled one.
pub fn thumb_renders(&self) -> u64 {
self.thumb_renders
}
/// Force the canvas to re-render next time (fonts loaded, theme changed in place…).
pub fn invalidate(&mut self) {
self.canvas = None;
self.estimate = None;
self.drawn = None;
self.thumbs.clear();
}
/// A thumbnail texture; renders at most `budget` new thumbnails per frame (returns None when
/// it is over budget, so the caller draws a placeholder and asks for another frame).
pub fn thumb(&mut self, ctx: &egui::Context, p: &Presentation, index: usize, width_px: u32, budget: &mut u32) -> Option<TextureHandle> {
let slide = p.slides.get(index)?.clone();
let master = master_of(p, &slide);
let h = (width_px as f64 * p.slide_size.height / p.slide_size.width.max(1.0)).round().max(1.0) as u32;
let key = Key {
slide: Arc::as_ptr(&slide) as usize,
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
size: (width_px, h),
edit: false,
extra: 0,
want: Region::slide(width_px, h),
editing_text: None,
};
self.clock += 1;
let clock = self.clock;
if let Some(e) = self.thumbs.get_mut(&key.slide)
&& e.key == key
{
e.stamp = clock;
return Some(e.tex.clone());
}
if *budget == 0 {
// Keep showing the stale thumbnail while the new one waits.
return self.thumbs.get(&key.slide).map(|e| e.tex.clone());
}
*budget -= 1;
let (tw, th) = fit_size((width_px, h), max_side(ctx));
let scale = tw as f64 / p.slide_size.width.max(1.0);
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() });
self.thumb_renders += 1;
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, false));
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
let region = key.want;
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
if self.thumbs.len() > 600 {
let mut v: Vec<(usize, u64)> = self.thumbs.iter().map(|(k, e)| (*k, e.stamp)).collect();
v.sort_by_key(|x| x.1);
for (k, _) in v.into_iter().take(200) {
self.thumbs.remove(&k);
}
}
Some(tex)
}
}
#[cfg(test)]
mod tests {
use super::device_pixel_rect;
use egui::{Rect, pos2, vec2};
fn on_grid(v: f32, ppp: f32) -> bool {
let px = v * ppp;
(px - px.round()).abs() < 1e-3
}
#[test]
fn a_retina_slide_rect_lands_on_device_pixels() {
let ppp = 2.0;
let r = Rect::from_min_size(pos2(10.3, 20.25), vec2(100.2, 50.4));
let s = device_pixel_rect(r, ppp);
assert!(on_grid(s.min.x, ppp) && on_grid(s.min.y, ppp) && on_grid(s.max.x, ppp) && on_grid(s.max.y, ppp));
assert!(s.width() > 0.0 && s.height() > 0.0);
// One texture pixel per device pixel: the snapped size is an integer pixel count.
let px = ((s.width() * ppp).round() as u32, (s.height() * ppp).round() as u32);
assert!((s.width() / px.0 as f32 * ppp - 1.0).abs() < 1e-3);
assert!((s.height() / px.1 as f32 * ppp - 1.0).abs() < 1e-3);
let aligned = Rect::from_min_max(pos2(1.0, 2.5), pos2(80.0, 40.0));
assert_eq!(device_pixel_rect(aligned, ppp), aligned);
}
#[test]
fn a_bad_scale_does_not_move_the_rect() {
let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0));
assert_eq!(device_pixel_rect(r, 0.0), r);
assert_eq!(device_pixel_rect(r, f32::NAN), r);
}
}
/// `r` cut to at most `max` pixels a side, keeping as much of `visible` as fits.
pub(crate) fn fit_texture(mut r: Region, visible: Region, max: u32) -> Region {
fn axis(start: &mut i32, len: &mut u32, vis: i32, max: u32) {
if *len > max {
let hi = *start as i64 + (*len - max) as i64;
*start = (vis as i64).clamp(*start as i64, hi) as i32;
*len = max;
}
}
axis(&mut r.x, &mut r.w, visible.x, max);
axis(&mut r.y, &mut r.h, visible.y, max);
r
}
#[cfg(test)]
mod fit_tests {
use super::*;
#[test]
fn canvas_textures_stay_within_the_texture_limit() {
let r = Region { x: -100, y: -50, w: 5000, h: 1000 };
let f = fit_texture(r, Region { x: 300, y: 0, w: 800, h: 600 }, 2048);
assert_eq!((f.x, f.y, f.w, f.h), (300, -50, 2048, 1000));
// Visible past the far end: the cut is pinned there.
let f = fit_texture(r, Region { x: 4800, y: 0, w: 800, h: 600 }, 2048);
assert_eq!((f.x, f.w), (-100 + 5000 - 2048, 2048));
assert_eq!(fit_texture(r, r, 8192), r);
}
#[test]
fn render_sizes_and_pictures_shrink_to_the_limit_keeping_their_shape() {
assert_eq!(fit_size((195, 10920), 2048), (36, 2048));
assert_eq!(fit_size((3000, 1500), 2048), (2048, 1024));
assert_eq!(fit_size((800, 600), 2048), (800, 600));
assert_eq!(fit_size((0, 0), 0), (1, 1));
// A 5000 × 3 picture: half red, half blue, averaged down to 1667 × 1.
let img = ColorImage::new(
[5000, 3],
(0..3)
.flat_map(|y| (0..5000).map(move |x| (x, y)))
.map(|(x, _)| if x < 2500 { egui::Color32::RED } else { egui::Color32::BLUE })
.collect(),
);
let small = shrink_to(img, 2048);
assert_eq!(small.size, [1667, 1]);
assert_eq!(small.pixels[0], egui::Color32::RED);
assert_eq!(small.pixels[1666], egui::Color32::BLUE);
let ok = ColorImage::filled([10, 10], egui::Color32::WHITE);
assert_eq!(shrink_to(ok.clone(), 2048).size, ok.size);
}
}