Skip to content

Events as Entities #2

@alanjfs

Description

@alanjfs

Problem

Currently, events are designed to fit with pre-existing entities in your game or application. For example, if your game character is an entity with Position and Renderable components, then you could attach a Sequentity::Track that carry events related to changes to those components.

registry.view<Sequentity::Track>().each([](const auto& track, auto& position) {

    // Iterate over all events for this track, at the current time
    Sequentity::Intersect(track, current_time, [](const auto& event) {
        if (event.type == TranslateEvent) {
            auto& data = static_cast<TranslateEventData*>(event.data);

            // Do something with it
            event.time
            event.length
        }
    });
});

So far so good.

The consequence however is that each Track becomes a "mini-ECS" in that they themselves form a registry of additional entities, each one carrying a Sequentity::Channel component, which in turn form yet another registry of the Sequentity::Event. Except they don't carry the advantage of an ECS, in that they are limited to this one component each, and you can't operate on them like you would other entities in your application.

Solution

What if each of these were entities of the same registry, associated to your application entity indirectly?

bob = registry.create();

registry.assign<Name>(bob, "Bob");
registry.assign<Position>(bob);
registry.assign<Renderable>(bob, "BobMesh.obj");

Just your everyday ECS. Now let's get Sequentity in there.

track1 = registry.create();
channel1 = registry.create();
event1 = registry.create();

registry.assign<Name>(track1, "Track 1");
registry.assign<Sequentity::Track>(track1, bob); // With reference to another entity

registry.assign<Name>(channel1, "Channel 1");
registry.assign<Sequentity::Channel>(channel1, track);  // With reference to its "parent" track

registry.assign<Sequentity::Event>(event1, channel1); // With reference to its "parent" channel
registry.assign<SomeData>(event1);  // Your data here

Benefits

  1. The immediate benefit is that we avoid the need to store a void* inside the Event struct itself, and instead rely on EnTT to take ownership and delete any memory alongside removal of the entity.
  2. Another is that we're now able to iterate over events independently.
  3. And another is that moving events from one track to another is now a matter of retargeting that .parent value, rather than physically moving a member of the channel.events vector like we must currently.
registry.view<Sequentity::Event>().each([](const auto& event) {
  // Do something with all events
});

We could also iterate over tracks and channels individually in the same way, if we needed just them and not the events (to draw the outliner, for example). And if we wanted, we could still reach out and fetch the associated channel and track from an event by traversing what is effectively a hierarchy.

registry.view<Sequentity::Event>().each([](const auto& event) {
  const auto& channel = registry.get<Sequentity::Channel>(event.parent);
  const auto& track = registry.get<Sequentity::Track>(channel.parent);
});

Cost

The primary (and poor) reason for not going this route immediately was the percieved performance cost of introducing this hierarchy of enities and traversing between levels.

One of the (hypothetical) advantage of the current data layout is that it is tightly packed.

struct Track {
  std::vector<Channel> channels {
    struct Channel {
      std::vector<Event> events {
        struct Event { ... };
      };
    };
  };
};

Each track can be drawn as a whole, with its channels tightly laid out in memory, and its inner events tightly laid out in memory too. Hypotethically, this should be the absolute most optimal way of storing and iterating over the data, and it just so happens to also make for a suitable editing format; e.g. moving a track takes all data with it, moving events in time along a single channel doesn't really make a difference, as the data is all the same and doesn't need to physically move (just need a change to the .time integer value).

If each event is an entity, then in order to read from its channel we need to perform a separate lookup for its channel. That channel may reside elsewhere in memory, as may its track.

We could potentially sort these three pools such that tracks, its channels and its events reside next to each other, in which case accessing these should be almost as fast (?), except we still need to perform a lookup by entity as opposed to just moving the pointer in a vector.

Aside from this (hypothetical) performance cost however, is there any cost to API or UX? Worth exploring.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions