Высокий DPI в WPF — почему «к DPI должен быть готов», а на деле всё размывается

· Обновлено: · · WPF, Высокий DPI, Windows, .NET, C#, .NET Framework, XAML, UI, Техническая консультация

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619945)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Высокий DPI в WPF — почему «к DPI должен быть готов», а на деле всё размывается. KomuraSoft LLC. https://comcomponent.com/ru/blog/wpf-high-dpi-guide/

DOI (зарегистрированный архив)
10.5281/zenodo.21619945
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21619946

«В WPF для DPI вообще ничего не нужно делать, правда?» — этот вопрос на консультациях по высокому DPI звучит часто. Наполовину это так, наполовину нет. На практике приносят такие симптомы: «на ноутбуке самом по себе всё чётко, а стоит подключить внешний монитор в переговорной — плывёт весь экран», «текст чёткий, а иконки на панели инструментов размыты», «линии в списке то толще, то тоньше, то вовсе пропадают», «старый элемент управления отчётами, встроенный в экран, один остаётся мельче остальных». Всё это реально случается в WPF-приложениях, которые «к DPI должны быть готовы».

В предыдущей статье «Высокий DPI в WinForms» мы разобрали, как Windows масштабирует DPI, режимы осведомлённости о DPI (Unaware / System Aware / Per-Monitor V2) и как вытянуть WinForms из эпохи жёстко прошитых 96 DPI. Эта статья — версия для WPF. Общую теорию виртуализации DPI и режимов осведомлённости оставляем статье про WinForms; здесь — специфика WPF: почему и где проблемы остаются во фреймворке, который «с самого начала должен уметь DPI». Дальше — в том порядке, в каком этим пользуются на практике: диагностика по симптомам, как объявить Per-Monitor DPI (отдельно для .NET Framework 4.6.2 и для .NET), что делать с размытием линий и растра, ловушка смешанного содержимого с WindowsFormsHost и таблица, до какого уровня имеет смысл доходить.

Термины, которые дальше идут без пояснения

Общую теорию режимов осведомлённости о DPI оставляем статье про WinForms. Здесь заранее фиксируем только те слова, которые в тексте встречаются без оговорок.

Термин Значение
DIP Device Independent Pixel (независимый от устройства пиксель). Единица макета WPF: 1 = 1/96 дюйма. Width="120" в XAML — это 120 именно в DIP
HWND Идентификатор, который Windows назначает каждому окну (дескриптор окна). У WPF HWND есть только у окна верхнего уровня; кнопки и текст внутри рисует сам WPF. Элементы, которые приносят HWND, оказываются «снаружи» отрисовки WPF (глава 7)
GDI Традиционный 2D API Windows. Рисует в физических пикселях; в официальной таблице поддержка масштабирования Per-Monitor DPI указана как «нет»1
Субпиксельное позиционирование Граница элемента не попадает на целочисленную позицию физического пикселя. Край «между» пикселями рисуется сглаживанием (промежуточным цветом) и выглядит размытым (раздел 5.1)
Виртуализация DPI Для приложений Unaware / System Aware ОС растягивает или сжимает результат отрисовки окна как растр. Макет не ломается, но всё изображение размывается1
Манифест XML, встраиваемый в исполняемый файл. Для WPF это единственная точка, где объявляют режим осведомлённости о DPI (глава 4)

1. Сначала выводы

Сначала три пункта, на которых держится вся статья.

  • WPF с самого начала корректно следует за «DPI на момент запуска». Единица макета — DIP, система отрисовки сама масштабируется под системный DPI, поэтому по умолчанию приложение — System DPI Aware. Настройка и проверка вроде AutoScaleMode в WinForms здесь не нужны.2
  • То, что всё же остаётся, распадается на 4 группы. (a) равномерное размытие при переносе окна на монитор с другим DPI, (b) размытие растровых изображений и иконок, (c) размытие тонких линий и рамок, (d) смешанное содержимое вроде WindowsFormsHost / WebBrowser. Причины и способы исправления разные, поэтому сначала определите свой случай по таблице в главе 3.
  • Работа на стороне WPF — «одна строка объявления плюс проверка остального». За следованием макета следит фреймворк; по-настоящему править нужно только код, который сам считает пиксели, растровые ресурсы и смешанное содержимое (глава 8).

Выводы по каждой из четырёх групп — ниже.

Группа Вывод Подробности
(a) Равномерное размытие Причину закрывает поддержка Per-Monitor DPI. WPF на .NET Framework 4.6.2 и новее поддерживает это на уровне фреймворка: достаточно объявить это в манифесте — пересчёт масштаба окна выполняется автоматически34. WPF на .NET (Core 3.1–.NET 8) по умолчанию тоже остаётся System Aware; аналога ApplicationHighDpiMode / SetHighDpiMode из WinForms у WPF нет Глава 4
(b) Размытие растра Первый кандидат — векторные ресурсы вроде Path / Geometry. То, что существует только как растр, готовят в нескольких разрешениях и переключают по DPI. Стандартная интерполяция WPF (Linear) сильнее всего размывает как раз маленькие изображения вроде иконок при нецелом масштабе5 Раздел 5.3
(c) Размытие тонких линий Это проблема субпиксельного позиционирования, она была и до высокого DPI. Первый шаг — UseLayoutRounding="True" на корневом элементе; SnapsToDevicePixels — другой инструмент, привязка к пикселям уже при отрисовке. Оба по умолчанию выключены67 Раздел 5.1
(d) Смешанное содержимое Задаёт верхнюю границу поддержки Per-Monitor. В частности, поведение Per-Monitor у WPF, «посаженного» в ElementHost / HwndSource, официально названо неподдерживаемым4 Глава 7

Общую теорию режимов осведомлённости о DPI (виртуализация DPI, разница между System Aware и Per-Monitor V2, переопределение пользователем через свойства exe) и способ собрать тестовую среду со смешанным DPI мы не повторяем: это те же главы 2–3 и 7 статьи про WinForms.

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 24, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Почему WPF «устойчив к DPI» — DIP и System DPI Aware

Корень проблем высокого DPI в WinForms в том, что и координаты, и размеры пишутся сразу в физических пикселях, а механизм, который на лету пересчитывает макет, рассчитанный на 96 DPI (AutoScale), пристроили уже потом. У WPF другая точка старта. 120 в записи Width="120" в XAML — не физические пиксели, а независимый от устройства пиксель (DIP), где 1 единица = 1/96 дюйма. Весь макет считается в этой единице и при отрисовке переводится в множитель системного DPI (при 150% — в 1,5 раза). Текст тоже рисуется как векторный шрифт, поэтому при увеличении нет растровой деградации. Благодаря этому WPF-приложение работает как System DPI Aware без каких-либо объявлений.2

Сведём отличия от WinForms в таблицу.

  WinForms WPF
Единица координат и размеров Физические пиксели DIP (1/96 дюйма)
Поведение без объявлений Unaware (ОС растягивает растр — размывается) System Aware (на основном мониторе чётко)
Следование за DPI при запуске Пересчёт на уровне формы через AutoScaleMode. Нужны настройка и проверка Система отрисовки масштабирует сама. Настройка не нужна
Типичный сбой на высоком DPI Наложение и обрезка при макете с фиксированными координатами Макет не ломается, проявляется как размытие
Сбои из-за конструктора Перезапись AutoScaleDimensions (классика) По сути нет (XAML сохраняется как есть, в DIP)

Как видно из таблицы, работа, которая в WinForms забирала основное время — «чинить ломающийся макет» и «не дать конструктору всё испортить», — в WPF почти не возникает. «WPF устойчив к DPI» — верное утверждение.

Но автоматизм действует только в пределах System Aware. System Aware значит «подстроиться под DPI основного монитора на момент входа в систему», а не следование за окружением, где у мониторов разный DPI (Per-Monitor). Кроме того, в DIP увеличивается только то, что WPF рисует сам (текст, фигуры, элементы управления): растровые изображения при увеличении всё равно размываются, а смешанное содержимое, которое приносит HWND, стоит вне масштабирования WPF. Обращения вида «к DPI же должен быть готов» почти всегда связаны именно с этим остатком.

3. Проблемы, которые всё же возникают — определяем причину по симптому

Это таблица диагностики, которую мы первой достаём на консультации. В WPF соответствие симптома и причины ещё чище, чем в WinForms, поэтому одной этой таблицы обычно хватает, чтобы назвать причину.

Симптом Причина Что делать
При переносе на монитор с другим DPI всё равномерно размывается. При возврате на исходный монитор проходит Осталось System Aware. ОС растягивает всё окно как растр Глава 4 (переход на Per-Monitor)
Размывается сразу после смены масштаба или при RDP с клиента с высоким DPI То же (System DPI фиксируется при входе в систему) Глава 4
Текст чёткий, а только иконки и изображения размыты или мельче Растровые ресурсы увеличиваются интерполяцией Раздел 5.3
Линия или рамка толщиной 1px разной толщины в разных местах, слегка размыта Субпиксельное позиционирование и сглаживание Раздел 5.1
На мониторе 100% (96 DPI) мелкий текст выглядит размытым Сглаживание стандартного форматирования текста (Ideal) Раздел 5.2
Собственные изображения (WriteableBitmap, RenderTargetBitmap и т. п.) размыты Размер в пикселях посчитан из расчёта на 96 DPI Глава 6
Положение окна, координаты мыши, координаты снимка экрана съехали Путаница DIP и физических пикселей Глава 6
Только внутри WindowsFormsHost / WebBrowser / части сторонних элементов содержимое мельче, грубее или ломается Смешанное содержимое (вне масштабирования WPF) Глава 7

Поясним первую строку — «размывается равномерно везде». Это состояние, когда виртуализация DPI (растяжение растра со стороны ОС), описанная в главе 2 статьи про WinForms, действует на приложение System Aware. Само приложение не сломано. Приложение System Aware рисует под «DPI основного монитора на момент входа в систему», а на мониторе с другим DPI ОС растягивает или сжимает картинку, чтобы компенсировать разницу. Макет не ломается, но взамен размывается — это мера совместимости со стороны ОС.2 Исправить это значит отказаться от этой подстраховки и заявить «я сам буду следовать за DPI каждого монитора», то есть перейти на Per-Monitor.

Наоборот, симптомы начиная с третьей строки можно закрыть, оставаясь System Aware. Если запрос звучит как «мультимониторной работы нет, но на 150% иконки и линии выглядят неопрятно», главу 4 можно пропустить и сразу идти в главу 5.

3.1 Как воспроизвести и увидеть симптомы у себя

Скриншотов в этой статье нет. Размытие и потерю резкости надёжнее увидеть на своей машине, чем на статичном кадре. Следующими шагами проверьте симптомы из таблицы своими глазами. Как собрать саму тестовую среду со смешанным DPI, описано в главе 7 статьи про WinForms, но до этого пункта достаточно и одного монитора.

  1. Смените масштаб. Пуск > Параметры > Система > Дисплей > «Масштаб и разметка» > «Изменение размера текста, приложений и других элементов» — переключайте 100% / 125% / 150%.8 Сильнее всего эффект на 125% и 150% (потому что коэффициент нецелый — об этом раздел 4.1).
  2. Перезапустите приложение. WPF по умолчанию System Aware, поэтому уже запущенное приложение за новым масштабом не следует. После смены настройки обязательно запустите его заново. Если и тогда не помогло — выйдите из системы и войдите снова (System DPI фиксируется при входе).
  3. Смотрите детали экранной лупой. Клавиша с логотипом Windows + + (плюс) запускает экранную лупу.9 Поднимите примерно до 400% и по очереди посмотрите три места.
    • Линии и рамки — если с одной или с обеих сторон линии вылезает светло-серая кайма в 1px, это субпиксельное позиционирование из раздела 5.1. Решающий признак: линии с одним и тем же заданием 1 в разных строках выглядят разной плотности и толщины.
    • Иконки и изображения — если контур двоится и линия, которой положено быть в 1px, превращается в 2px промежуточного цвета, это интерполяционное увеличение растра из раздела 5.3. Решающий признак диагностики — что текст на том же экране остаётся чётким (текст векторный и не размывается).
    • Весь экран — если текст, линии и иконки размыты одинаково, это не отдельная отрисовка, а виртуализация DPI со стороны ОС. Тогда переходите к главе 4.
  4. Перетащите окно между мониторами (если их два и больше). Перетащите окно на монитор с другим масштабом. Если на новом месте размывается, а при возврате проходит — это первая строка таблицы. Если после переноса картинка не меняется, группа (a) у вас не проявляется.
  5. Смените масштаб на лету. Не закрывая приложение, измените настройку из пункта 1. Приложение System Aware на месте размывается.

Шаги 1–5 после перехода на Per-Monitor можно использовать и как проверку. Официальный рекомендованный набор тестов тоже из четырёх пунктов: перенос между мониторами, запуск при разном DPI, смена масштаба во время работы, смена основного монитора с выходом и повторным входом в систему.1

4. Размытие при нескольких мониторах — поддержка Per-Monitor DPI

4.1 Где кончается System Aware

System DPI фиксируется при входе в систему по DPI основного монитора. Если основной — встроенный экран ноутбука на 150%, WPF рисует все окна с масштабом 1,5, а при переносе на внешний монитор на 100% ОС показывает изображение, сжатое до 2/3. Такое масштабирование со стороны ОС особенно заметно размывается при нецелом коэффициенте.2 На практике видно, что неудобное сочетание вроде 125% и 150% портит картинку сильнее, чем чистая двукратная разница 200% и 100%.

Иными словами, System Aware в WPF мешает только в двух ситуациях: когда окно гоняют между мониторами с разным DPI и когда масштаб меняется уже после входа в систему (основной монитор сменился из-за подключения или отключения док-станции, клиентский DPI пришёл по RDP и т. п.). Для внутреннего приложения, которым все пользуются на одном мониторе с фиксированным рабочим столом, оставаться System Aware на практике достаточно. К этому решению мы ещё вернёмся в таблице главы 8.

4.2 Как объявить в .NET Framework 4.6.2 и новее

Поддержка Per-Monitor DPI в WPF появилась в .NET Framework 4.6.2.3 До этого, даже объявив Per-Monitor операционной системе, сам WPF за сменой DPI не следовал, и пересчёт масштаба окна приходилось писать целиком самим (поэтому образцы времён Windows 8.1 строились вокруг нативной вспомогательной DLL и выглядели тяжеловесно). Начиная с 4.6.2 WPF сам обрабатывает WM_DPICHANGED и автоматически меняет размер окна, пересчитывает макет и перерисовывает содержимое.

Требований два: ОС Windows 10 Anniversary Update (1607) или новее и сборка с целевой платформой .NET Framework 4.6.2 или новее.4 Объявление пишут в манифесте приложения.

<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
  <asmv3:windowsSettings>
    <!-- На .NET Framework 4.6.2–4.7.2 объявляем одиночный PerMonitor (как в руководстве разработчика).
         Поддержка PerMonitorV2 в WPF есть только с .NET Framework 4.8,
         поэтому на 4.8+ / .NET ставим V2 первым: "PerMonitorV2, PerMonitor" -->
    <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings"
      >PerMonitor</dpiAwareness>
    <!-- Для старых ОС, которые не знают dpiAwareness (откат к System Aware) -->
    <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
  </asmv3:windowsSettings>
</asmv3:application>

У этой двухуровневой конструкции есть смысл. Элемент dpiAwareness распознаётся начиная с Windows 10 1607, и из значений через запятую берётся первое распознанное.10 На старых ОС, которые элемента dpiAwareness не знают, происходит откат к объявлению dpiAware (System Aware) — в этом и механизм.

Здесь важно выбирать PerMonitor или PerMonitorV2 в зависимости от целевой платформы. Официальная поддержка PerMonitorV2 (и Mixed-Mode DPI) в WPF начинается с .NET Framework 4.811, и пример в руководстве разработчика для 4.6.2 тоже объявляет одиночный PerMonitor.4 Если при цели 4.6.2–4.7.2 поставить V2 первым, ОС начиная с Windows 10 1703 выберет режим V2, который фреймворк на самом деле не поддерживает, и поведение станет неожиданным — особенно вокруг смешанного содержимого вроде WindowsFormsHost. Наша рекомендация — сначала перейти на 4.8 и новее (или на WPF в составе .NET), а затем указывать V2 первым как PerMonitorV2, PerMonitor, отдавая масштабирование неклиентской области (заголовок окна, полосы прокрутки) операционной системе (см. главу 3 статьи про WinForms).

Отдельно — ловушка целевой платформы. Даже если на машине стоит .NET Framework 4.6.2 или новее, если цель проекта осталась 4.6.1 или старше, следование Per-Monitor по умолчанию выключено. Тогда его явно включают переключателем AppContext в app.config.4

<configuration>
  <runtime>
    <!-- Осторожно, двойное отрицание: «не масштабировать при смене DPI» ставим в false = включаем -->
    <AppContextSwitchOverrides value="Switch.System.Windows.DoNotScaleForDpiChanges=false"/>
  </runtime>
</configuration>

По опыту, если «манифест написали, а не работает», причина почти всегда одна из двух: либо эта история с целевой платформой, либо манифест фактически не попадает в сборку (в настройках проекта всё ещё стоит манифест по умолчанию).

4.3 Как объявить в .NET (Core 3.1–.NET 8)

WPF на .NET тоже без манифеста по умолчанию остаётся System Aware. У WinForms есть отдельная точка входа через ApplicationHighDpiMode в файле проекта или Application.SetHighDpiMode; у WPF официального механизма переключить режим осведомлённости о DPI из кода или настроек проекта нет. Способ объявления тот же, что в .NET Framework.

Как добавить манифест, можно описать и без скриншотов — по именам пунктов меню.

  1. В Обозревателе решений щёлкните проект правой кнопкой и выберите «Добавить» > «Создать элемент» (сочетание клавиш Ctrl+Shift+A).
  2. В списке выберите шаблон «Файл манифеста приложения» и добавьте его с именем по умолчанию app.manifest.
  3. Откройте добавленный app.manifest и запишите то же объявление dpiAwareness, что в разделе 4.2, как элемент <asmv3:application>. В шаблоне уже есть requestedExecutionLevel (уровень запроса UAC) и другое — это не удаляйте, допишите рядом.
  4. В <PropertyGroup> файла проекта укажите <ApplicationManifest>app.manifest</ApplicationManifest> (в SDK-style проекте файл с именем app.manifest иногда подхватывается сам, но явное указание надёжнее).

Для проекта на .NET Framework вместо пункта 4 откройте свойства проекта > вкладка «Приложение» > «Манифест» и в выпадающем списке выберите добавленный манифест.

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

Среда выполнения новее, поэтому переключатель AppContext из раздела 4.2 не нужен. Имейте в виду только одно: «перешли на .NET — и высокий DPI решился сам» — так не работает. Миграция облегчает сторону WinForms (раздел 4.1 статьи про WinForms); WPF уже вышел на сегодняшний уровень ещё в .NET Framework 4.6.2.

После объявления работа остаётся, как и в WinForms, но состав другой. В WinForms основным фронтом был сломанный макет. В WPF за макетом следует фреймворк, поэтому остаются визуальная проверка всех экранов, переключение растровых ресурсов по DPI (раздел 5.3), доработка кода, который напрямую считает пиксели (глава 6) и проверка смешанного содержимого (глава 7). При том же числе экранов суммарные трудозатраты на переход к Per-Monitor обычно оказываются на ступень меньше, чем в WinForms.

5. Размытие при отрисовке — тонкие линии, текст, растр

Содержание этой главы не зависит от перехода на Per-Monitor. Оно работает и при System Aware, поэтому его стоит применять даже приложениям без нескольких мониторов.

5.1 Размытие тонких линий и рамок — UseLayoutRounding и SnapsToDevicePixels

Макет в DIP значит, что граница элемента не обязана попасть на целочисленную позицию физического пикселя. При 125% 1 DIP = 1,25px, поэтому линия шириной 1 DIP занимает 1,25 физического пикселя, а край, оказавшийся между пикселями, рисуется сглаживанием как полупрозрачный. Результат — «линия плывёт» или «одна и та же линия в 1px в разных строках выглядит разной толщины». То же может случиться и на 96 DPI, если деление размера со звёздочкой (*) в Grid или расчёт поля при выравнивании по центру попадёт на 0,5px. Это скорее особенность субпиксельной отрисовки WPF, чем проблема DPI как таковая: точнее сказать, что «с ростом DPI это становится заметнее».6

Первый шаг — округление макета. Добавьте одну строку на корневой элемент.

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

UseLayoutRounding округляет нецелые пиксельные значения на этапе макета; по умолчанию выключен, а при установке на корне распространяется на всё визуальное дерево.6 Похожее по имени SnapsToDevicePixels играет другую роль: это не макет, а привязка краёв к границам пикселей уже при отрисовке. Оно тоже по умолчанию false, и настройка наследуется поддеревом. Официальная документация прямо называет среди назначений снижение размытия от сглаживания вокруг тонких линий в средах выше 96 DPI.7

  UseLayoutRounding SnapsToDevicePixels
Когда действует На этапе макета (округляет результаты Measure / Arrange до целых пикселей) При отрисовке (привязывает края к границам пикселей)
Значение по умолчанию false false
Область действия Распространяется на потомков при установке на корне Тоже наследуется потомками
Основной эффект Макет в целом: смещение размера и положения элемента на 0,5px и связанное с этим размытие линий и границ Остаточное размытие отдельных элементов вроде Border или тонких линий
Практический ориентир Сначала это — на корневой Window. В новых приложениях — в общий стиль для всех окон Точечно там, где размытие осталось после UseLayoutRounding

Если сомневаетесь, порядок такой: UseLayoutRounding="True" на корне, а на том, что осталось размытым, — SnapsToDevicePixels="True". Побочный эффект округления: колонки, которые звёздочным размером делили поровну, могут стать неравными на пиксель, но на экранах бизнес-приложений с этим почти не сталкивались. Для размытия в коде, который рисует через DrawingContext вручную, есть и низкоуровневый GuidelineSet, но в большинстве случаев достаточно просто округлить координаты отрисовки.

5.2 Размытие текста — TextFormattingMode

Старое недовольство «текст в WPF бледный и плывёт» связано скорее с режимом форматирования текста, чем с DPI. У текстового форматирования WPF два режима: Ideal (по умолчанию), где символы ставятся по истинным идеальным метрикам шрифта, и Display, где размещение идёт по метрикам, совместимым с GDI.12 Ideal даёт красивые межсимвольные интервалы и хорошо масштабируется, но мелкий текст на 96 DPI (100%) из-за сглаживания может казаться размытым. TextOptions.TextFormattingMode="Display" на корне даёт ту самую чёткость в духе WinForms.

На высоком DPI картина переворачивается. Display подгоняет символы под сетку пикселей 96 DPI, поэтому при масштабировании качество, наоборот, может упасть. Практичное правило: если большинство пользователей на 100% — Display; в сегодняшней обычной среде, где преобладает 125% и выше, оставляйте Ideal по умолчанию. Можно переключать на Display только при 100%, но экранов, где это окупается (в духе текстового редактора), немного.

5.3 Размытие изображений и иконок — сначала вектор, затем несколько разрешений

WPF размещает растр, заданный в Image, тоже в DIP и увеличивает его под DPI. Иконка 16×16px при 150% интерполяцией растягивается до 24×24px, и стандартный алгоритм (Linear) её заметно размывает.5 Внешний вид «текст чёткий, а иконки будто в расфокусе» у WPF-приложения почти всегда объясняется именно этим.

Три меры, в порядке приоритета.

  1. Перевести в векторный ресурс (первый кандидат). Иконки как Path / Geometry / DrawingImage рисуются чётко при любом DPI и не требуют кода переключения. Инструментов конвертации из дизайнерских программ или SVG в XAML достаточно. Имеет смысл закрепить правилом, что иконки нового приложения с самого начала хранятся в векторе. Тот же эффект дают шрифты иконок вроде Segoe MDL2 Assets.
  2. Переключать несколько разрешений растра по DPI. Для ресурсов, которые нельзя векторизовать, — фотографии, снимки экрана и подобное — готовят версии под 96 / 120 / 144 / 192 DPI (например, 16 / 20 / 24 / 32px) и выбирают по текущему DPI. Официальное руководство разработчика тоже рекомендует переключение ресурсов по DPI как способ убрать размытие.2
  3. Подобрать интерполяцию через RenderOptions.BitmapScalingMode. Смягчение на случай, когда добавить ресурсы нельзя. При целом масштабе вроде 200% NearestNeighbor даёт более чёткий результат, увеличивая изображение «точка в точку», а для уменьшения крупных изображений подходит HighQuality (Fant).5

Переключение из пункта 2, если Per-Monitor уже включён, делают в OnDpiChanged (доступен начиная с .NET Framework 4.6.2).

public partial class MainWindow : Window
{
    protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi)
    {
        base.OnDpiChanged(oldDpi, newDpi);
        // Вызывается при каждом переносе между мониторами или смене масштаба
        AppIcon.Source = IconAssets.SelectFor(newDpi.DpiScaleX);
    }
}

public static class IconAssets
{
    // Из версий 16 / 24 / 32 px выбираем подходящую по коэффициенту масштаба
    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}"));
}

Если приложение остаётся System Aware, DPI после запуска не меняется, поэтому достаточно выбрать ресурс один раз при старте (Loaded) через VisualTreeHelper.GetDpi(this). Прежде чем писать код переключения, каждый раз стоит спросить себя: «а нельзя ли вообще сделать этот ресурс вектором» — в сопровождении это обычно самый дешёвый путь.

6. Код, который считает пиксели, и следование за сменой DPI

За пределами автоматического масштабирования WPF оказывается код, который сам считает физические пиксели. Типичны три случая, и все они проявляются одинаково неприятно: «на машине разработчика (100%) идеально, у заказчика на 150% размывается или съезжает».

Первый — код, который сам создаёт пиксельный буфер, например WriteableBitmap / RenderTargetBitmap. Если создать его с числом пикселей, равным размеру в DIP, при 150% он интерполяцией увеличится в 1,5 раза и размоется. Текущий DPI берут из структуры DpiScale, которую возвращает VisualTreeHelper.GetDpi: число пикселей задавайте в физических пикселях, а DPI корректно записывайте в буфер.

// imageHost: элемент, который показывает результат отрисовки (ActualWidth/Height — в DIP)
DpiScale dpi = VisualTreeHelper.GetDpi(imageHost);
int pixelWidth  = (int)Math.Ceiling(imageHost.ActualWidth  * dpi.DpiScaleX);
int pixelHeight = (int)Math.Ceiling(imageHost.ActualHeight * dpi.DpiScaleY);

// Не создавайте с фиксированными 96,96 — передавайте фактический DPI
// (тогда 1 физический пиксель = 1 пиксель буфера)
var bitmap = new WriteableBitmap(
    pixelWidth, pixelHeight,
    dpi.PixelsPerInchX, dpi.PixelsPerInchY,
    PixelFormats.Bgra32, null);

Второй — экранные координаты и взаимодействие с Win32. Координаты, которые возвращает PointToScreen, координаты из WM_MOUSEMOVE или хука, координаты, которые передают в MoveWindow, — все в физических пикселях. Если смешать их с DIP на стороне XAML, при 125% получится смещение в 1,25 раза. Для преобразования используют матрицу CompositionTarget.

var source = PresentationSource.FromVisual(this);
if (source?.CompositionTarget is { } target)
{
    // DIP → физические пиксели
    Point device = target.TransformToDevice.Transform(new Point(x, y));
    // физические пиксели → DIP
    Point dip = target.TransformFromDevice.Transform(devicePoint);
}

Третий — кэш. Если кэшируете растр, значения макета или отформатированный текст, созданные с учётом конкретного DPI, после перехода на Per-Monitor они устаревают при каждом переносе окна между мониторами. Как и в разделе 5.3, пересоздавайте их в OnDpiChanged (или в событии окна DpiChanged). И здесь тоже: если приложение остаётся System Aware, DPI фиксируется при запуске, и код следования не нужен. Оценка «стоимость перехода на Per-Monitor = объём кода, который считает пиксели» хорошо совпадает с реальностью.

Как обращаться с потоком UI, если такое пересоздание делают асинхронно, разобрано в статье «async и поток UI в WPF/WinForms».

7. Ловушка смешанного содержимого — WindowsFormsHost, WebBrowser, сторонние компоненты

Автоматическое масштабирование WPF распространяется только на то, что WPF рисует сам. Элементы, которые приносят HWND, стоят «снаружи» отрисовки WPF, поэтому DPI там на порядок запутаннее. В официальных материалах Windows прямо сказано: «другой фреймворк внутри WPF и WPF внутри другого фреймворка автоматически не масштабируются».1

Форма смешения Что происходит Как к этому относиться на практике
WindowsFormsHost (WinForms внутри WPF) Хост пересчитывает между двумя системами координат — DIP и физическими пикселями, но масштабирование содержимого работает лишь настолько, насколько это умеет сам элемент WinForms13 Проверить поддержку DPI внутренним элементом WinForms (AutoScaleMode и т. п.) по критериям статьи про WinForms. На следование Per-Monitor не рассчитывать
Элемент управления WebBrowser (движок IE) Нативное содержимое в отдельном HWND. Масштаб увеличения может не совпадать с масштабом приложения Считать устаревшим. Учитывать как довод при рассмотрении перехода на WebView2
ElementHost / HwndSource (WPF внутри WinForms или Win32) Сценарий Per-Monitor официально не поддерживается4 Проектировать до System Aware, ориентируясь на режим осведомлённости о DPI у хоста
Сторонние компоненты Уровень поддержки разный от продукта к продукту. Продукт без Per-Monitor задаёт потолок для этого экрана Сначала свести статус поддержки и версии у поставщиков, потом решать о переходе на Per-Monitor

На практике чаще всего встречается первая строка. Возьмём приложение, которое перешло на WPF, но предпросмотр отчётов или графики по-прежнему сажает старый элемент WinForms / ActiveX через WindowsFormsHost: если включить Per-Monitor, часть на WPF будет следовать за DPI идеально, а только содержимое внутри хоста после переноса за новым DPI не последует и останется мелким и грубым. На уровне Win32 есть механизм хостинга смешанного DPI (SetThreadDpiHostingBehavior), но из WPF им не воспользоваться запросто; наш подход — принять, что «смешанная часть задаёт верхнюю границу поддержки Per-Monitor», и начать с перечня смешанного содержимого. Доводы за то, чтобы от смешения уходить, собраны в статьях «Как выбрать WinForms, WPF или WinUI» и «Оставить, обернуть или заменить ActiveX/OCX».

8. До какого уровня доходить — таблица решений и поэтапный путь

В случае WPF вариантов по сути два (варианта «ничего не делать = Unaware», как в WinForms, здесь нет: без объявлений приложение и так System Aware).

  Остаться System Aware Перейти на Per-Monitor
Как выглядит На основном мониторе чётко. Размывается на мониторе с другим DPI, после смены масштаба и по RDP Чётко на всех мониторах
Работа по объявлению Не нужна (по умолчанию) Только добавить манифест (глава 4)
Оставшаяся работа после объявления Визуальная проверка всех экранов + переключение растровых ресурсов по DPI (раздел 5.3) + обработка DpiChanged в пиксельном коде (глава 6) + проверка смешанного содержимого (глава 7)
Что задаёт потолок WindowsFormsHost, WebBrowser, сторонние компоненты
Подходящий случай Внутреннее приложение вокруг одного монитора и фиксированного рабочего стола. Приложения с большим объёмом смешанного содержимого Смешанное использование ноутбука и внешнего монитора, долгоживущее основное приложение, продукт, который раздают заказчикам

Оси решения те же, что в главе 7 статьи про WinForms (срок жизни приложения, среда использования, бюджет на доработку), но разница в том, что у WPF предельная стоимость перехода на Per-Monitor невелика. Объявление — один файл, за макетом следует фреймворк, оставшаяся работа пропорциональна объёму пиксельного кода и ресурсов. Для приложения с небольшим объёмом смешанного содержимого отдача от перехода на Per-Monitor заметно выше, чем в WinForms. Поэтапный путь, который мы реально рекомендуем на проектах, состоит из трёх шагов.

  1. Улучшение качества при System Aware: UseLayoutRounding на корне, векторизация и несколько разрешений иконок, правка DPI в коде вроде WriteableBitmap. Здесь закрывается всё, кроме размытия на нескольких мониторах, и этот объём остаётся полезным даже если Per-Monitor решите не включать.
  2. Объявление Per-Monitor и проверка: добавляем манифест и прогоняем все экраны в среде со смешанным DPI, выявляя проблемные места. Найденное здесь должно укладываться в одну из глав 5–7.
  3. Следование за сменой DPI: переключение ресурсов и пересоздание кэша в OnDpiChanged. На этом этапе решают, насколько допустимо смешанное содержимое.

Тестовую среду можно взять ту же, что в главе 7 статьи про WinForms (два монитора с разным масштабом, смена основного монитора с повторным входом в систему, смена масштаба во время работы, RDP с клиента с высоким DPI).1 В WPF особенно смотрите линии, иконки и собственную отрисовку при нецелых коэффициентах (125% / 150%) и поведение при перетаскивании окна между мониторами. Если заранее знать распределение среды (какой процент пользователей работает с несколькими мониторами), инвестиционное решение меньше плавает. Этот аспект мы разбирали и в статье «Проектирование UX Windows-приложения».

9. Итог

Благодаря DIP и автоматическому масштабированию WPF с самого начала System DPI Aware, и борьбы со сломанным макетом, которая была основной работой в WinForms, здесь почти нет. То, что остаётся, — четыре группы: размытие на нескольких мониторах (нет Per-Monitor), размытие растра, размытие тонких линий и смешанное содержимое, — и для каждой есть отработанное решение.

  • Размытие на нескольких мониторах: на WPF под .NET Framework 4.6.2+ / .NET достаточно объявления в манифесте, чтобы автоматически получить следование Per-Monitor
  • Тонкие линии и рамки: первый шаг — UseLayoutRounding="True" на корне, для остального — SnapsToDevicePixels
  • Иконки: первый кандидат — векторные ресурсы, для растра — несколько разрешений плюс переключение по DPI
  • По-настоящему править нужно только код, который считает пикселиWriteableBitmap, экранные координаты и подобное. Его объём определяет трудозатраты на переход к Per-Monitor
  • Верхнюю границу поддержки задают WindowsFormsHost / WebBrowser / сторонние компоненты. Сначала сведите их перечень

Даже приложение, которое остановилось на мысли «раз это WPF, значит всё в порядке», при таком разборе часто оказывается с считаным числом мест, которые действительно нужно чинить. Если сомневаетесь, насколько можно поправить конкретное приложение, включая смешанное содержимое, и какой уровень поддержки выбирать — можем помочь с обследованием и решением.

Похожие статьи

Смежные области консультаций

KomuraSoft LLC (合同会社小村ソフト) занимается поддержкой высокого DPI в приложениях на WPF / WinForms (обследование текущего состояния, оценка возможности перехода на Per-Monitor, перечень и доработка смешанного содержимого), разбором причин сбоев отображения при замене ПК и внедрении 4K-мониторов, а также консультациями по обновлению UI.

Справочные ссылки

  1. Microsoft Learn, High DPI Desktop Application Development on Windows. О таблице поддержки Per-Monitor по UI-фреймворкам, о том, что другой фреймворк внутри WPF и WPF внутри другого фреймворка автоматически не масштабируются, и о точках проверки в среде со смешанным DPI.  2 3 4 5

  2. Microsoft Learn, Developing a Per-Monitor DPI-Aware WPF Application. О том, что WPF по умолчанию System DPI Aware, о механизме автоматического масштабирования через DIP, о том, что перенос на монитор с другим DPI заставляет ОС масштабировать изображение (особенно размыто при нецелых коэффициентах), и о практике переключения растровых ресурсов по DPI.  2 3 4 5

  3. Microsoft Learn, What’s new in .NET Framework. О том, что Per-Monitor DPI awareness в WPF включили в .NET Framework 4.6.2, о переключателе Switch.System.Windows.DoNotScaleForDpiChanges и о руководстве разработчика на GitHub.  2

  4. GitHub (microsoft/WPF-Samples), Per Monitor DPI Developer Guide. О требованиях Windows 10 Anniversary Update и .NET Framework 4.6.2+, о записи dpiAwareness / dpiAware в манифесте, о AppContextSwitchOverrides для целей старше 4.6.2 и о том, что Per-Monitor не поддерживается для WPF, размещённого в HwndSource / ElementHost.  2 3 4 5 6

  5. Microsoft Learn, BitmapScalingMode Enum. О том, что значение по умолчанию (Unspecified) — это Linear, и о свойствах алгоритмов интерполяции HighQuality (Fant) и NearestNeighbor.  2 3

  6. Microsoft Learn, Layout - WPF. О макете через DIP и механизме субпиксельной отрисовки, из-за которого края размываются, о том, что округление макета (UseLayoutRounding) по умолчанию выключено, и о том, что установка на корневом элементе распространяет его на визуальное дерево.  2 3

  7. Microsoft Learn, UIElement.SnapsToDevicePixels Property. О том, что значение по умолчанию — false, о том, что установка на корне наследуется поддеревом, и о том, что это может снизить визуальные артефакты сглаживания вокруг тонких линий в средах выше 96 DPI.  2

  8. Служба поддержки Майкрософт, Изменение разрешения экрана и макета в Windows. О выборе масштаба в «Пуск > Параметры > Система > Дисплей» в разделе «Масштаб и разметка» пунктом «Изменение размера текста, приложений и других элементов». 

  9. Служба поддержки Майкрософт, Как сделать элементы на экране удобнее для просмотра с помощью экранной лупы. О том, что сочетание клавиши с логотипом Windows и знака плюс запускает экранную лупу и позволяет увеличить часть экрана. 

  10. Microsoft Learn, Setting the default DPI awareness for a process. О том, что элемент dpiAwareness (Windows 10 1607+) имеет приоритет над dpiAware, и об откате, при котором берётся первое распознанное значение из списка через запятую. 

  11. Microsoft Learn, What’s new in .NET Framework. О том, что .NET Framework 4.8 добавил в WPF поддержку Per-Monitor V2 DPI Awareness и Mixed-Mode DPI-масштабирования, об улучшениях взаимодействия с размещёнными HWND / WinForms и о переключателе AppContext, нужном для включения. 

  12. Microsoft Learn, TextFormattingMode Enum. О двух режимах форматирования текста — Ideal (идеальные метрики) и Display (метрики, совместимые с GDI). 

  13. Microsoft Learn, Layout Considerations for the WindowsFormsHost Element. О том, что WindowsFormsHost пересчитывает между двумя системами координат — DIP и физическими пикселями, — и о том, что масштабирование работает лишь настолько, насколько это умеет размещённый внутри элемент управления Windows Forms. 

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Говорят, в WPF отдельно поддерживать DPI не нужно. Почему тогда всё размывается?
В WPF единица макета — независимый от устройства пиксель (DIP), 1/96 дюйма, и по умолчанию приложение работает как System DPI Aware, поэтому за DPI основного монитора на момент запуска оно следует корректно. Но System DPI фиксируется при входе в систему: если перенести окно на монитор с другим DPI, ОС растягивает или сжимает всё окно как растровое изображение, чтобы компенсировать разницу масштаба, и картинка равномерно размывается. Особенно заметна потеря качества на нецелых сочетаниях вроде 125% и 150%. Устранить причину можно, объявив поддержку Per-Monitor DPI в манифесте: начиная с .NET Framework 4.6.2 WPF сам пересчитывает масштаб окна.
Почему в WPF линия толщиной 1px размывается, а толщина не совпадает в разных местах?
Потому что макет считается в DIP, и граница элемента не обязана попасть на целочисленную позицию физического пикселя. При 125% 1 DIP = 1,25px, и край, оказавшийся между пикселями, рисуется сглаживанием как полупрозрачный — отсюда размытие. Первый шаг — задать UseLayoutRounding="True" на корневом Window: на этапе макета нецелые пиксельные значения округляются и распространяются по всему визуальному дереву. То, что осталось, точечно закрывают SnapsToDevicePixels="True": оно привязывает края к границам пикселей уже при отрисовке. Оба свойства по умолчанию выключены.
Почему в WPF текст чёткий, а иконки размыты?
Текст рисуется как векторный шрифт и остаётся чётким при любом DPI, а растровые изображения размещаются в DIP и при смене DPI увеличиваются интерполяцией. Иконка 16×16px при 150% растягивается до 24×24px, и стандартная интерполяция (Linear) её размывает. Порядок мер: (1) перевести ресурс в вектор — Path/Geometry/DrawingImage или шрифт иконок, (2) подготовить растр в нескольких разрешениях и переключать по DPI через VisualTreeHelper.GetDpi или OnDpiChanged, (3) подобрать алгоритм интерполяции через RenderOptions.BitmapScalingMode.
Как включить поддержку Per-Monitor в WPF?
Объявление пишут элементом dpiAwareness в манифесте приложения. В отличие от WinForms с ApplicationHighDpiMode, у WPF нет настройки проекта и нет переключателя из кода. Нужны Windows 10 1607 или новее и целевая платформа .NET Framework 4.6.2 или новее: после объявления фреймворк сам обрабатывает WM_DPICHANGED и пересчитывает масштаб окна. Поддержка PerMonitorV2 в WPF есть только с .NET Framework 4.8, поэтому на 4.6.2–4.7.2 объявляют одиночный PerMonitor. Если целевая платформа проекта осталась 4.6.1 или старше, следование за DPI по умолчанию выключено и его включают переключателем AppContext. Отдельно: поведение Per-Monitor у WPF, размещённого в ElementHost или HwndSource, официально не поддерживается.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог