#ecs #micro #parallel #projects #game-engine #parallelism #cache

moecs

Micro ECS engine. A small and lightweight ECS engine for Rust projects.

1 unstable release

0.1.0 Jan 20, 2024

#587 in Game dev

MIT license

46KB
855 lines

moecs

Build Status License

moecs (micro ECS) is a small ECS library written in Rust.

Built to be used with lightweight Rust-based game engines, like ggez.

See example implementations here.

Features

  • Simple user-facing API.
  • Entity query caching for efficient repeat lookups.
  • Configurable parallelism (powered by rayon):
    • Entity queries are run in parallel (when not cached).
    • System execution can be configured to run in parallel.
    • System parameters are wrapped in Arc<RwLock>, so parallelism can easily be achieved within a System as well.

Documentation

Components

Component

Components are highly configurable bundles of data that can be arbitrarily grouped together to form an Entity. For example, a Dog Entity may be comprised of a position component (where it is in the world), and a state component (what it's getting up to). In moecs, this would be implemented like so:

#[derive(Component)]
struct PositionComponent {
    x: f32,
    y: f32,
}

#[derive(Component)]
struct DogStateComponent {
    state: DogState,
}
enum DogState {
    Sleeping,
    Playing,
    Barking,
}

Note that the #[derive(Component)] attribute must be defined for each Component.

ComponentBundle

Components can be easily bundled together using a ComponentBundle (this is most useful when creating a new Entity, as explored later). Using our Dog example, we can group the PositionComponent and DogStateComponents using the following:

let bundle = ComponentBundle::new()
    .add_component(PositionComponent {
        x: 0,
        y: 0,
    })
    .add_component(DogStateComponent {
        state: Sleeping,
    });

Entities

EntityManager

As discussed in the Component section, Entities are simply bundles of relevant Components. Operations on Entities are done via the EntityManager.

Note: The EntityManager is passed directly to defined Systems. Therefore, all Entity-related mutations can only occur in a System.

Responsibilities of the EntityManager include:

  • Creating new Entities.

Entities are created by providing a ComponentBundle of initial Components to be associated with that Entity (Note: an empty ComponentBundle may be provided). A u32 will be returned denoting that Entity's unique identifier, which can be used to retrieve that Entity's Components later.

Note: A strict requirement is that an Entity can only have one Component of a given type registered at a given time. moecs will panic if this rule is broken.

entity_manager.create_entity(
    ComponentBundle::new()
        .add_component(PositionComponent { x: 0, y: 0 })
);
  • Removing existing Entities.

Given some Entity id, that Entity can be removed wholesale (incl. deleting all relevant Components) via:

entity_manager.delete_entity(entity_id);
  • Adding Components to existing Entities.

Additional Components can be added to an existing Entity via:

entity_manager.add_components_to_entity(
    &entity_id,
    ComponentBundle::new()
        .add_component(VelocityComponent { x_vel: 0, y_vel: 0 })
);

Note: The above stated rule that an Entity may only have one Component of a given type still applies here. moecs will panic if this rule is broken.

  • Removing Components from existing Entities.

Similarly, Components can be removed (deleted) from an existing Entity via:

entity_manager.remove_component_from_entity::<PositionComponent>(&entity_id);
  • Querying for Entities that have (or don't have) specified Components.

Querying is done using the Query struct, which has 2 mechanisms of specifying filter criteria: with, without. These are used to iterate over the list of all registered Entities in order to filter out Entities with a certain Component, or similarly without other components as applicable.

Query results are returned via a QueryResult struct, which includes the Entity id of the filtered Entity, as well as the relevant Components.

Note: Query results are automatically cached. Additionally, query processing is performed in parallel (across registered Entities) to improve efficiency.

Example (simplified) flow:

entity_manager
    .filter(
        Query::new()
            .with::<SomeComponent>()
            .without::<SomeOtherComponent>(),
    )
    .iter()
    .for_each(|result: QueryResult| {
        let component = result.get_component::<SomeComponent>();
        println!(
            "Entity: {} has component {:?}.",
            result.entity_id(),
            component
        );
    });

Systems

System

Systems are where Components belonging to certain Entities change and interact with each other. In other words, Systems contain the logic of your program. For example, a rudimentary PhysicsSystem could be implemented like so:

#[derive(System)]
struct PhysicsSystem;
impl System for PhysicsSystem {
    fn execute(entity_manager: Arc<RwLock<EntityManager>>, params: Arc<SystemParamAccessor>) {
        entity_manager
            .read()
            .unwrap()
            .filter(
                Query::new()
                    .with::<PositionComponent>()
                    .with::<VelocityComponent>(),
            )
            .iter()
            .for_each(|result| {
                let entity_id = result.entity_id();
                let position = result.get_component::<PositionComponent>().unwrap();
                let velocity = result.get_component::<VelocityComponent>().unwrap();

                position.write().unwrap().x += velocity.read().unwrap().x_vel;
                position.write().unwrap().y += velocity.read().unwrap().y_vel;
            });
    }
}

A couple things to note:

  • All Systems must use the #[derive(System)] attribute.
  • All Systems must similarly implement the System trait, which essentially means implementing the execute fn. This gives you access to the EntityManager (discussed above), as well as the SystemParamAccessor (discussed below).

System Parameters

It's often adventageous to pass data from outside the moecs ecosystem in (for example, an input handler, or a rendering canvas). These can be passed via. a SystemParam. An example definition:

#[derive(SystemParam)]
struct CanvasParam<'a> {
    canvas: &'a mut Canvas,
}

SystemParams are collected and accessed by a SystemParamAccessor, which essentially just provides a convenient means of looking up a SystemParam from within the System. For example:

#[derive(System)]
struct RenderSystem;
impl System for RenderSystem {
    fn execute(entity_manager: Arc<RwLock<EntityManager>>, params: Arc<SystemParamAccessor>) {
        let canvas_param = params.get_param::<CanvasParam>().unwrap();
        let canvas_param = &mut canvas_param.write().unwrap();
        let canvas = &mut canvas_param.canvas;

        // etc.
    }
}

System Groups

A SystemGroup is a user-defined grouping of like-Systems. Practically, a System can only be interacted with via. a SystemGroup.

Systems in a SystemGroup can be registered to execute in sequence or in parallel depending on configuration by the user. For example:

let group_1 = SystemGroup::new_sequential_group()
    .register::<PhysicsSystem>()
    .register::<CollisionSystem>();
let group_2 = SystemGroup::new_parallel_group().register::<RenderSystem>();

Parallelism here is horizontal. That is, the Systems themselves are run in parallel with each other. Parallelism within a System is done separatetely (manually).

Engine

The Engine is how moecs is interacted with by some outside process (i.e. some central game loop). It has the following responsibilities:

  • Register / deregister SystemGroups.
  • Execute a registered SystemGroup.

The general flow is as follows:

  • Upon program start up, define and group Systems of similar type using SystemGroups (discussed above).
  • Then, in the main loop, execute the SystemGroups in some sequential manner.

A basic example:

fn main() {
    let mut engine = Engine::new();

    let update_systems = engine.register_system_group(
        SystemGroup::new_sequential_group()
            .register::<PhysicsSystem>(),
    );
    let render_systems = engine.register_system_group(
        SystemGroup::new_sequential_group()
            .register::<DrawShapeSystem>(),
    );

    loop {
        engine.execute_group(update_systems, SystemParamAccessor::new());
        engine.execute_group(render_systems, SystemParamAccessor::new());
    }
}

Dependencies

~1.4–2MB
~41K SLoC