Insights
Technical Deep-Dive Jul 2021

Multi-Monitor DPI Scaling in WPF: What We Learned

A detailed breakdown of the per-monitor DPI challenges we solved building an enterprise RDP client - and the architectural decisions that made it work.

The Problem

When we started building a multi-monitor RDP client for an enterprise client, we assumed DPI scaling would be one of the easier parts. It wasn't.

WPF has had DPI awareness for years, but per-monitor DPI - where each display can have its own scale factor - is a different beast. The moment a window moves from a 100% monitor to a 150% monitor, you're dealing with coordinate translation, font rendering and bitmap scaling all at once.

What Per-Monitor DPI Actually Means

Windows distinguishes between three DPI awareness modes:

  • Unaware - the app thinks it's always at 96 DPI. Windows handles scaling by bitmap-stretching the window. Result: blurry text.
  • System DPI aware - the app is told the primary monitor's DPI at startup and renders accordingly. Works until the user drags the window to a different monitor.
  • Per-Monitor DPI aware (PMv2) - the app receives a WM_DPICHANGED message whenever the window's effective DPI changes and is responsible for re-rendering at the new scale.

PMv2 is what we needed. It's also where WPF starts to fight you.

The WPF Complication

WPF's layout system uses device-independent pixels (DIPs). One DIP is always 1/96th of an inch in WPF's model. This is great for most layouts, but it means WPF's internal coordinate system is decoupled from the physical pixel grid.

When you're in PMv2 mode and the DPI changes, WPF does update its rendering - but not uniformly. Some elements reflow correctly. Others - particularly DrawingVisual objects, hardware-accelerated layers and anything touching HwndHost - do not.

Our RDP client used HwndHost to embed the remote desktop surface. HwndHost is Win32 under the hood and Win32 window handles have their own DPI context. When the outer WPF window moved monitors, the HwndHost child didn't follow.

The Fix

The solution involved three pieces:

1. Manifest-level PMv2 declaration

The app manifest needs dpiAwareness set to PerMonitorV2. Without this, you can call SetProcessDpiAwarenessContext programmatically, but it only works before the first window is created - easy to get wrong in WPF's startup flow.

2. Intercept WM_DPICHANGED before WPF sees it

We overrode HwndSourceHook to intercept the WM_DPICHANGED message. When it fires, we extract the suggested rect from lParam and explicitly reposition and resize both the outer window and the embedded HwndHost child.

private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
    const int WM_DPICHANGED = 0x02E0;
    if (msg == WM_DPICHANGED)
    {
        var newDpi = (wParam.ToInt32() & 0xFFFF); // lo-word = X DPI
        var rect = Marshal.PtrToStructure<RECT>(lParam);
        OnDpiChanged(newDpi, rect);
        handled = true;
    }
    return IntPtr.Zero;
}

3. Scale the RDP session resolution

After a DPI change, we recalculated the remote session resolution to match the new physical pixel dimensions. Sending a DesktopResize to the RDP server ensured the remote side re-rendered at the correct resolution - preventing the double-scaling artefact where WPF scales an already-scaled bitmap.

What We'd Do Differently

If starting this today, we'd evaluate moving the RDP surface out of HwndHost entirely and into a DirectX swap chain surfaced through D3DImage. It gives you direct control over the pixel buffer and sidesteps WPF's DPI remapping. The tradeoff is significantly more code to manage the swap chain lifecycle.

For most enterprise desktop apps that don't need pixel-perfect remote rendering, PMv2 + WM_DPICHANGED interception is the right level of complexity. For an RDP client, the extra fidelity from a native surface is worth considering.

Takeaway

Per-monitor DPI in WPF is solvable, but it requires getting low enough into Win32 that WPF's abstractions stop being useful. The key insight is that HwndHost children live in a different DPI world from their WPF parents - and you have to bridge that gap explicitly.