Spawning Enemies Rant
Spawning enemies has always been problematic for us during our Mortal Rite journey. Spawning enemies is not something that I’ve seen people complain about on the internet about Unreal Engine, but, it seems like, there’s a lot that is just accepted when it comes to issues with Unreal Engine. E.G.: Something can affect one dev team and not another simply because of the type of project that they are working on -OR- because a Dev Team doesn’t know the proper way to do something… yet.
Originally we just spawned enemies using spawn actor nodes and that worked for the most part. This was in the time long, long ago.
All was good using spawn actor nodes until all wasn’t good using spawn actor nodes. What changed? Something about some of the enemies that we have created did not play well with the spawn actor nodes and caused huge amounts of lag (~10 seconds).
It was at this point that we decided to pre-spawn enemies. We went through several iterations of pre-spawning enemies: Pre-Spawning an enemy for each enemy needed in the level; Pre-Spawning a pool of enemies that can be re-used so that there are not as many in the level. Probably some other iteration that I don’t remember even though I made it.
The complexity added by pre-spawning enemies and making sure every enemy was properly recycled or cleaned up properly was significant. Each time new systems were added or changes to existing systems were made that affected enemies, which most do, it could cause something to not get cleaned up correctly.
After a lot of trial and error, we’ve now gone back to spawning enemies on demand using spawn actor nodes. What changed? We found out what was causing the huge lag and fixed it (Initializing cloth simulations that caused most of it). Well, it wasn’t all us. There was a fix introduced in a newer version of Unreal Engine that fixed a lot of it.
Optimization: Spawning. Where we are now
We use Enemy Placers to setup and place enemies in the world. Having all of the settings in a common place regardless of what enemy you are dealing with is good.
We now use a distance check between any player and each enemy to determine when they should spawn or de-spawn. This distance check can be done between some arbitrary location that can be literally anything in a level or something specifically put into a level to check against, or it can be the placer itself.
All of these ranges have defaults so not all of them need to be set for each enemy. Each enemy also has their own melee attack range, missile attack range, defense range, etc. that are editable per enemy type and don’t need to be changed often.
There is also logic that doesn’t let someone kite an enemy outside of the original distance check to despawn or cheese the enemy. The enemy will stick with targets until they lose their target regardless of spawn distance.
These spawn distances further optimize the number of enemies that the Enemy Manager needs to process moment to moment. Before, in Ai Optimization 1, we had well over 100 enemies in the test level that needed to be dealt with and optimizations were being done to make having that number of enemies be performant.
With this distance-based spawning and despawning in play, we now only have to deal with around ~30 enemies in the test level at most and around 10 enemies on average. This is with no appreciable change to what the player experiences.
New Trigger Mode: Trigger Volume
I also added a new spawn mode called ‘Volume’ that references a volume instead of a distance check. The logic is basically the same, but uses a placeable volume instead of a radius.
The volume can be placed anywhere, and any number of placers can reference it. Meaning: A Level Designer can spawn in an army or one enemy using a volume.
Optimization: Spawning too many enemies at the same time
One thing that I noticed once we had this ‘Volume’ trigger working is that spawning a bunch of enemies within the same frame caused huge hitches. Hitches are bad. Anything over an 8ms hitch is going to be noticed by most players.
It also wasn’t just because of the volume: In the Test Level there were times when spawning in multiple enemies just because of enemy placement and spawn radiuses caused hitches with just a few enemies here and there.
I don’t have numbers for a packaged game, but spawning in 12 enemies in a single frame caused hitches of up to 140ms (!).
Solution: Spawn one enemy per frame.
My solution was to update the Enemy Manager to only spawn one enemy per frame instead of on demand without any limit.
Now Enemy Placers request for an enemy to be spawned and the spawn requests are dealt with in the order which they were received unless the request is no longer valid. In the case where a player clips into a spawn radius/trigger causing spawn requests to be issued then the player runs out of the radius/trigger before those requests can be dealt with, the requests are removed before they are processed.
Future considerations might be made on the order that enemies are spawned based on distance so that closer enemies to players are spawned in first, but with the current system and how the levels are at the moment I haven’t seen a need to add that functionality yet.
After implementing the one-enemy-per-frame spawning, the peaks went from 140ms for 12 enemies to around 40ms for 12 enemies. Again, this is in the Unreal Engine Editor and not representative of a packaged game. In the packaged game spawning will take much less than 40ms.
At the end of the day, the biggest optimization that we have for performance and enemy performance is better use of the enemies that we have and how many enemies we have in play at any one time. There’s only so much optimization that can be justified on things like abilities, logic to run abilities, etc. while reducing the number of enemies in play has much more significant effect on performance.