From e87106d4d4d6340cd047d6d1650129f945bbbfcc Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 21 Feb 2026 10:42:11 +0100 Subject: [PATCH] Optimize 1bit font rendering --- lib/GfxRenderer/GfxRenderer.cpp | 300 ++++++++++++++++++++++++++++++++ lib/GfxRenderer/GfxRenderer.h | 5 + 2 files changed, 305 insertions(+) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index b385bc03..16b71426 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -61,6 +61,250 @@ static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, enum class TextRotation { None, Rotated90CW }; +// ============================================================================= +// Fast-path glyph rendering helpers (1-bit BW fonts, TextRotation::None) +// ============================================================================= +// +// OVERVIEW +// -------- +// The legacy path called drawPixel() once per set glyph pixel. drawPixel() +// invokes rotateCoordinates() (a switch), does a bounds check, logs on OOB, +// then writes one bit. For a typical 10×14 UI glyph that is ~100 calls. +// +// This fast path eliminates drawPixel() entirely by writing directly to the +// framebuffer in up to 8-pixel chunks via writeRowBits(). +// +// FRAMEBUFFER LAYOUT +// ------------------ +// 1 bpp, MSB-first, DISPLAY_WIDTH (800) pixels per row stored in +// DISPLAY_WIDTH_BYTES (100) bytes. Bit 7 of byte 0 = leftmost pixel of +// row 0. "Physical row" phyY occupies bytes [phyY*100 .. phyY*100+99]. +// A set bit (1) is WHITE; a cleared bit (0) is BLACK. +// +// LANDSCAPE ORIENTATIONS (2.5–3.1× speedup vs legacy) +// ------------------------------------------------------- +// phyX and phyY are both linear functions of glyphX/glyphY in these modes, +// so each glyph row maps directly to a physical framebuffer row. +// +// LandscapeCounterClockwise: phyX = screenXBase+glyphX, phyY = screenYBase+glyphY +// LandscapeClockwise: phyX = W-1-screenXBase-glyphX, phyY = H-1-screenYBase-glyphY +// +// Strategy: outer loop over glyphY (one physical row per iteration), inner +// loop reads 8-pixel chunks of that glyph row with bitmapExtract() and writes +// them with writeRowBits(). Bitmap access is purely sequential — fastest. +// LandscapeClockwise iterates glyph chunks right-to-left and applies +// reverseBits8() to flip horizontal direction. +// +// PORTRAIT ORIENTATIONS (~2× speedup vs legacy) +// ----------------------------------------------- +// Portrait (90° CW panel rotation): +// phyX = screenYBase+glyphY, phyY = H-1-screenXBase-glyphX +// PortraitInverted (90° CCW panel rotation): +// phyX = W-1-screenYBase-glyphY, phyY = screenXBase+glyphX +// +// Here glyph COLUMNS map to physical rows. Naively iterating column-by-column +// reads the bitmap with stride glyphWidth — cache-unfriendly and one bit at a +// time. Instead we use an 8×8 bit-matrix transpose: +// +// For each 8-row × 8-column glyph block: +// 1. Read 8 consecutive glyph rows (sequential bitmap access) into the +// top 8 bytes of a uint64_t (one bitmapExtract per row). +// 2. Call transpose8x8() — an O(log 8) butterfly transform — to swap +// the role of rows and columns in 3 passes of XOR-masking. +// 3. The resulting uint64_t holds 8 column bytes: byte k contains the +// bits for glyph column glyphX+k, one per physical row, MSB-aligned. +// 4. Write each column byte with writeRowBits() to its physical row. +// +// For PortraitInverted the glyph rows are packed in reverse order (last row +// at MSB of the uint64_t) before transposing. This ensures the post-transpose +// column bytes are already correctly ordered (MSB = leftmost phyX) without any +// per-column bit-reversal step. +// +// PARAMETERS +// ---------- +// screenXBase = cursorX + glyph->left (logical X of glyph pixel [0,0]) +// screenYBase = cursorY - glyph->top (logical Y of glyph pixel [0,0]) + +// Reverse all 8 bits of a byte (bit 7 ↔ bit 0). +static inline uint8_t reverseBits8(uint8_t b) { + b = (b & 0xF0) >> 4 | (b & 0x0F) << 4; + b = (b & 0xCC) >> 2 | (b & 0x33) << 2; + b = (b & 0xAA) >> 1 | (b & 0x55) << 1; + return b; +} + +// Transpose an 8×8 bit matrix packed into a uint64_t. +// +// Input layout (row-major, row 0 at MSB): +// bit (63 - 8*r - c) = matrix[r][c] (r=row 0..7, c=col 0..7) +// +// After transposition: +// bit (63 - 8*c - r) = matrix[r][c] +// i.e. byte k = bits [63-8k .. 56-8k] holds column k, MSB = row 0. +// +// Uses the classic 3-pass butterfly (Warren, "Hacker's Delight" §7-3): +// pass 1 swaps adjacent bit-pairs across a stride of 7 (nibble level), +// pass 2 swaps across stride 14 (byte level), +// pass 3 swaps across stride 28 (half-word level). +static inline uint64_t transpose8x8(uint64_t x) { + uint64_t t; + t = (x ^ (x >> 7)) & 0x00AA00AA00AA00AAULL; + x ^= t ^ (t << 7); + t = (x ^ (x >> 14)) & 0x0000CCCC0000CCCCULL; + x ^= t ^ (t << 14); + t = (x ^ (x >> 28)) & 0x00000000F0F0F0F0ULL; + x ^= t ^ (t << 28); + return x; +} + +// Extract up to 8 bits from a 1-bit MSB-first packed bitmap starting at bit +// position 'bitPos'. Returns them MSB-aligned (bit 7 = first extracted bit); +// the lower (8-count) bits are zeroed. +// All 'count' bits must lie within the valid bitmap byte range. +static inline uint8_t bitmapExtract(const uint8_t* bitmap, const int bitPos, const int count) { + const int byteIdx = bitPos >> 3; + const int bitOff = bitPos & 7; + uint8_t result; + if (bitOff == 0) { + result = bitmap[byteIdx]; + } else if (count <= 8 - bitOff) { + result = bitmap[byteIdx] << bitOff; // all bits inside first byte + } else { + result = (uint8_t)(((uint16_t)bitmap[byteIdx] << 8 | bitmap[byteIdx + 1]) >> (8 - bitOff)); + } + if (count < 8) result &= static_cast(0xFF << (8 - count)); + return result; +} + +// Write up to 8 foreground bits into a physical framebuffer row. +// bits — MSB-aligned; bit 7 = pixel at phyBitPos, lower (8-count) bits are zero. +// phyBitPos — physical X of the MSB pixel; must be in [0, DISPLAY_WIDTH). +// pixelState true → black (clear bits to 0), false → white (set bits to 1). +static inline void writeRowBits(uint8_t* const row, const int phyBitPos, const uint8_t bits, const bool pixelState) { + const int byteIdx = phyBitPos >> 3; + const int shift = phyBitPos & 7; + if (pixelState) { + row[byteIdx] &= ~(bits >> shift); + if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES) row[byteIdx + 1] &= ~(uint8_t)(bits << (8 - shift)); + } else { + row[byteIdx] |= (bits >> shift); + if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES) row[byteIdx + 1] |= (uint8_t)(bits << (8 - shift)); + } +} + +static void renderGlyphFastBW(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) { + switch (orientation) { + case GfxRenderer::LandscapeCounterClockwise: { + // phyX = screenXBase+glyphX, phyY = screenYBase+glyphY (identity mapping) + // Each glyph row is a contiguous physical h-span — read and write 8 px at a time. + for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { + const int phyY = screenYBase + glyphY; + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int rowBitStart = glyphY * glyphWidth; + for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { + const int count = std::min(8, glyphWidth - glyphX); + const uint8_t gbyte = bitmapExtract(bitmap, rowBitStart + glyphX, count); + if (gbyte == 0) continue; + const int phyBitPos = screenXBase + glyphX; + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, gbyte, pixelState); + } + } + break; + } + + case GfxRenderer::LandscapeClockwise: { + // phyX = W-1-screenXBase-glyphX, phyY = H-1-screenYBase-glyphY (180° flip) + // glyphX=0 is rightmost; iterate glyph row right-to-left in 8-px chunks so each + // chunk writes a contiguous left-to-right physical h-span after bit-reversal. + for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenYBase + glyphY); + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int rowBitStart = glyphY * glyphWidth; + for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) { + const int chunkStart = std::max(0, chunkEnd - 7); + const int count = chunkEnd - chunkStart + 1; + // Read chunk in glyph (left-to-right) order then reverse bits so MSB maps to + // glyphX=chunkEnd, which is the leftmost physical pixel of this chunk. + const uint8_t gbyte_fwd = bitmapExtract(bitmap, rowBitStart + chunkStart, count); + const uint8_t gbyte = reverseBits8(gbyte_fwd >> (8 - count)); + if (gbyte == 0) continue; + const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenXBase - chunkEnd; + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, gbyte, pixelState); + } + } + break; + } + + case GfxRenderer::Portrait: { + // phyX = screenYBase+glyphY, phyY = H-1-screenXBase-glyphX (90° CW) + // A glyph column maps to a physical row. Process in 8-row × 8-col blocks: + // pack 8 glyph rows (sequential reads) into uint64_t → transpose8x8 → + // each output byte is one glyph column's bits, MSB = row 0 = smallest phyX. + for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { + const int rowCount = std::min(8, glyphHeight - glyphY); + const int phyBitPos = screenYBase + glyphY; // leftmost phyX of this row-chunk + if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { + const int colCount = std::min(8, glyphWidth - glyphX); + uint64_t pack = 0; + int bitStart = glyphY * glyphWidth + glyphX; + for (int n = 0; n < rowCount; n++, bitStart += glyphWidth) { + pack |= static_cast(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * n); + } + pack = transpose8x8(pack); + // Byte k of pack = column (glyphX+k) bits, MSB = row 0 = leftmost phyX. + for (int k = 0; k < colCount; k++) { + const uint8_t cols_k = static_cast(pack >> (56 - 8 * k)); + if (cols_k == 0) continue; + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenXBase + glyphX + k); + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + writeRowBits(frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES, phyBitPos, cols_k, pixelState); + } + } + } + break; + } + + case GfxRenderer::PortraitInverted: { + // phyX = W-1-screenYBase-glyphY, phyY = screenXBase+glyphX (90° CCW) + // Like Portrait but glyphY=0 is the rightmost physical pixel. Pack rows in + // reverse order (last row at uint64_t MSB) so the transposed column bytes already + // have MSB = last row = leftmost phyX — no bit-reversal step needed. + for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { + const int rowCount = std::min(8, glyphHeight - glyphY); + // Leftmost phyX = W-1-screenYBase-(glyphY+rowCount-1). + const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenYBase - (glyphY + rowCount - 1); + if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { + const int colCount = std::min(8, glyphWidth - glyphX); + // Pack row (rowCount-1) at MSB down to row 0 at the lowest active byte. + uint64_t pack = 0; + int bitStart = glyphY * glyphWidth + glyphX; + for (int n = 0; n < rowCount; n++, bitStart += glyphWidth) { + pack |= static_cast(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * (rowCount - 1 - n)); + } + pack = transpose8x8(pack); + // Byte k = column (glyphX+k) bits, MSB = last row = leftmost phyX. + for (int k = 0; k < colCount; k++) { + const uint8_t cols_k = static_cast(pack >> (56 - 8 * k)); + if (cols_k == 0) continue; + const int phyY = screenXBase + glyphX + k; + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + writeRowBits(frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES, phyBitPos, cols_k, pixelState); + } + } + } + break; + } + } +} + // Shared glyph rendering logic for normal and rotated text. // Coordinate mapping and cursor advance direction are selected at compile time via the template parameter. template @@ -133,6 +377,16 @@ 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. + if constexpr (rotation == TextRotation::None) { + if (renderMode == GfxRenderer::BW) { + renderGlyphFastBW(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState, + renderer.getOrientation()); + *cursorX += glyph->advanceX; + return; + } + } + // Fallback: rotated text or non-BW render mode — per-pixel drawPixel(). int pixelPosition = 0; for (int glyphY = 0; glyphY < height; glyphY++) { const int outerCoord = outerBase + glyphY; @@ -231,6 +485,52 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha } } +#ifdef ENABLE_RENDERCHAR_BENCHMARK +// Legacy per-pixel rendering path — mirrors the old renderCharImpl 1-bit BW loop. +// Used only by the renderChar benchmark to establish the baseline. +void GfxRenderer::drawTextBWLegacy(const int fontId, const int x, const int y, const char* text) const { + if (text == nullptr || *text == '\0') return; + const auto fontIt = fontMap.find(fontId); + if (fontIt == fontMap.end()) return; + const auto& fontFamily = fontIt->second; + + int yPos = y + getFontAscenderSize(fontId); + int xPos = x; + uint32_t cp; + while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + const EpdGlyph* glyph = fontFamily.getGlyph(cp, EpdFontFamily::REGULAR); + if (!glyph) glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, EpdFontFamily::REGULAR); + if (!glyph) continue; + const EpdFontData* fontData = fontFamily.getData(EpdFontFamily::REGULAR); + if (fontData->is2Bit) { + xPos += glyph->advanceX; + continue; + } + const uint8_t* bitmap = getGlyphBitmap(fontData, glyph); + if (bitmap != nullptr) { + const int screenYBase = yPos - glyph->top; + const int screenXBase = xPos + glyph->left; + int pixelPosition = 0; + for (int glyphY = 0; glyphY < glyph->height; glyphY++) { + for (int glyphX = 0; glyphX < glyph->width; glyphX++, pixelPosition++) { + const uint8_t bit = (bitmap[pixelPosition >> 3] >> (7 - (pixelPosition & 7))) & 1; + if (!bit) continue; + // Inline drawPixel without OOB logging — mirrors the old per-pixel path but clips silently, + // matching the fast path's behaviour so the benchmark measures rendering cost only. + int phyX, phyY; + rotateCoordinates(orientation, screenXBase + glyphX, screenYBase + glyphY, &phyX, &phyY); + if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX / 8); + const uint8_t bitPosition = 7 - (phyX % 8); + frameBuffer[byteIndex] &= ~(1 << bitPosition); // black pixel + } + } + } + xPos += glyph->advanceX; + } +} +#endif // ENABLE_RENDERCHAR_BENCHMARK + void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const { if (x1 == x2) { if (y2 < y1) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e2d05d03..f774d9c7 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -137,4 +137,9 @@ class GfxRenderer { // Low level functions uint8_t* getFrameBuffer() const; static size_t getBufferSize(); + +#ifdef ENABLE_RENDERCHAR_BENCHMARK + // Legacy (per-pixel drawPixel) text rendering — used only by the renderChar benchmark. + void drawTextBWLegacy(int fontId, int x, int y, const char* text) const; +#endif };