Hello, fellow DOTS users and developers. I’ve been working on a small project for the past few weeks using Unity’s Entities framework, trying to squeeze every last bit of performance. I was able to bring the simulation time down from 12-15ms to 4-5ms by replacing structural changes with polling, and using batch methods instead of EntityCommandBuffer. But I have a common pattern in my project, namely creating and destroying a lot of entities per frame, which is one of the bottlenecks. I’d like to propose a feature that will help alleviate it.
Roughly, I propose a method called EntityManager.InstantiateAndReplace that takes a prefab and an array of entities. The method copies the component data of the prefab into all of the specified entities and increments their version. This is equivalent to destroying the old entities and instantiating the new ones, but should be much faster since it doesn’t involve moving the entities in memory, just writing the data to the destination. And it can mostly be done in parallel, except probably for the version incrementing part. The method should also handle LinkedEntityGroup correctly, replacing the associated entities as well.
I’ll describe the specific scenario to provide some context. Basically I have a bunch of agents gathering wood. Each agent looks for a tree in its vicinity, approaches it and cuts it down. When the tree is cut, it’s destroyed and a stump and a trunk are created in its place. The agent picks up the trunk and brings it to the nearest storage. Simultaneously, to maintain the constant number of entities in the simulation, each frame the trees are spawned, and the trunks and stumps are destroyed after some period of time. All of this is happening at a rapid rate: each frame of the simulation corresponds to several seconds of “real” time, and the goal is to maintain a fixed 60 fps of the simulation at the highest game speed.
In this scenario, with 100.000 trees and 20.000 agents, roughly a 1000 entities are created and destroyed each frame. Right now I just destroy all the entities in batch and instantiate the new ones. This takes about 1ms of the total 5ms of the simulation frame time (mostly the destruction part). This also heavily fragments the chunks for some entities, so the real cost is higher. But if I’m able to instead replace the entities pending for destruction with the ones being instantiated, the cost should go down to basically zero.
I must mention that I already implemented the pooling system for some entities, specifically the ai tasks. They have a single main component unique to the task type and a set of component types they all share, so I just explicitly fill them upon recreation (with a separate system for each task type, all based on a single generic system). The cost did go down to basically zero for them. But for most other systems I need a more general solution, since the set of components is unknown at compile time and is parametrised by the prefab.
In the mean time, to test the cost of random destruction and instantiation in batches, I made a simple project. It’s available here: https://github.com/dmitry-egorov/unity-ecs-batch-speed-test/blob/main/Assets/Scripts/Instantiation.cs. Look at the destroy_and_instantiate system. Basically, each frame it destroys a random subset of entities and creates new ones for replacement.
destroy_and_instantiate system
protected override void OnUpdate() {
// remaining frames of the current iteration
var remaining_frames = GetSingleton<has_current_iteration>().remaining_frames;
// randomly determine entities to destroy. Gather them in the destroyed_entities list.
var subjects_count = subjects_query.CalculateEntityCount();
using var destroyed_entities = new NativeList<Entity>(subjects_count, TempJob);
var j1 = Entities.WithName("EnqueueDeletes")
.WithAll<is_a_test_subject>()
.ForEach((int entityInQueryIndex, Entity e) => {
if (hash(uint2((uint) entityInQueryIndex, remaining_frames)) < (uint.MaxValue / 4 * 3))
destroyed_entities.AddNoResize(e);
})
.WithStoreEntityQueryInField(ref subjects_query)
.Schedule(Dependency);
// gather entities to spawn, generate positions for them
var count = spawns_query.CalculateEntityCount();
using var spawn_prefabs = new NativeList<Entity>(count, TempJob);
using var spawn_counts = new NativeList<int>(count, TempJob);
using var positions = new NativeList<float2>(count * 100, TempJob);
var j2 = Entities.WithName("GeneratePositions")
.ForEach((int entityInQueryIndex, in spawns spawns, in Translation translation) => {
var current_prefab_i = spawn_prefabs.Length - 1;
var instance_count = (int)spawns.count_per_fame;
var prefab = spawns.prefab;
if (current_prefab_i != -1 && spawn_prefabs[current_prefab_i] == prefab)
spawn_counts.get_ref(current_prefab_i) += instance_count;
else {
spawn_prefabs.Add(prefab);
spawn_counts.Add(instance_count);
}
var spawn_hash = hash(uint2((uint)entityInQueryIndex, remaining_frames));
var random = Random.CreateFromIndex(spawn_hash);
for (var i = 0; i < instance_count; i++) {
var position = translation.Value.xz + random.NextFloat2(new float2(-100, -100), new float2(100, 100));
positions.Add(position);
}
})
.WithStoreEntityQueryInField(ref spawns_query)
.Schedule(Dependency);
// wait for the scheduled jobs to complete
using (Profile("Wait for jobs"))
JobHandle.CombineDependencies(j1, j2).Complete();
// destroy entities
using (Profile($"Destroy {destroyed_entities.Length} subject entities"))
EntityManager.DestroyEntity(destroyed_entities);
// instantiate entities
var instances_count = positions.Length;
using var instances = new NativeArray<Entity>(instances_count, TempJob);
using (Profile($"Instantiate {instances_count} entities")) {
var data_index = 0;
for (var spawn_i = 0; spawn_i < spawn_prefabs.Length; spawn_i++) {
var spawn_count = spawn_counts[spawn_i];
var sub_array = instances.GetSubArray(data_index, spawn_count);
EntityManager.Instantiate(spawn_prefabs[spawn_i], sub_array);
data_index += spawn_count;
}
}
// set positions of the instantiated entities
using (Profile("Set positions")) {
Dependency = new set_positions_job {
instances = instances,
positions = positions,
translation_w = GetComponentDataFromEntity<Translation>()
}.ScheduleParallel(instances_count, 7, Dependency);
Dependency.Complete();
}
}
[BurstCompile] struct set_positions_job: IJobFor {
[ReadOnly] public NativeArray<Entity> instances;
[ReadOnly] public NativeArray<float2> positions;
[NativeDisableContainerSafetyRestriction] [WriteOnly] public ComponentDataFromEntity<Translation> translation_w;
public void Execute(int i) =>
translation_w[instances[i]] = new Translation { Value = positions[i].x0y() };
}
The creation and destruction of 2500 entities takes around 1.5ms (mostly the destruction), and everything else is basically negligible:
So, i’d like to get some feedback on the problem and the proposed solution. Maybe someone already implemented something similar in their project? Or maybe there’s another solution for this? Please, let me know.
Edit: fixed some errors in the code and grammar