Add dithering algorithms
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
|
#include "../../../../src/CrossPointSettings.h"
|
||||||
#include "../converters/DirectPixelWriter.h"
|
#include "../converters/DirectPixelWriter.h"
|
||||||
#include "../converters/ImageDecoderFactory.h"
|
#include "../converters/ImageDecoderFactory.h"
|
||||||
|
|
||||||
@@ -19,13 +20,13 @@ bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str());
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
std::string getCachePath(const std::string& imagePath) {
|
std::string getCachePath(const std::string& imagePath, ImageDitherMode ditherMode) {
|
||||||
// Replace extension with .pxc (pixel cache)
|
// Replace extension with .pxc (pixel cache)
|
||||||
size_t dotPos = imagePath.rfind('.');
|
size_t dotPos = imagePath.rfind('.');
|
||||||
if (dotPos != std::string::npos) {
|
if (dotPos != std::string::npos) {
|
||||||
return imagePath.substr(0, dotPos) + ".pxc";
|
return imagePath.substr(0, dotPos) + getImageDitherCacheSuffix(ditherMode) + ".pxc";
|
||||||
}
|
}
|
||||||
return imagePath + ".pxc";
|
return imagePath + getImageDitherCacheSuffix(ditherMode) + ".pxc";
|
||||||
}
|
}
|
||||||
|
|
||||||
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
||||||
@@ -110,7 +111,8 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to render from cache first
|
// Try to render from cache first
|
||||||
std::string cachePath = getCachePath(imagePath);
|
const ImageDitherMode ditherMode = imageDitherModeFromSetting(SETTINGS.imageDithering);
|
||||||
|
std::string cachePath = getCachePath(imagePath, ditherMode);
|
||||||
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
|
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
|
||||||
return; // Successfully rendered from cache
|
return; // Successfully rendered from cache
|
||||||
}
|
}
|
||||||
@@ -139,6 +141,7 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
|||||||
config.maxHeight = height;
|
config.maxHeight = height;
|
||||||
config.useGrayscale = true;
|
config.useGrayscale = true;
|
||||||
config.useDithering = true;
|
config.useDithering = true;
|
||||||
|
config.ditherMode = ditherMode;
|
||||||
config.performanceMode = false;
|
config.performanceMode = false;
|
||||||
config.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches
|
config.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches
|
||||||
config.cachePath = cachePath; // Enable caching during decode
|
config.cachePath = cachePath; // Enable caching during decode
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
// 4x4 Bayer matrix for ordered dithering
|
// 4x4 Bayer matrix for ordered dithering
|
||||||
inline const uint8_t bayer4x4[4][4] = {
|
inline const uint8_t bayer4x4[4][4] = {
|
||||||
{0, 8, 2, 10},
|
{0, 8, 2, 10},
|
||||||
@@ -10,6 +12,13 @@ inline const uint8_t bayer4x4[4][4] = {
|
|||||||
{15, 7, 13, 5},
|
{15, 7, 13, 5},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
inline uint8_t quantizeGray4Level(uint8_t gray) {
|
||||||
|
if (gray < 64) return 0;
|
||||||
|
if (gray < 128) return 1;
|
||||||
|
if (gray < 192) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply Bayer dithering and quantize to 4 levels (0-3)
|
// Apply Bayer dithering and quantize to 4 levels (0-3)
|
||||||
// Stateless - works correctly with any pixel processing order
|
// Stateless - works correctly with any pixel processing order
|
||||||
inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
|
inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
|
||||||
@@ -20,8 +29,59 @@ inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
|
|||||||
if (adjusted < 0) adjusted = 0;
|
if (adjusted < 0) adjusted = 0;
|
||||||
if (adjusted > 255) adjusted = 255;
|
if (adjusted > 255) adjusted = 255;
|
||||||
|
|
||||||
if (adjusted < 64) return 0;
|
return quantizeGray4Level((uint8_t)adjusted);
|
||||||
if (adjusted < 128) return 1;
|
|
||||||
if (adjusted < 192) return 2;
|
|
||||||
return 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DiffusedBayerDitherer {
|
||||||
|
public:
|
||||||
|
explicit DiffusedBayerDitherer(int width) : width(width) {
|
||||||
|
errorCurRow = new int16_t[width + 2]();
|
||||||
|
errorNextRow = new int16_t[width + 2]();
|
||||||
|
}
|
||||||
|
|
||||||
|
~DiffusedBayerDitherer() {
|
||||||
|
delete[] errorCurRow;
|
||||||
|
delete[] errorNextRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
DiffusedBayerDitherer(const DiffusedBayerDitherer&) = delete;
|
||||||
|
DiffusedBayerDitherer& operator=(const DiffusedBayerDitherer&) = delete;
|
||||||
|
|
||||||
|
uint8_t processPixel(int gray, int x, int screenX, int screenY) {
|
||||||
|
int adjusted = gray + errorCurRow[x + 1];
|
||||||
|
if (adjusted < 0) adjusted = 0;
|
||||||
|
if (adjusted > 255) adjusted = 255;
|
||||||
|
|
||||||
|
int thresholdAdjusted = adjusted + (bayer4x4[screenY & 3][screenX & 3] - 8) * 5;
|
||||||
|
if (thresholdAdjusted < 0) thresholdAdjusted = 0;
|
||||||
|
if (thresholdAdjusted > 255) thresholdAdjusted = 255;
|
||||||
|
|
||||||
|
uint8_t quantized = quantizeGray4Level((uint8_t)thresholdAdjusted);
|
||||||
|
int quantizedValue = quantized * 85;
|
||||||
|
int error = adjusted - quantizedValue;
|
||||||
|
|
||||||
|
errorCurRow[x + 2] += (error * 7) / 16;
|
||||||
|
errorNextRow[x] += (error * 3) / 16;
|
||||||
|
errorNextRow[x + 1] += (error * 5) / 16;
|
||||||
|
errorNextRow[x + 2] += error / 16;
|
||||||
|
|
||||||
|
return quantized;
|
||||||
|
}
|
||||||
|
|
||||||
|
void nextRow() {
|
||||||
|
int16_t* tmp = errorCurRow;
|
||||||
|
errorCurRow = errorNextRow;
|
||||||
|
errorNextRow = tmp;
|
||||||
|
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
|
||||||
|
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
int width;
|
||||||
|
int16_t* errorCurRow;
|
||||||
|
int16_t* errorNextRow;
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,11 +11,44 @@ struct ImageDimensions {
|
|||||||
int16_t height;
|
int16_t height;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum class ImageDitherMode : uint8_t {
|
||||||
|
Bayer = 0,
|
||||||
|
Atkinson = 1,
|
||||||
|
DiffusedBayer = 2,
|
||||||
|
COUNT,
|
||||||
|
};
|
||||||
|
|
||||||
|
inline ImageDitherMode imageDitherModeFromSetting(uint8_t value) {
|
||||||
|
switch (static_cast<ImageDitherMode>(value)) {
|
||||||
|
case ImageDitherMode::Bayer:
|
||||||
|
case ImageDitherMode::Atkinson:
|
||||||
|
case ImageDitherMode::DiffusedBayer:
|
||||||
|
return static_cast<ImageDitherMode>(value);
|
||||||
|
case ImageDitherMode::COUNT:
|
||||||
|
default:
|
||||||
|
return ImageDitherMode::Bayer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline const char* getImageDitherCacheSuffix(ImageDitherMode mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case ImageDitherMode::Atkinson:
|
||||||
|
return ".atkinson";
|
||||||
|
case ImageDitherMode::DiffusedBayer:
|
||||||
|
return ".diffused-bayer";
|
||||||
|
case ImageDitherMode::Bayer:
|
||||||
|
case ImageDitherMode::COUNT:
|
||||||
|
default:
|
||||||
|
return ".bayer";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct RenderConfig {
|
struct RenderConfig {
|
||||||
int x, y;
|
int x, y;
|
||||||
int maxWidth, maxHeight;
|
int maxWidth, maxHeight;
|
||||||
bool useGrayscale = true;
|
bool useGrayscale = true;
|
||||||
bool useDithering = true;
|
bool useDithering = true;
|
||||||
|
ImageDitherMode ditherMode = ImageDitherMode::Bayer;
|
||||||
bool performanceMode = false;
|
bool performanceMode = false;
|
||||||
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
|
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
|
||||||
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
|
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "JpegToFramebufferConverter.h"
|
#include "JpegToFramebufferConverter.h"
|
||||||
|
|
||||||
|
#include <BitmapHelpers.h>
|
||||||
#include <FsHelpers.h>
|
#include <FsHelpers.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
@@ -39,6 +40,10 @@ struct JpegContext {
|
|||||||
PixelCache cache;
|
PixelCache cache;
|
||||||
bool caching;
|
bool caching;
|
||||||
|
|
||||||
|
int currentDitherRow;
|
||||||
|
AtkinsonDitherer* atkinsonDitherer;
|
||||||
|
DiffusedBayerDitherer* diffusedBayerDitherer;
|
||||||
|
|
||||||
JpegContext()
|
JpegContext()
|
||||||
: renderer(nullptr),
|
: renderer(nullptr),
|
||||||
config(nullptr),
|
config(nullptr),
|
||||||
@@ -50,9 +55,59 @@ struct JpegContext {
|
|||||||
dstHeight(0),
|
dstHeight(0),
|
||||||
fineScaleFP(1 << 16),
|
fineScaleFP(1 << 16),
|
||||||
invScaleFP(1 << 16),
|
invScaleFP(1 << 16),
|
||||||
caching(false) {}
|
caching(false),
|
||||||
|
currentDitherRow(-1),
|
||||||
|
atkinsonDitherer(nullptr),
|
||||||
|
diffusedBayerDitherer(nullptr) {}
|
||||||
|
|
||||||
|
~JpegContext() {
|
||||||
|
delete atkinsonDitherer;
|
||||||
|
delete diffusedBayerDitherer;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
void prepareDitherRow(JpegContext& ctx, int dstY) {
|
||||||
|
if (!ctx.config || !ctx.config->useDithering) return;
|
||||||
|
|
||||||
|
if (ctx.currentDitherRow == -1 || dstY < ctx.currentDitherRow) {
|
||||||
|
if (ctx.atkinsonDitherer) ctx.atkinsonDitherer->reset();
|
||||||
|
if (ctx.diffusedBayerDitherer) ctx.diffusedBayerDitherer->reset();
|
||||||
|
ctx.currentDitherRow = dstY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (ctx.currentDitherRow < dstY) {
|
||||||
|
if (ctx.atkinsonDitherer) ctx.atkinsonDitherer->nextRow();
|
||||||
|
if (ctx.diffusedBayerDitherer) ctx.diffusedBayerDitherer->nextRow();
|
||||||
|
ctx.currentDitherRow++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int outY) {
|
||||||
|
if (!ctx.config || !ctx.config->useDithering) {
|
||||||
|
return quantizeGray4Level(gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ctx.config->ditherMode) {
|
||||||
|
case ImageDitherMode::Atkinson:
|
||||||
|
if (ctx.atkinsonDitherer) {
|
||||||
|
return ctx.atkinsonDitherer->processPixel(gray, localX);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ImageDitherMode::DiffusedBayer:
|
||||||
|
if (ctx.diffusedBayerDitherer) {
|
||||||
|
return ctx.diffusedBayerDitherer->processPixel(gray, localX, outX, outY);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ImageDitherMode::Bayer:
|
||||||
|
case ImageDitherMode::COUNT:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyBayerDither4Level(gray, outX, outY);
|
||||||
|
}
|
||||||
|
|
||||||
// File I/O callbacks use pFile->fHandle to access the FsFile*,
|
// File I/O callbacks use pFile->fHandle to access the FsFile*,
|
||||||
// avoiding the need for global file state.
|
// avoiding the need for global file state.
|
||||||
void* jpegOpen(const char* filename, int32_t* size) {
|
void* jpegOpen(const char* filename, int32_t* size) {
|
||||||
@@ -136,7 +191,6 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
|||||||
|
|
||||||
if (stride <= 0 || blockH <= 0 || validW <= 0) return 1;
|
if (stride <= 0 || blockH <= 0 || validW <= 0) return 1;
|
||||||
|
|
||||||
const bool useDithering = ctx->config->useDithering;
|
|
||||||
const bool caching = ctx->caching;
|
const bool caching = ctx->caching;
|
||||||
const int32_t fineScaleFP = ctx->fineScaleFP;
|
const int32_t fineScaleFP = ctx->fineScaleFP;
|
||||||
const int32_t invScaleFP = ctx->invScaleFP;
|
const int32_t invScaleFP = ctx->invScaleFP;
|
||||||
@@ -181,19 +235,14 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
|||||||
if (fineScaleFP == FP_ONE) {
|
if (fineScaleFP == FP_ONE) {
|
||||||
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
||||||
const int outY = cfgY + dstY;
|
const int outY = cfgY + dstY;
|
||||||
|
prepareDitherRow(*ctx, dstY);
|
||||||
pw.beginRow(outY);
|
pw.beginRow(outY);
|
||||||
if (caching) cw.beginRow(outY, ctx->config->y);
|
if (caching) cw.beginRow(outY, ctx->config->y);
|
||||||
const uint8_t* row = &pixels[(dstY - blockY) * stride];
|
const uint8_t* row = &pixels[(dstY - blockY) * stride];
|
||||||
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
|
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
|
||||||
const int outX = cfgX + dstX;
|
const int outX = cfgX + dstX;
|
||||||
uint8_t gray = row[dstX - blockX];
|
uint8_t gray = row[dstX - blockX];
|
||||||
uint8_t dithered;
|
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
|
||||||
if (useDithering) {
|
|
||||||
dithered = applyBayerDither4Level(gray, outX, outY);
|
|
||||||
} else {
|
|
||||||
dithered = gray / 85;
|
|
||||||
if (dithered > 3) dithered = 3;
|
|
||||||
}
|
|
||||||
pw.writePixel(outX, dithered);
|
pw.writePixel(outX, dithered);
|
||||||
if (caching) cw.writePixel(outX, dithered);
|
if (caching) cw.writePixel(outX, dithered);
|
||||||
}
|
}
|
||||||
@@ -215,6 +264,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
|||||||
|
|
||||||
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
||||||
const int outY = cfgY + dstY;
|
const int outY = cfgY + dstY;
|
||||||
|
prepareDitherRow(*ctx, dstY);
|
||||||
pw.beginRow(outY);
|
pw.beginRow(outY);
|
||||||
if (caching) cw.beginRow(outY, ctx->config->y);
|
if (caching) cw.beginRow(outY, ctx->config->y);
|
||||||
const int32_t srcFyFP = dstY * invScaleFP;
|
const int32_t srcFyFP = dstY * invScaleFP;
|
||||||
@@ -246,13 +296,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
|||||||
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
|
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
|
||||||
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
||||||
|
|
||||||
uint8_t dithered;
|
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
|
||||||
if (useDithering) {
|
|
||||||
dithered = applyBayerDither4Level(gray, outX, outY);
|
|
||||||
} else {
|
|
||||||
dithered = gray / 85;
|
|
||||||
if (dithered > 3) dithered = 3;
|
|
||||||
}
|
|
||||||
pw.writePixel(outX, dithered);
|
pw.writePixel(outX, dithered);
|
||||||
if (caching) cw.writePixel(outX, dithered);
|
if (caching) cw.writePixel(outX, dithered);
|
||||||
}
|
}
|
||||||
@@ -269,13 +313,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
|||||||
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx0 + 1] * fx) >> FP_SHIFT;
|
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx0 + 1] * fx) >> FP_SHIFT;
|
||||||
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
||||||
|
|
||||||
uint8_t dithered;
|
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
|
||||||
if (useDithering) {
|
|
||||||
dithered = applyBayerDither4Level(gray, outX, outY);
|
|
||||||
} else {
|
|
||||||
dithered = gray / 85;
|
|
||||||
if (dithered > 3) dithered = 3;
|
|
||||||
}
|
|
||||||
pw.writePixel(outX, dithered);
|
pw.writePixel(outX, dithered);
|
||||||
if (caching) cw.writePixel(outX, dithered);
|
if (caching) cw.writePixel(outX, dithered);
|
||||||
}
|
}
|
||||||
@@ -295,13 +333,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
|||||||
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
|
int bot = ((int)row1[lx0] * fxInv + (int)row1[lx1] * fx) >> FP_SHIFT;
|
||||||
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
uint8_t gray = (uint8_t)((top * fyInv + bot * fy) >> FP_SHIFT);
|
||||||
|
|
||||||
uint8_t dithered;
|
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
|
||||||
if (useDithering) {
|
|
||||||
dithered = applyBayerDither4Level(gray, outX, outY);
|
|
||||||
} else {
|
|
||||||
dithered = gray / 85;
|
|
||||||
if (dithered > 3) dithered = 3;
|
|
||||||
}
|
|
||||||
pw.writePixel(outX, dithered);
|
pw.writePixel(outX, dithered);
|
||||||
if (caching) cw.writePixel(outX, dithered);
|
if (caching) cw.writePixel(outX, dithered);
|
||||||
}
|
}
|
||||||
@@ -312,6 +344,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
|||||||
// === Nearest-neighbor (downscale: fineScale < 1.0) ===
|
// === Nearest-neighbor (downscale: fineScale < 1.0) ===
|
||||||
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
|
||||||
const int outY = cfgY + dstY;
|
const int outY = cfgY + dstY;
|
||||||
|
prepareDitherRow(*ctx, dstY);
|
||||||
pw.beginRow(outY);
|
pw.beginRow(outY);
|
||||||
if (caching) cw.beginRow(outY, ctx->config->y);
|
if (caching) cw.beginRow(outY, ctx->config->y);
|
||||||
const int32_t srcFyFP = dstY * invScaleFP;
|
const int32_t srcFyFP = dstY * invScaleFP;
|
||||||
@@ -328,13 +361,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
|
|||||||
if (lx >= validW) lx = validW - 1;
|
if (lx >= validW) lx = validW - 1;
|
||||||
uint8_t gray = row[lx];
|
uint8_t gray = row[lx];
|
||||||
|
|
||||||
uint8_t dithered;
|
uint8_t dithered = ditherGray(*ctx, gray, dstX, outX, outY);
|
||||||
if (useDithering) {
|
|
||||||
dithered = applyBayerDither4Level(gray, outX, outY);
|
|
||||||
} else {
|
|
||||||
dithered = gray / 85;
|
|
||||||
if (dithered > 3) dithered = 3;
|
|
||||||
}
|
|
||||||
pw.writePixel(outX, dithered);
|
pw.writePixel(outX, dithered);
|
||||||
if (caching) cw.writePixel(outX, dithered);
|
if (caching) cw.writePixel(outX, dithered);
|
||||||
}
|
}
|
||||||
@@ -479,6 +506,27 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.useDithering) {
|
||||||
|
switch (config.ditherMode) {
|
||||||
|
case ImageDitherMode::Atkinson:
|
||||||
|
ctx.atkinsonDitherer = new (std::nothrow) AtkinsonDitherer(destWidth);
|
||||||
|
if (!ctx.atkinsonDitherer) {
|
||||||
|
LOG_ERR("JPG", "Failed to allocate Atkinson ditherer, falling back to Bayer");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ImageDitherMode::DiffusedBayer:
|
||||||
|
ctx.diffusedBayerDitherer = new (std::nothrow) DiffusedBayerDitherer(destWidth);
|
||||||
|
if (!ctx.diffusedBayerDitherer) {
|
||||||
|
LOG_ERR("JPG", "Failed to allocate diffused Bayer ditherer, falling back to Bayer");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ImageDitherMode::Bayer:
|
||||||
|
case ImageDitherMode::COUNT:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
unsigned long decodeStart = millis();
|
unsigned long decodeStart = millis();
|
||||||
rc = jpeg->decode(0, 0, jpegScaleOption);
|
rc = jpeg->decode(0, 0, jpegScaleOption);
|
||||||
unsigned long decodeTime = millis() - decodeStart;
|
unsigned long decodeTime = millis() - decodeStart;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "PngToFramebufferConverter.h"
|
#include "PngToFramebufferConverter.h"
|
||||||
|
|
||||||
|
#include <BitmapHelpers.h>
|
||||||
#include <FsHelpers.h>
|
#include <FsHelpers.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
@@ -36,6 +37,9 @@ struct PngContext {
|
|||||||
bool caching;
|
bool caching;
|
||||||
|
|
||||||
uint8_t* grayLineBuffer;
|
uint8_t* grayLineBuffer;
|
||||||
|
int currentDitherRow;
|
||||||
|
AtkinsonDitherer* atkinsonDitherer;
|
||||||
|
DiffusedBayerDitherer* diffusedBayerDitherer;
|
||||||
|
|
||||||
PngContext()
|
PngContext()
|
||||||
: renderer(nullptr),
|
: renderer(nullptr),
|
||||||
@@ -49,9 +53,59 @@ struct PngContext {
|
|||||||
dstHeight(0),
|
dstHeight(0),
|
||||||
lastDstY(-1),
|
lastDstY(-1),
|
||||||
caching(false),
|
caching(false),
|
||||||
grayLineBuffer(nullptr) {}
|
grayLineBuffer(nullptr),
|
||||||
|
currentDitherRow(-1),
|
||||||
|
atkinsonDitherer(nullptr),
|
||||||
|
diffusedBayerDitherer(nullptr) {}
|
||||||
|
|
||||||
|
~PngContext() {
|
||||||
|
delete atkinsonDitherer;
|
||||||
|
delete diffusedBayerDitherer;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
void prepareDitherRow(PngContext& ctx, int dstY) {
|
||||||
|
if (!ctx.config || !ctx.config->useDithering) return;
|
||||||
|
|
||||||
|
if (ctx.currentDitherRow == -1 || dstY < ctx.currentDitherRow) {
|
||||||
|
if (ctx.atkinsonDitherer) ctx.atkinsonDitherer->reset();
|
||||||
|
if (ctx.diffusedBayerDitherer) ctx.diffusedBayerDitherer->reset();
|
||||||
|
ctx.currentDitherRow = dstY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (ctx.currentDitherRow < dstY) {
|
||||||
|
if (ctx.atkinsonDitherer) ctx.atkinsonDitherer->nextRow();
|
||||||
|
if (ctx.diffusedBayerDitherer) ctx.diffusedBayerDitherer->nextRow();
|
||||||
|
ctx.currentDitherRow++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t ditherGray(PngContext& ctx, uint8_t gray, int localX, int outX, int outY) {
|
||||||
|
if (!ctx.config || !ctx.config->useDithering) {
|
||||||
|
return quantizeGray4Level(gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ctx.config->ditherMode) {
|
||||||
|
case ImageDitherMode::Atkinson:
|
||||||
|
if (ctx.atkinsonDitherer) {
|
||||||
|
return ctx.atkinsonDitherer->processPixel(gray, localX);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ImageDitherMode::DiffusedBayer:
|
||||||
|
if (ctx.diffusedBayerDitherer) {
|
||||||
|
return ctx.diffusedBayerDitherer->processPixel(gray, localX, outX, outY);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ImageDitherMode::Bayer:
|
||||||
|
case ImageDitherMode::COUNT:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyBayerDither4Level(gray, outX, outY);
|
||||||
|
}
|
||||||
|
|
||||||
// File I/O callbacks use pFile->fHandle to access the FsFile*,
|
// File I/O callbacks use pFile->fHandle to access the FsFile*,
|
||||||
// avoiding the need for global file state.
|
// avoiding the need for global file state.
|
||||||
void* pngOpenWithHandle(const char* filename, int32_t* size) {
|
void* pngOpenWithHandle(const char* filename, int32_t* size) {
|
||||||
@@ -205,7 +259,6 @@ int pngDrawCallback(PNGDRAW* pDraw) {
|
|||||||
int dstWidth = ctx->dstWidth;
|
int dstWidth = ctx->dstWidth;
|
||||||
int outXBase = ctx->config->x;
|
int outXBase = ctx->config->x;
|
||||||
int screenWidth = ctx->screenWidth;
|
int screenWidth = ctx->screenWidth;
|
||||||
bool useDithering = ctx->config->useDithering;
|
|
||||||
bool caching = ctx->caching;
|
bool caching = ctx->caching;
|
||||||
|
|
||||||
// Pre-compute orientation and render-mode state once per row
|
// Pre-compute orientation and render-mode state once per row
|
||||||
@@ -219,6 +272,8 @@ int pngDrawCallback(PNGDRAW* pDraw) {
|
|||||||
cw.beginRow(outY, ctx->config->y);
|
cw.beginRow(outY, ctx->config->y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepareDitherRow(*ctx, dstY);
|
||||||
|
|
||||||
int srcX = 0;
|
int srcX = 0;
|
||||||
int error = 0;
|
int error = 0;
|
||||||
|
|
||||||
@@ -227,13 +282,7 @@ int pngDrawCallback(PNGDRAW* pDraw) {
|
|||||||
if (outX < screenWidth) {
|
if (outX < screenWidth) {
|
||||||
uint8_t gray = ctx->grayLineBuffer[srcX];
|
uint8_t gray = ctx->grayLineBuffer[srcX];
|
||||||
|
|
||||||
uint8_t ditheredGray;
|
uint8_t ditheredGray = ditherGray(*ctx, gray, dstX, outX, outY);
|
||||||
if (useDithering) {
|
|
||||||
ditheredGray = applyBayerDither4Level(gray, outX, outY);
|
|
||||||
} else {
|
|
||||||
ditheredGray = gray / 85;
|
|
||||||
if (ditheredGray > 3) ditheredGray = 3;
|
|
||||||
}
|
|
||||||
pw.writePixel(outX, ditheredGray);
|
pw.writePixel(outX, ditheredGray);
|
||||||
if (caching) cw.writePixel(outX, ditheredGray);
|
if (caching) cw.writePixel(outX, ditheredGray);
|
||||||
}
|
}
|
||||||
@@ -385,6 +434,27 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.useDithering) {
|
||||||
|
switch (config.ditherMode) {
|
||||||
|
case ImageDitherMode::Atkinson:
|
||||||
|
ctx.atkinsonDitherer = new (std::nothrow) AtkinsonDitherer(ctx.dstWidth);
|
||||||
|
if (!ctx.atkinsonDitherer) {
|
||||||
|
LOG_ERR("PNG", "Failed to allocate Atkinson ditherer, falling back to Bayer");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ImageDitherMode::DiffusedBayer:
|
||||||
|
ctx.diffusedBayerDitherer = new (std::nothrow) DiffusedBayerDitherer(ctx.dstWidth);
|
||||||
|
if (!ctx.diffusedBayerDitherer) {
|
||||||
|
LOG_ERR("PNG", "Failed to allocate diffused Bayer ditherer, falling back to Bayer");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ImageDitherMode::Bayer:
|
||||||
|
case ImageDitherMode::COUNT:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
unsigned long decodeStart = millis();
|
unsigned long decodeStart = millis();
|
||||||
rc = png->decode(&ctx, 0);
|
rc = png->decode(&ctx, 0);
|
||||||
unsigned long decodeTime = millis() - decodeStart;
|
unsigned long decodeTime = millis() - decodeStart;
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ STR_IMAGES: "Images"
|
|||||||
STR_IMAGES_DISPLAY: "Display"
|
STR_IMAGES_DISPLAY: "Display"
|
||||||
STR_IMAGES_PLACEHOLDER: "Placeholder"
|
STR_IMAGES_PLACEHOLDER: "Placeholder"
|
||||||
STR_IMAGES_SUPPRESS: "Suppress"
|
STR_IMAGES_SUPPRESS: "Suppress"
|
||||||
|
STR_IMAGE_DITHERING: "Image Dithering"
|
||||||
|
STR_IMAGE_DITHER_BAYER: "Bayer"
|
||||||
|
STR_IMAGE_DITHER_ATKINSON: "Atkinson"
|
||||||
|
STR_IMAGE_DITHER_DIFFUSED_BAYER: "Diffused Bayer"
|
||||||
STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Create fallback for invalid TOC"
|
STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Create fallback for invalid TOC"
|
||||||
STR_SHORT_PWR_BTN: "Short Power Button Click"
|
STR_SHORT_PWR_BTN: "Short Power Button Click"
|
||||||
STR_ORIENTATION: "Reading Orientation"
|
STR_ORIENTATION: "Reading Orientation"
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ class CrossPointSettings {
|
|||||||
|
|
||||||
// Image rendering in EPUB reader
|
// Image rendering in EPUB reader
|
||||||
enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT };
|
enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT };
|
||||||
|
enum IMAGE_DITHERING {
|
||||||
|
IMAGE_DITHER_BAYER = 0,
|
||||||
|
IMAGE_DITHER_ATKINSON = 1,
|
||||||
|
IMAGE_DITHER_DIFFUSED_BAYER = 2,
|
||||||
|
IMAGE_DITHERING_COUNT
|
||||||
|
};
|
||||||
|
|
||||||
// Timezone options (POSIX TZ rules for DST support)
|
// Timezone options (POSIX TZ rules for DST support)
|
||||||
enum TIMEZONE {
|
enum TIMEZONE {
|
||||||
@@ -223,6 +229,8 @@ class CrossPointSettings {
|
|||||||
uint8_t showHiddenFiles = 0;
|
uint8_t showHiddenFiles = 0;
|
||||||
// Image rendering mode in EPUB reader
|
// Image rendering mode in EPUB reader
|
||||||
uint8_t imageRendering = IMAGES_DISPLAY;
|
uint8_t imageRendering = IMAGES_DISPLAY;
|
||||||
|
// Dithering mode for decoded images (EPUB/JPG/PNG)
|
||||||
|
uint8_t imageDithering = IMAGE_DITHER_BAYER;
|
||||||
// Enable synthetic TOC fallback for malformed/sparse TOC books (1 = enabled, 0 = disabled)
|
// Enable synthetic TOC fallback for malformed/sparse TOC books (1 = enabled, 0 = disabled)
|
||||||
uint8_t syntheticTocFallback = 1;
|
uint8_t syntheticTocFallback = 1;
|
||||||
// Show clock in the reader status bar
|
// Show clock in the reader status bar
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ inline const std::vector<SettingInfo>& getSettingsList() {
|
|||||||
SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering,
|
SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering,
|
||||||
{StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS},
|
{StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS},
|
||||||
"imageRendering", StrId::STR_CAT_READER),
|
"imageRendering", StrId::STR_CAT_READER),
|
||||||
|
SettingInfo::Enum(
|
||||||
|
StrId::STR_IMAGE_DITHERING, &CrossPointSettings::imageDithering,
|
||||||
|
{StrId::STR_IMAGE_DITHER_BAYER, StrId::STR_IMAGE_DITHER_ATKINSON, StrId::STR_IMAGE_DITHER_DIFFUSED_BAYER},
|
||||||
|
"imageDithering", StrId::STR_CAT_READER),
|
||||||
SettingInfo::Toggle(StrId::STR_CREATE_FALLBACK_FOR_INVALID_TOC, &CrossPointSettings::syntheticTocFallback,
|
SettingInfo::Toggle(StrId::STR_CREATE_FALLBACK_FOR_INVALID_TOC, &CrossPointSettings::syntheticTocFallback,
|
||||||
"syntheticTocFallback", StrId::STR_CAT_READER),
|
"syntheticTocFallback", StrId::STR_CAT_READER),
|
||||||
// --- Controls ---
|
// --- Controls ---
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
namespace {
|
namespace {
|
||||||
constexpr const char* SLEEP_BMP_PATH = "/sleep.bmp";
|
constexpr const char* SLEEP_BMP_PATH = "/sleep.bmp";
|
||||||
|
|
||||||
|
uint8_t normalizeImageDitherModeValue(uint8_t mode) { return static_cast<uint8_t>(imageDitherModeFromSetting(mode)); }
|
||||||
|
|
||||||
bool isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); }
|
bool isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); }
|
||||||
|
|
||||||
bool isSupportedImageFile(const std::string& path) {
|
bool isSupportedImageFile(const std::string& path) {
|
||||||
@@ -53,7 +55,13 @@ void computeCenteredImagePlacement(const int imageWidth, const int imageHeight,
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
BmpViewerActivity::BmpViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path)
|
BmpViewerActivity::BmpViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path)
|
||||||
: Activity("BmpViewer", renderer, mappedInput), filePath(std::move(path)) {}
|
: Activity("BmpViewer", renderer, mappedInput),
|
||||||
|
filePath(std::move(path)),
|
||||||
|
imageDitherMode(normalizeImageDitherModeValue(SETTINGS.imageDithering)) {}
|
||||||
|
|
||||||
|
bool BmpViewerActivity::renderCurrentImage(const bool showControls) {
|
||||||
|
return isBmpFile(filePath) ? renderBmpImage(showControls) : renderDecodedImage(showControls);
|
||||||
|
}
|
||||||
|
|
||||||
void BmpViewerActivity::onEnter() {
|
void BmpViewerActivity::onEnter() {
|
||||||
Activity::onEnter();
|
Activity::onEnter();
|
||||||
@@ -62,7 +70,7 @@ void BmpViewerActivity::onEnter() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool rendered = isBmpFile(filePath) ? renderBmpImage() : renderDecodedImage();
|
const bool rendered = renderCurrentImage();
|
||||||
if (!rendered) {
|
if (!rendered) {
|
||||||
renderError("Could not render image");
|
renderError("Could not render image");
|
||||||
}
|
}
|
||||||
@@ -100,7 +108,8 @@ bool BmpViewerActivity::renderBmpImage(const bool showControls) {
|
|||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0);
|
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0);
|
||||||
if (showControls) {
|
if (showControls) {
|
||||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN));
|
const auto labels =
|
||||||
|
mappedInput.mapLabels(tr(STR_BACK), "", I18N.get(getCurrentDitherModeLabel()), tr(STR_SET_SLEEP_SCREEN));
|
||||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
}
|
}
|
||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
@@ -139,19 +148,44 @@ bool BmpViewerActivity::renderDecodedImage(const bool showControls) {
|
|||||||
config.useExactDimensions = true;
|
config.useExactDimensions = true;
|
||||||
config.useGrayscale = true;
|
config.useGrayscale = true;
|
||||||
config.useDithering = true;
|
config.useDithering = true;
|
||||||
|
config.ditherMode = imageDitherModeFromSetting(imageDitherMode);
|
||||||
|
|
||||||
if (!decoder->decodeToFramebuffer(filePath, renderer, config)) {
|
if (!decoder->decodeToFramebuffer(filePath, renderer, config)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showControls) {
|
if (showControls) {
|
||||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN));
|
const auto labels =
|
||||||
|
mappedInput.mapLabels(tr(STR_BACK), "", I18N.get(getCurrentDitherModeLabel()), tr(STR_SET_SLEEP_SCREEN));
|
||||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
}
|
}
|
||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StrId BmpViewerActivity::getCurrentDitherModeLabel() const {
|
||||||
|
switch (imageDitherModeFromSetting(imageDitherMode)) {
|
||||||
|
case ImageDitherMode::Atkinson:
|
||||||
|
return StrId::STR_IMAGE_DITHER_ATKINSON;
|
||||||
|
case ImageDitherMode::DiffusedBayer:
|
||||||
|
return StrId::STR_IMAGE_DITHER_DIFFUSED_BAYER;
|
||||||
|
case ImageDitherMode::Bayer:
|
||||||
|
case ImageDitherMode::COUNT:
|
||||||
|
default:
|
||||||
|
return StrId::STR_IMAGE_DITHER_BAYER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BmpViewerActivity::cycleDitherMode() {
|
||||||
|
imageDitherMode = (imageDitherMode + 1) % CrossPointSettings::IMAGE_DITHERING_COUNT;
|
||||||
|
SETTINGS.imageDithering = imageDitherMode;
|
||||||
|
SETTINGS.saveToFile();
|
||||||
|
|
||||||
|
if (!renderCurrentImage()) {
|
||||||
|
renderError("Could not render image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void BmpViewerActivity::renderError(const char* message) {
|
void BmpViewerActivity::renderError(const char* message) {
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
@@ -210,6 +244,11 @@ void BmpViewerActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||||
|
cycleDitherMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Next/Right button: set this image as the sleep screen
|
// Next/Right button: set this image as the sleep screen
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
||||||
setAsSleepScreen();
|
setAsSleepScreen();
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <Epub/converters/ImageToFramebufferDecoder.h>
|
||||||
|
#include <I18nKeys.h>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -16,8 +19,12 @@ class BmpViewerActivity final : public Activity {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
std::string filePath;
|
std::string filePath;
|
||||||
|
uint8_t imageDitherMode;
|
||||||
|
bool renderCurrentImage(bool showControls = true);
|
||||||
bool renderBmpImage(bool showControls = true);
|
bool renderBmpImage(bool showControls = true);
|
||||||
bool renderDecodedImage(bool showControls = true);
|
bool renderDecodedImage(bool showControls = true);
|
||||||
|
void cycleDitherMode();
|
||||||
|
StrId getCurrentDitherModeLabel() const;
|
||||||
void renderError(const char* message);
|
void renderError(const char* message);
|
||||||
void setAsSleepScreen();
|
void setAsSleepScreen();
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user