Lesson 18: Particle Effects

In lesson 14 you were given a sneak peak at particle effects with the Hocus Pocus example highlighting the use of color. In this lesson we'll cover the mechanics of creating particle effects and how to implement them in games. Particle effects add fun to any game they are incorporated into. Many mobile games incorporate an overkill of particle effects to hide the fact that they are fairly dull otherwise. The flashing colors and objects flying around make games seem more engaging. Of course with an excellent game particle effects are icing on the cake.

Pixel Effects

The simplest particle effects using nothing more than controlled pixels. Each pixel contains an x and y location, x and y vectors, and a speed at a minimum. The following example is a particle effect I often use called sparks. It's an example of the minimum code needed and I often use it as a basis to build other effects upon. The code named SparkDemo.BAS can be found in your .\tutorial\Lesson18\ directory.

Figure 1: Let the sparks fly!

When creating effects such as this there are two routines needed, an initiator and a maintainer. In the previous example the MakeSparks() subroutine is the initiator. When MakeSparks() is called it creates new entries in the Spark() dynamic array that are waiting to be be acted upon. In other words MakeSparks() is filling fields with values in the array spreadsheet. The UpdateSparks() subroutine then maintains and updates the entries residing within the Spark() dynamic array.  Each spark is created using a TYPE identifier that resides in the Spark() dynamic array.

TYPE SPARK '              single spark definition
    Location AS XYPAIR '  location of spark
    Vector AS XYPAIR '    spark vector
    Velocity AS SINGLE '  velocity of spark (speed)
    Fade AS INTEGER '     intensity of spark
    Lifespan AS INTEGER ' lifespan of spark

Spark().Lifespan is used as a count down timer that is given a value when a spark is created. With each passing frame Spark().Lifespan is decreased by one in effect giving the spark a finite time to live. The Spark().Location.x and Spark().Location.y variables are the current coordinate locations of the spark. The Spark().Vector.x and Spark().Vector.y variables are vector quantities that control the direction of travel for the spark. Spark().Velocity is the vector multiplier that controls the speed of each spark. Spark().Fade is used to control the intensity of the spark throughout its life span. As the spark gets older the intensity value decreases giving the illusion of fading sparks.

Line 18 of the code creates an empty dynamic array named Spark(0). This dynamic array will grow and shrink in size as needed to maintain the number of sparks currently alive within the array.

When the code recognizes that the user is pressing the left mouse button in line 31 the MakeSparks() subroutine is called and the current mouse cursor location is passed to it.

MakeSparks _MOUSEX, _MOUSEY ' make the sparks fly at the cursor location

The MakeSparks() subroutine first performs some array maintenance in lines 55 through 61. If there are no live sparks found within the Spark() array it is reset back to a zero index. This ensures that the array does not grow so large as to cause an out of memory error. The spark counter seen on the screen is actually the current size of the Spark() array and will reset back to zero when new sparks are not being made.

The largest index value within the array is then identified in line 62 so line 63 can increase the size of the array accordingly to handle the new sparks about to be made. The variable SparkNum controls how many sparks are made with each mouse click and this number is used to increase the array size by that amount.

Lines 66 through 76 are used to populate the new array indexes with initial values needed to create a spark. These new entries are now ready to be called upon by the UpdateSparks() maintainer subroutine which is located within the main program loop.

Line 94 within the UpdateSparks() subroutine is used to detect if the Spark() array is currently empty. If it is an immediate exit from the subroutine is performed. There is no reason to scan the Spark() array if it doesn't contain any data. UpdateSparks() also performs some array maintenance duties since it is always called up in the main program loop unlike MakeSparks() which is only called when needed. Line 95 sets a flag that assumes the array contains no live sparks. If this flag is still set in line 123 the Spark() array is reset to a zero index.

Each spark is drawn with a bright center pixel surround by 4 pixels at half intensity and 4 more pixels at one quarter intensity.  This gives each pixel a hot glow effect. Figure 2 below shows what the pixels look like zoomed in at 800%.

Figure 2: Sparks zoomed in 800%

Because the pixels move so fast and fade quickly the user can't tell that each spark is actually a 6x6 grid of pixels with different intensities. They simply blend together to appear as a single glowing pixel moving through space. Go ahead and REM out lines 107 through 114 to see each spark as a single pixel. It's not nearly as clean looking in my opinion.

Throughout the life span of the spark it's intensity and velocity are decreased until finally the life span timer reaches zero at which point the spark will no longer be acted upon. Multiplying each spark's velocity by 0.9 in line 119 effectively slows its speed by 10% with each frame. This is why the sparks move very fast at first but quickly slow exponentially afterwards.

Simulated Gravity

Adding simulated gravity to particle effects is a simple matter of tinkering with the y vector of each particle. By slowly increasing the y vector value any particles heading upward with a negative y vector value will eventually turn around and start heading downward. By increasing the y vector speed at the same time the particles will appear to speed up as the travel downward simulating a gravitational pull. Keep in mind that the next example simply simulates gravity which in many cases will look "good enough". This keeps the math involved to a bare minimum while still yielding a believable effect.

The following example is the previous example slightly altered to introduce simulated gravity. Any line of code that has been changed or added has been noted at the right-hand side of the code. The code named GravitySparkDemo.BAS can be found in your .\tutorial\Lesson18\ directory.

The code now needs the ability to separately control the speed of the x and y vectors. This was achieved in line 13 by changing .Velocity from a single type value to an XYPAIR TYPE. Since the vertical speed of the sparks will need to increase independent of the horizontal speed as they travel downward two variables will be required, Spark().Velocity.x and Spark().Velocity.y.

Lines 74 and 75 in the MarkSparks() subroutine now creates separate x and y vector velocities for each spark. Lines 120 and 121 in the UpdateSparks() subroutine can now have these velocities independently applied to the x and y vectors of each spark.

Lines 123 and 124 is where the calculations for the simulated gravity take place. In line 123 the vertical speed is slightly increased to make each spark appear to move faster downward as the frames progress. Line 120 adds a small positive value to the y vector with each passing frame. This has the effect of turning around any particles moving in an upward direction to a downward direction during their life span.

Again, this effect looks "good enough" for most situations because of the small size and fleeting life span of the particles. For larger and slower moving objects the correct physics may need to be calculated instead.

Particle Fountains

Pixels are great for effects but images do even better. By pointing your objects in a desired direction you can achieve a particle fountain. Particle fountains are useful for things such as engine exhaust, water fountains, and bullet hell games. Here's a piece of code that creates a Mario fountain complete with animation and sound, because, well why not! Pressing the space bar will release one Mario from the fountain. Holding the ENTER key will open the fountain for a flood of Marios. Holding the ENTER key and then holding the space bar is just insane! You'll get over 270 Marios on your screen at once. How's that for QB64 power! The code named MarioFountain.BAS can be found in your .\tutorial\Lesson18\ directory.

Figure 3: Release the Marios!

This code is a slight modification from the second code example with gravity. The key differences are:

The MakeMario subroutine in lines 80 through 114 no longer resets the object array, Mario(), when there is inactivity on the screen. Since the frame rate is 60 and up to two Marios can be created during each frame it can be assumed that a maximum of 120 Marios can be created by the user per second. Given some time for the Marios to fly and run off the screen the number of maximum array objects at any given time should be manageable. The code proves this out by showing the maximum number of Marios seen at a time which rarely exceeds 270. So the code was written to increase the size of the array as needed and reuse free indexes in the array when they become available.

Lines 90 through 96 scans the Mario() array for an index that is not in use. If one is found that index is used. If one is not found then line 98 increases the array size and that new index value is used.

Lines 106 through 108 set vector quantities for a 30 degree arc from -15 degrees to +15 degrees using a radian value. 15 degrees converted to a radian value is .2617994 so using a radian value from -.262 to +.262 will result in the 30 degree upward facing arc. Line 106 creates that range of radian values. The radian value is then converted to vector quantities in lines 107 and 108.

The UpdateMario subroutine scans the Mario() array for any live Marios by checking Mario().Liv. When a Mario is found to be alive a series of events happens to guide Mario through the animation sequence. A two dimensional array named Sprite() has been created that holds 9 sprite images, 3 for flying, 3 for landing, and another 3 for running. The first index in the Sprite() array indicates the action, FLYING, LANDING, and RUNNING, and the second index holds the series of three sprites needed for that action's animation.

When a Mario is created with MakeMario the array value Mario().Act is set to the action FLYING. The SELECT CASE statement in line 127 looks at the value in Mario().Act and uses that value to animate the current action Mario is going through.

When the CASE is FLYING the y vector of Mario is looked at. If the y vector is less than zero (going upward) the first sprite in the FLYING series is used. When the y vector has gone above zero but still less than .5 then Mario is beginning the downward arc and the second sprite in the series is used. When Mario has gone beyond the value of .5 in the y vector the third sprite in the series in used. Mario's location is then updated in lines 136 and 137 and the y vector is increased slightly in line 138. When Mario's y location exceeds 199 Mario's action is then set to LANDING, the sprite reset to one, a frame counter is set to 6, and Mario's y location is forced to 200. At this point the landing sound is played.

When the CASE is LANDING a frame counter is used to determine how long each sprite in the LANDING sequence should be shown. The frame count is decreased by one in line 147. When the frame count reaches zero the next sprite in the sequence is set and the frame counter is reset to a value of 6. When the sprite sequence reaches 4 that means all sprites in the LANDING sequence have been shown. Mario's action is now set to RUNNING, the sprite is reset back to one and the frame counter is reset to 6. Mario's running direction is determined by taking the sign of the x vector, negative or positive, and multiplying that by a velocity of 2 in line 155.

When the CASE is RUNNING Mario's x location is updated. If Mario has run off the screen then Mario().Liv is set to FALSE releasing the array index for the next Mario that comes along. If Mario is still RUNNING then the frame counter is decreased by one. When the frame counter reaches zero the next sprite in the sequence is set and the frame counter reset back to 6.

By incorporating frame counters into animation sequences you can precisely control when a change in the sprite image will occur. The main program loop runs at 30 frames per second. The RUNNING sequence changes Mario's sprite image every 6th frame in effect stepping down Mario's RUNNING animation to 5 frames per second. Mario running at 30 frames per second would create nothing but a blur.

Finally in lines 171 through 177 the Mario sprite based on action and sprite number, Sprite(Mario(count).Act, Mario(count).Spr), is drawn in the correct direction as determined by line 171. If the x vector of Mario is positive he must be running to the right, if the x vector is negative he's running to the left. The _PUTIMAGE statements are used to orient the sprite image as required.

The only change to Mario's trajectory needed was in line 138 with the y vector unlike the code in the second example. Mario's jumping arc simply needs to be a smooth up and down action without an increase in velocity or a change in the x vector. In fact most platform games that utilize a character that jumps around uses this same procedure, simply modifying the y vector after a velocity and vector quantity have been established for the jump. The x vector is typically the velocity the character happens to be running in.

This demo also uses the concepts of a sprite sheet. The background image and sprites have been combined into one image called MarioFountain.PNG as seen in Figure 4 below.

Figure 4: MarioFountain.PNG

The LoadAssets subroutine is used to create and load the images, sounds, parse the sprites from MarioFountain.PNG, and then remove them so the main image can be used as a static background.

Finally, the ReleaseAssets subroutine is used to remove all loaded images and sounds from RAM before exiting the program. It's always a good idea to have your software perform this sort of cleanup routine before exiting back to the operating system.

Putting It All Together

Let's finish off this lesson with a game that includes everything learned up to this point. You can find Falcon9.BAS in your .\tutorial\Lesson18\ directory.

Figure 5: Landing the Falcon 9 stage 1 booster

The game starts out with a one minute and 30 second cinematic introduction explaining the events that lead up to the premise of the game: You need to land the stricken Falcon 9 stage 1 booster manually. After the introduction (which you can skip by pressing any key) you are greeted by the CEO explaining the dire situation the company is facing. Pressing the UP ARROW key fires the main engine. The RIGHT and LEFT ARROW keys burst the side thrusters allowing you to maneuver the stricken spacecraft toward the awaiting barge. Land the booster and you are praised, crash it and, well, you're not. You can press the ESC key at any time to immediately exit the game.

Landing the rocket is hard! But after just a few minutes of playing you'll be able to achieve about a 50% proficiency in your landings, even better if you keep at it. Go easy on the side thrusters as there is a very limited supply of nitrogen on-board. There's no real physics calculations going on in this game. Everything is simulated using concepts learned in lesson 17. It's a good example of how simulated can be "good enough". Real physics calculations would of course be ideal but we'll get to those in a later lesson.

The source code of this game is a little over 1000 lines long which is rather small for a game that incorporates everything seen. Particles fountains are used to simulate the thrust blasts from the rocket's side thrusters.  Another particle fountain is used to simulate exhaust smoke from the main engine as well as fire smoke coming from the rocket when it crashes. The concepts of an initiator and maintainer program are used to control the fading text sequences seen in the cinematic intro to the game. And finally simulated gravity is used to control the motion of the rocket as you try to maneuver and land it on the floating barge.

The Intro subroutine shows how you can set up an introduction sequence using a frame counter to keep track of key entry points for actions or events. The MakeSmoke and UpdateSmoke subroutines are once again just modified versions of the sparks demo shown earlier as are the MakeBurst and UpdateBurst subroutines.

The LoadAssets subroutine is used to load the game's sounds and graphics. A master sprite sheet is loaded and picked apart into the various graphics images needed for game play. Finally a subroutine called ExitGame is called upon when the user wishes to quit the game. ExitGame releases all sound and graphics assets from RAM before returning to the operating system. Always remember to clean up after yourself before exiting.

The code is well documented so make sure to take a good look at it. All of the concepts from this and previous lessons have purposely been included in the game. It's not a highly polished game and could use some improvements. A difficulty level selector and more polished graphics would be nice place to start. If you decide to improve upon the game make sure to upload it to the QB64 forum for others to enjoy!

This game took me a little more than 3 days to write. If you stick with game programming and have a genuine interest in learning it you'll be doing the same in a surprisingly short amount of time.