From f24e7750e6e773644c29105884a9c2874b56c80f Mon Sep 17 00:00:00 2001 From: Harald Date: Mon, 12 Oct 2026 22:22:21 +0200 Subject: [PATCH] perf: Mipmaps importierter PDF-Seiten im Render-Thread (Issue #28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim Scrollen durch große PDFs stockte die Anzeige bei jeder neu gerenderten Seite: Der Haupt-Thread erzeugte die Mipmaps, bei 2560 px etwa 100–200 ms, beim Hineinzoomen (5120 px) bis zu 1 s. - Der PDF-Render-Thread liefert die fertigen Mipmap-Stufen mit; der Haupt-Thread lädt sie nur noch hoch (TextureSource::ReadyMips). - mip_chain halbiert mit einem 2×2-Mittelwert statt imageops::resize: etwa 7-mal schneller (2560 px: ~15 ms statt ~100–200 ms). - Belegter Grafikspeicher wird aus den tatsächlichen Stufen berechnet. Co-Authored-By: Claude Opus 5.5 --- src/image_layer.rs | 121 +++++++++++++++++++++++++++++++++++---------- src/main.rs | 32 ++++++------ src/pdf_doc.rs | 21 +++++--- 3 files changed, 123 insertions(+), 51 deletions(-) diff --git a/src/image_layer.rs b/src/image_layer.rs index c403545..90aae3d 100644 --- a/src/image_layer.rs +++ b/src/image_layer.rs @@ -45,26 +45,57 @@ pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] { [tl, tr, bl, bl, tr, br] } -/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1. -pub fn mip_chain(base: &RgbaImage) -> Vec { - let mut levels = vec![base.clone()]; - let Some(mut current) = - image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone()) - else { - return levels; - }; - while current.width() > 1 || current.height() > 1 { - let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1)); - current = image::imageops::resize(¤t, w, h, image::imageops::FilterType::Triangle); - levels.push(RgbaImage { - width: w, - height: h, - pixels: current.as_raw().clone(), - }); +/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1. Für eine gerenderte +/// PDF-Seite dauert das spürbar (2560 px: einige 10 ms); deshalb erzeugt sie der +/// PDF-Render-Thread und nicht der Haupt-Thread. +pub fn mip_chain(base: RgbaImage) -> Vec { + let mut levels = vec![base]; + loop { + let current = &levels[levels.len() - 1]; + if current.width <= 1 && current.height <= 1 { + break; + } + let next = half_size(current); + levels.push(next); } levels } +/// Halbiert ein Bild: Mittelwert aus je 2 × 2 Pixeln (bei ungerader Kante entfällt die +/// letzte Spalte bzw. Zeile, bei Kantenlänge 1 wird nur in der anderen Richtung gemittelt). +fn half_size(image: &RgbaImage) -> RgbaImage { + let (w, h) = (image.width as usize, image.height as usize); + let (nw, nh) = ((w / 2).max(1), (h / 2).max(1)); + let mut pixels = vec![0u8; nw * nh * 4]; + if image.pixels.len() < w * h * 4 { + return RgbaImage { + width: nw as u32, + height: nh as u32, + pixels, + }; + } + for y in 0..nh { + let (y0, y1) = ((2 * y).min(h - 1), (2 * y + 1).min(h - 1)); + let (row0, row1) = (&image.pixels[y0 * w * 4..], &image.pixels[y1 * w * 4..]); + let out = &mut pixels[y * nw * 4..(y + 1) * nw * 4]; + for x in 0..nw { + let (x0, x1) = ((2 * x).min(w - 1) * 4, (2 * x + 1).min(w - 1) * 4); + for c in 0..4 { + let sum = row0[x0 + c] as u16 + + row0[x1 + c] as u16 + + row1[x0 + c] as u16 + + row1[x1 + c] as u16; + out[x * 4 + c] = ((sum + 2) / 4) as u8; + } + } + } + RgbaImage { + width: nw as u32, + height: nh as u32, + pixels, + } +} + /// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist. const MISSING: &str = ""; /// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles @@ -74,6 +105,8 @@ const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024; /// Antwort einer Texturquelle für einen Schlüssel. pub enum TextureSource { Ready(RgbaImage), + /// Fertige Mipmap-Stufen (siehe [`mip_chain`]), z. B. vom PDF-Render-Thread + ReadyMips(Vec), /// Wird gerade erzeugt; später erneut fragen Pending, Failed(String), @@ -233,10 +266,16 @@ impl ImageLayer { continue; } let (bind_group, bytes) = match load(&key) { - TextureSource::Ready(image) => ( - self.upload_texture(device, queue, &key, &image), - image.pixels.len() as u64 * 4 / 3, - ), + TextureSource::Ready(image) => { + self.upload_texture(device, queue, &key, mip_chain(image)) + } + TextureSource::ReadyMips(levels) if !levels.is_empty() => { + self.upload_texture(device, queue, &key, levels) + } + TextureSource::ReadyMips(_) => { + eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: leeres Bild"); + (self.missing_texture(device, queue), 4) + } // Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen TextureSource::Pending => continue, TextureSource::Failed(err) => { @@ -342,17 +381,19 @@ impl ImageLayer { height: 1, pixels: vec![200, 200, 200, 255], }; - self.upload_texture(device, queue, MISSING, &gray) + self.upload_texture(device, queue, MISSING, vec![gray]).0 } + /// Lädt die Mipmap-Stufen hoch (Stufe 0 = volle Größe); liefert die Bind Group und den + /// belegten Grafikspeicher. fn upload_texture( &self, device: &wgpu::Device, queue: &wgpu::Queue, label: &str, - image: &RgbaImage, - ) -> wgpu::BindGroup { - let levels = mip_chain(image); + levels: Vec, + ) -> (wgpu::BindGroup, u64) { + let image = &levels[0]; let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some(label), size: wgpu::Extent3d { @@ -389,8 +430,9 @@ impl ImageLayer { }, ); } + let bytes = levels.iter().map(|l| l.pixels.len() as u64).sum(); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - device.create_bind_group(&wgpu::BindGroupDescriptor { + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Image Texture Bind Group"), layout: &self.texture_layout, entries: &[ @@ -403,7 +445,8 @@ impl ImageLayer { resource: wgpu::BindingResource::Sampler(&self.sampler), }, ], - }) + }); + (bind_group, bytes) } } @@ -439,11 +482,35 @@ mod tests { height: 3, pixels: vec![128; 8 * 3 * 4], }; - let levels = mip_chain(&base); + let levels = mip_chain(base); let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect(); assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]); for l in &levels { assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize); } } + + #[test] + fn test_half_size_averages_two_by_two_blocks() { + // 3 × 2: links schwarz/weiß übereinander, Mitte grau; die dritte Spalte entfällt + let px = |v: u8| [v, v, v, 255]; + let pixels = [px(0), px(100), px(7), px(255), px(100), px(7)].concat(); + let base = RgbaImage { + width: 3, + height: 2, + pixels, + }; + let half = half_size(&base); + assert_eq!((half.width, half.height), (1, 1)); + // (0 + 100 + 255 + 100) / 4 = 113,75 → 114 + assert_eq!(half.pixels, vec![114, 114, 114, 255]); + + // Kantenlänge 1: nur in der anderen Richtung mitteln + let column = RgbaImage { + width: 1, + height: 2, + pixels: [px(10), px(20)].concat(), + }; + assert_eq!(half_size(&column).pixels, vec![15, 15, 15, 255]); + } } diff --git a/src/main.rs b/src/main.rs index 61b168a..6a2cf82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -941,25 +941,21 @@ fn texture_for( assets: &AssetStore, pdf_renderer: &mut PdfRenderService, ) -> TextureSource { - let result = match key.contains('#') { - true => { - let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key) - .map(|(asset, page, px)| (assets.path_of(asset), page, px)) - else { - return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key)); - }; - match pdf_renderer.take(key, path, page, max_pixels) { - Some(result) => result, - None => return TextureSource::Pending, - } - } - false => assets - .load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION) - .map_err(|e| e.to_string()), + if !key.contains('#') { + return match assets.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION) { + Ok(image) => TextureSource::Ready(image), + Err(err) => TextureSource::Failed(err.to_string()), + }; + } + let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key) + .map(|(asset, page, px)| (assets.path_of(asset), page, px)) + else { + return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key)); }; - match result { - Ok(image) => TextureSource::Ready(image), - Err(err) => TextureSource::Failed(err), + match pdf_renderer.take(key, path, page, max_pixels) { + Some(Ok(levels)) => TextureSource::ReadyMips(levels), + Some(Err(err)) => TextureSource::Failed(err), + None => TextureSource::Pending, } } diff --git a/src/pdf_doc.rs b/src/pdf_doc.rs index a1a11b0..cbd9dab 100644 --- a/src/pdf_doc.rs +++ b/src/pdf_doc.rs @@ -1,7 +1,8 @@ //! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU). //! //! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`] -//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück. +//! erledigt es deshalb in einem Hintergrund-Thread, samt Mipmaps für die Anzeige, und meldet +//! fertige Seiten zurück. use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -106,8 +107,9 @@ struct RenderRequest { max_pixels: u32, } -/// Ergebnis eines Render-Auftrags. -pub type RenderResult = Result; +/// Ergebnis eines Render-Auftrags: die Mipmap-Stufen der Seite (siehe +/// [`crate::image_layer::mip_chain`]), Stufe 0 in voller Größe. +pub type RenderResult = Result, String>; /// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit /// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf. @@ -137,7 +139,10 @@ impl PdfRenderService { .map(|pdf| (request.path.clone(), pdf)); } let result = match &open_pdf { - Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels), + // Mipmaps hier statt im Haupt-Thread: sonst stockt das Scrollen bei + // jeder neu gerenderten Seite (Issue #28) + Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels) + .map(crate::image_layer::mip_chain), None => Err(t!("PDF kann nicht geöffnet werden").into()), }; if res_tx.send((request.key, result)).is_err() { @@ -303,8 +308,12 @@ pub(crate) mod tests { wake_rx .recv_timeout(std::time::Duration::from_secs(20)) .unwrap(); - let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap(); - assert_eq!(image.width, DISPLAY_RENDER_PIXELS); + let levels = service.take("a#0", path.clone(), 0, px).unwrap().unwrap(); + assert_eq!(levels[0].width, DISPLAY_RENDER_PIXELS); + assert_eq!( + (levels.last().unwrap().width, levels.last().unwrap().height), + (1, 1) + ); // Vorab rendern: danach sofort verfügbar service.prefetch("a#0", path.clone(), 0);