AmigaPorts / ACE

Amiga C Engine
Mozilla Public License 2.0
158 stars 26 forks source link

Add entity/component system? #154

Open tehKaiN opened 2 years ago

tehKaiN commented 2 years ago

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:

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:

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):

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 commented 2 years ago

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:

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:

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.

timfel commented 1 year ago

Just stumbled across this. Having had some discussions at Uni with someone who is doing research in that space, I came away with the feeling that ECS systems really do not play to the strengths of the Amiga's hardware - like you say, they don't really encourage you interleave coprocessor operations with other CPU-handled component operations, for example. I guess it can be done, just that it really would need some thought to make sure not to waste cycles.