Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 331 additions & 0 deletions docs/ecs/command-buffer.md

Large diffs are not rendered by default.

389 changes: 389 additions & 0 deletions docs/ecs/components.md

Large diffs are not rendered by default.

361 changes: 361 additions & 0 deletions docs/ecs/determinism.md

Large diffs are not rendered by default.

410 changes: 410 additions & 0 deletions docs/ecs/entities.md

Large diffs are not rendered by default.

531 changes: 531 additions & 0 deletions docs/ecs/pitfalls.md

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions docs/ecs/queries.md

Large diffs are not rendered by default.

320 changes: 320 additions & 0 deletions docs/ecs/scheduling.md

Large diffs are not rendered by default.

241 changes: 241 additions & 0 deletions docs/ecs/storage-model.md

Large diffs are not rendered by default.

412 changes: 412 additions & 0 deletions docs/ecs/systems.md

Large diffs are not rendered by default.

276 changes: 276 additions & 0 deletions docs/ecs/threading-and-reductions.md

Large diffs are not rendered by default.

550 changes: 550 additions & 0 deletions docs/ecs/world.md

Large diffs are not rendered by default.

381 changes: 381 additions & 0 deletions src/openvic-simulation/ecs/Archetype.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,381 @@
#pragma once

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <new>
#include <type_traits>
#include <utility>
#include <vector>

#include "openvic-simulation/ecs/ChecksumTraits.hpp"
#include "openvic-simulation/ecs/Chunk.hpp"
#include "openvic-simulation/ecs/ChunkPool.hpp"
#include "openvic-simulation/ecs/ComponentTypeID.hpp"
#include "openvic-simulation/ecs/EntityID.hpp"

namespace OpenVic::ecs {

// Type-erased operations needed to manage a component column without naming the type.
// For tag (zero-size, std::is_empty) component types, `size` and `align` are 0 and the
// move/destroy thunks are no-ops — the archetype then holds no slab for that column,
// only a row count tracked by chunks.
struct ColumnVTable {
std::size_t size;
std::size_t align;
void (*move_construct)(void* dst, void* src);
void (*destroy)(void* dst);
// Bulk analogue of move_construct: move-constructs `n` contiguous elements from `src`
// into `dst` and destroys the sources. dst/src must not overlap (the bulk paths always
// move from a staging block into a fresh slab region), which makes the memcpy fast
// path for trivially-copyable types legal (trivially copyable implies trivially
// destructible, so skipping the source destroy is also correct).
void (*move_construct_n)(void* dst, void* src, std::size_t n);
// Bulk analogue of destroy: destroys `n` contiguous elements at `dst`.
void (*destroy_n)(void* dst, std::size_t n);
// Folds `count` contiguous elements of this column into `seed` and returns the new
// running hash (the Checksum.hpp full-state walk). nullptr for tag columns — a tag's
// contribution is its presence in the archetype signature, folded separately.
uint64_t (*hash_rows)(void const* column, std::size_t count, uint64_t seed);
};

template<typename C>
inline ColumnVTable const& column_vtable_for() {
if constexpr (std::is_empty_v<C>) {
static ColumnVTable const v {
0,
0,
[](void*, void*) {},
[](void*) {},
[](void*, void*, std::size_t) {},
[](void*, std::size_t) {},
nullptr
};
return v;
} else {
static ColumnVTable const v {
sizeof(C),
alignof(C),
[](void* dst, void* src) {
::new (dst) C(std::move(*static_cast<C*>(src)));
static_cast<C*>(src)->~C();
},
[](void* dst) {
static_cast<C*>(dst)->~C();
},
[](void* dst, void* src, std::size_t n) {
if constexpr (std::is_trivially_copyable_v<C>) {
std::memcpy(dst, src, n * sizeof(C));
} else {
C* d = static_cast<C*>(dst);
C* s = static_cast<C*>(src);
for (std::size_t i = 0; i < n; ++i) {
::new (d + i) C(std::move(s[i]));
s[i].~C();
}
}
},
[](void* dst, std::size_t n) {
if constexpr (std::is_trivially_destructible_v<C>) {
(void) dst;
(void) n;
} else {
C* d = static_cast<C*>(dst);
for (std::size_t i = 0; i < n; ++i) {
d[i].~C();
}
}
},
// Global checksum enforcement point: instantiating this thunk static_asserts
// the universal hashing rule for every component type used with a World.
checksum_rows_thunk_for<C>()
};
return v;
}
}

// Sentinel for "tag column has no slab" / "component not in archetype".
constexpr std::size_t NO_COLUMN_OFFSET = static_cast<std::size_t>(-1);
constexpr std::size_t NO_COLUMN_INDEX = static_cast<std::size_t>(-1);

// One archetype = one unique sorted set of component type IDs.
// Storage is a list of fixed-size 16 KB chunks. Each chunk holds up to `chunk_capacity`
// rows; rows fill chunks left-to-right. When the last chunk is full, a fresh chunk is
// allocated. Removal swap-pops with the *last* row of the *last* chunk (cross-chunk swap
// when needed), and an emptied trailing chunk is dropped.
struct Archetype {
std::vector<component_type_id_t> signature;
std::vector<ColumnVTable const*> vtables;
// Byte offset of each column's slab within a chunk's `data`. Tag columns
// (vtable->size == 0) carry NO_COLUMN_OFFSET; their data must never be dereferenced.
std::vector<std::size_t> column_offsets;
// Per-column monotonic version counter, bumped on every push / swap-pop / migration
// touching the column. Replaces the per-Column version stamp from the pre-chunked
// design — `World::component_version_in<C>(eid)` reads from here.
std::vector<uint64_t> column_versions;
std::size_t chunk_capacity = 0; // rows per chunk; constant for the archetype's life
std::size_t total_entity_count = 0;
std::vector<DataChunk> chunks;
// Bitfield prefilter: one bit per component, derived from `id % 63`. Used by
// `World::resolve_query_cache` to fast-reject archetypes before the sorted-set walk.
uint64_t matcher_hash = 0;
// Non-owning pointer to the World's ChunkPool. Set by World when the archetype is
// created. Nullable: tests that construct an Archetype outside a World fall through
// to ::operator new / ::operator delete in allocate_chunk and the destructor.
ChunkPool* chunk_pool = nullptr;

Archetype() = default;
Archetype(Archetype const&) = delete;
Archetype& operator=(Archetype const&) = delete;
Archetype(Archetype&&) = default;
Archetype& operator=(Archetype&&) = default;

// Destroy every live component and release each chunk's backing block. With a
// ChunkPool wired in, World::~World calls drain_to_pool first and the loop below
// then sees an empty `chunks` vector — this destructor is the non-pool fallback
// (bare Archetype in tests, or any path that constructs an Archetype directly).
~Archetype() {
for (std::size_t ci = 0; ci < chunks.size(); ++ci) {
DataChunk& chunk = chunks[ci];
for (std::size_t row = 0; row < chunk.count; ++row) {
for (std::size_t col = 0; col < signature.size(); ++col) {
if (column_offsets[col] == NO_COLUMN_OFFSET) {
continue;
}
vtables[col]->destroy(row_in_column(ci, col, row));
}
}
chunk.count = 0;
if (chunk.data != nullptr) {
::operator delete(chunk.data, std::align_val_t { CHUNK_BLOCK_ALIGN });
chunk.data = nullptr;
}
}
}

// Returns NO_COLUMN_INDEX if the archetype doesn't carry `id`.
std::size_t column_index_for(component_type_id_t id) const {
for (std::size_t i = 0; i < signature.size(); ++i) {
if (signature[i] == id) {
return i;
}
}
return NO_COLUMN_INDEX;
}

bool has_component(component_type_id_t id) const {
return column_index_for(id) != NO_COLUMN_INDEX;
}

// Both inputs sorted ascending; returns true iff `required ⊆ signature`.
bool matches_all(std::vector<component_type_id_t> const& required) const {
std::size_t i = 0;
std::size_t j = 0;
while (i < required.size() && j < signature.size()) {
if (required[i] == signature[j]) {
++i;
++j;
} else if (signature[j] < required[i]) {
++j;
} else {
return false;
}
}
return i == required.size();
}

// Both inputs sorted ascending; returns true iff `excluded ∩ signature == ∅`.
bool matches_none(std::vector<component_type_id_t> const& excluded) const {
std::size_t i = 0;
std::size_t j = 0;
while (i < excluded.size() && j < signature.size()) {
if (excluded[i] == signature[j]) {
return false;
} else if (signature[j] < excluded[i]) {
++j;
} else {
++i;
}
}
return true;
}

// Returns the number of bytes of slab in a chunk that store EntityIDs (= chunk_capacity * sizeof(EntityID)).
std::size_t entity_slab_bytes() const {
return chunk_capacity * sizeof(EntityID);
}

EntityID* entity_array(std::size_t chunk_index) {
return reinterpret_cast<EntityID*>(chunks[chunk_index].data);
}
EntityID const* entity_array(std::size_t chunk_index) const {
return reinterpret_cast<EntityID const*>(chunks[chunk_index].data);
}

// Returns the base address of column `col`'s slab in the given chunk, or nullptr if
// the column is a tag (no slab). Callers must not dereference for tag columns.
void* column_array(std::size_t chunk_index, std::size_t col) {
if (column_offsets[col] == NO_COLUMN_OFFSET) {
return nullptr;
}
return chunks[chunk_index].data + column_offsets[col];
}
void const* column_array(std::size_t chunk_index, std::size_t col) const {
if (column_offsets[col] == NO_COLUMN_OFFSET) {
return nullptr;
}
return chunks[chunk_index].data + column_offsets[col];
}

// Returns a pointer to the column's slot at (chunk, row), or nullptr for tag columns.
void* row_in_column(std::size_t chunk_index, std::size_t col, std::size_t row) {
if (column_offsets[col] == NO_COLUMN_OFFSET) {
return nullptr;
}
return chunks[chunk_index].data + column_offsets[col] + row * vtables[col]->size;
}
void const* row_in_column(std::size_t chunk_index, std::size_t col, std::size_t row) const {
if (column_offsets[col] == NO_COLUMN_OFFSET) {
return nullptr;
}
return chunks[chunk_index].data + column_offsets[col] + row * vtables[col]->size;
}

// Allocates a new fresh chunk with no rows. The chunk's `data` pointer is non-null.
// Routes through chunk_pool when set; falls back to ::operator new otherwise (used
// by tests that construct an Archetype bare, without a World).
std::size_t allocate_chunk() {
DataChunk fresh;
if (chunk_pool != nullptr) {
fresh.data = chunk_pool->acquire();
} else {
fresh.data = static_cast<unsigned char*>(
::operator new(CHUNK_BLOCK_BYTES, std::align_val_t { CHUNK_BLOCK_ALIGN })
);
}
chunks.push_back(std::move(fresh));
return chunks.size() - 1;
}

// Drops the trailing chunk if it's empty, returning its block to the pool (or to
// ::operator delete if no pool is wired). No-op if there are no chunks or the
// trailing chunk still holds rows. No retain-one rule — a fully-drained archetype
// has chunks.size() == 0, and the next reserve_row pulls a fresh block from the
// pool at the same cost as the previous "retained spare" indexing.
void drop_empty_trailing_chunk() {
if (chunks.empty() || chunks.back().count != 0) {
return;
}
DataChunk& back = chunks.back();
if (chunk_pool != nullptr) {
chunk_pool->release(back.data);
} else if (back.data != nullptr) {
::operator delete(back.data, std::align_val_t { CHUNK_BLOCK_ALIGN });
}
back.data = nullptr;
chunks.pop_back();
}

// Destroys every live component and routes every chunk's block to the pool, then
// clears the chunks vector and resets total_entity_count. Called explicitly by
// World::~World before the archetypes vector destroys, so the (non-pool fallback)
// Archetype destructor sees an empty chunks vector and has nothing to do.
void drain_to_pool(ChunkPool& pool) {
for (std::size_t ci = 0; ci < chunks.size(); ++ci) {
DataChunk& chunk = chunks[ci];
for (std::size_t row = 0; row < chunk.count; ++row) {
for (std::size_t col = 0; col < signature.size(); ++col) {
if (column_offsets[col] == NO_COLUMN_OFFSET) {
continue;
}
vtables[col]->destroy(row_in_column(ci, col, row));
}
}
chunk.count = 0;
pool.release(chunk.data);
chunk.data = nullptr;
}
chunks.clear();
total_entity_count = 0;
}

// Reserve a new row at the end of storage — finds the first non-full chunk (or
// allocates a new one), bumps that chunk's `count`, and returns (chunk_index, row).
// Caller must placement-new component values into each non-tag column at this slot
// AFTER calling reserve_row, then push the EntityID via `entity_array(chunk_index)[row] = eid`.
// Bumps every column_version.
struct RowLocation {
std::size_t chunk_index;
std::size_t row;
};
RowLocation reserve_row() {
std::size_t chunk_index;
if (chunks.empty() || chunks.back().count >= chunk_capacity) {
chunk_index = allocate_chunk();
} else {
chunk_index = chunks.size() - 1;
}
std::size_t const row = chunks[chunk_index].count;
++chunks[chunk_index].count;
++total_entity_count;
for (uint64_t& v : column_versions) {
++v;
}
return { chunk_index, row };
}

// One contiguous run of freshly-reserved rows within a single chunk, produced by
// reserve_rows. Rows [row_begin, row_begin + count) in chunks[chunk_index].
struct RowRange {
std::size_t chunk_index;
std::size_t row_begin;
std::size_t count;
};

// Bulk equivalent of n× reserve_row with IDENTICAL packing: fills the partial trailing
// chunk first (only the trailing chunk can ever be partial — removal swap-pops the
// global last row), then allocates fresh chunks, in the same order reserve_row would.
// Clears `out` and appends one RowRange per touched chunk; the caller provides the
// vector so repeated batches reuse its capacity. Caller must placement-new component
// values into each non-tag column over every range, then write the EntityIDs via
// entity_array.
//
// Each column_version is bumped once per TOUCHED CHUNK, not once per row as the
// reserve_row loop would — versions only signal "this column changed" (CachedRef
// revalidation); nothing compares their numeric values across runs.
// No-op when n == 0.
void reserve_rows(std::size_t n, std::vector<RowRange>& out) {
out.clear();
if (n == 0) {
return;
}
std::size_t remaining = n;
while (remaining > 0) {
std::size_t chunk_index;
if (chunks.empty() || chunks.back().count >= chunk_capacity) {
chunk_index = allocate_chunk();
} else {
chunk_index = chunks.size() - 1;
}
std::size_t const row_begin = chunks[chunk_index].count;
std::size_t const take = std::min(remaining, chunk_capacity - row_begin);
chunks[chunk_index].count += take;
out.push_back({ chunk_index, row_begin, take });
remaining -= take;
}
total_entity_count += n;
for (uint64_t& v : column_versions) {
v += out.size();
}
}

// Returns the (chunk_index, row) of the global last row, used when swap-popping.
// Precondition: total_entity_count > 0.
RowLocation last_row_location() const {
std::size_t const last_chunk = chunks.size() - 1;
std::size_t const last_row = chunks[last_chunk].count - 1;
return { last_chunk, last_row };
}
};
}
Loading
Loading