With Silverlight 3, we finally get pixel shader capability just like those in WPF. Pixel shaders are a very powerful tool for manipulating graphics and can be confusing to get into.
With this in mind, I thought I’d write a (hopefully) simple tutorial.
Its a lengthy read, but hopefully manageable.
So, how does a pixel shader work?
Basically, a pixel shader is a small program that gets executed per pixel on the graphical element which it is assigned to.
This means that if we attach a pixel shader to a simple image, the shader code will walk through every single pixel on that image and modify it.
In Silverlight 3 beta, any brush can be used as input to a shader – movies, buttons, grids, etc – and they will retain their function even though the shader may completely alter their appearance.
How is shader code written?
This is where it all gets potentially very difficult or very easy.
Shader code for Silverlight is written in HLSL – High Level Shader Language which is more or less a variant of C and compiled using the DirectX SDK. The potential problem is not the language in itself, but how to perform the tasks that you want. Pixel shaders are very mathematically inclined and an effect can range from simple add/subtract to linear algebra and worse. This more or less means that pixel shaders are only limited by your creativity and mathematical knowledge (and some other limitations that I will explain later).
The anatomy of a shader
The following code is an example of a pixel shader that does absolutely nothing:
sampler2D source : register(S0);
float4 main(float2 uv : TEXCOORD) : COLOR {
return tex2D(source,uv);
}
Now, what exactly is happening here?
Our first line, sampler2D source : register(S0), assigns our image to the source variable which is a two-dimensional sample.
The main function is the shader’s entry point, this is where it starts executing. It takes one input parameter, an UV coordinate (which consists of X and Y coordinates described as a float2) and returns a color describes as a float4 (red, green, blue and alpha channels). The UV coordinates range from 0 to 1 regardless of the resolution of the source image.
And all it does is returning the color at the current x,y coordinate by using the tex2D() function (more information can be found here). As previously mentioned, a pixel shader iterates all the pixels in the source image and so the result on this particular shader is that we simply show the image unchanged.
As you may have noticed, the way we assign the image to a variable is by using a register.
There are basically two registers that you will use, S# and C# where # is the register index. S registers are for samplers (textures) and C is for constants. These are the links we will use to connect the shader to Silverlight.
Important: The S0 register is implicitly registered to the control that this effect is attached to in Silverlight. I will explain this later.
Limitations
Pixel shaders in Silverlight 3 beta are limited to Shader Model 2.0, which means the following (among others) limits apply:
- 16 texture registers (the S registers)
- 32 constant registers (the C registers)
- 64 arithmetic instruction slots
What does these limitations mean?
As an example, lets consider the Mandelbrot fractal. This fractal is ideal for pixel shaders as each point in the fractal is generated independently from the rest of the points.
This code snippet is the entire fractal generator and as the iterations variable grows larger, the fractal becomes more clear and recognizable as a mandelbrot.
float2 c = (uv - 0.5) * float2(3.8,3);
float2 v = c;
[unroll(7)]
for (int n = 0; n < iterations; n++){
v = float2(pow(v.x,2) - pow(v.y,2), v.x * v.y * 2) + c;
}
float4 color = tex2D(source,uv);
float4 result = (dot(v, v) > 1) ? 1- color :color;
result.a = 1;
return result;
As you may notice, there’s a [unroll(7)] instruction in there,this limits the iterations to 8 because beyond that, we hit the 64 arithmetic instructions limit. As a result, our mandelbrot does not look very much like what we’d expect.
The image in blue is a proper mandelbrot and the white one is the result after 8 iterations. The shape is familiar but it lacks details.
Silverlight 3 beta also has no GPU acceleration for pixel shaders thus far, hopefully this will change in the release as shaders without GPU gets very CPU intensive and thus becomes a performance issue.
Writing a shader
Now, lets get our hands dirty and dive in for a real example.
What we’ll do is write a pixel shader that gives us a spotlight effect and will look like this:
It will have an inner radius (this brightest spot) and a outer radius (the fall-off gradient). It will also have a center which we can move around so we can animate this or make it respond to mouse movements.
The HLSL code here is a few leaps up from the simple example I showed above but we’ll see if we can’t make some sense out of it still.
sampler2D source : register(S0);
float2 light_center : register(C0);
float outer_radius : register(C1);
float inner_radius : register(C2);
float4 main(float2 uv : TEXCOORD) : COLOR{
float4 sourceColor = tex2D(source,uv);
float d = distance(uv, light_center);
float4 color;
if(d < outer_radius){
if(d < inner_radius){
sourceColor += lerp(sourceColor, float4(0.8,0.8,0.8,1), distance(uv, light_center));
return sourceColor;
}
float radius_diff = outer_radius - inner_radius;
float ratio = (d - inner_radius) / radius_diff;
ratio = ratio * 3.14159;
float adjusted_ratio = cos(ratio);
adjusted_ratio = adjusted_ratio + 1;
adjusted_ratio = adjusted_ratio / 2;
color = lerp(float4(0,0,0,1),sourceColor, distance(uv, uv * (1-adjusted_ratio)));
return color;
}else{
return float4(0,0,0,1);
}
}
There’s some math instructions in there that I won’t go much into, but essentially what happens there is that we adjust the brightness of the current pixel according to its distance from the center point – creating a nice gradient effect. If the pixel is inside the inner radius, we make it even brighter to simulate that there’s a spotlight shining directly at it. And of course if the pixel is outside the outer radius, we’ll just turn it off (set the color to black).
Now we compile our shader using fxc.exe (belongs to the DirectX SDK) using these parameters:
/T ps_2_0 /E main /FoSpotlight.ps Spotlight.fx
- /T specifies the shader model we compile against, ps_2_0 is Shader Model 2.0 and is required for the shader to work with Silverlight 3.
- /E sets our entry point, this is the main function.
- /Fo specifies our output
Silverlight
Now, how do we make Silverlight speak to this shader?
We will first create a new class file, call it Spotlight.cs and make it extend ShaderEffect.
Then we will tell Silverlight where to find our shader. First we need to add our compiled shader to our project.
Just add the .ps file to the project and set the Build Action to Resource.
We will then refer to this file from our Spotlight.cs class.
private static PixelShader pixelShader;
static Spotlight() {
pixelShader = new PixelShader();
pixelShader.UriSource = new Uri("OurApplication;component/Effects/Spotlight.ps", UriKind.Relative);
}
Now, remember our shader variables? We need to link them to our Spotlight class. To do this, we add DependencyProperties.
public static readonly DependencyProperty InputProperty = ShaderEffect.RegisterPixelShaderSamplerProperty("Input", typeof(Spotlight), 0);
public static readonly DependencyProperty CenterProperty = DependencyProperty.Register("Center", typeof(Point), typeof(Spotlight), new PropertyMetadata(new Point(0.5,0.5), PixelShaderConstantCallback(0)));
public static readonly DependencyProperty InnerRadiusProperty = DependencyProperty.Register("InnerRadius", typeof(double), typeof(Spotlight), new PropertyMetadata(0.2, PixelShaderConstantCallback(2)));
public static readonly DependencyProperty OuterRadiusProperty = DependencyProperty.Register("OuterRadius", typeof(double), typeof(Spotlight), new PropertyMetadata(0.5, PixelShaderConstantCallback(1)));
As you can see from the source above, we register the two kinds of properties – sampler and constant. We also specify the register index – for example our C0 constant register as PixelShaderConstantCallback(0).
We also define default values to these properties and what type of value the property accepts.
Silverlight can now talk to the shader code using these properties.
We also add regular properties so we can set and read these values directly:
public Brush Input {
get {
return (Brush)GetValue(InputProperty);
}
set {
SetValue(InputProperty, value);
}
}
public Point Center {
get {
return (Point)GetValue(CenterProperty);
}
set {
SetValue(CenterProperty, value);
}
}
public double InnerRadius {
get {
return (double)GetValue(InnerRadiusProperty);
}
set {
SetValue(InnerRadiusProperty, value);
}
}
public double OuterRadius {
get {
return (double)GetValue(OuterRadiusProperty);
}
set {
SetValue(OuterRadiusProperty, value);
}
}
Now we’re only missing our effect constructor, all we’ll do here is update the shader with the default values we set in the dependencyproperties and den set the ShaderEffect.PixelShader property to use our pixel shader.
public Spotlight() {
this.PixelShader = pixelShader;
UpdateShaderValue(InputProperty);
UpdateShaderValue(CenterProperty);
UpdateShaderValue(InnerRadiusProperty);
UpdateShaderValue(OuterRadiusProperty);
}
All done!
To use this effect, we add it to XAML like so:
<Image Name="demoImage" Source="../Images/eye.jpg"> <Image.Effect> <effects:Spotlight x:Name="flashlight" InnerRadius="0.5" OuterRadius="0.2"/> </Image.Effect> </Image>
(remember to add the namespace ex. xmlns:effects="clr-namespace:OurApplication")
We can now bind the effect properties to storyboards or mouse movement or whatever we want so we get a dynamic spotlight effect, but this is outside the scope of this tutorial.
Hope this was understandable and if you have any questions, feel free to ask :)
I also recommend Shazzam – this is a nice tool for testing out HLSL code directly and see the effect without implementing it in Silverlight. It also autogenerates .NET class files for use with WPF (these can be used with Silverlight with only a few minor adjustments).
Ingen kommentarer:
Legg inn en kommentar