Отслеживание изменений

StaticEcs предоставляет четыре типа отслеживания изменений, все без аллокаций и включаются явно:

Тип Что отслеживает Область Как включить
Added Добавление компонента/тега Компоненты, теги Реализовать ITrackableAdded на типе
Deleted Удаление компонента/тега Компоненты, теги Реализовать ITrackableDeleted на типе
Changed Доступ к данным компонента через ref Только компоненты Реализовать ITrackableChanged на компоненте
Created Создание сущности Весь мир WorldConfig.TrackCreated = true
  • Bitmap-хранение: один ulong на 64 сущности для каждого отслеживаемого типа
  • Трекинг версионируется по тикам мира через кольцевой буфер (по умолчанию 8 тиков). Каждая система автоматически видит изменения с момента своего последнего запуска
  • Нулевые накладные расходы для типов с выключенным трекингом
  • Нулевые накладные расходы для Created при WorldConfig.TrackCreated = false

Конфигурация

Весь трекинг выключен по умолчанию и включается реализацией соответствующего интерфейса-маркера на типе компонента/тега.

Трекинг управляется тремя интерфейсами-маркерами, применимыми и к компонентам, и к тегам (с одним исключением ниже):

Интерфейс Что включает
ITrackableAdded Отслеживание добавлений (AllAdded, NoneAdded, AnyAdded, Entity.HasAdded<T>())
ITrackableDeleted Отслеживание удалений (AllDeleted, NoneDeleted, AnyDeleted, Entity.HasDeleted<T>())
ITrackableChanged Отслеживание изменений значения (AllChanged, NoneChanged, AnyChanged, Entity.HasChanged<T>()). Только для компонентов — на тегах игнорируется.

Фильтры запроса и методы Entity.HasXxx<T>() статически ограничены соответствующим интерфейсом-маркером в where-клаузе — отсутствие маркера даёт ошибку компиляции, а не runtime-ассерт.

Родственный opt-in маркер — IDisableable — управляет поддержкой Disable/Enable и *Disabled фильтрами по такому же паттерну compile-time-констрейнта. Описан в Component. Это не трекинг, но та же идея «нет маркера → нет аллокации, нет API».

Компоненты

// Отслеживать все три типа изменений
public struct Health : IComponent, ITrackableAdded, ITrackableDeleted, ITrackableChanged {
    public float Value;
}

// Отслеживать только добавления
public struct Velocity : IComponent, ITrackableAdded {
    public float X, Y;
}

// Совмещение с IComponentConfig<T> при необходимости кастомной конфигурации
public struct Position : IComponent, IComponentConfig<Position>,
                         ITrackableAdded, ITrackableDeleted, ITrackableChanged {
    public float X, Y;
    public ComponentTypeConfig<Position> Config() => new(
        guid: new Guid("..."),
        defaultValue: default
    );
}

W.Create(WorldConfig.Default());
//...
// Регистрация без параметров — маркеры обнаруживаются через `default(T) is IMarker`
W.Types().Component<Health>()
         .Component<Velocity>()
         .Component<Position>();
//...
W.Initialize();

Теги

Теги поддерживают ITrackableAdded и ITrackableDeleted. Теги не поддерживают Changed-трекинг — ITrackableChanged на теге молча игнорируется.

public struct Unit : ITag, ITrackableAdded, ITrackableDeleted { }

// С GUID для сериализации через ITagConfig<T>
public struct Poisoned : ITag, ITagConfig<Poisoned>,
                         ITrackableAdded, ITrackableDeleted {
    public TagTypeConfig<Poisoned> Config() => new(guid: new Guid("A1B2C3D4-..."));
}

// Регистрация без параметров
W.Types().Tag<Unit>()
         .Tag<Poisoned>();

Создание сущностей

Отслеживание создания сущностей настраивается на уровне мира через WorldConfig:

W.Create(new WorldConfig {
    TrackCreated = true,
    // ...другие настройки...
});
//...
W.Initialize();

Created отслеживает создание всех сущностей независимо от типа. Для фильтрации по типу комбинируйте с EntityIs<T>: W.Query<Created, EntityIs<Bullet>>().

Авто-регистрация

Интерфейсы-маркеры ITrackableAdded / ITrackableDeleted / ITrackableChanged автоматически обнаруживаются RegisterAll() — дополнительная настройка не требуется. Регистрация проверяет default(T) is ITrackableXxx для каждого регистрируемого типа компонента/тега.

Отключение на этапе компиляции

Директива 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 трекинг решает две распространённые проблемы:

  1. Системы в середине конвейера вносят изменения, которые системы в начале не видят в следующем кадре — если очистка трекинга происходит в конце кадра
  2. Разные группы систем (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 в зависимости от семантики доступа:

Делегаты Forref помечает Changed, in — нет:

// Position помечается как Changed (ref), Velocity — нет (in)
W.Query<All<Position, Velocity>>().For(static (ref Position pos, in Velocity vel) => {
    pos.Value += vel.Value;
});

Структуры IQueryWrite<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;
    }
}

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

После десериализации

  • Снимок мира (LoadWorldSnapshot): всё состояние трекинга — включая CurrentTick, CurrentLastTick, все слоты кольцевого буфера по каждому компоненту/тегу с маркерами трекинга и мировую историю TrackCreated — восстанавливается полностью. Вызов ClearTracking() не требуется; после загрузки AllAdded<T>, AllChanged<T>, AllDeleted<T>, Created и per-entity методы HasXxx(fromTick) возвращают те же результаты, что и до сохранения. Значения TrackingBufferSize и TrackCreated целевого мира должны совпадать со значениями сохранённого мира — несовпадение приводит к StaticEcsException.
  • Снимок кластера / чанка (LoadClusterSnapshot / LoadChunkSnapshot): данные трекинга не сохраняются в этих частичных снимках. Их загрузка не затрагивает тик и историю трекинга целевого мира. Применённые изменения сущностей/компонентов не формируют биты Added / Changed / Deleted в целевом мире — это прямая запись масок. Если нужно, чтобы загруженные чанки участвовали в трекинге дальше, вызовите ClearTracking() (или его per-component / per-entity варианты) для установки чистой базы, и затем продолжайте обычным образом.
// Снимок мира — трекинг восстанавливается полностью, дополнительных действий не нужно:
W.Serializer.LoadWorldSnapshot(worldSnapshot);

// Снимок кластера / чанка — опциональный полный сброс, если существующее
// состояние трекинга конфликтует с только что загруженными чанками:
W.Serializer.LoadClusterSnapshot(clusterSnapshot);
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 удалён

// Создание сущности (требует WorldConfig.TrackCreated = true)
bool wasCreated = entity.HasCreated();
bool createdSinceTick5 = entity.HasCreated(fromTick: 5);

// Комбинирование с проверкой наличия
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: 1 ulong на блок глобально, плюс эвристики на чанк для быстрого пропуска
  • Фильтры 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);
        }
    }
}

This site uses Just the Docs, a documentation theme for Jekyll.