source: https://www.freepik.com/free-vector/missile-arrow-game-vfx-effect-light-fire-trail_136129492.htm
My brain often doesn’t allow me to focus on the things I should be focused on, at least when it comes to personal projects. There’s several things that were more important to focus on than a visual effects system, but I was just finding that something was missing in my prototype - there just wasn’t enough feedback when landing a hit. So to appease my monkey brain, I decided spend a day and knock this system out.
The Design
I always strive for simplicity. I’ve found that you can always build on top of a simple system so long as it’s not too simple. I knew that I wanted a few things from it:
- Simple to use as a designer in the Unity editor.
- Painless integration with existing systems.
- Object pooling for performance.
- Easily extendable.
After several design attempts, I ultimately decided to take a step back and do what I’ve always done which is to start with a mockup of how I want to use the system. Ultimately what I landed on was 4 primary classes:
- EffectsManager would handle the object pooling and caching.
- VisualEffectData would be a scriptable object that defines the base properties, including the prefab, and functionality.
- VisualEffectController would be attached to the effect prefab, and be what actually played the effect using properties from the effect data.
- VisualEffectRegistry would be how I registered the visual effects, including pooling size.
The EffectsManager more specifically uses a Dictionary<System.Type, Queue<VisualEffectController>> for the pool
storage. This allows me to easily access specific types of controllers.
However, I ran into a snag. I realized I wanted to be able to also pass along custom parameters when playing effects from external systems, such as the Ability Effect system. For example, perhaps the effect should be scaled based on the total amount of damage dealt. What I didn’t want was a design where I had no compile-time type checking or support for intellisense. The issue here was that various things used the VisualEffectData class and I didn’t want to do something silly like
public readonly struct DataProps {}
VisualEffectData<DataProps>
The solution was simple though:
public abstract class VisualEffectData : ScriptableObject {
...
public abstract void Play();
}
public abstract class VisualEffectData<T> : VisualEffectData {
public abstract void Play(T runtimeData);
}
So now any effect data that offers custom runtime data properties can extend VisualEffectData<CustomData> which solves
all of my problems. Now I get compile-time type checking, intellisense support, etc. and I don’t need to typecast or use
runtime type checking. This may seem like an obviously solution in retrospect, but it took some time to land on.
Integration
What I wanted was to simply be able to compose various objects with specific types of visual effects. This was a whole other design decision I had to make because while I could have easily just added visual effects to a list and looped over them, I wanted to have more fine-grain control over when and how they were played, and so I landed on keeping it simple.
For example:
public class ExplosionAbilityEffect : AbilityEffect {
[SerializeField] private ExplosionVisualEffectData explosionVfx;
public override void Activate() {
var customData = new ExplosionVisualEffectData.CustomRuntimeData{
intensity: 2f // simplifying the example
};
explosionVfx.Play(customData);
}
}
As you can see, there is some tight coupling here - and that’s ok. It’s localized to this class only. Using this pattern, I can add additional effects later and even decide if a particular effect should be played based on some conditions being met. Each ExplosionAbilityEffect asset can have different ExplosionVisualEffect assets assigned which gives me a lot of reusability.
Simplifying Use
I didn’t necessarily want everything that uses visual effects to also have to know about their controller components or even about the effects manager. It’s a lot of extra boilerplate every time I want something to play an effect, and so I landed on having the effect data object itself take care of the work for me.
public override void Play() {
var controller = GameManager.Instance.EffectsManager.GetController<SomeSpecificController>();
if (controller == null) {
// log warning / error
return;
}
controller.SetData(this);
controller.Play();
}
Final Thoughts
The pooling system is overly simplified. It currently pre-caches everything in the visual effects registry on start. This is the one thing that I know will need to change down the road. Here’s why:
- If we preload vfx meant to be used exclusively for the final boss and we’re just starting the game, it makes zero sense to load them now.
- There may be a dozen or more enemy types - but only a handful per level / area. Pooling 10+ vfx for every enemy type immediately is a waste of memory.
- The player will get upgrades over time. Each upgrade may have a different associated visual effect. Same issue.
So eventually I’ll need to modify the system to only pre-load visual effects as needed. I might just change the registry to where each effect is only pooled once initially requested. This means I’ll probably need to use asset bundles or addressables.
Conclusion
I’m pretty happy with this foundation. I can easily create new types of visual effect assets with enough properties to reuse them in different situations which can all be controlled using a custom visual effect controller component. It’s still too early on to know where things may brake down, but as I said at the start of this post, I’ve built a simple system that won’t take much to build on top of later.