Add idle-time glyph prewarming for next page

Preload glyphs for the next page during idle time to reduce page-turn latency. After a 400ms debounce period, scan the next page in FCM scan mode (no pixels drawn) to cache missing glyphs from SD card, avoiding ~100ms of SD reads during the actual page turn. Prewarm is deferred when rendering is active, heap is low, or during rapid page-flipping.
This commit is contained in:
Justin Mitchell
2026-07-14 11:26:07 -04:00
parent 302c0771dd
commit 4d19d38152
2 changed files with 37 additions and 0 deletions
@@ -356,6 +356,35 @@ void EpubReaderActivity::loop() {
}
}
// Idle glyph prewarm for the likely next page (currentPage + 1). The scan
// pass draws nothing (FCM scan mode suppresses pixels), so the displayed
// framebuffer is untouched; endScanAndPrewarm loads only glyphs not already
// cached. Debounced past rapid page-flipping, one attempt per position, and
// deferred while a render/build owns the CPU or the heap is at the render
// floor. Cross-chapter prewarm is deliberately out of scope (next spine's
// section isn't loaded).
constexpr unsigned long IDLE_PREWARM_DEBOUNCE_MS = 400;
if (section && !section->isBuilding() && !RenderLock::peek() && renderer.hasFrameBuffer() &&
lastRenderCompleteMs != 0 && millis() - lastRenderCompleteMs > IDLE_PREWARM_DEBOUNCE_MS &&
ESP.getFreeHeap() > RENDER_MIN_FREE_HEAP &&
(idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) {
idlePrewarmSpine = currentSpineIndex;
idlePrewarmPage = section->currentPage;
const int nextPage = section->currentPage + 1;
if (nextPage < static_cast<int>(section->pageCount)) {
RenderLock lock; // the page table must not change under the scan
if (const auto p = section->loadPage(nextPage)) {
if (auto* fcm = renderer.getFontCacheManager()) {
const auto t0 = millis();
auto scope = fcm->createPrewarmScope();
p->render(renderer, SETTINGS.getReaderFontId(), 0, 0); // scan only, no pixels
scope.endScanAndPrewarm();
LOG_DBG("ERS", "Idle prewarm: page %d in %lums", nextPage, millis() - t0);
}
}
}
}
// Lazily resume a partial's extension build once the reader nears its watermark. Far from
// it the rebuild is all cost (whole-chapter re-layout from page 0) and no benefit this
// session, so reopening a partial deliberately does NOT start it (see the deferral in
@@ -1468,6 +1497,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
lastRenderCompleteMs = millis();
// Fragmentation tracker: free vs largest block after every page. A falling
// maxAlloc/free ratio across pages points at whichever allocation pattern the
// preceding lines show (mini rebuilds, kern reloads, BLE churn).
@@ -46,6 +46,13 @@ class EpubReaderActivity final : public Activity {
// to the chapter/book title the moment the connection completes (and
// returns on disconnect) instead of waiting for the next page turn.
bool statusBarBleConnected = false;
// Idle-time glyph prewarm: after a page settles, scan the LIKELY next page
// (scan mode draws nothing) and load its missing glyphs from SD during idle,
// so the next turn's in-render prewarm is a cache hit instead of ~100 ms of
// SD reads on the page-turn critical path. One attempt per position.
int idlePrewarmSpine = -1;
int idlePrewarmPage = -1;
unsigned long lastRenderCompleteMs = 0;
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
std::vector<BookmarkEntry> cachedBookmarks;
// Tracks whether this book is currently removed from Recent Books by the