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.
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.
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:
- Extract the bright parts of the image.
- Blur the bright parts.
- 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.
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.
BloomComponent bloom;
In the GameRoot
constructor, add the following lines.
bloom = new BloomComponent(this); Components.Add(bloom); bloom.Settings = new BloomSettings(null, 0.25f, 4, 2, 1, 1.5f, 1);
Finally, at the very beginning of GameRoot.Draw()
, add the following line.
bloom.BeginDraw();
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.
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:
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.
sampler TextureSampler : register(s0); float BloomThreshold; float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 { // Look up the original image color. float4 c = tex2D(TextureSampler, texCoord); // Adjust it to keep only values brighter than the specified threshold. return saturate((c - BloomThreshold) / (1 - BloomThreshold)); } technique BloomExtract { pass Pass1 { PixelShader = compile ps_2_0 PixelShaderFunction(); } }
Don’t worry if you aren’t familiar with HLSL. Let’s examine how this works.
sampler TextureSampler : register(s0);
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.
float BloomThreshold;
BloomThreshold
is a parameter that we can set from our C# code.
float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 {
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.
// Look up the original image color. float4 c = tex2D(TextureSampler, texCoord); // Adjust it to keep only values brighter than the specified threshold. return saturate((c - BloomThreshold) / (1 - BloomThreshold));
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:
c -- float4(BloomThreshold, BloomThreshold, BloomThreshold, BloomThreshold)
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 29×29 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.
sampler TextureSampler : register(s0); #define SAMPLE_COUNT 15 float2 SampleOffsets[SAMPLE_COUNT]; float SampleWeights[SAMPLE_COUNT]; float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 { float4 c = 0; // Combine a number of weighted image filter taps. for (int i = 0; i < SAMPLE_COUNT; i++) { c += tex2D(TextureSampler, texCoord + SampleOffsets[i]) * SampleWeights[i]; } return c; }
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.
sampler BloomSampler : register(s0); sampler BaseSampler : register(s1); float BloomIntensity; float BaseIntensity; float BloomSaturation; float BaseSaturation;
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.
GraphicsDevice.Textures[1] = sceneRenderTarget;
Next we have a helper function that adjusts the saturation of a color.
float4 AdjustSaturation(float4 color, float saturation) { // The constants 0.3, 0.59, and 0.11 are chosen because the // human eye is more sensitive to green light, and less to blue. float grey = dot(color, float3(0.3, 0.59, 0.11)); return lerp(grey, color, saturation); }
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.
float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 { // Look up the bloom and original base image colors. float4 bloom = tex2D(BloomSampler, texCoord); float4 base = tex2D(BaseSampler, texCoord); // Adjust color saturation and intensity. bloom = AdjustSaturation(bloom, BloomSaturation) * BloomIntensity; base = AdjustSaturation(base, BaseSaturation) * BaseIntensity; // Darken down the base image in areas where there is a lot of bloom, // to prevent things looking excessively burned-out. base *= (1 - saturate(bloom)); // Combine the two images. return base + bloom; }
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.
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.
class BlackHole : Entity { private static Random rand = new Random(); private int hitpoints = 10; public BlackHole(Vector2 position) { image = Art.BlackHole; Position = position; Radius = image.Width / 2f; } public void WasShot() { hitpoints--; if (hitpoints <= 0) IsExpired = true; } public void Kill() { hitpoints = 0; WasShot(); } public override void Draw(SpriteBatch spriteBatch) { // make the size of the black hole pulsate float scale = 1 + 0.1f * (float)Math.Sin(10 * GameRoot.GameTime.TotalGameTime.TotalSeconds); spriteBatch.Draw(image, Position, null, color, Orientation, Size / 2f, scale, 0, 0); } }
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
.
public static IEnumerable GetNearbyEntities(Vector2 position, float radius) { return entities.Where(x => Vector2.DistanceSquared(position, x.Position) < radius * radius); }
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.
public override void Update() { var entities = EntityManager.GetNearbyEntities(Position, 250); foreach (var entity in entities) { if (entity is Enemy && !(entity as Enemy).IsActive) continue; // bullets are repelled by black holes and everything else is attracted if (entity is Bullet) entity.Velocity += (entity.Position - Position).ScaleTo(0.3f); else { var dPos = Position - entity.Position; var length = dPos.Length(); entity.Velocity += dPos.ScaleTo(MathHelper.Lerp(2, 0, length / 250f)); } } }
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()
.
// handle collisions with black holes for (int i = 0; i < blackHoles.Count; i++) { for (int j = 0; j < enemies.Count; j++) if (enemies[j].IsActive && IsColliding(blackHoles[i], enemies[j])) enemies[j].WasShot(); for (int j = 0; j < bullets.Count; j++) { if (IsColliding(blackHoles[i], bullets[j])) { bullets[j].IsExpired = true; blackHoles[i].WasShot(); } } if (IsColliding(PlayerShip.Instance, blackHoles[i])) { KillPlayer(); break; } }
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.
if (EntityManager.BlackHoleCount < 2 && rand.Next((int)inverseBlackHoleChance) == 0) EntityManager.Add(new BlackHole(GetSpawnPosition()));
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.