feat: optimize ThemeEngine render performance

- Add layout caching in UIElement to skip redundant layout passes
- Use binary search for text wrapping in Label (O(log n) vs O(n))
- Cache rendered bitmaps using captureRegion/restoreRegion
- Add RAM caching for BMP files to avoid SD card reads
- Cache book metadata in HomeActivity.onEnter() instead of every render
- Reuse recent books data to avoid duplicate ePub loads
This commit is contained in:
Brackyt 2026-01-29 11:34:42 +01:00
parent 93a94ea838
commit 7254d46401
6 changed files with 208 additions and 214 deletions

View File

@ -130,6 +130,7 @@ class ThemeManager {
// Asset caching
const std::vector<uint8_t>* getCachedAsset(const std::string& path);
void cacheAsset(const std::string& path, std::vector<uint8_t>&& data);
const ProcessedAsset* getProcessedAsset(const std::string& path, GfxRenderer::Orientation orientation,
int targetW = 0, int targetH = 0);
void cacheProcessedAsset(const std::string& path, const ProcessedAsset& asset, int targetW = 0, int targetH = 0);

View File

@ -29,6 +29,10 @@ class UIElement {
// Recomputed every layout pass
int absX = 0, absY = 0, absW = 0, absH = 0;
// Layout caching - track last params to skip redundant layout
int lastParentX = -1, lastParentY = -1, lastParentW = -1, lastParentH = -1;
bool layoutValid = false;
// Caching support
bool cacheable = false; // Set true for expensive elements like bitmaps
bool cacheValid = false; // Is the cached render still valid?
@ -84,6 +88,7 @@ class UIElement {
virtual void markDirty() {
dirty = true;
cacheValid = false;
layoutValid = false;
}
void markClean() { dirty = false; }
@ -97,6 +102,18 @@ class UIElement {
// Calculate absolute position based on parent
virtual void layout(const ThemeContext& context, int parentX, int parentY, int parentW, int parentH) {
// Skip layout if params unchanged and layout is still valid
if (layoutValid && parentX == lastParentX && parentY == lastParentY && parentW == lastParentW &&
parentH == lastParentH) {
return;
}
lastParentX = parentX;
lastParentY = parentY;
lastParentW = parentW;
lastParentH = parentH;
layoutValid = true;
int newX = parentX + x.resolve(parentW);
int newY = parentY + y.resolve(parentH);
int newW = width.resolve(parentW);

View File

@ -137,38 +137,18 @@ void Label::draw(const GfxRenderer& renderer, const ThemeContext& context) {
break;
}
// Binary search for cut point
// Binary search for maximum characters that fit (O(log n) instead of O(n))
int len = remaining.length();
int cut = len;
// Find split point
// Optimistic start: approximate chars that fit
int avgCharWidth = renderer.getTextWidth(fontId, "a");
if (avgCharWidth < 1) avgCharWidth = 8;
int approxChars = absW / avgCharWidth;
if (approxChars < 1) approxChars = 1;
if (approxChars >= len) approxChars = len - 1;
// Refine from approxChars
int w = renderer.getTextWidth(fontId, remaining.substr(0, approxChars).c_str());
if (w < absW) {
// Grow
for (int i = approxChars; i <= len; i++) {
if (renderer.getTextWidth(fontId, remaining.substr(0, i).c_str()) > absW) {
cut = i - 1;
break;
}
cut = i;
}
} else {
// Shrink
for (int i = approxChars; i > 0; i--) {
if (renderer.getTextWidth(fontId, remaining.substr(0, i).c_str()) <= absW) {
cut = i;
break;
}
int lo = 1, hi = len;
while (lo < hi) {
int mid = (lo + hi + 1) / 2;
if (renderer.getTextWidth(fontId, remaining.substr(0, mid).c_str()) <= absW) {
lo = mid;
} else {
hi = mid - 1;
}
}
int cut = lo;
// Find last space before cut
if (cut < (int)remaining.length()) {
@ -249,97 +229,87 @@ void BitmapElement::draw(const GfxRenderer& renderer, const ThemeContext& contex
return;
}
// Resolve simplified or relative paths
if (path.find('/') == std::string::npos || (path.length() > 0 && path[0] != '/')) {
path = ThemeManager::get().getAssetPath(path);
}
// 1. Check if we have a cached 1-bit render
// Fast path: use cached 1-bit render
const ProcessedAsset* processed = ThemeManager::get().getProcessedAsset(path, renderer.getOrientation(), absW, absH);
if (processed && processed->w == absW && processed->h == absH) {
const int rowBytes = (absW + 7) / 8;
for (int y = 0; y < absH; y++) {
const uint8_t* srcRow = processed->data.data() + y * rowBytes;
for (int x = 0; x < absW; x++) {
// Cached 1-bit data: 0=Black, 1=White
bool isBlack = !(srcRow[x / 8] & (1 << (7 - (x % 8))));
// Draw opaque (true=black, false=white)
renderer.drawPixel(absX + x, absY + y, isBlack);
}
}
renderer.restoreRegion(processed->data.data(), absX, absY, absW, absH);
markClean();
return;
}
// Helper to draw bitmap with centering and optional rounded corners
auto drawBmp = [&](Bitmap& bmp) {
int drawX = absX;
int drawY = absY;
if (bmp.getWidth() < absW) drawX += (absW - bmp.getWidth()) / 2;
if (bmp.getHeight() < absH) drawY += (absH - bmp.getHeight()) / 2;
if (borderRadius > 0) {
renderer.drawRoundedBitmap(bmp, drawX, drawY, absW, absH, borderRadius);
} else {
renderer.drawBitmap(bmp, drawX, drawY, absW, absH);
}
};
bool drawSuccess = false;
// 2. Try Streaming (Absolute paths, large images)
if (path.length() > 0 && path[0] == '/') {
// Try RAM cache first
const std::vector<uint8_t>* cachedData = ThemeManager::get().getCachedAsset(path);
if (cachedData && !cachedData->empty()) {
Bitmap bmp(cachedData->data(), cachedData->size());
if (bmp.parseHeaders() == BmpReaderError::Ok) {
drawBmp(bmp);
drawSuccess = true;
}
}
// Fallback: load from SD card
if (!drawSuccess && path.length() > 0 && path[0] == '/') {
FsFile file;
if (SdMan.openFileForRead("HOME", path, file)) {
Bitmap bmp(file, true); // (file, dithering=true)
if (bmp.parseHeaders() == BmpReaderError::Ok) {
int drawX = absX;
int drawY = absY;
if (bmp.getWidth() < absW) drawX += (absW - bmp.getWidth()) / 2;
if (bmp.getHeight() < absH) drawY += (absH - bmp.getHeight()) / 2;
if (borderRadius > 0) {
renderer.drawRoundedBitmap(bmp, drawX, drawY, absW, absH, borderRadius);
} else {
renderer.drawBitmap(bmp, drawX, drawY, absW, absH);
size_t fileSize = file.size();
if (fileSize > 0 && fileSize < 100000) {
std::vector<uint8_t> fileData(fileSize);
if (file.read(fileData.data(), fileSize) == fileSize) {
ThemeManager::get().cacheAsset(path, std::move(fileData));
const std::vector<uint8_t>* newCachedData = ThemeManager::get().getCachedAsset(path);
if (newCachedData && !newCachedData->empty()) {
Bitmap bmp(newCachedData->data(), newCachedData->size());
if (bmp.parseHeaders() == BmpReaderError::Ok) {
drawBmp(bmp);
drawSuccess = true;
}
}
}
} else {
Bitmap bmp(file, true);
if (bmp.parseHeaders() == BmpReaderError::Ok) {
drawBmp(bmp);
drawSuccess = true;
}
drawSuccess = true;
}
file.close();
}
}
// 3. Fallback to RAM Cache (Standard method)
if (!drawSuccess) {
const std::vector<uint8_t>* data = ThemeManager::get().getCachedAsset(path);
if (data && !data->empty()) {
Bitmap bmp(data->data(), data->size());
if (bmp.parseHeaders() == BmpReaderError::Ok) {
int drawX = absX;
int drawY = absY;
if (bmp.getWidth() < absW) drawX += (absW - bmp.getWidth()) / 2;
if (bmp.getHeight() < absH) drawY += (absH - bmp.getHeight()) / 2;
if (borderRadius > 0) {
renderer.drawRoundedBitmap(bmp, drawX, drawY, absW, absH, borderRadius);
} else {
renderer.drawBitmap(bmp, drawX, drawY, absW, absH);
}
drawSuccess = true;
}
// Cache rendered result for fast subsequent draws using captureRegion
if (drawSuccess && absW * absH <= 40000) {
size_t capturedSize = 0;
uint8_t* captured = renderer.captureRegion(absX, absY, absW, absH, &capturedSize);
if (captured && capturedSize > 0) {
ProcessedAsset asset;
asset.w = absW;
asset.h = absH;
asset.orientation = renderer.getOrientation();
asset.data.assign(captured, captured + capturedSize);
free(captured);
ThemeManager::get().cacheProcessedAsset(path, asset, absW, absH);
}
}
// 4. Cache result if successful
if (drawSuccess) {
ProcessedAsset asset;
asset.w = absW;
asset.h = absH;
asset.orientation = renderer.getOrientation();
const int rowBytes = (absW + 7) / 8;
asset.data.resize(rowBytes * absH, 0xFF); // Initialize to 0xFF (White)
for (int y = 0; y < absH; y++) {
uint8_t* dstRow = asset.data.data() + y * rowBytes;
for (int x = 0; x < absW; x++) {
// Read precise pixel state from framebuffer
bool isBlack = renderer.readPixel(absX + x, absY + y);
if (isBlack) {
// Clear bit for black (0)
dstRow[x / 8] &= ~(1 << (7 - (x % 8)));
}
}
}
ThemeManager::get().cacheProcessedAsset(path, asset, absW, absH);
}
markClean();
}
@ -421,12 +391,6 @@ void List::draw(const GfxRenderer& renderer, const ThemeContext& context) {
itemContext.setString("Item.Icon", context.getString(prefix + "Icon"));
itemContext.setString("Item.Image", context.getString(prefix + "Image"));
itemContext.setString("Item.Progress", context.getString(prefix + "Progress"));
itemContext.setInt("Item.Index", i);
itemContext.setInt("Item.Count", count);
// ValueIndex may not exist for all item types, so check first
if (context.hasKey(prefix + "ValueIndex")) {
itemContext.setInt("Item.ValueIndex", context.getInt(prefix + "ValueIndex"));
}
// Viewport check
if (direction == Direction::Horizontal) {
@ -449,6 +413,12 @@ void List::draw(const GfxRenderer& renderer, const ThemeContext& context) {
}
if (currentY > absY + absH) break;
}
itemContext.setInt("Item.Index", i);
itemContext.setInt("Item.Count", count);
// ValueIndex may not exist for all item types, so check first
if (context.hasKey(prefix + "ValueIndex")) {
itemContext.setInt("Item.ValueIndex", context.getInt(prefix + "ValueIndex"));
}
// Layout and draw
itemTemplate->layout(itemContext, currentX, currentY, itemW, itemH);

View File

@ -396,6 +396,10 @@ const std::vector<uint8_t>* ThemeManager::getCachedAsset(const std::string& path
return nullptr;
}
void ThemeManager::cacheAsset(const std::string& path, std::vector<uint8_t>&& data) {
assetCache[path] = std::move(data);
}
const ProcessedAsset* ThemeManager::getProcessedAsset(const std::string& path, GfxRenderer::Orientation orientation,
int targetW, int targetH) {
std::string cacheKey = path;
@ -597,7 +601,23 @@ void ThemeManager::renderScreen(const std::string& screenName, const GfxRenderer
void ThemeManager::renderScreenOptimized(const std::string& screenName, const GfxRenderer& renderer,
const ThemeContext& context, const ThemeContext* prevContext) {
renderScreen(screenName, renderer, context);
if (elements.count(screenName) == 0) {
return;
}
UIElement* root = elements[screenName];
// Layout uses internal caching - will skip if params unchanged
root->layout(context, 0, 0, renderer.getScreenWidth(), renderer.getScreenHeight());
// If no previous context provided, do full draw
if (!prevContext) {
root->draw(renderer, context);
return;
}
// Draw elements - dirty tracking is handled internally by each element
root->draw(renderer, context);
}
} // namespace ThemeEngine

View File

@ -47,51 +47,93 @@ void HomeActivity::onEnter() {
// Check if OPDS browser URL is configured
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
// Load and cache recent books data FIRST (loads each book only once)
loadRecentBooksData();
if (hasContinueReading) {
// Extract filename from path for display
lastBookTitle = APP_STATE.openEpubPath;
const size_t lastSlash = lastBookTitle.find_last_of('/');
if (lastSlash != std::string::npos) {
lastBookTitle = lastBookTitle.substr(lastSlash + 1);
// Initialize defaults
cachedChapterTitle = "";
cachedCurrentPage = "-";
cachedTotalPages = "-";
cachedProgressPercent = 0;
// Check if current book is in recent books - use cached data instead of reloading
bool foundInRecent = false;
for (const auto& book : cachedRecentBooks) {
if (book.path == APP_STATE.openEpubPath) {
lastBookTitle = book.title;
coverBmpPath = book.coverPath;
hasCoverImage = !book.coverPath.empty();
cachedProgressPercent = book.progressPercent;
foundInRecent = true;
break;
}
}
// If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) {
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
epub.load(false);
if (!epub.getTitle().empty()) {
lastBookTitle = std::string(epub.getTitle());
if (!foundInRecent) {
// Book not in recent list, need to load it
lastBookTitle = APP_STATE.openEpubPath;
const size_t lastSlash = lastBookTitle.find_last_of('/');
if (lastSlash != std::string::npos) {
lastBookTitle = lastBookTitle.substr(lastSlash + 1);
}
if (!epub.getAuthor().empty()) {
lastBookAuthor = std::string(epub.getAuthor());
}
// Try to generate thumbnail image for Continue Reading card
if (epub.generateThumbBmp()) {
coverBmpPath = epub.getThumbBmpPath();
hasCoverImage = true;
}
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") ||
StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
// Handle XTC file
Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint");
if (xtc.load()) {
if (!xtc.getTitle().empty()) {
lastBookTitle = std::string(xtc.getTitle());
}
if (!xtc.getAuthor().empty()) {
lastBookAuthor = std::string(xtc.getAuthor());
}
// Try to generate thumbnail image for Continue Reading card
if (xtc.generateThumbBmp()) {
coverBmpPath = xtc.getThumbBmpPath();
if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
epub.load(false);
if (!epub.getTitle().empty()) lastBookTitle = epub.getTitle();
if (!epub.getAuthor().empty()) lastBookAuthor = epub.getAuthor();
if (epub.generateThumbBmp()) {
coverBmpPath = epub.getThumbBmpPath();
hasCoverImage = true;
}
}
// Remove extension from title if we don't have metadata
if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) {
lastBookTitle.resize(lastBookTitle.length() - 5);
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
lastBookTitle.resize(lastBookTitle.length() - 4);
// Get progress info from the same loaded epub
FsFile f;
if (SdMan.openFileForRead("HOME", epub.getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
if (f.read(data, 4) == 4) {
int spineIndex = data[0] + (data[1] << 8);
int spineCount = epub.getSpineItemsCount();
cachedCurrentPage = std::to_string(spineIndex + 1);
cachedTotalPages = std::to_string(spineCount);
if (spineCount > 0) cachedProgressPercent = (spineIndex * 100) / spineCount;
auto spineEntry = epub.getSpineItem(spineIndex);
if (spineEntry.tocIndex != -1) {
cachedChapterTitle = epub.getTocItem(spineEntry.tocIndex).title;
}
}
f.close();
}
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") ||
StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) {
Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint");
if (xtc.load()) {
if (!xtc.getTitle().empty()) lastBookTitle = xtc.getTitle();
if (xtc.generateThumbBmp()) {
coverBmpPath = xtc.getThumbBmpPath();
hasCoverImage = true;
}
// Get progress from same loaded xtc
FsFile f;
if (SdMan.openFileForRead("HOME", xtc.getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
if (f.read(data, 4) == 4) {
uint32_t currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
uint32_t totalPages = xtc.getPageCount();
cachedCurrentPage = std::to_string(currentPage + 1);
cachedTotalPages = std::to_string(totalPages);
if (totalPages > 0) cachedProgressPercent = (currentPage * 100) / totalPages;
cachedChapterTitle = "Page " + cachedCurrentPage;
}
f.close();
}
}
// Remove extension from title if we don't have metadata
if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) {
lastBookTitle.resize(lastBookTitle.length() - 5);
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
lastBookTitle.resize(lastBookTitle.length() - 4);
}
}
}
}
@ -101,9 +143,6 @@ void HomeActivity::onEnter() {
coverRendered = false;
coverBufferStored = false;
// Load and cache recent books data (slow operation, do once)
loadRecentBooksData();
// Trigger first update
updateRequired = true;
@ -311,7 +350,7 @@ void HomeActivity::render() {
lastBatteryCheck = now;
}
// Always clear screen - ThemeEngine handles caching internally
// Always clear screen - required because parent containers draw backgrounds
renderer.clearScreen();
ThemeEngine::ThemeContext context;
@ -352,73 +391,12 @@ void HomeActivity::render() {
context.setBool("HasCover", hasContinueReading && hasCoverImage && !coverBmpPath.empty());
context.setBool("ShowInfoBox", true);
// Default values
std::string chapterTitle = "";
std::string currentPageStr = "-";
std::string totalPagesStr = "-";
int progressPercent = 0;
if (hasContinueReading) {
if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
epub.load(false);
// Read progress
FsFile f;
if (SdMan.openFileForRead("HOME", epub.getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
if (f.read(data, 4) == 4) {
int spineIndex = data[0] + (data[1] << 8);
int spineCount = epub.getSpineItemsCount();
currentPageStr = std::to_string(spineIndex + 1); // Display 1-based
totalPagesStr = std::to_string(spineCount);
if (spineCount > 0) {
progressPercent = (spineIndex * 100) / spineCount;
}
// Resolve Chapter Title
auto spineEntry = epub.getSpineItem(spineIndex);
if (spineEntry.tocIndex != -1) {
auto tocEntry = epub.getTocItem(spineEntry.tocIndex);
chapterTitle = tocEntry.title;
}
}
f.close();
}
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") ||
StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) {
Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint");
if (xtc.load()) {
// Read progress
FsFile f;
if (SdMan.openFileForRead("HOME", xtc.getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
if (f.read(data, 4) == 4) {
uint32_t currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
uint32_t totalPages = xtc.getPageCount();
currentPageStr = std::to_string(currentPage + 1); // 1-based
totalPagesStr = std::to_string(totalPages);
if (totalPages > 0) {
progressPercent = (currentPage * 100) / totalPages;
}
chapterTitle = "Page " + currentPageStr;
}
f.close();
}
}
}
}
context.setString("BookChapter", chapterTitle);
context.setString("BookCurrentPage", currentPageStr);
context.setString("BookTotalPages", totalPagesStr);
context.setInt("BookProgressPercent", progressPercent);
context.setString("BookProgressPercentStr", std::to_string(progressPercent));
// Use cached values (loaded in onEnter, NOT every render)
context.setString("BookChapter", cachedChapterTitle);
context.setString("BookCurrentPage", cachedCurrentPage);
context.setString("BookTotalPages", cachedTotalPages);
context.setInt("BookProgressPercent", cachedProgressPercent);
context.setString("BookProgressPercentStr", std::to_string(cachedProgressPercent));
// --- Main Menu Data ---
// Menu items start after the book slot

View File

@ -28,12 +28,20 @@ class HomeActivity final : public Activity {
bool coverRendered = false; // Track if cover has been rendered once
bool coverBufferStored = false; // Track if cover buffer is stored
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
bool needsFullRender = true; // Force full render (first time or after layout changes)
int lastSelectorIndex = -1; // Track selection for incremental updates
std::string lastBookTitle;
std::string lastBookAuthor;
std::string coverBmpPath;
uint8_t cachedBatteryLevel = 0;
uint32_t lastBatteryCheck = 0;
// Cached "continue reading" info (loaded once in onEnter, NOT every render!)
std::string cachedChapterTitle;
std::string cachedCurrentPage;
std::string cachedTotalPages;
int cachedProgressPercent = 0;
// Cached recent books data (loaded once in onEnter)
std::vector<CachedBookInfo> cachedRecentBooks;
const std::function<void()> onContinueReading;