Search Unity

Where is the extensibility of OOP found in ECS?

Discussion in 'Entity Component System' started by jwvanderbeck, Jan 2, 2020.

  1. jwvanderbeck

    jwvanderbeck

    Joined:
    Dec 4, 2014
    Posts:
    825
    Something I am struggling to understand here and hoping someone can help me get my head around it.

    Let's take a simple scenario. I have a map consisting of various cells. In these cells I can place a MapObject.

    Now in a "traditional" Unity project, I might do something like the following. Make MapObject be a base ScriptableObjcect class with some common data and methods, such as a setup method (for when the object is first created and placed into a cell), a tick method, etc. Then to provide specific functionality for a object I would derive from that. Then when it comes to execution, the Map class simply sees a MapObject and calls methods on it, without knowing or caring about the specific cases.

    Now I am unclear on how to achieve a similar paradigm with ECS. Especially when I am in a more "hybrid" style, rather then pure ECS. By which I mean some things are traditional, and others are ECS.

    Any assistance building the proper mind picture here would be most appreciated.

    Thanks!
     
  2. P0150N

    P0150N

    Joined:
    Aug 1, 2015
    Posts:
    9
    It seems like you are trying to accomplish object-oriented programming here, and that's not really what ECS is about. You can't really abstract each MapObject like you're talking about here - each piece of "data" has to have a purpose.

    For example, here's how you would lay out data instead (using the classic player-type OOP abstraction).

    In OOP:
    We have an abstract Character, which contains health, etc...
    We have a Player, which extends off of Character, and adds functionality.
    We have a Monster, which extends off of Character, and adds functionality.

    In ECS:
    We have 3 components, Character, Player, Monster.

    For all entities with Character, we query it and do this "tick" method that you specified above.
    For all entities with Character AND Player, we do a "specific functionality" that you mentioned above.
    For all entities with Character AND Monster, we do a "specific functionality" that you mentioned above.

    For the setup method, a really neat trick that I found to be very useful is you can use ScriptableObjects for initial setup, read them using Addressables and create entities with them (useful if you are the programmer, and have a non-programmer friend who you are working with, and would like to provide them an easy "object-oriented" way of creating stuff).

    Here's how it works:

    Code (CSharp):
    1.    
    2. public class PreloadCharactersSystem : ComponentSystem
    3. {
    4.     protected override void OnCreate()
    5.     {
    6.         Addressables.LoadAssetsAsync<MonsterScriptableObject>("Characters", asset => { ... });
    7.         Addressables.LoadAssetsAsync<PlayerScriptableObject>("Characters", asset => { ... });
    8.  
    9.         // OR instead of accessing specific ScriptableObjects, you can access every SO with a specific parent too:
    10.         Addressables.LoadAssetsAsync<CharacterScriptableObject>("Characters", asset => { ... });
    11.     }
    12.  
    13.     protected override void OnUpdate()
    14.     {
    15.     }
    16. }
    17.  
    This is how I would approach for the section that you mentioned "when the object is first created and placed into a cell".

    You can access "asset" like you would from a MonoBehaviour, which is pretty neat. From there, you would use the variables passed in your "PlayerScriptableObject" and create entities with them. The "Characters" string in the first parameter is just the label you used for the Addressables item (could be anything really).

    I hope that helps. I recommend reading http://www.dataorienteddesign.com/dodbook/. It really helps if you instead try to picture everything as a big "database" (if you've worked with databases before. if not, this book explains it really well).
     
  3. jwvanderbeck

    jwvanderbeck

    Joined:
    Dec 4, 2014
    Posts:
    825
    You are 1000% correct and this continues to be my biggest hang up making the transition. I have 20+ years of experience writing OOP code and my brain is wired that way. I'm having a HECK of a time even wrapping my head around the differences, let alone implementing them.

    I'm going to go read that book before coming back to this. Thank you for the link!
     
  4. RandomGuy2020

    RandomGuy2020

    Joined:
    Jan 3, 2020
    Posts:
    3
    There's a movement in programming as a whole to slowly "give up" on OOP as a failed experiment.
    Several commercial reasons are leading to this, but the main ones are

    1: RAM clocks aren't getting any better
    2: CPU manufactures are adding more and more cores

    Unity is just flowing with the trends, in some years Rust language will be as common as JavaScript and many will say that they don't even use OOP at all anymore. If you can't figure out how to code anymore without OOP, here's place to start:

     
    starikcetin likes this.
  5. BackgroundMover

    BackgroundMover

    Joined:
    May 9, 2015
    Posts:
    224
    From how I read your post, it sounds like you see the benefits of inheritance from a flow control standpoint. A JungleTile can inherit from MapObject, and receive all the benefits of getting notified when the player steps on it, because the MapObject base does the heavy lifting of calculating player presence, and alerts its subclasses through an overridden OnPlayerEnter(), for example.

    Or using your example, suppose the player explores a new part of the map, which requires new tiles to be generated and set up. The tiles are spawned, and base MapObject determines Setup() hasn't been called yet, so it makes the call, JungleTile hears it since it overrides it, and does whatever setup is needed for a jungle.

    If thats how you view the benefits of extensibility in OOP, here's how I'd describe ECS: state is no longer passed through an explicit function chain, but is more implicit and subjective, based on which Components are present on the Entity. A JungleTile no longer is alerted to player presence through OnPlayerEnter(), but by virtue of there being both a PlayerPresent component AND a JungleTile component on an Entity. If you know Venn diagrams, the JungleSystem reacts to the player by looking at the middle set of entities, where on the left is the set of entities with Jungle components, and on the right is the set of entities with a PlayerPresent. The overlap describes the set of entites that are in the state we're interested in (has jungle and has player).

    PlayerPresent might be a bad example, I have no idea if its conventional or not, and when I first heard the idea of components that were primarily for "tags" it wrinkled my nose and I thought "code smell". But learning a bit more about Archetypes, it makes more sense than querying bools or doing calculations every frame. Presence or absence of a component (can be treated as) just as important as the data inside the component, except component presence is easier to read, from my understanding.

    Using the Setup example, maybe JungleTiles are prefabs that you spawn in, and on that prefab is a NeedsSetup component (awkward and gross, I know). Or maybe the tile spawning logic applies a FreshlySpawned component to tiles it creates. The JungleSystem can query for entities with a Jungle component and a FreshlySpawned component, and do Jungle-specific setup on them. Somewhere later in the frame, or maybe early in the next frame, the spawning system looks for entities with the FreshlySpawned component, and removes it, since by then all systems have updated, and done their Setups, so the tile should no longer need setting up any more.

    One nice thing about it is Jungle.Setup() no longer needs to remember to call base.Setup().
     
    justaguygames and Cynicat like this.
  6. jwvanderbeck

    jwvanderbeck

    Joined:
    Dec 4, 2014
    Posts:
    825
    Hi vestigial, I think you've really hit the nail on the head here in that my biggest difficulty adapting here is understanding control flow, or execution flow, under ECS. In general ECS seems almost like reactive programming. We build these systems that are just always running and greedily looking for things to process. But execution flow is often more structured than that.

    For example, if I am starting a new game, I need to first generate a map. This process needs to run some systems essentially "on demand" then take the result of those systems to generate entities. THEN I can go into the main loop and let things happen, but I need to get there first.

    I fully understand the benefits of these "new" ideas, and I am sure I will adapt given time to wrap my head around the concepts. But at the same, I really liked the "elegance" that traditional OOP provided me, so its proving difficult.

    Honestly I have a million questions between where I am now and where I need to be, and once I can find answers to those questions I'm sure I'll be good. But in the meantime finding answers to those questions is difficult. I wish I could just sit down with someone who really understands ECS and have pick their brain over lunch :D
     
    justaguygames likes this.
  7. DreamingImLatios

    DreamingImLatios

    Joined:
    Jun 3, 2017
    Posts:
    4,262
    Have you tried making a system's OnUpdate call an IEnumerator method? It's a stupid trick, but it can really clean up the code for event sequences that span multiple frames.
     
  8. 5argon

    5argon

    Joined:
    Jun 10, 2013
    Posts:
    1,555
    Did you already know about [UpdateBefore/After/InGroup] ?

    "always" is half true, there is still a frame and a concrete order for any world. It is just that that order should be implicitly generated from [UpdateBefore/After/InGroup] The InGroup is probably the most important but often overlooked from people who complained ECS lacks concrete ordering. You have to exploit it, nesting them up as much as you want so there is only 1 InGroup and maybe 1-3 BeforeAfter per system.

    I found this "on demand" thing to be tempting but it often leads to real design error such as systems calling each other's public methods. It is possible but you should try to turn it into "always" though you may feel that it is weird at first. Try creating a system that always work, yet it do things only once. (but automatically again when situation calls for)

    This loader system is a good example.

    The most OOP like approach that get very close to method call but data-oriented enough is a command entity. Like you create an entity with ChangeStage { int chageToStageId } to the world, this loader system look for it then "consume" by destroying it and at the same time load the level. You have implemented a simple on demand system. Then anything that looks to do anything with the stage must state [UpdateAfter(LoaderSystem)] and anything that looks to change stage must be Before so it would be consumed in a single frame if you care.

    This will get tedious to do it proper as it seems almost all systems would like to work with a stage in some way. And you will have more like this like monster loader system that comes a bit after the stage, then many systems may need After both stage and monster loader to do its work like a system that make the monster jump off stage platform.

    So you instead use [UpdateInGroup] and put all other systems in "worker group" and your stage loader system + monster loader system in "loader group". You then have to only order the group and put both in Simulation group. Monster loader system then just have to specify After stage loader. By dumping all other systems in worker group you ensured everything (usually on demand/one-off) has loaded. It is the best practice that you have at least 1 [UpdateInGroup(YourOwnGroup)] on top of each system. Then YourOwnGroup be manually assigned again in Simulation/Presentation/Intiialization.

    It is tempting to put loader type system group in Initialization since the group name sounds like it, but remember that Simulation comes after Mono Update. Putting your loader systems in Simulation allows interfacing easier with MonoBehaviour. The stage loader system that is waiting for command entity may receive commands from UGUI button in the same frame if it is in Simulation, but not if it is in Initialization. You may treat your loader group as a mini initialization inside Simulation.

    But how I would design it properly is not command entity. Probably I will have a singleton with component CurrentStage { int stageId }. This entity won't be destroyed, it is a proper data. It is not just that the properness matters but it could be the same data I use on my HUD that it show "STAGE 1" or something. I would avoid command entity if possible unless it really looks like a command.

    e.g. A claw game I could create command entity that move the claw and consume it to really move it, instead of true data oriented approach that create a data each frame for each button held then compute the final position following all previous data in order like turing machine and you view the tape strip as data. Note that order is an another error prone thing in ECS, but you can still achieve it with recording frame/time timestamp on each entity and sort them after querying back. The command approach do need to ensure there is one command per frame, or multiple if you have a timestamp which occurs first to sort when consuming them. (Or don't care if order doesn't matter)

    Then

    1. If I ensure that the entity is alone in the chunk, I can put change filter on type CurrentStage and whenever anyone change the stage number that stage is loaded once.
    2. If the entity is not alone then you risk reloading the stage anytime something else became dirty in the chunk. I can put system state component data LoadedStage { int stageId } on it. The system still have change filter but each time any change occurs I diff with the LoadedStage if CurrentStage is different or not. Load and set the LoadedStage if it is.
     
    Sarkahn likes this.
  9. jwvanderbeck

    jwvanderbeck

    Joined:
    Dec 4, 2014
    Posts:
    825
    As you say it feels weird, but I did consider this. The problem though is how do I orchestrate a series of jobs to run in sequence of each other?

    To generate a new map, I need to roughly do the following:
    1) Pick random points and generate an initial voronoi diagram from those points (Single thread Job)
    2) Calculate the centroid for each cell in the diagram (Multi thread job)
    3) Generate a new diagram from the centroid points (Single thread job)
    4) Repeat 2 & 3 N amount of times (known N)
    5) Calculate final centroid positions ( multi thread job)
    6) From those final voronoi sites, generate MapCell entities with appropriate components (center point, edges, neighbors, misc map data etc)

    What I am super confused on is even if I have a system running waiting for something to trigger it to start this process, how do I orchestrate multiple job systems to run sequentially after each other?

    EDIT APPENDAGE:
    Ok so doing some more reading, surfing, head banging. I think what I should be doing, is essentially building a series of jobs dependencies to run.
    So user presses "Generate Map" or whatever in the UI (non ECS) and that then sets some component on some entity that tells some system (heh) to start generating a map. That system then executes and the system essentially builds up a list of jobs to do each of the steps above, marking each job as dependent on the other. Then fires them off and lets them go.

    I think.

    Can jobs be run outside of systems?
     
    Last edited: Jan 5, 2020
  10. 5argon

    5argon

    Joined:
    Jun 10, 2013
    Posts:
    1,555
    C# Jobs was created before ECS and could do job handle deps. So you could.

    But with ECS it allows you to mindlessly throw jobs around and they may or may not wait on prior one appropriately. So to orchestrate sequential jobs you should do it in system. (return deps in the OnUpdate is the magic to allow this)

    Your 1. single thread job would have write access to Cell. Your 2. multithreaded job have read from Cell and write to Centroid. If you specify the system with 2. to update after 1., you have already orchastrate the jobs because job 2. can't start until prior write access to the component it wants to read has finished. Remember that the code says .Schedule, an actual run will be by ECS lib to decide should they be parallel or in sequence of prior one.

    Later, you may have an another system that also read from Cell but generate something else that is not Centroid. (e.g. assign Color based on randomized coordinate) then this system could say after 1. like the system with 2., has no mention to the system with 2., and ended up running in parallel with 2. since ganging up for read access has no problem.

    Much later you want to use Centroid and Color at the same time maybe for rendering. You make a new system that has read on Centroid and Color and put after systems that write to them (or easier just put it in Presentation group). It will now wait for all jobs that has write to Centroid and Color.

    As you see the key to this automatic planning is an existence of different kind of components and that the job know read or RW intent to each. If you do it out of system it would follow strictly on your chained JobHandle and not able to adapt as much. Using system along with ECS make component concept exist.

    Many of these features are possible because C# Jobs is a foolproof system that stripped messy cases away (job spawning jobs, stopping jobs midway, etc.) e.g. if jobs could spawn more jobs it would be difficult for the system to see should it really wait or go ahead. Making it easier to obtain performance (but not the highest ceiling as would be possible with true threading with no safety).
     
    Last edited: Jan 5, 2020
  11. MegamaDev

    MegamaDev

    Joined:
    Jul 17, 2017
    Posts:
    77
    You can do that?! Dang! That's good to know.