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;
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<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));
if (state) {
+138 -26
View File
@@ -8,6 +8,8 @@
#include <esp_heap_caps.h>
#include <algorithm>
#include <cassert>
#include <cstring>
#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 <uint8_t drawMask, bool inverted>
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<unsigned>(rowY) >= static_cast<unsigned>(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<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,
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<unsigned>(rowY) >= static_cast<unsigned>(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<unsigned>(rowY) >= static_cast<unsigned>(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<drawMask, false>(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase,
screenYBase, writeState, displayWidth, displayHeight, widthBytes);
screenYBase, writeState, displayWidth, displayHeight, widthBytes,
fbOriginY, fbRows);
break;
case GfxRenderer::PortraitInverted:
renderGlyphFast2BitPortrait<drawMask, true>(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;
@@ -808,8 +849,12 @@ 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) {
if (renderMode == GfxRenderer::BW && !renderer.isStripActive()) {
renderGlyphFastBW(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState,
renderer.getOrientation(), renderer.getDisplayWidth(), renderer.getDisplayHeight(),
renderer.getDisplayWidthBytes());
@@ -921,14 +966,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<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
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
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 +1294,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 +1929,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<size_t>(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<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 {
for (uint32_t i = 0; i < frameBufferSize; i++) {
frameBuffer[i] = ~frameBuffer[i];
+52
View File
@@ -72,6 +72,19 @@ class GfxRenderer {
mutable FontCacheManager* fontCacheManager_ = nullptr;
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,
EpdFontFamily::Style style) const;
void freeBwBufferChunks();
@@ -209,6 +222,45 @@ 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;
// 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 storeBwBufferRect(int x, int y, int width, int height); // Store only rows intersecting logical rect
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_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"
+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::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::getDisplayHeight() const { return einkDisplay.getDisplayHeight(); }
+12
View File
@@ -55,6 +55,18 @@ 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;
// 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;
+5
View File
@@ -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;
+8
View File
@@ -111,6 +111,14 @@ inline const std::vector<SettingInfo> 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)
+197 -109
View File
@@ -104,6 +104,70 @@ void logReaderMemSnapshot(const char* stage) {
inline void logReaderMemSnapshot(const char*) {}
#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
// pre-render writes), in which case the next saveProgress() will overwrite progress.bin with the
// 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();
// 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.
// Placeholder-only pages don't deposit grayscale data that needs settling.
if (page->hasImages() && !page->allImagesArePlaceholders(effectiveForceLoad) &&
@@ -2077,69 +2098,138 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> 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,
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,
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. 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
// 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);
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()) {
@@ -2195,24 +2285,22 @@ 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) {
if (runTiledGrayscalePass(renderer, page, getEffectiveReaderFontId(), orientedMarginLeft, contentTop,
SETTINGS.fastAntiAliasing)) {
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) {
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)) {
// 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);
page.renderTextOnly(renderer, getEffectiveReaderFontId(), orientedMarginLeft, contentTop);