perf: Eliminate per-pixel overheads in image rendering (#1293)

## Summary

Replace per-pixel getRenderMode() + rotateCoordinates() + bounds checks
with a DirectPixelWriter struct that pre-computes orientation and render
mode state once per row. Use bitwise ops instead of division/modulo for
cache pixel packing. Skip PNG cache allocation when buffer exceeds 48KB
(framebuffer size) since PNG decode is fast enough that caching provides
minimal benefit, and the large buffer competes with the 44KB PNG decoder
for heap.

## Additional Context
Measured improvements on ESP32-C3 @ 160MHz:
- JPEG decode: 5-7% faster (1:1 scale)
- PNG decode: 15-20% faster (1:1 scale)
- Cache renders: 3-6% faster across both formats
- Eliminates "Failed to allocate cache buffer" errors for large PNGs

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**<  PARTIALLY >**_
This commit is contained in:
martin brook
2026-03-30 11:03:49 -05:00
committed by GitHub
parent d12f0666d4
commit aa085425af
5 changed files with 215 additions and 32 deletions
@@ -9,6 +9,7 @@
#include <cstdlib>
#include <new>
#include "DirectPixelWriter.h"
#include "DitherUtils.h"
#include "PixelCache.h"
@@ -207,6 +208,17 @@ int pngDrawCallback(PNGDRAW* pDraw) {
bool useDithering = ctx->config->useDithering;
bool caching = ctx->caching;
// Pre-compute orientation and render-mode state once per row
DirectPixelWriter pw;
pw.init(*ctx->renderer);
pw.beginRow(outY);
DirectCacheWriter cw;
if (caching) {
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.originX);
cw.beginRow(outY, ctx->config->y);
}
int srcX = 0;
int error = 0;
@@ -222,8 +234,8 @@ int pngDrawCallback(PNGDRAW* pDraw) {
ditheredGray = gray / 85;
if (ditheredGray > 3) ditheredGray = 3;
}
drawPixelWithRenderMode(*ctx->renderer, outX, outY, ditheredGray);
if (caching) ctx->cache.setPixel(outX, outY, ditheredGray);
pw.writePixel(outX, ditheredGray);
if (caching) cw.writePixel(outX, ditheredGray);
}
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
@@ -356,10 +368,18 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
return false;
}
// Allocate cache buffer using SCALED dimensions
// Allocate cache buffer using SCALED dimensions.
// PNG decode is fast enough (~135ms for 400x600) that caching provides minimal benefit
// for larger images, while the cache buffer competes with the 44KB PNG decoder for heap.
// Skip caching when the buffer would exceed the framebuffer size (48KB).
static constexpr size_t PNG_MAX_CACHE_BYTES = 48000;
ctx.caching = !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) {
size_t cacheSize = (size_t)((ctx.dstWidth + 3) / 4) * ctx.dstHeight;
if (cacheSize > PNG_MAX_CACHE_BYTES) {
LOG_DBG("PNG", "Skipping cache: %zu bytes exceeds PNG limit (%zu)", cacheSize, PNG_MAX_CACHE_BYTES);
ctx.caching = false;
} else if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) {
LOG_ERR("PNG", "Failed to allocate cache buffer, continuing without caching");
ctx.caching = false;
}