Advertisement
  1. Game Development
  2. Programming
Gamedevelopment

Make a Neon Vector Shooter in XNA: Bloom and Black Holes

by
Difficulty:IntermediateLength:LongLanguages:
This post is part of a series called Cross-Platform Vector Shooter: XNA.
Make a Neon Vector Shooter in XNA: More Gameplay
Make a Neon Vector Shooter in XNA: Particle Effects

In this series of tutorials, I'll show you how to make a neon twin stick shooter, like Geometry Wars, in XNA. The goal of these tutorials is not to leave you with an exact replica of Geometry Wars, but rather to go over the necessary elements that will allow you to create your own high-quality variant.


Overview

In the series so far, we've set up the basic gameplay for our neon twin stick shooter, Shape Blaster. In this tutorial we will create the signature neon look by adding a bloom post-processing filter.

Warning: Loud!

Simple effects such as this or particle effects can make a game considerably more appealing without requiring any changes to the gameplay. Effective use of visual effects is an important consideration in any game. After adding the bloom filter, we will also add black holes to the game.


Bloom Post-Processing Effect

Bloom describes the effect you see when you look at an object with a bright light behind it and the light appears to bleed over the object. In Shape Blaster, the bloom effect will make the bright lines of the ships and particles look like bright, glowing, neon lights.

Sunlight blooming through the trees
Sunlight blooming through the trees

To apply bloom in our game, we must render our scene to a render target, and then apply our bloom filter to that render target.

Bloom works in three steps:

  1. Extract the bright parts of the image.
  2. Blur the bright parts.
  3. Recombine the blurred image with the original image while doing some brightness and saturation adjustments.

Each of these steps requires a shader - essentially a short program that run on your graphics card. Shaders in XNA are written in a special language called High-Level Shader Language (HLSL). The sample images below show the result of each step.

Initial image
Initial image
The bright areas extracted from the image
The bright areas extracted from the image
The bright areas after blurring
The bright areas after blurring
The final result after recombining with the original image
The final result after recombining with the original image

Adding Bloom to Shape Blaster

For our bloom filter, we will be using the XNA Bloom Postprocess Sample.

Integrating the bloom sample with our project is easy. First, locate the two code files from the sample, BloomComponent.cs and BloomSettings.cs, and add them to the ShapeBlaster project. Also add BloomCombine.fx, BloomExtract.fx, and GaussianBlur.fx to the content pipeline project.

In GameRoot, add a using statement for the BloomPostprocess namespace and add a BloomComponent member variable.

In the GameRoot constructor, add the following lines.

Finally, at the very beginning of GameRoot.Draw(), add the following line.

That's it. If you run the game now, you should see the bloom in effect.

When you call bloom.BeginDraw(), it redirects subsequent draw calls to a render target to which bloom will be applied. When you call base.Draw() at the end of the GameRoot.Draw() method, the BloomComponent's Draw() method is called. This is where the bloom is applied and the scene is drawn to the back buffer. Therefore, anything that needs have bloom applied must be drawn between the calls to bloom.BeginDraw() and base.Draw().

Tip: If you want to draw something without bloom (for example, the user interface), draw it after the call to base.Draw().

You can tweak the bloom settings to your liking. I've chosen the following values:

  • 0.25 for the bloom threshold. This means any parts of the image that are less than a quarter of full brightness will not contribute to bloom.
  • 4 for the blur amount. For the mathematically inclined, this is the standard deviation of the Gaussian blur. Larger values will blur the light bloom more. However, keep in mind that the blur shader is set to use a fixed number of samples, regardless of the blur amount. If you set this value too high, the blur will extend beyond the radius from which the shader samples, and artifacts will appear. Ideally this value should be no more than a third of your sampling radius to ensure the error is negligible.
  • 2 for the bloom intensity, which determines how strongly the bloom affects the final result.
  • 1 for the base intensity, which determines how strongly the original image affects the final result.
  • 1.5 for the bloom saturation. This causes the glow around bright objects to have more saturated colors than the objects themselves. A high value was chosen to simulate the look of neon lights. If you look at the center of a bright neon light, it looks almost white, while the glow around it is more strongly colored.
  • 1 for the base saturation. This value affects the saturation of the base image.
Without bloom
Without bloom
With bloom
With bloom

Bloom Under the Hood

The bloom filter is implemented in the BloomComponent class. The bloom component starts by creating and loading the necessary resources in its LoadContent() method. Here, it loads the three shaders it requires and creates three render targets.

The first render target, sceneRenderTarget, is for holding the scene that the bloom will be applied to. The other two, renderTarget1 and renderTarget2, are used to temporarily hold the intermediary results between each rendering pass. These render targets are made half the game's resolution to reduce the performance cost. This does not reduce the final quality of the bloom, because we will be blurring the bloom images anyway.

Bloom requires four rendering passes, as shown in this diagram:

Render target diagram

In XNA, the Effect class encapsulates a shader. You write the code for the shader in separate file, which you add to the content pipeline. These are the files with the .fx extension we added earlier. You load the shader into an Effect object by calling the Content.Load<Effect>() method in LoadContent(). The easiest way to use a shader in a 2D game is to pass the Effect object as a parameter to SpriteBatch.Begin().

There are several types of shaders, but for the bloom filter we will only being using pixel shaders (sometimes called fragment shaders). A pixel shader is a small program that runs once for every pixel you draw and determines the color of the pixel. We'll go over each of the shaders used.

The BloomExtract Shader

The BloomExtract shader is the simplest of the three shaders. Its job is to extract the areas of the image that are brighter than some threshold and then rescale the color values to use the full color range. Any values below the threshold will become black.

The full shader code is shown below.

Don't worry if you aren't familiar with HLSL. Let's examine how this works.

This first part declares a texture sampler called TextureSampler. SpriteBatch will bind a texture to this sampler when it draws with this shader. Specifying which register to bind to is optional. We use the sampler to look up pixels from the bound texture.

BloomThreshold is a parameter that we can set from our C# code.

This is our pixel shader function declaration that takes texture coordinates as input and returns a color. The color is returned as a float4. This is a collection of four floats, much like a Vector4 in XNA. They store the red, green, blue, and alpha components of the color as values between zero and one.

TEXCOORD0 and COLOR0 are called semantics, and they indicate to the compiler how the texCoord parameter and the return value are used. For each pixel output, texCoord will contain the coordinates of the corresponding point in the input texture, with (0, 0) being the top left corner and (1, 1) being the bottom right.

This is where all the real work is done. It fetches the pixel color from the texture, subtracts BloomThreshold from each color component, and then scales it back up so that the maximum value is one. The saturate() function then clamps the color's components between zero and one.

You may notice that c and BloomThreshold are not the same type, as c is a float4 and BloomThreshold is a float. HLSL allows you to do operations with these different types by essentially turning the float into a float4 with all components the same. (c - BloomThreshold) effectively becomes:

The rest of the shader simply creates a technique that uses the pixel shader function, compiled for shader model 2.0.

The GaussianBlur Shader

A Gaussian blur blurs an image using a Gaussian function. For each pixel in the output image, we sum up the pixels in the input image weighted by their distance from the target pixel. Nearby pixels contribute greatly to the final color while distant pixels contribute very little.

Because distant pixels make negligible contributions and because texture lookups are costly, we only sample pixels within a short radius instead of sampling the entire texture. This shader will sample points within 14 pixels of the current pixel.

A naïve implementation might sample all the points in a square around the current pixel. However, this can be costly. In our example, we would have to sample points within a 29x29 square (14 points on either side of the center pixel, plus the center pixel). That's a total of 841 samples for each pixel in our image. Luckily, there's a faster method. It turns out that doing a 2D Gaussian blur is equivalent to first blurring the image horizontally, and then blurring it again vertically. Each of these one-dimensional blurs only requires 29 samples, reducing our total to 58 samples per pixel.

One more trick is used to further increase the efficiency of the blur. When you tell the GPU to sample between two pixels, it will return a blend of the two pixels at no additional performance cost. Since our blur is blending pixels together anyway, this allows us to sample two pixels at a time. This cuts the number of required samples almost in half.

Below are the relevant parts of the GaussianBlur shader.

The shader is actually quite simple; it just takes an array of offsets and a corresponding array of weights and computes the weighted sum. All the complex math is actually in the C# code that populates the offset and weight arrays. This is done in the SetBlurEffectParameters() and ComputeGaussian() methods of the BloomComponent class. When performing the horizontal blur pass, SampleOffsets will be populated with only horizontal offsets (the y components are all zero), and of course the reverse is true for the vertical pass.

The BloomCombine Shader

The BloomCombine shader does a few things at once. It combines the bloom texture with the original texture while also adjusting the intensity and saturation of each texture.

The shader starts by declaring two texture samplers and four float parameters.

One thing to note is that SpriteBatch will automatically bind the texture you pass it when calling SpriteBatch.Draw() to first sampler, but it won't automatically bind anything to the second sampler. The second sampler is set manually in BloomComponent.Draw() with the following line.

Next we have a helper function that adjusts the saturation of a color.

This function takes a color and a saturation value and returns a new color. Passing a saturation of 1 leaves the color unchanged. Passing 0 will return grey, and passing values greater than one will return a color with increased saturation. Passing negative values is really outside the intended usage, but will invert the color if you do so.

The function works by first finding the luminosity of the color by taking a weighted sum based on our eyes' sensitivity to red, green and blue light. It then linearly interpolates between grey and the original color by the amount of saturation specified. This function is called by the pixel shader function.

Again, this shader is fairly straightforward. If you're wondering why the base image needs to be darkened in areas with bright bloom, remember that adding two colors together increases the brightness and any color components that add up to a value greater than one (full brightness) will be clipped to one. Since the bloom image is similar to the base image, this would cause much of the image that has over 50% brightness to become maxed out. Darkening the base image maps all the colors back into the range of colors we can properly display.


Black Holes

One of the most interesting enemies in Geometry Wars is the black hole. Let's examine how we can make something similar in Shape Blaster. We will create the basic functionality now, and we will revisit the enemy in the next tutorial to add particle effects and particle interactions.

A black hole with orbiting particles
A black hole with orbiting particles

Basic Functionality

The black holes will pull in the player's ship, nearby enemies, and (after the next tutorial) particles, but will repel bullets.

There are many possible functions we can use for attraction or repulsion. The simplest is to use constant force so that the black hole pulls with the same strength regardless of the object's distance. Another option is to have the force increase linearly from zero at some maximum distance, to full strength for objects directly on top of the black hole.

If we'd like to model gravity more realistically, we can use the inverse square of the distance, which means the force of gravity is proportional to \(1 / distance^2\). We'll actually be using each of these three functions to handle different objects. The bullets will be repelled with a constant force, the enemies and the player's ship will be attracted with a linear force, and the particles will use an inverse square function.

We'll make a new class for black holes. Let's start with the basic functionality.

The black holes take ten shots to kill. We adjust the scale of the sprite slightly to make it pulsate. If you decide that destroying black holes should also grant points, you must make similar adjustments to the BlackHole class as we did with the enemy class.

Next we'll make the black holes actually apply a force on other entities. We'll need a small helper method from our EntityManager.

This method could be made more efficient by using a more complicated spatial partitioning scheme, but for the number of entities we will have, it's fine as it is. Now we can make the black holes apply force in their Update() method.

Black holes only affect entities within a chosen radius (250 pixels). Bullets within this radius have a constant repulsive force applied, while everything else has a linear attractive force applied.

We'll need to add collision handling for black holes to the EntityManager. Add a List<> for black holes like we did for the other types of entities, and add the following code in EntityManager.HandleCollisions().

Finally, open the EnemySpawner class and have it create some black holes. I limited the maximum number of black holes to two, and gave a 1 in 600 chance of a black hole spawning each frame.


Conclusion

We've added bloom using various shaders, and black holes using various force formulas. Shape Blaster is starting to look pretty good. In the next part, we'll add some crazy, over the top particle effects.

Advertisement
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.