Thursday 3 January 2013

Pixel Shaders for Dummies

The goal of this post will be to give an extremely quick introduction to pixel shaders in XNA, what sort of basic things you can do with them and some other information.

What is a Pixel Shader?

A shader is a program or function that runs on the GPU instead of the CPU. The name comes from its primary use, which is to shade textures on a pixel-by-pixel basis. Typically, they're used for rendering lighting and shadows in a scene, however in recent years they've also been used for producing special effects and other forms of post processing.

The main advantage of pixel shaders is that most of the work is done on the GPU instead of the CPU, and that the GPU is very fast at performing these sorts of operations. In addition, their versatility allows all sorts of effects to be created with the right knowledge.

What sort of effects can you create?

Almost any effect you can think of in a graphics application, such as Photoshop, can be created using shaders. For the subject of this post though, we'll be focusing on alpha blending or "masking".

Image courtesy of Survarium.

Alpha blending is a texture editting technique where certain areas of a texture are made transparent or "masked" using another texture. In the example above, the image on the left had the mask in the middle applied which resulted in the image on the right. The black areas on the mask made the picture transparent, while the white areas did not affect it. The grey areas in between produced a gradual fade from opaque to transparent.

Writing the Shader

Before we can start writing our game code, we need to create our shader. This shader's going to be relatively simple, it will have two external variables (the screen's texture and the mask) and will multiply each pixel in the screen by the equivalent pixel's brightness in the mask.

First, we need to create an effect file. Right-click on your solution's Content project and click "Add", followed by "New Item...". Select "Effect File", call it what you want (alpha_map.fx, for example) and click "Add". XNA automatically generates some boiler plate code, but not much of it is directly relevant to this example so delete it.

Now that we have a clean slate, we're going to create our two external variables.
// The texture we are trying to render.
uniform extern texture ScreenTexture;  
sampler screen = sampler_state 
{
    Texture = <ScreenTexture>;
};

// The texture we are using to mask.
uniform extern texture MaskTexture;  
sampler mask = sampler_state
{
    Texture = <MaskTexture>;
};
This creates two paramaters for our shader, ScreenTexture and MaskTexture. The former is used to reference the current, unshaded texture and the latter will allow us to initialise and even change the mask during runtime.

Next, we need a quick function to determine the brightness of a pixel in our mask texture.
float MaskPixelBrightness(float4 inMaskColour)
{
    // Get the min and max values from the pixel's RGB components.
    float maxValue = max(inMaskColour.r, max(inMaskColour.g, inMaskColour.b));
    float minValue = min(inMaskColour.r, min(inMaskColour.g, inMaskColour.b));

    // Return the average of the two.
    return (maxValue + minValue) / 2;
}
Because shader pixel data is typically stored as a float4 RGBA value, there's no way to directly determine how "bright" the pixel is. This implementation uses the HSL "bi-hexcone" model as described on Wikipedia, which is simply the average of the largest and smallest values from the RGB components.

Now we move onto our main shader function, the part which will actually do the per-pixel shading.
float4 PixelShaderFunction(float2 inCoord: TEXCOORD0,
    float4 inColour : COLOR0) : COLOR
{
    // Get the colour value at the current pixel.
    float4 colour = tex2D(screen, inCoord);

    // Get the brightness value at the current mask pixel.
    float maskBrightness = MaskPixelBrightness(tex2D(mask, inCoord));

    // If a Color argument has been passed into SpriteBatch.Draw(), we
    // multiply the value by it to hue. Then we multiply by the brightness
    // value of the mask to hide the appropriate pixels.
    colour.rgba = colour.rgba * inColour.rgba * maskBrightness;

    // Return the updated pixel.
    return colour;
}
The first couple of lines get the RGBA value of the texture and the brightness value of the mask respectively. The next line multiplies the pixel value by the Color argument (if one has been passed in SpriteBatch.Draw()) and the brightness value. This has the effect of allowing textures to be hued as normal, as well as masking the texture.

Finally, we write the technique for the shader to use when rendering.
technique
{
    pass P0
    {
        // Compile as version 2.0 for compatability with Xbox.
        PixelShader = compile ps_2_0 PixelShaderFunction();
    }
}
A shader can have multiple techniques and each technique can have multiple passes. For our simple shader though, only one technique and pass are needed. We compile as Pixel Shader 2.0 so that the shader is compatible with Xbox as well as PC.

Creating a passive mask

Now that we have our shader, we can start writing some game code. First we'll add the appropriate content variables and load them in LoadContent().
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Effect alphaShader;
        RenderTarget2D wipeRender;
        Texture2D planetTexture;
        Texture2D wipeTexture;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
        }

        /// 
        /// LoadContent will be called once per game and is the place to load
        /// all of your content.
        /// 
        protected override void LoadContent()
        {
            // Create a new SpriteBatch, which can be used to draw textures.
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // Load our various content.
            alphaShader = Content.Load<Effect>("alpha_map");
            planetTexture = Content.Load<Texture2D>("planet");
            wipeTexture = Content.Load<Texture2D>("wipe");

            // Initialise the render target to be the same size as the planet.
            wipeRender = new RenderTarget2D(graphics.GraphicsDevice,
                planetTexture.Width, planetTexture.Height);

            // Set the render target to be blank initially.
            graphics.GraphicsDevice.SetRenderTarget(wipeRender);
            graphics.GraphicsDevice.Clear(Color.White);

            // Go back to drawing to the screen again.
            graphics.GraphicsDevice.SetRenderTarget(null);
        }
Most of this should be pretty explanatory except for the RenderTarget2D variable. This is going to be our "canvas" when we're creating our mask and will then get passed in as the MaskTexture paramater for our shader. As such we want the render target to be the same size as our planet and we initially want it to be completely white, so that the texture is drawn in full by default.

Next, we're going to write a method to draw our render target. Add this to the bottom of your Game1 class.
        /// 
        /// Update the wipe texture position.
        /// 
        private void UpdateWipeMask()
        {
            // Tell the graphics device we want to draw to our seperate render target,
            // instead of the screen, until we tell it otherwise.
            graphics.GraphicsDevice.SetRenderTarget(wipeRender);

            // Clear the render for the update.
            graphics.GraphicsDevice.Clear(Color.Transparent);

            spriteBatch.Begin();

            // Draw the mask in its current position.
            spriteBatch.Draw(wipeTexture, new Rectangle(-planetTexture.Width,
                    0, planetTexture.Height * 5, planetTexture.Height),
                Color.White);

            spriteBatch.End();

            // Go back to drawing to the screen again.
            graphics.GraphicsDevice.SetRenderTarget(null);
        }
This method simply sets the graphics device to draw to the render target we set up earlier, then draws our mask texture onto it. The X offset is because my mask texture has a white square at the start, so I offset slightly so that the gradient part of the mask is shown instead. My mask is also smaller than my planet texture, so I scale the rectangle to fit it.

Now we call the method in Update() and add the code to Draw() which will allow us to render our masked texture.
        /// 
        /// Allows the game to run logic such as updating the world,
        /// checking for collisions, gathering input, and playing audio.
        /// 
        /// Provides a snapshot of timing values.
        protected override void Update(GameTime gameTime)
        {
            // Allows the game to exit
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
                this.Exit();

            UpdateWipeMask();

            base.Update(gameTime);
        }

        /// 
        /// This is called when the game should draw itself.
        /// 
        /// Provides a snapshot of timing values.
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.Black);

            // Set the value of the alpha mask.
            alphaShader.Parameters["MaskTexture"].SetValue(wipeRender);

            // Start SpriteBatch using our custom effect.
            spriteBatch.Begin(SpriteSortMode.Immediate,
                BlendState.AlphaBlend, null, null, null, alphaShader);

            // Draw the planet texture, including any mask in alphaShader.
            spriteBatch.Draw(planetTexture, new Vector2(208, 24), Color.White);

            spriteBatch.End();

            base.Draw(gameTime);
        }
As you can see, this step is relatively straight forward once we've done all the preliminary coding. Each frame the draw method is called to set our mask texture and then this texture is passed during drawing.


This type of mask isn't really that useful though, as you can just bake the mask into the texture you're using rather than having to use pixel shaders. Additionally, some people will notice that having the render target update every frame isn't very optimal for our uses. This leads us onto our next topic.

Creating an active mask

Having a mask that moves is much more practical and will allow us to achieve effects which are not so simple otherwise. This time we'll create a wipe effect, where the texture gradually fades off from left to right and back on again. Thankfully, making the mask move is not so difficult given what we already have.

First, we need to add a variable to our game which will store the position of the mask.
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Effect alphaShader;
        RenderTarget2D wipeRender;
        Texture2D planetTexture;
        Texture2D wipeTexture;

        int wipeX = 0;
Now we need to modify our mask drawing method to update the position each time it draws.
        /// 
        /// Update the wipe texture position.
        /// 
        private void UpdateWipeMask()
        {
            // Wipe from left to right.
            wipeX += 6;

            // Each time we reach the end of the texture, reset to loop the effect.
            if (wipeX >= 0) wipeX = -planetTexture.Width * 4;

            // Tell the graphics device we want to draw to our seperate render target,
            // instead of the screen, until we tell it otherwise.
            graphics.GraphicsDevice.SetRenderTarget(wipeRender);

            // Clear the render for the update.
            graphics.GraphicsDevice.Clear(Color.Transparent);

            spriteBatch.Begin();

            // Draw the mask in its current position.
            spriteBatch.Draw(wipeTexture,
                new Rectangle(wipeX, 0, planetTexture.Width * 5, planetTexture.Height),
                Color.White);

            spriteBatch.End();

            // Go back to drawing to the screen again.
            graphics.GraphicsDevice.SetRenderTarget(null);
        }
You'll notice the only real difference to the method is that the wipeX variable we declared is increased and that value is used as the X position when drawing. I also clamp the variable so that when it reaches 0 (where the left side of the mask is parallel with the left side of the texture) it resets back to the end.


That's all there really is to it. You can improve the performance by moving the call to UpdateWipeMask() to Draw() instead, that way you only update the mask every time it's drawn. The only problem is that this then means that the speed of the wipe is dependant on the framerate (slower machines won't draw as quickly and therefore won't update the mask as fast). This would be suitable for background effects where the speed isn't so important though (like a glowing effect on a powerup).

Creating a dynamic mask

The final piece of the puzzle is to have the mask react to player input. For this part we will make it so that a crater is added to the mask when the player clicks on the planet, hiding that area of it. Since this is slightly different than the last two approaches, it will take a bit more work to implement.

First, we need to add some additional variables and initialise them.
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Effect alphaShader;
        RenderTarget2D craterRender;
        RenderTarget2D wipeRender;
        Texture2D planetTexture;
        Texture2D craterTexture;
        Texture2D wipeTexture;

        MouseState currentMouse;
        MouseState previousMouse;

        Random random = new Random();
        int wipeX = 0;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            IsMouseVisible = true;
        }

        /// 
        /// LoadContent will be called once per game and is the place to load
        /// all of your content.
        /// 
        protected override void LoadContent()
        {
            // Create a new SpriteBatch, which can be used to draw textures.
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // Load our various content.
            alphaShader = Content.Load<Effect>("alpha_map");
            planetTexture = Content.Load<Texture2D>("planet");
            craterTexture = Content.Load<Texture2D>("crater");
            wipeTexture = Content.Load<Texture2D>("wipe");

            // Initialise the two render targets to be the same size as the planet.
            craterRender = new RenderTarget2D(graphics.GraphicsDevice,
                planetTexture.Width, planetTexture.Height);
            wipeRender = new RenderTarget2D(graphics.GraphicsDevice,
                planetTexture.Width, planetTexture.Height);

            // Set each of the render targets to be blank initially.
            graphics.GraphicsDevice.SetRenderTarget(craterRender);
            graphics.GraphicsDevice.Clear(Color.White);
            graphics.GraphicsDevice.SetRenderTarget(wipeRender);
            graphics.GraphicsDevice.Clear(Color.White);

            // Go back to drawing to the screen again.
            graphics.GraphicsDevice.SetRenderTarget(null);
        }
You'll see that we've added a render target and texture for the crater part and loaded them as we've done previously. We've also added a random generator, so that we can later draw the craters with a different rotation each time; a current and previous mouse state, for detecting mouse presses and getting the position, and I've set the mouse to be visible in the game's constructor, to make things easier for the player.

Now we need a method to call when a new mouse press is detected. Add this to the bottom of your Game1 class.
        /// 
        /// Add a crater to the render target at the current mouse position.
        /// 
        private void AddCrater()
        {
            // Pick a random rotation for the new crater (between 0 and 360 degrees).
            float rotation = (float)random.NextDouble() * MathHelper.TwoPi;

            Vector2 origin = new Vector2(
                craterTexture.Width / 2, craterTexture.Height / 2);

            // Create a temporary render target to use while we update the mask.
            RenderTarget2D tempRender = new RenderTarget2D(graphics.GraphicsDevice,
                planetTexture.Width, planetTexture.Height);

            // Tell the graphics device we want to draw to our seperate render target,
            // instead of the screen, until we tell it otherwise.
            graphics.GraphicsDevice.SetRenderTarget(tempRender);

            spriteBatch.Begin();

            // Draw the previous render (including existing craters) to the new one.
            spriteBatch.Draw(craterRender, Vector2.Zero, Color.White);

            // Draw a new crater at the cursor's position.
            spriteBatch.Draw(craterTexture,
                new Vector2(currentMouse.X - 208, currentMouse.Y - 24),
                null, Color.White, rotation, origin, 1f, SpriteEffects.None, 1f);

            spriteBatch.End();

            // Go back to drawing to the screen again.
            graphics.GraphicsDevice.SetRenderTarget(null);

            // Update the crater render.
            craterRender = tempRender;
        }
The first couple of lines deal with setting up drawing variables. XNA draws using radians for rotation (where 0 to 2π is 0 to 360 degrees), so we generate a random double between 0 and 1 and multiply that by 2π to get our rotation. Next we set up a temporary render target, draw the current render target to it and then draw our new crater at the mouse's position (using the rotation and origin previously calculated). After that we just update the crater render target and finish.

All that's left to do is call this method when the mouse button is pressed.
        /// 
        /// Allows the game to run logic such as updating the world,
        /// checking for collisions, gathering input, and playing audio.
        /// 
        /// Provides a snapshot of timing values.
        protected override void Update(GameTime gameTime)
        {
            // Allows the game to exit
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
                this.Exit();

            previousMouse = currentMouse;
            currentMouse = Mouse.GetState();

            // If we press the mouse button, add a crater to the render target.
            if (currentMouse.LeftButton == ButtonState.Pressed
                && previousMouse.LeftButton == ButtonState.Released)
            {
                AddCrater();
            }

            base.Update(gameTime);
        }

        /// 
        /// This is called when the game should draw itself.
        /// 
        /// Provides a snapshot of timing values.
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.Black);

            // Set the value of the alpha mask.
            alphaShader.Parameters["MaskTexture"].SetValue(craterRender);

            // Start SpriteBatch using our custom effect.
            spriteBatch.Begin(SpriteSortMode.Immediate,
                BlendState.AlphaBlend, null, null, null, alphaShader);

            // Draw the planet texture, including any mask in alphaShader.
            spriteBatch.Draw(planetTexture, new Vector2(208, 24), Color.White);

            spriteBatch.End();

            base.Draw(gameTime);
        }
This is relatively straightforward. We update our current and previous mouse states and if we detect a new mouse click then we call the AddCrater() method we previously created. The Draw() method is largely the same as it was before, just using craterRender instead of wipeRender.


There you go, now you have a planet you can pick apart piece by piece. Some considerations you could think about are how to tell when the planet has been completely masked (the planet is round while the render target is rectangular) and how you might do collision detection with this system (perhaps keeping a list of crater positions and rotations).



You can download my sample here.
Thanks go to Syntax Warriors for their excellent posts.

No comments:

Post a Comment