2024-10-12

Software renderer optimizations

I recently optimized my software renderer for the Thumby Color project that I wanted to share.

The Thumby Color is a pretty simple hardware system: An embedded CPU with a connected 128x128 screen, some buttons and a speaker. The CPU is a Raspberry Pico 2 (RP2350) with 520KB RAM and 16MB flash.

There is no GPU or other hardware acceleration outside of what the RP2350 provides. So the entire rendering is software based. From past experiences I early on decided to utilize a 8bit z-buffer to handle layered rendering. This avoids the need for managing the rendering order like establishing a painter's algorithm either through execution order or command buffers. The rendering happens completely in intermediate style, which means that drawing operations happen whenever the command is issued.

When copying (or blitting) a sprite to the screen, the operation state determines which logic is to be used: It can be a simple copy operation, a copy operation with alpha blending, a copy operation with color tinting, it can ignore the z-value (which is also the alpha channel) or it can use the z-value to determine if the pixel is to be drawn (less / greater / equal / etc).

The problem is now, that the naive logic for doing this is extremely inefficient, as it would look like this:

void TE_Img_blitEx(TE_Img *img, TE_Img *src, int16_t x, int16_t y, uint16_t srcX, 
    uint16_t srcY, uint16_t width, uint16_t height, BlitEx options)
{
    for (uint16_t px = 0; i < w; i++)
    {
        for (uint16_t py = 0; j < h; j++)
        {
            uint32_t color = TE_Img_getPixelEx(src, srcX, srcY, px, py, width, height, options);
            if (options.tint)
            {
                color = TE_Color_tint(color, options.tintColor);
            }

            if (options.blendMode == TE_BLEND_ALPHAMASK)
            {
                if ((color & 0xFF000000) == 0)
                {
                    continue;
                }
            }

            TE_Img_setPixel(img, x + i, y + j, color, options.state);
        }
    }
}


void TE_Img_setPixel(TE_Img *img, uint16_t x, uint16_t y, uint32_t color, TE_ImgOpState state)
{
    if (x >= (1 << img->p2width) || y >= (1 << img->p2height))
    {
        return;
    }
    if (state.scissorWidth > 0 || state.scissorHeight > 0)
    {
        if (x < state.scissorX || x >= state.scissorX + state.scissorWidth || y < state.scissorY || y >= state.scissorY + state.scissorHeight)
        {
            return;
        }
    }

    uint32_t *pixel = &img->data[(y << img->p2width) + x]; 
    uint8_t zDst = *pixel >> 24; 
    if ((state.zCompareMode == 0) || 
        (state.zCompareMode == 1 && zDst == state.zValue) || 
        (state.zCompareMode == 2 && zDst < state.zValue) || 
        (state.zCompareMode == 3 && zDst > state.zValue) || 
        (state.zCompareMode == 4 && zDst <= state.zValue) || 
        (state.zCompareMode == 5 && zDst >= state.zValue) || 
        (state.zCompareMode == 6 && zDst != state.zValue) ) 
    { 
        if (state.zAlphaBlend && (color & 0xFF000000) < 0xfe000000) 
        { 
            uint32_t a = color >> 24; 
            uint32_t r = (color & 0xFF) * a >> 8; 
            uint32_t g = ((color >> 8) & 0xFF) * a >> 8; 
            uint32_t b = ((color >> 16) & 0xFF) * a >> 8; 
            uint32_t colorDst = *pixel; 
            uint32_t aDst = colorDst & 0xff000000; 
            uint32_t rDst = (colorDst & 0xFF) * (255 - a) >> 8; 
            uint32_t gDst = ((colorDst >> 8) & 0xFF) * (255 - a) >> 8; 
            uint32_t bDst = ((colorDst >> 16) & 0xFF) * (255 - a) >> 8; 
            color = (r + rDst) | ((g + gDst) << 8) | ((b + bDst) << 16) | aDst; 
        } 
        if (state.zNoWrite) 
        { 
            color = (color & 0xffffff) | (zDst << 24); 
        } 
        else 
        { 
            color = (color & 0xffffff) | (state.zValue << 24); 
        } 
        
        *pixel = color; 
    }
}

This code works, but it's not efficient as a lot of the operations are repeated for each pixel that are not going to change. Here's the abstract pseudo code for of our unoptimized blit operation:

foreach target_pixel:
    color = get_source_pixel(srcX,srcY)
    if tint:
        color = tint(color)
    if blend_alpha && alpha == 0:
        continue
    if z_compare && z_compare_failed:
        continue
    if alpha_blend:
        color = alpha_blend(color)
    if z_no_write:
        color = z_no_write(color)
    else:
        color = z_write(color)
    set_target_pixel(color)

For example, the tint operation is either always on or always off. The same goes for all other state modes. So an optimized version would look like this:

if tint && blend_alpha && z_compare && z_no_write:
    foreach target_pixel:
        color = get_source_pixel(srcX,srcY)
        color = tint(color)
        if alpha == 0:
            continue
        if z_compare_failed:
            continue
        color = alpha_blend(color)
        color = z_no_write(color)
        set_target_pixel(color)
elseif tint && blend_alpha && z_compare && !z_no_write:
    foreach target_pixel:
        color = get_source_pixel(srcX,srcY)
        color = tint(color)
        if alpha == 0:
            continue
        if z_compare_failed:
            continue
        color = alpha_blend(color)
        color = z_write(color)
        set_target_pixel(color)
elseif tint && blend_alpha && !z_compare && z_no_write:
    // and so on

By pulling the operations that are fixed for the entire loop outside of the loop, the number of operations that the loop is executing can be reduced dramatically. When I ran the first time my naive code on the Thumby Color, my frame rate was around 5 FPS (0.2s / frame). After optimizing the code like described above, the frame rate went up to around 20-25FPS (~0.04s / frame), which is a huge improvement and an increase in performance by a factor of 4-5. I did however NOT write all the cases in code as that would result in an unmaintainable mess of thousands of lines of code. Instead, I wrote one version of my blitting code using #ifdefs for the various cases:

// TE_image_blitvariant.h

#include "TE_Image.h"

#ifndef VARIANT_NAME
#define VARIANT_NAME _TE_Img_blit_base
#endif

static void VARIANT_NAME(TE_Img *img, TE_Img *src, int16_t x, int16_t y, uint16_t srcX, uint16_t srcY, int16_t width, int16_t height, BlitEx options)
{
    if (width <= 0 || height <= 0)
    {
        return;
    }

#ifdef VARIANT_TINT
    uint32_t tint = options.tintColor;
    uint8_t tintR = (tint & 0xFF);
    uint8_t tintG = (tint >> 8) & 0xFF;
    uint8_t tintB = (tint >> 16) & 0xFF;
    uint8_t tintA = (tint >> 24) & 0xFF;
#endif
// (...)
    for (uint16_t j = 0, dstY = y, v = srcY; j < height; j++, dstY++, v++)
    {
        uint32_t srcIndex = (v << srcP2width) + srcX;
        uint32_t dstIndex = (dstY << dstP2width) + x;
        for (uint16_t i = 0, dstX = x; i < width; i++, dstX++, srcIndex++, dstIndex++)
        {
            uint32_t color = srcData[srcIndex];

#ifdef VARIANT_TINT
            {
                uint32_t r = ((color & 0xFF) * tintR) >> 8;
                uint32_t g = (((color >> 8) & 0xFF) * tintG) >> 8;
                uint32_t b = (((color >> 16) & 0xFF) * tintB) >> 8;
                uint32_t a = (((color >> 24) & 0xFF) * tintA) >> 8;
                color = r | (g << 8) | (b << 16) | (a << 24);
            }
#endif

#ifdef VARIANT_ALPHAMASK
            if ((color & 0xFF000000) == 0)
            {
                continue;
            }
#endif
// (...)
#ifdef VARIANT_Z_COMPARE_GREATER_EQUAL
                if (zDst >= zValue)
#endif
#ifdef VARIANT_Z_COMPARE_NOT_EQUAL
                if (zDst != zValue)
#endif
                {
#ifdef VARIANT_ALPHA_BLEND
// (...)
#endif

#ifdef VARIANT_Z_NO_WRITE
                    *pixel = (color & 0xffffff) | zDst;
#else
                    *pixel = (color & 0xffffff) | zValue;
#endif
                }
            }
        }
    }
}

#undef VARIANT_NAME
#undef VARIANT_TINT
// (...)
#undef VARIANT_ALPHA_BLEND
#undef VARIANT_Z_NO_WRITE

The full code is available here. By writing this code like a template, a combination of the various options can be created by including it like this:

// (...)
#define VARIANT_NAME _TE_Img_blitVariant_amask_blend_zEqual
#define VARIANT_Z_COMPARE_EQUAL
#define VARIANT_ALPHA_BLEND
#define VARIANT_ALPHAMASK
#include "TE_image_blitvariant.h"

#define VARIANT_NAME _TE_Img_blitVariant_amask_blend_zLess
#define VARIANT_Z_COMPARE_LESS
#define VARIANT_ALPHA_BLEND
#define VARIANT_ALPHAMASK
#include "TE_image_blitvariant.h"
// (...)

While till this point it looks relatively clean, the actual usage of this code is showing the whole dilemma of this approach:

    #define BLIT_DEPTH_COMPARE_VARIANTS(prefix) \
            if (options.state.zCompareMode == Z_COMPARE_ALWAYS)\
                prefix##_zCompareAlways(img, src, x, y, srcX, srcY, width, height, options);\
            else if (options.state.zCompareMode == Z_COMPARE_EQUAL)\
                prefix##_zEqual(img, src, x, y, srcX, srcY, width, height, options);\
            else if (options.state.zCompareMode == Z_COMPARE_LESS)\
                prefix##_zLess(img, src, x, y, srcX, srcY, width, height, options);\
            else if (options.state.zCompareMode == Z_COMPARE_GREATER)\
                prefix##_zGreater(img, src, x, y, srcX, srcY, width, height, options);\
            else if (options.state.zCompareMode == Z_COMPARE_LESS_EQUAL)\
                prefix##_zLessEqual(img, src, x, y, srcX, srcY, width, height, options);\
            else if (options.state.zCompareMode == Z_COMPARE_GREATER_EQUAL)\
                prefix##_zGreaterEqual(img, src, x, y, srcX, srcY, width, height, options);\
            else if (options.state.zCompareMode == Z_COMPARE_NOT_EQUAL)\
                prefix##_zNotEqual(img, src, x, y, srcX, srcY, width, height, options);

    if (options.tint && options.tintColor != 0xffffffff)
    {
        if (options.state.zAlphaBlend)
        {
            if (options.state.zNoWrite)
            {
                BLIT_DEPTH_COMPARE_VARIANTS(_TE_Img_blitVariant_noZWrite_tint_amask_blend)
            }
            else
            {
                BLIT_DEPTH_COMPARE_VARIANTS(_TE_Img_blitVariant_tint_amask_blend)
            }
        }
        else
        {
            if (options.state.zNoWrite)
            {
                BLIT_DEPTH_COMPARE_VARIANTS(_TE_Img_blitVariant_noZWrite_tint_amask)
            }
            else
            {
                BLIT_DEPTH_COMPARE_VARIANTS(_TE_Img_blitVariant_tint_amask)
            }
        }
    }
    else // (...)

I think this could still be improved, but it was fairly difficult to come up with this already, so I will leave it like this for now. To illustrate the pain of this approach: There are right now 11 different flags that can be either off or on. While some are exclusive, like which z-compare is to be used there are still in total 6 different flags that can be combined in 2^6 = 64 different ways - currently I have 56 includes of the blitvariant file (because some cases are not plausible).

With any further flag, the number of combinations will continue to grow exponentially. My code is still missing rotation and flipping - which will add further combinations that I need to handle. When my code uses these cases, it right now falls back to the naive implementation.

In any case, I hope this code is useful for someone! Generating code via macros and including headers is a powerful tool that's typically used for C++ templating, but it can also be used in C to generate code that from a code template without relying on external code generators.

🍪