A simple HLSL tutorial

HLSL is a C-related language that exists for the purpose of adding filters (or “shaders”) to images for various graphic effects. It is the primary way to manipulate the appearance of completed images within VS2010 projects. (Note that I deliberately use certain terms, such as shader and filter, interchangeably in these posts, to balance the “correct” terms with how I tend to think. Hopefully this is helpful rather than confusing.)

I initially delved into HLSL because I wanted to add a simple effect to the selected card of a Canfield project in WPF within VC#.

I wanted to add a tint overlay to the selection, but I was surprised to find out how difficult that was. WPF allows for transparency of objects internally, but the problem with that approach was the selected card was usually partially or completely covering another card, which would then bleed through in a distracting way. WPF only offers five native bitmap effects, none of which provided an acceptable effect. Instead, I decided to add a reddish-orange tint to the selected card.

If effects other than the five native effects are desired, the standard route is to create the effect in HLSL, compile it, and then add it as a resource in C#. Luckily, there’s a free tool, Shazzam, which greatly simplifies the process of creating and fine-tuning shader effects. Before going further, make sure that you’ve installed both the DirectX SDK (also free) and Shazzam. (Edit 7/28/21: Shazzam seems to no longer be available.)

To create my own filter, I used a canned sample shader (ColorTone.fx), which only involved finding a good colors to use as the LightColor and DarkColor, then making some minor adjustments to the C# code. More recently, I’ve been focusing on the details of HLSL and playing with Shazzam to explore how exactly the language works.

Having a variety of sample shaders in Shazzam is particularly useful because of the sample code which can be readily tweaked for different effects. In this post, I’ll explore two of the simplest samples to provide a footing for further exploration.

One unexpected part of HLSL as a bitmap shader is that the most obvious aspect, i.e., nested for loops designed to apply to all pixels in an image, is implied. For instance, consider the sample shader InvertColor, one of the simpler effects. This effect reverses the value of each pixel, creating the look of a photographic negative (an effect perhaps getting increasingly less relevant, being an artifact from the days of film):

sampler2D implicitInputSampler : register(S0);
float4 main(float2 uv : TEXCOORD) : COLOR
{
 float4 color = tex2D( implicitInputSampler, uv );
 float4 invertedColor = float4(color.a - color.rgb, color.a);
 return invertedColor;
}

I’ve removed the comment lines for the sake of discussion; the comment lines in the sample shaders will often contain information used by Shazzam to allow the shader settings to be tweaked.

Looping through the x and y coordinates is implied. Since the same effect is applied to all pixels in this particular shader, there’s no need even for any conditional statements. In addition, variables are defined with the number of dimensions. Based on my background, I was expecting float4 to be a variable taking four bytes; it’s actually an array of four floating values, corresponding to the red, green, blue, and alpha channels. The most common variable types:

  • float: A single value
  • float2: A two-dimensional array, used for xy coordinates
  • float3: A three-dimensional array, used for rgb values (hence, excluding the alpha channel)
  • float4: A four-dimensional array, used for rgba values

Looking at the InvertColor code, the input consists of uv, representing the xy coordinates of the original image. The output of main will be the input image with each pixel adjusted according to the code.

The variable color is a four-dimensional array (“vector”) representing the pixels of the input image. The next line, which is key to the shader, illustrates the aspect of HLSL I had the most trouble getting my head around:

float4 invertedColor = float4(color.a - color.rgb, color.a);

It says, for each of the three channels of color (that is, red, green, and blue), take the inverse of the color value, and leave the alpha channel alone. The color channels of a four-dimensional variable are specified by .rgb; .a specifies the alpha channel alone. This line could be expanded to:

float4 invertedColor = float4(color.a - color.r,
color.a - color.g, color.a - color.b, color.a);

with identical results. Likewise, for a different effect, you could rotate the colors, so that the formerly red values are now blue, the blue values are green, and the green values are red, thus:

float4 invertedColor = float4(color.g, color.b, color.r, color.a);

You can also apply various calculations to each of the channels, such as:

float4 invertedColor = float4(sin(color.r)*2, cos(color.g),
pow(color.b,2), color.a);

In this case, the value of each red pixel in the output is twice the sine of the original red pixel, the value of each green pixel is the cosine of the original, and the value of each blue pixel is the square of the original (that is, color.b to the power of 2). In the last case, note that the carat (^) sometimes used to represent powers in other computer languages is reserved for a different function in HLSL, and hence the function pow is used instead.

There are many other intrinsic functions in HLSL. Once you have a basic understanding of the language, there are myriad different ways you can play with an existing image.

The last line is simply a directive to use invertedColor as the output.

This represents the minimal meaningful shader effect. Once I crossed the threshold of understanding that what looked like single-value variables were in fact sometimes multidimensional, I found myself having an easier time understanding more complex shader effects.

Next, consider the ColorTone shader, which masks the entire image, roughly as if it were covered by a sheet of colored film (again, I’ve removed the comments):

float Desaturation : register(C0);
float Toned : register(C1);
float4 LightColor : register(C2);
float4 DarkColor : register(C3);
sampler2D implicitInputSampler : register(S0);
float4 main(float2 uv : TEXCOORD) : COLOR
{
 float4 color = tex2D(implicitInputSampler, uv);
 float3 scnColor = LightColor.rgb * (color.rgb / color.a);
 float gray = dot(float3(0.3, 0.59, 0.11), scnColor);

 float3 muted = lerp(scnColor, gray.xxx, Desaturation);
 float3 middle = lerp(DarkColor.rgb, LightColor.rgb, gray);

 scnColor = lerp(muted, middle, Toned);
 return float4(scnColor * color.a, color.a);
}

In this case, certain values can be fed in after compilation: Desaturation, Toned, LightColor, and DarkColor. In the Shazzam-aimed comments (with triple-slashes), default values are specified for each other these, and they can be changed based on your tastes or needs.

This function uses two common functions, dot and lerp. Dot calculates the dot product of two vector arrays. In this case, these lines have the same effect:

float gray = dot(float3(0.3, 0.59, 0.11), scnColor);
float gray = 0.3*scnColor.r + 0.59*scnColor.g + 0.11*scnColor.b;

As you can see, dot doesn’t save many keystrokes in this sort of case, but once you’re used to it, it makes the code cleaner to read.

The other function, lerp, returns the linear interpolation between two points, at a specified distance. The formula is x + s(y-x), that is, the point which is a distance of s away from x in the direction of y. Hence, these lines create the same result:

float3 muted = lerp(scnColor, gray.xxx, Desaturation);
float3 muted = scnColor + Desaturation*(gray.xxx - scnColor);

If you choose, you can even expand it fully, still with the same result:

float3 muted;
 muted.r = scnColor.r + Desaturation*(gray.x - scnColor.r);
 muted.g = scnColor.g + Desaturation*(gray.x - scnColor.g);
 muted.b = scnColor.b + Desaturation*(gray.x - scnColor.b);

Note that 0.30, 0.59, and 0.11 are standard, fixed values for converting from color into grayscale, meant to compensate for difference in how the human eye perceives green, blue, and red, called luminance. Other values return similar results. Theoretically, you can use any values, but these provide the most naturalistic results.

Overall, then, ColorTone creates the following:

  • scnColor, a copy of the image tinted strongly towards the lighter of the two specified colors. For instance, if LightColor is yellow, then whites and lighter pixels in the original image will become yellow, but darker colors will be less affected. To see the effect, simply comment out the second to last line (scnColor …) of the subroutine and reapply it using F5.
  • gray, a grayscale multiplier.
  • muted, a copy of scnColor desaturated towards gray to the degree specified by Desaturation.
  • middle, a monochrome image in the color range between DarkColor and LightColor, as determined by the gray value for the pixel.
  • Finally, an adjusted scnColor based on mixing muted and middle.

Cautionary note: Some of the sample shaders I received from the Shazzam download generate the compilation error X3206 in the most recent version of DirectX. This is generally because earlier versions of DirectX allowed for implicit truncations of vectors (that is, if an RGBA value was included where an RGB value is expected, HLSL would ignore the alpha channel), but this has since been disallowed. This is usually easily corrected. For instance, in OldMovie, I changed the line

float3  rand = tex2D(NoiseSampler, rCoord);

to

float3 rand =  tex2D(NoiseSampler, rCoord).rgb;

Leave a Comment

Your email address will not be published.