⚡ Урок · JavaScript · Архітектура
📅 Березень 2026 ⏱ 20 хв 🎓 Середній рівень

Архітектура 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 тактів для влучання в кеш.

У цифрах: 100 000 частинок × 100 тактів = 10 млн тактів на кадр лише на промахи кешу. За 3 ГГц це 3,3 мс — ще до будь-яких обчислень. За розкладки SoA усі дані позицій лежать суцільно → більшість зчитувань — влучання в кеш → той самий цикл займає ~0,3 мс.

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())

Ключові правила:

3. Structure of Arrays проти Array of Structures

Замість одного масиву об'єктів використовуйте по одному масиву на кожне поле компонента. Коли ви ітеруєте позиції, ви читаєте єдиний суцільний Float32Array — максимально кеш-дружньо.

AoS (погано)
p0.x
p0.vx
p0.m
p1.x
p1.vx
p1.m
p2.x
p2.vx
SoA (добре)
p0.x
p1.x
p2.x
p3.x
p4.x
p0.vx
p1.vx
p2.vx

За 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.

Додаткові поради

Коли НЕ варто використовувати ECS: для симуляцій із менш ніж ~5 000 сутностей складність ECS не варта приросту продуктивності. Достатньо простих масивів об'єктів із TypedArray для «гарячих» полів. ECS окупається за 20 тис.+ сутностей або коли у вас багато різних типів сутностей із різноманітними комбінаціями компонентів.