Writing Custom GPU-based Effects for WPF
These three articles (1,
2, 3) focused on using Effects
in WPF.
In the (second),
for example, we showed how to apply a ColorComplementEffect to go from this to this:
ColorComplementEffect, which looks like a photographic negative, is one of simplest
Effects one can imagine: it takes the RGB color components and creates a new color
with each component subtracted from 1.0.
In this article, we show how to write the ColorComplementEffect and add it to your
application.
Creating the HLSL for Color Complement
Here's some simple HLSL for doing the color complement. Note that this series
is not intended to be an HLSL tutorial;
A Programmer's Guide and Reference Guide on MSDN is a good starting
point for that.
Starting from existing code samples, however, is always a good approach.
sampler2D implicitInput : register(s0);
float4 main(float2 uv : TEXCOORD) : COLOR
{
float4 color = tex2D(implicitInput, uv);
float4 complement;
complement.r = 1 - color.r;
complement.g = 1 - color.g;
complement.b = 1 - color.b;
complement.a = color.a;
return complement;
}
Note the following:
- The "implicitInput" shader constant is what's in shader constant sampler
register S0. That's going to be the "original" composition of the
textbox and button over the image, all converted to a "sampler". We'll
see in a bit how the association to register s0 happens.
- "main" is the entrypoint to the pixel shader. It receives as input the
texture coordinate (of type 'float2') of where we're currently outputting
in the texture coordinate space of the element being applied to. This varies from
0-1 in u and v. Note again that while HLSL in general provides more flexibility
on input types to shaders, for WPF currently, this is always a float2-valued texture
coordinate.
- Only a pixel shader is being provided here. No vertex shader.
Given these, we now get to the body of the function. The first line "tex2D"
samples our input texture at the current sampling position uv, and receives a float4
(a 4-component vector with each value a float) that represents the color. After
declaring a local 'complement' float4, we proceed to assign its R, G, and
B values to 1 minus the corresponding sampled value. The A value (alpha) receives
the corresponding sampled value's alpha directly. Finally, we return that value.
When the Effect runs, this main() function is executed on every pixel affected.
It's run very fast, and quite a bit in parallel, but it is run.
The above isn't the best way to express this HLSL. The thing is that HLSL drives
a SIMD vector processor; so, rather than have these three separate RGB calculations,
we can use HLSL to combine them:
float4 main(float2 uv : TEXCOORD) : COLOR
{
float4 color = tex2D(implicitInput, uv);
float4 complement;
complement.rgb = 1 - color.rgb;
complement.a = color.a;
return complement;
}
The syntax "complement.rgb = 1 - color.rgb" will treat the "1"
as replicated into each element of a 3-vector, and then do the subtraction on each
element, all with a single PS 2.0 instruction. (Actually, the HLSL compiler deduces
this without me being explicit so the two above shaders generate the same number
of instructions, but I never like to count on understanding exactly when compiler
optimizations will or won't kick in super performance sensitive code.)
Compiling the HLSL
WPF Effects do not take the HLSL text directly. You need to compile it into the
binary bytecode that DirectX natively accepts, which is how WPF passes it along.
To do this, you run fxc.exe on your shader file. Fxc.exe is the shader compiler
that comes with the
Microsoft DirectX SDK. You can find it at Utilities\Bin\x86\fxc.exe in the
SDK.
Say your HLSL was in 'cc.fx', the following command line would generate
the compiled bytecode int 'cc.ps' in the same directory:
> fxc /T ps_2_0 /E main /Focc.ps cc.fx
This says to compile to the PS 2.0 profile, and to look for the entrypoint named
"main".
(We have a rough prototype that adds the fxc.exe compilation as an MSBuild build
task so that it can be incorporated directly into projects without having to break
out to a command line shader compiler. When that's further along, we'll
post this out there for all to use.)
Writing Your Managed Code Effect Subclass
Now it's time to write our managed code that will expose this Effect out to
WPF developers. We'll use C# here.
using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Effects;
namespace MyEffects
{
public class ColorComplementEffect : ShaderEffect
{
public ColorComplementEffect()
{
PixelShader = _shader;
UpdateShaderValue(InputProperty);
}
public Brush Input
{
get { return (Brush)GetValue(InputProperty); }
set { SetValue(InputProperty, value); }
}
public static readonly DependencyProperty InputProperty =
ShaderEffect.RegisterPixelShaderSamplerProperty(
"Input",
typeof(ColorComplementEffect),
0);
private static PixelShader _shader =
new PixelShader() { UriSource = new Uri(@"pack://application:,,, /MyEffects;component/ColorComplementEffect.ps") };
}
}
Here's basically how this works:
- We derive from ShaderEffect, itself a subclass of Effect. Most importantly, ShaderEffect
exposes a PixelShader property of type PixelShader.
- We define a static PixelShader instance which references the compiled bytecode.
It's static because the same PixelShader object can be shared amongst all instances
of the Effect.
- We define a Brush-valued DependencyProperty called InputProperty, and the corresponding
Input CLR property. This is almost identical to how we define other DPs in WPF.
The difference is that we use a helper method called ShaderEffect.RegisterPixelShaderSamplerProperty.
As in other DP definitions, both the name and the owning type are specified. But
the third parameter here (0, in this case) represents the sampler register that
the Input property will be able to be accessed from in the shader. Note that this
0 matches the s0 in the HLSL above:
sampler2D implicitInput : register(s0);
- The instance constructor just assigns in the static _shader to the per-instance
PixelShader property, and calls UpdateShaderValue() on any DPs that are associated
with shader registers. In this case, just the InputProperty. This latter is necessary
to ensure it's set for the first time, since DPs don't call their PropertyChangedCallbacks
on the setting of their default values.
The other thing worth mentioning here is the gibberish in the "pack://"
URI when we reference our pixel shader bytecode file, ColorComponentEffect.ps. Since
we don't want to reference a loose file on disk, and we'd like the shader
bytecode to live in whatever packaging the C# ColorComponentEffect class goes into
(since they should travel together), we use the WPF
"pack://" URI syntax. In this case, we're building a library
called "MyEffects", which is why that appears in the pack URI.
In order for this "pack://" URI to work, the bytecode needs to make its
way into the built component. You do this by adding the shader bytecode file into
your project, and ensuring that its Build Action is set to "Resource"
as this snip from the Solution Explorer shows:
I have a helper function that lets me express this URI string without hardwiring
in "MyEffects", and without re-generating this same gibberish each time:
new PixelShader() { UriSource =
Global.MakePackUri("ColorComplementEffect.ps") }
I include the code for the Global helper class at the bottom of this post.
(Note also that PixelShader objects can also be defined via a Stream, which enables,
for instance, authoring tools to construct shader bytecode on the fly and pass it
to WPF without ever having to persist it to the file system.)
Recall that we considered ColorComplementEffect to be a zero-parameter effect. So
what's with this "Input" DP? Note that the Effect is invoked via this
XAML:
<Grid >
<Grid.Effect>
<eff:ColorComplementEffect />
</Grid.Effect>
...
</Grid>
The "sampler" that the shader works on is the rasterization of the Grid
itself into a bitmap. You can think of the implementation as creating a VisualBrush
of the Grid, then providing that Brush as the value for the Input DP. It doesn't
quite work like that, and we expose a new type of Brush called ImplicitInput to
represent this usage. ImplicitInput is the default value for DPs that defined using
"RegisterPixelShaderSamplerProperty", meaning that they automatically
receive the "brush" of the element that they're applied to as their
shader sampler (register 0 in this case, since that's what we specified).
We'll see in later posts why exposing these as Brushes, and having the ability
to control whether ImplicitInput is used is important. Hint: it has to do with multi-input
effects.
Appendix: Global.MakePackUri
This is the MakePackUri helper I reference above. It's not verbose (like the
pack: // URI itself); and, more importantly, it doesn't hardwire in a module name.
internal static class Global
{
/// <summary>
/// Helper method for generating a "pack://" URI for a given relative
file based on the
/// assembly that this class is in.
/// </summary>
public static Uri MakePackUri(string relativeFile)
{
string uriString = "pack://application:,,,/" + AssemblyShortName + ";component/" + relativeFile;
return new Uri(uriString);
}
private static string AssemblyShortName
{
get
{
if (_assemblyShortName == null)
{
Assembly a = typeof(Global).Assembly;
// Pull out the short name.
_assemblyShortName = a.ToString().Split(',')[0];
}
return _assemblyShortName;
}
}
private static string _assemblyShortName;