
ECS (Entity-Component-System) - это архитектурный паттерн. Есть разные подходы к реализации ECS, но основная концепция остаётся неизменной. Остановимся на двух основных вариациях: Entity-Component и Entity-Component-System.
В ECS все игровые объекты представлены в виде сущностей (entities), и каждая сущность состоит из компонентов (components). Компоненты добавляют сущности определённое поведение или данные, делая их гибко настраиваемыми. Далее идут различия ECS и EC.
В подходе Entity-Component компонент - это объект, ответственный за небольшую часть функционала, хранящий необходимые для него данные и правила работы с ними внутри себя. Примеры компонентов:
Для примера возьмем игрока. Это сущность, какой-то объект, “нечто” с чем мы хотим работать. Как точка в геометрии. Нам нужно определить его возможности. В случае ООП и наследования мы могли создать класс Player, определить у него поля для веса, здоровья, размеров, координат, какое-то поведение, возможность перемещения в случае нажатой клавиши W. Выглядело это бы так:
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
self.hp = 100
self.speed = 5
def update(self):
keys = pygame.key.get_pressed()
if keys[pygame.K_RIGHT]:
self.x += self.speed
# ... attack, animations, item interaction...
def draw(self, screen, sprite):
screen.blit(sprite, (self.x, self.y))
public class Player
{
public float X, Y;
public int Hp = 100;
public float Speed = 5f;
Texture2D sprite;
public Player(float x, float y, Texture2D sprite)
{
X = x; Y = y; this.sprite = sprite;
}
public void Update()
{
var kb = Keyboard.GetState();
if (kb.IsKeyDown(Keys.Right)) X += Speed;
// ... attack, animations, item interaction...
}
public void Draw(SpriteBatch sb)
{
sb.Draw(sprite, new Vector2(X, Y), Color.White);
}
}
Довольно простой и понятный пример. Но на странице ООП показано, во что он может вылиться.
С компонентным подходом игрок мог бы выглядеть так:
class PositionComponent:
def __init__(self, x, y):
self.x = x
self.y = y
def update(self, dx, dy):
self.x += dx
self.y += dy
class InputComponent:
def get_direction(self):
keys = pygame.key.get_pressed()
dx = keys[pygame.K_RIGHT] - keys[pygame.K_LEFT] # bool: 1 or 0, difference gives -1, 0 or 1
dy = keys[pygame.K_DOWN] - keys[pygame.K_UP]
return dx, dy
class RenderComponent:
def __init__(self, color, size):
self.color = color
self.size = size
def draw(self, screen, x, y):
pygame.draw.rect(screen, self.color, (x, y, self.size, self.size))
class Player:
def __init__(self, position, render, input_comp):
self.position = position
self.render = render
self.input = input_comp
def update(self):
dx, dy = self.input.get_direction()
self.position.update(dx, dy)
def draw(self, screen):
self.render.draw(screen, self.position.x, self.position.y)
def run_game():
# ...
player.update()
#...
player.draw(screen)
public class PositionComponent
{
public float X, Y;
public PositionComponent(float x, float y)
{
X = x; Y = y;
}
}
public class InputComponent
{
public void Update(PositionComponent positionComponent)
{
var kb = Keyboard.GetState();
if (kb.IsKeyDown(Keys.Left)) positionComponent.X -= 2;
if (kb.IsKeyDown(Keys.Right)) positionComponent.X += 2;
if (kb.IsKeyDown(Keys.Up)) positionComponent.Y -= 2;
if (kb.IsKeyDown(Keys.Down)) positionComponent.Y += 2;
}
}
public class RenderComponent
{
private Texture2D texture;
public RenderComponent(Texture2D texture)
{
this.texture = texture;
}
public void Draw(SpriteBatch spriteBatch, PositionComponent positionComponent)
{
spriteBatch.Draw(texture, new Vector2(positionComponent.X, positionComponent.Y), Color.White);
}
}
public class Player
{
public PositionComponent PositionComponent { get; private set; }
public RenderComponent RenderComponent { get; private set; }
public InputComponent InputComponent { get; private set; }
public Player(Texture2D texture, float x, float y)
{
PositionComponent = new PositionComponent(x, y);
RenderComponent = new RenderComponent(texture);
InputComponent = new InputComponent();
}
public void Update(GameTime gameTime)
{
InputComponent.Update(PositionComponent);
}
public void Draw(SpriteBatch spriteBatch)
{
RenderComponent.Draw(spriteBatch, PositionComponent);
}
}

Например, PhysicsComponent, который обрабатывает физическую модель сущности, и MovementComponent - они должны быть одним компонентом (ведь как без физики двигаться) или же разными (тогда во втором ни капли физики не будет)? Или должны быть взаимосвязанными? А какой из них сначала мы обрабатываем?
Но есть и плюсы:
Чем отличается Entity-Component-System (ECS) от Entity-Component?
Наличием System в архитектуре.
Entity - всё так же сущность. Component - всё такой же небольшой независимый контейнер, который теперь содержит лишь данные без функционала. А System - это функция или класс, которая перебирает сущности с нужными компонентами и изменяет данные в них.
Т.е. логику работы компонентов, их поведение в ECS мы выносим в системы. Беря тот же пример с игроком получаем следующую картину:
В ECS подходе у нас могут быть системы как MovementSystem, HealthSystem, PhysicsSystem, каждая из которых работает с определенным набором компонентов. И не обязательно должно быть так, что одна система работает с одним типом компонентов.
MovementSystem, вероятно, должен работать с WorldDataComponent (компонент, хранящий местоположение игрока в мире) и с InputComponent (определяющим тип управления игроком).
И вот тут проявляется заковырка подобного подхода. Разве MovementSystem не должна работать с физикой? Нет. Вроде, это система, ответственная за передвижение. Но мы можем прыгать, и это тоже передвижение, а прыгать мы хотели бы в соответствии с законами физики. Т.е. Movement System должна еще обрабатывать физику? Возможный вариант. Но немного странный, ведь игрока может толкнуть какой-то объект в игровом мире.
Но это действие человека? Его передвижение персонажа? Не совсем, это физическое взаимодействие одного игрового объекта на другой. Тогда стоит вынести физические расчеты в Physics System, и пусть Movement System будет ответственна только за передвижение и обработку ввода? Но что делать тогда с физическими прыжками? На это мы можем сказать так: пусть сначала сработает Movement System, обработается весь ввод от игрока, а потом мы обработаем физику и внесем поправки в передвижение игрока.
В общем, вариантов тут куча, у каждого свои плюсы и минусы, которые нужно проанализировать и сделать подходящий выбор. Подробнее об этом - в порядке обновления.
Ок, давайте всё-таки напишем рабочий код, а не только будем обсуждать.
В ECS компоненты - это только данные. Никаких методов update, draw, handle_input. Просто контейнеры с полями.
# components: pure data, no logic
class Position:
def __init__(self, x=0, y=0):
self.x = x
self.y = y
class Velocity:
def __init__(self, vx=0, vy=0):
self.vx = vx
self.vy = vy
class Health:
def __init__(self, current, maximum):
self.current = current
self.maximum = maximum
class Sprite:
def __init__(self, image, width, height):
self.image = image
self.width = width
self.height = height
// components: pure data, no logic
public class Position
{
public float X, Y;
public Position(float x = 0, float y = 0) { X = x; Y = y; }
}
public class Velocity
{
public float Vx, Vy;
public Velocity(float vx = 0, float vy = 0) { Vx = vx; Vy = vy; }
}
public class Health
{
public int Current, Maximum;
public Health(int current, int max) { Current = current; Maximum = max; }
}
public class Sprite
{
public Texture2D Image;
public int Width, Height;
public Sprite(Texture2D img, int w, int h) { Image = img; Width = w; Height = h; }
}
А сущность - это просто набор компонентов. В простейшем случае - словарь:
class World:
def __init__(self):
self.next_id = 0
self.components = {} # {component_type: {entity_id: component}}
def create_entity(self):
eid = self.next_id
self.next_id += 1
return eid
def add_component(self, entity_id, component):
comp_type = type(component)
if comp_type not in self.components:
self.components[comp_type] = {}
self.components[comp_type][entity_id] = component
def get_component(self, entity_id, comp_type):
return self.components.get(comp_type, {}).get(entity_id)
def get_all_with(self, *comp_types):
# find entities that have ALL listed component types
if not comp_types:
return []
sets = [set(self.components.get(ct, {}).keys()) for ct in comp_types]
ids = sets[0]
for s in sets[1:]:
ids = ids & s
return sorted(ids)
# creating entities
world = World()
player = world.create_entity()
world.add_component(player, Position(100, 200))
world.add_component(player, Velocity(0, 0))
world.add_component(player, Health(100, 100))
world.add_component(player, Sprite(player_img, 32, 32))
# enemy: same components, different values
enemy = world.create_entity()
world.add_component(enemy, Position(400, 200))
world.add_component(enemy, Velocity(30, 0))
world.add_component(enemy, Health(50, 50))
world.add_component(enemy, Sprite(enemy_img, 32, 32))
# tree: no Velocity, no Health - just position and sprite
tree = world.create_entity()
world.add_component(tree, Position(250, 300))
world.add_component(tree, Sprite(tree_img, 64, 64))
public class World
{
private int nextId = 0;
private Dictionary<Type, Dictionary<int, object>> components = new();
public int CreateEntity() => nextId++;
public void AddComponent<T>(int entityId, T component)
{
var type = typeof(T);
if (!components.ContainsKey(type))
components[type] = new Dictionary<int, object>();
components[type][entityId] = component;
}
public T GetComponent<T>(int entityId)
{
var type = typeof(T);
if (components.ContainsKey(type) && components[type].ContainsKey(entityId))
return (T)components[type][entityId];
return default;
}
public IEnumerable<int> GetAllWith(params Type[] compTypes)
{
if (compTypes.Length == 0) yield break;
HashSet<int> ids = null;
foreach (var ct in compTypes)
{
if (!components.ContainsKey(ct)) yield break;
var set = new HashSet<int>(components[ct].Keys);
ids = ids == null ? set : new HashSet<int>(ids.Intersect(set));
}
if (ids != null)
{
var sorted = new List<int>(ids);
sorted.Sort();
foreach (var id in sorted) yield return id;
}
}
}
// creating entities
var world = new World();
int player = world.CreateEntity();
world.AddComponent(player, new Position(100, 200));
world.AddComponent(player, new Velocity(0, 0));
world.AddComponent(player, new Health(100, 100));
world.AddComponent(player, new Sprite(playerImg, 32, 32));
int enemy = world.CreateEntity();
world.AddComponent(enemy, new Position(400, 200));
world.AddComponent(enemy, new Velocity(30, 0));
world.AddComponent(enemy, new Health(50, 50));
world.AddComponent(enemy, new Sprite(enemyImg, 32, 32));
int tree = world.CreateEntity();
world.AddComponent(tree, new Position(250, 300));
world.AddComponent(tree, new Sprite(treeImg, 64, 64));
Обратите внимание: player, enemy, tree - это просто числа (0, 1, 2). Не объекты, не классы. Числа. Дерево отличается от врага не типом класса, а набором компонентов: у дерева нет Velocity и Health, поэтому оно не двигается и его нельзя убить.
Теперь системы - функции (или классы), которые обрабатывают все сущности с определённым набором компонентов:
def movement_system(world, delta_time):
for eid in world.get_all_with(Position, Velocity):
pos = world.get_component(eid, Position)
vel = world.get_component(eid, Velocity)
pos.x += vel.vx * delta_time
pos.y += vel.vy * delta_time
def render_system(world, screen, camera):
for eid in world.get_all_with(Position, Sprite):
pos = world.get_component(eid, Position)
spr = world.get_component(eid, Sprite)
sx, sy = camera.apply(pos.x, pos.y)
screen.blit(spr.image, (int(sx), int(sy)))
def damage_system(world):
for eid in world.get_all_with(Health):
hp = world.get_component(eid, Health)
if hp.current <= 0:
# entity is dead - remove it, play sound, spawn particles, etc.
pass
# game loop
while running:
movement_system(world, delta_time)
damage_system(world)
render_system(world, screen, camera)
public static class MovementSystem
{
public static void Update(World world, float deltaTime)
{
foreach (int eid in world.GetAllWith(typeof(Position), typeof(Velocity)))
{
var pos = world.GetComponent<Position>(eid);
var vel = world.GetComponent<Velocity>(eid);
pos.X += vel.Vx * deltaTime;
pos.Y += vel.Vy * deltaTime;
}
}
}
public static class RenderSystem
{
public static void Draw(World world, SpriteBatch spriteBatch, Camera camera)
{
foreach (int eid in world.GetAllWith(typeof(Position), typeof(Sprite)))
{
var pos = world.GetComponent<Position>(eid);
var spr = world.GetComponent<Sprite>(eid);
var screenPos = camera.Apply(pos.X, pos.Y);
spriteBatch.Draw(spr.Image, screenPos, Color.White);
}
}
}
// game loop
MovementSystem.Update(world, deltaTime);
RenderSystem.Draw(world, spriteBatch, camera);
movement_system обрабатывает все сущности, у которых есть Position и Velocity - неважно, игрок это, враг или снаряд. render_system рисует всё, у чего есть Position и Sprite - включая деревья, которые не двигаются. Системы ничего не знают о "типах" сущностей. Они знают только о компонентах.
Хотите добавить новое поведение? Создаёте новый компонент и новую систему. Хотите, чтобы дерево горело? Добавляете ему Health и Flammable - и damage_system начнёт обрабатывать дерево, хотя мы ни строчки в ней не изменили.
Хм, может показаться странным: почему сущность - это число, а не объект? В EC сущность была классом (Player, Enemy). Зачем в ECS от этого отказываться?
Код
Worldвыше использует словари - это упрощение для наглядности. Настоящие ECS-фреймворки (EnTT, Flecs, Unity DOTS) хранят компоненты в плотных массивах. Именно про эти массивы - следующий абзац.
Ответ, грубо говоря, в том, как данные лежат в памяти. Когда MovementSystem обрабатывает 1000 сущностей, она проходит по массиву Position и массиву Velocity. Все позиции рядом друг с другом, все скорости рядом друг с другом. Процессор загружает их в кэш блоками и обрабатывает очень быстро, ему не надо ждать, пока данные дойдут до него из оперативной памяти.
Если бы сущность была объектом с полями position, velocity, health, sprite - каждый объект лежал бы в произвольном месте памяти. Процессор, обрабатывая 1000 объектов, прыгал бы по памяти хаотично. Каждый такой прыжок - промах кэша (cache miss), и это в десятки раз медленнее, чем последовательное чтение.
Т.е. ECS быстрее не потому что алгоритм другой, а потому что данные расположены удобнее для железа. Это и есть data-oriented design - проектирование, ориентированное на данные, а не на объекты. Мы уже видели это в действии на примере систем частиц, где массив простых частиц обрабатывается быстрее, чем куча отдельных объектов.
В чистом Python разница в скорости между EC и ECS будет минимальной - Python и так медленный, и его объекты не лежат последовательно в памяти. Но в C# (особенно с
structвместоclassдля компонентов) и в C++ разница может быть огромной. Тем не менее, архитектурные преимущества ECS (гибкость, добавление поведения без изменения существующего кода) работают в любом языке.
Ок, архитектурные плюсы ECS работают в любом языке. Но что насчёт производительности? В Python обычные объекты хранят атрибуты в __dict__ - это словарь, а словарь - это хеш-таблица в памяти на каждый объект. Для тысячи компонентов Position - тысяча словарей. Не очень.
__slots__Самый простой шаг - добавить __slots__. Это говорит Python: "не создавай __dict__, храни атрибуты в фиксированных слотах". Меньше памяти, чуть быстрее доступ к полям.
class Position:
__slots__ = ('x', 'y')
def __init__(self, x=0, y=0):
self.x = x
self.y = y
class Velocity:
__slots__ = ('vx', 'vy')
def __init__(self, vx=0, vy=0):
self.vx = vx
self.vy = vy
# same usage, smaller memory footprint
// C# equivalent: use struct instead of class
// structs are value types - stored inline, no heap allocation
public struct Position
{
public float X, Y;
public Position(float x, float y) { X = x; Y = y; }
}
public struct Velocity
{
public float Vx, Vy;
public Velocity(float vx, float vy) { Vx = vx; Vy = vy; }
}
Грубо говоря, __slots__ - это Python-аналог struct в C#. Не совсем то же самое (объекты всё ещё на куче), но идея похожа: фиксированная структура вместо динамического словаря.
Настоящий data-oriented подход в Python - хранить компоненты не как массив объектов, а как массив чисел. Т.е. вместо тысячи объектов Position - два массива: все x отдельно, все y отдельно.
import numpy as np
class PositionStorage:
def __init__(self, max_entities):
self.x = np.zeros(max_entities, dtype=np.float32)
self.y = np.zeros(max_entities, dtype=np.float32)
class VelocityStorage:
def __init__(self, max_entities):
self.vx = np.zeros(max_entities, dtype=np.float32)
self.vy = np.zeros(max_entities, dtype=np.float32)
# movement system: one line, vectorized, fast
def movement_system(pos, vel, dt):
pos.x += vel.vx * dt
pos.y += vel.vy * dt
// C# equivalent: arrays of structs (SoA)
// this is what Unity DOTS does internally
float[] posX = new float[maxEntities];
float[] posY = new float[maxEntities];
float[] velX = new float[maxEntities];
float[] velY = new float[maxEntities];
// movement system: tight loop over contiguous arrays
for (int i = 0; i < count; i++)
{
posX[i] += velX[i] * dt;
posY[i] += velY[i] * dt;
}
Numpy выполняет pos.x += vel.vx * dt одной операцией на C-уровне, без цикла в Python. Для 10 000 сущностей разница может быть в 50-100 раз по сравнению с циклом по объектам.
Ну, конечно, numpy-подход сложнее: нужно заранее выделять массивы, следить за удалением сущностей, индексы вместо объектов. Но если вам нужны тысячи частиц или пуль - это именно то, как делают системы частиц в реальных играх.
__slots__- простая оптимизация, добавляется за минуту (хотя имеет свои особенности). numpy - серьёзное изменение архитектуры хранения. Начните с__slots__, переходите к numpy только если профилирование показало, что узкое место именно в обработке компонентов.
Допустим, нам нужен патрулирующий враг с здоровьем, которого можно отрисовать на экране. Как это выглядит в каждом подходе?
Наследование:
class Entity -> class GameObject -> class Enemy -> class PatrolEnemy
PatrolEnemy хранит позицию, здоровье, маршрут, спрайт и логику патрулирования. Всё в одном классе (или размазано по цепочке наследования). Хотим бочку, которая взрывается? Новая ветка: GameObject -> Barrel -> ExplodingBarrel. Код рендеринга дублируется.
Entity-Component:
PatrolEnemy:
- PositionComponent (данные + логика перемещения)
- HealthComponent (данные + логика урона)
- PatrolComponent (данные + логика патрулирования)
- RenderComponent (данные + логика отрисовки)
Лучше - поведение собирается из кубиков. Но каждый компонент содержит и данные, и логику, и компоненты могут зависеть друг от друга (PatrolComponent должен менять данные PositionComponent).
Entity-Component-System:
Entity #4:
- Position {x: 400, y: 200}
- Velocity {vx: 30, vy: 0}
- Health {current: 50, max: 50}
- Patrol {waypoints: [...], current_wp: 0}
- Sprite {image: enemy_img}
Systems: MovementSystem, PatrolSystem, DamageSystem, RenderSystem
Компоненты - чистые данные. Системы работают со всеми сущностями одинаково. PatrolSystem находит все сущности с Patrol и Velocity, вычисляет направление к следующей точке, обновляет Velocity. MovementSystem двигает всё, у чего есть Position и Velocity. RenderSystem рисует всё, у чего есть Position и Sprite. Ни одна система не знает слова "враг". Ну, собственно, в этом и суть.
Хотим бочку? Даём ей Position, Sprite, Health. Она уже рисуется и может получать урон. Добавляем компонент Explosive и ExplosionSystem - всё, бочка взрывается. Врагу это не мешает, частицам это не мешает, камере это не мешает.
Мы рассмотрели наследование в ООП, а теперь - EC и ECS. Чтобы было проще ориентироваться, вот краткое сравнение:
| Наследование (ООП) | Entity-Component | Entity-Component-System | |
|---|---|---|---|
| Суть | Классы наследуются друг от друга, образуя иерархию | Сущность собирается из компонентов, компонент хранит данные и логику | Сущность собирается из компонентов (только данные), логика вынесена в системы |
| Где логика? | В самом классе и его предках | В компонентах | В системах |
| Гибкость | Низкая: изменить поведение = менять иерархию | Высокая: добавить/убрать компонент | Высокая: добавить/убрать компонент или систему |
| Сложность | Простая для малого числа сущностей, растёт с иерархией | Средняя: нужно продумать границы компонентов | Выше: нужно продумать и компоненты, и системы, и порядок их работы |
| Проблемы | Множественное наследование, хрупкая иерархия, дублирование | Взаимозависимость компонентов, размытые границы | Сложность проектирования систем, порядок обновления |
| Когда подойдёт | Мало типов сущностей, простая иерархия, небольшой проект | Много разнообразных сущностей, нужна гибкость | Большой проект, много сущностей, нужна производительность и масштабируемость |
Не существует "лучшего" подхода. Для небольшой игры с парой типов врагов наследование может быть проще и понятнее. Для игры с десятками разных сущностей, которые комбинируют поведения - EC или ECS будет удобнее. Выбирайте исходя из ваших задач.
Может показаться, что ECS или EC легки в реализации, но это лишь на первый взгляд. Проблема выбора компонентов и систем приведена выше.
В реализации существует огромное множество нюансов, скрытых подвохов. Но и данный проект предназначен не для галочки, а для вашего личного опыта как программиста, а не погромиста.
ECS и EC реализуют принцип "Composition over Inheritance" - "Композиция вместо наследования". Вместо глубокой иерархии классов - набор маленьких независимых кубиков. Этот принцип шире ООП и применяется в data-oriented design, функциональном программировании и многих архитектурных паттернах.
ECS-подход (данные отдельно, логика отдельно, массивы однотипных элементов) уже применяется в других темах на этом сайте:
| Тема | Как связана с ECS |
|---|---|
| Системы частиц | Частица - чистые данные (позиция, скорость, возраст). Эмиттер - система. Пул частиц - data-oriented design в действии. |
| ИИ врагов | Враг можно собрать из компонентов: Patrol, Detection, Attack, Health. Системы обрабатывают каждый аспект отдельно. |
| Анимации | AnimationComponent хранит текущий кадр и таймер. AnimationSystem обновляет кадры для всех сущностей с этим компонентом. |
| Событийная модель | Системы могут общаться через события: DamageSystem публикует "entity_damaged", SoundSystem реагирует, не зная ничего о DamageSystem. |
| Архитектура | Обзор архитектурных подходов |
| Игровой цикл | Системы вызываются внутри цикла: Input -> Update -> Render. DeltaTime передаётся в каждую систему. |
| Тема | Зачем читать |
|---|---|
| Порядок обновления | Когда систем много - важно, в каком порядке они работают. Fixed timestep, swept collision. |
| Основной игровой цикл | Как устроен цикл, в который встраиваются все системы. |
| Событийная модель | Как системы общаются между собой без прямых зависимостей. |
| Архитектура | Общая картина: где ECS стоит среди других подходов. |