feat: tiled grayscale rendering to drop the storeBwBuffer peak (#2106)

Tiled grayscale rendering to drop the storeBwBuffer peak largest
contiguous free block (the value that actually drives OOM on the C3)
from ~114 KB to ~82-90 KB.

This renders each grayscale plane band-by-band into a small (~8 KB)
scratch and
streams each band straight to controller RAM (community-sdk
writeGrayscalePlaneStrip), leaving the BW framebuffer intact. No save,
no
restore; controller RAM is re-synced for the next differential turn
directly
from the live framebuffer.

Three writers honor the active band target so per-band re-rendering
stays cheap
and correct:

- drawPixel (text) redirects writes to the band scratch and clips to it.
- renderCharImpl skips glyphs whose physical y-extent is outside the
band before
the bitmap decode (glyphIntersectsStrip), so the per-band re-render
doesn't
  pay N x glyph decode.
- DirectPixelWriter (images) writes the band scratch via getWriteTarget
instead
  of the framebuffer. Without this, image pixels wrote the live BW frame
directly and cleanup re-synced that corruption, leaving thin outlines
after
  navigating away from an image.

Controller specifics live in the SDK (X4 setRamArea windowing, X3 PTL);
the
reader checks supportsStripGrayscale() and is otherwise
controller-agnostic.

Measured on hardware (X4 and X3, text and images, visually correct):

- Grayscale scratch ~8 KB vs ~50 KB save; largest contiguous free block
held at
  full size during grayscale instead of dropping ~25-32 KB.
- X4 text page about +25 ms/page; X3 page time is dominated by its
intrinsic
  grayscale refresh, not tiling.

Depends on community-sdk #13 (the writeGrayscalePlaneStrip API). The
submodule
bump here points at that branch, so until #13 merges the submodule won't
resolve
from upstream and CI will fail there; keeping this a draft until then.
Will
rebase onto master and re-point the submodule to the merged SDK commit
once #13
lands.

Did you use AI tools to help write this code? partial
This commit is contained in:
Jeremy Klein
2026-05-25 18:03:01 -04:00
committed by GitHub
parent 67973166e3
commit 4ee406897b
7 changed files with 244 additions and 44 deletions
+78 -3
View File
@@ -148,6 +148,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) {
@@ -238,14 +255,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) * panelWidthBytes + (phyX / 8);
const uint32_t byteIndex = rowY * panelWidthBytes + (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
}
}
@@ -964,9 +993,47 @@ static unsigned long start_ms = 0;
void GfxRenderer::clearScreen(const uint8_t color) const {
start_ms = millis();
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 or wraps
// the downstream uint16_t cast in writeGrayscalePlaneStrip.
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(orientation, x0, y0, &ax, &ay, panelWidth, panelHeight);
rotateCoordinates(orientation, 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::invertScreen() const {
for (uint32_t i = 0; i < frameBufferSize; i++) {
frameBuffer[i] = ~frameBuffer[i];
@@ -1368,6 +1435,14 @@ void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuff
void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(fadingFix); }
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::freeBwBufferChunks() {
for (auto& bwBufferChunk : bwBufferChunks) {
if (bwBufferChunk) {
+43
View File
@@ -54,6 +54,18 @@ class GfxRenderer {
// as before, concentrated in a single pointer instead of four fields.
mutable FontCacheManager* fontCacheManager_ = nullptr;
// Tiled grayscale strip target. When active, drawPixel()/clearScreen()
// operate on a caller-owned scratch holding one horizontal band of physical
// rows [_stripY0, _stripY0 + _stripRows) (panelWidthBytes wide) instead of
// the shared framebuffer, clipping pixels outside the band. 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();
@@ -114,6 +126,31 @@ class GfxRenderer {
void clearScreen(uint8_t color = 0xFF) const;
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
// Tiled grayscale strip target. While active, drawPixel() and clearScreen()
// 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;
// Band culling for tiled grayscale. 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 an expensive bitmap decode. Returns true when no strip is active.
// Corners are rotated to physical, so it is orientation-aware.
bool glyphIntersectsStrip(int x0, int y0, int x1, int y1) const;
// Active pixel-write target for raw writers (DirectPixelWriter) 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 : panelHeight; }
// Drawing
void drawPixel(int x, int y, bool state = true) const;
void drawLine(int x1, int y1, int x2, int y2, bool state = true) const;
@@ -172,6 +209,12 @@ class GfxRenderer {
void copyGrayscaleLsbBuffers() const;
void copyGrayscaleMsbBuffers() const;
void displayGrayBuffer() const;
// Tiled grayscale (X4): 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;
bool storeBwBuffer(); // Returns true if buffer was stored successfully
void restoreBwBuffer(); // Restore and free the stored buffer
void cleanupGrayscaleWithFrameBuffer() const;