smhk

Pixel Perfect Rendering in SDL2

TL;DR: To ensure SDL2 rendering is pixel perfect, you must use “nearest pixel sampling” for the render quality, and mark the process as “DPI aware” on Windows.

Enable nearest pixel sampling §

The scale quality must be set to "0", which is nearest pixel sampling.

SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "0");

If set to "1" ("linear") or "2" ("best"), SDL2 will use linear filtering when scaling textures, resulting in antialiasing.

Set DPI aware on Windows §

Windows 10 may automatically apply a scaling of 125% to processes which do not declare themselves “DPI aware”. This will break any attempt at pixel perfect rendering.

The quick fix is to call SetProcessDPIAware() to tell Windows the process is “DPI aware”, and it will no longer apply any automatic scaling.

#ifdef _WIN32
#include <windows.h>
#endif

int main(int argc, char *argv[])
{
    // ...

#ifdef _WIN32
    // Prevent Windows from applying scaling.
    SetProcessDPIAware();
#endif
}

The official documentation recommends “that you set the process-default DPI awareness via application manifest, not an API call” and to see here for an example. But the API call works just fine.

Ideally, your application should then take the DPI into account.

Fixing error from implicit declaration of SetProcessDPIAware §

Despite including windows.h, you may still get an error indicating that SetProcessDPIAware() has not been declared:

C:\my_path\my_file.c:19:5: error: implicit declaration of function 'SetProcessDPIAware' [-Wimplicit-function-declaration]
   19 |     SetProcessDPIAware();
      |     ^~~~~~~~~~~~~~~~~~

This can happen because SetProcessDPIAware() requires that WINVER or _WIN32_WINNT is set, which indicates the version of Windows you are targeting, and therefore impacts which functions are declared by windows.h.

In my case, I was building with Mingw-w64, which contains this snippet in mingw64/x86_64-w64-mingw32/include/sdkddkver.h:

/* Choose WINVER Value */
#ifndef WINVER
#ifdef _WIN32_WINNT
#define WINVER _WIN32_WINNT
#else
#define WINVER 0x0502
#endif
#endif

If WINVER and _WIN32_WINNT are undefined, Mingw-w64 defaults to 0x0502.

The supported values for WINVER / _WIN32_WINNT are as follows:

//
// _WIN32_WINNT version constants
//
#define _WIN32_WINNT_NT4                    0x0400 // Windows NT 4.0
#define _WIN32_WINNT_WIN2K                  0x0500 // Windows 2000
#define _WIN32_WINNT_WINXP                  0x0501 // Windows XP
#define _WIN32_WINNT_WS03                   0x0502 // Windows Server 2003
#define _WIN32_WINNT_WIN6                   0x0600 // Windows Vista
#define _WIN32_WINNT_VISTA                  0x0600 // Windows Vista
#define _WIN32_WINNT_WS08                   0x0600 // Windows Server 2008
#define _WIN32_WINNT_LONGHORN               0x0600 // Windows Vista
#define _WIN32_WINNT_WIN7                   0x0601 // Windows 7
#define _WIN32_WINNT_WIN8                   0x0602 // Windows 8
#define _WIN32_WINNT_WINBLUE                0x0603 // Windows 8.1
#define _WIN32_WINNT_WINTHRESHOLD           0x0A00 // Windows 10
#define _WIN32_WINNT_WIN10                  0x0A00 // Windows 10
// . . .

The function SetProcessDPIAware() was introduced in Windows Vista, so we must use a value of at least 0x0600. Since Mingw-w64 defaults to 0x0502, i.e Windows Server 2003, the function SetProcessDPIAware() is not declared.

Good solution §

In my case, the fix is to update my top-level CMakeLists.txt to define WINVER and _WIN32_WINNT as 0x0600, i.e. Windows Vista:

if (WIN32)
    add_compile_definitions(WINVER=0x0600 _WIN32_WINNT=0x0600)
endif()

Bad solution §

In contrast, the following is not a good idea because it may be attempting to redefine the values. It is unwise to change the value of WINVER part way through a build, whereas defining them in CMake ensures they are set at the start of the build:

#ifdef _WIN32
#define WINVER 0x0600
#define _WIN32_WINNT 0x0600
#include <windows.h>
#endif