Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 6개 있습니다.)

WPF/Silverlight의 그래픽 단위와 Anti-aliasing 처리를 이해하자


얼마전부터, 글을 쓰다 보니 왠지 이 정도의 내용은 마이크로소프트웨어 잡지에 올려도 괜찮지 않을까 싶은 경우가 생기더군요. 아래의 글이 그랬고,

SQL Server 2012에 포함된 LocalDB 기능 소개
; https://www.sysnet.pe.kr/2/0/1258

이번 글이 또한 그랬습니다. 아래에 실린 내용이 원래 이 글을 썼던 '원본'입니다. 하지만 잡지사에 내려면 문체가 바뀌어야 하고, '지면의 압박'이 있어서 글의 내용이 다소 압축된 형태로 나가게 됩니다. 결국, 마이크로소프트웨어 잡지사에는 다음과 같은 내용으로 실렸습니다. (심지어 제목까지 바뀌었죠. ^^)

마이크로소프트의 새로운 그래픽 좌표 단위: DIU
; http://www.imaso.co.kr/?doc=bbs/gnuboard.php&bo_table=article&wr_id=39627

위의 글에 대한 오피스 워드 파일 원본은 이글에 실었으므로 참고하시고... 어떤 버전의 내용으로 글을 읽어도 전체적인 맥락은 같으므로 상관없으니... 각자 취향에 따라 읽어주시면 되겠습니다. ^^




지난번 경험을 통해서,

WPF - 그리기 성능 및 Blurring 문제
; https://www.sysnet.pe.kr/2/0/1186

이번에는 DPI와 WPF 그래픽 관계를 확실히 이해하고 넘어가야 할 때가 된 것 같아서 이렇게 정리해 봅니다. (사실, 제가 그래픽에는 약해서. ^^;)

참고로, 이에 대한 것은 역시 Petzold의 글이 최고인 듯 싶습니다. ^^

The 96 DPI Solution
; http://www.charlespetzold.com/blog/2005/11/250723.html

위의 글을 한번 정리해 볼까요?

WPF는 무조건 1/96inch에 해당하는 값을 좌표 단위로 사용합니다.
즉, WPF에서 Rectanlge.Width="96"인 사각형은 프린터로 출력 시 1inch(2.54cm)인 사각형이 출력되는 것입니다.

실제로 그런가 테스트를 해볼까요? 다음과 같이 길이가 (394 - 10) == 384인 선을 긋고,

<Window x:Class="WpfApplication2.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525" Loaded="Window_Loaded">

    <Grid Name="gridView">
        <Path Stroke="Black" StrokeThickness="1" >
            <Path.Data>
                <LineGeometry StartPoint="10,20" EndPoint="394,20" />
            </Path.Data>
        </Path>
    </Grid>

</Window>

wpf_dpi_1.png

코드로 프린팅을 해봅니다.

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void Window_Loaded(object sender, RoutedEventArgs e)
    {
        PrintDialog dialog = new PrintDialog();
        if (dialog.ShowDialog() == true)
        { 
            dialog.PrintVisual(gridView, "My"); 
        } 
    }
}

종이로 출력되었으면, 이제 직접 자를 대고 몇 cm인지 재봅니다. 어떠세요? 제 경우에는 거의 101mm 정도가 나왔습니다.

WPF 384 == 384 / 96 == 4inch
4inch * 2.54cm == 10.16cm == 101.6mm (1inch == 2.54cm)

계산값으로도 101.6mm가 나오니 육안으로 확인한 101mm라면 정확하다고 해야 겠지요. ^^

자... 이렇게 WPF에서 사용되는 수치 단위를 "DIU(device-independent units)"라고 부르고 있습니다. 1DIU == 1 / 96inch == 0.010416...inch == 0.0264583...cm 값입니다.

다음 단계로, 이제 윈도우 시스템에 설정된 DPI 값에 대해서 알아볼까요? 일반적으로 특별한 설정을 하지 않는 한 윈도우는 기본값으로 96 DPI로 설정합니다. 따라서, 윈도우에서는 모니터에 96pixel이 연속으로 찍힌 경우 프린터로 찍어 보면 1inch의 선이 그어지는 것입니다. 기본값으로 본다면, WPF의 DIU 단위와 윈도우의 DPI 단위가 같아서 1DIU == 1DPI == 1pixel 관계가 됩니다.

그럼, 모니터에서 실제로 보여지는 크기를 자로 재보면 어떻게 될까요? 가령, 96 pixel이 찍혔다고 해서 직접 모니터에 inch 잣대를 대고 계산하면 1inch가 나올까요?

여기서 PPI(Pixel per Inch) != DPI(Dots per Inch) 임을 알아야 합니다. PPI는 실제 모니터의 크기 안에 들어가는 pixel 수를 나타냅니다. (웹에서 검색된 자료들 중에는, 화면에서도 동일하게 1inch로 나온다는 식의 설명을 볼 수 있는데 이는 몇몇 경우에만 사실이고, 일반적인 대다수의 사용자들에게는 사실이 아닙니다.)

이에 대해서는 아래의 글에서 잘 설명이 되어 있습니다.

Is WPF Really Resolution Independent?
; http://www.wpflearningexperience.com/?p=41

(참고로, 위의 설명에서는 PPI를 Physical DPI로 표현하고 있습니다.)

이에 따라 제 노트북 액정화면의 PPI를 구해볼까요?

pixel 해상도: 1600 * 900
모니터 실제 크기: 34.4cm * 19.3cm (자를 대고 육안으로 확인)
가로 인치 계산: 34.4cm / 2.54 = 13.543inch
세로 인치 계산: 19.3cm / 2.54 = 7.59842inch
가로 PPI: 1600 / 13.543 == 118.1422...
세로 PPI: 900 / 7.59842 == 118.4456...

Dell Studio 1557 노트북 사양에 보면 15.6" 모니터라고 했는데 13.543 * 13.543 + 7.59842 * 7.59842 = 183.412849 + 57.7359865 = 241.148835가 나오고 그 값을 Sqrt(241.148835)로 하면 15.5289676...inch라고 나오니... 육안으로 잰 것을 감안하면 그런대로 맞는 것 같습니다. ^^

어쨌든, 제 모니터에서는 118개의 pixel이 화면에 보이는 경우 모니터 상에 자로 재보면 1inch(2.54cm)가 나오는 것입니다.

그렇다면 WPF의 96DIU 값은 96DPI로 설정된 제 액정에서는 96개의 pixel로 표현이 됩니다. 제 모니터의 PPI 값이 118.1422...이므로 96개의 pixel을 cm로 환산해 보면 96 / 118.1422... * 2.54 == 2.06395...cm가 나옵니다. 확인을 위해 96DIU인 선이 그어져 있는 액정 모니터에서 직접 자로 재보면 2cm가 나오는 것을 볼 수 있습니다.

자, 그럼 제 모니터를 기준으로 화면에 나오는 길이가 프린터로 나오는 길이와 동일하게 맞추고 싶다면 어떻게 해야 할까요? 간단합니다. 윈도우의 DPI 값을 118로 맞춰주면 됩니다.

118 / 96 * 100 = 123.065...가 나오므로, 다음과 같이 제어판에서 값을 조정해 주면 됩니다.

wpf_dpi_2.png

이렇게 조정하고, 다시 96DIU인 선을 액정 화면과 프린터 결과물에 대고 자로 재보면 동일하게 2.54cm 정도가 나옵니다. 물론 대부분 96DPI 기본값을 수용하면서 PC에서 작업을 하지만,,, 아마도 디자이너들에게는 실물 크기 그대로 모니터에 나오는 것이 유용할 수 있겠지요.

(계산 결과들에 소수점 이하의 값이 포함되기 때문에 약간의 오차는 있을 수 있습니다.)




정리해 보면, 기존에는 2.54cm의 선을 그으려면 우선 윈도우의 DPI 값을 알아내야 했습니다. 96DPI로 설정된 경우에는 96pixel로 선을 그으면 되었지만, 120DPI에서는 120pixel로 개발자가 맞춰주어야 했습니다. 허나, DIU 값을 사용하고 있는 WPF에서는 그냥 96DIU 값을 설정해 놓으면 그 값 그대로 2.54cm의 선이 프린터로 출력되는 것입니다.

바로 이런 관계 때문에 WPF의 좌표 단위가 "디바이스 독립적인" 단위(DIU)라고 불리는 것입니다. 자... 이렇게 해서 DIU에 대해서는 확실히 알게 되었고.

혹시, 시스템에 설정된 DPI 값을 프로그램으로 가져오는 방법이 있을까요? 이에 대해서는 다음의 글에서 잘 설명해 주고 있습니다.

Best way to get DPI value in WPF
; http://dzimchuk.net/blog/post/Best-way-to-get-DPI-value-in-WPF.aspx

몇몇 방법들을 소개해 주고 있는데, 여기서는 WPF 어셈블리 내에서 구할 수 있는 마지막 4번째 코드를 살펴보겠습니다.

public void TransformToPixels(Visual visual, double unitX, double unitY, out int pixelX, out int pixelY)
{
    Matrix matrix = PresentationSource.FromVisual(visual).CompositionTarget.TransformToDevice;
     
    pixelX = (int)(matrix.M11 * unitX);
    pixelY = (int)(matrix.M22 * unitY);
}

WPF에서는 개발자가 수작업으로 pixel 값을 맞춰줄 필요가 없어서인지 WPF 관련 어셈블리 내에서는 DPI 값 자체를 반환해 주는 방법은 없습니다. 단지, 반대로 DIU 단위가 몇 pixel로 표현이 되는지에 대해서 오히려 관심사가 되어버린 것입니다.

이렇게 DIU 값에 해당하는 pixel을 구하려면 DPI 비율을 알아내야 하는데 위의 코드에서 matrix.M11, matrix.M22 값으로 전달되는 것이 바로 그 비율입니다. 그리하여, 96DPI에서는 matrix.M11 == 1 이 되고, 120DPI에서는 matrix.M11 == 1.25 값이 나옵니다.

즉, 반대로 matrix.M11 * 96을 하면 해당 윈도우 시스템의 DPI 값이 나옵니다.




위의 지식을 가지고, 지난번에 작업했던 Bitmap 코드를 보면 RenderTargetBitmap 개체를 생성하는데 오류가 있다는 것을 확인할 수 있습니다.

==== 방법 2-1: Bitmap 그래픽으로 렌더링 ====

<ScrollViewer HorizontalScrollBarVisibility="Auto" Grid.Column="1" x:Name="scrollViewer1" >
    <Image Name="image1" Stretch="None" />
</ScrollViewer>

private void DrawVisual()
{
    DrawingVisual dv = new DrawingVisual();

    int size = 10;

    DrawingContext dc = dv.RenderOpen();
    Pen pen = new Pen(Brushes.Black, 1.0);

    Random rand = new Random();

    double x1 = 0;
    double y1 = 0;

    for (int i = 0; i < 50000; i++)
    {
        x1 = (i % 250) * size;
        y1 = (i / 250) * size;

        dc.DrawRectangle(null, pen, new Rect(x1, y1, size, size));
    }

    dc.Close();

    RenderTargetBitmap rtb = new RenderTargetBitmap(size * 250, size * 200, 96, 96, PixelFormats.Pbgra32);
    rtb.Render(dv);
    image1.Source = rtb;
}

4개의 인자 값이 요구되는 원래 단위는 다음과 같습니다.

size * 250: int pixelWidth
size * 200: int pixelHeight
첫 번째 96: dpiX
두 번째 96: dpiY

결국 위의 값들은 96DPI 윈도우 설정에서는 들어맞지만, 120DPI 로 설정된 경우에는 모든 값들이 유효하지 않게 됩니다. 따라서, 위의 코드는 다음과 같이 바뀌어야 합니다.

Size size = GetPixelSize(size * 250, size * 200);
var dpiValue = GetDPI();
            
RenderTargetBitmap rtb = new RenderTargetBitmap(
    (int)size.Width, (int)size.Height, dpiValue.Item1, dpiValue.Item2, PixelFormats.Pbgra32);

private Size GetPixelSize(int diuX, int diuY)
{
    int width, height;

    TransformToPixels(this, diuX, diuY, out width, out height);
    return new Size(width, height);
}

private Tuple<int, int> GetDPI()
{
    Matrix matrix = PresentationSource.FromVisual(this).CompositionTarget.TransformToDevice;

    return new Tuple<int, int>((int)matrix.M11 * 96, (int)matrix.M22 * 96);
}

public void TransformToPixels(Visual visual, double unitX, double unitY, out int pixelX, out int pixelY)
{
    Matrix matrix = PresentationSource.FromVisual(visual).CompositionTarget.TransformToDevice;

    pixelX = (int)(matrix.M11 * unitX);
    pixelY = (int)(matrix.M22 * unitY);
}




그다음, 왜 WPF에서 선을 그은 경우 뚜렷하지 않고 번져 보이게 되는지 알아보겠습니다.

전에도 설명드렸지만 이에 대해서는 다음의 글에서 잘 설명되고 있습니다.

Draw lines excactly on physical device pixels
; http://www.wpftutorial.net/DrawOnPhysicalDevicePixels.html

그래도, 이번 글에서 쓴 내용을 읽고 나서 위의 글을 읽어보시면 더욱 이해가 잘 되실 것입니다. ^^

그럼, 정리를 해볼까요?

우선, WPF의 DIU 단위가 윈도우의 DPI 설정에 따라 1pixel로도 될 수 있고 1.25pixel로도 될 수 있기 때문에 번져 보이는 것은 당연합니다. 1.25라는 pixel은 없기 때문인데요. 개발자는 프린터에 0.010416...inch의 굵기로 선을 그리고 싶어서 WPF에서 1DIU 값을 사용하지만 96DPI 윈도우에서는 1pixel로 정확하게 선을 표현할 수 있다고 해도 120DPI 윈도우에서는 1.25pixel을 화면에 정확하게 그릴 수가 없게 되는 것입니다. WPF에서는, 어쩔 수 없이 이렇게 double 형식의 pixel 값을 인정해야만 합니다.

그렇다면, 우리가 흔히 쓰는 96DPI 윈도우에서는 1DIU인 선이 정확하게 1pixel로 나와서 선이 그어져야 하지 않았을까요? 96DPI에서도 그렇게 나오지 않는 이유는, 1pixel 의 시작 지점을 물리 pixel과 일치시키지 않고 아래의 그림과 같이 2개의 pixel에 걸쳐서 0.5pixel로 나눠서 표현했기 때문입니다.

[그림 출처: http://www.wpftutorial.net/DrawOnPhysicalDevicePixels.html]
wpf_dpi_3.png

결국, (2,2) ~ (6,2) DIU 값을 사용해서 WPF에서 선을 그리면 논리적으로 (1.5,1.5) ~ (6.5, 2.5)의 선을 나타내고, 화면에서는 이를 2개의 pixel로 번져 보이도록 표현하게 된 것입니다. 게다가 물리적인 2개의 pixel로는 번짐 효과를 낼 수 없기 때문에 이를 위해 WPF에서는 컬러 값을 조정해서 표현합니다. 구체적인 것은 다음의 Subpixel Rendering 설명을 참고하세요.

Subpixel rendering
; http://en.wikipedia.org/wiki/Subpixel_rendering

그런데, 다시 질문이 이어집니다. 그렇다면 2 DIU 값으로 지정하면 정확하게 2pixel 값을 꽉 채우게 되므로 선이 선명하게 표현되어야 할 텐데 종종 그렇지 않은 경우가 있는 것은 왜일까요? 이유는, 선이 그려지는 UIElement의 위치부터 이미 double 값으로 틀어져 있기 때문에 2pixel로 Pen을 지정한 선을 긋는다 해도 연쇄적으로 번져 보이게 되는 것입니다.

자... 이에 대해서 눈으로 직접 테스트 해볼까요?

예제로 다음과 같이 XAML을 구성하고,

<Window x:Class="WpfApplication2.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="251" Width="419" Loaded="Window_Loaded">
    <Grid Name="gridView">
        <Canvas Height="181" HorizontalAlignment="Left" 
                Margin="12,12,0,0" Name="canvas1" VerticalAlignment="Top" Width="294">
            <Line X1="10" Y1="10" X2="50" Y2="10" Stroke="Black" StrokeThickness="1" />
        </Canvas>

        <Button Content="Button" Height="23" HorizontalAlignment="Left" Margin="312,12,0,0" Name="button1" VerticalAlignment="Top" Width="75" Click="button1_Click" />
    </Grid>
</Window>

button1_Click 이벤트 핸들러에서 각 UIElement의 Pixel 좌표를 계산해 보겠습니다.

private void button1_Click(object sender, RoutedEventArgs e)
{
    Point pt = this.gridView.TransformToAncestor(this).Transform(new Point(0, 0));
    System.Diagnostics.Trace.WriteLine("Grid: " + pt);

    pt = this.canvas1.TransformToAncestor(this).Transform(new Point(0, 0));
    System.Diagnostics.Trace.WriteLine("Canvas: " + pt);
}

다음은 실행한 결과 및 좌표값입니다.

wpf_dpi_4.png

Grid: 0,0 
Canvas: 12,12 

WPF의 <Window /> 요소 자체는 윈도우 운영체제내에서는 Pixel 좌표로 화면에 뜨기 때문에 이에 꽉 차게 자리잡고 있는 <Grid /> 역시 pixel 좌표 상으로 (0, 0)으로 정확하게 위치하고 있습니다. 부모 Grid가 정수 값의 pixel 좌표를 갖기 때문에 96DPI인 제 윈도우 시스템에서 Canvas 역시 (12,12)의 pixel 좌표에 정확하게 일치하고 있습니다.

그런데, Canvas 내부에 그려진 선은 Pen의 두께가 1DIU로 지정되었고 위에서 설명한 데로 2pixel에 걸쳐서 나오기 때문에 번져보이게 됩니다. 이론대로라면 이 상태에서 StrokeThickness 값을 2로 주면, 번짐 현상이 사라져야 하는데요. 실제로 해보시면 아래와 같이 예상대로 뚜렷한 2pixel의 선으로 나타납니다.

wpf_dpi_5.png

자, 그럼 다시 Line 값의 Y1/Y2 값을 double 형으로 주어보면 어떻게 될까요?

<Line Name="line1" X1="10" Y1="10.5" X2="50" Y2="10.5" Stroke="Black" StrokeThickness="2" />

예상한 바와 같이, 1pixel은 꽉 차게 표현되므로 뚜렷하게 나타나고 나머지 1pixel은 0.5pixel씩 걸치게 되므로 희미하게 처리됩니다.

wpf_dpi_6.png

그렇다면 주의해서 언제나 정수형의 좌표 체계를 쓰면 그런대로 선을 뚜렷하게 표현할 수 있을 것 같은데, 현실은 그렇지 않습니다. 이전에도 설명한 것처럼 그것은 96DPI일 때의 가정이고, 윈도우 사용자가 임의의 값으로 DPI 값을 지정한 경우에는 어쩔 수 없이 double 형으로 pixel 값 대응이 이뤄질 수밖에 없습니다. 그 외에도, Layout 컨트롤을 사용하는 경우에도 double 형 좌표값이 나올 수 있습니다.

예를 들어, 다음과 같이 Width == 419인 윈도우에 2개의 컬럼을 갖는 Grid를 정의한다면,

<Window x:Class="WpfApplication2.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="251" Width="419" Loaded="Window_Loaded">
    <Grid Name="gridView">
        <Grid.ColumnDefinitions>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
        </Grid.ColumnDefinitions>
    </Grid>
</Window>

0번째 컬럼은 pixel 0에서 시작하겠지만, 1번째 컬럼은 419 / 2 = 209.5의 값을 가져서 어쩔 수 없이 double 형 값을 가지게 됩니다.

그렇다면, 그럴 때마다 매번 Line의 Y1/Y2 값을 double 형의 값을 상쇄시키는 값으로 지정해 주어야 하는 것일까요?

다행히, 이를 위해서 WPF에서는 SnapsToDevicePixels라는 속성을 제공해 주고 있는데 이 값을 True로 하면 pixel에 일치하게 값을 보정해 줍니다. 예를 들어, 위의 Line에서는 다음과 같이 설정해 주면 다시 뚜렷한 2pixel의 선을 볼 수 있습니다.

<Line Name="line1" X1="10" Y1="10.5" X2="50" Y2="10.5" 
    Stroke="Black" StrokeThickness="2" SnapsToDevicePixels="True" />




SnapsToDevicePixels과 UseLayoutRounding 속성의 차이는 뭘까요? 2가지 다 비슷한 의미로 사용되는데, 사실 주요한 차이점들이 있습니다.

  1. SnapsToDevicePixels는 Silverlight에서 지원되지 않는다.
  2. UseLayoutRounding은 WPF 4.0 이상에서 지원되고 Silverlight에서는 3.0부터 지원된다.
  3. UseLayoutRounding은 Layout 정렬 단계에서 픽셀값을 보정하고, SnapsToDevicePixels은 Rendering 단계에서 보정한다.

1번과 2번은 그 자체가 설명이 되므로 생략하고, 3번의 차이점을 알아보기 위해 직접 환경을 만들어서 테스트를 해보겠습니다.

Layout 단계는 WPF에서 다시 Measure/Arrange 단계로 나뉘는 데, 사용자 정의 Panel을 만들면 UseLayoutRounding 값이 어떻게 적용되는지를 확인해 볼 수 있습니다. 이를 위해 다음과 같이 임의의 Panel을 만들고,

// 책 "Programming Windows Phone 7"에 포함된 SingleCellGrid 소스 코드

public class MyPanel : Panel
{
    protected override Size MeasureOverride(Size availableSize)
    {
        Size compositeSize = new Size();
        foreach (UIElement child in Children)
        {
            child.Measure(availableSize);
            compositeSize.Width = Math.Max(compositeSize.Width,
            child.DesiredSize.Width);
            compositeSize.Height = Math.Max(compositeSize.Height,
            child.DesiredSize.Height);
        }

        return compositeSize;
    }

    protected override Size ArrangeOverride(Size finalSize)
    {
        foreach (UIElement child in Children)
        {
            child.Arrange(new Rect(new Point(), finalSize));
        }

        return base.ArrangeOverride(finalSize);
    }
}

이 패널에 다음과 같이 Rectangle 요소를 넣어보겠습니다.

<Window ...[생략]...>
    <my:MyPanel>
        <Rectangle UseLayoutRounding="False" Name="rect1" StrokeThickness="2"
            Width="45.5" Height="10.5" Margin="10" Stroke="Black" />
    </my:MyPanel>
</Window>

이제 Visual Studio에서 디버깅 모드로 시작한 후, ArrangeOverride에서 child.DesiredSize == {65.5, 30.5} 값으로 나오는 것을 확인할 수 있고, 소수점으로 인해 Rectangle 요소의 선은 번져서 나옵니다. 반면, 이 상태에서 UseLayoutRounding 값을 다시 "True"로 바꾸면 child.DesiredSize == {66, 30}으로 바뀌어 사각형의 외곽선은 다시 뚜렷하게 바뀝니다. 이처럼, UseLayoutRounding은 해당 FrameworkElement가 가지게 되는 영역 값에 소수점이 있다면 정수로 바꾸고 Pixel 정렬이 되도록 해주는 역할을 합니다.

UseLayoutRounding 속성이 Layout 단계에서만 효과를 발휘하는 이런 성질로 인해, MSDN 도움말에는 다음과 같은 문구가 나옵니다.

FrameworkElement.UseLayoutRounding Property 
; https://docs.microsoft.com/en-us/dotnet/api/system.windows.frameworkelement.uselayoutrounding

You should set UseLayoutRounding to true on the root element. The layout system adds child coordinates to the parent coordinates; therefore, if the parent coordinates are not on a pixel boundary, the child coordinates are also not on a pixel boundary. If UseLayoutRounding cannot be set at the root, set SnapsToDevicePixels on the child to obtain the effect that you want.


이를 테스트 하는 방법은 간단합니다. 다음과 같이 MyPanel에 Margin 값을 소수점으로 주면 Rectangle에 아무리 UseLayoutRounding 값이 True로 설정되었다고 해도 다시 Rectangle의 선은 번지게 됩니다.

<Window ...[생략]...>
    <my:MyPanel Margin="0.5 0.5 0.5 0.5">
        <Rectangle UseLayoutRounding="True" Name="rect1" StrokeThickness="2"
            Width="45.5" Margin="10" Height="10.5" Stroke="Black" />
    </my:MyPanel>
</Window>

이를 방지하기 위해서는, 가능한 최상단 요소(위의 예제에서는 Window 개체)에 UseLayoutRounding="True" 속성을 지정해 줄 것을 권장합니다. 그래서 다음과 같이 바꿔주면 다시 뚜렷한 선처리를 확인할 수 있습니다. (물론, 아래의 예제에서는 MyPanel에 UseLayoutRounding="True" 값을 주어도 됩니다.)

<Window ...[생략]... UseLayoutRounding="True" >
    <my:MyPanel Margin="0.5 0.5 0.5 0.5" >
        <Rectangle Name="rect1" StrokeThickness="2"
            Width="45.5" Margin="10" Height="10.5" Stroke="Black" />
    </my:MyPanel>
</Window>

MSDN 문서에서는, 만약 UseLayoutRounding 속성을 최상위 개체에 사용할 수 없는 경우에 대해서도 언급하고 있는데, 그런 경우에는 SnapToDevicePixels를 지정해 주라고 씌여 있습니다.

예를 들어, 다음과 같은 환경에서는 Rectangle 개체를 포함하는 부모 요소가 소수점을 포함하는 위치 값을 가지고 있으면서 그 요소를 임의로 변경할 수 없는 상황에서는 다음과 같이 SnapsToDevicePixels 값을 설정해야 합니다.

<Window ...[생략]...>
    <my:MyPanel Margin="0.2 0.2 0.2 0.2" >
        <Rectangle Name="rect1" StrokeThickness="2" SnapsToDevicePixels="True"
            Width="45.5" Margin="10" Height="10.5" Stroke="Black" />
    </my:MyPanel>
</Window>

SnapsToDevicePixels 속성은 Layout 단계에서 값을 보정하지 않고 실제 Rendering 시에 값을 보정해 주기 때문에 내부의 요소에 대한 누적값이 소수점인 경우에도 정상적으로 값을 보정해 주기 때문입니다.

문제는, SnapsToDevicePixels 처리가 WPF에서 그다지 일률적으로 적용되지 않는다는 데 있습니다. 사실 System.Windows.Shapes.Shape에 포함된 Ellipse, Line, Path, Polygon, Polyline, Rectangle 중에서 요소 내에 소수점을 포함하는 경우 SnapsToDevicePixels이 적용되는 경우는 Rectangle 단 한 가지에 불과합니다. Ellipse는 태생 자체가 타원을 표현해야 하기 때문에 점과 점이 곡선으로 부드럽게 연결되는 것을 표현하기 위해 "anti-aliasing"이 처리되므로 어쩔 수 없고, 그 외의 Shape 클래스들은 내부적으로 소수점 좌표를 포함하는 경우 SnapsToDevicePixels 값에 상관없이 "anti-aliasing" 처리가 됩니다. 예를 들어, Path의 경우 다음과 같이 내부적으로 RectangleGeometry를 포함하더라도 그 값이 소수점 좌표를 포함하면 SnapsToDevicePixels(또는 UseLayoutRounding) 속성을 사용하더라도 "anti-aliasing" 처리가 됩니다.

<Path Stroke="Black" StrokeThickness="2" SnapsToDevicePixels="True" >
    <Path.Data>
        <RectangleGeometry Rect="10.5 10.5 20.5 20.5" />
    </Path.Data>
</Path>

엄밀히 따지면, 위의 경우를 예로 들어 'SnapsToDevicePixels 처리가 일률적으로 적용되지 않는다'라는 것은 올바른 표현은 아닙니다. 왜냐하면 Path 요소의 목적 자체가 임의의 선을 긋는 것이므로 Ellipse와 별다른 점이 없기 때문에 Anti-aliasing으로부터 자유로울수는 없습니다. 물론, RectangleGeometry 요소의 의미가 단순 사각형을 의미하기는 하지만 Rectangle 요소와는 달리 Path 내부에서 AA를 제거하도록 구성한다는 규칙을 두기에는 애매할 것이기 때문입니다.

따라서, AA 없는 사각형을 그리고 싶다면 Rectangle 요소를 사용해야 합니다. 그렇지 않고, Line, Path, Polyline, RectangleGeometry 등의 요소를 사용하게 되면 AA 없는 결과물을 얻기가 쉽지 않습니다.

또한, Canvas 상에 Shape을 나타낼 때에는 Path를 제외한 요소들에 대해서는 정상적으로 SnapsToDevicePixels 속성값이 적용되는 것을 확인할 수 있습니다.

정리하면, 만약 여러분들이 "anti-aliasing" 처리가 되지 않은 뚜렷한 선 처리를 원한다면 가장 최상위 클래스에 UseLayoutRounding="True" 값을 주고 그 이하의 요소들에서는 소수점 좌표를 사용하지 않아야만 원하는 목적을 이룰 수 있습니다.




마지막으로, 사용자가 직접 라인을 그리는 경우에 "anti-aliasing"이 되지 않도록 처리하는 방법을 설명해 보겠습니다. 이 방법이 필요한 이유는 UseLayoutRounding 속성은 FrameworkElement 수준에서 제공이 되고, SnapsToDevicePixels 속성은 UIElement 수준에서 제공되므로 사용자가 직접 DrawingVisual 개체를 사용해서 그리는 값에 대해서는 적용되지 않기 때문입니다.

예를 들어서, 다음과 같이 DrawingVisual 개체를 이용하여 StackPanel의 Background에 선을 그린 경우,

=== XAML ===
<StackPanel Name="stackPanel" Width="450" Orientation="Horizontal">

=== XAML.cs ===
DrawingVisual drawingVisual = new DrawingVisual();
DrawingContext drawingContext = drawingVisual.RenderOpen();

drawingContext.DrawRectangle(null, new Pen(Brushes.Black, 2), new Rect(0, 0, 60, 60));

drawingContext.Close();

VisualBrush brush = new VisualBrush(drawingVisual);
brush.Stretch = Stretch.None;
stackPanel.Background = brush;

분명히, DrawRectangle의 값으로 정수형 좌표를(0, 0, 60, 60) 설정해도 StackPanel을 포함하는 윈도우의 크기가 바뀌면 "anti-aliasing" 처리가 되는 것을 확인할 수 있습니다.

이런 경우에는, 다음과 같이 그래픽 영역이 그려질 처음과 끝의 영역에 대해 정수값으로 가이드라인을 지정해 주면 "anti-aliasing" 처리가 사라져서 선이 뚜렷하게 그어집니다.

DrawingVisual drawingVisual = new DrawingVisual();

Point ptStart = this.stackPanel.TransformToAncestor(this).Transform(new Point(0, 0));
Size size = this.stackPanel.DesiredSize;
Point ptEnd = this.stackPanel.TransformToAncestor(this).Transform(new Point(size.Width, size.Height));

GuidelineSet guidelines = new GuidelineSet();
guidelines.GuidelinesX.Add(ptStart.X);
guidelines.GuidelinesX.Add(ptEnd.X);
guidelines.GuidelinesY.Add(ptStart.Y);
guidelines.GuidelinesY.Add(ptEnd.Y);

DrawingContext drawingContext = drawingVisual.RenderOpen();

drawingContext.PushGuidelineSet(guidelines);
drawingContext.DrawRectangle(null, new Pen(Brushes.Black, 2), new Rect(0, 0, 60, 60));
drawingContext.Pop();

drawingContext.Close();

위의 코드에서 ptStart.X/Y, ptEnd.X/Y는 소수점을 포함하고 있으면 안됩니다. 만약 다음과 같이 Margin이 설정되어 소수점을 포함하게 되거나, 기타 레이아웃 관련 컨트롤의 자동 크기 조정이 되어 소수점이 적용된다면 필히 UseLayoutRounding="True" 값을 주어서 정수 영역이 반환되도록 해주어야 합니다.

<StackPanel Name="stackPanel" Width="450" Orientation="Horizontal" Margin="0.2 0.2 0.2 0.2"
    UseLayoutRounding="True" >




휴~~~, 드디어 기나긴 여정을 마쳤습니다. 여기까지 쓰고 나니, 이제서야 저도 WPF에서의 그래픽 좌표 단위와 Anti-aliased Rendering에 대한 전반적인 체계가 이해가 됩니다.

어떠세요? 그런대로 재미있었지요. ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 10/8/2022]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2020-08-13 07시45분
[오루라이] 감사합니다 도움이 많이 되었어요!
[guest]

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13838정성태12/4/2024657오류 유형: 935. Windbg - Breakpoint 0's offset expression evaluation failed.
13837정성태12/3/2024762디버깅 기술: 204. Windbg - 윈도우 핸들 테이블 (3) - Windows 10 이상인 경우
13836정성태12/3/2024989디버깅 기술: 203. Windbg - x64 가상 주소를 물리 주소로 변환 (페이지 크기가 2MB인 경우)
13835정성태12/2/20241002오류 유형: 934. Azure - rm: cannot remove '...': Directory not empty
13834정성태11/29/20241091Windows: 275. C# - CUI 애플리케이션과 Console 윈도우 (Windows 10 미만의 Classic Console 모드인 경우)파일 다운로드1
13833정성태11/29/20241100개발 환경 구성: 737. Azure Web App에서 Scale-out으로 늘어난 리눅스 인스턴스에 SSH 접속하는 방법
13832정성태11/27/20241111Windows: 274. Windows 7부터 도입한 conhost.exe
13831정성태11/27/2024979Linux: 111. eBPF - BPF_MAP_TYPE_PERF_EVENT_ARRAY, BPF_MAP_TYPE_RINGBUF에 대한 다양한 용어들
13830정성태11/25/20241087개발 환경 구성: 736. 파이썬 웹 앱을 Azure App Service에 배포하기
13829정성태11/25/20241027스크립트: 67. 파이썬 - Windows 버전에서 함께 설치되는 py.exe
13828정성태11/25/20241048개발 환경 구성: 735. Azure - 압축 파일을 이용한 web app 배포 시 디렉터리 구분이 안 되는 문제파일 다운로드1
13827정성태11/25/20241133Windows: 273. Windows 환경의 파일 압축 방법 (tar, Compress-Archive)
13826정성태11/21/20241206닷넷: 2313. C# - (비밀번호 등의) Console로부터 입력받을 때 문자열 출력 숨기기(echo 끄기)파일 다운로드1
13825정성태11/21/20241161Linux: 110. eBPF / bpf2go - BPF_RINGBUF_OUTPUT / BPF_MAP_TYPE_RINGBUF 사용법
13824정성태11/20/20241108Linux: 109. eBPF / bpf2go - BPF_PERF_OUTPUT / BPF_MAP_TYPE_PERF_EVENT_ARRAY 사용법
13823정성태11/20/20241085개발 환경 구성: 734. Ubuntu에 docker, kubernetes (k3s) 설치
13822정성태11/20/20241045개발 환경 구성: 733. Windbg - VirtualBox VM의 커널 디버거 연결 시 COM 포트가 없는 경우
13821정성태11/18/20241172Linux: 108. Linux와 Windows의 프로세스/스레드 ID 관리 방식
13820정성태11/18/20241144VS.NET IDE: 195. Visual C++ - C# 프로젝트처럼 CopyToOutputDirectory 항목을 추가하는 방법
13819정성태11/15/20241154Linux: 107. eBPF - libbpf CO-RE의 CONFIG_DEBUG_INFO_BTF 빌드 여부에 대한 의존성
13818정성태11/15/20241232Windows: 272. Windows 11 24H2 - sudo 추가
13817정성태11/14/20241103Linux: 106. eBPF / bpf2go - (BPF_MAP_TYPE_HASH) Map을 이용한 전역 변수 구현
13816정성태11/14/20241174닷넷: 2312. C#, C++ - Windows / Linux 환경의 Thread Name 설정파일 다운로드1
13815정성태11/13/20241102Linux: 105. eBPF - bpf2go에서 전역 변수 설정 방법
13814정성태11/13/20241215닷넷: 2311. C# - Windows / Linux 환경에서 Native Thread ID 가져오기파일 다운로드1
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...