Merge PR #652 from NikitkaRa: UI: keep every texture within the GPU max texture side

# Conflicts:
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panes.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:37:12 -04:00
9 changed files with 184 additions and 32 deletions
+6 -2
View File
@@ -375,14 +375,18 @@ fn master_tex(
visible: deckcraft_render::Region,
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible);
let region = crate::textures::fit_texture(
deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible),
visible,
crate::textures::max_side(ctx),
);
let img = deckcraft_render::render_layout(
doc,
master,
layout,
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
);
Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region))
Some((crate::textures::upload(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
}
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
+2
View File
@@ -1344,6 +1344,8 @@ mod replacefonts_tests;
#[cfg(test)]
mod slide_size_tests;
#[cfg(test)]
mod texture_limit_tests;
#[cfg(test)]
mod ui_soak_tests;
#[cfg(test)]
+2 -2
View File
@@ -287,8 +287,8 @@ impl MediaHost {
{
let img = egui::ColorImage::from_rgba_unmultiplied([f.width as usize, f.height as usize], &f.rgba);
match v.tex.as_mut() {
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
None => v.tex = Some(ctx.load_texture(format!("media-{id}"), img, egui::TextureOptions::LINEAR)),
Some(t) => crate::textures::replace(ctx, t, img),
None => v.tex = Some(crate::textures::upload(ctx, format!("media-{id}"), img)),
}
v.frame = Some(f);
}
+5 -2
View File
@@ -1002,13 +1002,16 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
if cache.key != key || cache.tex.is_empty() {
cache = IdeaThumbs { key, tex: vec![] };
let mut render = |name: String, d: &deckcraft_model::Presentation| {
let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32;
let (px, h) = crate::textures::fit_size(
(px, (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32),
crate::textures::max_side(ui.ctx()),
);
let img = deckcraft_render::render_slide(
d,
idx,
&deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() },
);
let tex = ui.ctx().load_texture(format!("idea-{name}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR);
let tex = crate::textures::upload(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false));
cache.tex.push((name, tex, px, h));
};
for (id, _) in &ideas {
+1 -2
View File
@@ -395,8 +395,7 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) {
..Default::default()
},
);
let tex =
ui.ctx().load_texture(format!("layout-{li}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR);
let tex = crate::textures::upload(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false));
ui.painter().image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
let t = Tokens::get(ui.ctx());
+19 -13
View File
@@ -399,7 +399,7 @@ fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option<usize>, t
let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() };
let tex = |i: usize, part: SlidePart, name: &str| {
let img = deckcraft_render::render_slide_part(doc, i, part, &opts);
ctx.load_texture(name, crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR)
crate::textures::upload(ctx, name, crate::textures::to_color_image(&img, false))
};
[
from.map(|i| tex(i, SlidePart::Backdrop, "trans-old-back")),
@@ -649,7 +649,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
let painter = ui.painter_at(full);
painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK);
let ppp = ctx.pixels_per_point();
let size = ((srect.width() * ppp).round().max(1.0) as u32, (srect.height() * ppp).round().max(1.0) as u32);
// A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit
// (drawn stretched to the screen).
let size = crate::textures::fit_size(
((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32),
crate::textures::max_side(&ctx),
);
if show.ended {
painter.text(
pos2(full.center().x, full.min.y + 40.0),
@@ -697,10 +702,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
};
let tex = match show.tex.take() {
Some((_, mut h)) => {
h.set(ci, egui::TextureOptions::LINEAR);
crate::textures::replace(&ctx, &mut h, ci);
h
}
None => ctx.load_texture("show", ci, egui::TextureOptions::LINEAR),
None => crate::textures::upload(&ctx, "show", ci),
};
show.tex = Some((key, tex.clone()));
Some(tex)
@@ -753,10 +758,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
let ci = crate::textures::to_color_image(&img, false);
let frame = match tr.morph.take() {
Some(mut h) => {
h.set(ci, egui::TextureOptions::LINEAR);
crate::textures::replace(&ctx, &mut h, ci);
h
}
None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR),
None => crate::textures::upload(&ctx, "morph", ci),
};
painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
tr.morph = Some(frame);
@@ -1696,16 +1701,17 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
if let Some(n) = next
&& show.next_tex.as_ref().is_none_or(|(k, _)| *k != n)
{
// A very tall slide shape would otherwise ask for a picture taller than the GPU's limit.
let next_size = crate::textures::fit_size(
(480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32),
crate::textures::max_side(ctx),
);
let img = deckcraft_render::render_slide(
doc,
n,
&RenderOpts {
scale: 480.0 / doc.slide_size.width.max(1.0),
size: Some((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)),
..Default::default()
},
&RenderOpts { scale: next_size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(next_size), ..Default::default() },
);
show.next_tex = Some((n, ctx.load_texture("presenter-next", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR)));
show.next_tex = Some((n, crate::textures::upload(ctx, "presenter-next", crate::textures::to_color_image(&img, false))));
}
let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone());
let notes = doc.slides.get(cur).map(|s| s.notes_text()).unwrap_or_default();
@@ -1963,7 +1969,7 @@ mod tests {
s.execute("transition.set", &json!({"kind": "fade", "index": 1})).unwrap();
let doc = s.active().unwrap().doc.clone();
let ctx = egui::Context::default();
let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR);
let red = crate::textures::upload(&ctx, "show", egui::ColorImage::filled([1, 1], Color32::RED));
let mut show = Show::new(&doc, 0, false, false);
show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0, plays: vec![] }, red.clone()));
assert!(show.apply(ShowAction::Slide(1), &doc, 0.0));
+53
View File
@@ -0,0 +1,53 @@
//! Textures stay within the renderer's `max_texture_side` (2048 in the test harness, as on many
//! GPUs and software fallbacks): bigger ones panicked in egui. Slide show on a big screen,
//! thumbnails, master views and presenter view of a very tall slide.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use serde_json::json;
use crate::{Services, SlideApp};
use deckcraft_engine::Session;
fn harness(session: Session, size: egui::Vec2) -> egui_kittest::Harness<'static, SlideApp> {
egui_kittest::Harness::builder().with_size(size).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(session, Services::default()),
)
}
/// The tallest slide allowed (1 × 56): its thumbnails and presenter-view preview are tens of
/// thousands of pixels high at their usual widths.
fn tall_deck() -> Session {
let mut s = Session::with_new();
s.execute("design.slideSize", &json!({"w": 72, "h": 4032})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s
}
#[test]
fn a_slide_show_on_a_screen_wider_than_the_texture_limit() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({})).unwrap();
let mut h = harness(s, egui::vec2(2600.0, 1500.0));
h.run_steps(2);
h.state_mut().run("show.start", json!({})).unwrap();
h.run_steps(4);
assert!(h.state().show.is_some());
}
#[test]
fn a_very_tall_slide_in_the_editor_views_and_presenter_view() {
let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0));
h.run_steps(3);
for (cmd, p) in [("view.sorter", json!({})), ("view.normal", json!({})), ("view.slideMaster", json!({})), ("view.closeMaster", json!({}))] {
let _ = h.state_mut().run(cmd, p);
h.run_steps(3);
}
h.state_mut().run("show.presenter", json!({"on": true})).unwrap();
h.state_mut().run("show.start", json!({"from": 0})).unwrap();
h.run_steps(4);
assert!(h.state().show.is_some());
}
+89 -6
View File
@@ -115,6 +115,66 @@ pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage {
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);
}
fn master_of(p: &Presentation, slide: &Slide) -> Option<Arc<Master>> {
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
}
@@ -165,7 +225,7 @@ impl Textures {
_ => estimate,
};
// The GPU's (or software fallback's) texture limit: a larger canvas texture panics in egui.
let max_side = ctx.input(|i| i.max_texture_side).max(1) as u32;
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,
@@ -204,10 +264,10 @@ impl Textures {
let ci = to_color_image(&img, grayscale);
let tex = match self.canvas.take() {
Some(mut e) => {
e.tex.set(ci, TextureOptions::LINEAR);
replace(ctx, &mut e.tex, ci);
e.tex
}
None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR),
None => upload(ctx, "slide-canvas", ci),
};
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
Some((tex, region))
@@ -253,9 +313,10 @@ impl Textures {
return self.thumbs.get(&key.slide).map(|e| e.tex.clone());
}
*budget -= 1;
let scale = width_px as f64 / p.slide_size.width.max(1.0);
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() });
let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR);
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() });
let tex = upload(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 });
@@ -298,4 +359,26 @@ mod fit_tests {
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);
}
}
+7 -5
View File
@@ -278,20 +278,22 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) {
let (row, resp) = ui.allocate_exact_size(vec2(width, th + 12.0), Sense::click());
let img = Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th));
let ppp = ui.ctx().pixels_per_point();
let px = ((tw * ppp) as u32).max(1);
let px = crate::textures::fit_size(
(((tw * ppp) as u32).max(1), ((tw * ppp) as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32),
crate::textures::max_side(ui.ctx()),
);
let pic = deckcraft_render::render_layout(
&doc,
mi,
li,
&deckcraft_render::RenderOpts {
scale: px as f64 / doc.slide_size.width.max(1.0),
scale: px.0 as f64 / doc.slide_size.width.max(1.0),
edit: true,
size: Some((px, (px as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)),
size: Some(px),
..Default::default()
},
);
let tex =
ui.ctx().load_texture(format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false), egui::TextureOptions::LINEAR);
let tex = crate::textures::upload(ui.ctx(), format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false));
ui.painter().image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
let selected = match (target, li) {
(Target::Master { master }, None) => master == mi,