In this series of tutorials, I'll show you how to make a Geometry Wars-inspired twin-stick shooter, with neon graphics, crazy particle effects, and awesome music, for iOS using C++ and OpenGL ES 2.0. In this final part, we'll add the background grid which warps based on the in-game action.
Overview
In the series so far we've created the gameplay, virtual gamepad, and particle effects. In this final part, we will create a dynamic, warping background grid.
As mentioned in the previous part, you'll notice a dramatic drop in framerate if you're still running the code in debug mode. See that tutorial for details on how to switch to release mode for full compiler optimization (and a faster build).
The Warping Grid
One of the coolest effects in Geometry Wars is the warping background grid. We'll examine how to create a similar effect in Shape Blaster. The grid will react to bullets, black holes, and the player respawning. It's not difficult to make and it looks awesome.
We'll make the grid using a spring simulation. At each intersection of the grid, we'll put a small weight and attach a spring on each side. These springs will only pull and never push, much like a rubber band. To keep the grid in position, the masses at the border of the grid will be anchored in place. Below is a diagram of the layout.
We'll create a class called Grid
to create this effect. However, before we work on the grid itself, we need to make two helper classes: Spring
and PointMass
.
The PointMass Class
The PointMass
class represents the masses to which we will attach the springs. Springs never connect directly to other springs; instead, they apply a force to the masses they connect, which in turn may stretch other springs.
class PointMass { protected: tVector3f mAcceleration; float mDamping; public: tVector3f mPosition; tVector3f mVelocity; float mInverseMass; public: PointMass(); PointMass(const tVector3f& position, float invMass); void applyForce(const tVector3f& force); void increaseDamping(float factor); void update(); }; PointMass::PointMass() : mAcceleration(0,0,0), mDamping(0.98f), mPosition(0), mVelocity(0,0,0), mInverseMass(0) { } PointMass::PointMass(const tVector3f& position, float invMass) : mAcceleration(0,0,0), mDamping(0.98f), mPosition(position), mVelocity(0,0,0), mInverseMass(invMass) { } void PointMass::applyForce(const tVector3f& force) { mAcceleration += force * mInverseMass; } void PointMass::increaseDamping(float factor) { mDamping *= factor; } void PointMass::update() { mVelocity += mAcceleration; mPosition += mVelocity; mAcceleration = tVector3f(0,0,0); if (mVelocity.lengthSquared() < 0.001f * 0.001f) { mVelocity = tVector3f(0,0,0); } mVelocity *= mDamping; mDamping = 0.98f; }
There are a few interesting points about this class. First, notice that it stores the inverse of the mass, 1 / mass
. This is often a good idea in physics simulations because physics equations tend to use the inverse of the mass more often, and because it gives us an easy way to represent infinitely heavy, immovable objects by setting the inverse mass to zero.
Second, the class also contains a damping variable. This is used roughly as friction or air resistance; it gradually slows the mass down. This helps make the grid eventually come to rest and also increases the stability of the spring simulation.
The PointMass::update()
method does the work of moving the point mass each frame. It begins by doing symplectic Euler integration, which just means we add the acceleration to the velocity and then add the updated velocity to the position. This differs from standard Euler integration in which we would update the velocity after updating the position.
Tip: Symplectic Euler is better for spring simulations because it conserves energy. If you use regular Euler integration and create springs without damping, they will tend to stretch further and further each bounce as they gain energy, eventually breaking your simulation.
After updating the velocity and position, we check whether the velocity is very small, and if so we set it to zero. This can be important for performance due to the nature of denormalized floating-point numbers.
(When floating-point numbers get very small, they use a special representation called a denormalized number. This has the advantage of allowing floats to represent smaller numbers, but it comes at a price. Most chipsets can't use their standard arithmetic operations on denormalized numbers and instead must emulate them using a series of steps. This can be tens to hundreds of times slower than performing operations on normalized floating-point numbers. Since we multiply our velocity by our damping factor each frame, it will eventually become very small. We don't actually care about such tiny velocities, so we simply set it to zero.)
The PointMass::increaseDamping()
method is used to temporarily increase the amount of damping. We will use this later for certain effects.
The Spring Class
A spring connects two point masses, and, if stretched past its natural length, applies a force pulling the masses together. Springs follow a modified version of Hooke's Law with damping:
\[f = −kx − bv\]
- \(f\) is the force produced by the spring.
- \(k\) is the spring constant, or the stiffness of the spring.
- \(x\) is the distance the spring is stretched beyond its natural length.
- \(b\) is the damping factor.
- \(v\) is the velocity.
The code for the Spring
class is as follows:
class Spring { public: PointMass* mEnd1; PointMass* mEnd2; float mTargetLength; float mStiffness; float mDamping; public: Spring(PointMass* end1, PointMass* end2, float stiffness, float damping); void update(); }; Spring::Spring(PointMass* end1, PointMass* end2, float stiffness, float damping) : mEnd1(end1), mEnd2(end2), mTargetLength(mEnd1->mPosition.distance(mEnd2->mPosition) * 0.95f), mStiffness(stiffness), mDamping(damping) { } void Spring::update() { tVector3f x = mEnd1->mPosition - mEnd2->mPosition; float length = x.length(); if (length > mTargetLength) { x = (x / length) * (length - mTargetLength); tVector3f dv = mEnd2->mVelocity - mEnd1->mVelocity; tVector3f force = mStiffness * x - dv * mDamping; mEnd1->applyForce(-force); mEnd2->applyForce(force); } }
When we create a spring, we set the natural length of the spring to be just slightly less than the distance between the two end points. This keeps the grid taut even when at rest, and improves the appearance somewhat.
The Spring::update()
method first checks whether the spring is stretched beyond its natural length. If it is not stretched, nothing happens. If it is, we use the modified Hooke's Law to find the force from the spring and apply it to the two connected masses.
Creating the Grid
Now that we have the necessary nested classes, we're ready to create the grid. We start by creating PointMass
objects at each intersection on the grid. We also create some immovable anchor PointMass
objects to hold the grid in place. We then link up the masses with springs.
std::vector<Spring> mSprings; PointMass* mPoints; Grid::Grid(const tRectf& rect, const tVector2f& spacing) { mScreenSize = tVector2f(GameRoot::getInstance()->getViewportSize().width, GameRoot::getInstance()->getViewportSize().height); int numColumns = (int)((float)rect.size.width / spacing.x) + 1; int numRows = (int)((float)rect.size.height / spacing.y) + 1; mPoints = new PointMass[numColumns * numRows]; mCols = numColumns; mRows = numRows; PointMass* fixedPoints = new PointMass[numColumns * numRows]; int column = 0, row = 0; for (float y = rect.location.y; y <= rect.location.y + rect.size.height; y += spacing.y) { for (float x = rect.location.x; x <= rect.location.x + rect.size.width; x += spacing.x) { SetPointMass(mPoints, column, row, PointMass(tVector3f(x, y, 0), 1)); SetPointMass(fixedPoints, column, row, PointMass(tVector3f(x, y, 0), 0)); column++; } row++; column = 0; } // link the point masses with springs for (int y = 0; y < numRows; y++) { for (int x = 0; x < numColumns; x++) { if (x == 0 || y == 0 || x == numColumns - 1 || y == numRows - 1) { mSprings.push_back(Spring(GetPointMass(fixedPoints, x, y), GetPointMass(mPoints, x, y), 0.1f, 0.1f)); } else if (x % 3 == 0 && y % 3 == 0) { mSprings.push_back( Spring(GetPointMass(fixedPoints, x, y), GetPointMass(mPoints, x, y), 0.002f, 0.02f)); } if (x > 0) { mSprings.push_back(Spring(GetPointMass(mPoints, x - 1, y), GetPointMass(mPoints, x, y), 0.28f, 0.06f)); } if (y > 0) { mSprings.push_back(Spring(GetPointMass(mPoints, x, y - 1), GetPointMass(mPoints, x, y), 0.28f, 0.06f)); } } } }
The first for
loop creates both regular masses and immovable masses at each intersection of the grid. We won't actually use all of the immovable masses, and the unused masses will simply be garbage collected some time after the constructor ends. We could optimize by avoiding creating unnecessary objects, but since the grid is usually only created once, it won't make much difference.
In addition to using anchor point masses around the border of the grid, we will also use some anchor masses inside the grid. These will be used to very gently help pull the grid back to its original position after being deformed.
Since the anchor points never move, they don't need to be updated each frame; we can simply hook them up to the springs and forget about them. Therefore, we don't have a member variable in the Grid
class for these masses.
There are a number of values you can tweak in the creation of the grid. The most important ones are the stiffness and damping of the springs. (The stiffness and damping of the border anchors and interior anchors are set independently of the main springs.) Higher stiffness values will make the springs oscillate more quickly, and higher damping values will cause the springs to slow down sooner.
Manipulating the Grid
In order for the grid to move, we must update it each frame. This is very simple as we already did all the hard work in the PointMass
and Spring
classes:
void Grid::update() { for(size_t i = 0; i < mSprings.size(); i++) { mSprings[i].update(); } for(int i = 0; i < mCols * mRows; i++) { mPoints[i].update(); } }
Now we will add some methods that manipulate the grid. You can add methods for any kind of manipulation you can think of. We will implement three types of manipulations here: pushing part of the grid in a given direction, pushing the grid outwards from some point, and pulling the grid in towards some point. All three will affect the grid within a given radius from some target point. Below are some images of these manipulations in action:
void Grid::applyDirectedForce(const tVector3f& force, const tVector3f& position, float radius) { for(int i = 0; i < mCols * mRows; i++) { if (position.distanceSquared(mPoints[i].mPosition) < radius * radius) { mPoints[i].applyForce(10.0f * force / (10 + position.distance(mPoints[i].mPosition))); } } } void Grid::applyImplosiveForce(float force, const tVector3f& position, float radius) { for (int i = 0; i < mCols * mRows; i++) { float dist2 = position.distanceSquared(mPoints[i].mPosition); if (dist2 < radius * radius) { mPoints[i].applyForce(10.0f * force * (position - mPoints[i].mPosition) / (100 + dist2)); mPoints[i].increaseDamping(0.6f); } } } void Grid::applyExplosiveForce(float force, const tVector3f& position, float radius) { for (int i = 0; i < mCols * mRows; i++) { float dist2 = position.distanceSquared(mPoints[i].mPosition); if (dist2 < radius * radius) { mPoints[i].applyForce(100 * force * (mPoints[i].mPosition - position) / (10000 + dist2)); mPoints[i].increaseDamping(0.6f); } } }
We will use all three of these methods in Shape Blaster for different effects.
Rendering the Grid
We'll draw the grid by drawing line segments between each neighbouring pair of points. First, we'll add an extension method taking a tSpriteBatch
pointer as a parameter that allows us to draw line segments by taking a texture of a single pixel and stretching it into a line.
Open the Art
class and declare a texture for the pixel:
class Art : public tSingleton<Art>; { protected: tTexture* mPixel; . . . public: tTexture* getPixel() const; . . . };
You can set the pixel texture the same way we set up the other images, so we'll add pixel.png
( a 1x1px image with the sole pixel set to white) to the project and load it into the tTexture
:
mPixel = new tTexture(tSurface("pixel.png"));
Now let's add the following method to the Extensions
class:
void Extensions::drawLine(tSpriteBatch* spriteBatch, const tVector2f& start, const tVector2f& end, const tColor4f& color, float thickness) { tVector2f delta = end - start; spriteBatch->draw(0, Art::getInstance()->getPixel(), tPoint2f((int32_t)start.x, (int32_t)start.y), tOptional<tRectf>(), color, toAngle(delta), tPoint2f(0, 0), tVector2f(delta.length(), thickness)); }
This method stretches, rotates, and tints the pixel texture to produce the line we desire.
Next, we need a method to project the 3D grid points onto our 2D screen. Normally this might be done using matrices, but here we'll transform the coordinates manually instead.
Add the following to the Grid
class:
tVector2f Grid::toVec2(const tVector3f& v) { float factor = (v.z + 2000.0f) * 0.0005f; return (tVector2f(v.x, v.y) - mScreenSize * 0.5f) * factor + mScreenSize * 0.5f; }
This transform will give the grid a perspective view where far away points appear closer together on the screen. Now we can draw the grid by iterating through the rows and columns and drawing lines between them:
void Grid::draw(tSpriteBatch* spriteBatch) { int width = mCols; int height = mRows; tColor4f color(0.12f, 0.12f, 0.55f, 0.33f); for (int y = 1; y < height; y++) { for (int x = 1; x < width; x++) { tVector2f left, up; tVector2f p = toVec2(GetPointMass(mPoints, x, y)->mPosition); if (x > 1) { left = toVec2(GetPointMass(mPoints, x - 1, y)->mPosition); float thickness = (y % 3 == 1) ? 3.0f : 1.0f; Extensions::drawLine(spriteBatch, left, p, color, thickness); } if (y > 1) { up = toVec2(GetPointMass(mPoints, x, y - 1)->mPosition); float thickness = (x % 3 == 1) ? 3.0f : 1.0f; Extensions::drawLine(spriteBatch, up, p, color, thickness); } } } }
In the above code, p
is our current point on the grid, left
is the point directly to its left and up
is the point directly above it. We draw every third line thicker both horizontally and vertically for visual effect.
Interpolation
We can optimize the grid by improving the visual quality for a given number of springs without significantly increasing the performance cost. We are going to do two such optimizations.
We will make the grid denser by adding line segments inside the existing grid cells. We do so by drawing lines from the midpoint of one side of the cell to the midpoint of the opposite side. The image below shows the new interpolated lines in red.
Drawing the interpolated lines is straightforward. If you have two points, a
and b
, their midpoint is (a + b) / 2
. So, to draw the interpolated lines, we add the following code inside the for
loops of our Grid::draw()
method:
if (x > 1 && y > 1) { tVector2f upLeft = toVec2(GetPointMass(mPoints, x - 1, y - 1)->mPosition); Extensions::drawLine(spriteBatch, 0.5f * (upLeft + up), 0.5f * (left + p), color, 1.0f); // vertical line Extensions::drawLine(spriteBatch, 0.5f * (upLeft + left), 0.5f * (up + p), color, 1.0f); // horizontal line }
The second improvement is to perform interpolation on our straight line segments to make them into smoother curves. In the original XNA version of this game, the code relied on XNA's Vector2.CatmullRom()
method which performs Catmull-Rom interpolation. You pass the method four sequential points on a curved line, and it will return points along a smooth curve between the second and third points you provided.
Since this algorithm doesn't exist in either C or C++'s standard library, we'll have to implement it ourselves. Luckily there is a reference implementation available to use. I've provided a MathUtil::catmullRom()
method based on this reference implementation:
float MathUtil::catmullRom(const float value1, const float value2, const float value3, const float value4, float amount) { // Using formula from http://www.mvps.org/directx/articles/catmull/ float amountSquared = amount * amount; float amountCubed = amountSquared * amount; return (float)(0.5f * (2.0f * value2 + (value3 - value1) * amount + (2.0f * value1 - 5.0f * value2 + 4.0f * value3 - value4) * amountSquared + (3.0f * value2 - value1 - 3.0f * value3 + value4) * amountCubed)); } tVector2f MathUtil::catmullRom(const tVector2f& value1, const tVector2f& value2, const tVector2f& value3, const tVector2f& value4, float amount) { return tVector2f(MathUtil::catmullRom(value1.x, value2.x, value3.x, value4.x, amount), MathUtil::catmullRom(value1.y, value2.y, value3.y, value4.y, amount)); }
The fifth argument to MathUtil::catmullRom()
is a weighting factor that determines which point on the interpolated curve it returns. A weighting factor of 0
or 1
will respectively return the second or third point you provided, and a weighting factor of 0.5
will return the point on the interpolated curve halfway between the two points. By gradually moving the weighting factor from zero to one and drawing lines between the returned points, we can produce a perfectly smooth curve. However, to keep the performance cost low, we will only take a single interpolated point into consideration, at a weighting factor of 0.5
. We then replace the original straight line in the grid with two lines that meet at the interpolated point.
The diagram below shows the effect of this interpolation:
Since the line segments in the grid are already small, using more than one interpolated point generally does not make a noticeable difference.
Often, the lines in our grid will be very straight and won't require any smoothing. We can check for this and avoid having to draw two lines instead of one: we check if the distance between the interpolated point and the midpoint of the straight line is greater than one pixel; if it is, we assume the line is curved and we draw two line segments.
The modification to our Grid::draw()
method for adding Catmull-Rom interpolation for the horizontal lines is shown below.
left = toVec2(GetPointMass(mPoints, x - 1, y)->mPosition); float thickness = (y % 3 == 1) ? 3.0f : 1.0f; int clampedX = (int)tMath::min(x + 1, width - 1); tVector2f mid = MathUtil::catmullRom(toVec2(GetPointMass(mPoints, x - 2, y)->mPosition), left, p, toVec2(GetPointMass(mPoints, clampedX, y)->mPosition), 0.5f); if (mid.distanceSquared((left + p) / 2) > 1) { Extensions::drawLine(spriteBatch, left, mid, color, thickness); Extensions::drawLine(spriteBatch, mid, p, color, thickness); } else { Extensions::drawLine(spriteBatch, left, p, color, thickness); }
The image below shows the effects of the smoothing. A green dot is drawn at each interpolated point to better illustrate where the lines are smoothed.
Using the Grid in Shape Blaster
Now it's time to use the grid in our game. We start by declaring a public, static Grid
variable in GameRoot
and creating the grid in the GameRoot::onInitView
. We'll create a grid with roughly 600 points like so.
const int maxGridPoints = 600; tVector2f gridSpacing = tVector2f((float)sqrtf(mViewportSize.width * mViewportSize.height / maxGridPoints)); mGrid = new Grid(tRectf(0,0, mViewportSize), gridSpacing);
Though the original XNA version of the game uses 1,600 points (rather than 600), this becomes way too much to handle just even for the powerful hardware in the iPhone. Fortunately, the original code left the amount of points customizable, and at about 600 grid points, we can still render them and still maintain an optimal frame rate.
Then we call Grid::update()
and Grid::draw()
from the GameRoot::onRedrawView()
method in GameRoot
. This will allow us to see the grid when we run the game. However, we still need to make various game objects interact with the grid.
Bullets will repel the grid. We already made a method to do this called Grid::applyExplosiveForce()
. Add the following line to the Bullet::update()
method.
GameRoot::getInstance()->getGrid()->applyExplosiveForce(0.5f * mVelocity.length(), mPosition, 80);
This will make bullets repel the grid proportionally to their speed. That was pretty easy.
Now let's work on black holes. Add this line to BlackHole::update()
:
GameRoot::getInstance()->getGrid()->applyImplosiveForce((float)sinf(mSprayAngle / 2.0f) * 10 + 20, mPosition, 200);
This makes the black hole suck in the grid with a varying amount of force. We've reused the mSprayAngle
variable, which will cause the force on the grid to pulsate in sync with the angle it sprays particles (although at half the frequency due to the division by two). The force passed in will vary sinusoidally between 10
and 30
.
Finally, we will create a shockwave in the grid when the player's ship respawns after death. We will do so by pulling the grid along the z-axis and then allowing the force to propagate and bounce through the springs. Again, this only requires a small modification to PlayerShip::update()
.
if (getIsDead()) { mFramesUntilRespawn--; if (mFramesUntilRespawn == 0) { GameRoot::getInstance()->getGrid()->applyDirectedForce(tVector3f(0, 0, 5000), tVector3f(mPosition.x, mPosition.y, 0), 50); } }
What's Next?
We have the basic gameplay and effects implemented. It's up to you to turn it into a complete and polished game with your own flavour. Try adding some interesting new mechanics, some cool new effects, or a unique story. In case you aren't sure where to start, here are a few suggestions:
- Tweak and customize the touch controls to your personal preferences.
- Add support for hardware game controllers in iOS 7 via the GameController Framework.
- Profile and optimize all the slow parts of the code using Xcode's built-in Instruments tool.
- Attempt to add post-processing effects like the Bloom Shader present in the original XNA version.
- Create new enemy types such as snakes or exploding enemies.
- Create new weapon types such as seeking missiles or a lightning gun.
- Add a title screen and main menu, and a high score table.
- Add some powerups such as a shield or bombs.
- Experiment to find powerups that are fun and help your game stand out.
- Create multiple levels. Harder levels can introduce tougher enemies and more advanced weapons and powerups.
- Add environmental hazards such as lasers.
The sky's the limit!