Pull to refresh

Откуда «мыло» в WPF и как с ним бороться

Reading time 21 min
Views 81K


Это руководство для WPF-разработчиков, стремящихся добиться максимально чёткой картинки в своих приложениях. Графическая система WPF до мозга костей векторная, но конечным результатом её работы по-прежнему является растр. Если не уделить этому факту должного внимания, можно столкнуться с различными сортами «мыла» — паразитными артефактами растеризации. В такой ситуации важно не терять присутствия духа, причины их возникновения вполне рациональны, а методы борьбы достаточно просты и эффективны.

Оглавление


Введение
1. Масштабирование растровых изображений
2. Координаты, не кратные размеру пикселя
3. Собственное разрешение растровых изображений
4. Растеризация векторных изображений
5. Перемещение текста по вертикали
6. Использование свойства SnapsToDevicePixels
7. Самостоятельная отрисовка контролов
Заключение
Ссылки

Введение


Коварство артефактов растеризации заключается в том, что они не бросаются в глаза. Многие разработчики просто не замечают дефектов размером в один-два пикселя. Тем не менее, эти мелочи влияют на ощущения пользователя от работы с приложением.

Небольшой тест на внимательность:


Далее речь пойдет о факторах, отличающих верхнюю картинку от нижней, и способах их устранения. Если в ваших приложениях таких проблем нет, попробуйте включить в настройках Windows режим увеличения текста и элементов интерфейса — скорее всего проблемы появятся. Обладатели небольших экранов с большим разрешением или просто люди со слабым зрением часто пользуются этой функцией.


Совсем не обязательно читать всё целиком и полностью, вы вполне можете ограничиться просмотром иллюстраций. Возможно, они вам запомнятся, и вы вернётесь к этому руководству, когда вам действительно понадобится бороться за чёткость графического вывода вашего приложения.

Каждый раздел руководства снабжен демонстрационным приложением, иллюстрирующим рассматриваемую проблему и методы её решения. Вы можете скачать всё единым архивом (104 Kb), содержащим скомилированные модули и их исходный код (формат проектов VS2010).

Итак, откуда же «мыло» WPF и как с ним бороться?

1. Масштабирование растровых изображений


При работе с растровыми изображениями самой частой причиной «замыливания» является масштабирование при выводе. Задайте элементу Image размеры, не совпадающие с физическим размером изображения, и результат уже не будет похож на исходник. Автоматическая подгонка размеров картинки к размеру контейнера часто приводит к аналогичному результату. В данном случае причина появления артефактов, это необходимость перевести изображение из одной растровой сетки в другую.



Противодействие

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

Если в ваши планы не входит масштабирование изображения у пользователей с увеличенными шрифтами, то придётся делать поправки на лету. Для начала понадобится узнать текущее разрешение вывода и его отношение к стандартному. Например так:

public static class Render
{
    static Render()
    {
        var flags = BindingFlags.NonPublic | BindingFlags.Static;
        var dpiProperty = typeof(SystemParameters).GetProperty("Dpi", flags);

        Dpi = (int)dpiProperty.GetValue(null, null);
        PixelSize = 96.0 / Dpi;
    }

    //Размер физического пикселя в виртуальных единицах
    public static double PixelSize { get; private set; }

    //Текущее разрешение
    public static int Dpi { get; private set; }
}

Далее можно в наследнике стандартного Image самостоятельно выставлять габариты картинки в зависимости от текущего разрешения вывода. В XAML такая картинка должна размещаться без указания конкретных размеров.

public class StaticImage : Image
{
    static StaticImage()
    {
        //Отслеживание смены исходной картинки
        Image.SourceProperty.OverrideMetadata(
            typeof(StaticImage), 
            new FrameworkPropertyMetadata(SourceChanged));
    }

    private static void SourceChanged(  DependencyObject obj, 
                                        DependencyPropertyChangedEventArgs e)
    {
        var image = obj as StaticImage;
        if (image == null) return;

        //Поправка размера картинки под текущее разрешение
        image.Width = image.Source.Width * Render.PixelSize;
        image.Height = image.Source.Height * Render.PixelSize;
    }
}



Если масштабирования не избежать, то всё, что вам остаётся, это побороться за уменьшение степени искажения, выбрав подходящий для вашего случая алгоритм вывода с помощью свойства RenderOptions.BitmapScalingMode.



2. Координаты, не кратные размеру пикселя


Достаточно простой способ получить незапланированное размытие — центрировать Image в контейнере (также работает для Rectangle и остальных наследников Shape). В половине случаев ширина контейнера не делится пополам нацело.

Впрочем, можно обойтись и без центрирования. Контейнер Grid, например, позволяет разделить себя на части в произвольных пропорциях. Вот характерный случай, когда в левой верхней ячейке всё хорошо, в правой нижней — туман, а в остальных — нечто среднее:

<!-- Габариты не делятся пополам нацело -->
<Grid Width="117" Height="117">        
    <Grid.RowDefinitions>
        <RowDefinition Height="*"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>

    <!-- Четыре идентичных картинки -->
    <Grid.Resources>
        <Style TargetType="Image">
            <Setter Property="Width" Value="48"/>
            <Setter Property="Height" Value="48"/>
            <Setter Property="Margin" Value="5"/>
            <Setter Property="VerticalAlignment" Value="Top"/>
            <Setter Property="HorizontalAlignment" Value="Left"/>
            <Setter Property="Source" Value="CookingPot.png"/>
        </Style>
    </Grid.Resources>

    <Image Grid.Column="0" Grid.Row="0"/>
    <Image Grid.Column="1" Grid.Row="0"/>
    <Image Grid.Column="0" Grid.Row="1"/>
    <Image Grid.Column="1" Grid.Row="1"/>
</Grid>


Не менее эффективно помогает размазать картинку задание в Canvas отступов или позиций, не кратных размеру пикселя.

Во всех перечисленных случаях вы получите «мыло», даже если никакого масштабирования при выводе не происходит. Границы пикселей изображения перестают попадать в границы пикселей экрана, и их размазывает при растеризации.


Противодействие

С контейнерами всё просто, установка свойства UseLayoutRounding в True заставляет контейнер (Window в том числе) автоматически округлять положение дочерних элементов до ближайшего целого значения пикселей. В остальных случаях придётся, так или иначе, явно привязывать координаты к границам пикселей.

Заметьте, что выражение «целые координаты» означает «координаты, делящиеся на размер пикселя нацело» только в стандартном разрешении 96 dpi, во всех остальных Math.Round вам не поможет. В общем случае округлить координату до ближайшей границы пикселей можно так:

static public double SnapToPixels(double value)
{
    value += PixelSize / 2;

    //На нестандартных DPI размер пикселя в WPF-единицах дробный.
    //Перемножение на 1000 нужно из-за потерь точности
    //при представлении дробных чисел в double
    //2.4 / 0.4 = 5.9999999999999991
    //240.0 / 40.0 = 6.0

    var div = (value * 1000) / (PixelSize * 1000);

    return (int)div * PixelSize;
}


3. Собственное разрешение растровых изображений


При первом столкновении с проблемой масштабирования растровых изображений вы, скорее всего, попробуете погуглить наличие у Image волшебного режима вывода без масштабирования. Такой режим есть, это Stretch=«None», но стоит на него понадеяться и убрать явное задание размеров вывода, как вы оказываетесь в группе риска. У растровых изображений есть своё собственное разрешение, оно указывается в метаданных, и WPF учитывает его при формировании габаритов картинки. Если вы не в курсе, что это такое, то, при удачном стечении обстоятельств, сможете поверить в черную магию: некоторые из имеющихся у вас изображений будут отрисовываться как положено, а полностью аналогичные им распухать или сжиматься при тех же условиях.

Скачайте эти три изображения для экспериментов (у каждого из них указано разное разрешение):

      

Если разрешение картинки не совпадает с разрешением вывода, то при загрузке его габариты умножаются на отношение разрешений, и при отрисовке в «оригинальном» размере вы внезапно получаете масштабирование. Кстати, приведённый в первом способе клаcc StaticImage также не защищён от этих искажений, так как опирается на свойства Source.Width и Source.Height.

Никто из графических редакторов или смотрелок не будет размахивать перед вами средствами управления разрешением изображения. Мало того, некоторые их этих программ вообще не показывают и не дают изменять этот параметр. Тем не менее, он важен.

<!-- Пиксельные размеры картинок одинаковы, разрешения разные -->
<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition Width="Auto"/>
    </Grid.ColumnDefinitions>     
        
    <Image Grid.Column="0" Source="Man1.png" Stretch="None" Margin="5"/>
    <Image Grid.Column="1" Source="Man2.png" Stretch="None" Margin="5"/>
    <Image Grid.Column="2" Source="Man3.png" Stretch="None" Margin="5"/>
</Grid>




Противодействие

Для того, чтобы габариты изображения в виртуальных единицах соответствовали его размеру в пикселях, вам необходимо выставить ему в графическом редакторе разрешение 96 dpi. С самим изображением при смене разрешения ничего не произойдёт, поменяются только метаданные.

Стандартный Paint для этого не подойдёт, вам понадобится что-то посерьёзнее. В популярной бесплатной смотрелке IrfanView вы можете задать разрешение в диалоге отображения свойств изображения (хоткей I):


В не менее бесплатном редакторе Paint.NET того же эффекта можно добиться зайдя в меню «Изображение», далее «Размер полотна…» (хоткей Ctrl+Shit+R).


Если вы не хотите или не можете работать с разрешением в графическом редакторе (например, ваше приложение работает с загружаемыми пользовательскими изображениями), вы можете поменять разрешение загружаемой картинки программно. Вот пример функции загрузки для 32-битных изображений:

//Загрузка Image.Source с принудительной установкой 96 dpi
BitmapSource ConvertBitmapTo96DPI(string path)
{
    var uri = new Uri(path);
    var bitmapImage = new BitmapImage(uri);

    int width = bitmapImage.PixelWidth;
    int height = bitmapImage.PixelHeight;

    int stride = width * 4; // 4 байта на пиксель
    var pixelData = new byte[stride * height];
    bitmapImage.CopyPixels(pixelData, stride, 0);

    return BitmapSource.Create( width, height, 96, 96, 
                                PixelFormats.Bgra32,
                                bitmapImage.Palette, 
                                pixelData, stride);
}


4. Растеризация векторных изображений


Из описания первых трёх причин возникновения «мыла» у вас может сложиться вполне обоснованное мнение, что c растровыми изображения в WPF одни проблемы. И действительно, для работы в векторной среде гораздо естественнее использовать векторные изображения. При первых экспериментах преобразование SVG в XAML кажется панацеей, позволяющей больше не задумываться о размерах и пикселях. Увы, это не так. Ровно в полночь карета превращается в тыкву, а векторное изображение растеризуется для вывода на экран.

Чем меньше пикселей на выходе, тем больше артефактов. На изображениях размером в 48 пикселей и меньше (это чуть ли не 80% всей графики в десктопных приложениях) ситуация вырождается в следующую: векторное изображение корректно растеризуется только в одном разрешении, под которое оптимизировано, в остальных уже постольку поскольку. Отобразите векторную иконку не в том размере, под который её готовили, и неумолимый антиалиасинг не заставит себя ждать.



Противодействие

В некоторых случаях можно обойтись простым увеличением размеров изображений. Например, для кнопок на панели инструментов использовать картинки размером 32х32 пикселя, а для иконок контекстного меню 25х25. Впрочем, если вам действительно важно, как будет растеризоваться векторная иконка, то её нужно оптимизировать под конкретное разрешение — нужные детали картинки должны совпадать с границами пикселей выходного растра.

5. Перемещение текста по вертикали


При отображении текста WPF использует некую технику повышения чёткости. Пока текст статичен, он выглядит максимально чётко для своего положения и выбранного режима растеризации (.NET Framework 4.0 и выше). При перемещениях же по вертикали, при некоторых величинах сдвига «шарпилка» резко отключается, а затем плавно включается обратно.

Вот пример паразитного эффекта размытия на кнопке с анимацией текста при нажатии:

<Button VerticalAlignment="Top">
    <Button.Template>
        <ControlTemplate TargetType="Button">
            <Border Width="255" Height="40"
                    BorderThickness="1 0 1 1" CornerRadius="0 0 10 10" 
                    BorderBrush="#FF202020" Background="#FFF7941D">
                <StackPanel Name="Panel" Orientation="Horizontal">
                    <Label    Content="Начните работу с нажатия этой кнопки" 
                            Foreground="#FF202020" VerticalAlignment="Center" 
                            Margin="20 0 0 0" Padding="0"/>
                </StackPanel>
            </Border>
            <ControlTemplate.Triggers>
                <Trigger Property="IsPressed" Value="True">
                    <Setter TargetName="Panel" Property="Margin" Value="3 1 -3 -1"/>
                </Trigger>
            </ControlTemplate.Triggers>
        </ControlTemplate>
    </Button.Template>
</Button>


Условия возникновения размытия в этом примере довольно таинственны. Судя по всему, работает комбинация из отсутствия верхнего бордюра, вложенности текста в StackPanel (в реальной ситуации в кнопке была ещё картинка) и резкого сдвига текста вниз на один-два пикселя.

Противодействие

В .NET Framework 4.0 и выше с помощью атрибута TextOptions вы можете выбирать из двух режимов растеризации текста: Ideal и Display. Это позволит немного уменьшить неприятный эффект размытия. В предыдущих версиях фреймворка режим растеризации соответствует режиму Ideal — как буква на пиксельную сетку ложится, так и растеризуется. В режиме Display используется промежуточная обработка: текст по горизонтали всегда четко привязан к пикселям, а одинаковые буквы растеризуются одинаково. Более подробно про режимы вывода текста можно прочитать здесь и здесь.

Эффект размытия от резкого перемещения текста легко воспроизвести в лабораторных условиях. Достаточно перемещать его по вертикали в положения, не кратные размеру пикселя. Ниже показан пример параллельного перемещения трёх блоков текста. Два верхних блока с одинаковым режимом вывода размываются по-разному. Очевидно, имеет значение не величина сдвига, а положение текста относительно растровой сетки.


Сам факт перемещения текста не обязательно приводит к «динамическому размытию». Если же этот эффект возникает, то влияет на всю строку. Увы, средств для управления этим эффектом разработчику не предоставляется. В некоторых случаях его возникновения можно избежать, если выравнивать координаты блока по границам пикселей или подбирать величину сдвига опытным путём.

6. Использование свойства SnapsToDevicePixels


Если вы используете такие базовые визуальные элементы как Rectangle, Ellipse, Line, Path, Border и др., то при выводе в координатах, не кратных размеру пикселя, они обязательно продемонстрируют вам размытие вертикальных и горизонтальных линий. Вот пример изображения, построенного с помощью обозначенных элементов:

<!-- Центрирующийся по обеим осям грид -->
<Grid HorizontalAlignment="Center" VerticalAlignment="Center">
    <Grid.RowDefinitions>
        <RowDefinition Height="10"/>
        <RowDefinition Height="20"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="25"/>
        <ColumnDefinition Width="6"/>
    </Grid.ColumnDefinitions>

    <!-- Антенна (нет ни вертикалей, ни горизонталей) -->
    <Ellipse Grid.Column="0" Grid.Row="0" Grid.RowSpan="2"
             Fill="Black" Width="10" Height="10"
             VerticalAlignment="Top" Margin="15 5 0 0"/>
    <Line  Grid.RowSpan="2"
           X1="10" X2="20" Y1="1" Y2="11" Stroke="Black"/>
    <Line  Grid.ColumnSpan="2" Grid.RowSpan="2"
           X1="30" X2="20" Y1="1" Y2="11" Stroke="Black" />

    <!-- Ящик с экраном (у экрана дробный отступ) -->
    <Border Grid.ColumnSpan="2" Grid.Row="1" Background="#FFF7941D"/>
    <Rectangle Grid.Column="0" Grid.Row="1"
               Fill="White" RadiusX="3" RadiusY="3" 
               Margin="2.5"/>

    <!-- Кнопки (тощина линий 1, линии совпадают границами пикселей) -->
    <Line Grid.Column="1" Grid.Row="1" StrokeThickness="1"  
          Stroke="Black" X1="0" X2="4" Y1="3" Y2="3"/>
    <Line Grid.Column="1" Grid.Row="1" StrokeThickness="1"  
          Stroke="Black" X1="0" X2="4" Y1="5" Y2="5"/>
    <Line Grid.Column="1" Grid.Row="1" StrokeThickness="1"  
          Stroke="Black" X1="0" X2="4" Y1="7" Y2="7"/>
</Grid>


На иллюстрациях ниже контрол с демонстрационной картинкой перемещается шагами по 0,2 пикселя. Сначала он делает это раздельно вдоль каждой из осей, потом по дуге окружности. В зависимости от фазы движения локальная координатная сетка контрола по-разному накладывается на физическую растровую сетку.



Иногда получаются четкими внешние границы телевизора, иногда границы его экрана, но это никогда не происходит одновременно. Кнопки четкие либо по горизонтали, либо по вертикали, а чаще размыты по обеим осям. Антенна в любой комбинации немного сглажена и по этому поводу не комплексует.

Противодействие

Можно включить у контролов привязку к пикселям установкой свойства SnapsToDevicePixels в True (для этого достаточно выставить этот атрибут у корневого грида). Результат будет стабильнее:



Тем не менее, картинка не является ни неизменной при перемещениях, ни идеально привязанной к пикселям. Экран телевизора мотает в пределах одного пикселя по обеим осям, а его кнопки всегда размыты.

Установка свойства SnapsToDevicePixels в True рекомендует визуальному элементу при отрисовке попадать своими границами в границы пикселей экрана. Каждый контрол будет стремиться это сделать с разным рвением и разными способами. Например, Image, Label и TextBlock относятся к этому атрибуту совершенно индифферентно. Line будет попадать в пиксели только при удачной исходной геометрии. Rectangle, наоборот, будет выпрыгивать из штанов и попадать в пиксели всегда.

Для более стабильной привязки к пикселям необходимо скорректировать исходное изображение:
  1. ко всем Y-координатам линий, изображающих кнопки, прибавить 0.5, чтобы их края совпали с пиксельной сеткой в пространстве контрола;
  2. сделать отступ у экрана телевизора целочисленным, например 2, чтобы его не мотало привязкой к ближайшим границам пикселей.



К слову, эти действия помогут получить четкий и стабильный вывод только в стандартном разрешении 96 dpi, в остальных же останется разброд и шатание. Для попадания в пиксели в любом разрешении следует обратиться к рекомендациям раздела «Самостоятельная отрисовка контролов» (размеры контролов придётся корректировать на ходу, исходя из физического размера пикселя).

7. Самостоятельная отрисовка контролов


Если вы перекрываете в своём визуальном элементе OnRender и отрисовываете его самостоятельно с помощью DrawingContext, то проблем растеризации у вас ровно столько же, сколько у наследников Shape из предыдущего способа, но при этом функциональность SnapsToDevicePixels вы должны реализовывать самостоятельно. Если, конечно, хотите. Можно не заморачиваться и делать примерно так:

public class Washer : FrameworkElement
{
    public Washer()
    {
        _brush = new SolidColorBrush(Color.FromRgb(247, 148, 29));
        _brush.Freeze();

        _pen = new Pen(Brushes.Black, 1);
        _pen.Freeze();
    }

    protected override void OnRender(DrawingContext dc)
    {
        //Ножки
        dc.DrawLine(_pen, new Point(1, 21), new Point(4, 21));
        dc.DrawLine(_pen, new Point(12, 21), new Point(15, 21));

        //Корпус
        var rect = new Rect(0, 0, 16, 21);
        dc.DrawRectangle(_brush, null, rect);
        
        //Кнопки
        dc.DrawLine(_pen, new Point(12, 1), new Point(12, 4));
        dc.DrawLine(_pen, new Point(14, 1), new Point(14, 4));
     
        //Окошко
        dc.DrawEllipse(Brushes.White, _pen, new Point(8, 11), 5, 5);

        //Защелка на окошке
        rect = new Rect(10, 10, 4, 2);
        dc.DrawRectangle(Brushes.White, _pen, rect);
    }

    private Pen _pen;
    private Brush _brush;
}

Несмотря на то, что все заданные координаты целочисленные, наложение на пиксельную сетку в пространстве контрола будет происходить так:


Ширина пера отсчитывается в обе стороны от заданной координатами линии. Если половина пера не делится на ширину пикселя нацело, то края линии не попадают в границы пикселей, даже если заданные координаты в них точно укладываются. Если изображение сложное, то может оказаться, что как изображение не сдвинь, а добиться, чтобы все его составляющие оказались чёткими не получается. На иллюстрации показано перемещение изображения с шагом в 0,2 пикселя.



Противодействие

WPF предоставляет специальные средства для привязки к пикселям — направляющие (guidelines). На этапе формирования цепочки действий по отрисовке контрола (именно этим занимается метод OnRender) вы можете указать вертикальные и горизонтальные координаты в пространстве контрола, которые при выводе должны попасть точно в границы пикселей.


В коде это выглядит так (только метод OnRender):

protected override void OnRender(DrawingContext dc)
{
    double halfPen = _pen.Thickness / 2;

    //Ножки
    var snapX = new double[] { 1, 12 };
    var snapY = new double[] { 21 + halfPen };
    dc.PushGuidelineSet(new GuidelineSet(snapX, snapY));        
    dc.DrawLine(_pen, new Point(1, 21), new Point(4, 21));
    dc.DrawLine(_pen, new Point(12, 21), new Point(15, 21));        
    dc.Pop();

    //Корпус
    snapX = new double[] { 0, };
    snapY = new double[] { 21 };
    dc.PushGuidelineSet(new GuidelineSet(snapX, snapY));
    var rect = new Rect(0, 0, 16, 21);
    dc.DrawRectangle(_brush, null, rect);
    dc.Pop();
        
    //Кнопки
    snapX = new double[] { 12 - halfPen };
    snapY = new double[] { 1 };
    dc.PushGuidelineSet(new GuidelineSet(snapX, snapY));
    dc.DrawLine(_pen, new Point(12, 1), new Point(12, 4));
    dc.DrawLine(_pen, new Point(14, 1), new Point(14, 4));
    dc.Pop();
     
    //Окошко
    snapX = new double[] { 3 - halfPen };
    snapY = new double[] { 6 - halfPen };
    dc.PushGuidelineSet(new GuidelineSet(snapX, snapY));
    dc.DrawEllipse(Brushes.White, _pen, new Point(8, 11), 5, 5);
    dc.Pop();

    //Защелка на окошке
    snapX = new double[] { 10 - halfPen };
    snapY = new double[] { 10 - halfPen };
    dc.PushGuidelineSet(new GuidelineSet(snapX, snapY));
    rect = new Rect(10, 10, 4, 2);
    dc.DrawRectangle(Brushes.White, _pen, rect);
    dc.Pop();
}

Для работы с направляющими вам не нужно знать текущее разрешение вывода. Достаточно расставить их надлежащим образом в локальных координатах контрола. Приведённый пример корректно работает только в стандартном разрешении 96 dpi, в котором ширина пера совпадает с размером пикселя. Чтобы добиться четких границ линий в других разрешениях, придётся назначать по направляющей на каждую из её сторон.



Важным нюансом является то, что с DrawingContext направляющие взаимодействуют через стек и при этом влияют на весь текущий вывод, а не только на фигуры, в границы которых они попадают. Именно поэтому в примере для выравнивания двух параллельных линий используется только одна направляющая на каждую ось. Если собрать все использованные направляющие и разом затолкать в стек, то результат будет плачевным. Из-за возникшего конфликта сработают только некоторые из них, остальные будут проигнорированы.

Выравнивание к пиксельным границам по направляющим осуществляется в обе стороны, поэтому в разных ситуациях разные части изображения могут быть передвинуты в разные стороны. При перемещениях картинки части стиральной машинки мотаются друг относительно друга, а в некоторых ситуациях у неё пропадают ножки. Изображение можно изменить, оптимизируя под стабильный вывод в конкретном разрешении, как было сделано в предыдущем разделе, но добиться стабильного вывода в любом разрешении не получится. Чуть ниже рассмотрен альтернативный способ привязки к пикселям, лишенный этого недостатка.

Альтернативное противодействие

Для привязки к пикселям при самостоятельной отрисовке контролов не обязательно использовать направляющие. Любители велосипедного спорта могут попадать в границы пикселей путём ручной коррекции отрисовываемых примитивов. Это не так сложно, как может показаться. Потребуется выполнять следующие условия:

  1. координаты исходных данных должны попадать в границы пикселей. Для 96 dpi можно воспользоваться Math.Round, для общего случая придётся округлять по конкретному размеру пикселя;
  2. ширина используемых перьев должна быть кратной размеру пикселя;
  3. в случаях когда ширина пера содержит нечётное количество пикселей, координаты отображаемого примитива должны быть сдвинуты на половину ширины пикселя;
  4. при выводе контрола необходимо делать поправку на сдвиг его координат относительно растровой сетки и перезапускать OnRender при любых его перемещениях.

Первые два пункта можно реализовать с помощью такого статического класса (его фрагменты приводились в первых двух разделах):

//Информация о текущем разрешении и функции по попаданию в границы пикселей
public static class Render
{
    static Render()
    {
        var flags = BindingFlags.NonPublic | BindingFlags.Static;
       var dpiProperty = typeof(SystemParameters).GetProperty("Dpi", flags);

       Dpi = (int)dpiProperty.GetValue(null, null);
        PixelSize = 96.0 / Dpi;
        HalfPixelSize = PixelSize / 2;
    }

    //Размер физического пикселя в виртуальных единицах
    public static double PixelSize { get; private set; }

    //Текущее разрешение
    public static int Dpi { get; private set; }

    //Округление до границ пикселей
    static public double SnapToPixels(double value)
    {
        value += HalfPixelSize;

        //На нестандартных DPI размер пикселя в WPF-единицах дробный.
        //Перемножение на 1000 нужно из-за потерь точности
        //при представлении дробных чисел в double
        //2.4 / 0.4 = 5.9999999999999991
        //2400.0 / 400.0 = 6.0

        var div = (value * 1000) / (PixelSize * 1000);

        return (int)div * PixelSize;
    }

    private static readonly double HalfPixelSize;
}

Если какую-либо величину (например, ширину пера или экранную координату) нужно жёстко привязать к размеру пикселя, то достаточно задать её как Render.PixelSize * n. Если же нужно округлить её до значения, кратного размеру пикселя, то необходимо воспользоваться методом Render.SnapToPixels.

Выполнение третьего и четвёртого условий (коррекции при сабпиксельных сдвигах контрола и нечётных размерах перьев) удобно реализовать в виде базового класса для самостоятельно отрисовываемых контролов:

public class SelfDrawingControlBase : FrameworkElement
{
    public SelfDrawingControlBase()
    {
        Snap = 0.5 * Render.PixelSize;
        SubpixelOffset = new Point(0, 0);
        LayoutUpdated += OnLayoutUpdated;
    }

    protected void OnLayoutUpdated(object sender, EventArgs e)
    {
        FixSubpixelOffset();
        InvalidateVisual();
    }

    //Подгонка координат линии для точного попадания в границы пикселей
    protected void SnapLine(Pen pen, ref Point begin, ref Point end)
    {
        var snapX = -SubpixelOffset.X;
        var snapY = -SubpixelOffset.Y;

        if (IsOdd(pen.Thickness))
        {
            if (begin.X == end.X)
                snapX += Snap;

            if (begin.Y == end.Y)
                snapY += Snap;
        }

        begin.X += snapX;
        begin.Y += snapY;

        end.X += snapX;
        end.Y += snapY;
    }

    //Подгонка координат прямоугольника для точного попадания в границы пикселей
    protected void SnapRectangle(Pen pen, ref Rect rect)
    {
        var snapX = -SubpixelOffset.X;
        var snapY = -SubpixelOffset.Y;

        if (pen != null && IsOdd(pen.Thickness))
        {
            snapX += Snap;
            snapY += Snap;
        }

        rect.Location = new Point(rect.Left + snapX, rect.Top + snapY);
    }

    //Подгонка координат эллипса для точного попадания в границы пикселей
    protected void SnapEllipse(Pen pen, ref Point center)
    {
        var snapX = -SubpixelOffset.X;
        var snapY = -SubpixelOffset.Y;

        if (pen != null && IsOdd(pen.Thickness))
        {
            snapX += Snap;
            snapY += Snap;
        }

        center.X += snapX;
        center.Y += snapY;
    }

    //Половинка пикселя для привязки к пиксельной сетке
    protected double Snap { get; private set; }

    //Общий сдвиг контрола относительно границ пикселей
    protected Point SubpixelOffset { get; private set; }

    //Выяснение сдвига контрола относительно границ пикселей
    //для учёта его в дальнейшей привязки к пикселям
    private void FixSubpixelOffset()
    {
        var offset = TranslatePoint(new Point(0, 0),
                                    Application.Current.MainWindow);

        SubpixelOffset = new Point( ModByPixel(offset.X),
                                    ModByPixel(offset.Y));
    }

    //Проверка на нечётное количество пикселей
    private static bool IsOdd(double value)
    {
        //На нестандартных DPI размер пикселя в WPF-единицах дробный.
        //Перемножение на 1000 нужно из-за потерь точности
        //при представлении дробных чисел в double
        //1.0 % 0.1 = 0.09999999999999995
        //1000.0 % 100.0 = 0.0
        return (value * 1000) % (Render.PixelSize * 2 * 1000) != 0;
    }

    //Остаток от деления на ширину пиксела
    private static double ModByPixel(double value)
    {
        return ((value * 1000) % (Render.PixelSize * 1000)) / 1000;
    }
}

Основная функциональность этого класса заключается в коррекции координат графических примитивов перед их выводом. Методы SnapXXX изменяют исходные данные таким образом, чтобы результат отрисовки попадал точно в границы пикселей.

Прямоугольники и эллипсы достаточно сдвинуть целиком на половину пикселя при перьях нечётной ширины. У горизонтальных линий нужно корректировать координату Y и не трогать координату X, у вертикальных — наоборот. При коррекциях координат также учитывается сдвиг контрола относительно пиксельной сетки.

Привязка к пикселям в примере со стиральной машинкой:

public class Washer : SelfDrawingControlBase
{
    public Washer()
    {
        _brush = new SolidColorBrush(Color.FromRgb(247, 148, 29));
        _brush.Freeze();

        _pen = new Pen(Brushes.Black, 1);
        _pen.Freeze();
    }

    protected override void OnRender(DrawingContext dc)
    {
        //Ножки
        Point start = new Point(1, 21);
        Point end = new Point(4, 21);
        SnapLine(_pen, ref start, ref end);
        dc.DrawLine(_pen, start, end);

        start = new Point(12, 21);
        end = new Point(15, 21);
        SnapLine(_pen, ref start, ref end);
        dc.DrawLine(_pen, start, end);

        //Корпус
        var rect = new Rect(0, 0,16, 21);
        SnapRectangle(null, ref rect);
        dc.DrawRectangle(_brush, null, rect);
        
        //Кнопки
        start = new Point(12, 1);
        end = new Point(12, 4);
        SnapLine(_pen, ref start, ref end);
        dc.DrawLine(_pen, start, end);

        start = new Point(14, 1);
        end = new Point(14, 4);
        SnapLine(_pen, ref start, ref end);
        dc.DrawLine(_pen, start, end);
     
        //Окошко
        var center = new Point(8, 11);
        SnapEllipse(_pen, ref center);
        dc.DrawEllipse(    Brushes.White, _pen,
                        center, 5, 5);

        //Защелка на окошке
        rect = new Rect(10, 10, 4, 2);
        SnapRectangle(_pen, ref rect);
        dc.DrawRectangle(Brushes.White, _pen, rect);
    }

    private Pen _pen;
    private Brush _brush;
}

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



Приведённое решение примера работает только в стандартном разрешении 96 dpi — размеры пера и координаты примитивов для наглядности вставлены в коде конкретными числами. Если вам необходимо, чтобы изображение корректно привязывалось к пикселям в любых разрешениях, то прежде чем передавать данные в методы SnapXXX, их нужно округлять до границ пикселей методом Render.SnapToPixels.
Вот, например, контрол, рисующий прямоугольник, который адекватно масштабируется при смене разрешения и попададает при этом в границы пикселей:

public class CrossDpiBrick : SelfDrawingControlBase
{
    public CrossDpiBrick()
    {
        _brush = new SolidColorBrush(Color.FromRgb(247, 148, 29));
        _brush.Freeze();

        _pen = new Pen(Brushes.Black, Render.SnapToPixels(7));
        _pen.Freeze();
    }

    protected override void OnRender(DrawingContext dc)
    {
        var rect = new Rect(Render.SnapToPixels(10),
                            Render.SnapToPixels(10),
                            Render.SnapToPixels(120),
                            Render.SnapToPixels(40));

        SnapRectangle(_pen, ref rect);

        dc.DrawRoundedRectangle(_brush, _pen, rect,
                                Render.SnapToPixels(10),
                                Render.SnapToPixels(10));
    }

    private Pen _pen;
    private Brush _brush;
}



Ручная привязка к пикселям требует чуть большего вмешательства в процесс отрисовки, чем при работе со встроенными средствами WPF, но позволяет более гибко управлять этим процессом и добиваться стабильного вывода при любом разрешении вывода.

Заключение


WPF предоставляет разработчику весь необходимый набор средств для привязки к экранным пикселям. В стандартной библиотеке контролов эти средства применяются по умолчанию, и в большинстве случаев можно не забивать себе голову сабпиксельными сдвигами. Однако, для достижения четкого вывода при работе с векторной и растровой графикой, а также при самостоятельной отрисовке контролов, разработчик вынужден использовать средства привязки к пикселям явным образом.

Для этого, в первую очередь, необходимо замечать возникающие проблемы растеризации. Это не так просто. Даже продукты от Microsoft не всегда могут похвастаться идеальной картинкой. Вот, например, элементы встроенного редактора векторной графики в Microsoft Word 2010:

Знакомые артефакты? Если теперь для вас ликвидация подобных обнаруженных проблем — дело техники, цель данного руководства достигнута. Спасибо за внимание!

Ссылки


MSDN — Pixel Snapping in WPF Applications
MSDN — UIElement.UseLayoutRounding Property
Pete Brown — Choose your Fonts and Text Rendering Options Wisely
MSDN Blogs — WPF 4.0 Text Stack Improvements
MSDN — How to: Apply a GuidelineSet to a Drawing
MSDN — UIElement.SnapsToDevicePixels Property

Исходный код демонстрационных примеров: скачать (104 Kb)

Спасибо за предоставленные иллюстрации:

romson

melkopuz

sevendot
Tags:
Hubs:
+166
Comments 62
Comments Comments 62

Articles