Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Entity Component System

Nightshade's ECS is freecs. The whole World struct, every per-component accessor, the archetype tables, the query plumbing, and the resource layout are stamped out by one declarative macro at compile time. No proc macros, no trait objects, no unsafe.

An ECS separates the data from the work. An entity is a small handle, a generational id with no fields and no methods. A component is a plain struct attached to that handle, like LocalTransform or Light. A system is a function that reads or writes components on the entities matching a query, like "every entity with RENDER_MESH and LOCAL_TRANSFORM." The data lives in components, the work happens in systems, and entities are the keys that line them up.

The point of doing it this way instead of with object-oriented game objects is layout. An OOP game object packs position, velocity, health, and mesh into a single class instance, with the fields of each instance scattered across the heap. A loop that wants to advance physics has to touch every instance's position and velocity, paying a cache miss for each one. The fix is to flip the storage. Group entities by which components they have, lay out each component type as its own dense Vec, and the physics loop becomes a stride-1 walk through packed memory.

Archetype storage

Entities with the same set of components live in the same archetype table. Inside each table, every component type gets its own contiguous Vec in slot-aligned order. The position at index 7 and the velocity at index 7 belong to the same entity.

Table A  (mask: LOCAL_TRANSFORM | GLOBAL_TRANSFORM)
  local_transforms:  [t0, t1, t2]
  global_transforms: [g0, g1, g2]

Table B  (mask: LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH)
  local_transforms:  [t3, t4]
  global_transforms: [g3, g4]
  render_meshes:     [m3, m4]

A query for LOCAL_TRANSFORM | GLOBAL_TRANSFORM checks each table's mask with a single table.mask & query_mask == query_mask. Both tables match. A query for RENDER_MESH only matches Table B. Tables whose masks miss any requested bit are skipped without looking at a single entity inside them.

This is "structure of arrays" instead of "array of structures." Iteration through the inner loop is a stride-1 read on dense arrays the CPU prefetcher can predict. There are no per-entity hash lookups and no v-table dispatch. The cost is paid once at structural-change time when an entity has to migrate between archetypes, not on every access.

Component flags

Each component is assigned a bit position at compile time. An entity's archetype is identified by a ComponentFlags integer where bit N is set if the entity has component N.

LOCAL_TRANSFORM    = 1 << 0
GLOBAL_TRANSFORM   = 1 << 1
RENDER_MESH        = 1 << 2
MATERIAL_REF       = 1 << 3

Combine flags with | to describe a set of components. A query for LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF is one integer that names the archetypes the renderer cares about.

#![allow(unused)]
fn main() {
const RENDERABLE: ComponentFlags =
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF;

let entity = world.spawn_entities(RENDERABLE, 1)[0];
}

The full list of built-in flags is in the Components chapter.

Quick tour

#![allow(unused)]
fn main() {
use nightshade::prelude::*;

let entity = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH | MATERIAL_REF,
    1,
)[0];

world.core.set_local_transform(entity, LocalTransform {
    translation: Vec3::new(0.0, 5.0, 0.0),
    rotation: Quat::identity(),
    scale: Vec3::new(1.0, 1.0, 1.0),
});
world.core.set_render_mesh(entity, RenderMesh {
    name: "cube".to_string(),
    id: None,
});
world.core.set_material_ref(entity, MaterialRef::new("Default"));

for entity in world.core.query_entities(RENDER_MESH | LOCAL_TRANSFORM) {
    let transform = world.core.get_local_transform(entity).unwrap();
    let mesh = world.core.get_render_mesh(entity).unwrap();
}
}

Spawn returns a Vec<Entity> of length count. The set methods write a component value into the slot. The query returns an iterator over every entity whose archetype has at least the requested bits.

A second ECS for game logic

The engine World holds rendering, physics, transforms, and the rest of the engine state. Game logic that does not belong in the engine, things like player state, inventory, AI, and score, lives in a second freecs::ecs! block the game defines on its own.

#![allow(unused)]
fn main() {
use freecs::ecs;

ecs! {
    GameWorld {
        player_state: PlayerState => PLAYER_STATE,
        inventory: Inventory => INVENTORY,
        health: Health => HEALTH,
        enemy_ai: EnemyAI => ENEMY_AI,
    }
    Resources {
        game_time: GameTime,
        score: u32,
        level: u32,
    }
}
}

The game holds both worlds and runs systems against each.

#![allow(unused)]
fn main() {
pub struct MyGame {
    game: GameWorld,
}

impl State for MyGame {
    fn run_systems(&mut self, world: &mut World) {
        update_player(&mut self.game);
        sync_positions(&self.game, world);
    }
}
}

The dual-world pattern is covered in detail in apps/game/src/systems/props.rs. The link from a game entity to its engine-side render entity is an EngineEntity(Entity) component that the engine ships.

Chapter guide