diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index e63ad51a..14d17977 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1451,6 +1451,20 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const display.displayBuffer(refreshMode, fadingFix); } +void GfxRenderer::displayBufferAsync(const HalDisplay::RefreshMode refreshMode) const { + // The async path has no turn-off-screen hook, which the sunlight fading fix + // relies on; keep those users on the blocking path. + if (fadingFix) { + display.displayBuffer(refreshMode, fadingFix); + return; + } + display.displayBufferAsync(refreshMode); +} + +void GfxRenderer::waitRefreshComplete() const { display.waitRefreshComplete(); } + +bool GfxRenderer::supportsAsyncRefresh() const { return !fadingFix && display.supportsAsyncRefresh(); } + std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, const EpdFontFamily::Style style) const { if (!text || maxWidth <= 0) return ""; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e5829679..9c3d0878 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -135,6 +135,17 @@ class GfxRenderer { int getScreenWidth() const; int getScreenHeight() const; void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; + // Non-blocking refresh: starts the waveform and returns so CPU work (e.g. + // grayscale strip rendering) can overlap the panel's refresh time. The + // framebuffer must stay untouched until waitRefreshComplete(). Falls back to + // a blocking refresh when fadingFix is enabled or the panel lacks deferral + // support. See HalDisplay::displayBufferAsync for the baseline contract. + void displayBufferAsync(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; + void waitRefreshComplete() const; + // True when displayBufferAsync() genuinely overlaps: panel defers and + // fadingFix isn't forcing the blocking path. Callers can skip overlap + // scaffolding (e.g. whole-plane grayscale buffers) when false. + bool supportsAsyncRefresh() const; // EXPERIMENTAL: Windowed update - display only a rectangular region // void displayWindow(int x, int y, int width, int height) const; void invertScreen() const; diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index f431bae7..c7a0ceb7 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -65,6 +65,18 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen); } +void HalDisplay::displayBufferAsync(HalDisplay::RefreshMode mode) { + if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) { + einkDisplay.requestResync(1); + } + + einkDisplay.displayBufferAsyncNoShadow(convertRefreshMode(mode)); +} + +void HalDisplay::waitRefreshComplete() { einkDisplay.waitRefreshComplete(); } + +bool HalDisplay::supportsAsyncRefresh() const { return einkDisplay.supportsAsyncRefresh(); } + void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) { if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) { einkDisplay.requestResync(1); diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index c57abfba..34e74a62 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -39,6 +39,17 @@ class HalDisplay { bool fromProgmem = false) const; void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); + // Non-blocking refresh (shadow-free): starts the panel waveform and returns + // while the panel refreshes on its own. The framebuffer must stay untouched + // until waitRefreshComplete(), and the caller must rebuild the differential + // baseline before the next differential update (the tiled grayscale cleanup + // does). Panels without deferral fall back to a blocking refresh. + void displayBufferAsync(RefreshMode mode = RefreshMode::FAST_REFRESH); + // Block until a pending deferred refresh completes (no-op when none is). + void waitRefreshComplete(); + // True when displayBufferAsync() genuinely overlaps (panel driver defers); + // false where it falls back to a blocking refresh. + bool supportsAsyncRefresh() const; void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); // Power management diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 69f056b7..e00a3fb8 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1526,6 +1526,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or const bool pageHasImagesNeedingDecode = pageHasImages && page->hasImagesNeedingDecode(); const bool needsTextGrayscale = SETTINGS.textAntiAliasing; const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages; + const bool tiledGrayscale = needsAnyGrayscale && renderer.supportsStripGrayscale(); + // Whole-plane buffering only pays when the BW refresh genuinely runs async + // underneath it; on blocking panels it would just spend ~50 KB for the + // identical serial timing. + const bool overlapRefresh = tiledGrayscale && renderer.supportsAsyncRefresh(); auto renderGrayscalePass = [&]() { if (needsTextGrayscale) { page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); @@ -1571,50 +1576,67 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or // regardless of residue. pagesUntilFullRefresh = 1; } else { - ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); + // Deferred when a tiled grayscale pass follows: the plane rendering below + // then overlaps the panel's refresh time instead of following it. + ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, /*async=*/overlapRefresh); } const auto tDisplay = millis(); - // Tiled grayscale: render each plane band-by-band into a small scratch and - // stream straight to the controller, leaving the BW framebuffer intact so no - // full-frame storeBwBuffer is needed; controller RAM is re-synced from the - // live framebuffer afterward. The page is re-rendered ceil(H/STRIP_ROWS) times - // per plane, but renderCharImpl culls out-of-band glyphs before decode so the - // cost stays close to one render. Both text (drawPixel) and images - // (DirectPixelWriter) honor the active strip target. - if (needsAnyGrayscale && renderer.supportsStripGrayscale()) { + // Tiled grayscale: render each plane band-by-band, leaving the BW + // framebuffer intact so no full-frame storeBwBuffer is needed; controller + // RAM is re-synced from the live framebuffer afterward. The page is + // re-rendered ceil(H/STRIP_ROWS) times per plane, but renderCharImpl culls + // out-of-band glyphs before decode so the cost stays close to one render. + // Both text (drawPixel) and images (DirectPixelWriter) honor the active + // strip target. When the BW refresh above went out async, the plane + // rendering below overlaps the panel's refresh time; only the controller + // RAM writes wait for BUSY. + if (tiledGrayscale) { constexpr int STRIP_ROWS = 80; const int gh = renderer.getDisplayHeight(); const int gwBytes = renderer.getDisplayWidthBytes(); + const size_t planeBytes = static_cast(gwBytes) * gh; - auto scratch = makeUniqueNoThrow(static_cast(gwBytes) * STRIP_ROWS); - if (!scratch) { - LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS); - } else { - // Bands may be streamed in any order: X4 windows each via setRamArea, X3 - // via PTL. - renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + // Render one plane band-by-band into a whole-plane buffer without touching + // the controller, so it can run while the refresh is still in flight. + auto renderPlaneToBuffer = [&](const bool lsbPlane, uint8_t* buf) { + renderer.setRenderMode(lsbPlane ? GfxRenderer::GRAYSCALE_LSB : GfxRenderer::GRAYSCALE_MSB); for (int y = 0; y < gh; y += STRIP_ROWS) { const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; - renderer.beginStripTarget(scratch.get(), y, rows); + renderer.beginStripTarget(buf + static_cast(y) * gwBytes, y, rows); renderer.clearScreen(0x00); renderGrayscalePass(); renderer.endStripTarget(); - renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows); } - const auto tGrayLsb = millis(); + }; - // MSB plane. - renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); - for (int y = 0; y < gh; y += STRIP_ROWS) { - const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; - renderer.beginStripTarget(scratch.get(), y, rows); - renderer.clearScreen(0x00); - renderGrayscalePass(); - renderer.endStripTarget(); - renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows); + // Tiered on heap pressure: two plane buffers hide both plane renders + // inside the refresh wait; one hides the LSB render (its buffer is reused + // for MSB after streaming); none falls back to the strip-scratch flow with + // no overlap. The MSB buffer is only attempted when it leaves ~60 KB free + // so the pass never starves concurrent allocations (BLE especially). + // Blocking panels skip the buffers entirely (nothing to overlap). + auto lsbPlaneBuf = overlapRefresh ? makeUniqueNoThrow(planeBytes) : nullptr; + auto msbPlaneBuf = (lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000) + ? makeUniqueNoThrow(planeBytes) + : nullptr; + + if (lsbPlaneBuf) { + renderPlaneToBuffer(true, lsbPlaneBuf.get()); + if (msbPlaneBuf) renderPlaneToBuffer(false, msbPlaneBuf.get()); + const auto tGrayRender = millis(); + + renderer.waitRefreshComplete(); + const auto tWait = millis(); + + renderer.writeGrayscalePlaneStrip(true, lsbPlaneBuf.get(), 0, gh); + if (msbPlaneBuf) { + renderer.writeGrayscalePlaneStrip(false, msbPlaneBuf.get(), 0, gh); + } else { + renderPlaneToBuffer(false, lsbPlaneBuf.get()); + renderer.writeGrayscalePlaneStrip(false, lsbPlaneBuf.get(), 0, gh); } - const auto tGrayMsb = millis(); + const auto tGrayWrite = millis(); renderer.setRenderMode(GfxRenderer::BW); renderer.displayGrayBuffer(); @@ -1623,14 +1645,63 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or // BW framebuffer is intact; re-sync controller RAM for the next // differential page turn directly from it. renderer.cleanupGrayscaleWithFrameBuffer(); - const auto tCleanup = millis(); - const auto tEnd = millis(); + LOG_DBG("ERS", - "Page render (tiled): prewarm=%lums bw_render=%lums display=%lums gray_lsb=%lums " - "gray_msb=%lums gray_display=%lums cleanup=%lums total=%lums", - tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayLsb - tDisplay, tGrayMsb - tGrayLsb, - tGrayDisplay - tGrayMsb, tCleanup - tGrayDisplay, tEnd - t0); + "Page render (tiled async): prewarm=%lums bw_render=%lums display=%lums gray_render=%lums " + "wait=%lums gray_write=%lums gray_display=%lums cleanup=%lums total=%lums (planes buffered: %d)", + tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayRender - tDisplay, tWait - tGrayRender, + tGrayWrite - tWait, tGrayDisplay - tGrayWrite, tEnd - tGrayDisplay, tEnd - t0, msbPlaneBuf ? 2 : 1); + } else { + // Per-strip scratch tier: blocking panels and the OOM fallback. The + // strip writes below need the panel idle, so wait out any pending async + // refresh first (no-op on blocking panels). + auto scratch = makeUniqueNoThrow(static_cast(gwBytes) * STRIP_ROWS); + renderer.waitRefreshComplete(); + if (!scratch) { + LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS); + } else { + // Bands may be streamed in any order: X4 windows each via setRamArea, + // X3 via PTL. + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + for (int y = 0; y < gh; y += STRIP_ROWS) { + const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; + renderer.beginStripTarget(scratch.get(), y, rows); + renderer.clearScreen(0x00); + renderGrayscalePass(); + renderer.endStripTarget(); + renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows); + } + const auto tGrayLsb = millis(); + + // MSB plane. + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + for (int y = 0; y < gh; y += STRIP_ROWS) { + const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; + renderer.beginStripTarget(scratch.get(), y, rows); + renderer.clearScreen(0x00); + renderGrayscalePass(); + renderer.endStripTarget(); + renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows); + } + const auto tGrayMsb = millis(); + + renderer.setRenderMode(GfxRenderer::BW); + renderer.displayGrayBuffer(); + const auto tGrayDisplay = millis(); + + // BW framebuffer is intact; re-sync controller RAM for the next + // differential page turn directly from it. + renderer.cleanupGrayscaleWithFrameBuffer(); + const auto tCleanup = millis(); + + const auto tEnd = millis(); + LOG_DBG("ERS", + "Page render (tiled): prewarm=%lums bw_render=%lums display=%lums gray_lsb=%lums " + "gray_msb=%lums gray_display=%lums cleanup=%lums total=%lums", + tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayLsb - tDisplay, tGrayMsb - tGrayLsb, + tGrayDisplay - tGrayMsb, tCleanup - tGrayDisplay, tEnd - t0); + } } } else { // Fallback path for a controller without strip support. grayscale rendering diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 4b37451a..8866635e 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -59,12 +59,21 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) { return {prev, next, tiltPrev || tiltNext}; } -inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh) { +// One helper, blocking or deferred: the async form starts the refresh and +// returns so the caller can overlap CPU work with the panel's refresh time. +// Async callers must not touch the framebuffer until +// renderer.waitRefreshComplete() and must rebuild the differential baseline +// before the next page turn (the tiled grayscale cleanup does). +inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh, bool async = false) { + const auto mode = (pagesUntilFullRefresh <= 1) ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH; + if (async) { + renderer.displayBufferAsync(mode); + } else { + renderer.displayBuffer(mode); + } if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { - renderer.displayBuffer(); pagesUntilFullRefresh--; } }