From bf173db1de94d1f9271bdbf9a2443111aac66d38 Mon Sep 17 00:00:00 2001 From: con3code Date: Fri, 9 Oct 2026 23:15:00 +0900 Subject: [PATCH 1/2] UI: no crash on textures past the GPU limit The Glyphs panel drew its whole grid as one texture: up to 1200 characters of a large CJK font in a narrow panel (7 columns x 60 px) made it 10320 px tall and wgpu panicked past the 8192 px limit. The grid now stops at the rows the largest texture holds, and every UI texture goes through widgets::fit_texture / load_texture, which sample an oversized image down instead of panicking. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/ui-egui/src/about.rs | 2 +- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/panels/glyphs.rs | 8 +++-- crates/ui-egui/src/panels/mod.rs | 2 +- crates/ui-egui/src/panels/pages.rs | 2 +- crates/ui-egui/src/widgets.rs | 46 +++++++++++++++++++++++++++++ 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs index 4eae2e7c6..badcb0a04 100644 --- a/crates/ui-egui/src/about.rs +++ b/crates/ui-egui/src/about.rs @@ -52,7 +52,7 @@ pub fn paint_mark(ui: &egui::Ui, rect: Rect, color: Color32) { let mut res = vello_cpu::Resources::new(); rc.render(&mut pm, &mut res); let ci = egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pm.data_as_u8_slice()); - let t = ctx.load_texture("artcraft_mark", ci, egui::TextureOptions::LINEAR); + let t = crate::widgets::load_texture(ctx, "artcraft_mark", ci, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(key, t.clone())); t }); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34dd61442..db4f8c9a0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -573,6 +573,7 @@ fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render: designcraft_render::separation_view(&mut img, None, app.ui.ink_limit); } let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let ci = crate::widgets::fit_texture(ci, ctx.input(|i| i.max_texture_side)); match &mut app.canvas.texture { Some(tex) => tex.set(ci, egui::TextureOptions::LINEAR), None => app.canvas.texture = Some(ctx.load_texture("canvas", ci, egui::TextureOptions::LINEAR)), @@ -1154,7 +1155,7 @@ fn draw_inverse_highlight( let mut res = vello_cpu::Resources::new(); rc.render(&mut pm, &mut res); let ci = egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], pm.data_as_u8_slice()); - let t = ctx.load_texture("inverse_selection", ci, egui::TextureOptions::NEAREST); + let t = crate::widgets::load_texture(ctx, "inverse_selection", ci, egui::TextureOptions::NEAREST); ctx.data_mut(|d| d.insert_temp(cache_id, (key, t.clone()))); t } diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 3fb9d00eb..f8fd8fdf4 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -73,7 +73,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } let cols = ((ui.available_width() / CELL).floor() as u32).max(1); let ppp = ui.ctx().pixels_per_point(); - let cell_px = (CELL * ppp).round() as u32; + let cell_px = (CELL * ppp).round().max(1.0) as u32; + // The grid is one texture: no more rows than the GPU's largest texture holds (a narrow + // panel of a large CJK font ran past it). + let max_rows = (ui.ctx().input(|i| i.max_texture_side) as u32 / cell_px).max(1); + chars.truncate((max_rows * cols) as usize); let ink = t.text_strong; let key = egui::Id::new(("glyph_grid", &st.family, &st.style, scope, &q, cols, cell_px, ink.to_array())); let tex: egui::TextureHandle = match ui.data(|d| d.get_temp::(key)) { @@ -81,7 +85,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { None => { let img = designcraft_render::glyphs::glyph_grid(&db, &st.family, &st.style, &chars, cols, cell_px, ink.to_array()); let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let h = ui.ctx().load_texture("glyph_grid", ci, egui::TextureOptions::LINEAR); + let h = crate::widgets::load_texture(ui.ctx(), "glyph_grid", ci, egui::TextureOptions::LINEAR); ui.data_mut(|d| d.insert_temp(key, h.clone())); h } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 05a73395b..047345c83 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -353,7 +353,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf let tex = tex.unwrap_or_else(|| { let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array()); let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR); + let h = crate::widgets::load_texture(ui.ctx(), format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR); ui.data_mut(|d| d.insert_temp(key, h.clone())); h }); diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6bbad9066..11f9a531d 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -34,7 +34,7 @@ fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option &designcraft_render::RenderOptions { printing_only: true, ..Default::default() }, )?; let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let tex = ctx.load_texture(format!("thumb{abs}"), ci, egui::TextureOptions::LINEAR); + let tex = crate::widgets::load_texture(ctx, format!("thumb{abs}"), ci, egui::TextureOptions::LINEAR); // Drop thumbnails of older revisions. let (uid, rev) = (st.uid, st.revision); map.retain(|k, _| !(k.0 == uid && k.1 != rev)); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b13627c9c..78515f85d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -712,3 +712,49 @@ mod tests { assert_eq!(m.step(), 1.0); } } + +/// `img` made to fit the GPU's largest texture (`max_side` pixels a side), sampled down +/// (nearest pixel) when it is larger. A texture past the limit panics in the renderer, so every +/// UI texture goes through this: an oversized image loses resolution, not the document. +pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage { + let [w, h] = img.size; + let max_side = max_side.max(1); + if w <= max_side && h <= max_side { + return img; + } + let k = (max_side as f64 / w.max(h) as f64).min(1.0); + let (nw, nh) = (((w as f64 * k).floor() as usize).clamp(1, max_side), ((h as f64 * k).floor() as usize).clamp(1, max_side)); + let mut pixels = Vec::with_capacity(nw * nh); + for y in 0..nh { + let sy = (y * h / nh).min(h.saturating_sub(1)); + for x in 0..nw { + let sx = (x * w / nw).min(w.saturating_sub(1)); + pixels.push(img.pixels.get(sy * w + sx).copied().unwrap_or(Color32::TRANSPARENT)); + } + } + egui::ColorImage::new([nw, nh], pixels) +} + +/// [`egui::Context::load_texture`] for an image of any size (see [`fit_texture`]). +pub fn load_texture(ctx: &egui::Context, name: impl Into, img: egui::ColorImage, options: egui::TextureOptions) -> egui::TextureHandle { + let max = ctx.input(|i| i.max_texture_side); + ctx.load_texture(name, fit_texture(img, max), options) +} + +#[cfg(test)] +mod texture_tests { + /// The glyph grid that crashed the renderer: 7 columns of 1200 characters at 60 px, 10320 px + /// tall against an 8192 px limit. + #[test] + fn oversized_images_fit_the_texture_limit() { + let img = egui::ColorImage::new([420, 10320], vec![egui::Color32::WHITE; 420 * 10320]); + let out = super::fit_texture(img, 8192); + assert!(out.size[0] <= 8192 && out.size[1] == 8192, "{:?}", out.size); + assert_eq!(out.pixels.len(), out.size[0] * out.size[1]); + let small = egui::ColorImage::new([10, 10], vec![egui::Color32::RED; 100]); + assert_eq!(super::fit_texture(small, 8192).size, [10, 10]); + let wide = egui::ColorImage::new([20000, 3], vec![egui::Color32::RED; 60000]); + let out = super::fit_texture(wide, 8192); + assert!(out.size[0] == 8192 && out.size[1] >= 1); + } +} From cc836fe4dffc427b7377d00c4212c94904f75e1c Mon Sep 17 00:00:00 2001 From: con3code Date: Sun, 11 Oct 2026 08:25:43 +0900 Subject: [PATCH 2/2] UI: an edit repaints a sampled-down canvas texture whole patch_texture computed the damaged region in view pixels and wrote it with set_partial into the canvas texture upload() had sampled down to the GPU limit: a viewport wider than max_texture_side put the region in the wrong place or outside the texture (a wgpu validation error on the first edit). Such a texture is now rendered whole. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/ui-egui/src/canvas.rs | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db4f8c9a0..a52569490 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -493,6 +493,11 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: let Some(regions) = designcraft_render::damage::damage_with(&old, &new, Some(&app.session.cache)) else { return false }; let t0 = crate::now_ms(); let (tw, th) = ((sh.size.0 as f64 * ppp).round() as i64, (sh.size.1 as f64 * ppp).round() as i64); + // A texture `upload` sampled down to the GPU limit has other pixels than the view: a region + // computed in view pixels would be written outside it. Render it whole instead. + if app.canvas.texture.as_ref().is_none_or(|t| t.size() != [tw.max(0) as usize, th.max(0) as usize]) { + return false; + } let k = sh.zoom * ppp; let mut px: Option<(i64, i64, i64, i64)> = None; for (r, rect) in ®ions { @@ -1773,6 +1778,38 @@ mod tests { (s.horizontal_units, s.vertical_units) } + /// A viewport wider than the GPU's largest texture: the canvas texture is sampled down, and an + /// edit repaints it whole. A region repainted in view pixels would land in the wrong place, + /// or outside the texture (a wgpu validation error). A view that fits is still patched. + #[test] + fn an_edit_repaints_a_canvas_texture_sampled_down_to_the_gpu_limit_whole() { + for (width, sampled_down) in [(1440.0, false), (2600.0, true)] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Text"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(width, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let limit = h.ctx.input(|i| i.max_texture_side); + let size = h.state().canvas.texture.as_ref().map(|t| t.size()).unwrap(); + assert!(size[0] <= limit && size[1] <= limit, "{size:?}"); + let shown = h.state().canvas.shown.unwrap().size; + assert_eq!(shown.0 > limit as f32, sampled_down, "view {shown:?}, limit {limit}"); + let patches = h.state().canvas.patches; + // A small edit: one more character in the frame. + h.state_mut().run("text.insert", json!({"story": frame["story"], "at": 4, "text": "s"})).unwrap(); + h.run_steps(6); + assert_eq!(h.state().canvas.patches == patches, sampled_down, "width {width}"); + assert_eq!(h.state().canvas.texture.as_ref().map(|t| t.size()), Some(size)); + } + } + #[test] fn right_clicking_a_ruler_sets_that_rulers_units() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());