A high-performance, archetype-based Entity Component System (ECS) library for modern C++20, featuring SIMD optimizations, relationship graphs, and cache-efficient iteration.
- Archetype-based storage - Entities with identical component sets grouped in contiguous 16KB chunks
- SIMD acceleration - Hardware-optimized operations (SSE2/SSE4.2/AVX2/NEON)
- Relationship graphs - Hierarchical parent-child and bidirectional entity links
- Advanced queries - Compile-time optimized queries with Optional, Not, Any, OneOf modifiers\
#include <Astra/Astra.hpp>
// Components must be nothrow-move-constructible and nothrow-destructible.
// Trivially copyable components get memcpy fast paths; non-trivial types
// are fully supported via type-erased descriptors.
struct Position {
float x, y, z;
};
struct Velocity {
float dx, dy, dz;
};
int main() {
// Create registry
Astra::Registry registry;
// Default-construct components
auto entity = registry.CreateEntity<Position, Velocity>();
// Or supply values directly
auto other = registry.CreateEntityWith(Position{0, 0, 0}, Velocity{1, 0, 0});
// Query and iterate - 1.05ns per entity at 10K scale
auto view = registry.CreateView<Position, Velocity>();
view.ForEach([](Astra::Entity e, Position& pos, Velocity& vel) {
pos.x += vel.dx;
pos.y += vel.dy;
pos.z += vel.dz;
});
return 0;
}- C++20 compatible compiler:
- MSVC 2022+ (Windows)
- GCC 11+ (Linux)
- Clang 13+ (macOS/Linux)
- Premake 5.0.0-beta6 or newer
# Generate Visual Studio 2022 (or later) solution
scripts/generate_vs2022.bat
# Open generated solution
Astra.slnCI downloads premake 5.0.0-beta6 automatically. For local builds the premake5 binary must be on PATH or the scripts directory.
- Debug - Debug symbols, assertions enabled (
ASTRA_BUILD_DEBUG) - Release - Optimized with debug symbols (
ASTRA_BUILD_RELEASE) - Dist - Maximum optimization, no debug symbols (
ASTRA_BUILD_DIST)
Astra groups entities with identical component sets into "archetypes", storing components in Structure-of-Arrays format within 16KB memory chunks:
Archetype [Position, Velocity]:
Chunk 0 (16KB):
[Position][Position][Position]... (contiguous array)
[Velocity][Velocity][Velocity]... (contiguous array)
[Entity][Entity][Entity]... (entity IDs)
Chunk 1 (16KB):
... more entities ...
This design ensures:
- Cache locality - Components accessed together are stored together
- SIMD-friendly - Component arrays are naturally vectorizable
- Memory efficiency - Minimal fragmentation with chunk allocation
- Fast iteration - Linear memory access pattern
Astra's query system uses compile-time validation and optimization:
// Basic queries
auto movables = registry.CreateView<Position, Velocity>();
// Advanced query modifiers
auto enemies = registry.CreateView<Position, Enemy, Not<Dead>>();
auto renderables = registry.CreateView<Transform, Optional<Sprite>>();
auto targets = registry.CreateView<Position, Any<Player, Enemy, NPC>>();
auto weapons = registry.CreateView<Item, OneOf<Sword, Bow, Staff>>();Query modifiers:
Optional<T>- Component may or may not exist (nullptr if absent)Not<T>- Exclude entities with component TAny<T...>- At least one of the specified componentsOneOf<T...>- Exactly one of the specified components
Separate from component storage to prevent archetype fragmentation:
// Hierarchies
registry.SetParent(child, parent);
auto relations = registry.GetRelations(parent);
for (Astra::Entity child : relations.GetChildren()) {
// Process children
}
// Filtered relationships
auto physicsChildren = registry.GetRelations<RigidBody>(parent);
physicsChildren.ForEachDescendant([](Entity e, size_t depth, RigidBody& rb) {
// Only descendants with RigidBody
});
// Bidirectional links
registry.AddLink(entity1, entity2);Components are data structures attached to entities. The Component concept
requires nothrow-move-constructible and nothrow-destructible. Trivially copyable
types get memcpy fast paths automatically; non-trivial types are fully supported
via type-erased descriptors.
struct Transform
{
float x, y, z;
float rotation;
float scale;
};
struct Health
{
int current;
int max;
};
// Register component (optional, for runtime type info)
auto componentRegistry = registry.GetComponentRegistry();
componentRegistry->RegisterComponent<Transform>();Entities are lightweight IDs that reference component data:
// Default-construct components
auto player = registry.CreateEntity<Transform, Health>();
// Or supply values
auto player = registry.CreateEntityWith(
Transform{100, 0, 50, 0, 1},
Health{100, 100}
);
// Add/remove components
registry.AddComponent<Velocity>(player, Velocity{0, 0, 0});
registry.RemoveComponent<Velocity>(player);
// Access components
if (auto* health = registry.GetComponent<Health>(player))
{
health->current -= 10;
}
// Destroy entity
registry.DestroyEntity(player);Views provide efficient iteration over entities with specific components:
// Basic view - entities with Position AND Velocity
auto view = registry.CreateView<Position, Velocity>();
// With query modifiers
auto enemies = registry.CreateView<Position, Enemy, Astra::Not<Dead>>();
auto targets = registry.CreateView<Position, Astra::Any<Player, Enemy>>();
auto renderables = registry.CreateView<Transform, Astra::Optional<Sprite>>();
// Iteration methods
view.ForEach([](Astra::Entity e, Position& pos, Velocity& vel) {
// ForEach - Fastest (~1.05ns/entity)
pos.x += vel.dx;
});
// Or use range-based for loop - dereference yields references, not pointers
for (auto [entity, pos, vel] : view)
{
// Range-based - Clean syntax (~3-4ns/entity)
pos.x += vel.dx;
}Not<T>- Exclude entities with component TOptional<T>- Include component T if present (can be nullptr)AnyOf<T...>- Require at least one of the specified componentsOneOf<T...>- Require exactly one of the specified components
Astra supports entity relationships for hierarchies and graphs:
// Parent-child relationships
auto parent = registry.CreateEntity<Transform>();
auto child = registry.CreateEntity<Transform>();
registry.SetParent(child, parent);
// Query relationships
auto relations = registry.GetRelations(parent);
for (Astra::Entity child : relations.GetChildren()) {
// Process children
}
// Filtered relationships
auto physicsChildren = registry.GetRelations<RigidBody>(parent);
physicsChildren.ForEachChild([](Entity e, RigidBody& rb)
{
// Only children with RigidBody component
});
// Entity links (many-to-many)
registry.AddLink(entity1, entity2);
for (Astra::Entity linked : relations.GetLinks()) {
// Process linked entities
}Optimize entity creation and destruction:
// Default-construct 1000 entities (Position + Velocity zeroed)
std::vector<Astra::Entity> enemies(1000);
registry.CreateEntities<Position, Velocity>(1000, enemies);
// Or supply per-entity values via a generator
registry.CreateEntitiesWith<Position, Velocity>(1000, enemies,
[](size_t i) {
return std::make_tuple(
Position{static_cast<float>(i) * 10.0f, 0, 0},
Velocity{-1, 0, 0}
);
});
// Batch destroy
registry.DestroyEntities(enemies);Astra creates no threads. Registries are single-threaded by design: structural
changes (create/destroy/add/remove) must not race. The job system is an open
seam: inject an IWorkScheduler (e.g. an enkiTS adapter) via
Registry::Config::workScheduler and ParallelForEach /
ParallelForEachDescendant / ParallelExecutor will use it -- with no
scheduler injected they run sequentially inline. Structural changes from worker
threads are deferred via CommandBuffer (thread-safe). RelationshipGraph
traversal caches and MetaRegistry are internally synchronized so concurrent
reads through an injected scheduler stay safe.
// No scheduler -- all Parallel* APIs run sequentially inline (the default)
Astra::Registry registry;
// With a scheduler -- parallel iteration uses the injected implementation
auto scheduler = std::make_shared<MyEnkiTSAdapter>();
Astra::Registry::Config config;
config.workScheduler = scheduler;
Astra::Registry registry(config);Configure memory allocation via ArchetypeChunkPool::Config:
Astra::Registry::Config config;
config.chunkPoolConfig.chunkSize = 16384; // bytes per chunk (default 16KB)
config.chunkPoolConfig.chunksPerBlock = 128; // chunks per allocator block
config.chunkPoolConfig.maxChunks = 4096; // hard cap
config.chunkPoolConfig.initialBlocks = 0; // pre-warm blocks at startup
config.chunkPoolConfig.useHugePages = true; // 2MB huge pages when available
Astra::Registry registry(config);By default every module gets its own DefaultTypeContext, which assigns
component IDs independently. If a host EXE and a plugin DLL must share one
Registry, they must agree on IDs. The host creates a shared context and hands
it to each plugin before any ECS use:
// Host EXE
auto ctx = std::make_unique<Astra::TypeContext>();
Astra::SetTypeContext(ctx.get()); // install in this module
LoadPlugin("myplugin.dll", ctx.get());
// Plugin DLL -- called by the host immediately after LoadLibrary
extern "C" void PluginInit(Astra::TypeContext* ctx)
{
// Must run before any TypeID<T>::Value() or Registry use in this module.
// Do NOT call TypeID<T>::Value() from your own static initializers --
// IDs are cached in per-module statics on first access, which happens
// before this call when triggered by a static initializer.
Astra::SetTypeContext(ctx);
}SetTypeContext also drains any pending static meta-registrations into the
context. The pending queue is module-local; registrations enqueued before
SetTypeContext is called (e.g. from ASTRA_REFLECT macros in static
initializers) are flushed when the context is installed.
Hot-reload sequence: serialize world -> unload DLL -> load new DLL ->
SetTypeContext -> componentRegistry->ReRegisterComponent<T>() for each type
whose descriptor may have changed -> deserialize. ReRegisterComponent
unconditionally rebuilds the descriptor (move/copy/serialize function pointers)
so they target the newly loaded module code. Type IDs are stable across reloads
because TypeID resolves by XXHash64 of the type name through the shared
TypeContext.
Astra automatically detects and uses available SIMD instructions:
- x86/x64: SSE2 (required), SSE4.2, AVX2
- ARM: NEON
void UpdateMovement(Astra::Registry& registry, float deltaTime) {
auto view = registry.CreateView<Position, Velocity, Astra::Not<Frozen>>();
view.ForEach([deltaTime](Astra::Entity e, Position& pos, Velocity& vel) {
pos.x += vel.dx * deltaTime;
pos.y += vel.dy * deltaTime;
pos.z += vel.dz * deltaTime;
});
}void UpdateWorldTransforms(Astra::Registry& registry, Astra::Entity root) {
auto relations = registry.GetRelations<Transform>(root);
relations.ForEachDescendant(
[](Astra::Entity e, size_t depth, Transform& local) {
// Update world transform based on parent
// Depth indicates hierarchy level
},
Astra::TraversalOrder::DepthFirst
);
}Run the included benchmarks!
Astra is available under the MIT License. See LICENSE file for details.
Inspired by: