EnTT Entity Component System

repository·main·Indexed 11 days ago

https://github.com/skypjack/entt

A high-performance, header-only C++ library featuring an Entity Component System (ECS) and optimized core utilities for memory management, type handling, and bit manipulation. Includes specialized containers like dense_map, dense_set, and basic_table, as well as utilities such as entt::any, hashed strings, and type-safe opaque containers.

Tokens
40.4K
Snippets
127
Records
152
Agent score
96%

What's inside EnTT

  1. Introduction to EnTT ECS

    main
    EnTT is a header-only, lightweight, and easy-to-use Entity-Component-System (ECS) module written in modern C++. It is primarily designed for architectural patterns used in game development, providing high-performance containers for managing entities, components, and systems.
  2. Use the Table adaptor for columnar data

    main

    The basic_table is a container adaptor that manages multiple sequential containers as if they were columns in a single table.

    Key Features:

    • Columnar Storage: Internally, it uses a tuple of containers (rather than a container of tuples) to allow efficient access to individual columns.
    • Row Access: Accessing a row or iterating over the table returns a std::tuple containing references to the elements of that row.
    • API: It provides a small set of functions similar to std::vector.

    Usage: You can use the table alias to simplify usage, which defaults to using std::vector as the underlying sequential container for each column.

  3. Handle type identifier conflicts

    main

    While rare, identifier conflicts can occur if two types have the same hash or if two types from different libraries share the same fully qualified name (resulting in the same type_name).

    Solutions:

    1. Define ENTT_STANDARD_CPP: This forces runtime identifiers, which typically avoid compile-time hash collisions, though it may not be ideal for plugin systems.
    2. Specialize entt::type_name: Assign a custom identifier to the conflicting type to ensure uniqueness.
    3. Custom Policy: Implement a fully customized identifier generation policy (e.g., using enum classes or preprocessing).
  4. How the cooperative scheduler works

    main

    The scheduler manages the lifecycle of multiple processes. Each process is invoked once per tick.

    Key behaviors:

    • Termination: When a process terminates, it is automatically removed from the scheduler.
    • Chaining (Children): If a process has a 'child' (defined via .then()), the parent is replaced by the child only if the parent succeeds. If the parent fails, both the parent and the child are discarded.
    • Sharing: Processes inherit from std::enable_shared_from_this. You can obtain a shared pointer to a process to intervene in its lifecycle (like pause()), but you should not use it to manage the process's ownership, as this can compromise the scheduler.
  5. Generate execution graphs with entt::organizer

    main

    The entt::organizer class template allows you to build an execution graph from a set of functions and their resource requirements. This graph can be used to safely schedule tasks in parallel.

    Registering Tasks

    Functions can be free functions, member functions, or lambdas. Supported parameters include:

    • entt::registry (or const entt::registry &)
    • entt::basic_view (any combination of storage classes)
    • A context variable of type T (or const T &)

    Resource Management

    When a function is registered, the organizer treats its parameters as resources. The const qualifier determines if access is Read-Only (RO) or Read-Write (RW). You can explicitly declare additional resources or override access modes via template parameters.

    Workflow

    1. Emplace tasks into the organizer.
    2. Generate the graph using .graph().
    3. Prepare the registry for execution using the graph's prepare function.
    4. Schedule the tasks using your own preferred scheduler.
    ```cpp
    entt::organizer organizer;
    
    // 1. Register tasks
    organizer.emplace<&free_function>(
  6. Use Signals and Sinks for event handling

    main

    EnTT provides entt::sigh (signals) to manage event listeners. To maintain a clean API, it is recommended to keep the signal as a private member and expose a entt::sink to clients. The sink is the tool used to connect and disconnect listeners.

    Key Concepts:

    • Signal (entt::sigh<Signature>): The object that holds the listeners and triggers them.
    • Sink (entt::sink): The interface used to manage connections (connect/disconnect) to a signal.
    • Connection: connect returns a connection object. You can call .release() on it to disconnect, or wrap it in a scoped_connection to disconnect automatically when it goes out of scope.

    Listener Types:

    • Free functions.
    • Member functions (requires an instance).
    • Lambdas and functors.
    entt::sigh<void(int, char)> signal;
    entt::sink sink{signal};
    
    void foo(int, char) { /* ... */ }
    struct listener {
        void bar(const int &, char) { /* ... */ }
    };
    
    listener instance;
    
    sink.connect<&foo>();
    sink.connect<&listener::bar>(instance);
    
    sink.disconnect<&foo>();
    sink.disconnect<&listener::bar>(instance);
    sink.disconnect(&instance);
    sink.disconnect(); // Discards all listeners
  7. Use the Dense Set container

    main

    The dense_set is a hash set implementation based on sparse sets. Like the dense_map, it aims to provide a packed array of elements to improve iteration performance by reducing memory jumps.

    Its interface is similar to std::unordered_set, but it adds support for reverse iteration (providing rbegin() and rend()).

  8. Use entt::handle to wrap an entity and registry

    main

    An entt::handle is a thin, non-owning wrapper around an entity and a registry. It replicates the entt::registry API (e.g., get, emplace) but implicitly passes the wrapped entity to every call.

    • Validity: A default-constructed handle is invalid (contains a null registry/entity). Use its implicit conversion to bool to check validity.
    • Mutability: Handles are trivially copyable. Because they are non-owning, mutability is part of the type (entt::handle vs entt::const_handle).
    • Customization: You can create custom handles for your own identifier types using entt::basic_handle.
    // Standard handles
    entt::handle handle{registry, entity};
    
    // Custom handles for custom identifiers
    using my_handle = entt::basic_handle<entt::basic_registry<my_identifier>>;
    using my_const_handle = entt::basic_handle<const entt::basic_registry<my_identifier>>;
    
    // Check validity
    if (handle) {
        handle.get<position>();
    }
  9. Use entt::delegate as a lightweight function invoker

    main

    An entt::delegate is a general-purpose invoker designed to be a lightweight alternative to std::function. It provides zero memory overhead for free functions, lambdas, and member functions (when provided with an instance).

    Key Characteristics:

    • No Allocations: Unlike std::function, it avoids hidden memory allocations.
    • Signature Flexibility: It supports functions with shorter argument lists than the delegate's signature (extra arguments are silently discarded). It also supports "payload" functions where the first argument is a reference to data passed during connection.
    • Safety: Invoking an empty delegate results in undefined behavior or a crash. Always check if a delegate is valid using if(delegate) before calling it.
    • Resetting: Use .reset() to clear a delegate; there is no disconnect method for individual delegates.
    // Create an empty delegate for a specific signature
    entt::delegate<int(int)> delegate{};
    
    // Check if it is valid before use
    if(delegate) {
        auto ret = delegate(42);
    }
    
    // Clear the delegate
    delegate.reset();
  10. Using a const registry and avoiding dangling views

    main

    A const registry is thread-safe because it does not perform lazy initialization of missing storages. This has implications for how you generate views.

    The Risk

    Because a const registry cannot lazily create storage, a view generated from a const registry might contain dangling references to non-existing storage if the storage hasn't been explicitly created. If you keep such a view aside for later use, it may misbehave.

    Solutions

    1. Immediate Use: Always create views when necessary and discard them immediately after use.
    2. Pre-announce Storage: Use the registry.storage<T>() method to explicitly instantiate storage classes for specific types. This 'announces' the type and ensures the storage exists, making views generated from a const registry safe to use.
  11. How View iteration order is determined

    main

    By default, a view iterates along the pool that contains the smallest number of elements to minimize work. The order of types in the template arguments does not affect the iteration order.

    Enforcing Order

    If you need to force the view to iterate based on a specific component's pool, use the .use<T>() method.

    Reverse Iteration

    Single type views support reverse iteration via rbegin() and rend(). Multi-type views do not support reverse iterators; you must implement this manually or use a single-type view to lead the iteration.

    // Default: iterates the smallest pool
    for(auto entity: registry.view<position, velocity>()) { 
        // ...
    }
    
    // Enforce iteration based on position pool
    auto view = registry.view<position, velocity>();
    view.use<position>();
    for(auto entity: view) { 
        // ...
    }
    
    // Reverse iteration (Single type views only)
    auto view = registry.view<position>();
    for(auto it = view.rbegin(), last = view.rend(); it != last; ++it) {
        // ...
    }
  12. Control execution order using fake resources in entt::flow

    main

    The entt::flow builder does not have an explicit before() or after() API. Instead, execution order is determined by resource access modes. To force a specific order, you can use fake resources.

    • To force Task A to run BEFORE Task B: Have Task A request a fake resource in rw (read-write) mode, and have Task B request the same fake resource in ro (read-only) mode.
    • To force Task B to run AFTER a group of tasks: Have the group of tasks request a fake resource in ro mode, and have Task B request that same fake resource in rw mode.

    This works because accessing a resource in opposite modes (one rw, one ro) requires sequential scheduling rather than parallel execution.

    // Force task_1 to run BEFORE task_2 and task_3
    builder
        .bind("task_1"_hs)
            .ro("resource_1"_hs)
            .rw("fake"_hs)
        .bind("task_2"_hs)
            .ro("resource_2"_hs)
            .ro("fake"_hs)
        .bind("task_3"_hs)
            .ro("resource_2"_hs)
            .ro("fake"_hs);
    
    // Force task_3 to run AFTER task_1 and task_2
    builder
        .bind("task_1"_hs)
            .ro("resource_1"_hs)
            .ro("fake"_hs)
        .bind("task_2"_hs)
            .ro("resource_1"_hs)
            .ro("fake"_hs)
        .bind("task_3"_hs)
            .ro("resource_2"_hs)
            .rw("fake"_hs);