Отслеживание изменений
StaticEcs предоставляет четыре типа отслеживания изменений, все без аллокаций и включаются явно:
| Тип | Что отслеживает | Область | Где включается |
|---|---|---|---|
| Added | Добавление компонента/тега | Компоненты, теги | ComponentTypeConfig / TagTypeConfig |
| Deleted | Удаление компонента/тега | Компоненты, теги | ComponentTypeConfig / TagTypeConfig |
| Changed | Доступ к данным компонента через ref | Только компоненты | ComponentTypeConfig |
| Created | Создание сущности | Весь мир | WorldConfig.TrackCreated |
- Bitmap-хранение: один
ulongна 64 сущности для каждого отслеживаемого типа - Трекинг версионируется по тикам мира через кольцевой буфер (по умолчанию 8 тиков). Каждая система автоматически видит изменения с момента своего последнего запуска
- Нулевые накладные расходы для типов с выключенным трекингом
- Нулевые накладные расходы для
CreatedприWorldConfig.TrackCreated = false
Конфигурация
Весь трекинг выключен по умолчанию и должен быть явно включён при регистрации типов.
Компоненты
ComponentTypeConfig<T> поддерживает три флага трекинга: trackAdded, trackDeleted, trackChanged:
W.Create(WorldConfig.Default());
//...
// Включить все три типа трекинга
W.Types().Component<Health>(new ComponentTypeConfig<Health>(
trackAdded: true,
trackDeleted: true,
trackChanged: true
));
// Включить только одно направление
W.Types().Component<Velocity>(new ComponentTypeConfig<Velocity>(
trackAdded: true // отслеживать только добавление
));
// Полная конфигурация с трекингом
W.Types().Component<Position>(new ComponentTypeConfig<Position>(
guid: new Guid("..."),
defaultValue: default,
trackAdded: true,
trackDeleted: true,
trackChanged: true
));
//...
W.Initialize();
Теги
TagTypeConfig<T> поддерживает trackAdded и trackDeleted. Теги не поддерживают Changed-трекинг.
W.Types().Tag<Unit>(new TagTypeConfig<Unit>(
trackAdded: true,
trackDeleted: true
));
// С GUID для сериализации
W.Types().Tag<Poisoned>(new TagTypeConfig<Poisoned>(
guid: new Guid("A1B2C3D4-..."),
trackAdded: true,
trackDeleted: true
));
Создание сущностей
Отслеживание создания сущностей настраивается на уровне мира через WorldConfig:
W.Create(new WorldConfig {
TrackCreated = true,
// ...другие настройки...
});
//...
W.Initialize();
Created отслеживает создание всех сущностей независимо от типа. Для фильтрации по типу комбинируйте с EntityIs<T>: W.Query<Created, EntityIs<Bullet>>().
Авто-регистрация
Параметры trackAdded, trackDeleted и trackChanged можно объявить в статическом поле Config внутри структуры — RegisterAll() подхватит их автоматически.
Отключение на этапе компиляции
Директива FFS_ECS_DISABLE_CHANGED_TRACKING удаляет все пути кода Changed-трекинга на этапе компиляции, включая фильтры AllChanged<T>, NoneChanged<T>, AnyChanged<T> и метод Mut<T>().
Tick-Based трекинг
WorldConfig.TrackingBufferSize задаёт глубину кольцевого буфера (по умолчанию 8 тиков). Вызывайте W.Tick() для продвижения тика и ротации буфера.
// По умолчанию: трекинг с историей 8 тиков
W.Create(WorldConfig.Default()); // TrackingBufferSize = 8
// Пользовательский размер буфера
W.Create(new WorldConfig {
TrackingBufferSize = 16, // 16 тиков истории
// ...другие настройки...
});
Выбор размера буфера
Буфер должен вмещать историю трекинга для самой редкой системы, которая использует фильтры трекинга. Если W.Tick() вызывается на 60fps, а некоторые системы работают на 20fps, они пропускают 2 тика между запусками и должны заглянуть на 3 тика назад.
Формула: TrackingBufferSize >= tickRate / slowestSystemRate
| Частота тиков | Самая редкая система | Мин. буфер |
|---|---|---|
| 60 fps | 60 fps (каждый тик) | 1 |
| 60 fps | 20 fps (каждый 3-й тик) | 3 |
| 60 fps | 10 fps (каждый 6-й тик) | 6 |
| 60 fps | 1 fps (каждый 60-й тик) | 60 |
Если системы используют интервалы реального времени вместо счётчика тиков, FPS выше ожидаемого увеличит количество тиков между запусками — берите запас. Значение по умолчанию 8 покрывает большинство игр, где самая редкая система с трекингом работает на ~20fps или быстрее.
Tick-Based трекинг
Tick-based трекинг решает две распространённые проблемы:
- Системы в середине конвейера вносят изменения, которые системы в начале не видят в следующем кадре — если очистка трекинга происходит в конце кадра
- Разные группы систем (Update / FixedUpdate) не могут синхронизировать трекинг — очистка в одной группе затрагивает другую
Как это работает
- Каждая система в
W.Systems<T>.Update()автоматически получает свойLastTick— она видит все изменения в диапазоне тиков(LastTick, CurrentTick]— изменения, сделанные в текущем кадре, становятся видимыми в следующем кадре - Когда система завершается, её
LastTickустанавливается вCurrentTick - Если система пропущена (
UpdateIsActive() = false), еёLastTickНЕ обновляется — при следующем запуске она увидит все накопленные изменения W.Tick()продвигает глобальный счётчик тиков и ротирует кольцевой буфер — слот записи становится доступной историей, новый слот очищается и становится целью записи для всех операций трекинга
Интеграция в игровой цикл
// Одна группа систем
while (running) {
W.Systems<GameLoop>.Update(); // каждая система видит изменения с её LastTick
W.Tick(); // продвинуть тик, ротировать буфер
}
// Несколько групп систем (например, Update + FixedUpdate)
while (running) {
W.Systems<Update>.Update();
// FixedUpdate может выполняться несколько раз за кадр — всё в одном тике
while (fixedTimeAccumulator >= fixedDeltaTime) {
W.Systems<FixedUpdate>.Update();
fixedTimeAccumulator -= fixedDeltaTime;
}
W.Tick(); // один тик за кадр
}
Вызывайте W.Tick() один раз за кадр после самой быстрой группы систем. Не вызывайте Tick() после каждой группы — это расходует слоты впустую. Per-system LastTick обеспечивает автоматическое накопление изменений за несколько тиков для редких систем. Изменения, сделанные в течение кадра, становятся видимыми в следующем кадре.
Задержка в один кадр
Изменения трекинга записываются в отдельный слот записи, отделённый от читаемой истории. При вызове W.Tick() слот записи становится частью истории. Поэтому каждая система видит изменения, сделанные после её предыдущего запуска и до текущего кадра — но не изменения текущего кадра.
Рассмотрим конвейер из 5 систем, где Sys1 и Sys5 изменяют Position, а Sys3 запрашивает AllChanged<Position>:
Кадр 1:
Sys1 → меняет Position (записывается в слот записи)
Sys3 → запрашивает трекинг → видит НИЧЕГО (история пуста, первый кадр)
Sys5 → меняет Position (записывается в тот же слот записи)
Tick() → слот записи становится history[тик 1]
Кадр 2:
Sys1 → меняет Position (записывается в новый слот записи)
Sys3 → запрашивает трекинг → видит history[тик 1] = Sys1 + Sys5 из кадра 1
Sys5 → меняет Position (записывается в тот же слот записи)
Tick() → слот записи становится history[тик 2]
Кадр 3:
Sys3 → запрашивает трекинг → видит history[тик 2] = Sys1 + Sys5 из кадра 2
Каждый кадр Sys3 видит ровно изменения из предыдущего кадра — как от систем до неё (Sys1), так и после неё (Sys5). Без повторной обработки, без пропусков.
Per-System трекинг тиков
Каждая система хранит свой LastTick. Системы, запускающиеся каждый тик, видят изменения ровно за 1 тик. Системы, пропускающие кадры, видят все накопленные изменения с момента последнего запуска:
public struct RareSystem : ISystem {
private int _counter;
public bool UpdateIsActive() => ++_counter % 5 == 0; // запускается каждые 5 тиков
public void Update() {
// Видит ВСЕ изменения за последние 5 тиков (или до TrackingBufferSize)
foreach (var entity in W.Query<All<Position>, AllAdded<Position>>().Entities()) {
// обработка добавленных позиций за последние 5 тиков
}
}
}
Пользовательский диапазон тиков (FromTick)
Все фильтры трекинга принимают опциональный параметр fromTick в конструкторе для переопределения автоматического диапазона:
// Автоматический — использует LastTick системы (по умолчанию, конструктор не нужен):
foreach (var entity in W.Query<All<Position>, AllAdded<Position>>().Entities()) { }
// Ручной — видит все изменения начиная с тика 5 до текущего:
var filter = new AllAdded<Position>(fromTick: 5);
foreach (var entity in W.Query<All<Position>>(filter).Entities()) { }
fromTick = 0(по умолчанию): автоматический диапазон изCurrentLastTick(устанавливаетсяW.Systems<T>.Update())fromTick > 0: ручная нижняя граница — видит изменения с этого тика до текущего
Синхронизация между группами
С tick-based трекингом разные группы систем работают вместе естественным образом:
W.Systems<Update>.Update(); // системы записывают трекинг в тик N
W.Systems<FixedUpdate>.Update(); // системы видят изменения Update из тика N + старые тики
W.Tick(); // продвинуть к тику N+1
LastTick каждой системы независим. Система FixedUpdate, пропускающая кадры, увидит все накопленные изменения из предыдущих тиков с момента своего последнего запуска.
Переполнение буфера
Если система не запускается дольше, чем TrackingBufferSize тиков, самые старые данные трекинга перезаписываются. Система увидит максимум TrackingBufferSize тиков истории.
В debug-режиме (FFS_ECS_DEBUG) выбрасывается StaticEcsException когда диапазон тиков системы превышает размер буфера. В release-режиме диапазон молча обрезается. Увеличьте WorldConfig.TrackingBufferSize если вашим системам нужна более глубокая история.
Фильтры запросов
Все фильтры трекинга используются аналогично стандартным фильтрам компонентов и тегов:
| Категория | Фильтр | Параметры | Описание |
|---|---|---|---|
| Компоненты Added | AllAdded<T0..T4> | 1–5 | ВСЕ указанные компоненты были добавлены |
NoneAdded<T0..T4> | 1–5 | Исключает сущности, у которых ХОТЬ ОДИН был добавлен | |
AnyAdded<T0..T4> | 2–5 | ХОТЯ БЫ ОДИН был добавлен | |
| Компоненты Deleted | AllDeleted<T0..T4> | 1–5 | ВСЕ указанные компоненты были удалены |
NoneDeleted<T0..T4> | 1–5 | Исключает сущности, у которых ХОТЬ ОДИН был удалён | |
AnyDeleted<T0..T4> | 2–5 | ХОТЯ БЫ ОДИН был удалён | |
| Компоненты Changed | AllChanged<T0..T4> | 1–5 | ВСЕ указанные компоненты были получены через ref |
NoneChanged<T0..T4> | 1–5 | Исключает сущности, у которых ХОТЬ ОДИН был изменён | |
AnyChanged<T0..T4> | 2–5 | ХОТЯ БЫ ОДИН был получен через ref | |
| Сущности | Created | — | Сущность была создана (требует WorldConfig.TrackCreated) |
Фильтры AllAdded, NoneAdded, AnyAdded, AllDeleted, NoneDeleted, AnyDeleted работают и с компонентами, и с тегами. Отдельных типов фильтров для тегов нет.
Примеры
// Сущности, которым добавлен Position и он сейчас есть
foreach (var entity in W.Query<All<Position>, AllAdded<Position>>().Entities()) {
ref var pos = ref entity.Ref<Position>();
}
// Сущности, которым добавлены И Position, И Velocity
foreach (var entity in W.Query<AllAdded<Position, Velocity>>().Entities()) { }
// Хотя бы один из Position или Velocity был добавлен
foreach (var entity in W.Query<AnyAdded<Position, Velocity>>().Entities()) { }
// Реакция на установку тега
foreach (var entity in W.Query<AllAdded<IsDead>>().Entities()) { }
// Хотя бы один из указанных тегов был установлен
foreach (var entity in W.Query<AnyAdded<Poisoned, Stunned>>().Entities()) { }
// Обработать сущности с изменённым Position (через ref)
foreach (var entity in W.Query<All<Position>, AllChanged<Position>>().Entities()) {
ref readonly var pos = ref entity.Read<Position>();
}
// Только реально изменённые, исключая новые
foreach (var entity in W.Query<All<Position>, AllChanged<Position>, NoneAdded<Position>>().Entities()) {
ref readonly var pos = ref entity.Read<Position>();
}
// Обработать недавно созданные сущности с Position
foreach (var entity in W.Query<Created, All<Position>>().Entities()) {
ref var pos = ref entity.Ref<Position>();
}
// Группировка фильтров через And
var filter = default(And<AllAdded<Position, Unit>, AllDeleted<Velocity>>);
foreach (var entity in W.Query(filter).Entities()) { }
Семантика
Added / Deleted
AllAdded<T> означает только факт добавления — НЕ гарантирует что компонент сейчас присутствует! Если компонент был добавлен, а затем удалён в том же кадре — он по-прежнему отмечен как Added, но компонента уже нет. Аналогично, AllDeleted<T> означает факт удаления — но компонент мог быть добавлен снова.
Рекомендуемые комбинации:
// "Добавлен И сейчас присутствует" — РЕКОМЕНДУЕМЫЙ паттерн
foreach (var entity in W.Query<All<Position>, AllAdded<Position>>().Entities()) {
ref var pos = ref entity.Ref<Position>(); // безопасно — All<Position> гарантирует наличие
}
// "Удалён И сейчас отсутствует"
foreach (var entity in W.Query<None<Position>, AllDeleted<Position>>().Entities()) {
// сущность жива, Position удалён — можно очистить ресурсы
}
// Только AllAdded<Position> — без гарантии наличия!
foreach (var entity in W.Query<AllAdded<Position>>().Entities()) {
// ОСТОРОЖНО: компонент мог быть уже удалён!
if (entity.Has<Position>()) {
ref var pos = ref entity.Ref<Position>();
}
}
Changed (пессимистичная модель)
Changed-трекинг использует модель dirty-on-access: любое получение ref-ссылки помечает компонент как Changed, независимо от того, были ли данные реально изменены. Это сделано намеренно — проверка реальных изменений на уровне полей была бы слишком дорогой для высокопроизводительного ECS.
Методы доступа к данным
| Метод | Возвращает | Changed | Added | Примечание |
|---|---|---|---|---|
Ref<T>() | ref T | — | — | Быстрый мутабельный доступ, без трекинга |
Mut<T>() | ref T | Да | — | Мутабельный доступ с трекингом |
Read<T>() | ref readonly T | — | — | Только чтение |
Add<T>() (новый) | ref T | Да | Да | Компонент новый |
Add<T>() (существующий) | ref T | — | — | Возвращает ссылку на существующий, без хуков |
Set(value) (новый) | void | Да | Да | Компонент новый |
Set(value) (существующий) | void | Да | — | Перезаписывает существующий |
Ref<T>() НЕ помечает Changed. Используйте Mut<T>() когда нужен трекинг изменений. Ref<T>() — самый быстрый способ доступа к данным компонента — нулевые накладные расходы, без ветвлений. Read<T>() — для доступа только на чтение. В итерации запросов через делегаты (For, ForBlock) параметры ref автоматически используют отслеживаемый доступ (семантика Mut), параметры in — только чтение (семантика Read).
Авто-трекинг в запросах
Итерация запросов автоматически помечает Changed в зависимости от семантики доступа:
Делегаты For — ref помечает Changed, in — нет:
// Position помечается как Changed (ref), Velocity — нет (in)
W.Query<All<Position, Velocity>>().For(static (ref Position pos, in Velocity vel) => {
pos.Value += vel.Value;
});
Структуры IQuery — Write<T> помечает Changed, Read<T> — нет:
public struct MoveSystem : IQuery.Write<Position>.Read<Velocity> {
public void Invoke(Entity entity, ref Position pos, in Velocity vel) {
pos.Value += vel.Value;
}
}
ForBlock — Block<T> (мутабельный) помечает Changed, BlockR<T> (readonly) — нет:
public struct MoveBlockSystem : IQueryBlock.Write<Position>.Read<Velocity> {
public void Invoke(uint count, EntityBlock entities, Block<Position> pos, BlockR<Velocity> vel) {
// обработка блока
}
}
Параллельные запросы следуют тем же правилам.
Взаимодействие Changed + Added
При добавлении компонента через Add<T>() или Set(value) он помечается ОДНОВРЕМЕННО как Added И Changed. Чтобы обрабатывать только реально изменённые сущности (без новых), комбинируйте AllChanged<T> с NoneAdded<T>:
foreach (var entity in W.Query<All<Position>, AllChanged<Position>, NoneAdded<Position>>().Entities()) {
// только изменённые, не новые
}
Created
Created отслеживает факт создания сущностей глобально. Не несёт информации о типе — для фильтрации по типу комбинируйте с EntityIs<T>:
foreach (var entity in W.Query<Created, EntityIs<Bullet>, All<Position>>().Entities()) {
// только что созданные пули с Position
}
Граничные случаи
Состояния Added и Deleted независимы и не отменяют друг друга. Они фиксируют все операции, произошедшие в течение текущего тика. Changed также независим от обоих.
Добавление → Удаление
entity.Set(new Position { X = 10 }); // Added = 1
entity.Delete<Position>(); // Deleted = 1, Added остаётся
// Результат: компонента нет, но Added=1 и Deleted=1
// Query<AllAdded<Position>> -> находит
// Query<AllDeleted<Position>> -> находит
// Query<All<Position>, AllAdded<Position>> -> НЕ находит (компонента нет)
// Query<None<Position>, AllDeleted<Position>> -> находит
Удаление → Добавление
entity.Delete<Weapon>(); // Deleted = 1
entity.Set(new Weapon { Damage = 50 }); // Added = 1, Deleted остаётся
// Результат: компонент есть, Added=1 и Deleted=1
// Query<All<Weapon>, AllAdded<Weapon>> -> находит
// Query<All<Weapon>, AllDeleted<Weapon>> -> находит
Добавление → Удаление → Добавление
entity.Set(new Health { Value = 100 }); // Added = 1
entity.Delete<Health>(); // Deleted = 1
entity.Set(new Health { Value = 50 }); // Added уже отмечен
// Результат: компонент есть (Value = 50), Added=1 и Deleted=1
// Эквивалентно «Удаление → Добавление» с точки зрения трекинга
Множественные добавления (идемпотентность)
// Add без значения — не перезаписывает существующий компонент
entity.Add<Position>(); // Added = 1 (новый компонент)
entity.Add<Position>(); // Added уже отмечен, без изменений
// Added отмечается только при первом добавлении (когда компонент новый)
// Set с значением — ВСЕГДА перезаписывает
entity.Set(new Position { X = 10 }); // Added = 1 (новый)
entity.Set(new Position { X = 20 }); // перезапись, Added не отмечается повторно
// (компонент уже существовал)
Mut без модификации
ref var pos = ref entity.Mut<Position>(); // ПОМЕЧЕН как Changed, даже если запись не последует!
// Changed-трекинг пессимистичный — отслеживает доступ, а не реальные мутации
// Используйте entity.Ref<Position>() если трекинг не нужен — нулевые накладные расходы
Множественные вызовы Mut
entity.Mut<Position>(); // помечен
entity.Mut<Position>(); // уже помечен, без дополнительных расходов
// Changed-бит идемпотентен
Итерация запроса помечает все итерируемые сущности
// ВСЕ сущности, подходящие под запрос, получают Changed для ref-компонентов,
// даже если делегат реально не модифицирует данные
W.Query<All<Position>>().For(static (ref Position pos) => {
var x = pos.X; // помечен Changed из-за `ref`, хотя мы только читаем
});
// Используйте `in` чтобы избежать этого:
W.Query<All<Position>>().For(static (in Position pos) => {
var x = pos.X; // НЕ помечен как Changed
});
Changed и Deleted независимы
Changed и Deleted — независимые биты. Если компонент был получен через ref, а затем удалён в том же кадре, оба бита будут установлены.
Destroy и десериализация
Destroy
entity.Destroy() удаляет все компоненты/теги — они отмечаются как Deleted. Но сущность мертва, поэтому маска alive отфильтрует её из ВСЕХ запросов. Следовательно, AllDeleted<T> не найдёт уничтоженные сущности.
var entity = W.Entity.New<Position, Velocity>();
entity.Destroy();
// Query<AllDeleted<Position>> -> НЕ находит (сущность мертва)
// Если нужно реагировать на уничтожение — удаляйте компоненты явно перед Destroy:
entity.Delete<Position>(); // Deleted = 1, сущность жива
// ... обработка AllDeleted<Position> ...
entity.Destroy();
После десериализации
// ReadChunk записывает маски напрямую — трекинг НЕ срабатывает
// ReadEntity проходит через Add — компоненты отмечаются как Added
// Рекомендуется вызвать ClearTracking() после загрузки:
W.Serializer.ReadChunk(ref reader);
W.ClearTracking(); // сбросить весь трекинг — очищает все слоты кольцевого буфера
Сброс отслеживания
Ручная очистка обычно не нужна — трекинг управляется автоматически через W.Tick() и W.Systems<T>.Update(). Методы ClearTracking() остаются доступными как «ядерная кнопка», очищающая ВСЕ слоты кольцевого буфера.
// === Полный сброс ===
W.ClearTracking(); // ВСЕ маски (Added + Deleted + Changed + Created)
// === По категориям ===
W.ClearAllTracking(); // все компоненты и теги (Added + Deleted + Changed)
W.ClearCreatedTracking(); // Created
// === По виду трекинга (все типы) ===
W.ClearAllAddedTracking(); // Added для всех компонентов и тегов
W.ClearAllDeletedTracking(); // Deleted для всех компонентов и тегов
W.ClearAllChangedTracking(); // Changed для всех компонентов
// === Для конкретного типа (компоненты и теги) ===
W.ClearTracking<Position>(); // Added + Deleted + Changed для Position
W.ClearAddedTracking<Position>(); // только Added
W.ClearDeletedTracking<Position>(); // только Deleted
W.ClearChangedTracking<Position>(); // только Changed
// Для тегов — те же методы
W.ClearTracking<Unit>(); // Added + Deleted для Unit
W.ClearAddedTracking<Unit>(); // только Added
W.ClearDeletedTracking<Unit>(); // только Deleted
W.Systems.Update() → W.Tick() → повторить. Ручная очистка не нужна. ClearTracking() — только для особых случаев (десериализация, полный сброс).
Проверка состояния
Помимо фильтров запросов, можно проверять состояние трекинга отдельных сущностей:
// Компоненты — ALL-семантика (все указанные должны совпадать)
bool wasAdded = entity.HasAdded<Position>();
bool bothAdded = entity.HasAdded<Position, Velocity>(); // Position И Velocity добавлены
bool wasDeleted = entity.HasDeleted<Health>();
bool wasChanged = entity.HasChanged<Position>();
bool bothChanged = entity.HasChanged<Position, Velocity>(); // Position И Velocity изменены
// Компоненты — ANY-семантика (хотя бы один должен совпадать)
bool anyAdded = entity.HasAnyAdded<Position, Velocity>(); // Position ИЛИ Velocity добавлен
bool anyDeleted = entity.HasAnyDeleted<Position, Velocity>(); // Position ИЛИ Velocity удалён
bool anyChanged = entity.HasAnyChanged<Position, Velocity>(); // Position ИЛИ Velocity изменён
// Теги — те же методы (ALL-семантика)
bool tagAdded = entity.HasAdded<Unit>();
bool tagDeleted = entity.HasDeleted<Poisoned>();
bool bothTagsAdded = entity.HasAdded<Unit, Player>(); // Unit И Player добавлены
// Теги — ANY-семантика
bool anyTagAdded = entity.HasAnyAdded<Unit, Player>(); // Unit ИЛИ Player добавлен
bool anyTagDeleted = entity.HasAnyDeleted<Unit, Player>(); // Unit ИЛИ Player удалён
// Комбинирование с проверкой наличия
if (entity.HasAdded<Position>() && entity.Has<Position>()) {
ref var pos = ref entity.Ref<Position>();
// компонент добавлен и сейчас присутствует
}
// Все методы принимают опциональный параметр fromTick для указания диапазона тиков:
bool addedSinceTick5 = entity.HasAdded<Position>(fromTick: 5);
bool changedRecently = entity.HasChanged<Position>(fromTick: W.CurrentTick);
Производительность
- Маски отслеживания хранятся как
ulongна блок из 64 сущностей — тот же формат, что маски компонентов/тегов - Компоненты: до 3 дополнительных
ulongна блок (Added, Deleted, Changed) для каждого отслеживаемого типа - Теги: до 2
ulongна блок (Added, Deleted) Created: 1ulongна блок глобально, плюс эвристики на чанк для быстрого пропуска- Фильтры
AllAdded<T>/AllDeleted<T>/AllChanged<T>— та же стоимость, чтоAll<T>/None<T>: одна побитовая операция на блок - Changed-трекинг в запросах: одна батчевая OR-операция на блок
ClearTracking()использует эвристики чанков для пропуска пустых регионов — O(занятые чанки), а не O(весь мир)Ref<T>()имеет нулевые накладные расходы — без runtime-ветвлений, идентичен коду до добавления трекинга- Нулевые накладные расходы для типов с выключенным трекингом
- Нулевые накладные расходы для
CreatedприWorldConfig.TrackCreated = false FFS_ECS_DISABLE_CHANGED_TRACKINGубирает все пути кода Changed-трекинга на этапе компиляции- Tick-based запись: нулевые накладные расходы (swap указателей)
- Tick-based чтение: O(ticksToCheck) операций OR, ограничено
TrackingBufferSize. Работает иерархическая фильтрация: сначала на уровне чанков (4096 сущностей), затем на уровне блоков (64 сущности) — проверяются только чанки/блоки с реальными данными трекинга - Продвижение тика: пренебрежимая стоимость за кадр
- Память: эвристические массивы ×
TrackingBufferSize; данные сегментов аллоцируются лениво
Сценарии использования
Сетевая синхронизация (дельта-обновления):
foreach (var entity in W.Query<All<Position>, AllChanged<Position>>().Entities()) {
ref readonly var pos = ref entity.Read<Position>();
SendPositionUpdate(entity, pos);
}
Физика:
foreach (var entity in W.Query<All<Transform, PhysicsBody>, AllChanged<Transform>>().Entities()) {
ref readonly var transform = ref entity.Read<Transform>();
ref var body = ref entity.Ref<PhysicsBody>();
SyncPhysicsBody(ref body, transform);
}
Реактивная инициализация:
foreach (var entity in W.Query<All<Position, Unit>, AllAdded<Position>>().Entities()) {
ref var pos = ref entity.Ref<Position>();
// создание визуального представления для новой сущности
}
Инициализация сущностей:
foreach (var entity in W.Query<Created, All<Position, Unit>>().Entities()) {
ref var pos = ref entity.Ref<Position>();
// создание визуалов, физического тела и т.д.
}
UI-обновления:
// Создать полоску здоровья для новых сущностей
foreach (var entity in W.Query<All<Health, Player>, AllAdded<Health>>().Entities()) {
ref var health = ref entity.Ref<Health>();
// создать UI-элемент
}
// Обновить полоску здоровья только при изменении данных
foreach (var entity in W.Query<All<Health, Player>, AllChanged<Health>>().Entities()) {
ref readonly var health = ref entity.Read<Health>();
// обновить отображение
}
Несколько групп систем (tick-based):
void GameLoop() {
W.Systems<Update>.Update(); // каждая система видит изменения из предыдущих кадров
W.Systems<FixedUpdate>.Update(); // аналогично — per-system LastTick определяет диапазон
W.Tick(); // зафиксировать трекинг текущего кадра в историю
}
Условные системы (tick-based):
public struct PeriodicSync : ISystem {
private int _frame;
public bool UpdateIsActive() => ++_frame % 10 == 0;
public void Update() {
// Автоматически видит ВСЕ изменения за последние 10 тиков
foreach (var entity in W.Query<All<Position>, AllChanged<Position>>().Entities()) {
SyncToNetwork(entity);
}
}
}