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.
This commit is contained in:
Claude
2026-04-19 16:59:23 +00:00
parent 640848a2f6
commit 49e913bec4
2 changed files with 26 additions and 11 deletions
@@ -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(
@@ -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