Le support du haut DPI dans WPF — pourquoi l'affichage reste flou et baveux malgré une application « censée résister au DPI », et comment y remédier

· Mis à jour le: · · WPF, DPI élevé, Windows, .NET, C#, .NET Framework, XAML, UI, Conseil technique

« Avec WPF, on ne devrait rien avoir à faire pour le DPI, non ? » — c’est une question qu’on nous pose souvent lors de consultations liées au haut DPI. La réponse est à moitié vraie, à moitié fausse. Les symptômes réellement rencontrés ressemblent à ceci : « Sur le PC portable seul, c’est net, mais dès qu’on le branche au moniteur externe de la salle de réunion, tout l’écran devient flou et bave. » « Le texte est net, mais seules les icônes de la barre d’outils sont floues. » « Les bordures d’une liste sont plus épaisses ou disparaissent selon l’endroit. » « Seul le vieux contrôle d’état intégré à l’écran apparaît plus petit que le reste. » Tous ces cas se produisent réellement dans des applications WPF « censées résister au DPI ».

L’article publié la veille, « Le support du haut DPI dans WinForms », détaillait le fonctionnement de la mise à l’échelle DPI de Windows, les modes de reconnaissance du DPI (Unaware / System Aware / Per-Monitor V2), et la façon de sauver une application WinForms héritée de l’époque du codage en dur à 96 DPI. Cet article en est le pendant pour WPF. Je laisse la théorie générale de la virtualisation DPI et des modes de reconnaissance DPI à l’article WinForms, et me concentre ici sur ce qui est propre à WPF : pourquoi, et où, des problèmes subsistent dans un framework censé être compatible DPI dès le départ. J’aborde les sujets dans l’ordre où on les rencontre en pratique : diagnostic des symptômes, comment déclarer le support Per-Monitor DPI (respectivement sous .NET Framework 4.6.2 et sous .NET), les correctifs contre le flou des traits fins et des bitmaps, les pièges du contenu mixte avec WindowsFormsHost, et enfin un tableau de décision pour savoir jusqu’où aller.

1. La conclusion d’abord

  • WPF suit correctement le « DPI au démarrage » dès le départ. Son unité de mise en page est le DIP (unité indépendante du périphérique, 1/96 de pouce), et le moteur de rendu s’adapte automatiquement au DPI système : WPF est donc System DPI Aware par défaut. Il n’y a, contrairement à WinForms, aucun réglage ni vérification de type AutoScaleMode à faire.1
  • Les problèmes qui subsistent malgré tout se répartissent en 4 catégories : (a) l’affichage qui bave dans son ensemble quand on déplace la fenêtre vers un moniteur au DPI différent, (b) le flou des images bitmap et des icônes, (c) le flou des traits et bordures, et (d) le contenu mixte comme WindowsFormsHost / WebBrowser. La cause et le remède diffèrent à chaque fois : commencez donc par utiliser le tableau du chapitre 3 pour poser le diagnostic.
  • Le vrai remède pour (a) est le support Per-Monitor DPI. WPF sous .NET Framework 4.6.2 et ultérieur le prend en charge au niveau du framework : il suffit de le déclarer dans le manifeste pour que le redimensionnement de la fenêtre lui-même se fasse automatiquement.23
  • WPF sous .NET (Core 3.1 à .NET 8) reste lui aussi System Aware par défaut. Il n’existe dans WPF aucun équivalent au ApplicationHighDpiMode / SetHighDpiMode de WinForms ; la déclaration se fait de la même façon que sous .NET Framework, via le manifeste.
  • (c) est en réalité un problème de positionnement en sous-pixel, antérieur au DPI. La première mesure consiste à mettre UseLayoutRounding="True" sur l’élément racine ; SnapsToDevicePixels est un outil distinct qui agit par un alignement au moment du rendu. Les deux sont désactivés par défaut.45
  • Pour (b), les ressources vectorielles comme Path / Geometry doivent être le premier choix. Pour les ressources qui n’existent qu’en bitmap, prévoyez plusieurs résolutions et commutez selon le DPI. L’interpolation par défaut de WPF (Linear) est ce qui rend le plus flou les petites images comme les icônes, en particulier aux échelles non entières.6
  • Le contenu mixte évoqué en (d) est ce qui fixe le plafond atteignable par le support Per-Monitor. En particulier, le comportement Per-Monitor d’un WPF « hébergé » dans ElementHost / HwndSource est explicitement documenté comme non pris en charge.3
  • La théorie générale des modes de reconnaissance DPI (virtualisation DPI, différence entre System Aware et Per-Monitor V2, la possibilité pour l’utilisateur de la remplacer depuis les propriétés de l’exe) et la manière de construire un environnement de test à DPI mixte sont communes aux chapitres 2 à 3 et 7 de l’article WinForms ; je ne les répète donc pas ici.

2. Pourquoi WPF est « résistant au DPI » — DIP et System DPI Aware

La racine du problème de haut DPI dans WinForms tenait au fait que coordonnées et tailles sont codées en dur en pixels physiques, avec un mécanisme (AutoScale) ajouté après coup pour reconvertir à l’exécution une mise en page conçue en partant du principe de 96 DPI. WPF part d’un point de départ différent. Le 120 de Width="120" écrit en XAML n’est pas des pixels physiques : c’est un DIP (unité indépendante du périphérique), où 1 unité vaut 1/96 de pouce. Toute la mise en page est calculée dans cette unité, puis convertie au moment du rendu vers un facteur correspondant au DPI système (1,5 pour 150 %). Le texte, lui aussi, est dessiné comme une police vectorielle, donc son agrandissement n’entraîne aucune dégradation de type bitmap. Grâce à ce mécanisme, une application WPF fonctionne en System DPI Aware sans qu’il soit nécessaire de déclarer quoi que ce soit.1

Voici une comparaison avec WinForms.

  WinForms WPF
Unité de coordonnées/taille Pixels physiques DIP (1/96 de pouce)
Sans aucune déclaration Unaware (flou dû à l’agrandissement bitmap de l’OS) System Aware (net sur le moniteur principal)
Suivi du DPI au démarrage Recalcul par formulaire via AutoScaleMode. Réglage et vérification nécessaires Le moteur de rendu s’adapte automatiquement. Aucun réglage nécessaire
Casse typique en haut DPI Chevauchement/recadrage d’une mise en page à coordonnées fixes La mise en page ne se casse pas ; le problème se manifeste par du flou et des bavures
Incident causé par le concepteur Réécriture d’AutoScaleDimensions (un classique) En principe aucun (le XAML reste enregistré en DIP)

Comme le montre ce tableau, le travail qui occupait l’essentiel des efforts sous WinForms — « corriger une mise en page qui se casse », « éviter les incidents causés par le concepteur » — n’existe quasiment pas sous WPF. Dire que « WPF résiste bien au DPI » est donc une perception juste.

Cela dit, l’automatisme ne couvre que le périmètre de System Aware. System Aware signifie « s’aligner sur le DPI du moniteur principal au moment de la connexion » ; cela n’inclut pas le suivi (Per-Monitor) d’un environnement où chaque moniteur a un DPI différent. Par ailleurs, ce qui est agrandi en DIP n’est que ce que WPF dessine lui-même (texte, formes, contrôles) : les images bitmap deviennent floues quand elles sont agrandies, et le contenu mixte qui apporte un HWND se trouve en dehors de la mise à l’échelle de WPF. Les consultations du type « pourtant, ça devrait résister au DPI » viennent presque toujours de cette partie restante.

3. Les problèmes qui subsistent malgré tout — diagnostiquer la cause à partir du symptôme

Voici le tableau de diagnostic que nous utilisons en premier lorsqu’on nous consulte. Dans WPF, la correspondance entre symptôme et cause est encore plus nette que sous WinForms, si bien que ce seul tableau permet presque toujours d’identifier la cause.

Symptôme Cause Solution
Déplacer la fenêtre vers un moniteur au DPI différent la fait baver uniformément dans son ensemble. Revenir au moniteur d’origine règle le problème Toujours en System Aware. L’OS agrandit toute la fenêtre comme un bitmap Chapitre 4 (passage en Per-Monitor)
Ça bave juste après avoir changé le paramètre de mise à l’échelle, ou lors d’une connexion RDP depuis un client haut DPI Idem (le DPI système est figé au moment de la connexion) Chapitre 4
Le texte est net mais seules les icônes/images sont floues, ou trop petites Les ressources bitmap sont agrandies par interpolation Section 5.3
Les bordures/traits de 1px ont une épaisseur qui varie selon l’endroit, ou bavent légèrement Positionnement en sous-pixel et anticrénelage Section 5.1
Le petit texte semble baver même sur un moniteur à 100 % (96 DPI) Anticrénelage du mode de formatage de texte par défaut (Ideal) Section 5.2
Les images générées maison (WriteableBitmap, RenderTargetBitmap, etc.) sont floues La taille en pixels est calculée en supposant 96 DPI Chapitre 6
La position de la fenêtre, les coordonnées de la souris ou celles d’une capture d’écran sont décalées Confusion entre DIP et pixels physiques Chapitre 6
Seul le contenu à l’intérieur d’un WindowsFormsHost / WebBrowser / de certains contrôles tiers est petit, grossier ou cassé Contenu mixte (hors du périmètre de mise à l’échelle de WPF) Chapitre 7

Un mot sur la première ligne, « ça bave uniformément dans son ensemble ». C’est le résultat de la virtualisation DPI (l’agrandissement bitmap de l’OS), décrite au chapitre 2 de l’article WinForms, qui s’applique à une application System Aware : l’application elle-même n’est pas cassée. Une application System Aware dessine avec « le DPI du moniteur principal au moment de la connexion », et sur tout autre moniteur à DPI différent, l’OS agrandit ou réduit pour compenser. C’est une mesure de secours de l’OS : la mise en page ne se casse pas, mais elle bave à la place.1 Corriger cela signifie refuser ce secours et déclarer « je vais suivre moi-même le DPI de chaque moniteur » — autrement dit, passer en Per-Monitor.

À l’inverse, les symptômes des lignes 3 et suivantes peuvent être corrigés tout en restant System Aware. Pour une demande du type « nous n’utilisons pas plusieurs moniteurs, mais à 150 % les icônes et les bordures sont sales », il est tout à fait possible de sauter le chapitre 4 et de commencer directement au chapitre 5.

4. Le flou multi-moniteur — le support Per-Monitor DPI

4.1 Les limites de System Aware

Le DPI système est figé, au moment de la connexion, sur le DPI du moniteur principal. Si l’écran intégré à 150 % d’un portable est le moniteur principal, WPF dessine toutes les fenêtres à 1,5x, et lorsqu’on les déplace vers un moniteur externe à 100 %, l’OS les affiche réduites aux 2/3. Cette mise à l’échelle par l’OS paraît particulièrement floue lorsque le ratio n’est pas entier.1 On s’en rend compte en testant : une combinaison malcommode comme 125 % et 150 % dégrade visuellement plus le résultat qu’un écart net comme 200 % contre 100 %.

Autrement dit, ce qui pose problème avec un WPF resté System Aware se limite à l’usage où l’on va et vient entre des moniteurs de DPI différents, et aux situations où le paramètre de mise à l’échelle change après la connexion (le moniteur principal qui change quand on branche ou débranche une station d’accueil, le DPI du poste client qui s’importe via RDP, etc.). Pour une application interne utilisée par tout le monde sur un bureau fixe à un seul moniteur, rester en System Aware suffit largement en pratique. Nous reviendrons sur cet arbitrage dans le tableau du chapitre 8.

4.2 Comment le déclarer sous .NET Framework 4.6.2 et ultérieur

Le support Per-Monitor DPI de WPF est arrivé avec .NET Framework 4.6.2.2 Avant cela, même en déclarant Per-Monitor à l’OS, WPF lui-même ne suivait pas les changements de DPI, et il fallait écrire soi-même tout le redimensionnement de la fenêtre (c’est pourquoi les exemples de l’époque Windows 8.1 avaient une structure aussi lourde, reposant sur une DLL d’assistance native). Depuis 4.6.2, WPF traite lui-même WM_DPICHANGED et prend en charge automatiquement l’ajustement de la taille de la fenêtre, la remise en page et le nouveau rendu.

Il y a deux prérequis : un OS à partir de Windows 10 Anniversary Update (1607), et une compilation ciblant .NET Framework 4.6.2 ou ultérieur.3 La déclaration s’écrit dans le manifeste de l’application.

<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
  <asmv3:windowsSettings>
    <!-- Sur .NET Framework 4.6.2-4.7.2, déclarer PerMonitor seul (comme dans le guide développeur).
         Le support PerMonitorV2 de WPF nécessite .NET Framework 4.8 ou ultérieur,
         donc à partir de 4.8+ / .NET, mettre V2 en premier : "PerMonitorV2, PerMonitor" -->
    <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings"
      >PerMonitor</dpiAwareness>
    <!-- Pour les OS plus anciens qui ne reconnaissent pas dpiAwareness (repli sur System Aware) -->
    <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
  </asmv3:windowsSettings>
</asmv3:application>

Cette structure à deux niveaux a un sens. L’élément dpiAwareness est reconnu à partir de Windows 10 1607, et parmi les valeurs listées séparées par des virgules, la première reconnue est celle qui est utilisée.7 Sur un OS plus ancien qui ne connaît même pas l’élément dpiAwareness, le mécanisme retombe sur la déclaration dpiAware (System Aware).

Il faut ici faire attention à choisir entre PerMonitor et PerMonitorV2 en fonction du framework cible. Le support officiel de PerMonitorV2 (et du DPI en mode mixte) par WPF ne commence qu’à partir de .NET Framework 4.88, et même l’exemple du guide développeur pour 4.6.2 déclare PerMonitor seul.3 Si vous ciblez 4.6.2 à 4.7.2 et mettez V2 en premier, un OS à partir de Windows 10 1703 choisira le mode V2 que le framework ne prend pourtant pas en charge, ce qui provoque des comportements inattendus, en particulier autour du contenu mixte comme WindowsFormsHost. Notre recommandation est de d’abord monter en version vers 4.8 ou ultérieur (ou vers WPF sous .NET), puis de mettre V2 en premier avec PerMonitorV2, PerMonitor, en laissant l’OS gérer la mise à l’échelle des zones hors client comme la barre de titre ou les barres de défilement (voir le chapitre 3 de l’article WinForms).

Il existe un piège lié au framework cible. Même si le .NET Framework installé sur la machine d’exécution est en 4.6.2 ou ultérieur, le suivi Per-Monitor reste désactivé par défaut si le projet cible toujours 4.6.1 ou antérieur. Dans ce cas, activez-le explicitement via un commutateur AppContext dans app.config.3

<configuration>
  <runtime>
    <!-- Attention à la double négation : mettre « ne pas mettre à l'échelle au changement de DPI » à false = activer -->
    <AppContextSwitchOverrides value="Switch.System.Windows.DoNotScaleForDpiChanges=false"/>
  </runtime>
</configuration>

D’après notre expérience, quand on nous signale « j’ai écrit le manifeste mais ça ne marche pas », la cause est presque toujours soit ce problème de framework cible, soit le fait que le manifeste n’est pas réellement inclus dans la compilation (les paramètres du projet pointent encore vers le manifeste par défaut).

4.3 Comment le déclarer sous .NET (Core 3.1 à .NET 8)

WPF sous .NET reste lui aussi System Aware par défaut en l’absence de manifeste. Alors que WinForms dispose d’un point d’entrée dédié, ApplicationHighDpiMode dans le fichier projet ou Application.SetHighDpiMode, WPF n’offre aucun mécanisme officiel permettant de basculer le mode de reconnaissance DPI depuis le code ou les paramètres du projet. La déclaration se fait de la même façon que sous .NET Framework : ajoutez un app.manifest au projet (dans Visual Studio, « Ajouter un nouvel élément » → « Fichier manifeste d’application »), écrivez la même déclaration dpiAwareness qu’à la section 4.2, et référencez-le depuis le fichier projet.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
    <ApplicationManifest>app.manifest</ApplicationManifest>
  </PropertyGroup>
</Project>

Le runtime étant plus récent, le commutateur AppContext de la section 4.2 n’est pas nécessaire. Attention simplement : « migrer vers .NET » ne résout pas automatiquement le haut DPI pour autant. Ce gain à la migration concerne le côté WinForms (section 4.1 de l’article WinForms) ; WPF, lui, avait déjà atteint le niveau actuel dès .NET Framework 4.6.2.

Comme sous WinForms, il reste du travail après la déclaration, mais le contenu diffère. Sous WinForms, le principal champ de bataille était la réparation des mises en page cassées. Sous WPF, le framework s’occupe du suivi de la mise en page, si bien qu’il ne reste que la vérification visuelle de tous les écrans, la commutation des ressources bitmap selon le DPI (section 5.3), le suivi, dans le code qui manipule directement des pixels (chapitre 6), et la vérification du contenu mixte (chapitre 7). Pour un nombre d’écrans identique, l’effort total pour passer en Per-Monitor est généralement un cran plus faible que sous WinForms.

5. Le flou du rendu — traits fins, texte et bitmaps

Le contenu de ce chapitre est indépendant du passage en Per-Monitor. Il reste efficace même en restant System Aware, et vaut donc la peine d’être appliqué même à des applications qui ne tournent jamais en configuration multi-moniteur.

5.1 Le flou des traits et bordures — UseLayoutRounding et SnapsToDevicePixels

Faire la mise en page en DIP signifie que rien ne garantit que la frontière d’un élément tombe sur une position entière en pixels physiques. À 125 %, 1 DIP équivaut à 1,25 px : une bordure d’une largeur de 1 DIP correspond donc à 1,25 pixel physique, et le bord qui tombe entre deux pixels est dessiné à demi-transparent par l’anticrénelage. Le résultat, c’est « le trait bave » ou « pour une même valeur de 1px, l’épaisseur semble différer d’une ligne à l’autre ». Même à 96 DPI, le même phénomène peut survenir si une division en taille étoilée (*) d’un Grid, ou le calcul d’une marge centrée, tombe sur 0,5 px. C’est une caractéristique du rendu en sous-pixel de WPF plutôt qu’un problème de DPI à proprement parler — on pourrait plutôt dire qu’« un DPI plus élevé rend le phénomène plus visible ».4

La première mesure est l’arrondi de mise en page. Ajoutez une ligne à l’élément racine.

<Window x:Class="MyApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        UseLayoutRounding="True">

UseLayoutRounding est un mécanisme qui arrondit les valeurs de pixels non entières pendant la passe de mise en page ; il est désactivé par défaut, et le définir sur la racine le propage à tout l’arbre visuel.4 SnapsToDevicePixels, au nom proche, joue un rôle différent : au lieu d’agir sur la mise en page, il aligne les bords sur les frontières de pixels au moment du rendu. Il est lui aussi à false par défaut, et son réglage se transmet au sous-arbre. La documentation officielle cite elle-même, comme cas d’usage, la réduction des bavures dues à l’anticrénelage autour des traits fins dans un environnement au-delà de 96 DPI.5

  UseLayoutRounding SnapsToDevicePixels
Moment d’action Passe de mise en page (arrondit les résultats de Measure/Arrange à des pixels entiers) Au rendu (aligne les bords sur les frontières de pixels)
Valeur par défaut false false
Portée Se propage aux descendants si défini sur la racine Également hérité par les descendants
Effet principal La mise en page en général. Le décalage de 0,5 px de la taille/position des éléments, et les bavures de traits/bordures qui en découlent Les bavures restantes sur des éléments isolés comme Border ou les traits fins
Repère d’usage À mettre d’abord sur la Window racine. Pour une nouvelle application, dans un style commun à toutes les fenêtres En ciblant précisément ce qui reste flou après UseLayoutRounding

En cas de doute, procédez dans cet ordre : UseLayoutRounding="True" sur la racine, puis SnapsToDevicePixels="True" sur ce qui reste flou. Comme effet secondaire de l’arrondi, des colonnes réparties de manière égale via une taille étoilée peuvent finir par différer d’un pixel, mais nous n’avons quasiment jamais vu cela poser problème sur un écran d’application métier. Pour les bavures dans du code qui dessine lui-même via DrawingContext, il existe aussi un outil de bas niveau appelé GuidelineSet, mais le simple arrondi des coordonnées de dessin règle déjà la plupart des cas.

5.2 Le flou du texte — TextFormattingMode

La vieille plainte « le texte de WPF est pâle, il bave » concerne moins le DPI que le mode de formatage du texte. Le formatage de texte de WPF connaît deux modes : Ideal (par défaut), qui positionne les glyphes selon les métriques idéales propres à la police, et Display, qui les positionne selon des métriques compatibles GDI.9 Ideal offre un espacement de caractères élégant et se comporte bien avec la mise à l’échelle, mais dessiner de petits caractères à 96 DPI (100 %) peut donner une impression de bavure due à l’anticrénelage. Définir TextOptions.TextFormattingMode="Display" sur la racine procure un rendu net, à la WinForms.

En environnement haut DPI, la situation s’inverse cependant. Display formate les glyphes pour s’aligner sur la grille de pixels à 96 DPI, si bien que la qualité peut au contraire se dégrader sous mise à l’échelle. Le compromis pratique consiste à utiliser Display si la majorité des utilisateurs est à 100 %, et à conserver le mode Ideal par défaut dans l’environnement standard actuel où 125 % et plus est devenu la norme. Il est possible de basculer vers Display uniquement à 100 %, mais les écrans où ce niveau de finition se justifie (du type éditeur de texte) restent limités.

5.3 Le flou des images et icônes — priorité au vectoriel et résolutions multiples

WPF place également en DIP les bitmaps assignés à un Image, et les agrandit selon le DPI. Une icône de 16 × 16 px est agrandie par interpolation à 24 × 24 px en environnement 150 %, et l’algorithme d’interpolation par défaut (Linear) la rend nettement floue.6 Ce look d’application WPF où « le texte est net mais les icônes sont molles » vient presque toujours de là.

Il existe trois contre-mesures, par ordre de priorité.

  1. Passer à des ressources vectorielles (premier choix). Les icônes tenues sous forme de Path / Geometry / DrawingImage se dessinent nettement à n’importe quel DPI, sans code de commutation. Les outils de conversion depuis les outils de design ou depuis SVG vers XAML sont également bien fournis. Il vaut la peine d’ériger en règle, pour une nouvelle application, que les icônes soient dès le départ en vectoriel. Les polices d’icônes comme Segoe MDL2 Assets offrent le même effet.
  2. Faire commuter des bitmaps en plusieurs résolutions selon le DPI. Pour les ressources qui ne peuvent pas être vectorisées, comme les photos ou les captures d’écran, préparez des versions pour 96/120/144/192 DPI (par exemple 16/20/24/32 px) et sélectionnez-les selon le DPI courant. Le guide développeur officiel recommande lui aussi la commutation des ressources par DPI comme remède au flou.1
  3. Choisir l’interpolation via RenderOptions.BitmapScalingMode. C’est une mesure d’atténuation pour quand on ne peut pas ajouter de ressources supplémentaires. À une échelle entière comme 200 %, NearestNeighbor, qui agrandit en conservant les points tels quels, donne un rendu plus net, tandis que HighQuality (Fant) convient à la réduction de grandes images.6

La commutation du point 2 se fait, une fois le support Per-Monitor en place, via OnDpiChanged (disponible à partir de .NET Framework 4.6.2).

public partial class MainWindow : Window
{
    protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi)
    {
        base.OnDpiChanged(oldDpi, newDpi);
        // Appelé à chaque déplacement entre moniteurs ou changement de mise à l'échelle
        AppIcon.Source = IconAssets.SelectFor(newDpi.DpiScaleX);
    }
}

public static class IconAssets
{
    // Choisit, parmi les ressources préparées en 16 / 24 / 32 px, celle qui correspond au facteur d'agrandissement
    public static BitmapImage SelectFor(double scale) => scale switch
    {
        <= 1.0 => Load("icon16.png"),
        <= 1.5 => Load("icon24.png"),
        _      => Load("icon32.png"),
    };

    private static BitmapImage Load(string name) =>
        new(new Uri($"pack://application:,,,/Assets/{name}"));
}

Si vous restez en System Aware, le DPI ne change pas après le démarrage, donc il suffit de faire un seul choix au démarrage (Loaded) via VisualTreeHelper.GetDpi(this). Avant d’écrire du code de commutation, se demander systématiquement « cette ressource ne pourrait-elle pas plutôt être vectorielle ? » reste, en fin de compte, la solution la plus simple à maintenir.

6. Le code qui manipule des pixels et le suivi des changements de DPI

Ce qui échappe au parapluie de la mise à l’échelle automatique de WPF, c’est le code qui compte lui-même en pixels physiques. Il existe trois cas typiques, qui se manifestent tous de la même façon désagréable : « parfait sur la machine de développement (100 %), flou ou décalé uniquement chez le client à 150 % ».

Le premier cas est le code qui construit lui-même un tampon de pixels, comme WriteableBitmap / RenderTargetBitmap. Si vous le construisez avec un nombre de pixels égal à la taille en DIP, il sera agrandi par interpolation de 1,5x et deviendra flou en environnement 150 %. Le DPI courant s’obtient via la structure DpiScale renvoyée par VisualTreeHelper.GetDpi : dimensionnez donc le nombre de pixels en pixels physiques, et inscrivez le DPI correct dans le tampon.

// imageHost : élément affichant le résultat du rendu (ActualWidth/Height sont en DIP)
DpiScale dpi = VisualTreeHelper.GetDpi(imageHost);
int pixelWidth  = (int)Math.Ceiling(imageHost.ActualWidth  * dpi.DpiScaleX);
int pixelHeight = (int)Math.Ceiling(imageHost.ActualHeight * dpi.DpiScaleY);

// Ne pas fixer 96,96 en dur : passer le DPI réel (1 pixel physique = 1 pixel du tampon)
var bitmap = new WriteableBitmap(
    pixelWidth, pixelHeight,
    dpi.PixelsPerInchX, dpi.PixelsPerInchY,
    PixelFormats.Bgra32, null);

Le deuxième cas concerne les coordonnées écran et l’interopérabilité Win32. Les coordonnées renvoyées par PointToScreen, celles reçues via WM_MOUSEMOVE ou un hook, et celles passées à MoveWindow sont toutes en pixels physiques : les mélanger avec les DIP côté XAML provoque un décalage de 1,25x en environnement 125 %. Utilisez la matrice de CompositionTarget pour la conversion.

var source = PresentationSource.FromVisual(this);
if (source?.CompositionTarget is { } target)
{
    // DIP -> pixels physiques
    Point device = target.TransformToDevice.Transform(new Point(x, y));
    // pixels physiques -> DIP
    Point dip = target.TransformFromDevice.Transform(devicePoint);
}

Le troisième cas est le cache. Si vous mettez en cache des bitmaps, des valeurs de mise en page ou du texte formaté construits en intégrant un DPI donné, ils deviennent obsolètes à chaque déplacement entre moniteurs une fois passé en Per-Monitor. Comme à la section 5.3, reconstruisez-les dans OnDpiChanged (ou l’événement DpiChanged de la fenêtre). Là encore, en restant System Aware, le DPI est fixé au démarrage et aucun code de suivi n’est nécessaire. Estimer que « le coût du passage en Per-Monitor est proportionnel à la quantité de code qui manipule des pixels » colle bien à la réalité.

Pour la gestion du thread d’interface lorsqu’on effectue cette régénération de façon asynchrone, reportez-vous à « Async et thread d’interface dans WPF/WinForms — récapitulatif en une page ».

7. Le piège du contenu mixte — WindowsFormsHost, WebBrowser, contrôles tiers

La mise à l’échelle automatique de WPF ne s’étend qu’à ce que WPF dessine lui-même. Les éléments qui apportent un HWND se trouvent « en dehors » du rendu de WPF, ce qui complique d’un cran la gestion du DPI. La documentation officielle de Windows précise elle-même explicitement qu’« un autre framework hébergé dans WPF, ou WPF hébergé dans un autre framework, ne se met pas automatiquement à l’échelle ».10

Forme de mixité Ce qui se passe Traitement réaliste
WindowsFormsHost (héberger WinForms dans WPF) L’hôte convertit entre les deux systèmes de coordonnées, DIP et pixels physiques, mais la mise à l’échelle du contenu ne va que jusqu’où le contrôle WinForms hébergé peut lui-même la supporter11 Vérifier le support DPI côté WinForms interne (AutoScaleMode, etc.) selon les critères de l’article WinForms. Ne pas attendre de suivi Per-Monitor
Contrôle WebBrowser (moteur IE) Contenu natif dans un HWND séparé. Le taux de zoom peut ne pas correspondre à la mise à l’échelle de l’application À considérer comme obsolète. À intégrer aux éléments de réflexion lors d’une migration vers WebView2
ElementHost / HwndSource (héberger WPF dans WinForms ou Win32) Le scénario Per-Monitor n’est officiellement pas pris en charge3 Concevoir en s’alignant sur le mode de reconnaissance DPI de l’application hôte, en restant au maximum en System Aware
Contrôles tiers Le niveau de prise en charge varie d’un produit à l’autre. Un produit sans support Per-Monitor plafonne ce que l’écran peut atteindre Faire l’inventaire du statut de support et des versions compatibles chez chaque éditeur avant de décider de passer en Per-Monitor

En pratique, le premier cas est de loin le plus fréquent. Prenez une application migrée vers WPF mais qui héberge encore, via WindowsFormsHost, un ancien contrôle WinForms / ActiveX pour l’aperçu d’états ou des graphiques : la faire passer en Per-Monitor fait que la partie WPF suit parfaitement, mais seul le contenu à l’intérieur de l’hôte ne suit pas le DPI après le déplacement, restant petit et grossier. Il existe au niveau Win32 un mécanisme pour héberger un DPI mixte (SetThreadDpiHostingBehavior), mais ce n’est pas quelque chose que l’on peut utiliser facilement depuis WPF ; chez nous, on part du principe que « la partie mixte fixe le plafond du support Per-Monitor » et l’on commence par dresser l’inventaire du contenu mixte. Les éléments de décision pour s’orienter vers la suppression de la mixité sont rassemblés dans « Comment choisir entre WinForms, WPF et WinUI » et « Faut-il conserver, encapsuler ou remplacer un contrôle ActiveX/OCX ? ».

8. Jusqu’où aller — tableau de décision et démarche progressive

Pour WPF, il n’existe en réalité que deux options (il n’y a pas d’équivalent au « ne rien faire = Unaware » de WinForms, puisque WPF est System Aware même sans aucune déclaration).

  Rester System Aware Passer en Per-Monitor
Rendu visuel Net sur le moniteur principal. Bave sur un moniteur à DPI différent, après un changement de mise à l’échelle, ou en RDP Net sur tous les moniteurs
Travail de déclaration Aucun (comportement par défaut) Ajout du seul manifeste (chapitre 4)
Travail restant après déclaration Vérification visuelle de tous les écrans + commutation DPI des ressources bitmap (section 5.3) + prise en charge de DpiChanged dans le code lié aux pixels (chapitre 6) + vérification du contenu mixte (chapitre 7)
Ce qui fixe le plafond WindowsFormsHost, WebBrowser, contrôles tiers
Cas adapté Application interne centrée sur un bureau fixe à un seul moniteur. Application avec beaucoup de contenu mixte Usage mixte portable + moniteur externe, application phare à longue durée de vie, produit distribué aux clients

Les axes de décision sont les mêmes qu’au chapitre 7 de l’article WinForms (durée de vie de l’application, environnement d’utilisation, budget de refonte), mais la différence tient au fait que le coût marginal du passage en Per-Monitor est faible pour WPF. La déclaration tient en un seul fichier, le framework suit la mise en page, et le travail restant est proportionnel à la quantité de code lié aux pixels et au nombre de ressources. Pour une application avec peu de contenu mixte, le rapport coût-bénéfice d’aller jusqu’au Per-Monitor est nettement plus favorable que sous WinForms. Voici la démarche en trois étapes que nous recommandons réellement sur nos missions.

  1. Améliorer la qualité en restant System Aware : UseLayoutRounding sur la racine, vectorisation/multiplication des résolutions des icônes, correction du DPI pour le code de type WriteableBitmap. Tout sauf le flou multi-moniteur se règle à ce stade, et ce travail reste acquis même si l’on décide finalement de ne pas passer en Per-Monitor.
  2. Déclaration Per-Monitor et vérification : ajoutez le manifeste et parcourez tous les écrans dans un environnement à DPI mixte pour repérer les points problématiques. Tout ce qui est trouvé à ce stade devrait pouvoir se classer dans l’un des chapitres 5 à 7.
  3. Implémenter le suivi des changements de DPI : commutation des ressources et régénération du cache dans OnDpiChanged. C’est à ce stade que l’on décide « jusqu’où » tolérer le contenu mixte.

L’environnement de test décrit au chapitre 7 de l’article WinForms (deux moniteurs à des niveaux de mise à l’échelle différents, permutation du moniteur principal avec reconnexion, changement de mise à l’échelle en cours d’exécution, RDP depuis un client haut DPI) peut être réutilisé tel quel.10 Ce sur quoi il faut porter une attention particulière sous WPF, ce sont les bordures, les icônes et le dessin maison aux ratios non entiers (125 % / 150 %), ainsi que le comportement lorsqu’on fait glisser une fenêtre d’un moniteur à l’autre. Connaître à l’avance la répartition de l’environnement d’utilisation (quel pourcentage d’utilisateurs travaille en multi-moniteur) évite que la décision d’investissement ne parte dans tous les sens. Nous avons également abordé cet angle dans « Conception UX des applications Windows ».

9. Conclusion

Grâce aux DIP et à la mise à l’échelle automatique, WPF est System DPI Aware dès le départ, et le combat contre les mises en page cassées, qui occupait l’essentiel du terrain sous WinForms, n’existe quasiment pas ici. Les problèmes qui subsistent malgré tout se répartissent en quatre catégories — le flou multi-moniteur (absence de support Per-Monitor), le flou des bitmaps, le flou des traits fins, et le contenu mixte — et chacune dispose d’un remède établi.

  • Le flou multi-moniteur : sous WPF pour .NET Framework 4.6.2+ ou .NET, une simple déclaration dans le manifeste suffit à obtenir un suivi Per-Monitor automatique
  • Traits et bordures : UseLayoutRounding="True" sur la racine est la première mesure, avec SnapsToDevicePixels pour ce qui reste
  • Icônes : les ressources vectorielles sont le premier choix ; pour les bitmaps, il faut des résolutions multiples et une commutation par DPI
  • Seul le code qui manipule des pixelsWriteableBitmap, les coordonnées écran, etc. — constitue une véritable cible de refonte ; sa quantité détermine l’effort du passage en Per-Monitor
  • WindowsFormsHost / WebBrowser / les contrôles tiers fixent le plafond du support. Commencez par en dresser l’inventaire

Même pour une application restée bloquée sur l’idée que « c’est du WPF, donc ça devrait aller », il ne reste souvent, une fois vue à travers cette grille de lecture, qu’une poignée d’endroits à corriger. Si vous hésitez sur ce qu’il est possible de corriger dans votre application, ou sur l’état des lieux à mener — contenu mixte compris — et le niveau de support à viser, nous pouvons vous accompagner.

Articles connexes

Domaines de conseil associés

KomuraSoft LLC (合同会社小村ソフト) prend en charge le support du haut DPI pour les applications WPF / WinForms (état des lieux, décision sur la faisabilité du passage en Per-Monitor, inventaire et remédiation du contenu mixte), l’investigation des causes d’anomalies d’affichage consécutives à un remplacement de PC ou à l’installation de moniteurs 4K, ainsi que le conseil en modernisation d’interface.

Références

  1. Microsoft Learn, Developing a Per-Monitor DPI-Aware WPF Application. Sur WPF étant System DPI Aware par défaut, le mécanisme de mise à l’échelle automatique via les DIP, le fait que déplacer la fenêtre vers un moniteur à DPI différent amène l’OS à la mettre à l’échelle (particulièrement flou aux ratios non entiers), et la pratique consistant à commuter les ressources bitmap par DPI.  2 3 4 5

  2. Microsoft Learn, What’s new in .NET Framework. Sur l’activation de la reconnaissance DPI Per-Monitor pour WPF dans .NET Framework 4.6.2, le commutateur Switch.System.Windows.DoNotScaleForDpiChanges, et le guide développeur disponible sur GitHub.  2

  3. GitHub (microsoft/WPF-Samples), Per Monitor DPI Developer Guide. Sur les prérequis Windows 10 Anniversary Update et .NET Framework 4.6.2 ou ultérieur, l’écriture des déclarations dpiAwareness / dpiAware du manifeste, AppContextSwitchOverrides pour les cibles antérieures à 4.6.2, et le fait que le Per-Monitor ne soit pas pris en charge pour un WPF hébergé dans HwndSource / ElementHost.  2 3 4 5 6

  4. Microsoft Learn, Layout - WPF. Sur la mise en page en DIP et le mécanisme de rendu en sous-pixel qui rend les bords flous, le fait que l’arrondi de mise en page (UseLayoutRounding) soit désactivé par défaut, et le fait que le définir sur l’élément racine le propage à l’arbre visuel.  2 3

  5. Microsoft Learn, UIElement.SnapsToDevicePixels Property. Sur la valeur par défaut false, le fait que le réglage soit hérité par le sous-arbre lorsqu’il est défini sur la racine, et la façon dont il peut réduire les artefacts visuels dus à l’anticrénelage autour des traits fins dans un environnement au-delà de 96 DPI.  2

  6. Microsoft Learn, BitmapScalingMode Enum. Sur le fait que la valeur par défaut (Unspecified) soit Linear, et les caractéristiques des algorithmes d’interpolation HighQuality (Fant) et NearestNeighbor.  2 3

  7. Microsoft Learn, Setting the default DPI awareness for a process. Sur le fait que l’élément dpiAwareness (Windows 10 1607 et ultérieur) soit prioritaire sur dpiAware, et le comportement de repli où, parmi les valeurs énumérées séparées par des virgules, la première reconnue est utilisée. 

  8. Microsoft Learn, What’s new in .NET Framework. Sur l’ajout, dans .NET Framework 4.8, du support de la reconnaissance DPI Per-Monitor V2 et de la mise à l’échelle DPI en mode mixte dans WPF, les améliorations de l’interopérabilité avec les HWND hébergés / WinForms, et le commutateur AppContext nécessaire à son activation. 

  9. Microsoft Learn, TextFormattingMode Enum. Sur les deux modes de formatage de texte, Ideal (métriques idéales) et Display (métriques compatibles GDI). 

  10. Microsoft Learn, High DPI Desktop Application Development on Windows. Sur le tableau de support Per-Monitor par framework d’interface, le fait qu’un autre framework hébergé dans WPF (ou WPF hébergé dans un autre framework) ne se mette pas à l’échelle automatiquement, et les points d’attention pour les tests en environnement DPI mixte.  2

  11. Microsoft Learn, Layout Considerations for the WindowsFormsHost Element. Sur le fait que WindowsFormsHost convertisse entre les deux systèmes de coordonnées, DIP et pixels physiques, et que la mise à l’échelle n’aille que jusqu’où le contrôle Windows Forms hébergé peut lui-même la supporter. 

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

On m'a dit que WPF n'a pas besoin de gestion du DPI. Pourquoi l'affichage bave-t-il alors ?
Dans WPF, l'unité de mise en page est le DIP (unité indépendante du périphérique, 1/96 de pouce), et l'application fonctionne par défaut en mode System DPI Aware : elle suit donc correctement le DPI du moniteur principal au démarrage. Mais le DPI système est figé au moment de la connexion (sign-in) : quand on déplace la fenêtre vers un moniteur dont le DPI est différent, l'OS agrandit ou réduit l'ensemble de la fenêtre comme un bitmap pour compenser, et tout l'affichage devient uniformément flou. La dégradation est particulièrement visible pour des combinaisons non entières comme 125 % et 150 %. Le vrai remède consiste à déclarer le support Per-Monitor DPI dans le manifeste : à partir de WPF sous .NET Framework 4.6.2, le redimensionnement de la fenêtre est ensuite pris en charge automatiquement.
Pourquoi les bordures de 1px bavent-elles ou ont-elles une épaisseur inégale dans WPF ?
Parce que la mise en page se fait en DIP, rien ne garantit que la frontière d'un élément tombe sur une position entière de pixel physique. À 125 %, 1 DIP équivaut à 1,25 px : une ligne large de 1 DIP correspond donc à 1,25 pixel physique, et le bord qui tombe entre deux pixels est dessiné à demi-transparent par l'anticrénelage, d'où l'effet de bavure. Le premier réflexe consiste à définir UseLayoutRounding="True" sur la Window racine : cela arrondit les valeurs de pixels non entières pendant la passe de mise en page et se propage à tout l'arbre visuel. Pour ce qui reste flou malgré cela, on applique ponctuellement SnapsToDevicePixels="True", qui aligne les bords sur les frontières de pixels au moment du rendu. Les deux propriétés sont désactivées par défaut.
Pourquoi, dans WPF, le texte est-il net alors que seules les icônes sont floues ?
Le texte est dessiné sous forme de police vectorielle et reste donc net quel que soit le DPI, tandis que les images bitmap sont placées en DIP puis agrandies par interpolation en fonction du DPI. Une icône de 16 × 16 px est agrandie à 24 × 24 px à 150 %, et l'interpolation par défaut (Linear) la rend nettement floue. L'ordre de priorité des contre-mesures est : (1) passer à des ressources vectorielles telles que Path/Geometry/DrawingImage ou des polices d'icônes ; (2) préparer des bitmaps en plusieurs résolutions et les faire commuter selon le DPI via VisualTreeHelper.GetDpi ou OnDpiChanged ; (3) ajuster l'algorithme d'interpolation avec RenderOptions.BitmapScalingMode.
Comment configure-t-on le support Per-Monitor dans WPF ?
On le déclare en écrivant l'élément dpiAwareness dans le manifeste de l'application. WPF n'offre aucun équivalent au ApplicationHighDpiMode de WinForms, ni aucun paramètre de projet ou moyen de le basculer depuis le code. Les prérequis sont un OS Windows 10 version 1607 ou ultérieure et une cible .NET Framework 4.6.2 ou ultérieure ; une fois déclaré, le framework prend en charge automatiquement le traitement de WM_DPICHANGED jusqu'au redimensionnement de la fenêtre. Le support de PerMonitorV2 dans WPF exige .NET Framework 4.8 ou ultérieur : pour les cibles 4.6.2 à 4.7.2, on déclare donc PerMonitor seul. Si la cible reste en 4.6.1 ou antérieure, le suivi est désactivé par défaut et il faut l'activer explicitement via un commutateur AppContext. À noter enfin que le comportement Per-Monitor de WPF hébergé dans ElementHost ou HwndSource n'est officiellement pas pris en charge.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog