High DPI Rendering

In this article I’ll explain how to handle “High-DPI” in games, specifically referring to the relevant Windows APIs, some Apple APIs, and the SDL library. I’ll also explain what it means to write a “DPI aware” app in layperson’s terms, which might be helpful regardless of whether you’re using SDL or not. Finally, I’ll show how you can use DPI scale in typical graphics applications, and briefly look at some advanced DXGI features for compositing scaled layers of rendering.

Background

With the increase of resolution of computer monitors, we’re seeing more and more so-called “high dpi” or “retina” displays. What this means is that the monitor has a high number of “dots per inch”, where “dot” effectively means pixel. For example, the screen of a Microsoft Surface Pro 4 is probably around the same size as an older laptop you’ve owned (12.3 inches), yet it has a resolution of 2736 by 1824, which is probably much more than that older laptop.

In other words, the number of pixels has increased, while the dimensions of the monitor have stayed the same. What does this mean for graphics development? Well, it means that your visuals have shrunk considerably. You might be assuming 96 pixels per inch, which is Windows’ default, meaning that 640 pixels are about 6.7 inches wide. On the other hand, with 267 pixels per inch (on Surface Pro 4), your 640 pixels are about 2.4 inches wide, and the text in your UI has probably become small enough to cause eyestrain just from reading it.

So, how do we address this issue? The answer is so-called “DPI awareness”. However, to understand what that means, we have to realize that the word “DPI” has become unfortunately overloaded, leading to much confusion.

High DPI: The Truths and the Lies

Let me give it to you straight: When people say “DPI”, they might mean one of two things:

  1. The number of pixels that physically exist per inch on your monitor.
  2. The display zoom set in the Windows control panel.

When it comes to software development, you really only have to care about the second meaning, which is mostly unrelated to the density of pixels on your specific monitor. You can tweak this display zoom by opening the control panel’s “Display Settings”, and playing with the slider shown below:

displaydpi

The purpose of this slider is to zoom in all apps on your computer, to make it easier to read text and click on buttons by countering the shrinking effect described earlier in this article. Now here’s the confusing part: This display zoom is a percentage, not a number of dots per inch. Again, this zoom factor isn’t about the number of pixels on your screen, it’s simply a display zoom as a percentage, and nothing more.

Reading and Interpreting the Display Zoom

Despite “DPI” being just a zoom factor, if you ask Windows for the DPI using eg. GetDpiForWindow(), you’ll get a number in dots per inch in return. You can get the display zoom that corresponds to this number by dividing it by “96”, which is the default DPI in Windows. For example, if I set the display zoom in control panel to 200%, then GetDpiForWindow() will return 192. If I divide 192 by 96, that gives me “2.0”, which is the display zoom (corresponding to 200%). As a result, I know that I should scale my UI by 2x.

Your App’s “DPI Awareness”

Every app in Windows has a DPI awareness level. This was done for backwards compatibility for apps that were written before DPI was considered. If an app is not DPI aware, that means Windows will automatically scale up the app, which it does by effectively lying to your app about its size in pixels on the display. That means you might ask for a window that is 1000 pixels wide, but get one that is actually 2000 pixels wide. Your app will happily render at 1000 pixels wide, then Windows will automatically scale up your rendering to 2000 pixels.

On the other hand, if your app is DPI-aware, Windows will not lie to it about its size in pixels. If Windows says your app is 1000 pixels wide, then it’s 1000 pixels wide, period. It’s business as usual, with the only added detail that you should be trying to scale up your rendering to match the display zoom that the user requested in the control panel.

To be fully DPI aware, you should also act in response to DPI changes at runtime, which might happen if the user tweaks the slider in control panel while your program is running, or if the user drags your window from one monitor to another monitor that has a different display zoom setting.

Handling DPI in SDL

There are two main things you need to do to handle DPI in SDL. First, you should mark your app as “DPI aware”, and second, you should read the current display zoom to and interpret it in your renderer.

Setting DPI Awareness

There are 2 main ways to set DPI awareness: Using the manifest/Info.plist (for Windows and Apple, respectively), or by setting DPI awareness programmatically.

Setting the DPI awareness through a Visual Studio project is extremely easy. Open your project properties by right-clicking on your project in your solution and picking “properties”, then go to the menu shown below and set the “DPI Awareness” flag.

dpivs

For Apple devices, you set the NSHighResolutionCapable flag in your app’s Info.plist file, as shown below. (Image from Apple’s documentation on the topic.)

If you want to set the DPI awareness programmatically, I have some bad news for you. It’s currently possible to set DPI awareness programmatically on Windows, but not for Apple. If that’s not a deal breaker for you, you can set DPI awareness using “SetProcessDpiAwareness()”, which you can call by including the header <ShellScalingAPI.h> and linking “Shcore.lib” to your project. Note that this call must be the first Window management-related call in your program, so you should probably call this at the very top of your main. The following code snippet might be helpful. You can wrap it in a function in a separate file if you want to contain the symbol pollution caused by Windows.h.

#include <Windows.h>
#include <ShellScalingAPI.h>
#include <comdef.h>

// SDL wants main to have this exact signature
extern "C" int main(int argc, char* argv[])
{
    HRESULT hr = SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE);
    if (FAILED(hr))
    {
        _com_error err(hr);
        fwprintf(stderr, L"SetProcessDpiAwareness: %s\n", err.ErrorMessage());
    }

    if (SDL_Init(SDL_INIT_EVERYTHING))
    {
        fprintf(stderr, "SDL_Init: %s\n", SDL_GetError());
        exit(1);
    }

    // TODO: Create your window and stuff
}

Furthermore, if you’re using SDL, you should pass the SDL_WINDOW_ALLOW_HIGHDPI flag to SDL_CreateWindow, which lets you use SDL’s APIs for managing DPI. (See documentation for SDL_CreateWindow).

It’s kind of unfortunate that DPI can’t be set programmatically on Apple devices, since it makes it harder to write cross-platform apps with SDL, requiring instead that custom manifests are built per platform. You should probably create proper per-platform manifests if you’re shipping a production app, but this extra work hurts productivity for small cross-platform projects. If somebody from Apple is reading this, it would be sweet if you could do something about it. 😉

Reading the Display Zoom

SDL has a function to read the current display DPI (which again, is the display zoom and nothing more.) This function is SDL_GetDisplayDPI. It reports a horizontal, vertical, and diagonal DPI. Windows guarantees the horizontal and vertical DPI are always the same, so you can basically ignore everything but the horizontal DPI.

There’s still something missing though. Again, we don’t actually want the DPI itself, we want the display zoom as a percentage (or a ratio). To do this, we have to get the DPI using SDL, then divide it by the default DPI. Unfortunately, SDL has no function to get the default DPI, and the default DPI is actually different depending on your OS. Windows’ default DPI is 96, and Apple’s default DPI is 72. For this reason, I wrap SDL_GetDisplayDPI in my own function that also returns the default DPI. Also, SDL_GetDisplayDPI can fail if DPI information is not available, so I handle that by returning the default DPI. This is implemented as follows:

void MySDL_GetDisplayDPI(int displayIndex, float* dpi, float* defaultDpi)
{
    const float kSysDefaultDpi =
#ifdef __APPLE__
        72.0f;
#elif defined(_WIN32)
        96.0f;
#else
        static_assert(false, "No system default DPI set for this platform.");
#endif

    if (SDL_GetDisplayDPI(displayIndex, NULL, dpi, NULL) != 0)
    {
        // Failed to get DPI, so just return the default value.
        if (dpi) *dpi = kSysDefaultDpi;
    }

    if (defaultDpi) *defaultDpi = kSysDefaultDpi;
}

Display Zoom for Window Creation

If you ask DPI-aware SDL to create a window that is sized of 640 by 480, you’ll still get a tiny window as I explained at the start of the article. Maybe that’s what you want, since it’s actually doing exactly what you asked. On the other hand, I personally like having an interface where I can ask for 640 by 480 window, and it gives me a window that has the same size in inches that a 640 by 480 window would have on a screen with a default DPI.

This is mainly just convenient for me, since I’ve built up certain expectations about how big a 640 by 480 or a 1280 by 720 window should be on my screen. If you want to do this, you can get the display zoom of the monitor your window is on, then scale up your window’s size based on the monitor’s display zoom. If you assume the window will be created on the primary monitor, you might use something like the following to create your window:

// Scale window according to DPI zoom
int windowDpiScaledWidth, windowDpiScaledHeight;
{
    int windowDpiUnscaledWidth = 640;
    int windowDpiUnscaledHeight = 480;

    float dpi, defaultDpi;
    MySDL_GetDisplayDPI(0, &dpi, &defaultDpi);

    windowDpiScaledWidth = int(windowDpiUnscaledWidth * dpi / defaultDpi);
    windowDpiScaledHeight = int(windowDpiUnscaledHeight * dpi / defaultDpi);
}

SDL_Window* window = SDL_CreateWindow(
    "dpi test",
    SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
    windowDpiScaledWidth, windowDpiScaledHeight,
    SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI);

By the way, allow me to repeat that the DPI might change at runtime if the user plays with the display zoom in the control panel, or if the user drags the window from one monitor to another monitor that has a different display zoom setting. For this reason, you might want to poll the DisplayDPI at every frame and resize your window in response. This is kind of difficult to do properly though, since switching the size of the window might make it suddenly pop onto another monitor and cause some kind of infinite loop, who knows.

The “proper” way to handle DPI changes at runtime is by responding to the Windows event WM_DPICHANGED, which gives you both the new DPI and a suggested new position and size for the window. Last I checked, SDL doesn’t handle WM_DPICHANGED, which is unfortunate, and would be a good thing to patch in.

Using Display Zoom in a Renderer

After marking your app as DPI aware, you need to scale up your rendering according to the display zoom. This section explains how to apply this display zoom to typical graphics scenarios.

Display Zoom for UI

If you have some kind of UI in your app, you probably want to scale that up based on the display zoom, since the user will have trouble reading any text otherwise. You might also want to create multiple versions of the artwork in your UI at different scales, to avoid the artifacts that come from automatic scaling algorithms. The goal here is to give the UI a sharp and crisp look at any display zoom, since blurry UIs tend to look pretty bad.

If your UI is designed to have a “pixely” look, like for example ImGui, then scaling will generally look bad unless you round to an integer scale, so keep that in mind as you decide how to interpret the display zoom.

On the topic of ImGui, it sorta has DPI awareness built-in through the “io.DisplayFramebufferScale” value. This value isn’t actually interpreted at all by ImGui, it’s basically just a way for you to pass your desired DPI scale from your window management code to your rendering code. ImGui itself (and its mouse handling code) assumes it lives in a world where scaling doesn’t exist, so even if you scale up the UI in your rendering code, you still need to “unscale” mouse coordinates before passing them to ImGui. Therefore, your code to pass mouse coordinates to ImGui might look as follows:

io.MousePos.x = actualMouseX;
io.MousePos.y = actualMouseY;
// Scale according to framebuffer display scale
io.MousePos.x /= io.DisplayFramebufferScale.x;
io.MousePos.y /= io.DisplayFramebufferScale.y;

Display Zoom for Scene Rendering

For the best look possible, you can ignore that the monitor has a high DPI and just render your scene at the same pixel resolution as the window it’s being displayed in. This only works if your app is DPI aware, since otherwise the OS will be automatically scaling up your app and you’ll see scaling artifacts anyways. If rendering at a 1-to-1 resolution is too expensive, as is likely with any heavy rendering work on a 4K display, you might want to render your scene at a low resolution and do the scaling yourself. This might improve your frame rate, or save battery life. You might do this framebuffer scaling using glBlitFramebuffer, as shown in a side-tip in my article on Reversed-Z in OpenGL.

While it can be important to lower the number of pixels rendered on high DPI displays for performance reasons, recall that’s not what the “display zoom” slider is about. The display zoom slider is supposed to scale the visuals in the program to make them easier to see. If your program responds to display zoom changes by increasing the size of its window, that’s enough to cause your scene to occupy a greater number of square inches on the user’s screen, so mission accomplished. This is independent from whether or not you decide to do your rendering at a lower resolution than the number of pixels on the screen. If you want to scale your rendering without resizing the window, then you might want a camera zoom feature in your renderer that is independent of the display zoom slider. You might think of ways to incorporate the display zoom into your 3D camera’s logic, but that’s a topic of user interface design, and should probably be handled on a case-by-case basis.

Composing Scaled Layers in Hardware

Direct3D 11.2 allows you to compose layers at different resolutions in hardware, as documented here: Swap chain scaling and overlays (MSDN). For example, you can render a high resolution UI and a low resolution scene in separate layers, then rely on the hardware to compose these two layers together rather than doing it yourself. This might be a more efficient way to handle DPI scaling in a rendering engine, although it relies on hardware features.

I’m not sure if there’s a way to use hardware overlays in OpenGL, at least on desktop. wgl has functions for overlay layers, but they’re old and busted and have been deprecated for a long time. This might be another good reason to use WGL_NV_DX_interop2 to do your OpenGL rendering within a DXGI swap chain on Windows, since that allows you to use modern Windows presentation features without having to rewrite your OpenGL code. Although that extension is marked “NV”, it is now supported by all desktop GPU vendors.

Unfortunately, it’s not clear how to use WGL_NV_DX_interop2 properly from its documentation. I have some quick tests here if you’re interested: OpenGL-on-DXGI (GitHub). If you’re part of Microsoft or Khronos or one of the IHVs, your help might be needed here. 🙂

Conclusion

Hopefully now you know more about writing DPI aware apps, especially using Windows and/or SDL. Having a sharp resolution on high DPI screens tends to look really good, so I hope this article helps get you there. Thank you for reading!

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s