From 49e913bec4c12ac9ee64e785e98b8da5a02205c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:59:23 +0000 Subject: [PATCH] perf(pdf): reduce pan/zoom jitter in viewer dialog The main cost during a live pan/zoom is the GPU re-sampling the page bitmap every frame under the zoomable modifier's transform. Three changes bring that cost down substantially: - Bitmap.Config.RGB_565 instead of ARGB_8888. PDFs are opaque, so the alpha channel is unused. Halves texture memory and GPU upload bandwidth; visually identical. - HI_RES_MAX_DIM_PX lowered from 6144 to 4096. 4096 stays within the common GPU texture-size limit, keeping rendering hardware-accelerated on mid-range devices. Peak bitmap is now ~24 MB (A4, RGB_565) instead of ~107 MB (ARGB_8888, 6144). - FilterQuality.Medium (bilinear) instead of High (bicubic/Mitchell). High is recomputed per frame during pan/zoom and is the main source of jitter on multi-megapixel sources. At 3072-4096 px base resolution bilinear is effectively indistinguishable. - HI_RES_ZOOM_THRESHOLD raised from 1.2 to 1.5 so we don't trigger a costly re-render for marginal zoom levels where the base is fine. - Cache the ImageBitmap wrapper via remember to avoid per-recomposition allocations. --- .../ui/components/pdf/PdfPreviewCard.kt | 4 ++- .../ui/components/pdf/PdfViewerDialog.kt | 33 +++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index 10525869f..2e652ed39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -256,7 +256,9 @@ private fun renderFirstPage( renderer.openPage(0).use { page -> val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx) - val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888) + // RGB_565 halves memory vs ARGB_8888 and is visually identical for + // opaque PDF renders. + val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.RGB_565) bitmap.eraseColor(android.graphics.Color.WHITE) page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) PdfLoadState.Ready( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index 523c48d81..e9b21d50b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -89,16 +89,18 @@ import net.engawapg.lib.zoomable.toggleScale import net.engawapg.lib.zoomable.zoomable // Hard ceiling on each base-rendered page bitmap, in pixels. Higher = sharper when -// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes -// of RAM (ARGB_8888). 3072 gives ~26 MB per A4-shaped page. +// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 2 bytes +// of RAM (RGB_565). 3072 gives ~13 MB per A4-shaped page. private const val VIEWER_MAX_DIM_PX = 3072 // Hard ceiling for the per-page zoom-aware detail render. When the user zooms in // past HI_RES_ZOOM_THRESHOLD we re-render the current page at -// (VIEWER_MAX_DIM_PX * scale) capped at this value. 6144 allows up to 2x sharper -// than the base render at peak cost of ~100 MB for one page (ARGB_8888, A4 shape). -private const val HI_RES_MAX_DIM_PX = 6144 -private const val HI_RES_ZOOM_THRESHOLD = 1.2f +// (VIEWER_MAX_DIM_PX * scale) capped at this value. 4096 keeps the bitmap within +// common GPU texture limits (so drawing stays hardware-accelerated) and caps +// memory at ~24 MB for an A4-shaped page (RGB_565). Above 4096 most mid-range +// GPUs fall back to software rendering, which is what caused the pan/zoom jitter. +private const val HI_RES_MAX_DIM_PX = 4096 +private const val HI_RES_ZOOM_THRESHOLD = 1.5f private const val HI_RES_DEBOUNCE_MS = 200L // Zoom level the viewer animates to when the user double-taps. Matches the @@ -352,13 +354,21 @@ private fun PdfPageView( } val current = hiResBitmap ?: baseBitmap + // Cache the ImageBitmap wrapper so each recomposition doesn't allocate a new + // one and push Compose into thinking the texture changed. + val imageBitmap = remember(current) { current?.asImageBitmap() } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - if (current != null) { + if (imageBitmap != null) { Image( - bitmap = current.asImageBitmap(), + bitmap = imageBitmap, contentDescription = null, contentScale = ContentScale.Fit, - filterQuality = FilterQuality.High, + // Medium = bilinear. High is bicubic/Mitchell and gets recomputed on every + // frame during pan/zoom, which is the main source of jitter when the + // source bitmap is several megapixels. Bilinear on a 3072-4096 px source + // looks effectively identical on-screen. + filterQuality = FilterQuality.Medium, modifier = Modifier .fillMaxSize() @@ -388,7 +398,10 @@ private suspend fun renderPageCatching( withContext(Dispatchers.IO) { handle.renderer.openPage(pageIndex).use { page -> val (width, height) = cappedRenderSize(page.width, page.height, maxDim) - val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + // RGB_565 uses half the memory of ARGB_8888 and is indistinguishable + // for PDFs (opaque white background, no alpha). Halves GPU texture + // upload size and sampling cost during pan/zoom. + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) bmp.eraseColor(android.graphics.Color.WHITE) page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) bmp