Unity DOTS Life Tutorial Ent 0.4.0 #5 SharedComponentData & Chunk Components: Update to Ent 0.8.0

Published January 11, 2020 by AntHillPlan
Do you see issues with this article? Let us know.
Advertisement

<<Previous Tutorial Next Tutorial>>

This tutorial covers SharedDataComponent & Chunk Components. This will be an attempted speed optimization by reducing the number of calls to render GameObject by having each GameObject cover a 2x2 square of cells. It will show usage of SharedComponentData and ComponentData used as Chunk Component.

Checked against 2019.3.5f1 & Entities 0.8.0 Preview.8 on March 13, 2020. Packages used are shown here

Note: DOTS Platforms is not the latest. It is only 0.2.1 preview 4 because Entities 0.8.0 does not compile with 0.2.2Burst 1.3.0 preview.6 is also not the latest. preview.5 & preview.7 can cause errors at run time depending on what Entities functions are called.

The source is at https://github.com/ryuuguu/Unity-ECS-Life.git. The Zip file of the project is at http://ryuuguu.com/unity/ECSLifeTutorials/ECSLifeTutorial5.zip

Warning: DOTS is still bleeding edge tech. Code written now will not be compatible with the first release version of DOTS. There are people using the current version DOTS for release games, but they usually freeze their code to a specific package and will not be able to use future features without rewriting existing code first to compile and run with newer versions. So if you need 10,000 zombies active at once in your game this is probably the only way to do it. Otherwise, this tutorial is a good introduction if you think you will want to simulate thousands of entities in the future or just want to get a feel for code this type of thing in Unity.

Each entity will still represent a single cell, but GameObjects will be SuperCells display a 2x2 square of cells. There are 16 materials one for each configuration 4 cells being alive or dead. The ECS simulation will calculate all cells' live state as before, but it will then calculate which of the 16 states each supercell is in. If a supercell has changed it will call the Monobehaviour with the new state of the supercell. The Monobehaviour will then change the material of the supercell renderer to match the state. There is no entity to represent a supercell. Instead, cells will be grouped into chunks with all cells in the same supercell in one chunk. The SuperCellsLives component will be added to each chunk and hold the position of the supercell, its state, and if it has changed. The state will be used as an index into an array of materials by the Monobehaviour. The SharedComponentData SuperCellXY will be associated with the chunk for the supercell at XY. First a word about SharedComponentData. From this forum thread https://forum.unity.com/threads/need-way-to-evaluate-animationcurve-in-the-job.532149/#post-4525402

"Shared component data is really for segmenting your entities into forced chunk grouping. The name is unfortunate I think. Because really if you use it as data sharing mechanism, you will mostly shoot yourself in the foot because often you just end up with too small chunks.

BlobData is just a reference to shared immutable data. BlobData is also easily accessible from jobs and can contain complex data." Joachim Ante

So SuperCellXY will just be used in setting up the supercells and then not used again. This post on the forums about Chunk Components is a good description of Chunk ComponetData. https://forum.unity.com/threads/chunkcomponents.761714/#post-5072456

Chunk data is attached to a chunk archetype this means Chunk data is fragile. The same 4 cells will always be in a single chunk, but which chunk may change. For example, if you add an InitializationTag to do initialization with a special InitializeSystem then remove the InitializationTag, all the entities will move to a new chunk and lose the chunk data. This happens because the archetype of the entity has changed since it no longer has an InitializationTag. Similarly if in your Monobehaviour code setting things up you assign the chunk data then the archetype changes the chunk data will be lost. AddComponetData() changes the archetype. In this tutorial, I set the pos data in the SuperCellLives system and use it in the next system. This is not as efficient as setting it once in the initial setup then assuming the chunk archetype will never change, but it is more robust to future code changes. There are instructions in the code for what to comment and uncomment to make the code faster but less robust.

Now to some code. The ECSGridSuperCell Monobehaviour first setups the SuperCell GameObjects and stores their renderers in an array.

    public void InitSuperCellDisplay() {
       _scale = ( Vector2.one / size);
       _offset = ((-1 * Vector2.one) + _scale)/2;
       _meshRenderersSC = new MeshRenderer[size.x+2,size.y+2];
       materialsStatic = materials;
       var cellLocalScale  = new Vector3(_scale.x,_scale.y,_scale.x) * superCellScale;
       for (int i = 0; i < size.x+2; i++) {
           for (int j = 0; j < size.y+2; j++) {
               var coord = Cell2Supercell(i, j);
               if (coord[0] != i || coord[1] != j) continue;
               var c = Instantiate(prefabMesh, holderSC);
               var pos = new Vector3((1f/superCellScale +i-1) * _scale.x + _offset.x,
                   (1f/superCellScale +j-1) * _scale.y + _offset.y, zLive);
               c.transform.localScale = cellLocalScale;
               c.transform.localPosition = pos;
               c.name += new Vector2Int(i, j);
               _meshRenderersSC[i,j] = c.GetComponent<MeshRenderer>();
           }
       }
   }

   public int2 Cell2Supercell(int i, int j) {
       var pos = new int2();
       pos[0] = (i  / 2) * 2; //(0,1) -> 0, (2,3) -> 2, etc.
       pos[1] = (j  / 2) * 2;
       return pos;
   }

Setting the materials on these renders can not be done directly from the ECS thread because in UpdateSuperCellChangedSystem, job.Run(m_Group) does not run on the main thread and Unity throws an error. "UnityException: SetMaterial can only be called from the main thread." I do not know if this job not being on the main thread even though Run() is used is a temporary 0.4 limitation or by design. So instead the information is buffered and then executed next Update().

 public static void ShowSuperCell(int2 pos,int val) {
       var command = new ShowSuperCellData() {
           pos = pos,
           val = val
       };
       SuperCellCommandBuffer.Add(command);
   }
   
   private static void RunSCCommandBuffer() {
       foreach (var command in SuperCellCommandBuffer) {
           //Debug.Log(" ShowSuperCell: "+ command.pos + " : "+ command.val);
           _meshRenderersSC[command.pos.x,command. pos.y].enabled = command.val != 0;
           if (command.val != 0) {
               _meshRenderersSC[command.pos.x, command.pos.y].material = materialsStatic[command.val];
           }
       }
       SuperCellCommandBuffer.Clear();
   }

Initializing the entities is almost the same as before but with 4 extra actions.

First calculate the relative position of the cell in its supercell encode and place this in SubcellIndex. Then calculate which supercell a cell is in "var pos = Cell2Supercell(i,j);" then add a SharedComponentData<SuperCellXY> with that pos to the instance. This will move the instance to the chunk associate with that SharedComponetData. Then add an AddChunkComponentData<SuperCellLives> which changes the archetype of the instance and is added to the new archetype is it not already attached.

   void InitECS() {
           var entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
           
           _cells = new Entity[size.x+2, size.y+2];
           
           for (int i = 0; i < size.x+2; i++) {
               for (int j = 0; j < size.y+2; j++) {
                   var instance = entityManager.CreateEntity();
                   entityManager.AddComponentData(instance, new Live { value = 0});
                   entityManager.AddComponentData(instance, new PosXY { pos = new int2(i,j)});
                   _cells[i, j] = instance;
               }
           }
           
           for (int i = 1; i < size.x+1; i++) {
               for (int j = 1; j < size.y+1; j++) {
                   var instance = _cells[i, j];
                   
                   entityManager.AddComponentData(instance, new SubcellIndex() {
                       index = ((i)%2) + (((j+1)%2)*2)
                   });
                   entityManager.AddComponentData(instance, new NextState() {value = 0});
                   entityManager.AddComponentData(instance, new Neighbors() {
                       nw = _cells[i - 1, j - 1], n = _cells[i - 1, j], ne =  _cells[i - 1, j+1],
                       w = _cells[i , j-1], e = _cells[i, j + 1],
                       sw = _cells[i + 1, j - 1], s = _cells[i + 1, j], se =  _cells[i + 1, j + 1]
                   });
                   
                   // New code is below here
                   var pos = Cell2Supercell(i,j);
                   entityManager.AddSharedComponentData(instance, new SuperCellXY() {pos = pos});
                   entityManager.AddChunkComponentData<SuperCellLives>(instance);
               }
           }
           InitLive(entityManager);
       }

That is it for the new Monobehaviour code. There are 4 new components. The SharedDataComponent SuperCellXY is only called explicitly in the Monobehaviour code above. SuperCellLives is a Chunk ComponentData because it added with AddChunkComponentData otherwise it just another ComponentData.

 /// <summary>
 /// SharedData Component
 ///   chunks cells into correct chunk
 ///    pos is only used to decide what cell goes into which Chunk
 /// </summary>
 public struct SuperCellXY : ISharedComponentData {
     public int2 pos; // these coordinates are the xMin, yMin corner
 }

 /// <summary>
 /// SuperCellLives
 ///  Chunk Component
 ///  uses lives of cells to calculate image index
 /// </summary>
 public struct SuperCellLives : IComponentData {
     public int index; //index of image to be displayed
     public bool changed;
     public int2 pos;
 }

 /// <summary>
 /// DebugSuperCellLives
 /// used for debugging SuperCellLives since the debugger
 /// is broken for ChunkComponents
 /// </summary>
 public struct DebugSuperCellLives : IComponentData {
     public int4 livesDecoded;
     public int index;
     public bool changed;
     public int2 pos;
 }

 /// <summary>
 /// SubcellIndex
 ///   relative pos of a cell in its SuperCell
 /// </summary>
 public struct SubcellIndex : IComponentData {
     public int index;
 }

DebugSuperCellLives can be added with AddComponentData() for debugging. Unfortunately, chunk ComponentData does not show properly in the debugger so DebugSuperCellLives can be used to view the data since it is a regular component. There is a system to copy the data from SuperCellLives to DebugSuperCellLives. livesDecoded is an extra variable that shows the live value of each cell in the supper cell. [0] = top Left corner, [1] = top right [2] = bottom Left and [3] = bottom Right cell. SubcellIndex is the index value in the livesDecoded array and is calculated once in the ECSGridSuperCell Monobehaviour. It is used to calculate the correct image to be used by the SuperCell Monobehaviour. The SubcellIndex also matches the index of livesDecoded in DebugSuperCellLives.

The chunk related code is in UpdateSuperCellIndexSystem and UpdateSuperCellChangedSystem .

/// <summary>
/// UpdateSuperCellIndexSystem
///     Calculate new image index for SuperCellLives
///     Set pos of SuperCellLives
///     set changed of SuperCellLives
/// </summary>
[AlwaysSynchronizeSystem]
[BurstCompile]
public class UpdateSuperCellIndexSystem : JobComponentSystem {
   EntityQuery m_Group;

   protected override void OnCreate() {
       // Cached access to a set of ComponentData based on a specific query
       m_Group = GetEntityQuery(
           ComponentType.ReadOnly<Live>(),
           ComponentType.ReadOnly<SubcellIndex>(),
           ComponentType.ReadOnly<PosXY>(),
           ComponentType.ChunkComponent<SuperCellLives>()
       );
   }
   
   struct SuperCellIndexJob : IJobChunk {
       [ReadOnly]public ArchetypeChunkComponentType<Live> LiveType;
       [ReadOnly]public ArchetypeChunkComponentType<SubcellIndex> SubcellIndexType;
       [ReadOnly]public ArchetypeChunkComponentType<PosXY> PosXYType;
       public ArchetypeChunkComponentType<SuperCellLives> SuperCellLivesType;

       public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex) {
           var lives = chunk.GetNativeArray(LiveType);
           var SubcellIndices = chunk.GetNativeArray(SubcellIndexType);
           var posXYs = chunk.GetNativeArray(PosXYType);
           
           var scLives = new int4();
           for (var i = 0; i < chunk.Count; i++) {
               scLives[SubcellIndices[i].index] = lives[i].value;
           }
           int index = 0;
           for (int i = 0; i < 4; i++) {
               index +=   scLives[i]<< i;
           }
           
           var pos = new int2();
           pos[0] = (posXYs[0].pos.x / 2) * 2; //(0,1) -> 0, (2,3) -> 2, etc.
           pos[1] = (posXYs[0].pos.y  / 2) * 2;
           
           var chunkData = chunk.GetChunkComponentData(SuperCellLivesType);
           bool changed = index != chunkData.index;
           chunk.SetChunkComponentData(SuperCellLivesType,
               new SuperCellLives() {
                   index = index,
                   changed = changed,
                   // for faster less robust code uncomment the 3 lines at the end of
                   // ECSGridSuperCell.InitECS() around SetChunkComponentData<SuperCellLives>
                   // uncomment the next line and comment the one after
                   //pos = chunkdata.pos
                   pos = pos
               });
       }
   }

   protected override JobHandle OnUpdate(JobHandle inputDependencies) {
       var LiveType = GetArchetypeChunkComponentType<Live>(true);
       var SubcellIndexType = GetArchetypeChunkComponentType<SubcellIndex>(false);
       var SuperCellLivesType = GetArchetypeChunkComponentType<SuperCellLives>();
       var PosXYType = GetArchetypeChunkComponentType<PosXY>();

       var job = new SuperCellIndexJob() {
           SubcellIndexType = SubcellIndexType,
           LiveType = LiveType,
           SuperCellLivesType = SuperCellLivesType,
           PosXYType = PosXYType
       };
       return job.Schedule(m_Group, inputDependencies);
   }
}

/// <summary>
/// UpdateSuperCellChangedSystem
///   Check all SuperCells
///   Call Monobehaviour to update changed SuperCells
/// </summary>
[AlwaysSynchronizeSystem]
[UpdateAfter(typeof(UpdateSuperCellIndexSystem))]
public class UpdateSuperCellChangedSystem : JobComponentSystem {
   EntityQuery m_Group;

   protected override void OnCreate() {
       // Cached access to a set of ComponentData based on a specific query
       m_Group = GetEntityQuery(
           ComponentType.ChunkComponentReadOnly<SuperCellLives>()
       );
   }
   
   struct SuperCellDisplayJob : IJobChunk {
       
       public ArchetypeChunkComponentType<SuperCellLives> SuperCellLivesType;

       public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex) {
           var chunkData = chunk.GetChunkComponentData(SuperCellLivesType);
           if (chunkData.changed) {
              ECSGridSuperCell.ShowSuperCell(chunkData.pos, chunkData.index);
           }
       }
   }

   protected override JobHandle OnUpdate(JobHandle inputDependencies) {
       
       var SuperCellLivesType = GetArchetypeChunkComponentType<SuperCellLives>();

       var job = new SuperCellDisplayJob() {
           SuperCellLivesType = SuperCellLivesType
       };
       job.Run(m_Group);
       return default;
   }
   
}

From top to bottom the chunk related code is

1) In the query ComponentType.ChunkComponent<SuperCellLives>() is used. There is also a ComponentType.ChunkComponentReadOnly<>() method if you are only reading the data.

2) SuperCellIndexJob is an IJobChunk which calls Schedule()

3) The structs are declared to access components using the same generic, ArchetypeChunkComponentType<>, for both regular ComponentData and chunk ComponentData.

4) The execute method signature is Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex), chunk can access all ComponentData either with GetNativeArray() or GetChunkComponentData()

5) Local native arrays are declared for regular ComponetData. var lives = chunk.GetNativeArray(LiveType);

6) I know that there will always be 4 entities per chunk because exactly 4 entities are assigned the same SharedDataComponent in the setup. So it is safe for scLives to be a fixed length array of int4.

var scLives = new int4();
for (var i = 0; i < chunk.Count; i++) {
   scLives[SubcellIndices[i].index] = lives[i].value;
}

7) SetChunkComponentData() is used to assign new chunk ComponetData.

8) In the OnUpdate() GetArchetypeChunkComponentType<Live>() is used to assign ComponentType structs.

9) In the Schedule() the EntityQuery and dependencies are passed

10) UpdateSuperCellChangedSystem runs its job on the main thread and passes the EntityQuery in the Run() call.

In conclusion, SharedDataComponent is not for sharing data but for partitioning instances into chunks. Chunk ComponentData contains data that refers to a single chunk but can be fragile since things that intuitively would not affect it can clear the data. Specifically changing an instance's archetype by adding or removing component data such as an empty flag component will clear the data by changing the archetype and moving the instance to a new chunk. As an optimization, this failed because changing a Material is expensive, but I feel it was still worth testing.

Next Tutorial>> ECS Unit Testing

Cancel Save
0 Likes 0 Comments

Comments

Nobody has left a comment. You can be the first!
You must log in to join the conversation.
Don't have a GameDev.net account? Sign up!
Advertisement