Search Unity

Question How to implement a basic scenario using Unity's ECS design philosophy?

Discussion in 'Entity Component System' started by Sluggy, Mar 30, 2023.

  1. Sluggy

    Sluggy

    Joined:
    Nov 27, 2012
    Posts:
    989
    Okay, so I'm playing around with a basic game scenario to wrap my head around how this whole Unity ECS thing is supposed to work and it's really not clicking with me. Everything about it seems to be the exact opposite of how I've ever worked in my life. In this scenario I wanted to fill a scene with characters doing a simulated battle (not even graphics, just run the simulation and crunch the numbers. I'll worry about input/output later). Each character should have a set of stats that affect their offense and defense. They also can have one of a handful of abilities that can buff or debuff stats on others. Honestly I haven't even gotten to that part yet because I'm completely frozen on the first part.

    But I'm really stuck with how I should even represent the most basic thing on these characters. For example, health, stamina, and magic. Let's start with health -
    Code (CSharp):
    1. public struct Health : IComponentData
    2.     {
    3.         public float Max;
    4.         public float Current;
    5.     }
    Okay, simple enough. Seems a bit overkill for two stats but I get it. Easy to pack in memory. Easy to filter. Easy to iterate. Let's do stamina next -
    Code (CSharp):
    1. public struct Stamina : IComponentData
    2.     {
    3.         public float Max;
    4.         public float Current;
    5.     }
    Ah hmmm, okay. How about magic...
    Code (CSharp):
    1. public struct Mana : IComponentData
    2.     {
    3.         public float Max;
    4.         public float Current;
    5.     }
    Alright. This looks like a problem to me. They are all exactly the same data. They work the same way. They store the same information, albeit different instances as such. Maybe I should just make a generic Resource stat instead. Alright, but I can't have more than one instance of a component per entity so I'd need to do something like use a DynmaicBuffer of ResourceElement instead. Or maybe a component that keeps a FixedList of them. But now I need to identify which is which. Using indices would be bad because now I've hardcoded logic that assumes data structure and logical use of that data. I guess I could store a unique hash id for each that was based off of the original resource's name (health, stamina, mana) and then I could iterate over each until I find the one I want to work on within a given system but now I'm iterating over stuff that is completely irrelevant to the system just to get to the stuff it actually cares about. Sounds like the antithesis of ECS.

    At the end of the day I get it. I get that ECS is about tightly packing memory. I get that cache coherency independent data that can easily be run on multiple threads and cores is going to make things fast when iterating and maybe it really makes such a huge difference that it doesn't matter but am I really supposed to just iterate over all of this stuff every frame even if absolutely nothing of interest happened? Like for example, most of the time (as in most frames) character health won't be changing so there is absolutely no reason to consider looking at it to see if they are dead, or should be stunned, or reacting to taking damage. And these examples are derived stats. Max health is determined by another stat. If that stat changes, the max health changes. But now I have to iterate over both of these numbers every frame just to see that once again, nothing of interest has happened most of the time? It just seems... wasteful. And maybe the cache hits and multi-core processing and burst compiling more than make up for it. Maybe iterating over data thousands of times every frame just to decide to ignore everything is so fast that it doesn't matter. But it just rings every alarm I've ever developed in my brain about how to write code, fast code - namely: the fastest polygons are the ones you don't render. So am I just completely misunderstanding how to work with ECS?
     
  2. Spy-Master

    Spy-Master

    Joined:
    Aug 4, 2022
    Posts:
    632
    Does this really belong in the Unity Physics subforum?
     
  3. Sluggy

    Sluggy

    Joined:
    Nov 27, 2012
    Posts:
    989
    No it does not. My mistake lol.
     
  4. Quit

    Quit

    Joined:
    Mar 5, 2013
    Posts:
    63
    Components represent data. Some components will be exactly the same but represent different data. That's normal. Let's say your component is TransformPositionComponent -> float3 value. There will be other components which contain only float3 value in their component, but those components will represent different things.

    If you want to pack the similar components so you would save your time typing component -> same values, use generics, inheritance, etc...

    However, your components might be simplified to Component -> float currentValue only, especially if MAX doesn't change. All the components would represent different data, but the composition of all of them would be the same -> all would only contain a float value.

    How you pack the data -> separate components or using a dynamic buffer depends on you. Do you need to filter per components, do you need to iterate over all of them at the same time, etc...

    Linear iteration is fast. Super fast. If you want to save on that as well, use Version Filters to iterate over the changed components only. It has its nuances so you might want to investigate that, cuz it doesn't work per component (it works per chunk), and it fires the component has changed event even if the component hasn't changed, but it was RefRW<> in the system.

    Hope this helps.
     
    Sluggy likes this.
  5. Laicasaane

    Laicasaane

    Joined:
    Apr 15, 2015
    Posts:
    361
    Even though their layout and composition are exactly the same, they do not represent the same information. Even in OOP you would have to differentiate them by declaring different fields inside some Stat class.

    Code (CSharp):
    1. // OOP
    2. class Stats
    3. {
    4.     public float CurrentHealth { get; set; }
    5.     public float CurrentMana { get; set; }
    6.     public float CurrentStamina { get; set; }
    7. }
    These 3 fields represent 3 different information. But in OOP you usually don't wrap them inside structs.

    In DOD or ECS, you just have an extra step of wrapping them inside custom structs to give them meaning and a way to identity their own chunk/array/table.

    If you are worrying about code duplication. You can declare some struct to act as the underlying value for these components. Like this:

    Code (CSharp):
    1.  
    2. struct ResourceValue
    3. {
    4.     public float current;
    5.     public float max;
    6. }
    7.  
    8. struct Health : IComponentData
    9. {
    10.     public ResourceValue value;
    11. }
    12.  
    13. struct Mana : IComponentData
    14. {
    15.     public ResourceValue value;
    16. }
    17.  
    18. struct Stamina : IComponentData
    19. {
    20.     public ResourceValue value;
    21. }
    22.  
    It probably does. Your idea of using DynamicBuffer just adds an overengineered layer on top of ECS.
     
    Last edited: Mar 31, 2023
    Sluggy and Quit like this.
  6. Richay

    Richay

    Joined:
    Aug 5, 2013
    Posts:
    122
    In my project I use a DynamicBuffer<StatValue> on the character entity to hold current/max floats. The buffer is populated by iterating over a StatType enum. Then, when I need to access a stat, I simply use buffer[(int)statType]. (This of course assumes that your stat enum is numbered sequentially!)

    I'm not sure if it's the most optimised method but it's very easy to both add and retrieve stats, and the simplicity likely outweighs those last microseconds of optimisation.
     
    Sluggy likes this.
  7. Laicasaane

    Laicasaane

    Joined:
    Apr 15, 2015
    Posts:
    361
    With the marvelous power of Burst and Jobs, the iterators run super fast it would blow your mind. I don't even usually care about this unless the profiler says otherwise. I regularly inspect the profiler after adding some more systems and jobs. It really helps.

    I have 1 system that loops over 1000 entities along with their components (some of them never change their value) and move these entities by a boid avoidance logic (which also involves collision detection).

    And because 2D has not been supported yet, I must go with the hybrid solution. So there is also another system to fetch position and sprite data from these 1000 entities, and use that data to update 1000 GameObject-SpriteRenderer companions. On a mid-range Android 9 device I have here, the game stays around 40 FPS. And on another mid-range Android 11 device the game stays around 55 FPS.
     
  8. Laicasaane

    Laicasaane

    Joined:
    Apr 15, 2015
    Posts:
    361
    Take these polygons as an example. I suggest you take a step back and observe how your OOP code behaves in relation to the hardwares and answer this question: How many cache misses your hardwares must experience in order to exclude some polygons from being rendered?

    Yes, your rendering code will run fast when it renders less. But how is the journey to arrive that part of your codebase? Is it comfortable (for the hardwares)? The problem isn't that your renderer will or won't render some polygons, but the journey to gather these polygons to feed it.

    So the philosophy behind DOD is that you do everything to make sure your hardwares experience as less cache miss, and more efficiency as possible, so the journey to the rendering code would be done much faster.
     
  9. Sluggy

    Sluggy

    Joined:
    Nov 27, 2012
    Posts:
    989
    Generally I find my profiler is dominated by the renderer and the physics even when I've got thousands of entities (in the gameplay sense, not the ECS sense) so this really is a case of me just wanting to learn some new stuff rather than trying to super optimize things lol. Granted I work on the PC not mobile.

    I think is where my traditional programming really butts heads with the ECS mentality. I like abstraction. For all of my previous games I simply use a generic Resource class and it gets used all over the place. It's the health for the characters. Also the health for destructables. It's the cooldown on skills and the timer on buffs and debuffs. It's the meter for super attacks and for weapons that can overheat. Basically anything that has an upper limit, and current values, and a recovery rate is represented with this one class and it works marvelously well. Despite its ubiquity though almost nothing ever interfaces with it because its also event driven. It listens for commands to change and posts event when it has done so or has met certain thresholds (for example hitting zero).

    But I do recognize the limits that it can have when it comes to performance and memory locality. It's always been on the back of my mind when dealing with large simulations of characters. And in this case I do want to try something a little more intense too. I'd like to see how much of the simulation I can actually just run without the traditional smoke n mirrors. It sounds like it might actually work very well as long as I play my cards right and give up some of the conveniences and niceties that my traditional programming style offers. In some ways it might even make up for it if I can spend less time writing code to determine what and when to do the work and instead just do the work all of the time.

    Anyway thanks for the comments all. While it seems to conflict with what I normally 'know' it does seems that I am probably over-complicating things and should just write the code, run, the simulation, and measure the results and not worry about the other stuff. I'll keep at it.
     
  10. Laicasaane

    Laicasaane

    Joined:
    Apr 15, 2015
    Posts:
    361
    To expand about loops: you are forgetting that games always run inside a giant Game Loop. And to get the necessary data to do work, you would have to loop over thousands of objects either way. In essense, the same as ECS. But these loops are hidden behind object interactions and vast amount of object dependencies.

    In OOP it takes me a great amount of time to understand the problem of object interactions, and I have to devise a way to manage them better - something I refer as a God Eye when I explain the idea to my colleagues. But it always seems to fall short.
     
  11. Sluggy

    Sluggy

    Joined:
    Nov 27, 2012
    Posts:
    989
    You'd be surprised how little I I update live in my games. Unless something truly is capable and likely to update every frame it almost always uses events instead.
     
  12. Laicasaane

    Laicasaane

    Joined:
    Apr 15, 2015
    Posts:
    361
    No, you don't give up anything of your traditional style. You will just code in a totally different way. It's a new land with different kind of beasts, to tell you the truth.

    Actually you would have to spend time considering which job will run after which job. But you won't spend time on scrutinizing the mess of object dependencies.

    Totally true. I was once trying to figure out DOD and ECS until one day I deciced just to jump on this ship and see how things go. I'm still struggling now, but I don't regret it.
     
    Sluggy likes this.
  13. Sluggy

    Sluggy

    Joined:
    Nov 27, 2012
    Posts:
    989
    Also, quick technical question: Someone mentioned above something about using generics and whatnot. I thought this kind of thing was a big no-no in ECS? At least for the component data itself.
     
  14. Laicasaane

    Laicasaane

    Joined:
    Apr 15, 2015
    Posts:
    361
    Do you really think this is not a loop? Instead of a visible loop, you are making it hidden, hard to discern and debug. With ECS, the flow of data is always visible when you know the data and the systems. I don't know how good you are with OOP, but I can never confidently tell the flow of data through these objects.

    No. Generics are not banned in ECS. It's just that the Burst compiler has to know in advance every concrete types. Currently Burst doesn't work well with generic Jobs, you have to manually register generic jobs for Burst to understand. For common generics that don't involve jobs, you are fine.

    There are some situations that Burst can figure out the concrete type of a generic job from the way we code, and we won't have to manually register them anymore.
    https://docs.unity3d.com/Packages/com.unity.entities@0.51/manual/ecs_generic_jobs.html
     
    Last edited: Mar 31, 2023
  15. Sluggy

    Sluggy

    Joined:
    Nov 27, 2012
    Posts:
    989
    Ah okay, so it was just a burst thing and more for jobs. I just wasn't sure if generics did anything funky with the memory layout of structs that would mess up chunks somehow. Good to know and thanks for the tip!
     
  16. DreamingImLatios

    DreamingImLatios

    Joined:
    Jun 3, 2017
    Posts:
    4,270
    I've found the easiest way to start with ECS data design is to take all the data that would be inside one of your MonoBehaviours, and split it into "stats" and "state". Stats don't change after startup, or rarely change. State changes every frame. Even though granularizing your data can improve performance with better cache coherency, you can also overdo it. The biggest advantage you will have in ECS is just getting your data in a Burst-friendly format. And Burst can still often do magic with suboptimal data layouts. So start with larger components, then split things up after profiling or when you want to split things up for reusable systems.

    As for events, they are the wrong mindset in ECS. If you want that pattern, don't use ECS. In ECS, you use concepts like "existence-based processing", and "change filtering" in combination with dead-simple polling to achieve something similar. As a consequence, you never need to worry about execution order, which can remove a lot defensive checks from your code.

    As for generics, keep them to static methods, utility structs, and containers. Don't use them for components or systems if you can help it. It will cause pain. The correct solution to these problems is source generators, which Unity needs to document a lot better. But if you do want to go down this route, just ask me and I can point you to some good resources.
     
    Laicasaane likes this.
  17. xVergilx

    xVergilx

    Joined:
    Dec 22, 2014
    Posts:
    3,296
    On topic of generic burst jobs - Burst is smart enough to detect interfaces, if they're added via type constraints that do not box.

    For example.
    struct SomeStruct : IDoSomething;

    In method, you can declare:
    Code (CSharp):
    1. public static void SomeMethod<TInterface>(TInterface data) where TInterface : struct, IDoSomething {
    2.   ...
    3. }
    As long as you're not performing any boxing on the structs, you can use interfaces just fine.
    This way you don't have to manually declare generics types, they're picked up automatically.

    Basically, process data as generic data instead of processing generic logic (if that makes sense).
     
    Last edited: Mar 31, 2023
  18. Laicasaane

    Laicasaane

    Joined:
    Apr 15, 2015
    Posts:
    361
    I'm still using some simple generic systems because they're convenient to prototype. Source generator surely is the way, but I'll come to it later. At this earlier stage of the project, systems are being added and removed somewhat frequently when I'm trying to understand the problems and how to model them.