5

I'm creating my first c++ game engine project (for learning purposes) and in it I've attempted to implement an entity/component system utilizing some data oriented design principles while also not fully giving up my object oriented way of thinking. I have data only component structs:

(Example)

Position.h

struct Position
{
    Vector3D position{ 0.0f, 0.0f, 0.0f };
};

which are stored in arrays within my SceneManager class:

SceneManager.h

class SceneManager
{
    //Some other code....

private:
    //So only systems have access to component arrays
    friend class BGraphics::RenderSystem;
    friend class BInput::InputSystem;
    friend class BPhysics::MovementSystem;
    friend class BPhysics::CollisionSystem;
    friend class BAudio::AudioSystem;

    //Component arrays. Each index of the arrays represents an entity.
    //So positionComponents.at(1) represents entity 1's position. 
    Array<Position, 10> positionComponents;
    Array<Velocity, 10> velocityComponents;
};

As you can see, my engine's system classes are the only ones who have access to the component arrays and so every system can process components. There are some other things involved in this setup that I don't want to get too deep into for simplicity's sake. My issues are as follows:

Each system accepts a SceneManager reference in an Update function like so:

RenderSystem::Update(SceneManager& scene)
{ 
   //Use scene component arrays necessary for processing...
}

What I'm struggling with is right now my components seem like global variables in that every system has access to them and can change their state. Though it seems like a lot of data oriented design involves structs like these with public data for which other functions can operate on, changing their state. With that being said, is there something I'm missing with my implementation that would make my component data more encapsulated and safer? How am I suppose to think about handling data in a safe way when utilizing data oriented design? Any clarification is appreciated.

  • Where are your "action" methods, like MoveCharacter()? What are its parameters, and what does it return? – Robert Harvey Mar 10 '17 at 1:16
  • Right now I'm using a movementSystem to move my entities which just takes an entity's position component and adds an entity's velocity: position += velocity. This now represents the new position that the RenderSystem will use to render the entity for a certain frame. – Jason Mar 10 '17 at 1:42
3

Indeed, in your design, the SceneManager acts as a container for global data. This is the inconvenience of a data centric design that sees data as a flat passive structure, and which leave to subsystems' functions the responsibility to manage the data.

A first remark is that each of the subsystems has to know that your data is in an array of fixed size, and needs to know the number of active elements therein. This is a fatal dependency. If later you'd choose to use a dynamic vector, or a searchable unordered multimap (e.g. for targeting group of components corresponding to a game tag), you'd need to rewrite everything.

You therefore need a minimum of encapsulation for accessing to elements in the container (e.g. With an iterator like interface such as first(), next(), previous(), last() ). The containers would then remain private, and the access to the components would be controlled.

Nevertheless this kind of architecture will be difficult to maintain. If you encapsulate data and the interface for accessing it, you are in an object oriented paradigm. So why not go for it ? Your SceneManager would the maintain a a container of Components. The components would become more comprehensive entities, which know their own position and velocity, the other subsystems will then not know these internals, but use the object's interface in a well behaved fashion.

If you want to know more about such approach, I'd recommend you Mike McShaffry's "Game Coding Complete". This book gives full insight about a game architecture, including hints about when to use object oriented features such as inheritance, and when rather avoid it.

  • Thank you for the insight. I asked this same question on another thread and got this response gamedev.stackexchange.com/questions/138438/…. Basically, he states that encapsulation is still present, you just have to think about it in a different way, with subsystem functions still acting like member functions. Though he seems to implement things slightly differently from my current implementation. Wondered if you had any specific thoughts on his response? – Jason Mar 18 '17 at 19:09
  • @Jason it's a very nice answer and exactly in the same direction than mine (although with more explanations and details). In fact his Ball example illustrates in more details the OOP approach I suggested when i wrote about making "more comprehensive entities". And the ECS approach is one of the core elements of McShaffry's book, in which Entity/component is recommended in preference to a static inheritance. The book goes by the way in more details about how this interacts with physics, audio, video or scripting subsystems. – Christophe Mar 18 '17 at 19:27
  • Okay sounds good, I'll check it out. – Jason Mar 18 '17 at 19:35
2

What I'm struggling with is right now my components seem like global variables in that every system has access to them and can change their state.

This was my biggest struggle too with ECS and one which I tried to avoid (bastardizing my first implementation of ECS by making components into objects with functions that went beyond just setting/getting data).

It didn't help that I came from working in a legacy C codebase where I was the annoying guy on the team insisting on proper engineering standards. In that codebase, I frequently ran into the dumbest bugs like some Array struct (similar to std::vector) having a size field which was a negative value, with so much of the codebase directly touching that size field along with calling realloc on the data. I had to set memory breakpoints on that field and spend hours just going from one breakpoint to the next to find which line of code set that raw field to a negative value... something which could have been outright avoided or at least caught so much sooner if this was simply a class with private fields that required the system to go through a public interface in a way that allowed it to maintain invariants, like size never being allowed to go into the negatives.

And that wasn't even an unusual occurrence. It was a daily one where I had to lean on memory breakpoints in the debugger to see which, among millions of lines of code, set this raw data field to a value that it should never have. Refactoring these towards classes as a way to reduce the time to spot bugs, fix them, and then prevent them once and for all was forbidden. When I joined that team I inherited a bug report list that had over 5,000 bugs reported in spite of the QA team very carefully filtering out duplicates, and 90% of the bugs were just violations of basic invariants like these which information hiding would have prevented along with other stupid preventable things like uninitialized variables (and initializing uninitialized variables at the time they were defined was also forbidden in the existing code until we could prove that a variable was used prior to initialization, for which it didn't help that the codebase generated over 100,000 warnings with many of the warnings about variables potentially being uninitialized being benign). That codebase actually made me hate C and embrace the C++ mindset to the fullest in spite of being a C coder originally. It took some years for me to reverse the brain damage and get back to loving C again.

So anyway, as you can imagine it was very, very difficult for me to embrace the idea that components should just be raw data with publicly accessible fields, so I stubbornly resisted that in my first attempt at ECS by still making components into objects with functionality with separate public/private interfaces, but it soon proved to be overkill and a PITA.

With ECS, you tend to have so few broad systems, and all generally completely decoupled from each other, to reason about as opposed to, say, hundreds of different and interdependent subtypes in an OOP hierarchy or countless procedural functions calling each other in a procedural legacy codebase like the above. A lot of the appeal from a maintenance standpoint is having so few places in your codebase that have logic associated with them, and therefore so few places in the codebase that can do something wrong. Raw data can never misbehave, only code that tampers with them. If you end up adding functionality to components, now you end up having way more places in your system which have actual code, and not data. The reason that legacy codebase I described above was a nightmare to maintain and one that was hopeless bug hive was because of the monstrous amount of code that could and often would touch a given piece of data.

Maintaining ECS Invariants

I found it easy enough to maintain invariants over components given how few systems there are that access any given component type and how quickly you can deduce and diagram which systems access which component types or rapidly find which systems do that with a simple text search through the codebase. By design, a system in an ECS has to explicitly request to access a component type before it can touch it. If an invariant is violated at the component level, which I've found to very rare in my experience at least, you end up having an extremely narrow list of suspects right away without having to wade through monstrous seas of code as I had to in my legacy codebase where everything was potentially touching everything. It's not so complicated with ECS. If you find a motion component with fields set to values they should never have, then you can immediately deduce that only your physics and animation system tamper with motion components and therefore narrow down the suspects dramatically right away.

And you can, just as you would for the implementation details of a class, sprinkle sanity assertions for debug to make sure each system is dealing with valid states and not setting any component fields to values they should never have.

In fact, I find it easier to maintain the most difficult system-wide invariants in ECS. There are granular invariants of the kind like a data structure should never have a size that goes to zero -- simple enough, and OOP with its encapsulation and information hiding was always brilliant at helping with that. I wasted so much time dealing with violations of those granular invariants in the legacy codebase. But in a software that isn't sprawling with stupid bugs, there are broader system-wide invariants like making sure GPU rendering data is in sync with CPU scene data, and those are much harder to maintain and reason about and require so much more brain power in ways where trying to dice everything up into objects and then working towards bigger and bigger objects doesn't always help, since you still end up having a boatload of interdependent, albeit loosely-coupled simple code to wade through if something goes wrong with such complex interactions between the code.

Meanwhile ECS yields very flat code modeling logic at a very broad scale with minimal code interactions (bare minimum number of couplings even if the coupling from system to component is tight), often making it easier to reason about those broad, higher-level, system-wide invariants. I never benefited quite as much from the ridiculous flexibility of ECS to adapt to new design ideas with minimal code changes (I use ECS for VFX, not games, which is rather unusual), but I benefited tremendously over how they made it easier to write and maintain systems and reason about their correctness without getting lost in a sea of small but interdependent abstract objects calling abstract functions that lead to who knows where.

If you know for sure that some components are only going to be accessed by a handful of systems, you can treat them as opaque types (declared but not defined) to the rest of the codebase and just define the component struct in a place that's only visible to those few systems. It'll also start to click in the future why components are deliberately made to be just raw data. It's like having position in poker. It's hard to see why that's so advantageous until you record your winnings over a hundred thousand hands and see, indeed, that you won the most money always in position. ECS was like that for me. I couldn't comprehend how it would make maintaining complex codebases so much easier until I actually created one properly and started to maintain it, and I still find myself at odds about explaining exactly why it's so much easier to maintain except that it's modeling human ideas at a coarser level and bringing the number of couplings to a minimum, even if the couplings are tighter than you'd get with a SOLID OOP design.

Also raw data doesn't have to mean you have to avoid functions completely, but the functions should only be concerned with accessing data. For example, it's not necessarily a violation of this type of model of ECS to have a vector field in your ECS, even though vector has functions. The difference is that vector only has functions related to accessing and manipulating raw data. Likewise it's arguably fine to have constructors and destructors. It's just, conceptually, components are still raw data.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.