During the course of some hobby projects I like to take the time to investigate ways I might be able to provide cleaner more intuitive interfaces between the various components of the software. Currently I am building a small game and decided to write everything from scratch, mostly as a learning experience. Currently I am working on the Particle System, and besides the fact that there a hundreds of implementations out there, and I have personally written more than a few over the years, I always seem to learn something new.
As with most particle systems I would imagine, I have a class that provides the initial setup of the particle system, the values that the particle emitter works with to select the initial starting point, velocity, colour, size, lifespan etc, and how those values change over the life of the particle. Typically such a class might look something like the following fragment
public class ParticleSystemSettings
{
public Color MinColorStart
{
get;
set;
}
public Color MaxColorStart
{
get;
set;
}
public Color MinColorEnd
{
get;
set;
}
public Color MaxColorEnd
{
get;
set;
}
public float MinLifeSpan
{
get;
set;
}
public float MaxLifeSpan
{
get;
set;
}
}
Using the above settings class the particle emitter would randomly select a starting colour between MinColorStart and MaxColorStart, likewise it would randomly select the final target colour for the particle in the range of MinColorEnd and MaxColorEnd. The lifespan of the particle would randomly be selected to fall between MinLifeSpan and MaxLifeSpan and so on.
As you can imagine a fully fleshed out Settings class can become quite elaborate, so I thought I would create a Range type class something that would allow me to write code along the following lines.
public class ParticleSystemSettings
{
public Range<Color> StartColorRange
{
get;
set;
}
public Range<Color> EndColorRange
{
get;
set;
}
public Range<float> LifeSpanRange
{
get;
set;
}
}
Clearly this is much less noisy, but besides the arguably more readable interface I also hope to gain some functional enhancements, especially when it comes to selecting values between the ranges and interpolating from one value to the next.
For example, the following code demonstrates how I would like to be initializing an instance of the ParticleSystemSettings class.
ParticleSystemSettings settings = new ParticleSystemSettings();
settings.StartColorRange = new Range<Color>(Color.Yellow, Color.Orange);
settings.EndColorRange = new Range<Color>(Color.Orange, Color.Red);
settings.LifeSpanRange = new Range<float>(0.2f, 0.5f);
And the the following is an example of how a new particle could be initialized
Particle particle = new Particle();
particle.LifeSpan = settings.LifeSpanRange.Random();
particle.ColorRange = new Range<Color>(
settings.StartColorRange.Random(),
settings.EndColorRange.Random());
And the final piece of the puzzle, calculating the appropriate value from the particles ranges based on the current progression of the particle through it’s lifespan. The following would typically be done in the Update method of the particle.
float lerpAmount = particle.Age / particle.LifeSpan;
Color renderColor = particle.ColorRange.Lerp(lerpAmount);
float renderSize = particle.SizeRange.Lerp(lerpAmount);
From these examples, you should be notice that the goal for our Range type implementation is to provide not only a container for start and end points of a range, but also methods to select a random value from the range as well as perform a linear interpolation (lerp) on the range. And most importantly the Range type should support this for any number of base types ranging from floats, Colors, Vector2, Vector3 even between dates if you so desired.
So with a basic outline of what I wanted to achieve I went through essentially two iterations before settling on a final solution. I will share both solutions and highlight a few of the pros and cons I have come across. I will start with saying that neither is perfect, but they do achieve the core goals albeit with some caveats.
Below is the complete code for my first implementation of the Range type.
public struct Range<T>
{
private T _start;
private T _end;
private Func<T, T, float, T> _lerp;
public Range(T value, Func<T, T, float, T> lerp)
: this(value, value, lerp)
{
}
public Range(T start, T end, Func<T, T, float, T> lerp)
{
_start = start;
_end = end;
_lerp = lerp;
}
public T Start
{
get { return _start; }
}
public T End
{
get { return _end; }
}
public T Lerp(float amount)
{
if (_lerp == null) return _start;
return _lerp(_start, _end, amount);
}
public T Random()
{
return Lerp(Utilities.GetRandomFloat());
}
}
First some things you will notice,
- The type is defined as a struct, since literally hundreds if not thousands of particles will be initialized per frame I wanted to ensure that the minimum of objects are created on the heap.
- The range constructor requires that an appropriate Lerp functor is provided. This deviates from my initial construction syntax and was one of the motivators to explore alternative implementations. This signature of the lerp argument matches the implementation of Lerp on the XNA Vector2, Vector3 and Color types, but I will need to provide a custom implementation for interpolating floats or any other type that does not provide a ready made Lerp function of sorts.
- The Utilities.GetRandomFloat() just returns a random float value between 0.0f and 1.0f.
The next bit of code shows a few examples of how this implementation of the Range type would be used. For this example I have used a simple Lambda expression to provide the Lerp implementation for the float data type, later we will explore a more reusable solution.
// Initialize a few ranges
Range<Vector2> positionRange = new Range<Vector2>(Vector2.Zero, Vector2.One, Vector2.Lerp);
Range<Color> colorRange = new Range<Color>(Color.White, Color.Black, Color.Lerp);
Range<float> floatRange = new Range<float>(0.0f, 5.0f,
(start, end, amount) => { return start + ((end - start) * amount); });
// Get a value midway through the range
Vector2 position = positionRange.Lerp(0.5f);
Color color = colorRange.Lerp(0.5f);
float value = floatRange.Lerp(0.5f);
Of course having to pass a Lambda expression every time you wanted to use a Range on a type that does not provide a Lerp function in some form or the other would be very error prone. The obvious solution is to create some static helper methods in a static class and as we require additional lerp functions we can build on this library. We could go one step further and provide factory functions to create type specific Ranges that would provide the Lerp function internally that way we do not need to pass it every time. An example of this might look like the following.
public static class Range
{
// Factory Functions
public static Range<Vector2> CreateVector2(Vector2 start, Vector2 end)
{
return new Range<Vector2>(start, end, Vector2.Lerp);
}
public static Range<Color> CreateColor(Color start, Color end)
{
return new Range<Color>(start, end, Color.Lerp);
}
public static Range<float> CreateFloat(Color start, Color end)
{
return new Range<Color>(start, end, LerpFloat);
}
// Custom Lerp functions
public static float LerpFloat(float start, float end, float amount)
{
return start + ((end - start) * amount);
}
}
You will notice that I named the class Range, this seems to conflict with the name of the generic struct, however the CS compiler is “smart” enough to distinguish which type is being referenced in the code and while a name like RangeHelper or something similar might be a safer option, I liked the symmetry in the following code.
// Initialize a few ranges
Range<Vector2> positionRange = Range.CreateVector2(Vector2.Zero, Vector2.One);
Range<Color> colorRange = Range.CreateColor(Color.White, Color.Black);
Range<float> floatRange = Range.CreateFloat(0.0f, 5.0f);
There you have it, the first iteration of a passable Range type that met most of the goals I initially set out to achieve. I only have one major concern, and that is of performance. I do not even own an XBOX 360 so I have no idea what the performance impact is of calling Func<> delegate thousands of times per frame. On the notebook I am using to write this post, the code easily achieves about 30,000 (thirty thousand) calls per frame, but I have no clue how efficient this would be on the XBOX 360. So what are my alternatives?
Well, having used C and latter C++ since ‘89 I grew with the evolution of C++ templates and one aspect of template that would have come in handy here would have been support for template specialization. Essentially you can provide a specialized implementation of a template for a specific type, then when ever you declare a template of that type the specialized implementation is used rather than the “generic” implementation. Cool, but generics do not offer this functionality so what can I do?
One option might be to make the Range type a class (reference type), and provide specialized implementations which inherit from Range<T>, for example class FloatRange : Range<float>. The big problem with this is that I would end up creating millions of objects on the heap that need to be garbage collected resulting in significant performance degradation. Of course I could work around this as well since my particles are cached, I could make sure I reuse the member instances rather than recreate new ones each time. Maybe this is the route I should have gone. My only concern is this would require the developer to be very conscious of how the type is used to ensure optimal performance, no I would like to stick to the struct option.
So the next take on the solution required basically to declarations, the Range type and extension methods which provided the illusion of “type specialization”. The Range type is significantly simplified and no longer provides an implementation of Lerp or Random, these have been delegated to the realm of extension methods.
Here is the new code for the Range type
public struct Range<T>
{
private T _start;
private T _end;
public Range(T value)
: this(value, value)
{
}
public Range(T start, T end)
{
_start = start;
_end = end;
}
public T Start
{
get { return _start; }
set { _start = value; }
}
public T End
{
get { return _end; }
set { _end = value; }
}
}
As you will notice, the Range type now only contains the start and end points of the range without any implied functionality. The following set of extension methods provide the functionality we expect from the Range type and is specialized for each type.
public static class XnaRangeExtensions
{
public static float Lerp(this Range<float> range, float amount)
{
return range.Start + ((range.End - range.Start) * amount);
}
public static Vector2 Lerp(this Range<Vector2> range, float amount)
{
return Vector2.Lerp(range.Start, range.End, amount);
}
public static Vector3 Lerp(this Range<Vector3> range, float amount)
{
return Vector3.Lerp(range.Start, range.End, amount);
}
public static Color Lerp(this Range<Color> range, float amount)
{
return Color.Lerp(range.Start, range.End, amount);
}
public static float Random(this Range<float> range)
{
return Lerp(range, Utilities.GetRandomFloat());
}
public static Vector2 Random(this Range<Vector2> range)
{
return Lerp(range, Utilities.GetRandomFloat());
}
public static Vector3 Random(this Range<Vector3> range)
{
return Lerp(range, Utilities.GetRandomFloat());
}
public static Color Random(this Range<Color> range)
{
return Lerp(range, Utilities.GetRandomFloat());
}
public static bool IsEmpty(this Range<float> range)
{
return range.Start == range.End;
}
public static bool IsEmpty(this Range<Vector2> range)
{
return range.Start == range.End;
}
public static bool IsEmpty(this Range<Vector3> range)
{
return range.Start == range.End;
}
public static bool IsEmpty(this Range<Color> range)
{
return range.Start == range.End;
}
}
Since this is the implementation I went with there are a few additional methods like the IsEmpty methods which identify empty Ranges. Lets see how this implementation of the Range class would be used.
// Initialize a few ranges
Range<Vector2> positionRange = new Range<Vector2>(Vector2.Zero, Vector2.One);
Range<Color> colorRange = new Range<Color>(Color.White, Color.Black);
Range<float> floatRange = new Range<float>(0.0f, 5.0f);
// Get a value midway through the range
Vector2 position = positionRange.Lerp(0.5f);
Color color = colorRange.Lerp(0.5f);
float value = floatRange.Lerp(0.5f);
Syntactically this code matches exactly our original requirements, even if it required the syntactic sugar of extension methods, but to be honest, I was not dissatisfied with syntax imposed by the first implementation. So which option is better, well you can rest assured I will be evolving my thinking on this as well as possible implementation routes, hopefully with the aid of your input. As new and better alternatives evolve I will definitely post updates.