[BACK]Return to tiles.c CVS log [TXT][DIR] Up to [contributed] / brogue-ce / src / platform

File: [contributed] / brogue-ce / src / platform / tiles.c (download)

Revision 1.1, Thu May 27 20:31:43 2021 UTC (2 years, 11 months ago) by rubenllorente
Branch point for: MAIN

Initial revision

#include <math.h>
#include <stdlib.h>
#include <SDL_image.h>
#include "platform.h"
#include "tiles.h"

#define PI  3.14159265358979323846

#define PNG_WIDTH    2048   // width (px) of the source PNG
#define PNG_HEIGHT   5568   // height (px) of the source PNG
#define TILE_WIDTH    128   // width (px) of a single tile in the source PNG
#define TILE_HEIGHT   232   // height (px) of a single tile in the source PNG
#define TILE_ROWS      24   // number of rows in the source PNG
#define TILE_COLS      16   // number of columns in the source PNG
#define TEXT_X_HEIGHT 100   // height (px) of the 'x' outline
#define TEXT_BASELINE  46   // height (px) of the blank space below the 'x' outline
#define MAX_TILE_SIZE  64   // maximum width or height (px) of screen tiles before we switch to linear interpolation


// How each tile should be processed:
//  -  's' = stretch: tile stretches to fill the space
//  -  'f' = fit: preserve aspect ratio (but tile can stretch up to 20%)
//  -  't' = text: characters must line up vertically (max. stretch 40%)
//  -  '#' = symbols: other Unicode characters (max. stretch 40%)
static const char TileProcessing[TILE_ROWS][TILE_COLS+1] = {
    "ffffffffffffffff", "ssssssssssssssss", "#t##########t#t#", "tttttttttttt###t",
    "#ttttttttttttttt", "ttttttttttt#####", "#ttttttttttttttt", "ttttttttttt#####",
    "################", "################", "################", "################",
    "tttttttttttttttt", "ttttttt#tttttttt", "tttttttttttttttt", "ttttttt#tttttttt",
    "ffsfsfsffsssssss", "ssfsfsffffffffff", "fffffffffffffsff", "ffffffffffffffff",
    "fsssfffffffffffs", "fsffffffffffffff", "ffffssssffssffff", "ffffsfffffssssff"
};

typedef struct ScreenTile {
    short foreRed, foreGreen, foreBlue; // foreground color (0..100)
    short backRed, backGreen, backBlue; // background color (0..100)
    short charIndex;    // index of the glyph to draw
    short needsRefresh; // true if the tile has changed since the last screen refresh, else false
} ScreenTile;

static SDL_Window *Win = NULL;      // the SDL window
static SDL_Surface *TilesPNG;       // source PNG
static SDL_Texture *Textures[4];    // textures used by the renderer to draw tiles
static int numTextures = 0;         // how many textures are available in `Textures`
static int8_t tilePadding[TILE_ROWS][TILE_COLS];  // how many black lines are at the top/bottom of each tile in the source PNG
static boolean tileEmpty[TILE_ROWS][TILE_COLS];   // true if a tile is completely black in the source PNG, else false

// How much should a corner of a tile be shifted by before its downscaling, to improve its sharpness.
// The first two dimensions are the tile's coordinates (row and column, both zero-based).
// The third dimension is either 0 for shifts along the horizontal axis, or 1 for the vertical axis.
// The fourth dimention is the downscaling operation's target size (width for horizontal axis, height for vertical).
// The last dimension is 0 for top/left, 1 for bottom/right, 2 for center.
// The values stored in tileShifts are signed integers. Unit is 1/10th of a pixel.
static int8_t tileShifts[TILE_ROWS][TILE_COLS][2][MAX_TILE_SIZE][3];

static ScreenTile screenTiles[ROWS][COLS];  // buffer for the expected contents of the screen
static int baseTileWidth = -1;      // width (px) of tiles in the smallest texture (`Textures[0]`)
static int baseTileHeight = -1;     // height (px) of tiles in the smallest texture (`Textures[0]`)


int windowWidth = -1;               // the SDL window's width (in "screen units", not pixels)
int windowHeight = -1;              // the SDL window's height (in "screen units", not pixels)
boolean fullScreen = false;         // true if the window should be full-screen, else false
boolean softwareRendering = false;  // true if hardware acceleration is disabled (by choice or by force)


/// Prints the fatal error message provided by SDL then closes the app.
static void sdlfatal(char *file, int line) {
    fprintf(stderr, "Fatal SDL error (%s:%d): %s\n", file, line, SDL_GetError());
    exit(1);
}


/// Prints the fatal error message provided by SDL_image then closes the app.
static void imgfatal(char *file, int line) {
    fprintf(stderr, "Fatal SDL_image error (%s:%d): %s\n", file, line, IMG_GetError());
    exit(1);
}


#if !SDL_VERSION_ATLEAST(2, 0, 5)

SDL_Surface *SDL_CreateRGBSurfaceWithFormat(Uint32 flags, int width, int height, int depth, Uint32 format) {
    Uint32 r, g, b, a;
    if (!SDL_PixelFormatEnumToMasks(format, &depth, &r, &g, &b, &a)) sdlfatal(__FILE__, __LINE__);
    return SDL_CreateRGBSurface(flags, width, height, depth, r, g, b, a);
}

#endif


/// Returns the numbers of black lines at the top and bottom of a given glyph in the source PNG.
///
/// For example, if the glyph has 30 black lines at the top and 40 at the bottom, the function
/// returns 30 (the least of the two).
///
/// In case the glyph is very small, the function never returns more than TILE_HEIGHT / 4.
/// This is to avoid drawing the glyph more than twice its size relative to other glyphs
/// when the window's aspect ratio is very large (super wide).
///
static int getPadding(int row, int column) {
    int padding;
    Uint32 *pixels = TilesPNG->pixels; // each pixel is encoded as 0xffRRGGBB
    for (padding = 0; padding < TILE_HEIGHT / 4; padding++) {
        for (int x = 0; x < TILE_WIDTH; x++) {
            int y1 = padding;
            int y2 = TILE_HEIGHT - padding - 1;
            if (pixels[(x + column * TILE_WIDTH) + (y1 + row * TILE_HEIGHT) * PNG_WIDTH] & 0xffffffU ||
                pixels[(x + column * TILE_WIDTH) + (y2 + row * TILE_HEIGHT) * PNG_WIDTH] & 0xffffffU)
            {
                return padding;
            }
        }
    }
    return padding;
}


/// Tells if a tile is completely empty (black) in the source PNG.
static boolean isTileEmpty(int row, int column) {
    Uint32 *pixels = TilesPNG->pixels; // each pixel is encoded as 0xffRRGGBB
    for (int y = 0; y < TILE_HEIGHT; y++) {
        for (int x = 0; x < TILE_WIDTH; x++) {
            if (pixels[(x + column * TILE_WIDTH) + (y + row * TILE_HEIGHT) * PNG_WIDTH] & 0xffffffU) {
                return false;
            }
        }
    }
    return true;
}


/// Downscales a tile to the specified size.
///
/// The downscaling is performed in linear color space, rather than in gamma-compressed space which would cause
/// dimming (average of a black pixel and a white pixel should be 50% grey = 0.5 luminance = 187 sRGB, not 128).
/// To simplify the computation, we assume a gamma of 2.0.
///
/// The tile is logically split into 16 parts (sliced by 3 vertical lines and 3 horizontal ones), so that:
///
///  -  the four corners can be positioned to preserve the tile's aspect ratio,
///     possibly filling the blank spaces at the top and bottom;
///  -  the top, bottom, left, right, and center areas can be independently aligned
///     with output pixels to improve sharpness. This is achieved by adding pre-computed
///     sub-pixel shifts to the target coordinates as we map source pixels to target pixels.
///
/// Some tiles, like walls and doors, always stretch to fill the space regardless of aspect ratio.
/// Other tiles must preserve aspect ratio, but we allow up to 20% stretch for graphic tiles and
/// 40% for text tiles.
///
/// For text tiles (letters, digits, punctuation signs), the characters' x-height and baseline
/// (corresponding to the top and bottom of "x") are pixel-aligned regardless of outlines.
/// This ensures that all letters neatly line up without jumping up and down.
/// We also reduce perceived boldness by applying a custom brightness curve;
/// it makes the text more legible at the smaller sizes.
///
/// Wall tops are diagonal sine waves, approximately 4 pixels apart.
///
/// \param surface the target surface
/// \param tileWidth width (px) of tiles in the target surface
/// \param tileHeight height (px) of tiles in the target surface
/// \param row row (zero-based) on which the tile is located in both the source PNG and the target surface
/// \param column column (zero-based) in which the tile is located in both the source PNG and the target surface
/// \param optimizing pass true when optimizing tiles, else false
/// \return estimated amount of blur in the resulting tile (when optimizing)
///
static double downscaleTile(SDL_Surface *surface, int tileWidth, int tileHeight, int row, int column, boolean optimizing) {
    int8_t noShifts[3] = {0, 0, 0};
    int padding = tilePadding[row][column];         // how much blank spaces there is at the top and bottom of the source tile
    char processing = TileProcessing[row][column];  // how should this tile be processed?

    // Size of the area the glyph must fit into
    int fitWidth = max(1, baseTileWidth);
    int fitHeight = max(1, baseTileHeight);

    // Number of sine waves that can fit in the tile (for wall tops)
    const int numHorizWaves = max(2, min(6, round(fitWidth * .25)));
    const int numVertWaves = max(2, min(11, round(fitHeight * .25)));

    // Size of the downscaled glyph
    int glyphWidth, glyphHeight;

    // accumulator for pixel values (linear color space), encoded as
    // 0xCCCCCCCCSSSSSSSS where C is a counter and S is a sum of squares
    uint64_t *values = malloc(tileWidth * tileHeight * sizeof(uint64_t));
    memset(values, 0, tileWidth * tileHeight * sizeof(uint64_t));
    double blur = 0;

    // if the tile is empty, we can skip the downscaling
    if (tileEmpty[row][column]) goto downscaled;

    // decide how large we can draw the glyph
    if (processing == 's' || optimizing) {
        // stretch
        glyphWidth = fitWidth = tileWidth;
        glyphHeight = fitHeight = tileHeight;
    } else if (processing == 'f') {
        // fit
        int hi = fitHeight * TILE_HEIGHT / (TILE_HEIGHT - 2 * padding);
        int lo = max(1, min(fitHeight, round(1.2 * fitWidth * TILE_HEIGHT / TILE_WIDTH)));
        glyphHeight = max(lo, min(hi, round((double)fitWidth * TILE_HEIGHT / TILE_WIDTH)));
        glyphWidth = max(1, min(fitWidth, round(1.2 * glyphHeight * TILE_WIDTH / TILE_HEIGHT)));
    } else {
        // text
        glyphWidth = max(1, min(fitWidth, round(1.4 * fitHeight * TILE_WIDTH / TILE_HEIGHT)));
        glyphHeight = max(1, min(fitHeight, round(1.4 * fitWidth * TILE_HEIGHT / TILE_WIDTH)));
    }

    // map source pixels to target pixels...
    int scaledX[TILE_WIDTH], scaledY[TILE_HEIGHT];
    int stop0, stop1, stop2, stop3, stop4;
    double map0, map1, map2, map3, map4;
    int8_t *shifts;

    // ... horizontally:

    // horizontal coordinates on the source tile for the left border (stop0), 3 taps (stop1, 2, 3), and right border (stop4)
    stop0 = 0;
    stop1 = TILE_WIDTH / 5;   // 20%
    stop2 = TILE_WIDTH / 2;   // 50%
    stop3 = TILE_WIDTH * 4/5; // 80%
    stop4 = TILE_WIDTH;

    // corresponding coordinates on the target tile, taking into account centering and sub-pixel positioning
    shifts = (glyphWidth > MAX_TILE_SIZE ? noShifts : tileShifts[row][column][0][glyphWidth - 1]);
    map0 = (fitWidth - glyphWidth + (shifts[0] + shifts[1] < 0 ? 1 : 0)) / 2;
    map1 = map0 + glyphWidth * (double)(stop1 - stop0) / (stop4 - stop0) + shifts[0] * 0.1;
    map2 = map0 + glyphWidth * (double)(stop2 - stop0) / (stop4 - stop0) + shifts[2] * 0.1;
    map3 = map0 + glyphWidth * (double)(stop3 - stop0) / (stop4 - stop0) + shifts[1] * 0.1;
    map4 = map0 + glyphWidth;

    // now we can interpolate the horizontal coordinates for all pixels
    for (int x = stop0; x < stop1; x++) scaledX[x] = map0 + (map1 - map0) * (x - stop0) / (stop1 - stop0);
    for (int x = stop1; x < stop2; x++) scaledX[x] = map1 + (map2 - map1) * (x - stop1) / (stop2 - stop1);
    for (int x = stop2; x < stop3; x++) scaledX[x] = map2 + (map3 - map2) * (x - stop2) / (stop3 - stop2);
    for (int x = stop3; x < stop4; x++) scaledX[x] = map3 + (map4 - map3) * (x - stop3) / (stop4 - stop3);

    // ... vertically:

    if (processing == 't') {
        // vertical coordinates on the source tile for the top edge (stop0), stem (stop1), "x" (stop2, stop3), and bottom edge (stop4)
        stop4 = TILE_HEIGHT;
        stop3 = stop4 - TEXT_BASELINE;
        stop2 = stop3 - TEXT_X_HEIGHT;
        stop1 = stop2 / 3;
        stop0 = 0;
    } else {
        // vertical coordinates on the source tile for the top edge (stop0), 3 taps (stop1, 2, 3), and bottom edge (stop4)
        stop0 = 0;
        stop1 = TILE_HEIGHT / 5;   // 20%
        stop2 = TILE_HEIGHT / 2;   // 50%
        stop3 = TILE_HEIGHT * 4/5; // 80%
        stop4 = TILE_HEIGHT;
    }

    // corresponding coordinates on the target tile, taking into account centering
    map0 = (fitHeight - glyphHeight) / 2;
    map1 = map0 + glyphHeight * (double)(stop1 - stop0) / (stop4 - stop0);
    map2 = map0 + glyphHeight * (double)(stop2 - stop0) / (stop4 - stop0);
    map3 = map0 + glyphHeight * (double)(stop3 - stop0) / (stop4 - stop0);
    map4 = map0 + glyphHeight;

    // for text tiles, we must exactly align stops #2 and #3 with output pixels
    if (processing == 't') {
        map3 += round(map2) - map2;
        map2 = round(map2);
        map3 = max(map2 + 1, round(map3));
        map1 = map0 + (map2 - map0) / 3;
    }

    // now add sub-pixel positioning (for text tiles, we have shifts[1] == shifts[2] == 0)
    shifts = (glyphHeight > MAX_TILE_SIZE ? noShifts : tileShifts[row][column][1][glyphHeight - 1]);
    map1 += shifts[0] * 0.1;
    map2 += shifts[2] * 0.1;
    map3 += shifts[1] * 0.1;

    // finally we can interpolate the vertical coordinates for all pixels
    for (int y = 0; y < stop0; y++) scaledY[y] = -1; // not mapped (can happen with fitted tiles)
    for (int y = stop0; y < stop1; y++) scaledY[y] = map0 + (map1 - map0) * (y - stop0) / (stop1 - stop0);
    for (int y = stop1; y < stop2; y++) scaledY[y] = map1 + (map2 - map1) * (y - stop1) / (stop2 - stop1);
    for (int y = stop2; y < stop3; y++) scaledY[y] = map2 + (map3 - map2) * (y - stop2) / (stop3 - stop2);
    for (int y = stop3; y < stop4; y++) scaledY[y] = map3 + (map4 - map3) * (y - stop3) / (stop4 - stop3);
    for (int y = stop4; y < TILE_HEIGHT; y++) scaledY[y] = -1; // not mapped (can happen with fitted tiles)

    // downscale source tile to accumulator
    for (int y0 = 0; y0 < TILE_HEIGHT; y0++) {
        int y1 = scaledY[y0];
        if (y1 < 0 || y1 >= tileHeight) continue;
        uint64_t *dst = &values[y1 * tileWidth];
        Uint32 *src = TilesPNG->pixels; // each pixel is encoded as 0xffRRGGBB
        src += (column * TILE_WIDTH) + (row * TILE_HEIGHT + y0) * PNG_WIDTH;
        for (int x0 = 0; x0 < TILE_WIDTH; x0++) {
            uint64_t value = src[x0] & 0xffU;
            dst[scaledX[x0]] += (value * value) | 0x100000000U; // (gamma = 2.0, count = 1)
        }
        // interpolate skipped lines, if any
        if (y1 >= 2 && scaledY[y0 - 1] == y1 - 2) {
            for (int x1 = 0; x1 < tileWidth; x1++) {
                dst[x1 - tileWidth] = dst[x1 - 2*tileWidth] + dst[x1];
            }
        }
    }
    downscaled:

    // procedural wall tops: diagonal sine waves
    if ((row == 16 && column == 2 || row == 21 && column == 1 || row == 22 && column == 4) && !optimizing) {
        for (int y = 0; y < tileHeight; y++) {
            if (row != 21 && (y > tileHeight / 2 || (values[y * tileWidth] & 0xffffffffU))) break;
            for (int x = 0; x < tileWidth; x++) {
                double value = sin(2. * PI * ((double)x / tileWidth * numHorizWaves
                                            + (double)y / tileHeight * numVertWaves)) / 2. + 0.5;
                values[y * tileWidth + x] = (uint64_t)round(255 * 255 * value * value) | 0x100000000U;
            }
        }
    }

    // convert accumulator to image transparency
    for (int y = 0; y < tileHeight; y++) {
        Uint32 *pixel = surface->pixels; // each pixel is encoded as 0xAARRGGBB
        pixel += (column * tileWidth) + (row * tileHeight + y) * surface->w;
        for (int x = 0; x < tileWidth; x++) {
            uint64_t value = values[y * tileWidth + x];

            // average light intensity (linear scale, 0 .. 255*255)
            value = ((value >> 32) ? (value & 0xffffffffU) / (value >> 32) : 0);

            // metric for "blurriness": black (0) and white (255*255) pixels count for 0, gray pixels for 1
            if (optimizing) blur += sin(PI/(255*255) * value);

            // make text look less bold, at the cost of accuracy
            if (processing == 't' || processing == '#') {
                value = (value < 255*255/2 ? value / 2 : value * 3/2 - 255*255/2);
            }

            // opacity (gamma-compressed, 0 .. 255)
            uint32_t alpha = (value == 0 ? 0 : value > 64770 ? 255 : round(sqrt(value)));

            *pixel++ = (alpha << 24) | 0xffffffU;
        }
    }

    free(values);
    return blur; // (used by the optimizer)
}


/// Finds the best possible sub-pixel alignments of tiles for their downscaling at every possible size.
/// Results are recorded into `tileShifts`.
///
/// This is a slow function (takes ~2 minutes) so the results are saved to disk and reloaded when Brogue starts.
/// After you modify the PNG, you should also delete "tiles.bin" and run Brogue so that the new tiles get optimized.
static void optimizeTiles() {
    SDL_Window *window = SDL_CreateWindow("Brogue", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 400, 300, 0);

    for (int row = 0; row < TILE_ROWS; row++) {
        for (int column = 0; column < TILE_COLS; column++) {
            if (tileEmpty[row][column]) continue;
            char processing = TileProcessing[row][column];

            // show what we are doing
            char title[100];
            sprintf(title, "Brogue - Optimizing tile %d / %d ...\n", row * TILE_COLS + column + 1, TILE_ROWS * TILE_COLS);
            SDL_SetWindowTitle(window, title);
            SDL_Surface *winSurface = SDL_GetWindowSurface(window);
            if (!winSurface) sdlfatal(__FILE__, __LINE__);
            if (SDL_BlitSurface(TilesPNG, &(SDL_Rect){.x=column*TILE_WIDTH, .y=row*TILE_HEIGHT, .w=TILE_WIDTH, .h=TILE_HEIGHT},
                    winSurface, &(SDL_Rect){.x=0, .y=0, .w=TILE_WIDTH, .h=TILE_HEIGHT}) < 0) sdlfatal(__FILE__, __LINE__);
            if (SDL_UpdateWindowSurface(window) < 0) sdlfatal(__FILE__, __LINE__);

            // horizontal shifts
            baseTileHeight = MAX_TILE_SIZE;
            for (baseTileWidth = 5; baseTileWidth <= MAX_TILE_SIZE; baseTileWidth++) {
                int8_t *shifts = tileShifts[row][column][0][baseTileWidth - 1];
                SDL_Surface *surface = SDL_CreateRGBSurfaceWithFormat(0, baseTileWidth * TILE_COLS, baseTileHeight * TILE_ROWS, 32, SDL_PIXELFORMAT_ARGB8888);
                if (!surface) sdlfatal(__FILE__, __LINE__);

                for (int i = 0; i < 3; i++) {
                    for (int idx = 0; idx < (processing == 't' || processing == '#' ? 2 : 3); idx++) {
                        double bestResult = 1e20;
                        int8_t bestShift = 0;
                        int8_t midShift = (idx == 2 ? (shifts[0] + shifts[1]) / 2 : 0);
                        for (int8_t shift = midShift - 5; shift <= midShift + 5; shift++) {
                            shifts[idx] = shift;
                            if (processing == 't' || processing == '#') {
                                shifts[2] = (shifts[0] + shifts[1]) / 2;
                            }
                            double blur = downscaleTile(surface, baseTileWidth, baseTileHeight, row, column, true);
                            if (blur < bestResult) {
                                bestResult = blur;
                                bestShift = shift;
                            }
                        }
                        shifts[idx] = bestShift;
                        if (processing == 't' || processing == '#') {
                            shifts[2] = (shifts[0] + shifts[1]) / 2;
                        }
                    }
                }

                SDL_FreeSurface(surface);
            }

            // vertical shifts
            baseTileWidth = MAX_TILE_SIZE;
            for (baseTileHeight = 7; baseTileHeight <= MAX_TILE_SIZE; baseTileHeight++) {
                int8_t *shifts = tileShifts[row][column][1][baseTileHeight - 1];
                SDL_Surface *surface = SDL_CreateRGBSurfaceWithFormat(0, baseTileWidth * TILE_COLS, baseTileHeight * TILE_ROWS, 32, SDL_PIXELFORMAT_ARGB8888);
                if (!surface) sdlfatal(__FILE__, __LINE__);

                for (int i = 0; i < 3; i++) {
                    for (int idx = 0; idx < (processing == 't' ? 1 : 3); idx++) {
                        double bestResult = 1e20;
                        int8_t bestShift = 0;
                        int8_t midShift = (idx == 2 ? (shifts[0] + shifts[1]) / 2 : 0);
                        for (int8_t shift = midShift - 5; shift <= midShift + 5; shift++) {
                            shifts[idx] = shift;
                            double blur = downscaleTile(surface, baseTileWidth, baseTileHeight, row, column, true);
                            if (blur < bestResult) {
                                bestResult = blur;
                                bestShift = shift;
                            }
                        }
                        shifts[idx] = bestShift;
                    }
                }

                SDL_FreeSurface(surface);
            }
        }
    }
    SDL_DestroyWindow(window);
}


/// Loads the PNG and analyses it.
void initTiles() {

    // load the large PNG
    char filename[BROGUE_FILENAME_MAX];
    sprintf(filename, "%s/assets/tiles.png", dataDirectory);
    SDL_Surface *image = IMG_Load(filename);
    if (!image) imgfatal(__FILE__, __LINE__);
    TilesPNG = SDL_ConvertSurfaceFormat(image, SDL_PIXELFORMAT_ARGB8888, 0);
    if (!TilesPNG) sdlfatal(__FILE__, __LINE__);
    SDL_FreeSurface(image);

    // measure padding
    for (int row = 0; row < TILE_ROWS; row++) {
        for (int column = 0; column < TILE_COLS; column++) {
            tileEmpty[row][column] = isTileEmpty(row, column);
            tilePadding[row][column] = (TileProcessing[row][column] == 'f' ? getPadding(row, column) : 0);
        }
    }

    // load shifts
    sprintf(filename, "%s/assets/tiles.bin", dataDirectory);
    FILE *file = fopen(filename, "rb");
    if (file) {
        fread(tileShifts, 1, sizeof(tileShifts), file);
        fclose(file);
    } else {
        optimizeTiles();
        file = fopen(filename, "wb");
        fwrite(tileShifts, 1, sizeof(tileShifts), file);
        fclose(file);
    }
}


/// Creates the textures to fit a specific output size
/// (which is equal to the window size on standard DPI displays, but can be larger on HiDPI).
///
/// We build up to 4 textures, with different tile sizes. Each texture has all the tiles on it.
/// The first texture (`Textures[0]`) is always present, and is always the smallest of the bunch.
///
/// The reason for having tiles of different sizes ready to be drawn on screen is that the window
/// width is usually not a multiple of 100 (the `COLS` constant), and height not a multiple of 34
/// (`ROWS`). Since tiles must have integer dimensions, that means some tiles must be larger by
/// 1 pixel than others, so that we can cover the window without black padding on the sides nor
/// columns/rows of blank pixels between tiles.
///
/// If the window is so large that tiles would have to be over 64x64 pixels, we generate a single, large
/// texture instead of four and use it for all tiles, allowing the renderer to do some linear interpolation.
///
/// To ensure compatibility with older OpenGL drivers, texture dimensions are always powers of 2.
///
/// \param outputWidth renderer's output width
/// \param outputHeight renderer's output height
///
static void createTextures(SDL_Renderer *renderer, int outputWidth, int outputHeight) {

    // choose tile size
    double tileAspectRatio = (double)(outputWidth * ROWS) / (outputHeight * COLS);
    int newBaseTileWidth = max(1, outputWidth / COLS);
    int newBaseTileHeight = max(1, outputHeight / ROWS);
    if (newBaseTileWidth >= MAX_TILE_SIZE || newBaseTileHeight >= MAX_TILE_SIZE) {
        newBaseTileWidth = max(1, min(TILE_WIDTH, round(TILE_HEIGHT * tileAspectRatio)));
        newBaseTileHeight = max(1, min(TILE_HEIGHT, round(TILE_WIDTH / tileAspectRatio)));
    }

    // if tile size has not changed, we don't need to rebuild the tiles
    if (baseTileWidth == newBaseTileWidth && baseTileHeight == newBaseTileHeight) {
        return;
    }

    baseTileWidth = newBaseTileWidth;
    baseTileHeight = newBaseTileHeight;

    // destroy the old textures
    for (int i = 0; i < 4; i++) {
        if (Textures[i]) SDL_DestroyTexture(Textures[i]);
        Textures[i] = NULL;
    }

    // choose the number of textures
    if (baseTileWidth >= MAX_TILE_SIZE || baseTileHeight >= MAX_TILE_SIZE) {
        numTextures = 1;
        SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear");
    } else {
        numTextures = 4;
        SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "nearest");
    }

    // The original image will be resized to 4 possible sizes:
    //  -  Textures[0]: tiles are   W   x   H   pixels
    //  -  Textures[1]: tiles are (W+1) x   H   pixels
    //  -  Textures[2]: tiles are   W   x (H+1) pixels
    //  -  Textures[3]: tiles are (W+1) x (H+1) pixels

    for (int i = 0; i < numTextures; i++) {

        // choose dimensions
        int tileWidth = baseTileWidth + (i == 1 || i == 3 ? 1 : 0);
        int tileHeight = baseTileHeight + (i == 2 || i == 3 ? 1 : 0);
        int surfaceWidth = 1, surfaceHeight = 1;
        while (surfaceWidth < tileWidth * TILE_COLS) surfaceWidth *= 2;
        while (surfaceHeight < tileHeight * TILE_ROWS) surfaceHeight *= 2;

        // downscale the tiles
        SDL_Surface *surface = SDL_CreateRGBSurfaceWithFormat(0, surfaceWidth, surfaceHeight, 32, SDL_PIXELFORMAT_ARGB8888);
        if (!surface) sdlfatal(__FILE__, __LINE__);
        for (int row = 0; row < TILE_ROWS; row++) {
            for (int column = 0; column < TILE_COLS; column++) {
                downscaleTile(surface, tileWidth, tileHeight, row, column, false);
            }
        }

        // convert to texture
        Textures[i] = SDL_CreateTextureFromSurface(renderer, surface);
        if (!Textures[i]) sdlfatal(__FILE__, __LINE__);
        if (SDL_SetTextureBlendMode(Textures[i], SDL_BLENDMODE_BLEND) < 0) sdlfatal(__FILE__, __LINE__);
        SDL_FreeSurface(surface);
    }
}


/// Updates the screen buffer.
///
/// \param row row on screen (between 0 and ROWS-1)
/// \param column column on screen (between 0 and COLS-1)
/// \param charIndex glyph number (e.g 48 for "@", 281 for Dragon)
/// \param foreRed red component of the foreground color (0..100)
/// \param foreGreen green component of the foreground color (0..100)
/// \param foreBlue blue component of the foreground color (0..100)
/// \param backRed red component of the background color (0..100)
/// \param backGreen green component of the background color (0..100)
/// \param backBlue blue component of the background color (0..100)
///
void updateTile(int row, int column, short charIndex,
    short foreRed, short foreGreen, short foreBlue,
    short backRed, short backGreen, short backBlue)
{
    screenTiles[row][column] = (ScreenTile){
        .foreRed   = foreRed,
        .foreGreen = foreGreen,
        .foreBlue  = foreBlue,
        .backRed   = backRed,
        .backGreen = backGreen,
        .backBlue  = backBlue,
        .charIndex = charIndex,
        .needsRefresh = 1
    };
}


/// Draws everything on screen.
///
/// OpenGL drivers don't like alternating between different textures too much, so we
/// first draw the background colors then do 4 passes over the tiles, one pass per texture.
///
/// Some video drivers are quite inefficient, notably in virtual machines, so
/// there is a new `--no-gpu` command-line parameter to disable hardware acceleration.
/// The software renderer does not support HiDPI, though.
///
/// To improve performance of the software renderer, we don't redraw the whole screen but
/// only the tiles that have changed recently (which is tracked with ScreenTile::needsRefresh).
/// This works because, unlike the accelerated renderers, the software renderer draws on a
/// single surface and doesn't do double-buffering.
///
void updateScreen() {
    if (!Win) return;

    SDL_Renderer *renderer = SDL_GetRenderer(Win);
    if (!renderer) {
        renderer = SDL_CreateRenderer(Win, -1, (softwareRendering ? SDL_RENDERER_SOFTWARE : 0));
        if (!renderer) sdlfatal(__FILE__, __LINE__);

        if (SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE) < 0) sdlfatal(__FILE__, __LINE__);

        // see if we ended up using the software renderer or not
        SDL_RendererInfo info;
        if (SDL_GetRendererInfo(renderer, &info) < 0) sdlfatal(__FILE__, __LINE__);
        softwareRendering = (strcmp(info.name, "software") == 0);
    }

    int outputWidth, outputHeight;
    if (SDL_GetRendererOutputSize(renderer, &outputWidth, &outputHeight) < 0) sdlfatal(__FILE__, __LINE__);
    if (outputWidth == 0 || outputHeight == 0) return;

    createTextures(renderer, outputWidth, outputHeight);

    if (!softwareRendering) {
        // black out the frame (double-buffering invalidated it)
        if (SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0) < 0) sdlfatal(__FILE__, __LINE__);
        if (SDL_RenderClear(renderer) < 0) sdlfatal(__FILE__, __LINE__);
    }

    // To please the OpenGL renderer, we'll proceed in 5 steps:
    //  -1. background colors
    //  0.  Textures[0]
    //  1.  Textures[1]
    //  2.  Textures[2]
    //  3.  Textures[3]

    for (int step = -1; step < numTextures; step++) {

        for (int x = 0; x < COLS; x++) {
            int tileWidth = ((x+1) * outputWidth / COLS) - (x * outputWidth / COLS);
            if (tileWidth == 0) continue;

            for (int y = 0; y < ROWS; y++) {
                int tileHeight = ((y+1) * outputHeight / ROWS) - (y * outputHeight / ROWS);
                if (tileHeight == 0) continue;

                ScreenTile *tile = &screenTiles[y][x];
                if (softwareRendering && !tile->needsRefresh) {
                    continue; // software rendering does not use double-buffering, so the tile is still on screen
                }

                if (step < 0) {
                    if (!softwareRendering && tile->backRed == 0 && tile->backGreen == 0 && tile->backBlue == 0) {
                        continue; // SDL_RenderClear already painted everything black
                    }

                    SDL_Rect dest;
                    dest.w = tileWidth;
                    dest.h = tileHeight;
                    dest.x = x * outputWidth / COLS;
                    dest.y = y * outputHeight / ROWS;

                    // paint the background
                    if (SDL_SetRenderDrawColor(renderer,
                        round(2.55 * tile->backRed),
                        round(2.55 * tile->backGreen),
                        round(2.55 * tile->backBlue), 255) < 0) sdlfatal(__FILE__, __LINE__);
                    if (SDL_RenderFillRect(renderer, &dest) < 0) sdlfatal(__FILE__, __LINE__);

                } else {
                    int textureIndex = (numTextures < 4 ? 0 : (tileWidth > baseTileWidth ? 1 : 0) + (tileHeight > baseTileHeight ? 2 : 0));
                    if (step != textureIndex) {
                        continue; // this tile uses another texture and gets painted at another step
                    }

                    int tileRow    = tile->charIndex / 16;
                    int tileColumn = tile->charIndex % 16;

                    if (tileEmpty[tileRow][tileColumn]
                            && !(tileRow == 21 && tileColumn == 1)) {  // wall top (procedural)
                        continue; // there is nothing to draw
                    }

                    SDL_Rect src;
                    src.w = baseTileWidth  + (step == 1 || step == 3 ? 1 : 0);
                    src.h = baseTileHeight + (step == 2 || step == 3 ? 1 : 0);
                    src.x = src.w * tileColumn;
                    src.y = src.h * tileRow;

                    SDL_Rect dest;
                    dest.w = tileWidth;
                    dest.h = tileHeight;
                    dest.x = x * outputWidth / COLS;
                    dest.y = y * outputHeight / ROWS;

                    // blend the foreground
                    if (SDL_SetTextureColorMod(Textures[step],
                        round(2.55 * tile->foreRed),
                        round(2.55 * tile->foreGreen),
                        round(2.55 * tile->foreBlue)) < 0) sdlfatal(__FILE__, __LINE__);
                    if (SDL_RenderCopy(renderer, Textures[step], &src, &dest) < 0) sdlfatal(__FILE__, __LINE__);
                }
            }
        }
    }

    SDL_RenderPresent(renderer);

    // the screen is now up to date
    for (int y = 0; y < ROWS; y++) {
        for (int x = 0; x < COLS; x++) {
            screenTiles[y][x].needsRefresh = 0;
        }
    }
}


/*
Creates or resizes the game window with the currently loaded font.
*/
void resizeWindow(int width, int height) {

    SDL_DisplayMode mode;
    if (SDL_GetCurrentDisplayMode(0, &mode) < 0) sdlfatal(__FILE__, __LINE__);

    // 70% of monitor width by default, with height following 16:10 aspect ratio
    if (width < 0) width = mode.w * 7/10;
    if (height < 0) height = width * 10/16;

    // go to fullscreen mode if the window is as big as the screen
    if (width >= mode.w && height >= mode.h) fullScreen = true;

    if (Win == NULL) {
        // create the window
        Win = SDL_CreateWindow("Brogue",
            SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, width, height,
            SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI | (fullScreen ? SDL_WINDOW_FULLSCREEN_DESKTOP : 0));
        if (!Win) sdlfatal(__FILE__, __LINE__);

        // set its icon
        char filename[BROGUE_FILENAME_MAX];
        sprintf(filename, "%s/assets/icon.png", dataDirectory);
        SDL_Surface *icon = IMG_Load(filename);
        if (!icon) imgfatal(__FILE__, __LINE__);
        SDL_SetWindowIcon(Win, icon);
        SDL_FreeSurface(icon);
    }

    if (fullScreen) {
        if (!(SDL_GetWindowFlags(Win) & SDL_WINDOW_FULLSCREEN_DESKTOP)) {
            // switch to fullscreen mode
            if (SDL_SetWindowFullscreen(Win, SDL_WINDOW_FULLSCREEN_DESKTOP) < 0) sdlfatal(__FILE__, __LINE__);
        }
    } else {
        if (SDL_GetWindowFlags(Win) & SDL_WINDOW_FULLSCREEN_DESKTOP) {
            // switch to windowed mode
            if (SDL_SetWindowFullscreen(Win, 0) < 0) sdlfatal(__FILE__, __LINE__);
        } else {
            // what is the current size?
            SDL_GetWindowSize(Win, &windowWidth, &windowHeight);
            if (windowWidth != width || windowHeight != height) {
                // resize the window
                SDL_SetWindowSize(Win, width, height);
                SDL_RestoreWindow(Win);
            }
        }
    }

    SDL_GetWindowSize(Win, &windowWidth, &windowHeight);
    refreshScreen();
    updateScreen();
}


SDL_Surface *captureScreen() {
    if (!Win) return NULL;

    // get the renderer
    SDL_Renderer *renderer = SDL_GetRenderer(Win);
    if (!renderer) return NULL;

    // get its size
    int outputWidth, outputHeight;
    if (SDL_GetRendererOutputSize(renderer, &outputWidth, &outputHeight) < 0) sdlfatal(__FILE__, __LINE__);
    if (outputWidth == 0 || outputHeight == 0) return NULL;

    // take a screenshot
    SDL_Surface *screenshot = SDL_CreateRGBSurfaceWithFormat(0, outputWidth, outputHeight, 32, SDL_PIXELFORMAT_ARGB8888);
    if (!screenshot) sdlfatal(__FILE__, __LINE__);
    if (SDL_RenderReadPixels(renderer, NULL, SDL_PIXELFORMAT_ARGB8888, screenshot->pixels, outputWidth * 4) < 0) sdlfatal(__FILE__, __LINE__);
    return screenshot;
}