Code Monkey home page Code Monkey logo

dynec's Introduction

dynec

An opinionated ECS-like framework.

CI codecov

What is ECS?

ECS is a data-oriented programming paradigm that focuses on optimizing the CPU cache. Objects ("Entities") store their data in "Components", which are processed in "Systems".

dynec has E, C and S, but it is not the typical ECS

dynec is statically archetyped. The archetype of an entity refers to the set of components it can have, which is comparable to the class of an object in OOP. In dynec, entities cannot change to another archetype once created. Entities can still have optional components, but they must be known in advance.

This allows entity references to be strictly typed. When you hold an entity reference, you are assured that all entities in the reference are present. Entities of different archetypes are stored separately, which further improves cache locality (since components of different archetypes are mostly unrelated). Components can also declare that they must always be present on entities of an archetype, which give you more confidence that the component really exists.

Furthermore, archetypes cannot be subtyped. This means that, unlike the traditional ECS, there is no "join query" that queries all entities with a subset of components present. Iteration can only be performed on all entities of an archetype (it is also possible to iterate over all entities with a single component, but this is for a different purpose). If you want to fetch all entities with all of multiple components like how you would usually do in other frameworks, you probably wanted to split them to be a separate entity instead.

Doesn't this make polymorphism more difficult to use?

I imagine your design is like this: some entities have the "pig" archetype, some have the "bird" archetype, both share the common animal components, while "bird" also has the additional flight- and egg-related components.

This is not the perspective to organize entities in dynec. Pigs and birds are both the same archetype, let's say "animal". The term "bird" is just an umbrella term to refer to abilities such as flying and laying eggs. It should not be a concept that appears in the code logic at all, because "bird" does not really mean anything at the programming level. In a sense, entities in dynec are comparable to some optional components in traditional ECS.

This implies entities would have a lot of references among them โ€” a bird entity needs to reference its flight management entity and egg-laying entity. Although this seems to complicate the design a lot, this is actually inevitable in high-quality software where one-to-one relationship is probably rare compared to one-to-many relationship. For example, a bird may lay multiple types of eggs, which would result in multiple egg-laying entities; this cannot be trivially managed anyway.

Entities are (optionally) reference-counted and trackable

When debug assertions are enabled, all entity references are counted. When an entity is deleted, dynec panics if there are still dangling references to the entity and searches for the dangling references from all components and states in the world. This means we can be (mostly) confident that any entity reference points to a live one, and enables reduction of the size of a strong entity reference to one integer because strong reference should not be able to outlive the referenced entity (most ECS frameworks require another integer to store the "generation" to avoid dangling references from pointing to a new entity recreated at the same offset).

Dropping all references before an entity is deleted sounds troublesome to implement, but dynec provides two solutions for this. First, dynec supports "finalizer components", where components serve as asynchronous finalizers. Systems can create finalizers that ensure that references to the entity are dropped before the actual deletion (and the dangling reference check) takes place. This gives different systems the chance to clean up an entity without losing the context that describes the entity (because components are dropped after deletion and cannot be read anymore). Second, if it is really necessary to retain the (dangling) entity reference, you can store a "weak reference" instead โ€” weak references are also reference-counted, but they do not cause a dangling reference panic.

Nevertheless, in order to track where entities are located, all components and global and system-local states (basically all storages managed by dynec) must implement a trait that supports scanning all owned strong/weak entity references. dynec provides a derive macro to achieve this, but since Rust does not support specialization (yet), an #[entity] attribute needs to be applied on every field that may reference entities. However, with the use of static assertions, most mistakes in implementing the trait can be revealed at compile time (exceptions are types with generic parameters, which require manual confirmation). Despite all the trouble, the ability to scan for entity references make more features feasible, including automatic system dependency declaration and entity rearrangement (described below).

Entities can have multiple components of the same type

How would we store the health of a player? We create a Health component for player entities. What if we also want to store the hunger of a player? OK, we also add a Hunger component for player entities. Now, what if we have an unknown number of such attributes, determined at runtime (e.g. by plugins or an authoritative game server)? Since we cannot declare types dynamically, it seems we have to refactor into a map or a Vec. Or maybe a SmallVec<[Attribute; N]> to avoid heap allocation. Wait, how much is N?

In dynec, we avoid this problem with "isotope components". Similar to isotopes in chemistry, there may be multiple components of the same type (Attribute) for the same entity, but these components belong to different "discriminants" (e.g. the attribute ID). So in terms of semantics, it looks as if we got a HashMap<AttributeId, Attribute> component, but in terms of performance, each AttributeId gets allocated in a new storage as if it is a different component. This design is also more efficient in the example here, because some systems may only want to manipulate health but not hunger, so it should be able to execute concurrently with systems that use the hunger attribute; it is also better for cache locality since it avoids striding attributes with unused values. This is not possible in ECS frameworks that only support type-based component key, which lack flexibility for dynamically defined logic.

Entities can be rearranged to optimize random access (not yet implemented)

One of the reasons why ECS performs better than traditional OOP-based code style is that components are stored in a compact region instead of scattered around the heap, reducing the frequency of CPU cache penetration that causes slow memory access. However, when the amount of data is large, since entities are typically randomly arranged (no less random than heap allocation), systems may need to access components from entities arranged far apart. For example, in the case of iterating over all edges in a network simulation (where nodes and edges are entities, and edges have components referencing the endpoint nodes), although the data describing the edge itself are contiguously arranged, accessing the data for the endpoint nodes would lead to random memory access, greatly deterriorating the performance.

In dynec, since all entity references are trackable, it is possible to permute all entities of the same archetype so that relevant entities are located more closely. For example, in the case of a spatial graph (where edge length is comparable to node density, i.e. very few super-long edges), we can perform an quadtree/octree sort on all nodes and edges such that iterating over all edge entities would process spatially nearby edges, which in turn accesses spatially nearby nodes, both of which have higher chance of getting nearby memory allocation.

Of course, entity rearrangement is only useful for scenarios where the ideal entity arrangement can be retained for a long period. For example, it is useful to rearrange buildings on a map because they are mostly stationary, but it is not useful to rearrange cars travelling on the map since their order quickly changes (unless cars have very slow speed or move in a similar direction as nearby cars). Since entity rearrangement requires mutable access to all component storages for an archetype and processes a lot of data at the same time, this is a stop-the-world operation that must not be performed frequently, so the period for which the arrangement drifts away (such that rearrangement is necessary) should be negligibly long such that user experience is not affected.

dynec's People

Contributors

dependabot[bot] avatar sof3 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

dynec's Issues

Isotope archetypes?

Is it meaningful to have dynamically created archetypes?

Currently isotope components work like "dynamic columns" for an entity, but they waste a lot of memory for entities that don't use all isotope components if they use storage::Vec for storage.

If we allow isotope archetypes, entities that use a particular isotope component can be abstracted out.

The problem with this approach is, in that case, we have multiple archetypes with the same combination of components anyway. In that case, why not just store them together?

Therefore, it seems isotope archetypes don't really make sense, since the root problem of isotope components is the need for a dynamic number of components without creating new entities.

Leaving this issue open for future reference.

Missing dependency of an unused component

If both A and B are optional dependencies, even though the initializer of B depends on A, no error should be generated even if A is missing without initializers.

Using both `impl EntityIterator<A>` and `impl EntityCreator<A>` in the same system causes panic

thread 'world::tests::test_entity_create_and_delete' panicked at 'already borrowed: BorrowMutError', src/entity/ealloc.rs:555:27

This is because both arguments require calling ealloc_shard_map.get(TypeId::of::<A>()).borrow().

We do not need to allow concurrent mutable access to the same archetype, and EntityIterator does not read the output of EntityCreator, so the immutable part could be extracted out.

Track entity generation

  • Maintain a generation_store: Vec<Geneneration> for each archetype, initialized as zero.
  • Every time an entity is allocated, increment the Generation by 1.
  • This buffer is readable to all systems during online mode, because it is used for verifying validity of entity::Weak.
  • When an entity::Weak is created from a entity::Entity, retrieve the generation from generation_store. This means entity::Entity::weak(&self) also requires an argument for accessing the store.

Delete all components of a type

Add an API to quickly drop all components in a storage. This is implemented by setting all bitflags to 0, which is much faster than calling set(None) one by one.

Add #[derive(Discrim)]

Supports enums and single-field structs in which the only field U satisfies T: xias::SmallInt<usize>, usize: xias::SmallInt<T>.

Unsafety of Storage is actually unsound

Even though we obtain &mut map[k1] and &mut map[k2] for distinct keys k1 != k2 and we only access their interior, it is still unsound to obtain the latter by taking a &mut map first, because BTreeMap does not guarantee not to e.g. rebalance the tree as we call map.get_mut (although it has no practical reason to).

The correct approach would be to use SyncUnsafeCell and/or splitting slices.

Ealloc implementation should be configurable

Allow users to change the implementation of entity allocator in the Archetype implementation.

If near hints are not required, the fastest implementation is actually to use a FILO stack storing all available entity IDs.

Add benchmarks

Things we are interested in benchmarking:

  • Scheduler initial planning: how long does it take to initialize the planner?
  • Scheduler throughput: how many empty, mutually exclusive systems can we dispatch per second?
  • Entity creation: how many entities (with one dummy component) can we create per second?
  • Entity deletion: how many entities (with zero or one deferred finalizer component) can we delete per second?
  • Chunked component access: throughput of i32 sum += delta over all entities (with randomly generated holes)
  • Same as above, but for isotope components
  • Random component access: throughput of flow graph simulation
  • parallel accessors

Optimize the scheduler better

Currently, worker threads poll tasks by acquiring a mutex on the scheduler planner. This may not scale very well if there are many systems that execute very quickly, or if there are many cores fighting for the same lock.

Allow multiple sets of schedulers

This is equivalent to "flushing" in other ECS frameworks. This allows entities created/deleted in scheduler set A to take effect before systems in scheduler set B execute, thus allowing more flexibility with entity creation partition guarantees.

Entity creation dependency validation

If a system s1 requests an entity creator for archetype A, for each component/global T where s1 requests write access to T and T may own a Entity<A>, for any other system s2 where s2 reads/writes T, s2 must execute strictly before s1 within a cycle (i.e. there must exist a partition P where s2 executes before P and s1 executes after P, or other systems P[i] and s[i] such that s2 < P[i] < s[i] < P < s1.

Alternatively, when s2 requests access to T, it must declare maybe_uninit(A) (or #[dynec(maybe_uninit(A))] in #[system] macro syntax), where the system author acknowledges that some Entity<A> values may be uninitialized.

Entity deletion (with finalizer support)

Maintain a deletion_flag: Vec<bool> and finalizer_count: Vec<AtomicUsize> for each archetype.

Entity creation

  • Count the components that are finalizers, stored to finalizer_count
  • Initialize deletion_flag as false.

Entity deletion flagging

  • Offline mode: accepts an entity: impl entity::Ref, get the raw ID and drop(entity).
  • Online mode: accepts an entity: impl entity::Ref, get the raw ID, push the raw ID to a sharded queue, process in batch after join.
  • During offline/join: If finalizer count is zero, proceed to "Entity deletion". Otherwise, set deletion_flag to true to wait for trigger from finalizers.

Component update

If a component is removed and the deletion flag for its entity is true, the entity is queued for finalizer test after join.

Entity deletion

  • Check the refcount of the entity, ensure that the internal refcount is the only reference.
  • Loop through all storages, drop the component from the storage if exists.
  • Deallocate the entity ID.

entity::Weak generation is unused

It is supposed to be cross-checked with generation::WeakStore, but apparently it is not done anywhere.

entity::Weak should not implement entity::Ref directly. It should expose a separate function to use as entity::Ref:

impl<A: Archetype> entity::Weak<A> {
    pub fn try_as_ref(&self, store: impl generation::WeakStore<A>) -> Option<entity::TempRef<'_, A>>;
}

Add more tests for Storage

The Storage implementors contain a lot of unsafe code, but they are severely lacking tests, greatly increasing the risk of UB.

Disambiguate terminology for "storage"

Currently storage refers to two different concepts. In the public API, "storage" only refers to the actual data structure that holds the components (i.e. VecStorage and storage::Tree), but internally it is also used to refer to the storage wrapper types for simple and isotope components (i.e. storage::Simple and storage::IsotopeMap). We should rename the latter to something else like "storage wrapper" etc.

Implement a fast version of comp::Map

Currently comp::Map uses a BTreeMap, where remove_single is a slow operation. Checking for duplicates is probably unnecessary anyway, and isotopes could have been split to a separate field instead of mixing with simple components.

Remove lazy initializers for isotope components

While auto initializers make sense for simple components because they are required for modular initialization for comp::Must, isotope lazy initializers do not really serve any good purpose other than increasing implementation complexity. They can totally be implemented in userland without getting built into the isotope declaration.

In particular, it is very inconsistent that isotope storages return Some for nonexistent lazy-initialized isotopes but subsequent iterators do not yield the value.

Duplicate partitions

Add a test to ensure that, if the same system adds two equal partitions as dependencies, it should have no error if the dependencies are of the same direction.

Dependencies in different directions are expected to panic during cycle detection.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.