Search Unity

Hybrid ECS and incorporating Monobehavior APIs

Discussion in 'Entity Component System' started by Abbrew, Aug 27, 2020.

  1. Abbrew

    Abbrew

    Joined:
    Jan 1, 2018
    Posts:
    417
    DOTS support for animation, AI, navigation, and more won't be available for quite some time, so I'll share an architectural overview of how I implemented Hybrid ECS.

    Interfacing between ECS and Monobehavior

    In general, it is better to have as few dependencies between modules as possible. This applies to Hybrid - you can have ECS be aware of Monobehavior or vice-versa, but preferably not both. In my architecture I have Monobehavior depend on ECS, while ECS is only aware of itself in its own bubble.

    The key to having Monobehavior read from and write to ECS is in storing entity fields in Monobehaviors. Below are two scripts for doing so

    Code (CSharp):
    1. using UnityEngine;
    2. using Unity.Entities;
    3. using System.Collections.Generic;
    4.  
    5. [RequiresEntityConversion]
    6. public class EntitySharer : MonoBehaviour, IConvertGameObjectToEntity
    7. {
    8.     [SerializeField]
    9.     private List<EntityStore> store;
    10.  
    11.     public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    12.     {
    13.         var numStores = store.Count;
    14.         for(var i = 0; i < numStores; i++)
    15.         {
    16.             store[i].entity = entity;
    17.         }
    18.     }
    19. }
    Code (CSharp):
    1. using UnityEngine;
    2. using Unity.Entities;
    3.  
    4. public class EntityStore : MonoBehaviour
    5. {
    6.     public Entity entity
    7.     {
    8.         get;
    9.         set;
    10.     }
    11. }
    12.  
    Attach EntitySharer to any convertible GameObject, and drag GameObjects to whom you want to reference the converted entity into the store array. In those GameObjects, annotate Monobehaviors that depend on the entity with.

    Code (CSharp):
    1. [RequireComponent(typeof(EntityStore))]
    and they'll be guaranteed to be able to access the referenced entity.

    Another tenet of good design is to define proper boundaries between modules. We want ECS to do its own thing, and for Monobehaviors to do theirs. More importantly though we want Monobehaviors to act upon and pull data from ECS in a standardized way. To accomplish the former, have the Monobehavior send messages, and to have the former, have the Monobehavior read from its entity field in EntityStore.

    Implementation-wise there will be XMessageSystems that query for message entities. These message entities contain an XMessageComponent which must always include an Entity field named userEntity, and possible additional data. However, the majority of the semantics come from the name of the message component. For example, if a Monobehavior wants a user to calculate an encapsulating polygon for its perceived enemies, have it send a CalculateEnemyBoundsMessageComponent (with the userEntity being the entity from EntityStore), and CalculateEnemyBoundsMessageSystem will handle the ECS effect.

    For reading from entity, use World.DefaultGameObjectInjectionWorld's EntityManager to read from the entity in EntityStore.

    Now that we have clearly defined ways of ECS and Monobehavior interacting, we can apply it to a specific use case: gluing together a Monobehavior-based asset and ECS

    Incorporating Monobehavior code

    We will be using Eric Begue's Pandabehavior asset. Link is here: http://www.pandabehaviour.com/?page_id=23. The asset provides a lightweight but powerful behavior tree framework. The only issue? It's not DOTS compatible. We'll solve that.

    Use these scripts for managing behavior tree assets

    Code (CSharp):
    1. using UnityEngine;
    2. using Unity.Entities;
    3. using Panda;
    4. using System.Collections;
    5.  
    6. [RequireComponent(typeof(EntityStore))]
    7. [RequireComponent(typeof(BehaviorTreeAILinkerAggregator))]
    8. public abstract class BehaviorTreeBaseAILinker : MonoBehaviour, IBehaviorTreeAILinker
    9. {
    10.     protected TextAsset ai;
    11.  
    12.     public TextAsset GetAI()
    13.     {
    14.         return ai;
    15.     }
    16.  
    17.     protected EntityManager EM
    18.     {
    19.         get;
    20.         private set;
    21.     }
    22.  
    23.     protected Entity User
    24.     {
    25.         get;
    26.         private set;
    27.     }
    28.  
    29.     protected void SendMessage<T>(T message)
    30.         where T : struct, IComponentData
    31.     {
    32.         if (Task.current.isStarting)
    33.         {
    34.             CreateMessage(message);
    35.         }
    36.         else
    37.         {
    38.             Task.current.Succeed();
    39.         }
    40.     }
    41.  
    42.     protected void AttachMessage<T>(T message)
    43.         where T : struct, IComponentData
    44.     {
    45.         EM.AddComponentData(User, message);
    46.     }
    47.  
    48.     protected virtual void Start()
    49.     {
    50.         EM = World.DefaultGameObjectInjectionWorld.EntityManager;
    51.         User = GetComponent<EntityStore>().entity;
    52.     }
    53. }
    Code (CSharp):
    1. using UnityEngine;
    2. using Panda;
    3. using UnityEditor;
    4. using System.Collections.Generic;
    5. using System.IO;
    6.  
    7. [RequireComponent(typeof(PandaBehaviour))]
    8. public class BehaviorTreeAILinkerAggregator : MonoBehaviour
    9. {
    10.     public void ImportAILinkers()
    11.     {
    12.         var ai = GetComponent<PandaBehaviour>();
    13.  
    14.         var aiLinkers = GetComponents<IBehaviorTreeAILinker>();
    15.         var numAILinkers = aiLinkers.Length;
    16.  
    17.         var aiScripts = new List<TextAsset>();
    18.         for (var i = 0; i < numAILinkers; i++)
    19.         {
    20.             var aiLinker = aiLinkers[i];
    21.             var aiLinkerClassName = aiLinker.GetType().Name;
    22.             var aiScriptName = aiLinkerClassName.Replace("Linker", "");
    23.             var guids = AssetDatabase.FindAssets(aiScriptName);
    24.             var numGuids = guids.Length;
    25.             for(var j = 0; j < numGuids; j++)
    26.             {
    27.                 var guid = guids[j];
    28.                 var path = AssetDatabase.GUIDToAssetPath(guid);
    29.                 if (Path.GetFileName(path).Equals(aiScriptName + ".txt"))
    30.                 {
    31.                     var aiScript = (TextAsset)AssetDatabase.LoadAssetAtPath(path, typeof(TextAsset));
    32.                     aiScripts.Add(aiScript);
    33.                 }
    34.             }
    35.         }
    36.  
    37.         var numAIScripts = aiScripts.Count;
    38.         ai.scripts = new TextAsset[numAIScripts];
    39.  
    40.         for(var i = 0; i < numAIScripts; i++)
    41.         {
    42.             ai.scripts[i] = aiScripts[i];
    43.         }
    44.     }
    45. }
    46.  
    Code (CSharp):
    1. using UnityEngine;
    2. using UnityEditor;
    3.  
    4. [CustomEditor(typeof(BehaviorTreeAILinkerAggregator))]
    5. public class BehaviorTreeAILinkerAggregatorEditor : Editor
    6. {
    7.     public override void OnInspectorGUI()
    8.     {
    9.         DrawDefaultInspector();
    10.  
    11.         BehaviorTreeAILinkerAggregator script = (BehaviorTreeAILinkerAggregator)target;
    12.         if (GUILayout.Button("Import AI Linkers"))
    13.         {
    14.             script.ImportAILinkers();
    15.         }
    16.  
    17.         Repaint();
    18.     }
    19. }
    The XAILinker class inheriting from BehaviorTreeBaseAILinker must be accompanied by an XAI .txt file describing the behavior tree. Use BehaviorTreeAILinkerAggregator to inject the the behavior trees into the Pandabehavior script hassle-free. The references to the entity in ECS is handled.

    An example of a Monobehavior script that consolidates these features is shown below:

    Code (CSharp):
    1. using Panda;
    2.  
    3. public class MountingAILinker : BehaviorTreeBaseAILinker
    4. {
    5.     /*
    6.      * Call this twice, once when deciding whether to go to mount,
    7.      * and once right before mounting
    8.      */
    9.     [Task]
    10.     public bool MountIsAvailable()
    11.     {
    12.         var mounting = EM.GetBuffer<MountingNearbyAvailableComponent>(User);
    13.         return mounting.Length > 0;
    14.     }
    15.  
    16.     [Task]
    17.     public void AttemptMounting()
    18.     {
    19.         SendMessage(new MountMountingMessageComponent
    20.         {
    21.             mounterEntity = User
    22.         });
    23.     }
    24.  
    25.     [Task]
    26.     public void AttemptDismounting()
    27.     {
    28.         SendMessage(new MountDismountMessageComponent
    29.         {
    30.             dismounterEntity = User
    31.         });
    32.     }
    33.  
    34.     [Task]
    35.     public bool IsMounted()
    36.     {
    37.         var mount = EM.GetComponentData<MountUserMountComponent>(User);
    38.         return mount.hasMount;
    39.     }
    40. }
    41.  
    The value of having Monobehavior handle the AI decision-making and ECS handle the computation goes further than just integrating a missing feature. The behavior tree, Pandabehavior in particular, offers unexpected OOP potential. For example, you could simulate polymorphism by referencing an undefined behavior tree in one script, and leave implementation to other scripts. The way Pandabehavior hides behavior tree nodes behind tree definitions is in itself abstraction in action.

    Back to our Hybrid ECS discussion, we can see that one can easily have Monobehavior peer into the ECS bubble and make changes to or read data from it.

    Testing

    We group features by Authorings, so we will test by Authorings. An Authoring translates to multiple Components, whose owning Entity will be queried by Systems. For this reason we'll define unit tests as allowing a small subset of Systems to run, and integration tests as allowing all Systems to run. In either case the test input will be GameObjects with the specific Authoring atta
    Code (CSharp):
    1. using NUnit.Framework;
    2. using Unity.Entities;
    3. using UnityEngine;
    4. using UnityEditor;
    5.  
    6. [TestFixture]
    7. public abstract class TestHarnessBase
    8. {
    9.     private World testWorld;
    10.     private EntityManager em;
    11.  
    12.     private static readonly string PATH_TO_TEST_SUITES = "Assets/MyAssets/Tests/Suites/";
    13.  
    14.     #region Setup
    15.  
    16.     [SetUp]
    17.     public void SetUp()
    18.     {
    19.         testWorld = World.DefaultGameObjectInjectionWorld;
    20.         em = testWorld.EntityManager;
    21.         WorldSetup();
    22.  
    23.         PreConversionSetup();
    24.  
    25.         ConvertRegisteredTestEntities();
    26.     }
    27.  
    28.     protected abstract void WorldSetup();
    29.  
    30.     protected abstract void PreConversionSetup();
    31.  
    32.     protected void RegisterTestEntity(
    33.         string name,
    34.         string testPrefabPathRelativeToSuites
    35.     )
    36.     {
    37.         var fullTestPrefabPath = PATH_TO_TEST_SUITES + testPrefabPathRelativeToSuites;
    38.         var testPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(fullTestPrefabPath);
    39.         var testGameObject = Object.Instantiate(testPrefab);
    40.         var testEntityAuthoring = testGameObject.AddComponent<TestEntityAuthoring>();
    41.         testEntityAuthoring.SetTestEntityName(name);
    42.     }
    43.  
    44.     protected void RegisterTestEntityWithBehaviorTree(
    45.         string name,
    46.         string testPrefabPathRelativeToSuites,
    47.         string testBehaviorTreeGOPathRelativeToSuites
    48.     )
    49.     {
    50.         var fullTestPrefabPath = PATH_TO_TEST_SUITES + testPrefabPathRelativeToSuites;
    51.         var testPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(fullTestPrefabPath);
    52.         var testGameObject = Object.Instantiate(testPrefab);
    53.  
    54.         var fullTestBehaviorTreeGOPath = PATH_TO_TEST_SUITES + testBehaviorTreeGOPathRelativeToSuites;
    55.         var testBehaviorTreePrefab = AssetDatabase.LoadAssetAtPath<GameObject>(fullTestBehaviorTreeGOPath);
    56.         var testBehaviorTreeGO = Object.Instantiate(testBehaviorTreePrefab);
    57.  
    58.         var testEntityAuthoring = testGameObject.AddComponent<TestEntityAuthoring>();
    59.         testEntityAuthoring.SetTestEntityName(name);
    60.         testEntityAuthoring.SetTestBehaviorTreeGameObject(testBehaviorTreeGO);
    61.     }
    62.  
    63.     private void ConvertRegisteredTestEntities()
    64.     {
    65.         testWorld.GetOrCreateSystem<GameObjectConversionGroup>().Update();
    66.     }
    67.  
    68.     #endregion
    69.  
    70.     #region Actions
    71.  
    72.  
    73.  
    74.     #endregion
    75.  
    76.     #region Assertions
    77.  
    78.     protected void AssertTestEntityHasComponent<T>(string name, T component)
    79.         where T : struct, IComponentData
    80.     {
    81.         Assert.IsTrue(em.GetComponentData<T>(TestEntityRegistry.GetTestEntity(name)).Equals(component));
    82.     }
    83.  
    84.     protected void AssertTestEntityComponentExists<T>(string name)
    85.         where T : struct, IComponentData
    86.     {
    87.         Assert.IsTrue(em.HasComponent<T>(TestEntityRegistry.GetTestEntity(name)));
    88.     }
    89.  
    90.     protected void AssertTestEntityBehaviorTreeSucceeded(string name)
    91.     {
    92.         var testEntity = TestEntityRegistry.GetTestEntity(name);
    93.     }
    94.  
    95.     #endregion
    96.  
    97.     #region TearDown
    98.  
    99.     [TearDown]
    100.     public void TearDown()
    101.     {
    102.      
    103.     }
    104.  
    105.     #endregion
    106. }
    Code (CSharp):
    1. using Unity.Entities;
    2. using UnityEngine;
    3.  
    4. [RequiresEntityConversion]
    5. public class TestEntityAuthoring : MonoBehaviour, IConvertGameObjectToEntity
    6. {
    7.     private string testEntityName;
    8.     private GameObject behaviorTreeGameObject;
    9.  
    10.     public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    11.     {
    12.         TestEntityRegistry.RegisterTestEntity(testEntityName, entity);
    13.         if(behaviorTreeGameObject != null)
    14.         {
    15.             var entityStore = behaviorTreeGameObject.GetComponent<EntityStore>();
    16.             entityStore.entity = entity;
    17.         }
    18.     }
    19.  
    20.     public void SetTestEntityName(string testName)
    21.     {
    22.         this.testEntityName = testName;
    23.     }
    24.  
    25.     public void SetTestBehaviorTreeGameObject(GameObject behaviorTreeGameObject)
    26.     {
    27.         this.behaviorTreeGameObject = behaviorTreeGameObject;
    28.     }
    29. }
    30.  
    Code (CSharp):
    1. using System.Collections.Generic;
    2. using Unity.Entities;
    3. using UnityEngine;
    4.  
    5. public class TestEntityRegistry : MonoBehaviour
    6. {
    7.     private Dictionary<string, Entity> namesToTestEntities;
    8.  
    9.     private static TestEntityRegistry instance;
    10.  
    11.     public static void RegisterTestEntity(string name, Entity testEntity)
    12.     {
    13.         if (instance.namesToTestEntities.ContainsKey(name))
    14.         {
    15.             throw new UnityException($"A test entity with name {name} already exists");
    16.         }
    17.         instance.namesToTestEntities.Add(name, testEntity);
    18.     }
    19.  
    20.     public static Entity GetTestEntity(string name)
    21.     {
    22.         return instance.namesToTestEntities[name];
    23.     }
    24.  
    25.     private void Awake()
    26.     {
    27.         namesToTestEntities = new Dictionary<string, Entity>();
    28.         instance = this;
    29.     }
    30. }
    31.  
    In your test class inheriting from TestHarnessBase, just register an Entity by supplying its pre-converted GameObject and a name that will be later used to fetch the Entity and make assertions upon it. A typical unit test flow is easy to research; the integration test flow needs explanation

    1. Register an entity by supplying its test name and pre-converted GameObject and non-convertible GameObject containing the Pandabehavior script (The pre-converted GameObject has Authorings that basically set up the state of the Entity in a change-proof way, while the Pandabehavior tree has behavior tree nodes that basically act as assertions and intermediate actions in a change-proof way. You must try to keep the Authorings and behavior tree nodes as close to the source code as possible - avoid making your own test-specific Authoring and behavior tree nodes whenever you can)
    2. Use the (not shown yet) TestHarnessBase.WaitForBehaviorTreeToComplete(string name) method to wait until the associated Pandabehavior script either succeeds or fails.
    3. Assert if the script succeeds or fails

    Conclusion



    You can achieve an effective, structured and understandable Hybrid ECS flow by organizing your code and utilizing Pandabehavior. I am not a shill for Pandabehavior.
     
    Last edited: Aug 27, 2020
    apkdev, bb8_1, Egad_McDad and 10 others like this.
  2. Abbrew

    Abbrew

    Joined:
    Jan 1, 2018
    Posts:
    417
    There are serious problems with the approach that I've described, due to:
    • The gargantuan archetype chunk space wasted by each unique message component you define
    • Changes from a "Message System" take multiple EntityCommandBufferSystem executions to propagate throughout "Normal Systems". If you're using PandaBehavior, then you have to write some polling code to check if the changes have propagated through
    • Using EntityManager from a Monobehaviour is supposedly less performant than doing it from a SystemBase. Thus the following code pattern as seen in
      Code (CSharp):
      1. [Task]
      2.     public bool IsMounted()
      3.     {
      4.         var mount = EM.GetComponentData<MountUserMountComponent>(User);
      5.         return mount.hasMount;
      6.     }
      which is executed every Update is not a good way of polling
    Fortunately, the DataFlowGraph package solves every one of these issues. This package can be adapted to support a message-based architecture in a performant way. I'll write a new post and link it here describing how to replace the ECS portion of this tutorial with DataFlowGraph. If your ECS code has very few systems that update every frame or most frames, and many systems that update in response to events, consider looking into or even adopting DataFlowGraph
     
    apkdev likes this.