perf: Mipmaps importierter PDF-Seiten im Render-Thread (Issue #28)

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 22:22:21 +02:00
co-authored by Claude Opus 5.5
parent 097933882c
commit f24e7750e6
3 changed files with 123 additions and 51 deletions
+94 -27
View File
@@ -45,26 +45,57 @@ pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
[tl, tr, bl, bl, tr, br] [tl, tr, bl, bl, tr, br]
} }
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1. /// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1. Für eine gerenderte
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> { /// PDF-Seite dauert das spürbar (2560 px: einige 10 ms); deshalb erzeugt sie der
let mut levels = vec![base.clone()]; /// PDF-Render-Thread und nicht der Haupt-Thread.
let Some(mut current) = pub fn mip_chain(base: RgbaImage) -> Vec<RgbaImage> {
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone()) let mut levels = vec![base];
else { loop {
return levels; let current = &levels[levels.len() - 1];
}; if current.width <= 1 && current.height <= 1 {
while current.width() > 1 || current.height() > 1 { break;
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1)); }
current = image::imageops::resize(&current, w, h, image::imageops::FilterType::Triangle); let next = half_size(current);
levels.push(RgbaImage { levels.push(next);
width: w,
height: h,
pixels: current.as_raw().clone(),
});
} }
levels 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. /// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
const MISSING: &str = ""; const MISSING: &str = "";
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles /// 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. /// Antwort einer Texturquelle für einen Schlüssel.
pub enum TextureSource { pub enum TextureSource {
Ready(RgbaImage), Ready(RgbaImage),
/// Fertige Mipmap-Stufen (siehe [`mip_chain`]), z. B. vom PDF-Render-Thread
ReadyMips(Vec<RgbaImage>),
/// Wird gerade erzeugt; später erneut fragen /// Wird gerade erzeugt; später erneut fragen
Pending, Pending,
Failed(String), Failed(String),
@@ -233,10 +266,16 @@ impl ImageLayer {
continue; continue;
} }
let (bind_group, bytes) = match load(&key) { let (bind_group, bytes) = match load(&key) {
TextureSource::Ready(image) => ( TextureSource::Ready(image) => {
self.upload_texture(device, queue, &key, &image), self.upload_texture(device, queue, &key, mip_chain(image))
image.pixels.len() as u64 * 4 / 3, }
), 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 // Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
TextureSource::Pending => continue, TextureSource::Pending => continue,
TextureSource::Failed(err) => { TextureSource::Failed(err) => {
@@ -342,17 +381,19 @@ impl ImageLayer {
height: 1, height: 1,
pixels: vec![200, 200, 200, 255], 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( fn upload_texture(
&self, &self,
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
label: &str, label: &str,
image: &RgbaImage, levels: Vec<RgbaImage>,
) -> wgpu::BindGroup { ) -> (wgpu::BindGroup, u64) {
let levels = mip_chain(image); let image = &levels[0];
let texture = device.create_texture(&wgpu::TextureDescriptor { let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label), label: Some(label),
size: wgpu::Extent3d { 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()); 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"), label: Some("Image Texture Bind Group"),
layout: &self.texture_layout, layout: &self.texture_layout,
entries: &[ entries: &[
@@ -403,7 +445,8 @@ impl ImageLayer {
resource: wgpu::BindingResource::Sampler(&self.sampler), resource: wgpu::BindingResource::Sampler(&self.sampler),
}, },
], ],
}) });
(bind_group, bytes)
} }
} }
@@ -439,11 +482,35 @@ mod tests {
height: 3, height: 3,
pixels: vec![128; 8 * 3 * 4], 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(); 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)]); assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
for l in &levels { for l in &levels {
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize); 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]);
}
} }
+9 -13
View File
@@ -941,25 +941,21 @@ fn texture_for(
assets: &AssetStore, assets: &AssetStore,
pdf_renderer: &mut PdfRenderService, pdf_renderer: &mut PdfRenderService,
) -> TextureSource { ) -> TextureSource {
let result = match key.contains('#') { if !key.contains('#') {
true => { 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) 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)) .map(|(asset, page, px)| (assets.path_of(asset), page, px))
else { else {
return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key)); return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key));
}; };
match pdf_renderer.take(key, path, page, max_pixels) { match pdf_renderer.take(key, path, page, max_pixels) {
Some(result) => result, Some(Ok(levels)) => TextureSource::ReadyMips(levels),
None => return TextureSource::Pending, Some(Err(err)) => TextureSource::Failed(err),
} None => TextureSource::Pending,
}
false => assets
.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION)
.map_err(|e| e.to_string()),
};
match result {
Ok(image) => TextureSource::Ready(image),
Err(err) => TextureSource::Failed(err),
} }
} }
+15 -6
View File
@@ -1,7 +1,8 @@
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU). //! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
//! //!
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`] //! 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::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
@@ -106,8 +107,9 @@ struct RenderRequest {
max_pixels: u32, max_pixels: u32,
} }
/// Ergebnis eines Render-Auftrags. /// Ergebnis eines Render-Auftrags: die Mipmap-Stufen der Seite (siehe
pub type RenderResult = Result<RgbaImage, String>; /// [`crate::image_layer::mip_chain`]), Stufe 0 in voller Größe.
pub type RenderResult = Result<Vec<RgbaImage>, String>;
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit /// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf. /// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
@@ -137,7 +139,10 @@ impl PdfRenderService {
.map(|pdf| (request.path.clone(), pdf)); .map(|pdf| (request.path.clone(), pdf));
} }
let result = match &open_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()), None => Err(t!("PDF kann nicht geöffnet werden").into()),
}; };
if res_tx.send((request.key, result)).is_err() { if res_tx.send((request.key, result)).is_err() {
@@ -303,8 +308,12 @@ pub(crate) mod tests {
wake_rx wake_rx
.recv_timeout(std::time::Duration::from_secs(20)) .recv_timeout(std::time::Duration::from_secs(20))
.unwrap(); .unwrap();
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap(); let levels = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
assert_eq!(image.width, DISPLAY_RENDER_PIXELS); 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 // Vorab rendern: danach sofort verfügbar
service.prefetch("a#0", path.clone(), 0); service.prefetch("a#0", path.clone(), 0);