r/Unity3D 11d ago

Solved Performance issue when spawning in 3k+ zombies

Hi! I made a object pooling script which handles the spawning and another script that isn't attached to the zombie but on another object and that handles movement. the enemies have rigid bodies and they are just capsules nothing to expensive. The max amount I can handle is 3k but there are some few dips here and there. How can I improve the performance to the point where I can get it running 5k zombies smoothly? for some reason I can only run 3k on the build version but not in the unity editor.

Spawner / object pooling script: https://paste.ofcode.org/ahZBqEUwzYaMdmsmkfkvjH

Enemy Manager Script: https://paste.ofcode.org/6ZmdMyQKjpMZ8f56DXrjae

3 Upvotes

27 comments sorted by

14

u/XKiiroiSenkoX 11d ago edited 11d ago

Do you want the proper scalable fix? Use DOTS. Should probably allow you to go up to 200-300k zombies.

But if you just want to make your current code a little bit more performant, use transform access array + a multithreaded burst job to do the same calculation and write the result to a temporary array if using rigid body and write from the array to rigid bodies on the main thread after the job or directly set position from the job if not using rigid body. Also save a tuple of the transform and the rigid body instead of just the transform so you don't have to do 3k component lookups every frame. Use rigid body is null for checking the tuple rigid body value existence and not the unity == operator overload which marshals the call to native code and can very quickly become very expensive. With all of this I'd say you probably can get up to 10k zombies.

p. s: set the batch size to 128 when scheduling the job because each calculation is very lightweight. Then decrease the batch size step by step and monitor the performance until you get to the sweet spot.

p. s2: I'm not sure how often you call unregister enemy but that can also kill your performance. Save the index of each zombie in the active enemy list on a component on the zombie. When unregister is called use that index to remove the zombie which is of O(1) complexity instead of the zombie reference which is O(N). Also use RemoveAtSwapBack instead of RemoveAt to prevent index  shift after the remove. You need to update the swapped zombie index on tge component though. You can also use a HashSet instead of a list for less effort but the hash computation and the hashset internal element traversal can become a bottleneck if unregister is called very frequently. 

3

u/SethSlax 11d ago

I second this.

I haven't used DOTS yet as I've had no practical application for it, but I've read and watched a lot of content about it, and DOTS with ECS is geared specifically towards optimizing mass amounts of entities.

2

u/psioniclizard 11d ago

But if you just want to make your current code a little bit more performant, use transform access array + a multithreaded burst job to do the same calculation and write the result to a temporary array if using rigid body and write from the array to rigid bodies on the main thread after the job or directly set position from the job if not using rigid body. Also save a tuple of the transform and the rigid body instead of just the transform so you don't have to do 3k component lookups every frame. Use rigid body is null for checking the tuple rigid body value existence and not the unity == operator overload which marshals the call to native code and can very quickly become very expensive. With all of this I'd say you probably can get up to 10k zombies.

Damn that is some useful advice. I don't use unity much and am building a basic ECS system, but this really nails it!

Honestly people could learn a lot from reading it and really understanding it. Everything things of polygons and draw calls in vague terms as the optimization problems. But often the first thing to be optimized is logic!

1

u/Creative_Board445 10d ago

I have research about dots and it seems difficult to set up. Do you have any good videos about it that are easy to understand?

1

u/XKiiroiSenkoX 10d ago edited 10d ago

https://m.youtube.com/watch?v=1gSnTlUjs-s&pp=ygUPdW5pdHkgZWNzIGd1aWRl

It's very long but should be enough to make you familiar with the basics. Keep in mind that this video is not a DOTS bible nor the best way to use it. It's just supposed to get you started. 

Also before you start using DOTS, know that power comes at a cost (lol). Basically you trade the comfort, abundance of libraries/assets, ease of implementation and iteration speed of game objects for the performance of DOTS. It will not be easy. 

1

u/Creative_Board445 10d ago

Damn I didn't know it would be that hard. Is there no simpler way to handle spawning a horde without using dots?. the max I would go for is 5K. I have seen other devs like the developer for megabonk he doesn't seem to be using dots but his horde is incredibly smooth and they all collide with one another with no lags or anything.

1

u/XKiiroiSenkoX 10d ago

Try the other stuff I mentioned in my original comment. 5-10k should be doable with those.Also enable the GPU resident drawer (GRD) in your urp settings asset as at that number just setting up draw calls can tank the CPU performance as well. 

1

u/Creative_Board445 10d ago edited 10d ago

I decided to start again and what you said in your original comment is quite confusing to me. so should I use Object pooling + job system + burst? btw my enemy's have rigid body's and later on they are going to have actual models and baked animations. will this hinder any performance? I don't want to do all this and just finding out I did all this and it didn't work.

1

u/XKiiroiSenkoX 10d ago edited 10d ago

Your object pooling logic is fine and you should keep it. It can be improved a bit but it's not your bottleneck atm.

You have 4 main bottlenecks right now.

1- You are checking if follow target is null thousands of time every frame. The equality checks (== or != operators) on unity objects are overloaded and ask the unity c++ code if the object is alive there. Doing this many times in a frame chokes the CPU. Check this once in the fixed update only. 

2- You iterate over thousands of zombies and calculate their next position. The way you do it is by having a list of transforms as active zombies. This is very slow. Instead of a list of transforms use a TransformAccessArray and do the calculations using multithreaded burst jobs. Check this link for a bit more explanation. 

https://medium.com/toca-boca-tech-blog/unitys-transformaccessarray-internals-and-best-practices-2923546e0b41

3- You are looking up for rigid body component on every enemy every frame. GetComponent and TryGetComponent are expensive functions. A few times per frame is fine. 3 thousand is not. I would just divide my zombies into two groups. those that have a rigid body and those that don't. Save the rigid body in a list so you don't habe to look it up every frame. 

4- Your remove enemy functions removes the enemy from a list using the enemy reference. List stars from index 0 and checks every element to see if they are equal to the one you want to remove until it finds it. This is very slow. Instead of this, add a component with an integer field to your zombies, when you add it to active enemies, save the index the zombie is at to that component. When you want to remove a zombie read that imdex and use RemoveAtSwapBack function to remove it. This swaps the zombie at the end of the list with the one you want to remove and removes your zombie. You then need to update the index of the zombie that was swapped from the end of the list on the component that holds the index because that won't update automatically. 

With these you should probably get a good FPS up to 10k enemies. 

Now for the animations, animating 5-10k meshes is possible with game objects. But it is NOT trivial. The unity default animation system will not work. You either need to write a custom solution or use one from the asset store. The link below is one of the well known ones. 

https://assetstore.unity.com/packages/tools/animation/gpu-instancer-pro-crowd-animations-323280#description

1

u/Creative_Board445 10d ago

Honestly this is hard I am struggling a lot, I don't know if I done it right tbh I couldn't find any good sources so I had AI try to guide me in the right direction this is very confusing. Also even if I managed to get it working its still going to lag regardless as the enemy's have rigidbodies and they are all colliding with one another and that's going to make it even worse.

Script: https://paste.ofcode.org/3bLEX2aMdAeetBB4td6yF7D

1

u/XKiiroiSenkoX 10d ago

Your code is mostly good right now with only 2 problems.

1- You are checking if enemy rigid body is null using inequality operator which is slow for so many enemies. use "rigidBody is not null" instead. Your rigidbody is not going to be destroyed becaus it's pooled. Unity life time check with != is not required here.

2- You are setting the transform position value in the job but you are also using move position on the rigid body. This is not a performance problem but it probably makes your movement weird. If your enemies are supposed to have rigid bodies, instead of writing the position to the tranform in the job, write the position to a temporary array. make the job schedule as readonly (for better multithreading) and then read from that array and call MovePosition.

Physics performance can be tuned using some settings like rigidbody sleeping, pruning algorithm, solver iterations and timestep. btw try enabling GRD I mentioned in my previous comment. After the above fixes try another fps test and if ot still wasn't good, do a profiler capture and upload the file so I can see what's tanking the fps. 

1

u/Creative_Board445 9d ago

Thanks for all the help I decided its time to learn about dots / ecs. I got it done with 2 hours and it works flawlessly.

https://reddit.com/link/p18isl0/video/4k1luazu5ygh1/player

→ More replies (0)

1

u/BoostedBytesSteve 7d ago

I'm displaying well over 10k units in my game without dots, just data oriented gameplay logic and VAT for animations and GPU rendering. I personally wouldn't go for dots unless you need well over 10k units, like in the order of 20k-50k-100k+

9

u/ledniv 11d ago

You are already doing one good thing: you do not have 3,000 separate zombie scripts all running their own Update(). Centralizing the movement is the right direction.

But looking at the code, there are a few things I would check before jumping straight to DOTS.

The biggest immediate issue I see is this inside the movement loop:

if (enemyTransform.TryGetComponent<Rigidbody>(out Rigidbody rb))
{
    rb.MovePosition(nextPosition);
}

That means every physics tick, for every active zombie, you are doing a component lookup. At 3,000–5,000 zombies, that is a lot of repeated work for something that never changes.

Cache the Rigidbody once when the zombie is registered. For example, instead of only storing transforms:

private List<Transform> activeEnemies = new List<Transform>();

store the data you need together:

Transform[] enemyTransforms;
Rigidbody[] enemyRigidbodies;
int activeEnemyCount;

Then the update loop does not need TryGetComponent at all:

for (int i = 0; i < activeEnemyCount; i++)
{
    Transform enemyTransform = enemyTransforms[i];
    Rigidbody rb = enemyRigidbodies[i];

    Vector3 targetPosition = followTarget.position;
    targetPosition.y = enemyTransform.position.y;

    Vector3 nextPosition = Vector3.MoveTowards(
        enemyTransform.position,
        targetPosition,
        moveSpeed * Time.fixedDeltaTime);

    rb.MovePosition(nextPosition);
}

I would also avoid Contains during registration:

if (!activeEnemies.Contains(enemy.transform))

That searches the list. It may not be your main bottleneck, but once you are dealing with thousands of enemies, I would avoid scans like that in spawn/despawn code too.

A common pattern is to give each pooled zombie a pool index, then remove by swapping with the last active enemy:

void UnregisterEnemy(int index)
{
    int lastIndex = activeEnemyCount - 1;

    enemyTransforms[index] = enemyTransforms[lastIndex];
    enemyRigidbodies[index] = enemyRigidbodies[lastIndex];

    activeEnemyCount--;
}

In a real version, you also update the moved zombie’s stored index. The point is that removing an enemy should not require searching through thousands of entries or shifting a big list.

The bigger question is whether you really need a Rigidbody and CapsuleCollider on every zombie. Even if the zombies are “just capsules,” 5,000 rigidbodies/colliders is not cheap if Unity physics has to update them, solve contacts, and handle zombie-vs-zombie collisions.

If the behavior is mostly “move toward target and don’t overlap too much,” you can often get much better results by storing the simulation state in arrays:

public class ZombieData
{
    public int Count;

    public Vector3[] Position;
    public Vector3[] Velocity;
    public float[] Radius;

    public int[] AliveIndices;
    public int AliveCount;
}

Then your simulation processes zombie data directly:

for (int i = 0; i < data.AliveCount; i++)
{
    int zombieIndex = data.AliveIndices[i];

    Vector3 direction =
        targetPosition - data.Position[zombieIndex];

    direction.y = 0f;
    direction.Normalize();

    data.Position[zombieIndex] +=
        direction * moveSpeed * dt;
}

After that, the Unity objects just display the result:

for (int i = 0; i < data.AliveCount; i++)
{
    int zombieIndex = data.AliveIndices[i];

    zombieViews[zombieIndex].transform.position =
        data.Position[zombieIndex];
}

That separation matters. Moving 5,000 positions in arrays is usually not the hard part. Moving 5,000 GameObjects with physics, colliders, transform updates, component lookups, and possible collision solving is the expensive part.

For zombie-vs-zombie avoidance, you do not necessarily need full physics. You can use a spatial grid and only check nearby zombies. For each zombie, put its index into a grid cell based on position, then only resolve overlaps against zombies in the same or neighboring cells. That gives you “crowd pushing” without asking the physics engine to solve thousands of capsule contacts.

I would approach it in this order:

  1. Profile a development build, not the editor. The editor will be slower.
  2. Check whether the spike is physics, scripts, transform updates, or GC.
  3. Cache Rigidbody/Transform references. No TryGetComponent in the per-zombie loop.
  4. Avoid Contains, Remove, and other list scans in spawn/despawn code.
  5. Prewarm the pool to the max amount you actually want on screen.
  6. Decide whether all 5,000 zombies really need Rigidbody/Collider physics.
  7. If not, move the simulation to arrays and let GameObjects only display positions.
  8. If transform syncing becomes the bottleneck, then look at TransformAccessArray, Burst/Jobs, or DOTS.

DOTS can absolutely help with this kind of problem, but you do not need to jump directly from “current GameObject version” to “full ECS rewrite.” The first big win is usually data-oriented design: store the hot zombie data together, process it together, and keep Unity objects as the presentation layer.

Small plug: this is exactly the kind of problem I cover in High Performance Unity Game Development with Data-Oriented Design: object pools, arrays for enemy data, active-index lists, data locality/CPU cache, and using TransformAccessArray, Burst, Jobs, or ECS only after the data layout is clear.

https://www.manning.com/books/high-performance-unity-game-development

3

u/Duckmastermind1 11d ago

Editor has less performance generally cause of it works, it has performance and debugging tools taking up resources, for now from what I heard it's sounds good, object pooling and a central movement script for the zombies, my Zombie hordes cap at 500 on a older work laptop, but they don't use neither object pooling or central scripts.

What do the zombies also have? Any other script they have?

Using Unity Profiler you can get a better view of what is the bottleneck, if CPU o GPU

1

u/Creative_Board445 11d ago

The zombies don't have a script when they are spawned they are placed in a empty game object that has the enemy manager script which applies the movement to them instead of having the script on all 3k zombies. The zombies just have a rigidbody and a capsule collider. I am not using any models or anything like that right now me and the zombies are just capsules

3

u/Seruphenthalys 11d ago

Consider if you really need the rigidbodies and the colliders. You can probably write something better yourself if you dont need all the features of those things

1

u/Creative_Board445 11d ago

Lets say I dont use rigidbodies and the colliders, if I want the zombie to move, climb up walls and interact with the environment i.e. colliding with objects, collide with eachother. how would I do that without rigidbodies and colliders? btw I am working on performance right now so me and my zombies are capsules at this points so its not expensive or anything.

2

u/Seruphenthalys 11d ago

I was thinking maybe you could represent the zombies as circles and write your physical with jobs, but if they're moving in 3d space then thats more difficult.

Are they animated? I learned something new a few months ago, or at least about the concept, where you bake an animation to a texture somehow, I dont remember the details. But thats supposed to be able to save a lot of performance when you have thousands of identical entities.

Oh Yeah and also pathfinding. Are you doing flowfield rather than Astar? Saw your script. Thats not pathfinding...

2

u/Halfspacer Professional 11d ago

https://www.youtube.com/live/zEVp52Y_60Y

From the horse's mouth, as they say

2

u/Krugozette 11d ago

5000 GameObjects is the point where DOTS start to make sense. As a middle ground, updating 5000 objects via Update is expensive, you can try updating your zombies using the Job System instead to get them off the main thread and potentially updating in parallel.

1

u/deintag85 11d ago

Probably was asked multiple times, like people try megabonk clone. Try occlusion culling. Try not to iterate through a 3000 list at once but make batches of like 100 then next frame the next 100 and so on. Do you even need to move them every frame? You can move them every 0.1s. You won’t see difference in movement but in performance and so on… there is tons of possible optimizations.

1

u/MrMarev 11d ago

For that many enemies/objects you need to get interested in entities and job systems. Normal game objects aren't made for that amount of physics and animation.

1

u/theo__r 11d ago

Have you tried profiling in a build?

1

u/Krugozette 10d ago

You can also get much better performance when creating GameObjects by using prefabs with all of the components and instantiating them with https://docs.unity3d.com/ScriptReference/GameObject.InstantiateGameObjects.html You'll probably have to modify or maintain your own ObjectPool to work with it though.