A detailed breakdown of the per-monitor DPI challenges we solved building an enterprise RDP client - and the architectural decisions that made it work.
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.
Windows distinguishes between three DPI awareness modes:
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.
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 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.
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.
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.