Skip to content

One spawner system

 3 min A comment in a public Unity workshop asked how to instantiate entities at a position through a command buffer. My fork answered it with one system, one job, and one aspect.
Published

May 1, 2023

Tarodev’s How To Render 2 Million Objects At 120 FPS walks through nine levels of the same scene, each drawing more cubes than the last, and its source is public. Level 6 is “ECS + Jobs + Burst”. In April 2023 I forked it for a workshop I was giving at ZipRecruiter’s Tech Week, the engineering gathering in Los Angeles that May, labeled the fork Tech Week Workshop, and revised that level. Entities 1.0 was still a prerelease; I moved the project from pre.15 to pre.65 on the way. The revision went in the night before I flew out; the colors and a typo were fixed on site, the same day I last touched the note of Unity links I kept for the session. The fork and that note are what remain of the workshop.

The comment

The original level spawned its cubes from a managed SystemBase and moved them from an unmanaged ISystem. The spawner carried a comment:

This is bad code. Can somebody tell me how to instantiate entities at a position using a command buffer?

Its workaround was two passes. On the first update it instantiated the prefab once per position through a command buffer and attached a TargetPosition to each new entity. On the next update, once entities with that component existed, it queried them, copied each target into the transform, and disabled itself. The position could not go in with the instantiation, so it went in afterward.

One system

My revision replaced both systems with one Burst-compiled ISystem. Its OnCreate reads the prefab out of the baked Spawner singleton, reads the prefab’s scale from its LocalTransform, and for every position instantiates through the begin-simulation command buffer, adds a SpawnerPosition, and sets the transform in the same breath:

var entity = buffer.Instantiate(prefab);
buffer.AddComponent(entity, new SpawnerPosition { Value = pos.y });
buffer.SetComponent(entity, LocalTransform.FromPosition(pos).ApplyScale(scale));

That is the answer to the comment. A command buffer records component writes against the entity it has just instantiated, and the write plays back with the instantiation, so there is no second pass and no system that has to disable itself.

On each update the system schedules the movement and waits for it:

new SpawnerJob { Moment = (float)SystemAPI.Time.ElapsedTime }
    .ScheduleParallel(state.Dependency).Complete();

The immediate Complete() matters. The work is parallel entity work, but the update does not return until it is done, which keeps the example’s timing as plain as its structure.

SpawnerJob is an IJobEntity whose Execute receives a SpawnerAspect: read/write access to LocalTransform, read-only access to SpawnerPosition, and one method that assigns position and rotation from the workshop’s existing motion calculation. The system decides when to run, the job selects each entity, and the aspect groups the component access. The authoring side is a MonoBehaviour naming a prefab and a baker that records the corresponding entity in the Spawner component.

The level’s MonoBehaviour still owns the lifecycle. A coroutine waits half a second, creates the system in the default world, and holds its handle; Update drives it; OnDestroy destroys it. The system has automatic creation disabled, so nothing else runs it. The original had the same shape with two handles and a delayed Invoke.

What it was for

What the fork settled for me, and what the workshop walked through, was the shape of an ECS system: authored data baked in, a command buffer to create, a job over an aspect to update, and a lifecycle somebody visibly owns. By early 2024 my co-owner and I were working through Grit’s own port to ECS, starting from the same places: sub-scenes and baked authoring data, and how much a job could take on without engaging the physics engine.