Level Up Your ECS - Baking (System) Recipes

Level Up Your ECS - Baking (System) Recipes
Hi everyone!

More and more, I have been told by people that they still feel too new to understand some of the technologies I build or aren’t aware of some the ECS tools I use. Even those with experience and competence have asked me about advanced topics and tools that are available but no one really dives into. It is time I share some of this information with the community, in the hopes that there will be more people doing more advanced things and sharing them online. I will eventually post this as a new documentation article series for my framework, but I am posting on these forums first so that you have a chance to ask questions and discuss the ideas after each post. While I will reference the framework several times as examples, none of what I describe in these series is exclusive to that. Rather, this info applies to anyone working with Unity’s ECS. These series are meant for intermediate and advanced users, and assume some minimal level of understanding of Unity’s ECS.

This first series is about baking systems. As I have stated several times across the forums, baking systems are really hard to get right. Unfortunately, messing up is hard to detect because Unity provides no correctness checks over this mechanism. Instead, you’ll just be stuck with these mysterious bugs that seem to correct themselves by closing and reopening the subscene. So for this first post, I am going to break down what is happening, what causes these mysterious bugs, and what to watch out for. By the end, you’ll realize that baking systems are hard, and even Unity themselves frequently write them incorrectly.

When does baking happen?
Disclaimer, I am not 100% confident that the information I am sharing is fully correct. And I definitely have no idea if what I am sharing matches what Unity intended. So if anyone, especially from Unity, would like to chime in, please do so!

Opening a subscene
When you first open or create a subscene, Unity builds up an internal hierarchy data structure of all GameObjects and Components in the subscene. It also constructs a dedicated ECS World for all the entities associated with that subscene.

Next, it runs a Pre-Baking System Group. Unless you are modifying the Entities package directly, this group is likely not helpful, and I won’t mention it anymore.

Then, for every component on every GameObject, it runs all compatible Bakers. Bakers record their changes via an EntityCommandBuffer. Once all the bakers run, the ECB is played back.

After that, there’s the TransformBakingSystemGroup, followed by the normal BakingSystemGroup.

Then a single system updates LinkedEntityGroup, followed by Companion GameObject baking.

Then PostBakingSystemGroup runs.

And finally, a BakingStripSystem runs to clean up any [TemporaryBakingType] components.

From now on, I will refer to all the steps so far after running the bakers as “the baking system groups”.

The last thing that happens is that the subscene entities get copied into the Editor World using a diff copy mechanism (which I will refer to as “diff-merge” from now on). I won’t explain the details here, but changes caused by the main Editor World are purely for preview and those changes do not propagate backwards into the baked subscene.

Incremental Updates
The subscene is now open, and baking is now operating in “incremental mode”. This mode is both what allows you to see the effects of your changes immediately, and is also the source of all the frustrating restrictions that come with bakers, and all the bugs that people write in baking systems.

For every authoring component type, Unity keeps a collection of bakers that run on it. These bakers are static and run for all subscenes. However, there is a unique baker state per authoring component instance per baker. This state keeps track of all the other authoring components and assets the baker declared as dependencies the last time it ran on the authoring component instance.

Whenever you make a change to any authoring component, Unity reruns all bakers associated with that component as well as any bakers on any components that declared a dependency on the changed component. “Rerunning” actually means first playing back an EntityCommandBuffer that “undoes” whatever the baker did for that authoring component the last time. Then, it does normal baker execution. The number of Baker.Bake() invocations that run in incremental baking with every edit should be significantly smaller than the full rebake when the subscene was opened.

After the bakers run, the baking system groups run in their entirety over all the entities in the subscene’s baking world. And then this world is diff-merged into the Editor World.

This process repeats every time the user makes an edit to the authoring representation of a subscene.

Closing the subscene
Closing the subscene works a bit surprisingly, in that Unity discards the incremental subscene world state completely. Instead, just like opening, it does a full rebake from scratch with all the bakers, but does a few extra steps as well. Also, in Prerelease 15, this baking happens in a completely separate background Unity process without Burst.

So after all the bakers and baking system groups run, there’s another set of systems that run in the category of EntitySceneOptimizations. These systems all get dumped into a ComponentSystemGroup called OptimizationGroup. The group runs twice. The idea being that the first time it makes structural changes and the second time it updates chunk component values. I’m not sure I agree with that philosophy versus having explicit groups with the latter issuing a warning if there were structural changes detected, but that’s how Unity does it today.

And then finally, the subscene gets serialized, remapping blob assets in the process.

Baking System Basics
I said that Bakers make all their changes via ECBs. There is one exception. Bakers create entities directly, and they always create the entities with one of several initial archetypes. If the GameObject is a prefab, the entity receives the prefab tag. If the GameObject is disabled, the entity receives the Disabled tag. These are inherited by any additional entities created via CreateAdditionalEntity inside a Baker.

Baking systems are like any runtime systems, in that they make entity queries, and process entities. You can make them Burst-compiled ISystem types for a more responsive editor experience. But consequently, queries will likely not run on the entities you care about because queries ignore Disabled and Prefab entities by default. Therefore, your baking systems should always query Disabled and Prefab entities via EntityQueryOptions. This is a really common mistake to make, so always check that first when a baking system doesn’t work right.

Also, because baking systems run on the full set of entities in the subscene, be cognizant of the work you are doing. Take advantage of change filters to skip chunks. And be wary of structural changes. While it isn’t as bad as runtime, you can still make a horrible editor experience with a poorly-written baking system.

There are two special attributes that you can decorate on components, [BakingType] and [TemporaryBakingType]. The former makes it so that the component type is removed from all entities during diff-merge or subscene serialization. The latter gets stripped at the end of the baking system groups every time they run. You can store unstable data in [TemporaryBakingType] such as UnityObjectRef (a handle to a UnityEngine.Object that can be used in unmanaged code such as a key to a NativeHashMap) or InstanceIDs. If there are important pieces of information a baking system needs about the authoring world, there are ways to transfer it using TemporaryBakingType.

Incremental Baking System Problems
Bakers record every change they make so that prior to being rerun, they can undo those changes.

Unfortunately, baking systems do not have this luxury. This means it is totally up to you to undo any changes when an entity is rebaked.

Let’s walk through an example. Let’s suppose you have a TeamColorAuthoring, which gives you the option of setting a color. The baker sets a TeamColor [BakingType] to the entity. Because a baker isn’t allowed to add components to the children, a baking system is written for that purpose. The baking system iterates through all children of an entity with the TeamColor and adds a material property color component to those children. Seems simple, right?

Now a user adds the TeamColorAuthoring to a root GameObject, and in the game tab, all the children change to that color. The user tweaks the TeamColorAuthoring’s color value and the children update in realtime. So far so good.

Then the user removes the TeamColorAuthoring. The colors on the children stay, because the material property components were added by a baking system and there was no baking system logic to ever remove them. Okay, that’s a little confusing. The component is destructive in nature. People write MonoBehaviours to work like that too sometimes.

But then, when the subscene is closed, the team colors disappear. And now the designer is really confused. Cue bug reports, frustrated programmers, and shouting matches between two big egos belonging to two very different disciplines. It ain’t pretty. And it is all too easy to do in the current design.

These leftover “phantom components” aren’t the only kinds of residual artifacts that can occur with incremental baking. But they are by far the most common and also quite tricky to catch and fix in a performant manner. Always test for this by adding and removing an authoring component and make sure the subscene entities don’t have any leftover components.

In future posts, I will go over some recipes I have developed to do some common operations that require baking systems, while simultaneously avoiding baking system artifacts. It is my belief that if you follow these recipes faithfully, you will be able to avoid many conflicts with your designers.

In the meantime, I would like to open this thread up for discussion and questions.

29 Likes

Thanks for taking the time to do this writeup on baking.

Do the closed subscenes automatically detect (and update) when referenced prefabs are changed in the editor?

For runtime (in build / end user) purposes, the performance of the baking systems does not matter. They don’t run unless something changes to the subscene. Is this correct?

I was also not able to instantiate a prefab in the baker, I guess this is the same reason. Only allowed to create an additional entity, which is empty and can be filled in the baker. So if you want to link separate entities (non-parent-child) you always need to make a temp-component, with the prefab you want to instantiate and the Entity value of the ‘baked entity’ so that in a baking system you can instantiate this prefab and give it the ‘baked entity’ Entity value?

Or I guess you could use CreateAdditionalEntity() and get all the component values from the prefab and add them to this ‘AditionalEntity’… I guess this is a lot of work since you would not have a function to get the ECS components, only the Mono GameObject components (or is there?)…

So my question is, why is there an option for CreateAdditionalEntity() in the baker? Should this type of change not also be done in the baker system, to bring it in line with prefab Instantiate?

1 Like

I believe that is the intent (they should trigger a full reimport of the subscene which does a full rebake in the background) based on how Asset Database Dependencies work. However, I don’t think Unity has ironed out all the bugs with this approach yet.

In incremental mode, baking systems run on the main thread after every user edit. So if your baking systems take 500 milliseconds, and the user drags a slider, the experience is going to be laggy and awful.

I believe it is because Unity could make a baker be able to do that without compromising the safety design goals of baker. It will have its uses in the Deferred Delegated Entity Recipe. :wink:

1 Like

How do you use ECB + Jobs in a baking system (ISystem)? (or should you not?)
The example https://github.com/Unity-Technologies/EntityComponentSystemSamples/blob/master/EntitiesSamples/HelloCube/Assets/7. BakingTypes/CompoundBBSystem.cs
does everything with the systemAPI and Queries in the OnUpdate.

I’ve been trying to get the state.EntityManager or using ‘new EntityCommandBuffer().AsParallelWriter()’ to get something to work. But there is always an error, or I cannot playback the ECB or set the right dependency.

I am missing a ECB for baking like what I use in ‘normal’ systems :
var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();

Edit: Update, it now works. I was doing the .AsParrallelWriter() too soon, if you keep a variable to the normal ECB it can be used for playback. DreamingImLatios link in this thread below really helped.

Thanks for writing this guide!

The documentation about baking is clearly lacking. We are currently addressing that and I’m in the middle of writing a document which is quite similar to what you’ve done there.

Your example about adding a color component to a set of entities in a baking system is indeed one of the most surprising issues with the current design. Just like with anything in ECS, reacting to data being added or modified is easy enough, but properly reacting to data being removed is more complicated.

The reason why creating entities is complicated (and instantiating prefabs for that matter) is that when entities are created during live baking they also get an extra GUID component which allows “diff-merge” to work. Keeping that GUID stable is impossible when entities are created from everywhere, so for now it’s always about creating entities in bakers and then processing them either in the same baker or in subsequent baking systems.

Using ECB, jobs and Burst in baking systems should work. The reason why our samples tend to use SystemAPI is because it makes the code a bit easier to follow, but we should definitely use those tools in a few places.

Thanks once again for the writeup and the feedback. And know that improving our samples and doc situation is high up on our list!

6 Likes

This real-world use case should answer your questions. Note that this system is not water-tight like the recipes I will be covering, as I wrote this before I figured out the recipes formally. https://github.com/Dreaming381/Latios-Framework/blob/v0.6.4/Kinemation/Authoring/BakingSystems/SetupExportedBonesSystem.cs
Also note that Burst does not run during subscene import, but does run in incremental mode, which is generally more performance-sensitive to a good editor experience.

I look forward to reading it! Hopefully I will get to learn something new! I will ask though that you make sure all of your real-world examples are up to date and pass the “undo test”. Your blob asset example does (though it is limited to one blob per GameObject), but some of your other usages like BakingOnlyEntityAuthoring or the MegaCity and NetCode racer do not pass this test.

2 Likes

Recipe 1: Request Any Reactive Pattern
Let’s say you have a zero-sized IComponentData called SuperCoolTag. This component is very important, and so many bakers want to ensure it gets added. So, there’s this one GameObject that has two authoring components. Each of those components has an associated baker that tries to add SuperCoolTag. Guess what Unity does? It freaks out!

Idea
The solution to this is to make every baker define its own variant of SuperCoolTag, such as BakerASuperCoolTag and BakerBSuperCoolTag, which are both decorated with [BakingType]. Next, a baking system queries for any of BakerASuperCoolTag or BakerBSuperCoolTag and adds the SuperCoolTag component to all entities matching that query. But, we also need to be able to undo this operation. For that, we specify a second query with all of SuperCoolTag and none of BakerASuperCoolTag and BakerBSuperCoolTag. For such a query, we remove SuperCoolTag.

Don’t forget to add Disabled and Prefab into your EntityQueryOptions for both queries!

Extending
It is a simple solution, and it handles the “phantom components” correctly. But every time there’s a new baker with a new SuperCoolTag variant, the baking system needs to be updated. That isn’t always possible, because the bakers might be in different assemblies or the baking system may need to be shipped as part of a package.

Fortunately, this solution can be somewhat generalized. The baking system simply needs to define an interface like this:

[BakingType]
public interface ISuperCoolTagRequest : IComponentData {}

Every baker then defines an ICD that implements this interface.

Now, in OnCreate of the baking system, search through all component types in TypeManager and find the ones that implement this interface and store them in an array. Then use that array to build the Any query, and the None query. After that, you should never have to touch the baking system again.

public struct SuperCoolTag : IComponentData { }

[BakingType]
public interface ISuperCoolTagRequest : IComponentData { }

class BakerA : Baker<AuthoringA>
{
    struct BakerASuperCoolTag : ISuperCoolTagRequest { }
   
    public override void Bake(AuthoringA authoring)
    {
        AddComponent<BakerASuperCoolTag>();
    }
}

class BakerB : Baker<AuthoringB>
{
    struct BakerBSuperCoolTag : ISuperCoolTagRequest { }

    public override void Bake(AuthoringB authoring)
    {
        AddComponent<BakerBSuperCoolTag>();
    }
}

[WorldSystemFilter(WorldSystemFilterFlags.BakingSystem)]
[RequireMatchingQueriesForUpdate]
[BurstCompile]
partial struct SuperCoolBakingSystem : ISystem
{
    EntityQuery m_addQuery;
    EntityQuery m_removeQuery;

    // Cache this across all worlds. Will get rebuilt on domain reload.
    static List<ComponentType> s_requestTypes;

    public void OnCreate(ref SystemState state)
    {
        if (s_requestTypes == null)
        {
            s_requestTypes = new List<ComponentType>();
            var interfaceType = typeof(ISuperCoolTagRequest);

            foreach (var type in TypeManager.AllTypes)
            {
                if (!type.BakingOnlyType)
                    continue;
                if (interfaceType.IsAssignableFrom(type.Type))
                    s_requestTypes.Add(ComponentType.ReadOnly(type.TypeIndex));
            }
        }

        var typeList = s_requestTypes.ToNativeList(Allocator.Temp);

        m_addQuery = new EntityQueryBuilder(Allocator.Temp).WithAny(ref typeList).WithNone<SuperCoolTag>().WithOptions(EntityQueryOptions.IncludePrefab | EntityQueryOptions.IncludeDisabledEntities).Build(ref state);
        m_removeQuery = new EntityQueryBuilder(Allocator.Temp).WithAll<SuperCoolTag>().WithNone(ref typeList) .WithOptions(EntityQueryOptions.IncludePrefab | EntityQueryOptions.IncludeDisabledEntities).Build(ref state);
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        state.CompleteDependency();

        state.EntityManager.AddComponent<SuperCoolTag>(m_addQuery);
        state.EntityManager.RemoveComponent<SuperCoolTag>(m_removeQuery);
    }

    [BurstCompile]
    public void OnDestroy(ref SystemState state)
    {
    }
}

Data Writes
Now what if SuperCoolTag was no longer zero-sized? What if it became SuperCoolData?

The pattern still works, but you need to compute the values of SuperCoolData somehow. You can do that with an IJobEntity after adding and removing the component from the two queries. If you recompute the values of all instances every update, you will never have artifacts. However, checking both change versions and order versions and operating on all changes should give you a little more editor performance. Do not try to track archetype changes! You will mess up. It is better to be conservative and recompute the values unless you are absolutely sure nothing changed since the last incremental update.

In practice, I often find that SuperCoolData just needs to be default-initialized, because it represents runtime state. That’s just as easy as the zero-sized case.

Buffer merging
Finally, there’s one more use case where this technique can be applied. And that is merging dynamic buffers. You still have to clear and remerge all the buffers every update (or using change and version filters). But that can be parallelized.

[InternalBufferCapacity(0)]
public struct BufferElement : IBufferElementData
{
    public Entity entity;
}

class BufferBakerA : Baker<ABufferAuthoring>
{
    [BakingType]
    public struct RequestInBuffer : IComponentData
    {
        public Entity entity;
    }

    public override void Bake(ABufferAuthoring authoring)
    {
        AddComponent(new RequestInBuffer { entity = GetEntity(authoring.someReferencedGameObject) });
    }
}

class BufferBakerB : Baker<BBufferAuthoring>
{
    [BakingType]
    public struct RequestInBuffer : IComponentData
    {
        public Entity entity;
    }

    public override void Bake(BBufferAuthoring authoring)
    {
        AddComponent(new RequestInBuffer { entity = GetEntity(authoring.someReferencedGameObject) });
    }
}

[WorldSystemFilter(WorldSystemFilterFlags.BakingSystem)]
[RequireMatchingQueriesForUpdate]
[BurstCompile]
partial struct BufferBakingSystem : ISystem
{
    EntityQuery m_addQuery;
    EntityQuery m_removeQuery;
    EntityQuery m_anyQuery;

    public void OnCreate(ref SystemState state)
    {
        m_addQuery = new EntityQueryBuilder(Allocator.Temp).WithAny<BufferBakerA.RequestInBuffer, BufferBakerB.RequestInBuffer>().WithNone<BufferElement>().WithOptions(EntityQueryOptions.IncludePrefab | EntityQueryOptions.IncludeDisabledEntities).Build(ref state);
        m_removeQuery = new EntityQueryBuilder(Allocator.Temp).WithAll<BufferElement>().WithNone<BufferBakerA.RequestInBuffer, BufferBakerB.RequestInBuffer>().WithOptions(EntityQueryOptions.IncludePrefab | EntityQueryOptions.IncludeDisabledEntities).Build(ref state);
        m_anyQuery = new EntityQueryBuilder(Allocator.Temp).WithAny<BufferBakerA.RequestInBuffer, BufferBakerB.RequestInBuffer>().WithOptions(EntityQueryOptions.IncludePrefab | EntityQueryOptions.IncludeDisabledEntities).Build(ref state);
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        state.CompleteDependency();

        state.EntityManager.AddComponent<BufferElement>(m_addQuery);
        state.EntityManager.RemoveComponent<BufferElement>(m_removeQuery);

        state.Dependency = new Job
        {
            bufferHandle = SystemAPI.GetBufferTypeHandle<BufferElement>(false),
            aHandle = SystemAPI.GetComponentTypeHandle<BufferBakerA.RequestInBuffer>(true),
            bHandle = SystemAPI.GetComponentTypeHandle<BufferBakerB.RequestInBuffer>(true),
            lastSystemVersion = state.LastSystemVersion
        }.ScheduleParallel(m_anyQuery, state.Dependency);
    }

    [BurstCompile]
    public void OnDestroy(ref SystemState state)
    {
    }

    [BurstCompile]
    struct Job : IJobChunk
    {
        public BufferTypeHandle<BufferElement> bufferHandle;
        [ReadOnly] public ComponentTypeHandle<BufferBakerA.RequestInBuffer> aHandle;
        [ReadOnly] public ComponentTypeHandle<BufferBakerB.RequestInBuffer> bHandle;
        public uint lastSystemVersion;

        public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask)
        {
            bool hasA = chunk.Has(ref aHandle);
            bool hasB = chunk.Has(ref bHandle);
           
            bool anythingChanged = chunk.DidOrderChange(lastSystemVersion);
            anythingChanged |= hasA && chunk.DidChange(ref aHandle, lastSystemVersion);
            anythingChanged |= hasB && chunk.DidChange(ref bHandle, lastSystemVersion);
            if (!anythingChanged)
                return;

            var buffers = chunk.GetBufferAccessor(ref bufferHandle);
            var aArray = chunk.GetNativeArray(ref aHandle);
            var bArray = chunk.GetNativeArray(ref bHandle);
            for (int i = 0; i < chunk.Count; i++)
            {
                var buffer = buffers[i].Reinterpret<Entity>();
               
                // We have to rebuild the buffer from scratch every time. Don't try to incrementalize this.
                buffer.Clear();

                if (hasA)
                    buffer.Add(aArray[i].entity);
                if (hasB)
                    buffer.Add(bArray[i].entity);
            }
        }
    }
}

So there you have it. You can now start merging tags and dynamic buffers safely using a baking system!

4 Likes

I actually fixed that in the BakingTypes sample earlier today, that one didn’t pass the undo test either.
I’ll follow up with the teams in charge of MegaCity and the racing game, thanks!

edit: by “fixed today” I mean it should show up in the next update.

4 Likes

Before baking was a thing, you needed to manually copy entities with the Prefab tag to other worlds. Now Prefabs get automatically copied to other worlds, at least for worlds created in ICustomBootstrap not sure about other cases. They are not copied immediately when the world is created. Instead, for example, in a manually updated world, they are created later only after world.Update() is called.

I do find this behavior a little strange. It generally should not be a problem unless you are polling the newly created world. However, even this can be worked around by immediately calling world.Update() when you create the world. I am just posting this to document this behavior somewhere.

1 Like

Thank god I stumbled upon this thread. It’s very informative! Thanks y’all!

btw. I don’t think it works for manually created worlds. It’s totally weird, the same code for world creation in ICustomBootstrap and when manually creating, one works - the other doesn’t. I spent hours on this and only when I use ICustomBootstrap prefabs are properly copied.

I was wondering, what is the best way to copy prefabs to the other world? Can you share how you did it?

Very simple you need to copy them from your main world into your new world. Something like this. This is old code pre-baking and you can do better but it should give you the idea. Nowadays you could just copy all entities with the prefab tag.

            //copyPrefabs
            NativeArray<Entity> e1 = new NativeArray<Entity>(4, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
            EntityQuery singletonGroupPrefabGuy = World.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<PrefabSpawnComponentGuy>());
            Entity prefabEntGuy = singletonGroupPrefabGuy.GetSingletonEntity();
            e1[0] = prefabEntGuy;
            PrefabSpawnComponentGuy prefabRefGuy = singletonGroupPrefabGuy.GetSingleton<PrefabSpawnComponentGuy>();
            e1[1] = prefabRefGuy.prefab;

            EntityQuery singletonGroupPrefabBullet = World.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<PrefabSpawnComponentBullet>());
            Entity prefabEntBullet = singletonGroupPrefabBullet.GetSingletonEntity();
            e1[2] = prefabEntBullet;
            PrefabSpawnComponentBullet prefabRefBullet = singletonGroupPrefabBullet.GetSingleton<PrefabSpawnComponentBullet>();
            e1[3] = prefabRefBullet.prefab;

            lockStepWorld.EntityManager.CopyEntitiesFrom(World.EntityManager, e1);
1 Like

Sorry for necro-ing the thread. In my tests, this only copies the Root Entity of LinkedEntityGroups. Yes, it copies the LinkedEntityGroup component but EntityManager.CopyEntitiesFrom() doesn’t instantiate the elements of the group to the destination World.

Am I missing something, or we need to manually instantiate and assign the elements of the group to the copied root entity in the destination World?

[EDIT]
Also commented with more info here:
https://discussions.unity.com/t/939663
[/EDIT]