Архітектура ECS для масштабних симуляцій
Коли ваша симуляція частинок має 10 000 сутностей і працює нижче за 30 fps, проблема зазвичай у розкладці даних, а не в алгоритмі. Entity-Component-System (ECS) зі сховищем Structure-of-Arrays може дати у 3–10× більшу пропускну здатність порівняно з підходами «об'єкт на сутність» — ось як і чому.
1. Чому не просто об'єкти? (проблема промахів кешу)
Природний підхід у JavaScript зберігає кожну частинку як об'єкт:
// Array of Objects (AoS) — інтуїтивно, але повільно для масових оновлень
class Particle {
constructor() {
this.x = 0; this.y = 0; this.z = 0;
this.vx = 0; this.vy = 0; this.vz = 0;
this.mass = 1; this.alive = true;
// ... більше полів
}
}
const particles = Array.from({length: 100000}, () => new Particle());
// Цикл оновлення
for (const p of particles) {
p.x += p.vx * dt; // читання розкидані по купі — ПРОМАХ КЕШУ на кожній частинці
}
Проблема: кожен об'єкт Particle лежить за випадковою адресою в купі. Ітерація по 100 тис. частинок означає 100 тис. окремих ділянок пам'яті — це нівелює кеш CPU L1/L2 (зазвичай 32–512 КБ). На сучасному CPU промах кешу коштує ~100 тактів проти ~4 тактів для влучання в кеш.
2. Базові концепції ECS
ECS розділяє дані та логіку на три окремі абстракції:
| Концепція | Роль | Приклад |
|---|---|---|
| Entity (сутність) | Унікальний ID — і нічого більше | Сутність 42 (просто число) |
| Component (компонент) | Проста структура даних, без методів | Position{x,y,z}, Velocity{vx,vy,vz}, Mass{m} |
| System (система) | Логіка, що обробляє сутності з певними компонентами | GravitySystem (потребує Position + Velocity + Mass) |
| World (світ) | Контейнер, що містить усі сутності, сховища компонентів і системи | world.addSystem(new GravitySystem()) |
Ключові правила:
- Компоненти містять лише дані — без методів, без логіки
- Системи містять лише логіку — без сталого стану (окрім конфігурації)
- Сутності — це просто цілочисельні ID, а World керує їхньою належністю до компонентів
3. Structure of Arrays проти Array of Structures
Замість одного масиву об'єктів використовуйте по одному масиву на кожне поле компонента. Коли ви ітеруєте позиції, ви читаєте єдиний суцільний Float32Array — максимально кеш-дружньо.
За SoA система, що звертається лише до позицій, читає x[],
y[], z[] — три суцільні масиви. SIMD-векторизатор
у V8/SpiderMonkey також може автоматично векторизувати внутрішній цикл.
4. Клас World
class World {
constructor(capacity = 100000) {
this.capacity = capacity;
this.entityCount = 0;
this.components = new Map(); // componentName → ComponentStore
this.systems = [];
this.alive = new Uint8Array(capacity); // бітсет активних сутностей
}
createEntity() {
const id = this.entityCount++;
this.alive[id] = 1;
return id;
}
destroyEntity(id) {
this.alive[id] = 0;
// Дані компонентів за індексом id тепер вважаються "сміттям"
// Можна повернути в обіг, ведучи список вільних слотів (free-list)
}
registerComponent(name, store) {
this.components.set(name, store);
}
addSystem(system) {
system.world = this;
this.systems.push(system);
}
update(dt) {
for (const system of this.systems) system.update(dt);
}
}
5. Сховище компонентів
// SoA-сховище компонента для 3D-позиції
class PositionStore {
constructor(capacity) {
this.x = new Float32Array(capacity);
this.y = new Float32Array(capacity);
this.z = new Float32Array(capacity);
}
set(id, x, y, z) {
this.x[id] = x; this.y[id] = y; this.z[id] = z;
}
}
class VelocityStore {
constructor(capacity) {
this.x = new Float32Array(capacity);
this.y = new Float32Array(capacity);
this.z = new Float32Array(capacity);
}
}
class MassStore {
constructor(capacity) {
this.m = new Float32Array(capacity);
this.invM = new Float32Array(capacity); // попередньо обчислене 1/m
}
set(id, mass) {
this.m[id] = mass;
this.invM[id] = 1.0 / mass;
}
}
// Реєструємо у World
const world = new World(200000);
world.registerComponent('pos', new PositionStore(200000));
world.registerComponent('vel', new VelocityStore(200000));
world.registerComponent('mass', new MassStore(200000));
// Створюємо сутності
for (let i = 0; i < 100000; i++) {
const id = world.createEntity();
world.components.get('pos').set(id,
(Math.random()-0.5)*100, (Math.random()-0.5)*100, 0);
world.components.get('vel').set(id, ... );
world.components.get('mass').set(id, 1.0 + Math.random());
}
6. Системи та запити
class GravitySystem {
constructor(g = -9.81) { this.g = g; }
update(dt) {
const { world, g } = this;
const count = world.entityCount;
const alive = world.alive;
const vel = world.components.get('vel');
// Пряма ітерація по масиву — без виділення об'єктів, максимально кеш-дружньо
for (let id = 0; id < count; id++) {
if (!alive[id]) continue; // пропускаємо мертві сутності
vel.y[id] += g * dt; // застосовуємо гравітацію до vy
}
}
}
class IntegrateSystem {
update(dt) {
const { world } = this;
const count = world.entityCount;
const alive = world.alive;
const pos = world.components.get('pos');
const vel = world.components.get('vel');
const posX = pos.x, posY = pos.y, posZ = pos.z;
const velX = vel.x, velY = vel.y, velZ = vel.z;
// Розгорнуто для найкращої JIT-оптимізації (V8 може SIMD-векторизувати цей патерн)
for (let id = 0; id < count; id++) {
if (!alive[id]) continue;
posX[id] += velX[id] * dt;
posY[id] += velY[id] * dt;
posZ[id] += velZ[id] * dt;
}
}
}
world.addSystem(new GravitySystem(-9.81));
world.addSystem(new IntegrateSystem());
if (!alive[id])
повністю. Вартість хибного передбачення гілки може бути помітною за
100 тис. ітерацій.
7. Повний приклад: симуляція частинок із рендерингом на GPU
ECS виконує логіку симуляції на CPU. Щокадру вивантажуйте масиви
позицій у GPU-буфер і рендерте за допомогою
gl.drawArrays(gl.POINTS, …):
// Одноразово: створюємо чергований GPU VBO з даних SoA
const vbo = gl.createBuffer();
let gpuPositions = new Float32Array(world.entityCount * 3);
class RenderSystem {
constructor(gl, shaderProgram) {
this.gl = gl;
this.program = shaderProgram;
this.vbo = gl.createBuffer();
this.aLoc = gl.getAttribLocation(shaderProgram, 'aPos');
}
update(_dt) {
const { gl, world } = this;
const count = world.entityCount;
const pos = world.components.get('pos');
// Чергуємо SoA → чергований формат для GPU (або використовуйте окремі буфери атрибутів)
for (let i = 0, id = 0; id < count; id++) {
if (!world.alive[id]) continue;
gpuPositions[i++] = pos.x[id];
gpuPositions[i++] = pos.y[id];
gpuPositions[i++] = pos.z[id];
}
gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo);
gl.bufferData(gl.ARRAY_BUFFER, gpuPositions, gl.DYNAMIC_DRAW);
gl.useProgram(this.program);
gl.enableVertexAttribArray(this.aLoc);
gl.vertexAttribPointer(this.aLoc, 3, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.POINTS, 0, aliveCount);
}
}
Float32Array на кожен атрибут і використовуйте
gl.bindBuffer + gl.bufferSubData, щоб оновлювати лише
змінені частини. Як альтернатива — виконуйте симуляцію теж на GPU
(обчислювальний шейдер або пінг-понг фрагментних шейдерів) і повністю
уникайте вивантаження CPU→GPU.
8. Поради щодо продуктивності та бенчмарки
| Підхід | Час/кадр для 100 тис. частинок |
|---|---|
| Array of Objects (AoS), наївний JS | ~18 мс |
| AoS з типізованими масивами (Float64Array) | ~8 мс |
| SoA Float32Array — у стилі ECS | ~2,5 мс |
| SoA + пропуск перевірки «живості» (щільний) | ~1,8 мс |
| Обчислення на GPU (фрагментний шейдер WebGL2) | ~0,3 мс |
Бенчмарки на Chrome 120, MacBook Air M2. Час = оновлення на CPU + рендеринг, без урахування VSYNC.
Додаткові поради
- Використовуйте Float32, а не Float64: Float32 — це 4 байти проти 8 — у кеш поміщається вдвічі більше даних. Достатньо для позицій і швидкостей, якщо вам не потрібна астрономічна точність.
- Список вільних слотів для мертвих сутностей: замість сканування мертвих слотів ведіть чергу повторно використовуваних ID сутностей. Заповнюйте з початку масиву, щоб максимізувати щільність кешу.
- Бітова маска компонентів: зберігайте бітову маску на кожну сутність, що вказує, які компоненти вона має. Запит про те, які сутності мають і Position, і Velocity, стає скануванням з побітовим AND.
- Групуйте сутності за архетипом: усі сутності з однаковим набором компонентів спільно використовують суцільні блоки пам'яті (саме так роблять Unity DOTS і ECS у Bevy). Потребує більше інфраструктури, але дає 5–20× прискорення для складних запитів компонентів.
- Уникайте видалення + повторного створення: повторно використовуйте ID сутностей замість виклику createEntity() щокадру. Коливання у виділенні пам'яті створюють навантаження на GC.