Tuesday, March 29, 2011

Silverlight Scaling and Anti-Aliasing Issues

I recently had the opportunity to work on a rather nagging issue with a customer related to how Silverlight scales UI elements. The customer was kind enough to approve me posting the solution. The problem is with scaling UI elements on a canvas. The scenario is simple (and easy to reproduce).

First, create a canvas exactly 1000 pixels wide. Then, place two blue rectangles in the canvas that are exactly 500 pixels wide. Offset the second rectangle by 500, like this:

<Canvas Width="1000" Height="800" >
        <Rectangle Fill="Blue" Width="500" Height="800"/>
        <Rectangle Fill="Blue" Width="500" Height="800" Canvas.Left="500"/>

So far, so good. You'll get the illusion of a single, solid blue rectangle because the edges of the rectangles are flush.

Now, let's toss the rectangle inside a Viewbox. You can also simply apply a scale transform to the containing grid or canvas, but a view box makes this easier. Put the canvas inside a grid and add some controls to adjust the height dynamically.

<Grid x:Name="LayoutRoot" Background="White">
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
    <Viewbox Width="{Binding ElementName=slider, Path=Value}" Stretch="Uniform" Grid.Row="1">
        <Canvas Width="1000" Height="800" >
            <Rectangle Fill="Blue" Width="500" Height="800"/>
            <Rectangle Fill="Blue" Width="500" Height="800" Canvas.Left="500"/>
    <StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Center">
            <Slider x:Name="slider" Width="1000" Minimum="100" Maximum="1024" Value="2000"/>

Launch the program, and slide the slider to scale the rectangles. You'll almost immediately catch the issue with anti-aliasing:

At certain zoom levels, the algorithm that computes the shape is obviously allowing the background to bleed through. Here's the same picture above, magnified several times:

The anti-aliasing happens by blending the background into the edge. You can confirm the background is bleeding through by adding a rectangle behind the two that is a different color. Here's an example with a red rectangle:

<Canvas Width="1000" Height="800" >
        <Rectangle Fill="Red" Width="1000" Height="800"/>
        <Rectangle Fill="Blue" Width="500" Height="800"/>
        <Rectangle Fill="Blue" Width="500" Height="800" Canvas.Left="500"/>

... and the magnified result:

After discovering this and scouring the forums for an answer, it appears the problem has not been resolved. Overlapping the edges is unacceptable in cases where the borders contain intricate designs that break when they are shifted. Even with the solid rectangles and a full pixel overlap, the issue can still be seen at certain zoom resolutions. So, finally, I decided to take advantage of the defect and use the "background" to my advantage.

For the blue rectangles, this was simple. I created a new "hack panel" as a blue rectangle. To see the difference, I added a button with code behind to toggle the visibility. Here is the XAML with the blue correction panel. Notice it is only 5 pixels wide and positioned just right to overlap the edges of the panels on top of it:

<Canvas Width="1000" Height="800" >
                    <Rectangle x:Name="HackPanel" Fill="Blue" Width="5" Height="800" Visibility="Visible" Canvas.Left="499"/>
                    <Rectangle Fill="Blue" Width="500" Height="800"/>
                <Rectangle Fill="Blue" Width="500" Height="800" Canvas.Left="500"/>

The new toggle button simply toggles the visibility of the strip:

private void _ButtonClick(object sender, RoutedEventArgs e)
            _visibility = !_visibility;
            HackPanel.Visibility = _visibility ? Visibility.Visible : Visibility.Collapsed;

So the problem is solved with a solid color, but what about a more complex border? To solve this, I took my Jounce logo and hacked it in half. It's not a perfect job because I threw this together quickly, so you'll notice I shift the right side down by a pixel and it doesn't line up perfectly, but the point is the anti-aliasing effect. With the hacks turned off, you can see a very clear line appearing up and down the edge. But what to put behind it? Green will bleed on the white, and vice versa.

The solution? A hack grid. I dropped this onto the canvas:

<Grid x:Name="HackGrid" Loaded="HackGrid_Loaded" Width="5" Height="800" Visibility="Collapsed" Canvas.Left="499"/>

And in the loaded event, transform the image into it. What essentially happens is we get a 5 pixel strip behind the panels that is a shifted view of the left seam of the right picture (the grid width will clip the rest). Because the anti-alias bleeds through the background, the pixels will overlap just enough to blend the seams and reduce the impact of the effect. Here's the code that can easily be turned into a behavior:

private void HackGrid_Loaded(object sender, RoutedEventArgs e)
    var bitmap = new WriteableBitmap(RightImage, null);
    var imageClone = new Image
                                Width = bitmap.PixelWidth,
                                Height = bitmap.PixelHeight,
                                Source = bitmap,
                                Stretch = Stretch.None
    imageClone.SetValue(HorizontalAlignmentProperty, HorizontalAlignment.Left);
    imageClone.SetValue(VerticalAlignmentProperty, VerticalAlignment.Top);

And here is the chopped up picture before:

...and after applying the strip behind the two parts:

(The blue strip on the top is from the rectangles).

It's not a perfect solution, but it reduces the impact of the effect and hopefully will help others who run into the same issue. Here's the source code for you to play with it yourself: Side-by-Side Source.

Jeremy Likness