Slicing and Dicing Components for Unity ECS
This is the second article spawned by my exploration of Unity ECS in general and Project Tiny specifically.
If you missed my first article, don’t you worry, they are not really connected, but you might want to check it out anyways 😉.
This article will be based on my personal experience and believes. So take it with a grain of salt. Also it will be based on the sample code from Project Tiny. I have huge respect for people behind the project and I don’t want to pick on the code. My intention is to provide another view on the topic of component design and how it can influence performance, memory footprint and readability of the code.
Based on my first article I decided to create a small tool for analysing component design. Here is a screenshot showing how it looks like if applied to DragAndDrop sample project of Project Tiny:
We can see that DragAndDrop project has 7 components, two of them have non optimal memory layout, however in this article I would like to concentrate on the amount of values a component holds.
I personally tend to keep the number of values in a component below two. I like to say that components are the atomic parts of your data. Keeping them slim, gives us the possibility to combine them in a very rich and surprising ways, which normally pays off in the long run.
When I look at the components listed above, I see 4, 5, 8, 11 and even 13 values combined together in a single component. Lets go through those components and analyse, if they could be broken down in more atomic pieces. Afterwards we will analyse, how breaking them down might harm, or benefit overall performance, memory layout and code readability.
public struct PuzzleConfiguration : IComponentData
{
public float PieceDragSnapDistance;
public int WidthPieceCount;
public int HeightPieceCount;
public bool IsCompleted;
}
PuzzleConfiguration
is a component which holds values relevant for the puzzle. This component will be set on the config entity. Every Project Tiny game has a config entity, which can be accessed through TinyEnvironment
. I believe it is generally a good decision to have a single easily accessible config entity, as every game needs a config. That sad I think that the PuzzleConfiguration
component itself is overloaded. It is a “kitchen-sink” component, we end up putting unrelated things into it and such components tend to grow dramatically, when our games evolve.
I believe it is important to break down components into logical units.
For example as following:
public struct PuzzleDimensions : IComponentData
{
public int WidthPieceCount;
public int HeightPieceCount;
}
public struct PuzzlePieceDragSnapDistance : IComponentData
{
public float Value;
}
public struct PuzzleIsCompletedFlag : IComponentData
{
}
I broke down this one component into three, which will still all live on the config entity, but have a more logical grouping and even occupy 1 byte less memory as a Flag component (data component without fields) is reflected just as a bit flag and otherwise occupies 0 memory, where a boolean value occupies 1 byte. As PuzzleConfiguration
component is meant to be set only on one entity, the memory footprint gain is ridiculous, but the code readability can be substantially improved. When we see a system, or a job which is dependent on the PuzzleConfiguration
, we need to read the code further to understand what exactly it does. However if we see a system / job checking for PuzzleIsCompletedFlag
, or PuzzlePieceDragSnapDistance
this is a big indicator for the logic behind the system.
public struct SelectionCursor : IComponentData
{
public Entity SelectedEntity;
public bool IsVisible;
public float ScaleAnimationTimer;
public bool IsLocked;
}
SelectionCursor
component also supposed to be set on only one entity. An entity, which represents the selection of a puzzle piece by virtual cursor (virtual cursor is used for the case, when player is using keyboard to select and move the puzzle pieces around).
A careful reader might ask, why did my tool identify the component as holding 5 values even though it clearly has only 4 fields. That’s simple, my tool counts the number of actual values, Entity
is itself a struct which holds two properties. This is why the number of values is 5.
How would I slice and dice this component?
I would go with a rather radical approach:
public struct SelectionCursor : IComponentData
{
}
public struct SelectedWithVirtualCursorFlag : IComponentData
{
}
public struct SelectionCursorIsVisibleFlag : IComponentData
{
}
public struct SelectionCursorIsLockedFlag : IComponentData
{
}
public struct SelectionCursorScaleAnimationTimer : IComponentData
{
public float Value;
}
SelectionCursor
itself becomes just a flag. Its sole purpose is to identify the entity which represents the selection.
SelectedWithVirtualCursorFlag
is added to the “puzzle piece” entity which is selected. One puzzle piece is always “selected” even though the selection is not always visible.
SelectionCursorIsVisibleFlag
identifies if the selection is visible, it can be set on the “selection” entity.
SelectionCursorIsLockedFlag
identifies if the space button is pressed and the arrow movement suppose to move the puzzle piece rather than the selection switching from one puzzle piece to another.
SelectionCursorScaleAnimationTimer
is the only actual data component, which is needed for selection scale animation.
I warned you, this is a rather aggressive refactoring. It means that we would need to refactor the system also rather dramatically, hopefully introducing improved code readability. However what is interesting in this case is the change in performance characteristics.
By breaking down a big component into multiple small ones and eliminating boolean properties in favour of flag components, we reduced the memory footprint, but we also introduced a much more complex entity management for the underlying engine. From Unity ECS documentation:
The EntityManager organizes unique combinations of components appearing on your entities into Archetypes. It stores the components of all entities with the same archetype together in blocks of memory called Chunks. The entities in a given Chunk all have the same component archetype.
While changing selection form one puzzle piece to another, we will remove SelectedWithVirtualCursorFlag
component from one entity and add it to another. This means that Archetype of those both components will be changed and the entities will be moved form one chunk to another. This is probably more expensive than just replacing values in a component. Sadly I am not knowledgeably enough to answer how much more expensive. Maybe somebody from Unity ECS development team will care to comment 😉.
All in all, adding and removing components should be more computationally expensive than just replacing values on a component.
public struct DragAnimation : IComponentData
{
public Entity SpriteRenderer;
public float AnimationProgress;
public float DefaultScale;
public float DraggedScale;
public bool IsSnapped;
public int DefaultSortOrder;
public int DraggedSortOrder;
}
This component is set on every puzzle piece. It has 7 properties (8 values) and in my opinion has another strong anti pattern. Components DefaultScale
, DraggedScale
and DefaultDraggedOrder
have same values for every entity and should be considered general configuration and not part of the runtime changeable state. We can also start discussion about IsSnapped
property, but I think we talked enough about the benefits and pitfalls of boolean properties vs. flag components in previous section.
So being radical as I am let’s break it down as following:
public struct SpriteRenderer : IComponentData
{
public Entity Reference;
}
public struct AnimationProgress : IComponentData
{
public float Value;
}
public struct PuzzlePieceScaleValues : IComponentData
{
public float DefaultScale;
public float DraggedScale;
}
public struct PuzzlePieceIsSnappedFlag : IComponentData
{
}
public struct PuzzlePieceDefaultSortOrder : IComponentData
{
public int Value;
}
public struct PuzzlePieceDragSortOrder : IComponentData
{
public int Value;
}
Where SpriteRenderer
, AnimationProgress
, PuzzlePieceIsSnapped
and PuzzlePieceDefaultSortOrder
are set on “puzzle piece” entities and PuzzlePieceScaleValues
PuzzlePieceDragSortOrder
are set on the the “config” entity.
public struct ButtonReplay : IComponentData
{
public SceneReference SceneToLoad;
}
There is actually nothing we can do about this component, it has only one field and it boils down to 11 values, because SceneReference
wraps Guid
struct, which has 11 fields (which is justified).
public struct Draggable : IComponentData
{
public float3 DragOffset;
public bool InMouseDrag;
public bool InKeyboardDrag;
public int TouchID;
public bool IsLocked;
public float KeyboardDragMoveSpeed;
public float3 DragStartPosition;
public float2 Size;
}
Draggable
has 8 fields resulting in 13 values. IMHO this component is a bit of a “kitchen-sink” again. It is added to every “puzzle piece” entity and it has fields, which will never be set at the same time. For example InMouseDrag
, InKeyBoardDrag
andTouchId
are mutually exclusive. There can be only one way of interaction at a certain point in time. KeyboardDragMoveSpeed
and Size
are configurations and should be set on “config” entity. IsLocked
seems to be a duplicate with SelectionCursorIsLockedFlag
or SelectionCursorIsLockedFlag
. So again IMHO it would be better to break this monolith apart and reduce memory footprint + increase code readability.
As I mentioned in the beginning of this article, all advice is based on my personal opinions, so please take it with a grain of salt. I am always happy to consider other opinions.
Thank you very much for reading!!! To give you a bit of a spoiler for the next article. It will be about improving developer experience by providing method extensions on Entity
struct and a bit more.