Implementing smoke within the Reach profile
If implemented in a naive fashion, rendering smoke could place a significant burden on the hardware of a device running a game under the Reach profile.
In this recipe, you'll learn a method for improving the distribution of work and data between the CPU and GPU, to hopefully get as close as possible to the unachievable goal of hardware instancing in an environment that doesn't allow for custom shaders.
In the following illustration, you can see this recipe in use within a stylized city scene:
Getting ready
An image of a smoke "particle" is required for this special effect, but don't feel pressured to spend too much time or effort creating anything too elaborate. Although it forms the heart of the display, the technique's repetition and distortion of even the most basic smoke pattern can result in some quite intricate and realistic results.
How to do it...
To create smoke within the Reach Profile:
1. Start by creating a new smoke particle class:
class ReachSmokeParticle {
2. Insert instance variables for the position and age of the particle:
public Vector3 Position; public float PositionDelta; public float Scale; public float Rotation; public float RotationDelta; public float Age; public float AgeDelta; public bool Visible; public Texture2D Texture; public Vector2 TextureOrigin;
3. Add an
Update()
method to calculate a particle's details:public void Update( GameTime gameTime, Vector3 wind, Vector3 spawnPoint, float spawnRadius, Random random) { var timeScale = (float)gameTime.ElapsedGameTime.TotalSeconds; Position += ((Vector3.Up * PositionDelta) + wind) * timeScale; Rotation += RotationDelta * timeScale; Age += AgeDelta * timeScale; if (Age > 1) { var offset = ((.5f - (float)random.NextDouble()) * (Vector3.Right * spawnRadius)) + ((.5f - (float)random.NextDouble()) * (Vector3.Forward * spawnRadius)); Position = spawnPoint + offset; Age = 0; Visible = true; } }
4. Continue by adding a
Draw()
method:public void Draw( SpriteBatch spriteBatch, Viewport viewport, Matrix view, Matrix projection, float projectedScale) { if (!Visible) { return; } var projectedPosition = viewport.Project( Position, projection, view, Matrix.Identity); var screenPosition = new Vector2( projectedPosition.X, projectedPosition.Y); var tint = Color.FromNonPremultiplied( 255, 255, 255, 255 - (int)(255f * Age)); var displayScale = Scale * projectedScale; spriteBatch.Draw( Texture, screenPosition, null, tint, Rotation, TextureOrigin, displayScale, SpriteEffects.None, projectedPosition.Z); }
5. Now, add the class that will be emitting the freshly defined particles:
class ReachSmoke {
6. Insert some instance variables to the new class to hold the details of the particles:
SpriteBatch spriteBatch; Texture2D smoke; Vector2 halfSmokeSize; List<ReachSmokeParticle> particles; Vector3 spawnPoint = Vector3.Zero; float spawnRadius = 0.2f; Random random = new Random(); Vector3 wind = Vector3.Right * 0.3f;
7. Add a constructor to create instances of all the particles:
public ReachSmoke( GraphicsDevice graphicsDevice, ContentManager content) { spriteBatch = new SpriteBatch(graphicsDevice); smoke = content.Load<Texture2D>("smoke/smoke"); halfSmokeSize = new Vector2( smoke.Width / 2, smoke.Height / 2); var particleCount = 300; particles = new List<ReachSmokeParticle>(); for (var index = 0; index < particleCount; index++) { var particle = new ReachSmokeParticle() { Texture = smoke, TextureOrigin = halfSmokeSize, Position = spawnPoint, PositionDelta = (0.8f * (float)random.NextDouble()) + .2f, Scale = (0.8f * (float)random.NextDouble()) + .2f, Rotation = (float)random.NextDouble(), RotationDelta = 0.5f - (float)random.NextDouble(), Age = (float)random.NextDouble(), AgeDelta = (0.8f * (float)random.NextDouble()) + .2f, Visible = false }; particles.Add(particle); } }
8. Add an
Update()
method to update all the particles to their latest positions:public void Update(GameTime gameTime) { foreach (var particle in particles) { particle.Update( gameTime, wind, spawnPoint, spawnRadius, random); } }
9. Finish the class with the addition of the
Draw()
method to render the particles onto the screen:public void Draw( Matrix view, Matrix projection, Matrix world, Viewport viewport) { var scaleTestPositionOne = viewport.Project( Vector3.Zero, projection, view, Matrix.Identity); if (scaleTestPositionOne.Z < 0) { return; } var scaleTestPositionTwo = viewport.Project( Vector3.Up + Vector3.Right, projection, view, Matrix.Identity); var projectedScale = Vector3.Distance( scaleTestPositionOne, scaleTestPositionTwo) / (smoke.Height * 2); if (projectedScale > 5f) { return; } spriteBatch.Begin( SpriteSortMode.Deferred, BlendState.AlphaBlend, null, DepthStencilState.DepthRead, null); foreach (var particle in particles) { particle.Draw(spriteBatch, viewport, view, projection, projectedScale); } spriteBatch.End(); }
How it works...
Inspecting the constructor of the ReachSmoke
class, we can see the creation of the smoke particles.
A diverse selection of random sizes, speeds, and states throughout the particles lessens the chance of players being able to spot any obvious signs of repetition, despite the use of only one texture across all of the particles.
In order to lessen the chance of unwanted pressure on the garbage collector, we create a set number of particles at the beginning and recycle them as required.
Within the Update()
method of the ReachSmokeParticle
class, the code to move and rotate each particle can be seen along the recycling process that re-spawns a particle once it has reached the end of its life.
Eagle-eyed readers may notice that a given particle's visibility isn't enabled until it has re-spawned at least once. This delay in visibility is done to give the particles the best chance of appearing in a reasonably even manner, and avoid one large, unrealistic clump at the beginning.
To enjoy the best chance of maximum performance, the Draw()
methods of both the ReachSmoke
and ReachSmokeParticle
classes are designed with the idea of harnessing the optimizations present in the SpriteBatch
class.
With this in mind, the transformation from 3D world space into 2D screen space is performed on the CPU via .NET code. This allows the GPU to receive and display the particles in one unified update, without the need to recalculate or reload.
Handling the 3D calculations ourselves presents two issues:
The first is that of perspective and the need to scale, which is dependent on how far away the viewer is from the smoke.
A measurement of the distance between screen positions of two known points in 3D space is made at the start of the
Draw()
method ofReachSmoke
, to help approximate the scaling of perspective.The second problem is of depth, where the
SpriteBatch
class is more commonly called upon to draw images over the top of a 3D scene rather than within it.Thankfully, the
Draw()
method of theSpriteBatch
class has an override that allows us to specify the depth of a given texture, and use this to ensure that particles appear correctly in front of, and behind other elements in a 3D scene.
There's more...
With some tweaking of the numbers and the texture, a variety of effects from flame, to steam, and even bubbles, can be achieved with a similar approach.
One addition that can really improve the realism of particle-based effects such as smoke, is animating the texture of each particle as it moves through the scene.
Examples of this can be found in games such as Naughty Dog's Uncharted 3, where they utilized an additional texture as a "movement map", where the color of each pixel in the movement map dictated the direction and intensity of the translation/distortion applied to the corresponding pixel in the texture map.