From 9f34adcf99550ee23ffb62e0c3b4c7218550b952 Mon Sep 17 00:00:00 2001 From: jeremydk Date: Fri, 22 May 2026 16:00:55 +0200 Subject: [PATCH] Applay striped buffer approach to save memory --- lib/Epub/Epub/converters/DirectPixelWriter.h | 17 +- lib/GfxRenderer/GfxRenderer.cpp | 158 +++++++++-- lib/GfxRenderer/GfxRenderer.h | 47 +++ lib/hal/HalDisplay.cpp | 7 + lib/hal/HalDisplay.h | 6 + src/activities/reader/EpubReaderActivity.cpp | 283 ++++++++++++------- 6 files changed, 396 insertions(+), 122 deletions(-) diff --git a/lib/Epub/Epub/converters/DirectPixelWriter.h b/lib/Epub/Epub/converters/DirectPixelWriter.h index bc66c2f7..25cb5755 100644 --- a/lib/Epub/Epub/converters/DirectPixelWriter.h +++ b/lib/Epub/Epub/converters/DirectPixelWriter.h @@ -16,6 +16,12 @@ struct DirectPixelWriter { uint8_t* fb; GfxRenderer::RenderMode mode; uint16_t displayWidthBytes; // Runtime framebuffer stride (X4: 100, X3: 99) + // Active write target: for tiled grayscale, fb is the band scratch, originY is + // the band's top physical row, and clipRows is the band height. Off-band + // pixels are dropped. With no strip active these collapse to the full frame + // (originY 0, clipRows panelHeight) so the clip doubles as a bounds guard. + int originY; + int clipRows; // Orientation is collapsed into a linear transform: // phyX = phyXBase + x * phyXStepX + y * phyXStepY @@ -28,7 +34,9 @@ struct DirectPixelWriter { int rowPhyXBase, rowPhyYBase; void init(GfxRenderer& renderer) { - fb = renderer.getFrameBuffer(); + fb = renderer.getWriteTarget(); + originY = renderer.getWriteOriginY(); + clipRows = renderer.getWriteRows(); mode = renderer.getRenderMode(); displayWidthBytes = renderer.getDisplayWidthBytes(); @@ -120,7 +128,12 @@ struct DirectPixelWriter { const int phyX = rowPhyXBase + logicalX * phyXStepX; const int phyY = rowPhyYBase + logicalX * phyYStepX; - const uint16_t byteIndex = phyY * displayWidthBytes + (phyX >> 3); + // Band-local row. The unsigned compare drops both off-band pixels (strip + // mode) and any out-of-frame row (full-frame mode) in one branch. + const int sy = phyY - originY; + if (static_cast(sy) >= static_cast(clipRows)) return; + + const uint16_t byteIndex = static_cast(sy * displayWidthBytes + (phyX >> 3)); const uint8_t bitMask = 1 << (7 - (phyX & 7)); if (state) { diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 1b49e38c..670d49d5 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -8,6 +8,8 @@ #include #include +#include +#include #include "FontCacheManager.h" @@ -611,15 +613,21 @@ static inline uint8_t build2BitColMask(const uint8_t* const bitmap, const int gl // inverted=false → Portrait (phyY counts down, phyBitPos counts up). // inverted=true → PortraitInverted (phyY counts up, phyBitPos counts down). // Both template params are compile-time constants; all ternaries fold away. +// `frameBuffer` may be a strip scratch covering only rows [fbOriginY, fbOriginY+fbRows); +// the writer subtracts fbOriginY when indexing and drops rows outside the band. +// In non-strip mode the caller passes fbOriginY=0, fbRows=displayHeight, so the +// translation is a no-op and the existing absolute-row indexing is preserved. template static void renderGlyphFast2BitPortrait(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, const int glyphHeight, const int screenXBase, const int screenYBase, const bool writeState, const int displayWidth, const int displayHeight, - const int widthBytes) { + const int widthBytes, const int fbOriginY, const int fbRows) { for (int glyphX = 0; glyphX < glyphWidth; glyphX++) { const int phyY = inverted ? (screenXBase + glyphX) : (displayHeight - 1 - (screenXBase + glyphX)); if (phyY < 0 || phyY >= displayHeight) continue; - uint8_t* const row = frameBuffer + phyY * widthBytes; + const int rowY = phyY - fbOriginY; + if (static_cast(rowY) >= static_cast(fbRows)) continue; + uint8_t* const row = frameBuffer + rowY * widthBytes; for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { const int count = std::min(8, glyphHeight - glyphY); const uint8_t mask = build2BitColMask(bitmap, glyphWidth, glyphX, glyphY, count, inverted); @@ -635,18 +643,25 @@ template static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, const int glyphHeight, const int screenXBase, const int screenYBase, const bool pixelState, const GfxRenderer::Orientation orientation, - const int displayWidth, const int displayHeight, const int widthBytes) { + const int displayWidth, const int displayHeight, const int widthBytes, + const int fbOriginY, const int fbRows) { // Non-rotated text fast path for 2-bit glyphs. Writes compact masks directly to framebuffer rows. // TextRotation::Rotated90CW keeps the legacy per-pixel fallback path for safety and readability. // BW (drawMask 0x0E) honors the caller's pixelState; grayscale passes always clear the bit. + // + // Tiled grayscale: `frameBuffer` may be a strip scratch with origin fbOriginY + // and fbRows; we subtract the origin when indexing and clip rows outside the + // band. The unsigned compare drops both off-band rows (strip mode) and any + // out-of-frame row (full-frame mode) in one branch. const bool writeState = (drawMask == 0x0E) ? pixelState : false; switch (orientation) { case GfxRenderer::LandscapeCounterClockwise: { for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { const int phyY = screenYBase + glyphY; - if (phyY < 0 || phyY >= displayHeight) continue; - uint8_t* const row = frameBuffer + phyY * widthBytes; + const int rowY = phyY - fbOriginY; + if (static_cast(rowY) >= static_cast(fbRows)) continue; + uint8_t* const row = frameBuffer + rowY * widthBytes; const int rowStartPixel = glyphY * glyphWidth; for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { const int count = std::min(8, glyphWidth - glyphX); @@ -673,8 +688,9 @@ static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const // within each row, which is more cache-friendly than the chunk-outer alternative. for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { const int phyY = displayHeight - 1 - (screenYBase + glyphY); - if (phyY < 0 || phyY >= displayHeight) continue; - uint8_t* const row = frameBuffer + phyY * widthBytes; + const int rowY = phyY - fbOriginY; + if (static_cast(rowY) >= static_cast(fbRows)) continue; + uint8_t* const row = frameBuffer + rowY * widthBytes; const int rowStartPixel = glyphY * glyphWidth; for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) { const int chunkStart = std::max(0, chunkEnd - 7); @@ -698,12 +714,14 @@ static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const case GfxRenderer::Portrait: renderGlyphFast2BitPortrait(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase, - screenYBase, writeState, displayWidth, displayHeight, widthBytes); + screenYBase, writeState, displayWidth, displayHeight, widthBytes, + fbOriginY, fbRows); break; case GfxRenderer::PortraitInverted: renderGlyphFast2BitPortrait(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase, - screenYBase, writeState, displayWidth, displayHeight, widthBytes); + screenYBase, writeState, displayWidth, displayHeight, widthBytes, + fbOriginY, fbRows); break; } } @@ -727,6 +745,23 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode const int left = glyph->left; const int top = glyph->top; + // Tiled-grayscale band culling: if this glyph's physical y-extent is entirely + // outside the active strip, skip it before the expensive bitmap decode. This + // is what makes per-band re-rendering cheap. No-op outside strip mode. + if constexpr (rotation == TextRotation::Rotated90CW) { + const int ob = cursorX + fontData->ascender - top; + const int ib = cursorY - left; + if (!renderer.glyphIntersectsStrip(ob, ib - (width - 1), ob + height - 1, ib)) { + return; + } + } else { + const int gx0 = cursorX + left; + const int gy0 = cursorY - top; + if (!renderer.glyphIntersectsStrip(gx0, gy0, gx0 + width - 1, gy0 + height - 1)) { + return; + } + } + const uint8_t* bitmap = renderer.getGlyphBitmap(fontData, glyph); if (bitmap != nullptr) { @@ -752,26 +787,32 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode if constexpr (rotation == TextRotation::None) { // Fast path for normal text orientation. Handles all device orientations via renderGlyphFast2Bit. + // Strip-aware: getWriteTarget() returns the band scratch when a strip is active, otherwise + // the live framebuffer; the (fbOriginY, fbRows) pair tells the writer how to translate phyY + // and clip rows outside the band. + uint8_t* const fb = renderer.getWriteTarget(); + const int fbOriginY = renderer.getWriteOriginY(); + const int fbRows = renderer.getWriteRows(); switch (drawMask) { case 0x0E: // BW - renderGlyphFast2Bit<0x0E>(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, - pixelState, renderer.getOrientation(), renderer.getDisplayWidth(), - renderer.getDisplayHeight(), renderer.getDisplayWidthBytes()); + renderGlyphFast2Bit<0x0E>(fb, bitmap, width, height, innerBase, outerBase, pixelState, + renderer.getOrientation(), renderer.getDisplayWidth(), + renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows); break; case 0x06: // raw {1,2} - renderGlyphFast2Bit<0x06>(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, - pixelState, renderer.getOrientation(), renderer.getDisplayWidth(), - renderer.getDisplayHeight(), renderer.getDisplayWidthBytes()); + renderGlyphFast2Bit<0x06>(fb, bitmap, width, height, innerBase, outerBase, pixelState, + renderer.getOrientation(), renderer.getDisplayWidth(), + renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows); break; case 0x04: // raw {2} - renderGlyphFast2Bit<0x04>(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, - pixelState, renderer.getOrientation(), renderer.getDisplayWidth(), - renderer.getDisplayHeight(), renderer.getDisplayWidthBytes()); + renderGlyphFast2Bit<0x04>(fb, bitmap, width, height, innerBase, outerBase, pixelState, + renderer.getOrientation(), renderer.getDisplayWidth(), + renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows); break; case 0x02: // raw {1} - renderGlyphFast2Bit<0x02>(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, - pixelState, renderer.getOrientation(), renderer.getDisplayWidth(), - renderer.getDisplayHeight(), renderer.getDisplayWidthBytes()); + renderGlyphFast2Bit<0x02>(fb, bitmap, width, height, innerBase, outerBase, pixelState, + renderer.getOrientation(), renderer.getDisplayWidth(), + renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows); break; } return; @@ -921,14 +962,26 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { return; } + // Tiled grayscale: redirect writes to the strip scratch and clip to the + // current band. Single predictable branch on the hot per-pixel path. + uint8_t* target = frameBuffer; + uint32_t rowY = static_cast(phyY); + if (stripActive_) { + if (phyY < stripY0_ || phyY >= stripY0_ + stripRows_) { + return; // pixel outside the band currently being rendered + } + target = stripBuf_; + rowY = static_cast(phyY - stripY0_); + } + // Calculate byte position and bit position - const uint32_t byteIndex = static_cast(phyY) * getDisplayWidthBytes() + (phyX / 8); + const uint32_t byteIndex = rowY * getDisplayWidthBytes() + (phyX / 8); const uint8_t bitPosition = 7 - (phyX % 8); // MSB first if (state) { - frameBuffer[byteIndex] &= ~(1 << bitPosition); // Clear bit + target[byteIndex] &= ~(1 << bitPosition); // Clear bit } else { - frameBuffer[byteIndex] |= 1 << bitPosition; // Set bit + target[byteIndex] |= 1 << bitPosition; // Set bit } } @@ -1237,7 +1290,17 @@ void GfxRenderer::fillPhysicalHSpanByte(const int phyY, const int phyX_start, co const int cX1 = std::min(phyX_end, (int)getDisplayWidth() - 1); if (cX0 > cX1 || phyY < 0 || phyY >= (int)getDisplayHeight()) return; - uint8_t* const row = frameBuffer + phyY * getDisplayWidthBytes(); + // Tiled grayscale: redirect to the strip scratch and drop rows outside the + // active band. Off-band rows return cheaply before any bit-fiddling. + uint8_t* target = frameBuffer; + int rowY = phyY; + if (stripActive_) { + if (phyY < stripY0_ || phyY >= stripY0_ + stripRows_) return; + target = stripBuf_; + rowY = phyY - stripY0_; + } + + uint8_t* const row = target + rowY * getDisplayWidthBytes(); const int startByte = cX0 >> 3; const int endByte = cX1 >> 3; const int leftBits = cX0 & 7; // first bit index within startByte @@ -1862,9 +1925,54 @@ static bool start_ms_valid = false; void GfxRenderer::clearScreen(const uint8_t color) const { start_ms = millis(); start_ms_valid = true; + if (stripActive_) { + // Clear only the active band's scratch, not the shared framebuffer. + memset(stripBuf_, color, static_cast(panelWidthBytes) * stripRows_); + return; + } display.clearScreen(color); } +void GfxRenderer::beginStripTarget(uint8_t* scratch, int stripY0, int stripRows) const { + // Band is caller-guaranteed in-bounds (the reader's grayscale loop computes + // it); assert catches future misuse in debug before it mis-renders. + assert(scratch != nullptr && stripRows > 0 && stripY0 >= 0 && stripY0 <= static_cast(panelHeight) - stripRows); + stripBuf_ = scratch; + stripY0_ = stripY0; + stripRows_ = stripRows; + stripActive_ = true; +} + +void GfxRenderer::endStripTarget() const { + stripActive_ = false; + stripBuf_ = nullptr; + stripY0_ = 0; + stripRows_ = 0; +} + +bool GfxRenderer::glyphIntersectsStrip(int x0, int y0, int x1, int y1) const { + if (!stripActive_) { + return true; + } + // Rotate the two opposite bbox corners to physical coords. For 90-degree + // orientations the physical bbox stays axis-aligned, so min/max of the two + // rotated corners' Y bounds the glyph's physical y-extent. + int ax, ay, bx, by; + rotateCoordinates(getOrientation(), x0, y0, &ax, &ay, panelWidth, panelHeight); + rotateCoordinates(getOrientation(), x1, y1, &bx, &by, panelWidth, panelHeight); + const int minY = ay < by ? ay : by; + const int maxY = ay > by ? ay : by; + return !(maxY < stripY0_ || minY >= stripY0_ + stripRows_); +} + +void GfxRenderer::writeGrayscalePlaneStrip(bool lsbPlane, const uint8_t* scratch, int yStart, int numRows) const { + // Guard the uint16_t casts below: a negative would wrap to a huge length. + assert(yStart >= 0 && numRows > 0 && yStart <= static_cast(panelHeight) - numRows); + display.writeGrayscalePlaneStrip(lsbPlane, scratch, static_cast(yStart), static_cast(numRows)); +} + +bool GfxRenderer::supportsStripGrayscale() const { return display.supportsStripGrayscale(); } + void GfxRenderer::invertScreen() const { for (uint32_t i = 0; i < frameBufferSize; i++) { frameBuffer[i] = ~frameBuffer[i]; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 6ce974a3..e9b7bfd9 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -72,6 +72,19 @@ class GfxRenderer { mutable FontCacheManager* fontCacheManager_ = nullptr; mutable std::atomic refreshOverride = REFRESH_OVERRIDE_NONE; + // Tiled grayscale strip target. When active, drawPixel(), clearScreen(), + // fillPhysicalHSpanByte() and renderGlyphFast2Bit() write into a caller-owned + // scratch holding one horizontal band of physical rows + // [_stripY0, _stripY0 + _stripRows) (panelWidthBytes wide) instead of the + // shared framebuffer; pixels outside the band are clipped. Lets grayscale + // planes render band-by-band straight to the controller without destroying + // the BW framebuffer (no storeBwBuffer). Mutable because the render path is + // const. See beginStripTarget()/endStripTarget(). + mutable uint8_t* stripBuf_ = nullptr; + mutable int stripY0_ = 0; + mutable int stripRows_ = 0; + mutable bool stripActive_ = false; + void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState, EpdFontFamily::Style style) const; void freeBwBufferChunks(); @@ -209,6 +222,40 @@ class GfxRenderer { void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; + + // Tiled grayscale (X4 + X3): stream one band of a plane straight to + // controller RAM from `scratch` (panelWidthBytes * numRows, physical rows + // [yStart, yStart+numRows)), bypassing the framebuffer. + // supportsStripGrayscale() gates use. + void writeGrayscalePlaneStrip(bool lsbPlane, const uint8_t* scratch, int yStart, int numRows) const; + bool supportsStripGrayscale() const; + + // Tiled grayscale strip target. While active, drawPixel(), clearScreen(), + // fillPhysicalHSpanByte() and renderGlyphFast2Bit() operate on `scratch` + // (panelWidthBytes * stripRows bytes, holding physical rows + // [stripY0, stripY0 + stripRows)) instead of the framebuffer; pixels whose + // physical row falls outside the band are clipped. The clip is applied after + // the orientation rotate, so it is orientation-agnostic. Used to render + // grayscale planes band-by-band without a full second buffer. + void beginStripTarget(uint8_t* scratch, int stripY0, int stripRows) const; + void endStripTarget() const; + + // Active pixel-write target for raw writers that bypass drawPixel for speed. + // When a strip target is active these return the band scratch plus its + // physical-row origin and extent; otherwise the full framebuffer ([0, + // panelHeight)). Writers subtract the origin and clip to the extent, so they + // honor tiled-grayscale banding without per-pixel method calls. + uint8_t* getWriteTarget() const { return stripActive_ ? stripBuf_ : frameBuffer; } + int getWriteOriginY() const { return stripActive_ ? stripY0_ : 0; } + int getWriteRows() const { return stripActive_ ? stripRows_ : static_cast(panelHeight); } + bool isStripActive() const { return stripActive_; } + + // Band culling. Takes a glyph bounding box in logical screen coords and + // returns false only when a strip is active AND the box's physical y-extent + // lies entirely outside the active band, letting callers skip expensive + // bitmap decode. Returns true when no strip is active. + bool glyphIntersectsStrip(int x0, int y0, int x1, int y1) const; + bool storeBwBuffer(); // Returns true if buffer was stored successfully bool storeBwBufferRect(int x, int y, int width, int height); // Store only rows intersecting logical rect void restoreBwBuffer(); // Restore and free the stored buffer diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index 43cd316f..6fda0c0f 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -97,6 +97,13 @@ void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay. void HalDisplay::displayGrayBuffer(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); } +void HalDisplay::writeGrayscalePlaneStrip(bool lsbPlane, const uint8_t* rows, uint16_t yStart, uint16_t numRows) { + einkDisplay.writeGrayscalePlaneStrip(lsbPlane ? EInkDisplay::GRAY_PLANE_LSB : EInkDisplay::GRAY_PLANE_MSB, rows, + yStart, numRows); +} + +bool HalDisplay::supportsStripGrayscale() const { return einkDisplay.supportsStripGrayscale(); } + uint16_t HalDisplay::getDisplayWidth() const { return einkDisplay.getDisplayWidth(); } uint16_t HalDisplay::getDisplayHeight() const { return einkDisplay.getDisplayHeight(); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index 6a6b99a1..695b0991 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -55,6 +55,12 @@ class HalDisplay { void displayGrayBuffer(bool turnOffScreen = false); + // Tiled grayscale: stream one band of a plane (lsbPlane selects LSB/MSB RAM) + // straight to the controller; supportsStripGrayscale() gates the path. See + // EInkDisplay::writeGrayscalePlaneStrip. + void writeGrayscalePlaneStrip(bool lsbPlane, const uint8_t* rows, uint16_t yStart, uint16_t numRows); + bool supportsStripGrayscale() const; + // Runtime geometry passthrough uint16_t getDisplayWidth() const; uint16_t getDisplayHeight() const; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 38c0528e..61ab7e95 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -160,6 +160,63 @@ bool computePageDynamicYBand(const Page& page, const GfxRenderer& renderer, cons return true; } +// 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 +// storeBwBuffer / restoreBwBuffer is needed. Controller RAM is re-synced from +// the live framebuffer afterward via cleanupGrayscaleWithFrameBuffer(). +// +// Returns true when the strip path ran end-to-end (controller now holds the AA +// planes and the live BW frame is clean). Returns false when the controller +// doesn't support strip grayscale OR the scratch allocation fails — caller +// should fall back to the legacy storeBwBufferRect path. +// +// The page is re-rendered ceil(panelHeight/STRIP_ROWS) times per plane, but +// renderCharImpl culls out-of-band glyphs before bitmap decode so the cost +// stays close to one render. Only renderTextOnly() is called here, matching the +// legacy AA pass — images and HRs do not participate in grayscale. +bool runTiledGrayscalePass(GfxRenderer& renderer, Page& page, int fontId, int marginLeft, int contentTop) { + if (!renderer.supportsStripGrayscale()) return false; + + // Strip height trades scratch size for the number of re-renders. Each render + // pays layout + glyph-cull overhead even when bitmap decode is skipped, so + // fewer/bigger bands win as long as the scratch fits. 240 rows × ~100 bytes + // ≈ ~24 KB — still well below the legacy partial-snapshot footprint while + // cutting X3 (480 px) to 2 bands/plane and X4 (800 px) to 4 bands/plane. + constexpr int STRIP_ROWS = 240; + const int gh = renderer.getDisplayHeight(); + const int gwBytes = renderer.getDisplayWidthBytes(); + + auto scratch = std::unique_ptr(new (std::nothrow) uint8_t[static_cast(gwBytes) * STRIP_ROWS]); + if (!scratch) { + LOG_INF("ERS", "Tiled grayscale: scratch alloc failed (%d bytes); falling back to legacy path", + gwBytes * STRIP_ROWS); + return false; + } + + auto renderPlane = [&](GfxRenderer::RenderMode mode, bool lsbPlane) { + renderer.setRenderMode(mode); + 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); + page.renderTextOnly(renderer, fontId, marginLeft, contentTop); + renderer.endStripTarget(); + renderer.writeGrayscalePlaneStrip(lsbPlane, scratch.get(), y, rows); + } + }; + + renderPlane(GfxRenderer::GRAYSCALE_LSB, true); + renderPlane(GfxRenderer::GRAYSCALE_MSB, false); + + renderer.setRenderMode(GfxRenderer::BW); + renderer.displayGrayBuffer(); + + // BW framebuffer is intact; re-sync controller RAM for the next differential + // page turn directly from it. + renderer.cleanupGrayscaleWithFrameBuffer(); + return true; +} + // Computes the [0..100] EPUB progress percent. Returns 0 when pageCount is unknown (sync/bookmark // pre-render writes), in which case the next saveProgress() will overwrite progress.bin with the // real value before the user can leave the reader. @@ -2083,47 +2140,6 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or } const auto tDisplay = millis(); - // Save bw buffer to reset buffer state after grayscale data sync. - // Pre-check heap to avoid entering the chunk-retry path when success is unlikely. - const uint32_t bwStoreFreeHeap = esp_get_free_heap_size(); - const uint32_t bwStoreContigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT); - const bool shouldAttemptBwSnapshot = - bwStoreFreeHeap >= BW_SNAPSHOT_MIN_FREE_HEAP_BYTES && bwStoreContigHeap >= BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES; - - logReaderMemSnapshot("bw_store_begin"); - bool bwBufferStored = false; - if (shouldAttemptBwSnapshot) { - const int contentLeft = orientedMarginLeft; - const int contentRight = std::max(contentLeft, renderer.getScreenWidth() - orientedMarginRight); - const int contentBottom = std::max(contentTop, renderer.getScreenHeight() - orientedMarginBottom); - - int bandTop = 0; - int bandBottom = std::max(0, contentBottom - contentTop); - if (!computePageDynamicYBand(*page, renderer, getEffectiveReaderFontId(), bandBottom, &bandTop, &bandBottom)) { - bandTop = 0; - bandBottom = std::max(0, contentBottom - contentTop); - } - - const int snapshotTop = contentTop + bandTop; - const int snapshotHeight = std::max(0, bandBottom - bandTop); - bwBufferStored = renderer.storeBwBufferRect(contentLeft, snapshotTop, contentRight - contentLeft, snapshotHeight); - } else { - LOG_INF("ERS", "Skipping BW snapshot precheck (free=%lu contig=%lu, need free>=%lu contig>=%lu)", bwStoreFreeHeap, - bwStoreContigHeap, static_cast(BW_SNAPSHOT_MIN_FREE_HEAP_BYTES), - static_cast(BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES)); - } - const auto tBwStore = millis(); - logReaderMemSnapshot("bw_store_end"); - if (!bwBufferStored) { - if (aaEnabledForThisRender && !antiAliasingSuspendedLowMemory) { - antiAliasingSuspendedLowMemory = true; - const uint32_t freeHeapNow = esp_get_free_heap_size(); - const uint32_t contigHeapNow = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT); - LOG_INF("ERS", "Suspending text anti-aliasing due to low heap (free=%lu contig=%lu)", freeHeapNow, contigHeapNow); - } - LOG_INF("ERS", "Skipping grayscale/BW-restore for this page (insufficient heap for BW snapshot)"); - } - // Only schedule the half-refresh if at least one real image was decoded on this page. // Placeholder-only pages don't deposit grayscale data that needs settling. if (page->hasImages() && !page->allImagesArePlaceholders(effectiveForceLoad) && @@ -2131,69 +2147,139 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or pendingHalfRefreshAfterImagePage = true; } - // grayscale rendering - // TODO: Only do this if font supports it - if (aaEnabledForThisRender && bwBufferStored) { - logReaderMemSnapshot("gray_lsb_begin"); - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); - page->renderTextOnly(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop); - renderer.copyGrayscaleLsbBuffers(); - const auto tGrayLsb = millis(); - logReaderMemSnapshot("gray_lsb_end"); - - // Render and copy to MSB buffer - logReaderMemSnapshot("gray_msb_begin"); - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); - page->renderTextOnly(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop); - renderer.copyGrayscaleMsbBuffers(); - const auto tGrayMsb = millis(); - logReaderMemSnapshot("gray_msb_end"); - - // display grayscale part - logReaderMemSnapshot("gray_display_begin"); - renderer.displayGrayBuffer(); - const auto tGrayDisplay = millis(); - renderer.setRenderMode(GfxRenderer::BW); - fcm->logStats("gray"); - logReaderMemSnapshot("gray_display_end"); - - // restore the bw data - logReaderMemSnapshot("bw_restore_begin"); - renderer.restoreBwBuffer(); - const auto tBwRestore = millis(); - logReaderMemSnapshot("bw_restore_end"); + // Tiled grayscale: try the strip path first when AA is on and the controller + // supports it. It allocates an ~8 KB scratch instead of saving a partial BW + // frame, leaving the live BW framebuffer intact (no storeBwBuffer needed). + // Falls through to the legacy snapshot path if strip is unsupported or the + // scratch can't be allocated. + bool grayscaleDone = false; + uint32_t tiledGrayMs = 0; + if (aaEnabledForThisRender) { + logReaderMemSnapshot("tiled_gray_begin"); + const auto tTiledBegin = millis(); + grayscaleDone = runTiledGrayscalePass(renderer, *page, getEffectiveReaderFontId(), orientedMarginLeft, contentTop); + if (grayscaleDone) { + tiledGrayMs = millis() - tTiledBegin; + fcm->logStats("tiled_gray"); + logReaderMemSnapshot("tiled_gray_end"); + } + } + if (grayscaleDone) { const auto tEnd = millis(); lastRenderStats.usedGrayscale = true; - lastRenderStats.phases = {tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, - tBwStore - tDisplay, tGrayLsb - tBwStore, tGrayMsb - tGrayLsb, - tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0}; - LOG_DBG("ERS", - "Page render: prewarm=%lums bw_render=%lums display=%lums bw_store=%lums " - "gray_lsb=%lums gray_msb=%lums gray_display=%lums bw_restore=%lums total=%lums", - tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tGrayLsb - tBwStore, - tGrayMsb - tGrayLsb, tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0); + lastRenderStats.phases = {tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, 0, tiledGrayMs, 0, 0, 0, + tEnd - t0}; + LOG_DBG("ERS", "Page render (tiled): prewarm=%lums bw_render=%lums display=%lums tiled_gray=%lums total=%lums", + tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tiledGrayMs, tEnd - t0); } else { - uint32_t bwRestoreMs = 0; - if (bwBufferStored) { + // Legacy fallback: save (partial) BW frame, render LSB+MSB planes into the + // live framebuffer, display, then restore. Pre-check heap to avoid entering + // the chunk-retry path when success is unlikely. + const uint32_t bwStoreFreeHeap = esp_get_free_heap_size(); + const uint32_t bwStoreContigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT); + const bool shouldAttemptBwSnapshot = + bwStoreFreeHeap >= BW_SNAPSHOT_MIN_FREE_HEAP_BYTES && bwStoreContigHeap >= BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES; + + logReaderMemSnapshot("bw_store_begin"); + bool bwBufferStored = false; + if (shouldAttemptBwSnapshot) { + const int contentLeft = orientedMarginLeft; + const int contentRight = std::max(contentLeft, renderer.getScreenWidth() - orientedMarginRight); + const int contentBottom = std::max(contentTop, renderer.getScreenHeight() - orientedMarginBottom); + + int bandTop = 0; + int bandBottom = std::max(0, contentBottom - contentTop); + if (!computePageDynamicYBand(*page, renderer, getEffectiveReaderFontId(), bandBottom, &bandTop, &bandBottom)) { + bandTop = 0; + bandBottom = std::max(0, contentBottom - contentTop); + } + + const int snapshotTop = contentTop + bandTop; + const int snapshotHeight = std::max(0, bandBottom - bandTop); + bwBufferStored = renderer.storeBwBufferRect(contentLeft, snapshotTop, contentRight - contentLeft, snapshotHeight); + } else { + LOG_INF("ERS", "Skipping BW snapshot precheck (free=%lu contig=%lu, need free>=%lu contig>=%lu)", bwStoreFreeHeap, + bwStoreContigHeap, static_cast(BW_SNAPSHOT_MIN_FREE_HEAP_BYTES), + static_cast(BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES)); + } + const auto tBwStore = millis(); + logReaderMemSnapshot("bw_store_end"); + if (!bwBufferStored) { + if (aaEnabledForThisRender && !antiAliasingSuspendedLowMemory) { + antiAliasingSuspendedLowMemory = true; + const uint32_t freeHeapNow = esp_get_free_heap_size(); + const uint32_t contigHeapNow = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT); + LOG_INF("ERS", "Suspending text anti-aliasing due to low heap (free=%lu contig=%lu)", freeHeapNow, + contigHeapNow); + } + LOG_INF("ERS", "Skipping grayscale/BW-restore for this page (insufficient heap for BW snapshot)"); + } + + // grayscale rendering + // TODO: Only do this if font supports it + if (aaEnabledForThisRender && bwBufferStored) { + logReaderMemSnapshot("gray_lsb_begin"); + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + page->renderTextOnly(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop); + renderer.copyGrayscaleLsbBuffers(); + const auto tGrayLsb = millis(); + logReaderMemSnapshot("gray_lsb_end"); + + // Render and copy to MSB buffer + logReaderMemSnapshot("gray_msb_begin"); + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + page->renderTextOnly(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop); + renderer.copyGrayscaleMsbBuffers(); + const auto tGrayMsb = millis(); + logReaderMemSnapshot("gray_msb_end"); + + // display grayscale part + logReaderMemSnapshot("gray_display_begin"); + renderer.displayGrayBuffer(); + const auto tGrayDisplay = millis(); + renderer.setRenderMode(GfxRenderer::BW); + fcm->logStats("gray"); + logReaderMemSnapshot("gray_display_end"); + // restore the bw data logReaderMemSnapshot("bw_restore_begin"); renderer.restoreBwBuffer(); const auto tBwRestore = millis(); logReaderMemSnapshot("bw_restore_end"); - bwRestoreMs = tBwRestore - tBwStore; - } - const auto tEnd = millis(); - lastRenderStats.usedGrayscale = false; - lastRenderStats.phases = { - tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, 0, 0, 0, bwRestoreMs, - tEnd - t0}; - LOG_DBG("ERS", - "Page render: prewarm=%lums bw_render=%lums display=%lums bw_store=%lums bw_restore=%lums total=%lums", - tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, bwRestoreMs, tEnd - t0); + const auto tEnd = millis(); + lastRenderStats.usedGrayscale = true; + lastRenderStats.phases = {tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, + tBwStore - tDisplay, tGrayLsb - tBwStore, tGrayMsb - tGrayLsb, + tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0}; + LOG_DBG("ERS", + "Page render: prewarm=%lums bw_render=%lums display=%lums bw_store=%lums " + "gray_lsb=%lums gray_msb=%lums gray_display=%lums bw_restore=%lums total=%lums", + tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tGrayLsb - tBwStore, + tGrayMsb - tGrayLsb, tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0); + } else { + uint32_t bwRestoreMs = 0; + if (bwBufferStored) { + // restore the bw data + logReaderMemSnapshot("bw_restore_begin"); + renderer.restoreBwBuffer(); + const auto tBwRestore = millis(); + logReaderMemSnapshot("bw_restore_end"); + bwRestoreMs = tBwRestore - tBwStore; + } + + const auto tEnd = millis(); + lastRenderStats.usedGrayscale = false; + lastRenderStats.phases = { + tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, 0, 0, 0, bwRestoreMs, + tEnd - t0}; + LOG_DBG("ERS", + "Page render: prewarm=%lums bw_render=%lums display=%lums bw_store=%lums bw_restore=%lums total=%lums", + tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, bwRestoreMs, tEnd - t0); + } } if (const auto* cacheManager = renderer.getFontCacheManager()) { @@ -2249,9 +2335,16 @@ void EpubReaderActivity::displayPreRenderedPage(const Page& page, const int orie ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); } - // Grayscale AA pass (same as normal render — BW snapshot, gray LSB+MSB, restore). + // Grayscale AA pass. Prefer the tiled strip path (no BW snapshot needed); + // fall back to the legacy storeBwBufferRect path on controllers that don't + // support strip grayscale or when the strip scratch can't be allocated. const bool aaConfigured = SETTINGS.textAntiAliasing && !antiAliasingSuspendedLowMemory; if (aaConfigured) { + Page& pageRef = const_cast(page); + if (runTiledGrayscalePass(renderer, pageRef, getEffectiveReaderFontId(), orientedMarginLeft, contentTop)) { + return; + } + const uint32_t freeHeap = esp_get_free_heap_size(); const uint32_t contigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT); if (freeHeap >= BW_SNAPSHOT_MIN_FREE_HEAP_BYTES && contigHeap >= BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES) {