From 9f34adcf99550ee23ffb62e0c3b4c7218550b952 Mon Sep 17 00:00:00 2001 From: jeremydk Date: Fri, 22 May 2026 16:00:55 +0200 Subject: [PATCH 1/6] 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) { From cb8c2f75ff2692a4bc5da8c216c26dad60903167 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 22 May 2026 16:35:37 +0200 Subject: [PATCH 2/6] Add X3 fast LUT option --- lib/GfxRenderer/GfxRenderer.h | 5 +++++ lib/I18n/translations/english.yaml | 1 + lib/hal/HalDisplay.cpp | 4 ++++ lib/hal/HalDisplay.h | 6 ++++++ open-x4-sdk | 2 +- src/CrossPointSettings.h | 5 +++++ src/SettingsList.h | 8 ++++++++ src/activities/reader/EpubReaderActivity.cpp | 18 +++++++++++++++--- 8 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e9b7bfd9..dad90cdd 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -230,6 +230,11 @@ class GfxRenderer { void writeGrayscalePlaneStrip(bool lsbPlane, const uint8_t* scratch, int yStart, int numRows) const; bool supportsStripGrayscale() const; + // X3-only: trade AA visual fidelity for ~2.2 s faster page-flip wall clock. + // No effect on X4 (its single grayscale LUT already runs at ~500 ms). + void setFastGrayscaleLut(bool fast) const { display.setFastGrayscaleLut(fast); } + bool getFastGrayscaleLut() const { return display.getFastGrayscaleLut(); } + // Tiled grayscale strip target. While active, drawPixel(), clearScreen(), // fillPhysicalHSpanByte() and renderGlyphFast2Bit() operate on `scratch` // (panelWidthBytes * stripRows bytes, holding physical rows diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index bd4b1aec..d5e99b9e 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -97,6 +97,7 @@ STR_SLEEP_COVER_MODE: "Sleep Screen Cover Mode" STR_HIDE_BATTERY: "Hide Battery %" STR_EXTRA_SPACING: "Extra Paragraph Spacing" STR_TEXT_AA: "Text Anti-Aliasing" +STR_FAST_AA: "Fast AA (X3 only)" STR_TEXT_DARKNESS: "Text Darkness" STR_EXTRA_DARK: "Extra Dark" STR_MAX_DARK: "Maximum" diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index 6fda0c0f..93560384 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -104,6 +104,10 @@ void HalDisplay::writeGrayscalePlaneStrip(bool lsbPlane, const uint8_t* rows, ui bool HalDisplay::supportsStripGrayscale() const { return einkDisplay.supportsStripGrayscale(); } +void HalDisplay::setFastGrayscaleLut(bool fast) { einkDisplay.setFastGrayscaleLut(fast); } + +bool HalDisplay::getFastGrayscaleLut() const { return einkDisplay.getFastGrayscaleLut(); } + 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 695b0991..bf016189 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -61,6 +61,12 @@ class HalDisplay { void writeGrayscalePlaneStrip(bool lsbPlane, const uint8_t* rows, uint16_t yStart, uint16_t numRows); bool supportsStripGrayscale() const; + // X3-only knob: pick between the OEM 53-frame grayscale LUT (default, slow + // and accurate) and the 7-frame community LUT (fast, slightly darker + // mid-tones). No effect on X4. See EInkDisplay::setFastGrayscaleLut. + void setFastGrayscaleLut(bool fast); + bool getFastGrayscaleLut() const; + // Runtime geometry passthrough uint16_t getDisplayWidth() const; uint16_t getDisplayHeight() const; diff --git a/open-x4-sdk b/open-x4-sdk index 15050cb2..5f4a46ea 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit 15050cb2a801b1fe6bad5212818307df1b834c5c +Subproject commit 5f4a46ea3a3adeca8dd29aeb8fc023a82afdffdd diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 2fce1e4c..5cd6deed 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -223,6 +223,11 @@ class CrossPointSettings { // Text rendering settings uint8_t extraParagraphSpacing = 1; uint8_t textAntiAliasing = 1; + // X3-only: when on, the AA refresh uses the 7-frame community grayscale LUT + // (~130 ms panel time) instead of the OEM 53-frame LUT (~2.4 s). Mid-tones + // run slightly darker than X4. Matches what papyrix-reader has shipped since + // 2025-11. Default off preserves OEM-fidelity grays. No effect on X4. + uint8_t fastAntiAliasing = 0; // Text darkness (0 = normal, 1 = dark, 2 = extra dark). Default 1 preserves // historical AA rendering (both grayscale shades drawn in the MSB pass). uint8_t textDarkness = DARKNESS_DARK; diff --git a/src/SettingsList.h b/src/SettingsList.h index 5fc16b06..6fd8bfce 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -111,6 +111,14 @@ inline const std::vector list = { SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing", StrId::STR_CAT_READER) .withSubmenu(StrId::STR_MENU_READER_FONT), + // X3-only fast AA LUT toggle. Swaps the 53-frame OEM grayscale waveform + // (~2.4 s panel time, X4-accurate grays) for the 7-frame community LUT + // (~130 ms, mid-tones slightly darker). See open-x4-sdk + // EInkDisplay::setFastGrayscaleLut for trade-offs. + SettingInfo::Toggle(StrId::STR_FAST_AA, &CrossPointSettings::fastAntiAliasing, "fastAntiAliasing", + StrId::STR_CAT_READER) + .withSubmenu(StrId::STR_MENU_READER_FONT) + .withDeviceTarget(SettingDeviceTarget::X3), SettingInfo::Enum(StrId::STR_TEXT_DARKNESS, &CrossPointSettings::textDarkness, {StrId::STR_NORMAL, StrId::STR_DARK, StrId::STR_EXTRA_DARK, StrId::STR_MAX_DARK}, "textDarkness", StrId::STR_CAT_READER) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 61ab7e95..beed9a2c 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -174,9 +174,15 @@ bool computePageDynamicYBand(const Page& page, const GfxRenderer& renderer, cons // 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) { +bool runTiledGrayscalePass(GfxRenderer& renderer, Page& page, int fontId, int marginLeft, int contentTop, bool fastAA) { if (!renderer.supportsStripGrayscale()) return false; + // Push the SETTINGS toggle into the SDK before the AA refresh. No-op on X4; + // on X3 picks between OEM _gc (slow/accurate) and community _grayscale + // (fast/darker mid-tones). Re-applied per render so a settings change takes + // effect on the next page flip without rebooting. + renderer.setFastGrayscaleLut(fastAA); + // 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 @@ -2157,7 +2163,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or if (aaEnabledForThisRender) { logReaderMemSnapshot("tiled_gray_begin"); const auto tTiledBegin = millis(); - grayscaleDone = runTiledGrayscalePass(renderer, *page, getEffectiveReaderFontId(), orientedMarginLeft, contentTop); + grayscaleDone = runTiledGrayscalePass(renderer, *page, getEffectiveReaderFontId(), orientedMarginLeft, contentTop, + SETTINGS.fastAntiAliasing); if (grayscaleDone) { tiledGrayMs = millis() - tTiledBegin; fcm->logStats("tiled_gray"); @@ -2219,6 +2226,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or // grayscale rendering // TODO: Only do this if font supports it if (aaEnabledForThisRender && bwBufferStored) { + // Push fast-AA toggle into the SDK before the AA refresh (X3 only; no-op + // on X4). Mirrors what runTiledGrayscalePass() does. + renderer.setFastGrayscaleLut(SETTINGS.fastAntiAliasing); logReaderMemSnapshot("gray_lsb_begin"); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); @@ -2341,7 +2351,8 @@ void EpubReaderActivity::displayPreRenderedPage(const Page& page, const int orie const bool aaConfigured = SETTINGS.textAntiAliasing && !antiAliasingSuspendedLowMemory; if (aaConfigured) { Page& pageRef = const_cast(page); - if (runTiledGrayscalePass(renderer, pageRef, getEffectiveReaderFontId(), orientedMarginLeft, contentTop)) { + if (runTiledGrayscalePass(renderer, pageRef, getEffectiveReaderFontId(), orientedMarginLeft, contentTop, + SETTINGS.fastAntiAliasing)) { return; } @@ -2360,6 +2371,7 @@ void EpubReaderActivity::displayPreRenderedPage(const Page& page, const int orie const int snapshotTop = contentTop + bandTop; const int snapshotHeight = std::max(0, bandBottom - bandTop); if (renderer.storeBwBufferRect(contentLeft, snapshotTop, contentRight - contentLeft, snapshotHeight)) { + renderer.setFastGrayscaleLut(SETTINGS.fastAntiAliasing); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); page.renderTextOnly(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop); From 9d79ed26f9be37192c50749a7023a37376e929d5 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 22 May 2026 18:29:33 +0200 Subject: [PATCH 3/6] Apply revert optimsation fix here too --- src/activities/reader/EpubReaderActivity.cpp | 97 ++++---------------- 1 file changed, 19 insertions(+), 78 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index beed9a2c..43be822d 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -104,62 +104,6 @@ void logReaderMemSnapshot(const char* stage) { inline void logReaderMemSnapshot(const char*) {} #endif -bool computePageDynamicYBand(const Page& page, const GfxRenderer& renderer, const int fontId, const int viewportHeight, - int* outTop, int* outBottom) { - if (viewportHeight <= 0 || !outTop || !outBottom) { - return false; - } - - bool hasRange = false; - int minY = viewportHeight; - int maxY = -1; - const int lineHeight = std::max(1, renderer.getLineHeight(fontId)); - - for (const auto& el : page.elements) { - if (!el) continue; - - const int elementTop = el->yPos; - int elementBottom = elementTop; - switch (el->getTag()) { - case TAG_PageLine: - elementBottom = el->yPos + lineHeight; - break; - case TAG_PageImage: { - const auto& img = static_cast(*el); - elementBottom = el->yPos + img.getImageBlock().getHeight(); - break; - } - case TAG_PageTable: { - const auto& table = static_cast(*el); - elementBottom = el->yPos + table.getTotalHeight(); - break; - } - default: - continue; - } - - minY = std::min(minY, elementTop); - maxY = std::max(maxY, elementBottom); - hasRange = true; - } - - if (!hasRange) { - return false; - } - - constexpr int BAND_PAD_PX = 2; - minY = std::max(0, minY - BAND_PAD_PX); - maxY = std::min(viewportHeight, maxY + BAND_PAD_PX); - - if (minY >= maxY) { - return false; - } - - *outTop = minY; - *outBottom = maxY; - 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 @@ -2191,20 +2135,23 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or logReaderMemSnapshot("bw_store_begin"); bool bwBufferStored = false; if (shouldAttemptBwSnapshot) { + // Snapshot must cover every BW pixel drawn outside the grayscale text region — + // status bar (in top/bottom margins), truncated-section hint, edge progress bars. + // The AA pass clears the whole BW buffer to 0x00, renders text-only, and pushes + // grayscale; rows outside the restored band end up zero, causing a BW/grayscale + // mismatch in the status bar area on the next page turn — visible as ghosting + // at the bottom in Landscape CCW (issue #256). + // + // The X argument is only meaningful in portrait (where panel rows map to logical + // X); in landscape, panel rows map to logical Y and the X range doesn't affect + // the saved row band. Spanning the full logical Y therefore saves the full panel + // rows in landscape (≈ full framebuffer), while in portrait we keep the + // optimization by clipping to the content X band — the status bar still fits + // since it's anchored to the same content margins. 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); + bwBufferStored = + renderer.storeBwBufferRect(contentLeft, 0, contentRight - contentLeft, renderer.getScreenHeight()); } 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), @@ -2359,18 +2306,12 @@ void EpubReaderActivity::displayPreRenderedPage(const Page& page, const int orie 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) { + // See the matching snapshot site above for why the full logical Y is needed: + // partial bands leave the status bar rows zeroed during the AA pass and the + // controller ghosts them on the next page turn (issue #256). 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); - if (renderer.storeBwBufferRect(contentLeft, snapshotTop, contentRight - contentLeft, snapshotHeight)) { + if (renderer.storeBwBufferRect(contentLeft, 0, contentRight - contentLeft, renderer.getScreenHeight())) { renderer.setFastGrayscaleLut(SETTINGS.fastAntiAliasing); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); From 35d3cbeebdb0c21be70f7c9e122fef45744dd0dc Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 22 May 2026 18:38:27 +0200 Subject: [PATCH 4/6] Some review comments --- lib/GfxRenderer/GfxRenderer.cpp | 9 ++++++++- src/activities/reader/EpubReaderActivity.cpp | 20 +++++++++++--------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 670d49d5..fbfb387b 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -851,7 +851,14 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode // Fast path: 1-bit BW mode, non-rotated text — byte-level framebuffer writes, no drawPixel() per pixel. if constexpr (rotation == TextRotation::None) { if (renderMode == GfxRenderer::BW) { - renderGlyphFastBW(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState, + // Use getWriteTarget() for buffer-routing symmetry with the 2-bit fast path above. + // renderGlyphFastBW is NOT strip-aware (no fbOriginY/fbRows in its signature), so it + // can only safely write to the full framebuffer. Today no caller activates a strip in + // BW mode (only the grayscale planes do), so this is equivalent to getFrameBuffer(). + // The assert is a tripwire if a future BW-under-strip path is added without + // retrofitting renderGlyphFastBW with strip-aware row math. + assert(!renderer.isStripActive()); + renderGlyphFastBW(renderer.getWriteTarget(), bitmap, width, height, innerBase, outerBase, pixelState, renderer.getOrientation(), renderer.getDisplayWidth(), renderer.getDisplayHeight(), renderer.getDisplayWidthBytes()); return; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 43be822d..6c4bd24a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -118,7 +118,8 @@ inline void logReaderMemSnapshot(const char*) {} // 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, bool fastAA) { +bool runTiledGrayscalePass(GfxRenderer& renderer, const Page& page, int fontId, int marginLeft, int contentTop, + bool fastAA) { if (!renderer.supportsStripGrayscale()) return false; // Push the SETTINGS toggle into the SDK before the AA refresh. No-op on X4; @@ -2126,11 +2127,13 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or } else { // 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. + // the chunk-retry path when success is unlikely. Skip the snapshot entirely + // when AA is off — there is nothing to restore from since no grayscale pass + // will run. 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; + const bool shouldAttemptBwSnapshot = aaEnabledForThisRender && bwStoreFreeHeap >= BW_SNAPSHOT_MIN_FREE_HEAP_BYTES && + bwStoreContigHeap >= BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES; logReaderMemSnapshot("bw_store_begin"); bool bwBufferStored = false; @@ -2152,15 +2155,15 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or const int contentRight = std::max(contentLeft, renderer.getScreenWidth() - orientedMarginRight); bwBufferStored = renderer.storeBwBufferRect(contentLeft, 0, contentRight - contentLeft, renderer.getScreenHeight()); - } else { + } else if (aaEnabledForThisRender) { 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) { + if (aaEnabledForThisRender && !bwBufferStored) { + if (!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); @@ -2297,8 +2300,7 @@ void EpubReaderActivity::displayPreRenderedPage(const Page& page, const int orie // 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, + if (runTiledGrayscalePass(renderer, page, getEffectiveReaderFontId(), orientedMarginLeft, contentTop, SETTINGS.fastAntiAliasing)) { return; } From b1f9fa9b3b4d94e9768e744a6d1714132a96a84b Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 22 May 2026 18:44:39 +0200 Subject: [PATCH 5/6] Another fix --- src/activities/reader/EpubReaderActivity.cpp | 32 ++++++-------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 6c4bd24a..b6afdc34 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2138,23 +2138,13 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or logReaderMemSnapshot("bw_store_begin"); bool bwBufferStored = false; if (shouldAttemptBwSnapshot) { - // Snapshot must cover every BW pixel drawn outside the grayscale text region — - // status bar (in top/bottom margins), truncated-section hint, edge progress bars. - // The AA pass clears the whole BW buffer to 0x00, renders text-only, and pushes - // grayscale; rows outside the restored band end up zero, causing a BW/grayscale - // mismatch in the status bar area on the next page turn — visible as ghosting - // at the bottom in Landscape CCW (issue #256). - // - // The X argument is only meaningful in portrait (where panel rows map to logical - // X); in landscape, panel rows map to logical Y and the X range doesn't affect - // the saved row band. Spanning the full logical Y therefore saves the full panel - // rows in landscape (≈ full framebuffer), while in portrait we keep the - // optimization by clipping to the content X band — the status bar still fits - // since it's anchored to the same content margins. - const int contentLeft = orientedMarginLeft; - const int contentRight = std::max(contentLeft, renderer.getScreenWidth() - orientedMarginRight); - bwBufferStored = - renderer.storeBwBufferRect(contentLeft, 0, contentRight - contentLeft, renderer.getScreenHeight()); + // Snapshot the full framebuffer. The AA pass calls clearScreen(0x00) which + // touches every panel row, and restoreBwBuffer() ends with + // cleanupGrayscaleBuffers(frameBuffer) that syncs the controller's belief + // from the framebuffer — so any row not in the snapshot ends up zero in the + // FB and the controller drifts out of sync with what's physically on the + // panel, producing ghosting on the next page turn (issue #256). + bwBufferStored = renderer.storeBwBuffer(); } else if (aaEnabledForThisRender) { 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), @@ -2308,12 +2298,8 @@ void EpubReaderActivity::displayPreRenderedPage(const Page& page, const int orie 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) { - // See the matching snapshot site above for why the full logical Y is needed: - // partial bands leave the status bar rows zeroed during the AA pass and the - // controller ghosts them on the next page turn (issue #256). - const int contentLeft = orientedMarginLeft; - const int contentRight = std::max(contentLeft, renderer.getScreenWidth() - orientedMarginRight); - if (renderer.storeBwBufferRect(contentLeft, 0, contentRight - contentLeft, renderer.getScreenHeight())) { + // Full-framebuffer snapshot — see the matching site above for the rationale (issue #256). + if (renderer.storeBwBuffer()) { renderer.setFastGrayscaleLut(SETTINGS.fastAntiAliasing); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); From d383d32bcb0decc68410608ee24bd8cc4ca62bff Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 22 May 2026 18:49:18 +0200 Subject: [PATCH 6/6] And another one --- lib/GfxRenderer/GfxRenderer.cpp | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index fbfb387b..984f9b30 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -849,16 +849,13 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode } } else { // Fast path: 1-bit BW mode, non-rotated text — byte-level framebuffer writes, no drawPixel() per pixel. + // renderGlyphFastBW is NOT strip-aware (no fbOriginY/fbRows in its signature) and would + // mis-index into the strip scratch as if it were the full framebuffer. Today no caller + // activates a strip in BW mode, but route to the per-pixel fallback (drawPixel is + // strip-aware) if that ever changes so we never hand a strip buffer to the fast helper. if constexpr (rotation == TextRotation::None) { - if (renderMode == GfxRenderer::BW) { - // Use getWriteTarget() for buffer-routing symmetry with the 2-bit fast path above. - // renderGlyphFastBW is NOT strip-aware (no fbOriginY/fbRows in its signature), so it - // can only safely write to the full framebuffer. Today no caller activates a strip in - // BW mode (only the grayscale planes do), so this is equivalent to getFrameBuffer(). - // The assert is a tripwire if a future BW-under-strip path is added without - // retrofitting renderGlyphFastBW with strip-aware row math. - assert(!renderer.isStripActive()); - renderGlyphFastBW(renderer.getWriteTarget(), bitmap, width, height, innerBase, outerBase, pixelState, + if (renderMode == GfxRenderer::BW && !renderer.isStripActive()) { + renderGlyphFastBW(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState, renderer.getOrientation(), renderer.getDisplayWidth(), renderer.getDisplayHeight(), renderer.getDisplayWidthBytes()); return;