Buy me a coffee on ko-fi.com
Reese Schultz

I'm making my first commercial game. Read more about me here đź‘‹.

Spawning Prefabs with Unity ECS

How to instantiate copies of a prefab as entities, at runtime, with Unity ECS.

unitycsharptutorial

Created on November 7, 2019. Last updated on January 26, 2020.

Video of spawning prefabs with Unity ECS.

Get the tutorial code from GitHub! đź‘€

Spawning entity-associated prefabs with Unity ECS is a bit complicated, especially if it can occur at any point during your game. Moreover, initiating spawning from other (ECS) systems and even MonoBehaviors complicates matters when it comes to achieving thread safety and conventional usage with the tools provided. Thankfully, none of these challenges will prevent us from achieving our goal.

Note: If at any point you find this tutorial to be a little too advanced, don't worry! Try my Getting Started with Unity DOTS tutorial first.

Here I'll refer to the to-be-spawned subjects as people. Feel free to call them characters, NPCs, agents, or whatever. Most importantly, we need a way to store their prefab entity data like this:

struct PersonPrefab : IComponentData
{
    public Entity Value;
}

By the way, when thinking in terms of ECS, really there are two modes. There's "authoring" and "runtime." Authoring means to, well, author stuff. That's when we're in the editor, moving things around and adjusting parameters. It also includes conversion of GameObjects into entities upon startup, which are then used for the remainder of the runtime.

So, the above component will be referenced in our PersonPrefabAuthoring class, which converts GameObject prefabs into entities with components via the below class:

[RequiresEntityConversion]
class PersonPrefabAuthoring : MonoBehaviour, IConvertGameObjectToEntity, IDeclareReferencedPrefabs
{
    public GameObject PersonPrefab;

    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        dstManager.AddComponentData(entity, new PersonPrefab
        {
            Value = conversionSystem.GetPrimaryEntity(PersonPrefab)
        });
    }

    public void DeclareReferencedPrefabs(List<GameObject> referencedPrefabs)
    {
        referencedPrefabs.Add(PersonPrefab);
    }
}

Now you want to create an empty GameObject in your scene, add the PersonPrefabAuthoring script to it, and finally drag and drop your prefab onto the Person Prefab field. Additionally, in order for the PersonPrefabAuthoring script to work, you will also need to add another script to the same GameObject: the Convert To Entity script. For its Conversion Mode, elect for Convert And Inject Game Object if there are other MonoBehaviours attached to it that you want to keep running after conversion; otherwise, select Convert And Destroy.

With a means to convert prefabs, we can use them when enqueuing spawning. Yes, enqueue, meaning we will use a queue data structure. But before we get ahead of ourselves, we'll do a couple things. First, let's create our person data:

struct Person : IComponentData
{
    public bool RandomizeTranslation; // This will allow us to optionally randomize the spawn position.
}

Second, we'll create a struct (but not of IComponentData) that stores the initial data we would want to set on a person, or group of people, from another system or MonoBehaviour:

struct PersonSpawn
{
    public Person Person;
    public Rotation Rotation;
    public Translation Translation;
}

The Rotation and Translation components are likely data you'd want to initialize during the spawning process. Why? Because then you have the ability to set the initial rotation via Rotation.Value, and the initial position with Translation.Value.

If we want to permit spawning from either a system or MonoBehaviour in a consistent way, we need an accessible public interface for doing so. Since spawning may be requested elsewhere at any time, we must mind thread safety. Thus, the appropriate data structure to cover all of our bases is a ConcurrentQueue of PersonSpawns. Let's instantiate it in a JobComponentSystem.

class PersonSpawnSystem : JobComponentSystem
{
    public static readonly int SPAWN_BATCH_MAX = 50;

    static ConcurrentQueue<PersonSpawn> spawnQueue = new ConcurrentQueue<PersonSpawn>();

    EntityCommandBufferSystem barrier => World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();

    public static void Enqueue(PersonSpawn spawn)
        => spawnQueue.Enqueue(spawn);

    public static void Enqueue(PersonSpawn[] spawnArray)
        => Array.ForEach(spawnArray, spawn =>
        {
            spawnQueue.Enqueue(spawn);
        });

    public static void Enqueue(PersonSpawn spawn, int spawnCount)
    {
        for (int i = 0; i < spawnCount; ++i) spawnQueue.Enqueue(spawn);
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        # TODO
    }
}

We've defined SPAWN_BATCH_MAX, setting it to a reasonable integer for batching our spawns in a job we'll create soon. Additionally there's the concurrent queue I mentioned, allowing us to enqueue spawning from any other system or even MonoBehaviour. The queue internally enforces thread safety with a lock. Since it's static, we can call it from a parallel job as long as we give up the right to Burst-compilation, which is fine for this use case. Anyway, opting for composition over inheritance, we expose the queue through overloaded and self-explanatory Enqueue methods. There are three.

  1. One overload handles only a single PersonSpawn.
  2. Another handles an array of PersonSpawns.
  3. The last takes one unit of PersonSpawn data, reusing it to enqueue spawnCount times.

You'll notice I additionally snuck in a reference to an EntityCommandBufferSystem, which will queue up all of the operations involved in spawning during the parallel-executing job we have yet to define. Specifically we're using a command buffer that occurs toward the beginning of a frame as opposed to the end, since this is spawning, after all. Anyway, the reference is called barrier because technically it's a memory barrier. Operations performed with the barrier execute deterministically, meaning that they're played back in order. Now let's move on to our job definition:

protected override JobHandle OnUpdate(JobHandle inputDeps)
{
    if (spawnQueue.IsEmpty) return inputDeps;

    var randomArray = World.GetExistingSystem<RandomSystem>().RandomArray;
    var commandBuffer = barrier.CreateCommandBuffer().ToConcurrent();

    var job = inputDeps;
    for (int i = 0; i < SPAWN_BATCH_MAX; ++i)
    {
        job = Entities
            .WithNativeDisableParallelForRestriction(randomArray)
            .ForEach((int entityInQueryIndex, int nativeThreadIndex, ref PersonPrefab prefab) =>
            {
                if (
                    !spawnQueue.TryDequeue(out PersonSpawn spawn)
                ) return;

                var entity = commandBuffer.Instantiate(entityInQueryIndex, prefab.Value);

                commandBuffer.AddComponent(entityInQueryIndex, entity, spawn.Person);
                commandBuffer.AddComponent(entityInQueryIndex, entity, spawn.Rotation);
                commandBuffer.AddComponent(entityInQueryIndex, entity, spawn.Translation);

                if (!spawn.Person.RandomizeTranslation) return;

                var random = randomArray[nativeThreadIndex];

                commandBuffer.SetComponent(entityInQueryIndex, entity, new Translation
                {
                    Value = new float3(
                        random.NextInt(-25, 25),
                        2,
                        random.NextInt(-25, 25)
                    )
                });

                randomArray[nativeThreadIndex] = random; // This is NECESSARY.
            })
            .WithoutBurst()
            .WithName("SpawnJob")
            .Schedule(job);

        barrier.AddJobHandleForProducer(job);
    }

    return job;
}

First, we check if spawnQueue is empty from the main thread so we don't schedule unnecessary jobs—if we don't, a notable performance impact will be incurred by all the pointless scheduling. Second, we grab a NativeArray of thread-indexed random number generators—and no, the RandomSystem is not a default—I create and explain it my Random Number Generation with Unity DOTS tutorial. All of this code is in my GitHub demo project, and it works!

Note we acquire a concurrent EntityCommandBuffer created from the EntityCommandBufferSystem. We want the concurrent flavor of the buffer since we are using a parallel job. The explicit reference to the EntityCommandBufferSystem via the barrier variable is significant, by the way, since, after scheduling the job, we must call AddJobHandleForProducer to inform the barrier system that said job must be completed before the queued commands (hence, command buffer) in the EntityCommandBuffer are played back.

In defining our job we have chosen the Entities.ForEach syntactic sugar, saving many lines of code. We iterate over the one and only PersonPrefab that exists, so the semantics of ForEach may make it seem like we're doing more work than we actually are. We must pass the randomArray to WithNativeDisableParallelForRestriction since it's up to us to ensure the safety of our code. Don't worry, it's safe because we're fetching Unity.Mathematics.Randoms from said array with the magic nativeThreadIndex, and writing to them on their own thread, which is kind of like using [ThreadLocal].

Finally, we try to dequeue spawns from the the spawnQueue assuming there are any. We immediately return if not. We instantiate spawns with commandBuffer, and add component data to them as needed. Those with Person.RandomizeTranslation == true will have their Translation components overwritten with fresh, random values via the randomArray. Each Unity.Mathematics.Random within it must maintain state so that each ensuing random number is random-ish, hence the semantics of NextInt, for example. That is why we must write to each of these Randoms with randomArray[nativeThreadIndex] = random.

And that should be all you need to know to be dangerous.

Call one of the Enqueue overloads to spawn people from either a system or MonoBehaviour at your leisure. The GitHub repo includes glue code for that, which I did not document here. Again, it also includes code for Burst-compilable(!) random number generation. Remember, in this specific instance we're not using Burst since we're directly interacting with a static concurrent queue from the job.

Oh, one more thing: you're welcome! Please star my GitHub repo if this tutorial helped you.