Design Patterns – OOP ECS vs Pure ECS in Game Development

design-patternsentity-component-systemgame developmentobject-oriented

Firstly, I am aware that this question links with the topic of game development but I have decided to ask it here since it really comes down to a more general software engeneering problem.

During the past month, I have read a lot about Entity-Component-Systems and now are quite comfortable with the concept. However, there is one aspect that seems to be missing a clear 'definition' and different articles have suggested radically different solutions:

This is the question of whether an ECS should break encapsulation or not. In other words its the OOP style ECS (components are objects with both state and behaivour that encapsulate the data specific to them) vs the pure ECS (components are c style structs that only have public data and systems provide the functionality).

Note that I am developping a Framework / API / Engine. So the goal is that it can easily be extended by whoever is using it. This includes stuff like adding a new type of render or collision component.

Problems with the OOP approach

  • Components must access data of other components. E.g. the render component's draw method must access the transform component's position. This creates dependencies in code.

  • Components can be polymorphic which further introduces some complexity. E.g. There might be a sprite render component that overrides the render component's virtual draw method.

Problems with the pure approach

  • Since the polymorphic behaivour (e.g. for rendering) has to be implemented somewhere, it is just outsourced into the systems. (e.g. the sprite render system creates a sprite render node that inherits render node and adds it to the render engine)

  • The communication between systems can be difficult to avoid. E.g. the collision system might need the bounding box which is calculated from whatever concrete render component there is. This can be solved by letting them communicate via data. However, this removes instant updates since the render system would update the bounding box component and the collision system would then use it. This may lead to preblems if the order of calling the system's update functions is not defined. There is an event system in place that allows for systems to raise events that other systems can subscribe their handlers to. However, this only works for telling systems what to do i.e. void functions.

  • There are additional flags needed. Take a tile map component for example. It would have a size, tile size and index list field. The tile map system would handle the respective vertex array and assign the texture coordinates based on the component's data. However, recalculating the entire tilemap every frame is expensive. Therefore, a list would be needed to keep track of all the changes made to then update them in the system. In the OOP way this could be encapsulated by the tile map component. E.g. the SetTile() method would update the vertex array whenever its called.

Although I see the beauty of the pure approach, I don't really understand what kind of concrete benefits it would have over a more traditional OOP. The dependencies between components still exist although being hidden away in the systems. Also I would need a lot more classes to accomplish the same goal. This seems to me like a somewhat over engineered solution which is never a good thing.

Furthermore, I am not that interrested in performance so this whole idea of data-oriented design and cashe misses doesn't really matter to me. I just want a nice architecture ^^

Still, most of the articles and discussion I read suggest the second approach. WHY?

Animation

Lastly, I want to ask the question of how I would handle animation in a pure ECS. Currently I have defined an animation as a functor that manipulates an entity based on some progress between 0 and 1. The animation component has a list of animators which has a list of animations. In its update function it then applies whatever animations are currently active to the entity.

Note:

I have just read this post Is the Entity Component System architecture object oriented by definition? which explains the problem a bit better than I do. Although basically being on the same topic it still doesn't give any answers as to why the pure data approach is better.

Best Answer

This is a tough one. I'll just try to tackle some of the questions based on my particular experiences (YMMV):

Components must access data of other components. E.g. the render component's draw method must access the transform component's position. This creates dependencies in code.

Don't underestimate the amount and complexity (not degree) of coupling/dependencies here. You could be looking at the difference between this (and this diagram is already ridiculously simplified to toy-like levels, and the real-world example would have interfaces in between to loosen the coupling):

enter image description here

... and this:

enter image description here

... or this:

enter image description here

Components can be polymorphic which further introduces some complexity. E.g. There might be a sprite render component that overrides the render component's virtual draw method.

So? The analogical (or literal) equivalent of a vtable and virtual dispatch can be invoked via the system rather than the object hiding its underlying state/data. Polymorphism is still very practical and feasible with the "pure' ECS implementation when the analogical vtable or function pointer(s) turns into "data" of sorts for the system to invoke.

Since the polymorphic behaivour (e.g. for rendering) has to be implemented somewhere, it is just outsourced into the systems. (e.g. the sprite render system creates a sprite render node that inherits render node and adds it to the render engine)

So? I hope this is not coming off as sarcasm (not my intent though I've been accused of it often but I wish I could communicate emotions better through text), but "outsourcing" polymorphic behavior in this case doesn't necessarily incur an additional cost to productivity.

The communication between systems can be difficult to avoid. E.g. the collision system might need the bounding box which is calculated from whatever concrete render component there is.

This example seems particularly weird to me. I don't know why a renderer would be outputting data back to the scene (I generally consider renderers read-only in this context), or for a renderer to be figuring out AABBs instead of some other system to do this for both renderer and collision/physics (I might be getting hung up on the "render component" name here). Yet I don't want to get too hung up on this example since I realize that's not the point you're trying to make. Still the communication between systems (even in the indirect form of read/writes to the central ECS database with systems depending rather directly on transformations made by others) shouldn't need to be frequent, if at all necessary. That's contradicting some of what I wrote immediately below about the importance of determining order of evaluation upfront but that's with practical needs for user response rather than "correctness" (it's not necessarily a temporal coupling issue but a user-end design issue of ensuring frames output the latest results without lagging behind).

This may lead to preblems if the order of calling the system's update functions is not defined.

This absolutely should be defined. The ECS is not the end-all solution to rearrange system processing evaluation order of every possible system in the codebase and get back exactly same kind of results to the end user dealing with frames and FPS. This is one of the things, when designing an ECS, that I'd at least strongly suggest should be anticipated somewhat upfront (though with a lot of forgiving breathing room to change minds later provided it's not altering the most critical aspects of the ordering of system invocation/evaluation).

However, recalculating the entire tilemap every frame is expensive. Therefore, a list would be needed to keep track of all the changes made to then update them in the system. In the OOP way this could be encapsulated by the tile map component. E.g. the SetTile() method would update the vertex array whenever its called.

I didn't quite understand this one except that it's a data-oriented concern. And there are no pitfalls as to representing and storing data in an ECS, including memoization, to avoid such performance pitfalls (the biggest ones with an ECS tend to relate to things like systems querying for available instances of particular component types which is one of the most challenging aspects of optimizing a generalized ECS). The fact that logic and data are separated in a "pure" ECS doesn't mean you suddenly have to recompute things you could have otherwise cached/memoized in an OOP representation. That's a moot/irrelevant point unless I glossed over something very important.

With the "pure" ECS you can still store this data in the tile map component. The only major difference is that the logic to update this vertex array would move to a system somewhere.

You can even lean on the ECS to simplify the invalidation and removal of this cache from the entity if you create a separate component like TileMapCache. At that point when the cache is desired but not available in an entity with a TileMap component, you can compute it and add it. When it's invalidated or no longer needed, you can remove it through the ECS without having to write more code specifically for such invalidation and removal.

The dependencies between components still exist although being hidden away in the systems

There's no dependency between components in a "pure" rep (I don't think it's quite right to say that dependencies are being hidden here by the systems). Data doesn't depend on data, so to speak. Logic depends on logic. And a "pure" ECS tends to promote the logic to be written in a way so as to depend on the absolute minimal subset of data and logic (often none) a system requires to work, which is unlike many alternatives which often encourage depending on far more functionality than required for the actual task. If you're using the pure ECS right, one of the first things you should appreciate is the decoupling benefits while simultaneously questioning everything you ever learned to appreciate in OOP about encapsulation and specifically information hiding.

By decoupling I specifically mean how little information your systems need to work. Your motion system doesn't even need to know about something far more complex like a Particle or Character (the developer of the system doesn't necessarily even need to know such entity ideas even exist in the system). It just needs to know about the bare minimum data like a position component which could be as simple as a few floats in a struct. It's even less information and fewer external dependencies than what a pure interface like IMotion tends to carry along with it. It's primarily due to this minimal knowledge that each system requires to work that makes the ECS often so forgiving to handle very unanticipated design changes in hindsight without facing cascading interface breakages all over the place.

The "impure" approach you suggest somewhat diminishes that benefit since now your logic isn't localized strictly to systems where changes don't cause cascading breakages. The logic would now be centralized to some degree in the components accessed by multiple systems which now have to fulfill interface requirements of all the various systems that could use it, and now it's like every system then needs to have knowledge of (depend on) more information than it strictly needs to work with that component.

Dependencies to Data

One of the things that's controversial about the ECS is that it tends to replace what might otherwise be dependencies to abstract interfaces with just raw data, and that's generally considered a less desirable and tighter form of coupling. But in the kinds of domains like games where ECS can be very beneficial, it's often easier to design the data representation upfront and keep it stable than it is to design what you can do with that data at some central level of the system. That's something I've painfully observed even among seasoned veterans in codebases that utilizes more of a COM-style pure interface approach with things like IMotion.

The developers kept finding reasons to add, remove, or change functions to this central interface, and each change was ghastly and costly because it would tend to break every single class that implemented IMotion along with every since place in the system that used IMotion. Meanwhile the entire time with so many painful and cascading changes, the objects that implemented IMotion were all just storing a 4x4 matrix of floats and the whole interface was just concerned with how to transform and access those floats; the data representation was stable all the way from the beginning, and a lot of pain could have been avoided if this centralized interface, so prone to change with unanticipated design needs, didn't even exist in the first place.

This could all sound almost as disgusting as like global variables but the nature of how the ECS organizes this data into components retrieved explicitly by type through systems makes it so, while compilers can't enforce anything like information hiding, the places that access and mutate the data are generally very explicit and obvious enough to still effectively maintain invariants and predict what sort of transformations and side effects go on from one system to the next (actually in ways that can arguably be simpler and more predictable than OOP in certain domains given how the system turns into a flat sort of pipeline).

enter image description here

Lastly, I want to ask the question of how I would handle animation in a pure ECS. Currently I have defined an animation as a functor that manipulates an entity based on some progress between 0 and 1. The animation component has a list of animators which has a list of animations. In its update function it then applies whatever animations are currently active to the entity.

We're all pragmatists here. Even in gamedev you'll probably get conflicting ideas/answers. Even the purest ECS is a relatively new phenomena, pioneering territory, for which people haven't necessarily formulated the strongest opinions on how to skin cats. My gut reaction is an animation system which increments this sort of animation progress in animated components for the rendering system to display, but that's ignoring so much nuance for the particular application and context.

With the ECS it's not a silver bullet and I do still find myself with tendencies to go in and add new systems, remove some, add new components, change an existing system to pick up that new component type, etc. I don't get things right at all the first time around still. But the difference in my case is that I'm not changing anything central when I fail to anticipate certain design needs upfront. I'm not getting the rippling effect of cascading breakages that require me to go all the over the place and change so much code to handle some new need that crops up, and that's quite the time saver. I'm also finding it easier on my brain because when I sit down with a particular system, I don't need to know/remember that much about anything else besides the relevant components (which are just data) to work on it.

Related Topic