An introduction to ECS by example

In this post, I'm taking a closer look at how an ECS system works, talking about the pros and cons of different approaches, and trying to answer if it's something that could be used outside of gaming.

What is an ECS

ECS is a programming pattern commonly used in high-end game engines, simulations, visual effects and CAD/CAM software, among others. The acronym stands for Entity-Component-System, and it forms the foundation of the ECS architecture. Unlike the traditional object-oriented programming approach based on inheritance, this architecture uses composition. While it's not as well-known for smaller projects, ECS is a go-to choice for top-tier applications where performance is of utmost importance. This is because it efficiently utilizes CPU instruction and data caches.

In the following sections, we'll explore the ECS design of a basic game.

Components

To create basic movement, two main components are used: transformations and movement. Transform2d enables entities to be positioned within the world, while Move handles updates to movement. These components can be represented as plain objects.

type
  Move* = object
    direction*: Vec2
    speed*: float32

  Transform2d* = object
    world*: Mat2d      # Matrix relative to the world
    translation*: Vec2 # local translation relative to the parent
    rotation*: Rad
    scale*: Vec2
    children*: array[10, Entity]

You might be wondering why we're opting for an array[10, Entity] instead of using types like seq that reference memory. The reason is that using such types would violate the principle of data locality, which is a fundamental requirement of a strict ECS implementation.

Storing components

To maintain data locality in a strict ECS architecture, all components are stored in linear arrays. It's worth noting that, for now, these arrays are sparsely populated and therefore not space-efficient. The explanation for their indexing can be found in the section on Populating the World.

type
  Array*[T] = object
    data: ptr array[maxEntities, T]

  World* = object
    moves*: Array[Move]
    cameraShake*: ref Shake
    transforms*: Array[Transform2d]

It's worth noting that the cameraShake component, being a singleton, utilizes a ref instead.

It's worth mentioning that creating a custom heap array of a fixed size that's automatically managed by the memory system is quite easy in Nim. Additionally, if you want to learn more, you can refer to the official documentation.

I manually declare an enum value for each component, which is used to establish a "has-a" relationship. The section on Entity's signature explores the usage of these values in more detail.

type
  HasComponent* = enum
    HasMove,
    HasShake,
    HasTransform2d

Entities

The Entity is a unique identifier that represents a distinct item in the world. Its implementation is as follows:

type Entity* = distinct uint16

This imposes a limitation on the maximum number of entities that can exist, which will be discussed later.

Association

Transforms can have child transforms attached to them, which is utilized to group entities into larger structures (such as a character being a hierarchy of body parts). A scene graph offers a mechanism to transform a child node's transform relative to its parent node's transform.

How can a child be linked to its parent? The answer is by using their Entity handle.

type
  Transform2d* = object
    ...
    children*: array[10, Entity]

However, this method sets a strict constraint on the number of children that can be linked to a parent. In the Unconstrained Hierarchies section, I explain how to surpass this restriction.

Entity management

To ensure that an entity is referencing valid data, a specialized data structure called a SlotTable can be used. When you insert a value into the SlotTable, you receive a unique key that can be used to retrieve the value.

var st: SlotTable[string]
let ent: Entity = st.incl("Banana")

assert st[ent] == "Banana"
echo ent # Entity(i: 0, v: 1)

A SlotTable ensures that keys to deleted values will no longer function by increasing a counter. This means that the version of the internal slot corresponding to the value and that of the key must match. When a value is erased, the slot's version is incremented, rendering the key invalid.

This functionality is achieved by storing the version number in the upper bits of the integer. To retrieve the version number of a key, bitwise operations are used:

template version(e: Entity): untyped = e.uint16 shr indexBits and versionMask

var st: SlotTable[string]
let ent1 = st.incl("Pen")

st.del(ent1)
echo ent1 in st # false
echo ent1.version # 1

If a larger number of entities is needed, a wider unsigned type can be used to accommodate more bits for indexing. In such cases, a SparseSet can be used to store the components. This data structure keeps the values in a dense internal container, making it efficient for storing sparsely populated arrays.

Entity's signature

The SlotTable is utilized to store a dense sequence of set[HasComponent], which represents the signature for each entity. A signature is essentially a bitset that describes the component composition of an entity. The role of signatures is explained further in the Systems section.

type
  World* = object
    signatures*: SlotTable[set[HasComponent]]
    ...

Populating the world

The entity obtained from the SlotTable serves as an index for the "secondary" component arrays, which may contain holes due to entities being created and deleted. The SlotTable recycles entities as they become available.

var st: SlotTable[string]
let ent1 = st.incl("Pen")
let ent2 = st.incl("Pineapple")
st.del(ent1)
let ent3 = st.incl("Apple")

echo ent1 in st # false
echo ent1 # Entity(i: 0, v: 1)
echo ent2 # Entity(i: 1, v: 1)
echo ent3 # Entity(i: 0, v: 3)

One way to create a new entity with Transform2d and Move components is to insert {HasTransform2d, HasMove} into the signatures. Then, using the index of the entity returned by the SlotTable, you can set the corresponding items in the world.transforms and world.moves arrays.

template idx*(e: Entity): int = e.int and indexMask

var world: World
let ent = world.signatures.incl({HasTransform2d, HasMove})
world.transforms[ent.idx] = Transform2D(world: mat2d(), translation: vec2(0, 0),
    rotation: 0.Rad, scale: vec2(1, 1))
world.moves[ent.idx] = Move(direction: vec2(0, 0), speed: 10'f32)

Unconstrained Hiearchies

Efficient implementation of the one-to-many association between a parent Transform2D and its children can be achieved through the use of another component, the Hierarchy. The traversal of the Hierarchy is explained in the Systems section.

type
  Hierarchy* = object
    head*: Entity        # the first child, if any.
    prev*, next*: Entity # the prev/next sibling in the list of children for the parent.
    parent*: Entity      # the parent, if any.

The standard textbook algorithm for prepending nodes in a linked list has been adapted to work with the Entity type instead of pointers.

template `?=`(name, value): bool = (let name = value; name != invalidId)
proc prepend*(h: var Array[Hierarchy], parentId, entity: Entity) =
  hierarchy.prev = invalidId
  hierarchy.next = parent.head
  if headSiblingId ?= parent.head:
    assert headSibling.prev == invalidId
    headSibling.prev = entity
  parent.head = entity

Multiple hierarchy arrays can be created, for example, one for the model and another for entity scene graphs.

type
  World* = object
    ...
    modelSpace*: Array[Hierarchy]
    worldSpace*: Array[Hierarchy]

To optimize memory efficiency and iteration speed, it is necessary to sort the hierarchies by parent. This can be achieved by using a SparseSet.

Mixins

Components can be viewed as a mixin idiom, where classes can be "included" rather than "inherited".

proc mixMove*(world: var World, entity: Entity, direction: Vec2, speed: float32) =
  world.signatures[order].incl HasMove
  world.moves[entity.idx] = Move(direction: direction, speed: speed)

Systems

Another bitset called Query encodes the set of components required for operations on entities. When iterating over all entities, only the ones whose signature includes Query are processed, while the rest are skipped.

const Query = {HasTransform2d, HasMove}

proc sysMove*(game: var Game) =
  for entity, signature in game.world.signatures.pairs:
    if Query <= signature:
      update(game, entity)

A performance issue arises when the number of entities is large, as the total iteration cost for all systems can become high. More complex solutions can be implemented to address this problem.

Tags

At times, values are added to HasComponent without a corresponding component. Such values are employed to trigger further processing or signal a result in an efficient manner.

type
  HasComponent = enum
    ...
    HasDirty

Tags are added/removed at run-time without a cost:

proc update(game: var Game, entity: Entity) =
  template transform: untyped = game.world.transforms[entity.idx]
  template move: untyped = game.world.moves[entity.idx]
  
  if move.direction.x != 0.0 or move.direction.y != 0.0:
    transform.translation.x += move.direction.x * move.speed
    transform.translation.y += move.direction.y * move.speed
    
    world.signatures[entity].incl HasDirty

Data is usually transferred between systems by storing it in components. To compute the current world position of each entity after being modified by sysMove:

const Query = {HasTransform2d, HasHierarchy, HasDirty}

iterator queryAll*(parent: Entity, query: set[HasComponent]): Entity =
  var frontier = @[parent]
  while frontier.len > 0:
    let entity = frontier.pop()
    if query <= db.signatures[entity]:
      yield entity
    var childId = hierarchy.head
    while childId != invalidId:
      frontier.add(childId)
      childId = childHierarchy.next

proc sysTransform2d*(game: var Game) =
  for entity in queryAll(game.world, game.camera, Query):
    world.signatures[entity].excl HasDirty
    
    let local = compose(transform.scale, transform.rotation, transform.translation)
    if parentId ?= hierarchy.parent:
      template parentTransform: untyped = world.transforms[parentId.idx]
      transform.world = parentTransform.world * local
    else:
      transform.world = local

To display each entity on the screen, sysDraw accesses transform.world and obtains the current world position of each entity.

Summary

The ECS pattern is particularly useful when processing large amounts of data, and can be applied to many different problem domains. While it can be complex to implement, it offers a high degree of extensibility. Nim's flexibility makes it well-suited for implementing ECS, and its destructors allow for easy implementation of data structures with custom allocators and desired semantics. Thank you for reading, and I hope you found it informative.

Comments

Popular posts from this blog

Using NimScript for your build system

Naylib Goes Mobile: Porting to Android in Just 3 Days!