Thursday, June 12, 2008

Grayscale Effect - A Pixel Shader Effect in WPF

This post will describe how Effects (pixel shaders) are used in WPF. I will give a simple step-by-step example of a grayscale effect. You must have .NET Framework 3.5 sp1 or later installed for this to work. You also need DirectX SDK installed to be able to compile the pixel shader effect.

Download Sample Project and source code here.
Download effect here (add it to your project and use a grayscale effect on your WPF components)

image  image   image

1. New Project
Start Visual Studio and create a new WPF Application. (Targeting the .NET 3.5 Framework)

image

2. Add new project. (Targeting the .NET 3.5 Framework)
In your solution, add a new project, a Class Library-project. Name your project "GrayscaleEffect". Now you will have the following view in your Solution Explorer:

image

3. Prepare the Grayscale Effect.
First add some references to the GrayscaleEffect-project:
PresentationCore
PresentationFramework
WindowsBase
Delete the autogenerated Class1.cs-file and instead create a new C# class and name it "GrayscaleEffect.cs". This will be an empty class. Now lets add the C#-part of the effect:

using System;
using System.Windows.Media.Effects;
using System.Windows;
using System.Windows.Media;

namespace GrayscaleEffect
{
public class GrayscaleEffect : ShaderEffect
{
private static PixelShader _pixelShader = new PixelShader() { UriSource = new Uri(@"pack://application:,,,/GrayscaleEffect;component/GrayscaleEffect.ps") };

public GrayscaleEffect()
{
PixelShader
= _pixelShader;

UpdateShaderValue(InputProperty);
UpdateShaderValue(DesaturationFactorProperty);
}

public static readonly DependencyProperty InputProperty = ShaderEffect.RegisterPixelShaderSamplerProperty("Input", typeof(GrayscaleEffect), 0);
public Brush Input
{
get { return (Brush)GetValue(InputProperty); }
set { SetValue(InputProperty, value); }
}

public static readonly DependencyProperty DesaturationFactorProperty = DependencyProperty.Register("DesaturationFactor", typeof(double), typeof(GrayscaleEffect), new UIPropertyMetadata(0.0, PixelShaderConstantCallback(0), CoerceDesaturationFactor));
public double DesaturationFactor
{
get { return (double)GetValue(DesaturationFactorProperty); }
set { SetValue(DesaturationFactorProperty, value); }
}

private static object CoerceDesaturationFactor(DependencyObject d, object value)
{
GrayscaleEffect effect
= (GrayscaleEffect)d;
double newFactor = (double)value;

if (newFactor < 0.0 || newFactor > 1.0)
{
return effect.DesaturationFactor;
}

return newFactor;
}
}
}


This is the C# side of the effect and will be the interface to the shader effect. This class and its properties (dependency properties) can be used directly from WPF and XAML. Note that the properties can be animated in the same way as other dependency properties. The difference is that we use a special type for the Input property and for our custom property we just refer to a register on the GPU, but we really do not need to know that ;). I really love the simplicity!



4. Add the effect files.

In the GrayscaleEffect-project, add two new files (Add -> New Item). GrayscaleEffect.fx and GrayscaleEffect.ps as Text-file. Make sure that GrayscaleEffect.ps is set as a Resource (in Build Action) in Properties. NOTE: The GrayscaleEffect.fx must be in ANSI format. Convert it to ANSI by opening the file in Notepad and save it, select ANSI format in the save dialog.



5. Now open the GrayscaleEffect.fx and add the following code (this is HLSL - High Level Shader Language):



sampler2D implicitInput : register(s0);
float factor : register(c0);

float4 main(float2 uv : TEXCOORD) : COLOR
{
float4 color
= tex2D(implicitInput, uv);
float gray = color.r * 0.3 + color.g * 0.59 + color.b *0.11;

float4 result;
result.r
= (color.r - gray) * factor + gray;
result.g
= (color.g - gray) * factor + gray;
result.b
= (color.b - gray) * factor + gray;
result.a
= color.a;

return result;
}



This is our pixel shader. As we can see at the top implicitInput refers to one registry and a float named factor refers to another register. This is the register that we set in our C#-file (the last parameter in RegisterPixelShaderSamplerProperty(,,0) and the parameter in PixelShaderConstantCallback(0)). Below is our entry point the the shader file. We take a position as input and the first thing we do is to get the value in the "texture" at this position.


The magic below this is out grayscale alogrithm. We first create a grayscale value from the red, green and blue channel. Then we set this value in the output value depending on the factor parameter. The factor parameter is the DesaturationFactor in the C#-file and we use it to create a desaturation effect and a gradient between color and grayscale mode.



6. Compile the pixel shader.

We have created the pixel shader effect but we need to compile it to use it. Note that we can do this outside of Visual Studio, but we don't want that, so do the following. Open the project-properties for the GrayscaleEffect-project and select "Build Events". Under Pre-build event command line, add the following:


"$(DXSDK_DIR)Utilities\Bin\x86\fxc.exe" /T ps_2_0 /E main /Fo"$(SolutionDir)GrayscaleEffect/GrayscaleEffect.ps" "$(SolutionDir)GrayscaleEffect/GrayscaleEffect.fx"


If you now compile the project your effect should compile just fine. But we are not using it so lets use it:



7. Now in WpfApplication1.

First add a reference to the GrayscaleEffect-project from the WpfApplication1-project.


We also want to use some images that we can apply out effect on. NOTE that the effect is not limited to images, we can use any control or panel in WPF, and that's cool!!! However, we use images because they are nice ;). Add a folder the the WpfApplication1 called "images". Add a few (~4) images to this folder (Add -> Existing item), name them like img1.jpg, img2.jpg... .


Your Solution Explorer should look like this:


image 
Now add the following XAML to Window1.xaml:



<Window x:Class="WpfApplication1.Window1"
xmlns
="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x
="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:effect
="clr-namespace:GrayscaleEffect;assembly=GrayscaleEffect"
Title
="Grayscale Effect - Dotway (www.dotway.se)" Height="400" Width="480">

<Window.Resources>

<DataTemplate x:Key="itemTemplate">
<Grid Width="225" Margin="3">
<Border BorderBrush="White" BorderThickness="2">
<Image Source="{Binding}" HorizontalAlignment="Center" VerticalAlignment="Center">
<Image.Effect>
<effect:GrayscaleEffect x:Name="grayscaleEffect"/>
</Image.Effect>
<Image.Triggers>
<EventTrigger RoutedEvent="Mouse.MouseEnter">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation To="1.0" Duration="0:0:0.3" AccelerationRatio="0.10" DecelerationRatio="0.25" Storyboard.TargetName="grayscaleEffect" Storyboard.TargetProperty="(effect:GrayscaleEffect.DesaturationFactor)" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
<EventTrigger RoutedEvent="Mouse.MouseLeave">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation To="0.0" Duration="0:0:4" AccelerationRatio="0.10" DecelerationRatio="0.25" Storyboard.TargetName="grayscaleEffect" Storyboard.TargetProperty="(effect:GrayscaleEffect.DesaturationFactor)" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Image.Triggers>
</Image>
</Border>
</Grid>
</DataTemplate>

</Window.Resources>

<Grid Background="Black">

<ItemsControl x:Name="myItemsControl" ItemTemplate="{StaticResource itemTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>

</Grid>

</Window>


We have a ItemsControl that have a WrapPanel as layout panel. We also have a template for each item in the ItemsControl. In the template two triggers have been added with animations. These animations will animate the DesaturationFactor property which will affect the shader effect.

To use the images add the following to the code-behind file:




public Window1()
{
InitializeComponent();

List
<string> images = new List<string>();
for (int i = 0; i < 4; i++)
{
images.Add(
"images/img" + i + ".jpg");
}
myItemsControl.ItemsSource
= images;
}






Now just compile and run your new effect!

BUT, how is this really useful except that it gives us "pretty images". Well, I am a fan of focus and context/master-detail implementations. It is all about putting focus to some part of my GUI and leave the rest unfocused and in a context. With the color/grayscale we can put focus to an item by giving it color and all the rest (context) is grayscale. 
This is not the end of it. In a later post I hope I can show you how to interact with the GUI after applying a pixel shader effect. The only thing you have to do is to implement EffeceMapping in you C#-file, more on that later. 
I hope this will help someone, otherwise contact the consultants at Dotway (www.dotway.se)! ;)

8 comments:

Anonymous said...

Very nice sample Anders!

Anonymous said...

Cool effects! :)

Kent Boogaart said...

Beautiful effect + great explanation == gold. Well done, sir.

Unknown said...

Hi,
I have a document page view for which I am applying this grayscale effect.
It works fine, its just that I see a small shift in the glyphs (text) when the effect is applied.
Not able to figure out why its happening. Any thoughts?

PS: May have posted this in another blog entry. Sorry about that..

-
Sharath

DevSolution said...

very nice.
Devsolution

feral said...

Hi,
I'm new to wpf and struggling to simplify your example. I am not interested in animating I just want to make an image grayscale or not in this kind of scenario:

[Style TargetType="{x:Type Image}" x:Key="toolbarImageStyle"]
[Style.Triggers]
[Trigger Property="IsEnabled" Value="True"]
TURN GRAYSCALE OFF
[/Trigger]
[Trigger Property="IsEnabled" Value="False"]
TURN GRAYSCALE ON
[/Trigger]
[/Style.Triggers]
[/Style]

Thanks

candritzky said...

Very nice and very good post.

I'm only wondering why the effect's property is called "Desaturation". I'd think it should be called "Saturation" because a high value (1.0) results in a "colorful" picture whereas a small value (0.0) results in a grayscale picture.

Anonymous said...

Great job, thanks!