The various ways to use Unity ECS: A Starter Guide

The Various Ways to Use Unity ECS: A Starter Guide

It seems like periodically there’s a wave of new users trying to learn ECS, and almost always they start asking about “best practices”. Too often, they get conflicting answers from different people, answers which also often contradict Unity’s documentation, and it can all be quite confusing. The reason why this happens is simple. There are different ways to use Unity’s ECS, and the “best practices” wildly differ between them.

The purpose of this guide is to identify and differentiate these different approaches, so that our community can have better discussion on these topics in the future.

Disclaimer: I’m just a lonely developer. And a lot of what I will be discussing are based on personal experiences and conversations I have had with various people in the community. There are likely projects that use ECS that do not follow any one of these approaches.

Hybrid

There are mainly two ways to do hybrid. There’s hybrid that uses baking and subscenes, and hybrid that does not.

Generally, you want to use baking when you intend for ECS to represent the core of your gameplay. In this case, hybrid is really just a layer on top of any of the other approaches that will be discussed. The main rule is that you almost always want systems to access managed GameObjects and MonoBehaviours, and you almost never want to access entities and systems in typical MonoBehaviour callbacks.

If you are mostly looking to leverage ECS to speed up a few things in your game, you probably want to avoid baking. Instead, you will want to keep your ECS to a single World, and you’ll use EntityManager from MonoBehaviours to create entities. You store those Entity handles inside the MonoBehaviour, and you use EntityManager to read and write entity data. In addition, you can add a UnityObjectRef<YourMonoBehaviour> to your IComponentData for your entities to allow systems to call back into your MonoBehaviours as a form of event processing. While you can be quite liberal with EntityManager in this workflow, it is a good practice to avoid any interactions with EntityQueries inside of MonoBehaviours.

A lot of people will tell you that you shouldn’t access ECS stuff from MonoBehaviours because it causes “sync points”. This more MonoBehaviour-focused your project is, the less this becomes true. Really, your main concern is to make sure your ECS systems run right before something on the main thread that doesn’t need the ECS data. One hack you can try is to put all your systems in PresentationSystemGroup, have MonoBehaviours setup entity data in LateUpdate(), and then have those MonoBehaviours read the results in Update(). But really, if you want this kind of hybrid, you need to study the update order of systems relative to MonoBehaviour callbacks, and study the profiler to figure out what system placements work best for your game.

Also, in this kind of hybrid, you should be careful about which ECS features you use, and only use them if they make sense for your project. Only use ECS Transforms if you need transforms to be in ECS. Only use ECS rendering if you are going to have visual entities not backed 1-1 by GameObjects.

Simple ECS

Simple ECS is best for those who are looking to benefit from the architecture of ECS, and also build their game with “performance by default”. This is for people who struggle with the details of the job system and all the rules. In simple ECS, there’s basically one rule that you follow. Don’t use jobs!

You use baking. You use ISystem for pretty much every system. And you use SystemAPI methods like Query() and GetComponent(). You can nest foreach statements with queries. You can nest native containers inside of other native containers. Structural changes will be a lot less problematic in Simple ECS compared to some of the other categories, so don’t shy away from them. Create ECBs with WorldUpdateAllocator and play them back at the end of the system update. Only use the dedicated EntityCommandBufferSystems for instantiation and destruction.

You can express a lot of things quite efficiently. Most Unity documentation is written for this approach, and for good reason. This approach is way faster than most people give it credit for. It scales down to fewer entity counts especially well, making it suitable in cases where you only have a few hundred entities but a lot of logic to apply to them.

As you get more experienced, you might find opportunities where particularly heavy systems can be refactored to use a job or two. But for the most part, you want to keep everything on the main thread and avoid all the threading overhead and weird rules.

NetCode-First ECS

If the primary reason you want to use ECS is to leverage the server-authoritative NetCode package, then you really want to follow the guiding principles for Simple ECS above, especially for any systems that run in the prediction loop. With prediction, you typically have only a few entities you need to predict, but potentially a lot of prediction ticks to burn through. Unless you can aggregate all your game logic into a single job, main thread code is going to be way faster at this. This approach makes NetCode viable on mobile, with Unity Physics being the likely bottleneck to watch out for.

If for some reason you have a lot of systems in the prediction loop and they take up too much time, you can get a little extra boost by adding [DisableAutoCreation] to your systems and then writing a system like this:

partial struct MegaSystem : ISystem
{
    MySystemA a;
    MySystemB b;
        
    public void OnCreate(ref SystemState state)
    {
        a.OnCreateForCompiler(ref state);
        a.OnCreate(ref state);

        b.OnCreateForCompiler(ref state);
        b.OnCreate(ref state);
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        a.OnUpdate(ref state);
        b.OnUpdate(ref state);
    }

    public void OnDestroy(ref SystemState state)
    {
        a.OnDestroy(ref state);
        b.OnDestroy(ref state);
    }
}

Big Sim with Threaded Gameplay ECS

If you are making a single-player experience that involves lots of the same type of thing, you probably want to consider this approach. We’re talking factory games, base building games, tower defense games, simulation games, stuff that is a combination of procedurally-generated and player-driven. The game is defined by very explicit rules, with a lot of entities to process.

Your main goal in ECS is to use IJobEntity and other job types, as well as a few hashmaps and other data structures to create one big job chain through all your simulation systems. In the profiler, you’ll have one big “WaitForJobGroupID” somewhere in your frame. All structural changes go to an EntityCommandBufferSystem, or sometimes two ECB systems right next to each other (the latter system being exclusively for destruction). And right next to those, you might have a few systems that do a lot of structural changes on the main thread which handle things that are a bit more complicated to set up.

You are generally writing gameplay code directly in your jobs. And consequently, it will make a lot of sense to treat entities in the world similar to Game Objects, things that have transforms, maybe rendering data, attributes, ect. Traditional abstraction is not a good idea. Instead, you’ll be explicitly implementing specific abilities, stats, and modifiers in code. So make sure the quantity of these is manageable. Some of your entities might end up with a lot of components. That’s okay. You are mostly worried about Burst and good worker thread usage. And most likely, the things you want to watch out for performance-wise are Physics, transform hierarchy updates, and rendered poly count.

From a development perspective, the trickiest part of this approach is learning to represent “relationships” in multi-threaded ECS. Some people will really struggle to wrap their mind around this, and should probably stick to the Simple ECS which doesn’t have that problem. Other people find a strange satisfying sensation with this approach and write code with it even if their entity count is low enough that main thread code would actually be faster. (I’ve been guilty of this.)

NetCode can be a little awkward here regarding prediction. It may be that a predicted client wants to run the predicted code on the main thread, while the server may want to run the same code in a job. You will likely need to experiment. Custom networking solutions seem to be a bit more popular with this style of ECS compared to the others.

Also, if you do go down this route, try to avoid streaming or dynamic loading as much as possible. If you can fit all your assets into RAM at startup, you’ll save yourself a lot of headaches. I say this specifically because a lot of people who take interest in this approach are used to Addressables. You will want to challenge your intuition here, because the rules and tools for memory management are completely different.

Data Driven ECS

Welcome to AA and AAA ECS!

Jobs, baking, and subscenes are all in use, as well as a whole bunch of editor tools. In this style of ECS, you stop seeing things as objects and entities, and instead start seeing everything as data. Entities are just handles to a collection of data. Stat types are just integer data. Conditional checks, state machines, behaviors, all just data. When everything is data, you can build very generalized systems for transforming the data based on data. The data itself becomes the source of expressing complexity. If perfected, you may find that most of your gameplay programming is actually happening in bakers and authoring tools.

What used to be a single GameObject in authoring will not likely be multiple entities that contain data for handling different aspects of the data. This does mean that the number of relationships will be high, which could result in a lot of random accesses via ComponentLookup. That’s okay. Your goal is not to eliminate random accesses, but to instead reduce them compared to a traditional OOP approach. This drastically reduces the likelihood of dependent random accesses, reducing the overall latency of every entity you process.

As you become more experienced with this style of ECS, you will find ways to organize your data such that your lookups can be hidden behind unlikely branches determined by linearly-iterated data. You’ll also make heavy use of change filters and IEnableableComponent to reduce the number of entities you process with each system dramatically. Optimization quickly becomes a matter of statistics.

A couple other major benefits with being data driven is that subscene streaming with scene sections absolutely shines. Additionally, the abstractedness of your systems may help keep the number of prediction-loop jobs down to something manageable in a NetCode project targeting desktop and consoles. Unity Physics is also viable with the help of streaming.

However, this style also has its downsides. If you aren’t ready to invest heavily into tooling, or simply prefer to express game details through code rather than parameters in the editor, you will likely struggle to see the benefits. Additionally, the abstract nature of being data driven introduces an indirection in both how you reason about problems and how the CPU executes the game logic. The former can increase the learning curve substantially. The latter should serve as a reminder that there is no silver bullet.

If you are interested in this data-driven style for your own production, I recommend checking out the DOTS channel on the official Unity Discord. Some very successful proponents of this style hang out there. I don’t personally participate in that conversation, as data-driven isn’t right for my needs. But I do periodically skim through the conversations there and I am willing to provide my own perspective if specifically requested.

Extreme Simulation ECS

Sometimes, you don’t just need performance, you need PERFORMANCE!

Your entity count is massive for a few key archetypes, and you need to be extra careful if you want this project to run fast enough on target hardware. Typically, there are two things you want to watch out for.

If you have any operation with algorithmic complexity greater than O(n) on your massive entity counts, then you will need to brush up on your assembly and be ready to dive deep into the Burst inspector. You may also want to learn how to use sampling profilers with detailed microarchitecture analysis.

It is common to have two different implementations for a given process, one that is faster than the other but requires some kind of assumption. Then you can classify each entity for each of these two algorithms using IEnableableComponent.

But aside from algorithms, and even assuming you have your random accesses in check, you can still run into memory bandwidth problems at this scale. Bandwidth bottlenecks are a lot trickier to identify if you aren’t experienced pushing things to this extreme.

To address them, you want to pay very close attention to both your chunk capacity and the number of actual bytes you are reading and writing for each entity in each job. Smaller data types and better alignment can make a huge difference. half, short, byte, and bitfields are your friends, and always specify the backing type for your enum types.

Moving logic from one job to another can also make a difference, especially when it results in a larger component no longer being used in one of the jobs. And don’t discount opportunities to deduplicate data across components or across entities.

As for all the other archetypes that aren’t as plentiful, you can resort to either the big simulation style or data-driven, whichever makes the most sense.

At this scale, Unity Physics is not really viable. And if these high-quantity entities are rendered, vanilla Entities Graphics may not cut it either. You have to be very careful about how you instantiate and destroy entities to ensure as much batch processing happens as possible. And be wary of DynamicBuffers. The [InternalBufferCapacity] is extremely important to get right. At this scale, zero is not always a safe answer.

There are not many people in the ECS community that have explored this extreme much. If you do decide to explore, don’t hesitate to reach out. Also, if you want to learn this stuff, I don’t recommend voxel worlds as a first project. I’ve seen too many newcomers immediately faceplant over the way Unity does camera culling.

The Future

If you’ve paid attention to Unity’s ECS announcements the last couple months, you probably know about the “ECS for all” initiative. This will naturally impact the various ways to use ECS, and will likely make some styles more enticing.

The biggest impact will be on Hybrid ECS without subscenes and baking. If you recall, I suggest only using the ECS features (transforms, physics, graphics, NetCode, ect) if you absolutely need them in ECS form. There are real problems right now with making those solutions play nice with GameObjects. “ECS for all” is first-and-foremost aimed to fix that. In a way, ECS will become an extension of the engine itself, and a means to put core gameplay mechanics and third-party packages “into the engine”. Meanwhile, MonoBehaviours may return back towards their “scripting” origins.

For subscene fans (all the other styles), the benefits are indirect. With this new initiative, most ECS code could still be effectively leveraged in a primarily GameObject-based project. And that makes ECS-compatible code more economical of a target for both Unity as well as third-party developers.

But if that’s a little underwhelming for you, then I hope you don’t mind if I plug something I’ve been working on.

When it comes to scalability in entity count, ECS clearly has it covered. But when it comes to logical complexity often faced in real projects, the choices I presented may be less appealing to some. If your team has strong inclinations towards having and maintaining visual scripting and other visual tools as essential to your workflow, then being data-driven is an obvious choice. However, many teams may only have a few gameplay programmers plus some technical artists and designers who are used to writing MonoBehaviour scripts. For such teams, as well as many solo developers like myself, data-driven isn’t very friendly.

But without being data-driven, complexity is hard to achieve with Unity’s ECS. It gets a bit better if you stick to simple ECS due to the increased expressivity. But chances are you were drawn to ECS because some parts of your project could really benefit from multithreading. So how else can we achieve complexity? What other tools are out there?

OOP. :scream:

While we all know OOP isn’t very good at doing the same operation on a bunch of entities, it is a lot better at doing a lot of complex operations on a smaller set of entities. That’s especially true if the logic doesn’t need to run all the time, but rather in reaction to events. In real projects, you’ll have some archetypes with lots of entities and simple logic, and you’ll have many other archetypes with far fewer entities but a lot more complexity to deal with. OOP would be great for the latter, if it weren’t for the fact it has to run on the main thread, introduce sync points for any ECS data it touches, add GC pressure, and do it all without Burst. No thanks.

But what if instead our OOP objects lived in unmanaged ECS memory, could be called into abstractly in jobs, and leverage Burst? The only expensive things would be the virtual calls, and maybe also having not-the-most-efficient cache utilization. But you’d otherwise have something familiar to the tech artists, and something that allows programmers to fall back on known solutions for some more complex gameplay elements.

Aside from a Burst bug that is supposedly fixed in 1.8.19, I have something functional. It is called Unika, and it packs “script” structs into a DynamicBuffer with extra indexing and type metadata. It then uses source-generated interface v-tables and source-generated abstract interface handles to do polymorphism, fully independent of assembly and struct size. I’ve put a lot of care and effort into designing this to play nice within the ECS workflows, and I can’t wait to see what people do with it!

Final Thoughts

There you have it, six different ways to use ECS with very different priorities and best practices, plus some new ways you will be able to use ECS in the future. Hopefully this helps clear up some confusion and provides some focus and direction for anyone new to Unity’s ECS.

With that said, some of you may be using ECS in ways I didn’t cover. I suspect a fair number of you use a blend of styles where they make sense. But feel free to chime in with your style. And if you are new, don’t hesitate to ask questions!

Thanks for reading! And best of luck with your ECS adventures!

17 Likes