Pixel Density and High-DPI Displays

Learn how to create SDL3 applications that look great on modern displays across different platforms

Ryan McCombe
Updated

Modern displays come in various resolutions and pixel densities. In this lesson, we'll learn how to create SDL applications that look great regardless of the display being used. We'll explore DPI awareness, scaling techniques, and cross-platform considerations.

The relationship between pixel dimensions and physical dimensions is usually represented in terms of pixels per inch, or equivalently, dots per inch (DPI).

A higher pixel density (that is, higher DPI) is desirable as it allows graphics to be more detailed. The following image shows a close-up photo of the battery indicator on a phone using a 163DPI screen, compared to the same design rendered on a 326DPI display:

This lesson continues to use the Window class we created previously:

src/Window.h

#pragma once
#include <SDL3/SDL.h>

class Window {
public:
  Window() {
    SDLWindow = SDL_CreateWindow(
      "Window", 700, 200,
      SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_RESIZABLE
    );
  }

  SDL_Window* GetRaw() const {
    return SDLWindow;
  }

  void Render() {
    const auto* Fmt = SDL_GetPixelFormatDetails(
      GetSurface()->format
    );
    SDL_FillSurfaceRect(
      GetSurface(),
      nullptr,
      SDL_MapRGB(Fmt, nullptr, 50, 50, 50)
    );
  }

  void Update() {
    SDL_UpdateWindowSurface(SDLWindow);
  }

  SDL_Surface* GetSurface() const {
    return SDL_GetWindowSurface(SDLWindow);
  }

  Window(const Window&) = delete;
  Window& operator=(const Window&) = delete;

  ~Window() {
    if (SDLWindow && SDL_WasInit(SDL_INIT_VIDEO)) {
      SDL_DestroyWindow(SDLWindow);
    }
  }

private:
  SDL_Window* SDLWindow{nullptr};
};

DPI Awareness

As higher DPI displays became increasingly common, platforms like Windows and macOS faced a problem: older applications designed for low-DPI screens appeared physically smaller on high-DPI displays.

To solve this, operating systems introduced DPI scaling. They assume that older applications are not "DPI-aware" and automatically scale up their windows and content to maintain a consistent physical size. For example, on a display with 200% scaling, a DPI-unaware 800x600 window is rendered by the OS into a 1600x1200 pixel area.

This automatic scaling makes old apps usable, but it often results in blurry or pixelated graphics because it's just stretching a low-resolution image.

To differentiate between the size before and after this scaling, two units are used:

  • Screen coordinates (or points) are the logical units an application works with.
  • Pixels are the physical dots on the display.

A major improvement in SDL3 is that it is DPI-aware by default. Unlike SDL2, you no longer need to set special hints like SDL_HINT_WINDOWS_DPI_SCALING to opt-in. SDL3 applications automatically understand and work with the native pixel density of the display.

To ensure your window can take full advantage of high-DPI capabilities, you can use the SDL_WINDOW_HIGH_PIXEL_DENSITY flag when creating it. On many platforms, this is now the default behavior, but explicitly setting it ensures cross-platform consistency.

SDL_Window* Window = SDL_CreateWindow(
  "My Game",
  800, 600,
  SDL_WINDOW_HIGH_PIXEL_DENSITY
);

Working with Pixel Sizes and Scaling

To take advantage of higher quality screens, we first need to understand what pixel density we're working with. SDL3 provides new tools to make this straightforward.

Getting the Display Scale

The most direct way to handle DPI is to get the display's scaling factor. The SDL_GetWindowDisplayScale() function returns this as a float.

  • A value of 1.0f means no scaling (e.g., a standard 96 DPI display on Windows).
  • A value of 2.0f means the display has double the pixel density (a "Retina" display).
  • Other values like 1.25f or 1.5f represent intermediate scaling levels.

src/main.cpp

#include <SDL3/SDL.h>
#include <iostream>
#include "Window.h"

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);
  Window GameWindow;

  float RenderScale{SDL_GetWindowDisplayScale(
    GameWindow.GetRaw()
  )};
  std::cout << "Render Scale: " << RenderScale;
  
  SDL_Event E;
  bool isRunning{true};
  while (isRunning) {
    while (SDL_PollEvent(&E)) {
      if (E.type == SDL_EVENT_QUIT) {
        isRunning = false;
      }
    }
    GameWindow.Render();
    GameWindow.Update();
  }
  
  SDL_Quit();
  return 0;
}
Render Scale: 1.25

You can use this scale factor to convert between logical screen coordinates and physical pixels:

Pixel Size=Screen Coordinates×Scale \text{Pixel Size} = \text{Screen Coordinates} \times \text{Scale}

Retrieving Pixel Size Directly

If you need the exact pixel dimensions of the window's drawable area (the surface), you can use SDL_GetWindowSizeInPixels(). This is useful when you need to work directly with pixel buffers or configure graphics APIs like OpenGL or Vulkan.

Depending on the platform and your settings, the value returned from SDL_GetWindowSizeInPixels() may be different from that returned by SDL_GetWindowSize().

This is just based on what units that platform uses to represent window sizes. On Windows, for example, these values will be the same, but they will likely be different on macOS:

src/main.cpp

#include <SDL3/SDL.h>
#include <iostream>
#include "Window.h"

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);
  Window GameWindow;

  int w1, h1;
  SDL_GetWindowSize(
    GameWindow.GetRaw(), &w1, &h1
  );

  int w2, h2;
  SDL_GetWindowSizeInPixels(
    GameWindow.GetRaw(), &w2, &h2
  );

  std::cout << "Screen Size: " << w1 << "x" << h1
    << ", Pixel Size: " << w2 << "x" << h2;

  SDL_Event E;
  bool isRunning{true};
  while (isRunning) {
    while (SDL_PollEvent(&E)) {
      if (E.type == SDL_EVENT_QUIT) {
        isRunning = false;
      }
    }
    GameWindow.Render();
    GameWindow.Update();
  }
  
  SDL_Quit();
  return 0;
}
Screen Size: 700x200, Pixel Size: 700x200

Window Display Scale vs Display Content Scale

We've introduced SDL_GetWindowDisplayScale() as the primary way of working with varying pixel-densities, but you might notice another, similar-sounding function: SDL_GetDisplayContentScale(). You should almost always use SDL_GetWindowDisplayScale(), but this section briefly explains the differences.

The existence of two similar functions is a result of SDL3 abstracting away major differences in how operating systems handle high-DPI scaling.

Windows, Android, and X11 (Linux)

On these platforms, window coordinates are typically treated as physical pixels. A call to SDL_CreateWindow() for an 800x600 window creates an 800x600 pixel window.

The SDL_GetDisplayContentScale() will be greater than 1.0 (e.g., 1.5 for 150% scaling). On these systems, SDL_GetWindowDisplayScale() will usually return the same value as SDL_GetDisplayContentScale().

macOS, iOS, and Wayland (Linux)

On these platforms, window coordinates are logical "points". An 800x600 window is 800x600 points. If the window has the SDL_WINDOW_HIGH_PIXEL_DENSITY flag on a high-DPI (Retina) display, the operating system gives it a backing framebuffer with more pixels (e.g., 1600x1200).

On these systems, SDL_GetDisplayContentScale() is usually 1.0, and the scaling is communicated through the window's pixel density - SDL_GetWindowPixelDensity().

Comparing Functions

The recommended SDL_GetWindowDisplayScale() function provides the correct, unified scaling factor regardless of the underlying platform's model. It correctly combines the pixel density and the content scale to give you the single number you care most about: "By what factor do I multiply my logical sizes to get the correct pixel sizes for rendering?"

The official SDL documentation provides more information on these functions, as well as a numeric example for a 4K (3840x2160) monitor with 200% scaling. The table below illustrates how SDL3 unifies the different platform approaches:

ValuemacOS (High-DPI)Windows (200% Scale)
SDL_GetWindowSize()1920x10803840x2160
SDL_GetWindowSizeInPixels()3840x21603840x2160
SDL_GetDisplayContentScale()1.0f2.0f
SDL_GetWindowPixelDensity()2.0f1.0f
SDL_GetWindowDisplayScale()2.0f2.0f

Notice how, despite the underlying differences, SDL_GetWindowDisplayScale() returns the correct 2.0f multiplier on both platforms.

This makes it the most reliable choice for cross-platform code, although we should of course test our program on all platforms we're releasing on.

Creating DPI-Aware Graphics

Once we know what scaling factor is being applied, any component that can benefit from high-DPI displays can react to that value. A component that loads images, for example, might select to use higher-resolution images if the application is running on a high-DPI display.

To set this up, we simply make the scaling factor available to any component in our application that might want it. The following Render() function receives not just the SDL_Surface to render on, but also the DPI scaling factor that is currently in effect:

Files

src
Select a file to view its content

Reacting to DPI Changes

The DPI scaling factor is not constant. It can change during your application's lifetime, most commonly when the user drags the window from one display to another with a different pixel density.

If we don't react to these changes then, depending on the platform, our graphics may no longer be rendering in the way we intended:

To create a seamless experience, our application needs to detect and respond to these changes. SDL3 provides two main events for this:

  • SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: This event is sent when the scale factor of the display the window is on changes. That is, after this event, we'd expect SDL_GetWindowDisplayScale() to return a different value.
  • SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: This fires whenever the backing pixel buffer of the window changes size. This happens as a result of regular window resizing, but it can also happen as a result of DPI changes - ie, where SDL_GetWindowPixelDensity() would return a new value. After a window pixel size change event, we'd expect SDL_GetWindowSizeInPixels() to calculate different values, but not necessarily SDL_GetWindowSize().

We can listen for one or both of these events in our main loop and have our components react as appropriate based on the type of content they're rendering.

Below, we recalculate our RenderScale whenever either changes, which influences how our Rectangle is scaled:

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <iostream>
#include "Window.h"
#include "Rectangle.h"

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);
  Window GameWindow;
  Rectangle Rect;

  float RenderScale{SDL_GetWindowDisplayScale(
    GameWindow.GetRaw()
  )};
  
  SDL_Event E;
  bool isRunning{true};
  while (isRunning) {
    while (SDL_PollEvent(&E)) {
      if (E.type == SDL_EVENT_QUIT) {
        isRunning = false;
      }

      if (E.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED ||
          E.type == SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED) {
        RenderScale = SDL_GetWindowDisplayScale(
          GameWindow.GetRaw()
        );
        std::cout << "DPI Scale changed to: "
          << RenderScale << '\n';
      }
    }
    GameWindow.Render();
    Rect.Render(GameWindow.GetSurface(), RenderScale);
    GameWindow.Update();
  }
  
  SDL_Quit();
  return 0;
}

Summary

In this lesson, we've explored how to create SDL3 applications that work across different display resolutions and pixel densities. We've learned about DPI awareness, scaling techniques, and how to handle resolution changes dynamically. Key takeaways:

  • DPI awareness is enabled by default in SDL3, but using the SDL_WINDOW_HIGH_PIXEL_DENSITY flag is good practice for cross-platform consistency.
  • Use SDL_GetWindowDisplayScale() to get the current display's scaling factor.
  • SDL_GetWindowSizeInPixels() provides the exact pixel dimensions of the window's drawable surface.
  • Scale graphics at render time based on the current DPI scaling factor to maintain physical size.
  • Monitor window events like SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED and SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED to handle DPI changes dynamically, such as when a window is moved between monitors.
Have a question about this lesson?
Answers are generated by AI models and may not be accurate