Merge pull request #257 from jpirnay/refactor-stripedbuffer

feat: tiled grayscale rendering to drop the storeBwBuffer peak (#2106 by jermydk)
This commit is contained in:
jpirnay
2026-05-22 19:02:04 +02:00
committed by GitHub
10 changed files with 440 additions and 138 deletions
+15 -2
View File
@@ -16,6 +16,12 @@ struct DirectPixelWriter {
uint8_t* fb; uint8_t* fb;
GfxRenderer::RenderMode mode; GfxRenderer::RenderMode mode;
uint16_t displayWidthBytes; // Runtime framebuffer stride (X4: 100, X3: 99) 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: // Orientation is collapsed into a linear transform:
// phyX = phyXBase + x * phyXStepX + y * phyXStepY // phyX = phyXBase + x * phyXStepX + y * phyXStepY
@@ -28,7 +34,9 @@ struct DirectPixelWriter {
int rowPhyXBase, rowPhyYBase; int rowPhyXBase, rowPhyYBase;
void init(GfxRenderer& renderer) { void init(GfxRenderer& renderer) {
fb = renderer.getFrameBuffer(); fb = renderer.getWriteTarget();
originY = renderer.getWriteOriginY();
clipRows = renderer.getWriteRows();
mode = renderer.getRenderMode(); mode = renderer.getRenderMode();
displayWidthBytes = renderer.getDisplayWidthBytes(); displayWidthBytes = renderer.getDisplayWidthBytes();
@@ -120,7 +128,12 @@ struct DirectPixelWriter {
const int phyX = rowPhyXBase + logicalX * phyXStepX; const int phyX = rowPhyXBase + logicalX * phyXStepX;
const int phyY = rowPhyYBase + logicalX * phyYStepX; 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<unsigned>(sy) >= static_cast<unsigned>(clipRows)) return;
const uint16_t byteIndex = static_cast<uint16_t>(sy * displayWidthBytes + (phyX >> 3));
const uint8_t bitMask = 1 << (7 - (phyX & 7)); const uint8_t bitMask = 1 << (7 - (phyX & 7));
if (state) { if (state) {
+138 -26
View File
@@ -8,6 +8,8 @@
#include <esp_heap_caps.h> #include <esp_heap_caps.h>
#include <algorithm> #include <algorithm>
#include <cassert>
#include <cstring>
#include "FontCacheManager.h" #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=false → Portrait (phyY counts down, phyBitPos counts up).
// inverted=true → PortraitInverted (phyY counts up, phyBitPos counts down). // inverted=true → PortraitInverted (phyY counts up, phyBitPos counts down).
// Both template params are compile-time constants; all ternaries fold away. // 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 <uint8_t drawMask, bool inverted> template <uint8_t drawMask, bool inverted>
static void renderGlyphFast2BitPortrait(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, 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 int glyphHeight, const int screenXBase, const int screenYBase,
const bool writeState, const int displayWidth, const int displayHeight, 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++) { for (int glyphX = 0; glyphX < glyphWidth; glyphX++) {
const int phyY = inverted ? (screenXBase + glyphX) : (displayHeight - 1 - (screenXBase + glyphX)); const int phyY = inverted ? (screenXBase + glyphX) : (displayHeight - 1 - (screenXBase + glyphX));
if (phyY < 0 || phyY >= displayHeight) continue; if (phyY < 0 || phyY >= displayHeight) continue;
uint8_t* const row = frameBuffer + phyY * widthBytes; const int rowY = phyY - fbOriginY;
if (static_cast<unsigned>(rowY) >= static_cast<unsigned>(fbRows)) continue;
uint8_t* const row = frameBuffer + rowY * widthBytes;
for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) {
const int count = std::min(8, glyphHeight - glyphY); const int count = std::min(8, glyphHeight - glyphY);
const uint8_t mask = build2BitColMask<drawMask>(bitmap, glyphWidth, glyphX, glyphY, count, inverted); const uint8_t mask = build2BitColMask<drawMask>(bitmap, glyphWidth, glyphX, glyphY, count, inverted);
@@ -635,18 +643,25 @@ template <uint8_t drawMask>
static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, 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 int glyphHeight, const int screenXBase, const int screenYBase,
const bool pixelState, const GfxRenderer::Orientation orientation, 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. // 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. // 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. // 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; const bool writeState = (drawMask == 0x0E) ? pixelState : false;
switch (orientation) { switch (orientation) {
case GfxRenderer::LandscapeCounterClockwise: { case GfxRenderer::LandscapeCounterClockwise: {
for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { for (int glyphY = 0; glyphY < glyphHeight; glyphY++) {
const int phyY = screenYBase + glyphY; const int phyY = screenYBase + glyphY;
if (phyY < 0 || phyY >= displayHeight) continue; const int rowY = phyY - fbOriginY;
uint8_t* const row = frameBuffer + phyY * widthBytes; if (static_cast<unsigned>(rowY) >= static_cast<unsigned>(fbRows)) continue;
uint8_t* const row = frameBuffer + rowY * widthBytes;
const int rowStartPixel = glyphY * glyphWidth; const int rowStartPixel = glyphY * glyphWidth;
for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) {
const int count = std::min(8, glyphWidth - glyphX); 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. // within each row, which is more cache-friendly than the chunk-outer alternative.
for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { for (int glyphY = 0; glyphY < glyphHeight; glyphY++) {
const int phyY = displayHeight - 1 - (screenYBase + glyphY); const int phyY = displayHeight - 1 - (screenYBase + glyphY);
if (phyY < 0 || phyY >= displayHeight) continue; const int rowY = phyY - fbOriginY;
uint8_t* const row = frameBuffer + phyY * widthBytes; if (static_cast<unsigned>(rowY) >= static_cast<unsigned>(fbRows)) continue;
uint8_t* const row = frameBuffer + rowY * widthBytes;
const int rowStartPixel = glyphY * glyphWidth; const int rowStartPixel = glyphY * glyphWidth;
for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) { for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) {
const int chunkStart = std::max(0, chunkEnd - 7); 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: case GfxRenderer::Portrait:
renderGlyphFast2BitPortrait<drawMask, false>(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase, renderGlyphFast2BitPortrait<drawMask, false>(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase,
screenYBase, writeState, displayWidth, displayHeight, widthBytes); screenYBase, writeState, displayWidth, displayHeight, widthBytes,
fbOriginY, fbRows);
break; break;
case GfxRenderer::PortraitInverted: case GfxRenderer::PortraitInverted:
renderGlyphFast2BitPortrait<drawMask, true>(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase, renderGlyphFast2BitPortrait<drawMask, true>(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase,
screenYBase, writeState, displayWidth, displayHeight, widthBytes); screenYBase, writeState, displayWidth, displayHeight, widthBytes,
fbOriginY, fbRows);
break; break;
} }
} }
@@ -727,6 +745,23 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
const int left = glyph->left; const int left = glyph->left;
const int top = glyph->top; 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); const uint8_t* bitmap = renderer.getGlyphBitmap(fontData, glyph);
if (bitmap != nullptr) { if (bitmap != nullptr) {
@@ -752,26 +787,32 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
if constexpr (rotation == TextRotation::None) { if constexpr (rotation == TextRotation::None) {
// Fast path for normal text orientation. Handles all device orientations via renderGlyphFast2Bit. // 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) { switch (drawMask) {
case 0x0E: // BW case 0x0E: // BW
renderGlyphFast2Bit<0x0E>(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, renderGlyphFast2Bit<0x0E>(fb, bitmap, width, height, innerBase, outerBase, pixelState,
pixelState, renderer.getOrientation(), renderer.getDisplayWidth(), renderer.getOrientation(), renderer.getDisplayWidth(),
renderer.getDisplayHeight(), renderer.getDisplayWidthBytes()); renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows);
break; break;
case 0x06: // raw {1,2} case 0x06: // raw {1,2}
renderGlyphFast2Bit<0x06>(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, renderGlyphFast2Bit<0x06>(fb, bitmap, width, height, innerBase, outerBase, pixelState,
pixelState, renderer.getOrientation(), renderer.getDisplayWidth(), renderer.getOrientation(), renderer.getDisplayWidth(),
renderer.getDisplayHeight(), renderer.getDisplayWidthBytes()); renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows);
break; break;
case 0x04: // raw {2} case 0x04: // raw {2}
renderGlyphFast2Bit<0x04>(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, renderGlyphFast2Bit<0x04>(fb, bitmap, width, height, innerBase, outerBase, pixelState,
pixelState, renderer.getOrientation(), renderer.getDisplayWidth(), renderer.getOrientation(), renderer.getDisplayWidth(),
renderer.getDisplayHeight(), renderer.getDisplayWidthBytes()); renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows);
break; break;
case 0x02: // raw {1} case 0x02: // raw {1}
renderGlyphFast2Bit<0x02>(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, renderGlyphFast2Bit<0x02>(fb, bitmap, width, height, innerBase, outerBase, pixelState,
pixelState, renderer.getOrientation(), renderer.getDisplayWidth(), renderer.getOrientation(), renderer.getDisplayWidth(),
renderer.getDisplayHeight(), renderer.getDisplayWidthBytes()); renderer.getDisplayHeight(), renderer.getDisplayWidthBytes(), fbOriginY, fbRows);
break; break;
} }
return; return;
@@ -808,8 +849,12 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
} }
} else { } else {
// Fast path: 1-bit BW mode, non-rotated text — byte-level framebuffer writes, no drawPixel() per pixel. // 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 constexpr (rotation == TextRotation::None) {
if (renderMode == GfxRenderer::BW) { if (renderMode == GfxRenderer::BW && !renderer.isStripActive()) {
renderGlyphFastBW(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState, renderGlyphFastBW(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState,
renderer.getOrientation(), renderer.getDisplayWidth(), renderer.getDisplayHeight(), renderer.getOrientation(), renderer.getDisplayWidth(), renderer.getDisplayHeight(),
renderer.getDisplayWidthBytes()); renderer.getDisplayWidthBytes());
@@ -921,14 +966,26 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
return; 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<uint32_t>(phyY);
if (stripActive_) {
if (phyY < stripY0_ || phyY >= stripY0_ + stripRows_) {
return; // pixel outside the band currently being rendered
}
target = stripBuf_;
rowY = static_cast<uint32_t>(phyY - stripY0_);
}
// Calculate byte position and bit position // Calculate byte position and bit position
const uint32_t byteIndex = static_cast<uint32_t>(phyY) * getDisplayWidthBytes() + (phyX / 8); const uint32_t byteIndex = rowY * getDisplayWidthBytes() + (phyX / 8);
const uint8_t bitPosition = 7 - (phyX % 8); // MSB first const uint8_t bitPosition = 7 - (phyX % 8); // MSB first
if (state) { if (state) {
frameBuffer[byteIndex] &= ~(1 << bitPosition); // Clear bit target[byteIndex] &= ~(1 << bitPosition); // Clear bit
} else { } else {
frameBuffer[byteIndex] |= 1 << bitPosition; // Set bit target[byteIndex] |= 1 << bitPosition; // Set bit
} }
} }
@@ -1237,7 +1294,17 @@ void GfxRenderer::fillPhysicalHSpanByte(const int phyY, const int phyX_start, co
const int cX1 = std::min(phyX_end, (int)getDisplayWidth() - 1); const int cX1 = std::min(phyX_end, (int)getDisplayWidth() - 1);
if (cX0 > cX1 || phyY < 0 || phyY >= (int)getDisplayHeight()) return; 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 startByte = cX0 >> 3;
const int endByte = cX1 >> 3; const int endByte = cX1 >> 3;
const int leftBits = cX0 & 7; // first bit index within startByte const int leftBits = cX0 & 7; // first bit index within startByte
@@ -1862,9 +1929,54 @@ static bool start_ms_valid = false;
void GfxRenderer::clearScreen(const uint8_t color) const { void GfxRenderer::clearScreen(const uint8_t color) const {
start_ms = millis(); start_ms = millis();
start_ms_valid = true; start_ms_valid = true;
if (stripActive_) {
// Clear only the active band's scratch, not the shared framebuffer.
memset(stripBuf_, color, static_cast<size_t>(panelWidthBytes) * stripRows_);
return;
}
display.clearScreen(color); 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<int>(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<int>(panelHeight) - numRows);
display.writeGrayscalePlaneStrip(lsbPlane, scratch, static_cast<uint16_t>(yStart), static_cast<uint16_t>(numRows));
}
bool GfxRenderer::supportsStripGrayscale() const { return display.supportsStripGrayscale(); }
void GfxRenderer::invertScreen() const { void GfxRenderer::invertScreen() const {
for (uint32_t i = 0; i < frameBufferSize; i++) { for (uint32_t i = 0; i < frameBufferSize; i++) {
frameBuffer[i] = ~frameBuffer[i]; frameBuffer[i] = ~frameBuffer[i];
+52
View File
@@ -72,6 +72,19 @@ class GfxRenderer {
mutable FontCacheManager* fontCacheManager_ = nullptr; mutable FontCacheManager* fontCacheManager_ = nullptr;
mutable std::atomic<unsigned int> refreshOverride = REFRESH_OVERRIDE_NONE; mutable std::atomic<unsigned int> 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, void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, int* y, bool pixelState,
EpdFontFamily::Style style) const; EpdFontFamily::Style style) const;
void freeBwBufferChunks(); void freeBwBufferChunks();
@@ -209,6 +222,45 @@ class GfxRenderer {
void copyGrayscaleLsbBuffers() const; void copyGrayscaleLsbBuffers() const;
void copyGrayscaleMsbBuffers() const; void copyGrayscaleMsbBuffers() const;
void displayGrayBuffer() 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;
// 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
// [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<int>(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 storeBwBuffer(); // Returns true if buffer was stored successfully
bool storeBwBufferRect(int x, int y, int width, int height); // Store only rows intersecting logical rect bool storeBwBufferRect(int x, int y, int width, int height); // Store only rows intersecting logical rect
void restoreBwBuffer(); // Restore and free the stored buffer void restoreBwBuffer(); // Restore and free the stored buffer
+1
View File
@@ -97,6 +97,7 @@ STR_SLEEP_COVER_MODE: "Sleep Screen Cover Mode"
STR_HIDE_BATTERY: "Hide Battery %" STR_HIDE_BATTERY: "Hide Battery %"
STR_EXTRA_SPACING: "Extra Paragraph Spacing" STR_EXTRA_SPACING: "Extra Paragraph Spacing"
STR_TEXT_AA: "Text Anti-Aliasing" STR_TEXT_AA: "Text Anti-Aliasing"
STR_FAST_AA: "Fast AA (X3 only)"
STR_TEXT_DARKNESS: "Text Darkness" STR_TEXT_DARKNESS: "Text Darkness"
STR_EXTRA_DARK: "Extra Dark" STR_EXTRA_DARK: "Extra Dark"
STR_MAX_DARK: "Maximum" STR_MAX_DARK: "Maximum"
+11
View File
@@ -97,6 +97,17 @@ void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.
void HalDisplay::displayGrayBuffer(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); } 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(); }
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::getDisplayWidth() const { return einkDisplay.getDisplayWidth(); }
uint16_t HalDisplay::getDisplayHeight() const { return einkDisplay.getDisplayHeight(); } uint16_t HalDisplay::getDisplayHeight() const { return einkDisplay.getDisplayHeight(); }
+12
View File
@@ -55,6 +55,18 @@ class HalDisplay {
void displayGrayBuffer(bool turnOffScreen = false); 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;
// 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 // Runtime geometry passthrough
uint16_t getDisplayWidth() const; uint16_t getDisplayWidth() const;
uint16_t getDisplayHeight() const; uint16_t getDisplayHeight() const;
+5
View File
@@ -223,6 +223,11 @@ class CrossPointSettings {
// Text rendering settings // Text rendering settings
uint8_t extraParagraphSpacing = 1; uint8_t extraParagraphSpacing = 1;
uint8_t textAntiAliasing = 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 // Text darkness (0 = normal, 1 = dark, 2 = extra dark). Default 1 preserves
// historical AA rendering (both grayscale shades drawn in the MSB pass). // historical AA rendering (both grayscale shades drawn in the MSB pass).
uint8_t textDarkness = DARKNESS_DARK; uint8_t textDarkness = DARKNESS_DARK;
+8
View File
@@ -111,6 +111,14 @@ inline const std::vector<SettingInfo> list = {
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing", SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
StrId::STR_CAT_READER) StrId::STR_CAT_READER)
.withSubmenu(StrId::STR_MENU_READER_FONT), .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, 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_NORMAL, StrId::STR_DARK, StrId::STR_EXTRA_DARK, StrId::STR_MAX_DARK}, "textDarkness",
StrId::STR_CAT_READER) StrId::STR_CAT_READER)
+144 -56
View File
@@ -104,6 +104,70 @@ void logReaderMemSnapshot(const char* stage) {
inline void logReaderMemSnapshot(const char*) {} inline void logReaderMemSnapshot(const char*) {}
#endif #endif
// 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, 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;
// 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
// ≈ ~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<uint8_t[]>(new (std::nothrow) uint8_t[static_cast<size_t>(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 // 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 // pre-render writes), in which case the next saveProgress() will overwrite progress.bin with the
// real value before the user can leave the reader. // real value before the user can leave the reader.
@@ -2027,49 +2091,6 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
} }
const auto tDisplay = millis(); 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) {
// 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. So spanning the full logical Y always 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());
} else {
LOG_INF("ERS", "Skipping BW snapshot precheck (free=%lu contig=%lu, need free>=%lu contig>=%lu)", bwStoreFreeHeap,
bwStoreContigHeap, static_cast<uint32_t>(BW_SNAPSHOT_MIN_FREE_HEAP_BYTES),
static_cast<uint32_t>(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. // 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. // Placeholder-only pages don't deposit grayscale data that needs settling.
if (page->hasImages() && !page->allImagesArePlaceholders(effectiveForceLoad) && if (page->hasImages() && !page->allImagesArePlaceholders(effectiveForceLoad) &&
@@ -2077,9 +2098,77 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
pendingHalfRefreshAfterImagePage = true; pendingHalfRefreshAfterImagePage = true;
} }
// 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,
SETTINGS.fastAntiAliasing);
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, 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 {
// 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. 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 = aaEnabledForThisRender && bwStoreFreeHeap >= BW_SNAPSHOT_MIN_FREE_HEAP_BYTES &&
bwStoreContigHeap >= BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES;
logReaderMemSnapshot("bw_store_begin");
bool bwBufferStored = false;
if (shouldAttemptBwSnapshot) {
// 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<uint32_t>(BW_SNAPSHOT_MIN_FREE_HEAP_BYTES),
static_cast<uint32_t>(BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES));
}
const auto tBwStore = millis();
logReaderMemSnapshot("bw_store_end");
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);
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 // grayscale rendering
// TODO: Only do this if font supports it // TODO: Only do this if font supports it
if (aaEnabledForThisRender && bwBufferStored) { 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"); logReaderMemSnapshot("gray_lsb_begin");
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
@@ -2141,6 +2230,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
"Page render: prewarm=%lums bw_render=%lums display=%lums bw_store=%lums bw_restore=%lums total=%lums", "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); tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, bwRestoreMs, tEnd - t0);
} }
}
if (const auto* cacheManager = renderer.getFontCacheManager()) { if (const auto* cacheManager = renderer.getFontCacheManager()) {
if (const auto* decompressor = cacheManager->getDecompressor()) { if (const auto* decompressor = cacheManager->getDecompressor()) {
@@ -2195,24 +2285,22 @@ void EpubReaderActivity::displayPreRenderedPage(const Page& page, const int orie
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); 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; const bool aaConfigured = SETTINGS.textAntiAliasing && !antiAliasingSuspendedLowMemory;
if (aaConfigured) { if (aaConfigured) {
if (runTiledGrayscalePass(renderer, page, getEffectiveReaderFontId(), orientedMarginLeft, contentTop,
SETTINGS.fastAntiAliasing)) {
return;
}
const uint32_t freeHeap = esp_get_free_heap_size(); 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); 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) { if (freeHeap >= BW_SNAPSHOT_MIN_FREE_HEAP_BYTES && contigHeap >= BW_SNAPSHOT_MIN_CONTIG_HEAP_BYTES) {
const int contentLeft = orientedMarginLeft; // Full-framebuffer snapshot — see the matching site above for the rationale (issue #256).
const int contentRight = std::max(contentLeft, renderer.getScreenWidth() - orientedMarginRight); if (renderer.storeBwBuffer()) {
const int contentBottom = std::max(contentTop, renderer.getScreenHeight() - orientedMarginBottom); renderer.setFastGrayscaleLut(SETTINGS.fastAntiAliasing);
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)) {
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
page.renderTextOnly(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop); page.renderTextOnly(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop);