算法艺术创作
algorithmic-art
通过代码和可调参数创作生成艺术与交互式视觉效果。
ecs-pattern【设计与创意】Expert Entity Component System (ECS) architecture for high-performance game engines — data-oriented design, archetype storage, system scheduling, cache-coherent iteration, and production tuning.
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
ECS is the dominant data-oriented architecture for modern game engines (Unity DOTS, Bevy, Unreal Mass, Frostbite, Overwatch's ECS, Minecraft, Soldank, EnTT). It replaces scattered OOP objects with contiguous arrays of plain data so tens of thousands of entities update at 60 FPS on cache-limited CPUs.
A classic OOP hierarchy:
class GameObject {
public:
virtual void Update() = 0;
glm::vec3 m_Position;
float m_Health;
std::vector<GameObject*> m_Children;
};
class Enemy : public GameObject {
public:
void Update() override;
private:
std::string m_Name;
float m_Speed;
};
Each Enemy is heap-allocated individually. The pointer-chasing heap layout scatters m_Position across DRAM. Iterating 10,000 enemies forces the CPU to evict its L1/L2/L3 caches on every single object. The CPU does nothing but wait on memory.
Measured impact: random-access iteration over a fragmented heap is 20–100x slower than streaming over contiguous arrays. At 1.8 GHz × 8 cores, this is the difference between updating 1,000 and 100,000 entities per frame.
OOP layout (heap): [Enemy0][Enemy1][Enemy2] --- scattered pages ---
Data layout (SoA): Position[].x = {2.1, 9.4, 5.0, ...}
Position[].y = {0.0, 3.3, 8.2, ...}
Health[] = {100, 42, 7, ...}
| Term | Definition | Example |
|---|---|---|
| Entity | A lightweight opaque ID (integer). No data, no behavior. | Entity(42) is just uint32_t 42 |
| Component | A plain-data struct (POD, no virtuals, no methods beyond trivial accessors) | struct Position { float x, y, z; } |
| System | Pure logic that reads/writes components for every matched entity | MovementSystem does pos += vel * dt |
A cache line is 64 bytes. A Position + Velocity pair in SoA form is 24 bytes. A cache-line fetch delivers two entities' worth of hot data instead of two pointers to cold data. This is called a cache-miss-free stream.
%%{init: {"theme": "default", "flowchart": {"useMaxWidth": true}}}%%
flowchart TD
subgraph RAM ["DRAM: Component Arrays (SoA)"]
A["Position[0..N] (contiguous)"]
B["Velocity[0..N] (contiguous)"]
C["Health[0..N] (contiguous)"]
end
subgraph CORE ["CPU L1/L2 Cache"]
D["Cache Line: 64B"]
E["prefetch next line"]
end
F["Movement System"] -->|"iterates pos[i]+=vel[i]"| A
F --> B
F -->|"next element already in cache"| D
D --> E
The simplest ECS stores each component type in one flat array, where array index == entity ID:
struct PositionArray { std::vector<Vec3> data; }; // data[entity] = position
struct HealthArray { std::vector<float> data; }; // data[entity] = health
The canonical solution to sparse entity IDs. Two parallel arrays:
template<typename T>
class SparseSet {
std::vector<int> dense; // component payload, packed by insertion order
std::vector<int> sparse; // entity -> index into dense
std::vector<Entity> entities; // dense -> entity
public:
void add(Entity e, T comp) {
sparse[e.id] = dense.size();
dense.push_back(comp);
entities.push_back(e);
}
void remove(Entity e) {
int idx = sparse[e.id];
dense[idx] = dense.back(); dense.pop_back();
entities[idx] = entities.back(); entities.pop_back();
sparse[entities[idx].id] = idx;
}
T& get(Entity e) { return dense[sparse[e.id]]; }
};
sparse maps entity → index; dense packs only live components. Destruction swaps the last element into the hole (O(1)) — exactly how EnTT, Bevy, and flecs remove entities.
Archetypes group entities with identical component shape:
Archetype A = {Position, Velocity} archetype_id = hash([Position, Velocity])
Archetype B = {Position, Velocity, Health}
Each archetype owns contiguous "chunk" blocks (typically 4KB–16KB) that are pure SoA:
Chunk (16 KB):
Position[0..maxEntity Per Chunk]
Velocity[0..maxEntities]
Health [0..maxEntities] -- only if archetype has Health
Entity ID → archetype ID → chunk index → row. When an entity gains/removes a component, it is moved to a different archetype (data migration). This yields:
EnTT (the most popular single-header ECS) uses a grouped sparse set storage:
view<> iteration yields pointer-like iterators for direct SoA traversal.| Engine | Entity representation |
|---|---|
| Unity DOTS | uint32_t index + uint32_t version (generation counter) |
| Bevy | Entity { index: u32, generation: u32 } |
| EnTT | entt::entity = uint32_t (32-bit) or uint64_t with version bits |
| flecs | ecs_entity_t = uint64_t with flags |
| Unreal Mass | FMassEntityHandle { uint32 SerialNumber; uint32 Index; } |
Generation counters prevent a recycled ID from silently colliding with a stale reference:
uint32_t index = id & 0xFFFF; // low 16 bits = index
uint32_t generation = (id >> 16); // high 16 bits = version
Entity create(uint32_t& nextIndex, std::vector<uint32_t>& generation) {
uint32_t idx = freeList; // reuse a freed slot
uint32_t gen = generation[idx]; // bump the version
generation[idx] = gen + 1;
freeList = ...; // consume next freed slot
return pack(idx, gen);
}
bool isValid(Entity e, const std::vector<uint32_t>& generation) {
return generation[e.index()] == e.generation(); // stale ID detected
}
A system declares the components it reads (Reads) and writes (Writes). The scheduler uses this to:
// Bevy style
fn movement_system(time: Res<Time>, mut query: Query<(&mut Transform, &Velocity)>) {
for (mut transform, velocity) in query.iter_mut() {
transform.translation += velocity.dir * time.delta_seconds();
}
}
The scheduler builds a task graph each frame. Edges encode dependencies:
%%{init: {"theme": "default", "flowchart": {"useMaxWidth": true}}}%%
flowchart LR
A["Movement (W: Position)"] --> B["Physics (W: Collision, R: Position)"]
A --> C["Render Raycast (R: Position)"]
B --> D["Constraint Solve"]
C --> E["Draw"]
D --> E
BurstCompilerOptions + Entities.ForEach + ISystem schedule onto the main thread or job threads.systems: [input, movement, collisions, render]
Frame N: input ──► movement ──► physics ──► render
└─────────► ai ────────┘ (parallel branch)
Render of frame N overlaps simulation of frame N+1 via double buffering.
| Layout | Memory pattern | Cache behavior | Use case |
|---|---|---|---|
AoS struct {x,y,vx,vy} | interleaved per entity | poor for single-component systems | small systems, GPU vertex data |
SoA float x[], y[], vx[], vy[] | separate arrays | perfect streaming for one component at a time | simulation-heavy ECS |
| SoAoS (structure of arrays of structs) | runs of entities grouped | balance between locality and API ergonomics | hybrid, tile-based games |
// SoA: ideal for a Position+Velocity move system
float* x = posX.data(); float* y = posY.data();
float* vx = velX.data(); float* vy = velY.data();
for (size_t i = 0; i < n; ++i) { x[i] += vx[i] * dt; y[i] += vy[i] * dt; }
Design each system to touch the minimum set of components. MovementSystem should never touch Inventory. Pulling extra data into the cache line is the bug.
Visual) out of the hot update path entirely.Update()' into UpdateSim()(dirty flags) andRenderPrepare()` (called only when visible).Contiguous SoA arrays vectorize trivially:
// With -O3 / -ftree-vectorize, the compiler emits AVX2 for this loop:
for (size_t i = 0; i < n; i += 8) {
__m256 xs = _mm256_loadu_ps(x + i);
__m256 vs = _mm256_loadu_ps(vx + i);
_mm256_storeu_ps(x + i, _mm256_add_ps(xs, _mm256_mul_ps(vs, _mm256_set1_ps(dt))));
}
Even better: #pragma omp simd or [BurstCompile] in Unity (which generates ARM NEON / AVX2 automatically).
#include <vector>
#include <unordered_map>
#include <cstdint>
using Entity = uint32_t;
struct Position { float x, y; };
struct Velocity { float vx, vy; };
// Archetype-storage: one contiguous array per component type per archetype.
struct Archetype {
uint32_t id;
std::vector<Position> pos;
std::vector<Velocity> vel;
std::vector<Entity> entities;
};
class World {
std::vector<Archetype> archetypes;
std::unordered_map<uint32_t, uint32_t> entityToArchetype;
public:
Entity spawn(const Position& p, const Velocity& v) {
Archetype& a = archetypes[0]; // archetype {Pos, Vel}
a.pos.push_back(p);
a.vel.push_back(v);
Entity e = static_cast<Entity>(a.entities.size());
a.entities.push_back(e);
entityToArchetype[e] = 0;
return e;
}
template<typename Fn>
void forEachSystem(Fn&& fn) {
for (Archetype& a : archetypes) {
for (size_t i = 0; i < a.pos.size(); ++i)
fn(a.pos[i], a.vel[i]); // direct, cache-coherent access
}
}
};
int main() {
World w;
for (int i = 0; i < 100000; ++i)
w.spawn({(float)i, 0.f}, {0.01f, 0.02f});
float dt = 1.f / 60.f;
w.forEachSystem([&](Position& p, Velocity& v) { // 100k updates
p.x += v.vx * dt; // cache-friendly
p.y += v.vy * dt;
});
}
LocalToWorld from two sources).Entities.ForEach and IJobEntity to SIMD.[BurstCompile]
partial struct MovementJob : IJobEntity {
public float dt;
public void Execute(ref Position position, in Velocity velocity) {
position.Value += velocity.Value * dt;
}
}
commands.spawn((Transform::default(), Velocity::default())).Query<(&Transform, &Velocity), Without<Frozen>>.App.UMassEntitySubsystem, FMassEntityManager.FMassEntityHandle.UMassProcessor and UCLASS() markers.UMassObserver reacts to fragment add/remove.| Symptom | Better approach |
|---|---|
| <2,000 entities, one-off gameplay code | Plain OOP/MonoBehaviour is simpler and fine |
| Deep polymorphic behaviors (attack patterns, chain reactions) | State machines + ECS State component, or script-based (Blueprint) |
| Data exists only ephemerally per-event (input events, UI) | Message queue / event bus, not ECS storage |
| Team unfamiliar with data-oriented design, small scope | Keep OOP; adopt ECS only for hot paths (particles, crowds) |
| Cross-platform memory constraints (embedded) | Custom arena allocators + fixed arrays |
Rule of thumb: adopt ECS when you measure a cache-bound hot loop, not preemptively.
%%{init: {"theme": "default", "flowchart": {"useMaxWidth": true}}}%%
flowchart TD
A{Entity count > 10k?} -->|No| B["Sparse Set / simple dict"]
A -->|Yes| C{Hot path dominated by same-shape entities?}
C -->|Yes| D["Archetype chunks (DOTS/Bevy)"]
C -->|No| E["Grouped sparse set (EnTT)"]
B --> F[Single system loop]
D --> G[Burst/SIMD]
E --> G
flowchart TD
A{Component always present?} -->|Yes| B[Add to archetype at spawn]
A -->|No| C{Migrated rarely (<1%)?}
C -->|Yes| D[Runtime add: compile-time known]
C -->|No| E[Model as optional/tag component to avoid churn]
D --> F[Archetype migration cost: ~100ns/entity]
E --> F
| Anti-pattern | Why it hurts | Fix |
|---|---|---|
| God System | One system touching 8 components per entity defeats cache separation | Split into single-responsibility systems; pipeline |
| Storing pointers in components | Introduces indirection; blasts cache coherency | Store Entity IDs / handles, resolve on demand |
| Virtual methods in components | I-cache misses; prevents SoA layout; blocks SIMD | Plain data; behavior lives in systems |
| Entity iteration via indirection | for e in entities: lookup(e) = pointer chase | Region of interest queries designed into storage |
| Ignoring generation counters | Recycled IDs corrupt references / dangling handles | Always verify isValid() in hot paths |
| Migrating archetypes in tight loops | Thousands of copies per frame | Batch add/remove outside update hot loop; defer via command buffer |
| Single-thread main-thread systems | Wastes 4–8 cores | Use job system / parallel systems for independent sets |
| Copying component data | Implicit copies every spawn/move cost bandwidth | Move semantics; chunk recycling |
| Reading unrelated arrays | Extra cache pollution per entity | Query filtering (Without<T>, WriteGroup) |
EntityDebugger, Bevy bevy_ecs_debug_commands + bevy_inspector_egui (World inspector), EnTT entt::registry::view.perf stat -e cache-misses.-fopt-info-vec (GCC) / /Qvec-report:2 (MSVC) or Burst inspector shows whether loops vectorized.For deterministic replay (netcode/lockstep, replays):
PlayerInputCommand), so systems never read raw input directly.#[no_mangle]-exported can be DLL-reloaded for live iteration (UE4 modules, Unity Burst assemblies).f32 positions drift over long sessions; quantize or use f64 for world coordinates near origin.Games with huge open worlds don't need to simulate every entity every frame. ROI queries restrict systems to entities within a region:
// Bevy: iterate only entities visible to the current camera
fn render_cull(
camera: Query<&Transform, With<MainCamera>>,
mut query: Query<(&Transform, &mut Visible), Without<MainCamera>>,
) {
let cam_pos = camera.single().translation;
for (transform, mut visible) in query.iter_mut() {
let dist2 = transform.translation.distance_squared(cam_pos);
visible.is_visible = dist2 < CULL_DISTANCE_SQ;
}
}
Hashed spatial grids + chunk buckets let systems skip whole regions:
Grid: 0 1 2 3
+---+---+---+---+
0 | | | | |
+---+---+---+---+ Cells active for camera: {1,2}
1 | | X | X | | (only these archetypes get updated)
+---+---+---+---+
Structural changes (add/remove components, spawn/destroy) inside a parallel system must be deferred. The canonical pattern is entity command buffers:
// EnTT: enqueue operations during parallel iteration, replay later
registry.view<Position>().each([&](entt::entity e, Position& p) {
if (p.y < -100.f)
to_destroy.push_back(e); // record
});
// after parallel section:
registry.destroy(to_destroy.begin(), to_destroy.end()); // bulk
Unity DOTS EntityCommandBuffer and Bevy Commands do exactly this under the hood. Never mutate entity structure while a view/query is iterating.
For ultimate throughput, move the simulation to the GPU:
Enable this ONLY when >100k entities and access pattern is uniform (no branching on per-entity unique data). Typical use: particle systems, crowd simulation. Hybrid pattern: GPU for simulation, CPU ECS for logic/decision.
Pure ECS naturally models flat data, but transforms & parenting produce trees:
#[derive(Component)]
struct Parent(Entity);
#[derive(Component)]
struct Children(Vec<Entity>);
The relationship ChildOf in Flecs is first-class (see references). For transforms, propagate parent→child each frame in a dedicated TransformPropagateSystem — never each frame in every child's logic.
Editor/undo/redo, debuggers, and web overlays need component modifications tracked. Keep a monotonically increasing component version counter:
uint64_t worldVersion; // bumps on every structural/mutating op
uint64_t entityVersion[MaxEntities]; // per-entity since-changed marker
Tools subscribe to version changes and invalidate caches only for dirty entities.
ECS data must feed physics and audio engines without copies:
CollisionEvent components.SyncAudioSources system reads ECS AudioSource + Transform and writes into the audio engine's source pool.SyncRenderBuffers packs ECS transforms into StructuredBuffer scheduled before GPU upload.Keep these as boundary systems; do not let physics/audio own component memory.
| Operation | Rough cost (modern desktop CPU) |
|---|---|
| Entity create/destroy | ~20–60 ns |
| Archetype migration (1 component) | ~100–500 ns |
| Iterate 1M entities (one component, SIMD) | ~1–3 ms |
| Iterate 1M entities (2 components, SoA) | ~2–5 ms |
| Snapshot 1M entities serialization | ~5–15 ms |
| Rebuild spatial hash (1M entities) | ~2–8 ms |
| Command buffer playback (10k ops) | ~0.5–2 ms |
Budget rule: simulation should stay under 4 ms/frame at 60 FPS (leave ~8 ms for render, 4 ms headroom). If your ECS core exceeds 4 ms, you are memory-bound — fix layout, not logic.
references/archetype-storage-deep-dive.md — chunk layout, migration, fragmentationreferences/system-scheduling-pipelines.md — dependency graphs, parallel scheduling, determinismreferences/unity-dots-in-practice.md — Burst, Jobs, WriteGroup, structural changesreferences/bevy-ecs-patterns.md — queries, bundles, states, commands, ECS in Rustreferences/entt-and-flecs.md — sparse sets, groups, observers, eventsreferences/ecs-netcode.md — snapshot replication, rollback, lockstep with ECS