ACE icon indicating copy to clipboard operation
ACE copied to clipboard

Add entity/component system?

Open tehKaiN opened this issue 2 years ago • 2 comments

I'm writing this as a reaction to Nivrig's and Kwahu's discussion on Amiga Game Dev discord. Some points of reference for ECS systems:

  • https://github.com/SanderMertens/flecs
  • https://github.com/soulfoam/ecs

The unity-like gameobject system

This thing is better written in OOP languages, so it would need https://github.com/AmigaPorts/ACE/issues/153

Basically the thing used by Unity and most game engines nowadays. The most basic ECS is something like this:

for(AceObject Entity: Entities) {
  for(AceBehavior Component: Entity.Components) {
    Component.process();
  }
}

along with constructors and destructors for them. Now, all ACE managers are already written to have create/process/destroy fns and are sometimes nested and cross-referencing each other so they could be converted to such components.

This is my quick and dirty proposal, I'm not a big fan of it at this point, so it definitely needs more work and thoughts. There are some problems to solve and I'd like to hear some more input about it.

Components of components

If you look at the ACE view/viewport system, it already looks like ECS. Each view has viewports and are processed on the list, so they could be treated as view's components. Then, each viewport has viewport managers - be it camera, simplebuffer, etc. So that would mean that... each viewport can be treated as an entity and have the viewport managers as components. This deep nesting breaks ECS pattern, so something else would be needed.

Instead, it could be solved by limiting it to component system. That means, ACE would expose primitives to manage and process list of components. This way, each class could have its components and those could be nested indefinitely. The code could look something like this:

void insideGameState() {
  view.process();
}

void tView::process() {
  // do your own stuff - as little as possible, preferably everything is in components
  components.process(); // this processes the list of components (here: viewports), calling their process() method
}

// the same pattern is repeated for tVPort::process() and processes viewport managers

then, the view could be a component of the game state and insideGameState() could be just done with gameState.components.process().

The tComponentList could expose following functionality:

  • processing the list of components - just calling their process() method
  • getComponentById() where id is an unique string or enum or int - could be sped up by using std::map<id, tComponent> instead of plain array or std::vector or having both for those two access types, or make it use a vector but emphase that searching for references is slow and should be done in the component constructor or post-construct step
  • getComponentByType(lastSearchPos, type) - the tSimpleBufferManager looks for already present tCameraManager and creates the new one if it isn't found. Unity ECS enforces components to have unique type, but since my proposal skips entity lists, we'd have to work around this limitation, so making this function re-entrant to get next result is a must.
  • perhaps there should be the way to get the component of the parent's parent etc. - e.g. viewport manager should be able to access the view, but that could be done by the vport fields and holding the ref to the parent inside the component. Viewport manager could get the tVPort & arg in its constructor and work from there to the view on its own.

The post-construct step

Finding reference to other components brings another problem - when done in constructors, it will only work with already-constructed components. There would have to be another step to doing post-init jobs like finding references. This could be skipped by making ref search inside process() function very fast, but perhaps it would expose more unexpected problems of same nature later on.

Perhaps the component constructors should to as little as possible and have the dedicated Construct() method which would be called after all lists are filled.

Zero-cost abstractions

The first thing which comes to mind is that tComponentList should hold something following and just call the process() method:

class tComponent {
  virtual void process(void);
  virtual ~tComponent(void);
};

although deriving all components from such class allows for putting different component types into same list, this would introduce some overhead in scenarios where they are all the same (think of viewports inside a view):

  • each object is bigger because it has to store the pointer to its virtual method table,
  • it heavily obstructs compiler's ability to deduce that process() functions are all the same and can be inlined and optimized further

The solution would be to have the whole component system templated:

// require via concept that t_tComponent has process() method, I don't remember the syntax
template<typename t_tComponent>
class tComponentList {
  std::map<tComponentId, t_tComponent> m_mComponents;
};

// no virtual thingies here
class tVPort {
  void process(void);
};

// inside tView class:
tComponentList<tVPort> m_VPorts;

this would result in calling a same process() function in a loop with different objects passed in an arg - perhaps even giving optimization opportunity to unroll it. Which shows that maybe even component storage could be passed to the template - either use std::vector or std::array of predefined size for extra optimizing lists of constant size.

Order of execution is crucial

Currently, view must execute viewports in order of their appearance. Also, there is a strict order for viewport managers execution or else everything explodes. Also, when writing gamestates for most of the games, it quickly becomes apparent that most operations must be done in strict order, so component list must be ordered. They probably could store an ordinal number and be sorted with each addition of component, so that the process() wouldn't have to waste time on determining the order of execution. ACE could define them in hundred- or thousand-increments to allow users adding custom logic in between.

That solves the display, input and other core components, but doesn't solve the game logic components, should ACE bundle them. Their order may vary between projects. Perhaps those should accept ordinal number as a parameter in constructor but that adds a bit of tedious manual management.

Another option would be setting the components when instantiating the list, e.g.

// components here are in correct order of execution
components = new tComponentList({
  new Component1(),
  new Compontent2(),
  new Compontent3()
});

but that prevents instantiating additional components by others (e.g. tSimplebuffer/tScrollbuffer instantiates tCamera). Perhaps someone would like to have enemies or other entities dynamically spawned and managed as a list of components. What then?

Changing which components are executed and reordering them

Some components may need to be occasionally disabled (enemy component is dead, projectile has ended its lifespan and is ready for reuse). Skipping components which have m_isDisabled set to true is a one way to solve this, but having lots of those lists introduces significant overhead in checking if any of them should be skipped. Perhaps there should be a list of enabled components acting as a cache, but updating it should be done on-demand and e.g. once per frame.

Also, in case someone is mad enough to do single-buffered game and have the blittable entities as components, there should be a way to Y-sort the relevant component list so that blits are always done from top to bottom.

Forcing a code layout

This one is big. Shifting ACE into component system would enforce that code layout on all projects instead of allowing each project to have their own less or more hardcoded systems. I don't know how I feel about taking away that degree of freedom from ACE users. This would drastically shift ACE from being a mostly-library framework-ish to being full-blown engine/framework.

Blitter concurrency

I thought this might be a problem, but then if the components are in fixed order then it is possible to have blits scattered across the code, preventing prolonged blitter idle times. Other blitter management methods exist, but I prefer to not prevent any of them as ACE should be as versatile and not limiting tool as possible.

tehKaiN avatar Mar 19 '22 08:03 tehKaiN

Data-oriented ECS system

This approach which I've drafted above is not really an ECS in its true sense as in GitHub projects listed above or described on wiki because:

  • entities should have informations scattered across components, e.g. separate component for position, velocity etc
  • the system then queries the entities which have specific set of components and apply operations on it

The overhead cost of ECS here is going through the list of all entities and finding those matching the required component set. The https://github.com/soulfoam/ecs solves it by defining the component mask for each entity. Matching is done by simple ANDing with specific mask combination. The other approach is to have struct of

This could work and should be integrable with ACE just fine at the present point - it doesn't affect the ACE architecture in any way, because only game logic elements use ECS and the remaining engine code is written as-is. There are some concerns which I have:

  • finding entities which have desired component set is costly. Can be solved by caching relevant lists and process them in fixed way.
  • the data processing is very much batched. In modern systems this allows for SIMD and CPU caches to do its work. On expanded Amiga configurations we have only CPU cache, there is no SIMD
  • all entities on the list should have the same component set - either as classes within entity classes and virtual methods which return them or null, or struct of pointers to component structs - sounds like quite big overhead.

One major upside - when organizing as:

for(auto &Entity: Entities) {
  if(Entity.pBob != null) {
    Entity.pBob->undraw(Entity);
  }
}

for(auto &Entity: Entities) {
  if(Entity.pBob != null) {
    Entity.pBob->saveBg(Entity);
  }
}

for(auto &Entity: Entities) {
  if(Entity.pSteerLogic != null) {
    // Can be class derived from tSteerLogic with virtual process()
    Entity.pSteerLogic->process(Entity);
  }
}

for(auto &Entity: Entities) {
  if(Entity.pBob != null) {
    Entity.pBob->draw(Entity);
  }
}

it makes code quite clean. The downside is that this approach discourages interleaving blitter and CPU work - e.g. requesting drawing of object as soon as its steer logic is processed so that it could be drawn while other one is being calculated. Again, this could be solved with cached lists of entities which are processed by same systems.

Since this system is for game logic only, it doesn't have to be integrated with ACE - and I would skip doing so because falling back to generic approach misses opportunity to optimize for given game project.

tehKaiN avatar Mar 19 '22 13:03 tehKaiN