mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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) {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user