В двух словах о проблеме

Допустим, вы делаете симуляцию города. Или RTS. Или RPG с открытым миром. И у вас в сцене одновременно находится 5, 10, а то и 20 тысяч живых существ. У каждого свои цели, приоритеты, эмоции, социальные связи.

Ваша архитектура AI начинает трещать по швам.

Классический подход — дать каждому NPC компонент с методом Update() — перестаёт работать где-то после 500–1000 объектов. Дальше начинаются проблемы:

  • 10 000 вызовов виртуальных методов за кадр → промахи кэша и давление на предсказатель переходов.

  • LINQ-запросы для поиска ближайшего врага → аллокации и GC.

  • Пересчёт эмоций по полному графу → O(N²) на каждое распространение.

  • Сортировка всех NPC для выбора топ-10 → пустая трата O(N log N) сравнений.

В результате ваши 60 FPS превращаются в 30, а потом в 15. Игроки жалуются на фризы (это GC собрал 20 мегабайт временных массивов). Вы начинаете нанимать отдельного инженера по оптимизации AI.

Этого можно избежать, если с самого начала заложить правильные примитивы.

Что лежит в коробке

Библиотека GameAI.Net — это не фреймворк. Это набор низкоуровневых алгоритмических кирпичей. Вы не обязаны использовать всё. Вы можете взять только то, что нужно, и встроить в свою архитектуру.

Версия: 0.1.7
Целевая платформа: .NET 8.0+
Установка: dotnet add package GameAI.Net

Четыре модуля:

1. Behavior Tree на стероидах

Вместо иерархии объектов — плоский массив инструкций. Вместо рекурсии — цикл с индексом. Вместо виртуальных вызовов — переключатель по NodeKind. Результат: дерево для 10 000 NPC тикает за 285 микросекунд.

Есть две формы хранения чёрной доски:

  • Blackboard — для быстрого прототипирования (по одному NPC).

  • BlackboardSoA — Structure of Arrays для массовых тиков (рекомендовано).

2. Utility Scoring с SIMD

Батчевая оценка пригодности через взвешенную сумму признаков. Внутри — SIMD-инструкции (AVX2/SSE), которые процессор выполняет за один такт на несколько значений.

ScoreAll принимает матрицу (кандидаты × признаки) и вектор весов. Для 100 000 кандидатов с 64 признаками однопоточный расчёт занимает 4.68 мс, параллельный — 1.16 мс на i5-11400F.

3. Эмоциональный граф

Разреженный граф в CSR-формате для распространения влияния между NPC. Поддерживает два режима:

  • Step — полный обход всех узлов (дорого).

  • StepActive — только узлы, изменившиеся в этом кадре (рекомендовано).

Для 100 000 узлов с 10% активных источников и обновлением раз в 4 кадра — ~0.45 мс на кадр.

4. Селектор толпы с квотами

Задача: из набора кандидатов выбрать топ-K в каждой группе (например, 3 ближайших врага, 2 с низким здоровьем, 5 с высоким уроном). Без полной сортировки, без аллокаций.

SelectWithQuotasInto использует быстрый selection на стеке для малых квот.

Быстрый старт

Поведенческое дерево

using GameAI.Net.BehaviorTree;
using GameAI.Net.Core;
// Условие: float[0] > 0.5
var condition = new ConditionProgram(new[]
{
    new Instruction(OpCode.LoadFloat, argI: 0),
    new Instruction(OpCode.PushConst, argF: 0.5f),
    new Instruction(OpCode.Gt)
});
// Дерево: Selector(Sequence(Condition, Attack), Idle)
var nodes = new[]
{
    new Node(NodeKind.Selector, 0, 0),
    new Node(NodeKind.Sequence, 0, 0),
    new Node(NodeKind.Condition, 0, 0),
    new Node(NodeKind.Action, 1, 0),  // Attack
    new Node(NodeKind.Action, 0, 0)   // Idle
};
var children = new[] { 1, 4, 2, 3 };
var childStart = new[] { 0, 2, 4, 4, 4 };
var childCount = new[] { 2, 2, 0, 0, 0 };
var tree = new BehaviorTree(nodes, children, childStart, childCount, 
    new[] { condition }, new MyActions());
// Для одного NPC
var bb = new Blackboard(floats: new float[8]);
bb.SetFloat(0, 0.9f);
tree.TickFast(new TickContext(1f/60f, bb));
// Для 10 000 NPC (SoA)
var bbSoA = new BlackboardSoA(10000, floatSlots: 8);
for (int i = 0; i < 10000; i++)
{
    bbSoA.SetFloat(i, 0, Random.Shared.NextSingle());
    tree.TickFast(1f/60f, bbSoA, i);
}

Пакетная оценка пригодности

using GameAI.Net.Utility;
using SlidingRank.FastOps;
int agents = 10000;
int featuresCount = 32;
var data = new float[agents * featuresCount];
// ... заполняем признаки ...
var features = new EmbeddingMatrix(data, agents, featuresCount);
var weights = new float[featuresCount];
var scores = new float[agents];
UtilityScorer.ScoreAll(features, weights, scores);      // последовательно
UtilityScorer.ScoreAllParallel(features, weights, scores); // параллельно

Граф эмоций

using GameAI.Net.Emotion;
int npcCount = 5000;
int[] start = new int[npcCount + 1];
int[] to = new int[totalEdges];
float[] weight = new float[totalEdges];
var graph = new EmotionGraph(npcCount, start, to, weight);
// Только активные источники
bool[] active = new bool[npcCount];
active[42] = true; // NPC 42 разозлился
graph.StepActive(active, 1f/60f);

Выбор с квотами

using GameAI.Net.Crowd;
Span<Candidate> candidates = stackalloc Candidate[5000];
// ... заполняем ...
Span<GroupQuota> quotas = stackalloc GroupQuota[]
{
    new(1, 2), // группа 1: 2 кандидата
    new(2, 1), // группа 2: 1 кандидат
    new(3, 3)  // группа 3: 3 кандидата
};
Span<int> output = stackalloc int[100];
int written = CrowdDirector.SelectWithQuotasInto(candidates, quotas, output);

Цифры, которые имеют значение

Тесты на Intel Core i5-11400F, Windows 11, .NET 8.0, BenchmarkDotNet 0.15.8.

Модуль

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

Результат

Behavior Tree (TickFast, SoA)

10 000 NPC

285 µs

Utility Scoring (последовательный)

100k × 64

4.68 ms

Utility Scoring (параллельный)

100k × 64

1.16 ms

Emotion Graph (активные источники)

100k узлов, 10% активных, раз в 4 кадра

0.45 ms/кадр

Сложим всё вместе для 10 000 NPC с полным AI (поведение + скоринг + эмоции раз в 4 кадра):

  • Behavior Tree: 0.285 мс

  • Utility Scoring (параллельно): 1.16 мс (доля от 10 000 из 100 000)

  • Emotion Graph: 0.45 мс

Итого: ~1.9 мс на кадр. Остаётся 14+ мс на рендеринг, физику, анимации и сеть.

Почему это работает

Отсутствие аллокаций в горячем цикле

В библиотеке всё, что можно предвыделить — предвыделено при инициализации. То, что можно разместить на стеке — размещается на стеке через stackalloc. Временные массивы не создаются. GC просто нечего собирать.

Кэш-локальность

BlackboardSoA хранит все значения одного типа подряд. Когда вы тикаете 10 000 NPC подряд, процессор загружает в кэш непрерывный блок памяти, а не дёргает указатели на разбросанные объекты.

SIMD без вашего участия

SIMD-инструкции включены через SlidingRank.FastOps. Вам не нужно писать Vector<T> или Span<T>. Вы просто вызываете ScoreAll, а внутри уже решено: есть AVX2 — используем его, нет — SSE, нет SSE — обычные float.

Слабая связность модулей

Можете использовать только Utility Scoring, игнорируя остальное. Можете переписать свой селектор толпы, но взять граф эмоций. Можете даже не использовать библиотеку в горячем пути, а только для прототипирования.

Архитектурные паттерны

LOD для AI

if (distance < 20f)
{
    // Полный AI каждый кадр
    tree.TickFast(deltaTime, bb, i);
    scorer.ScoreAll(features, weights, scores);
}
else if (distance < 50f && frame % 3 == 0)
{
    // Только поведение, каждый 3-й кадр
    tree.TickFast(deltaTime, bb, i);
}
else if (frame % 10 == 0)
{
    // Минимальное обновление состояния
    bb.SetFloat(i, "lastSeen", Time.time);
}

Один мастер-тик вместо тысячи Update()

В Unity особенно важно: не вешайте скрипт с Update() на каждого NPC.

public class AIScheduler : MonoBehaviour
{
    private static BlackboardSoA _blackboard;
    private static BehaviorTree _tree;
    
    void Update()
    {
        for (int i = 0; i < _blackboard.AgentCount; i++)
        {
            _tree.TickFast(Time.deltaTime, _blackboard, i);
        }
    }
}

Активные источники в графе эмоций

Вместо Step() (полный пересчёт) используйте StepActive() и ведите массив флагов isDirty. Эмоция изменилась только у нескольких NPC → обновите только их и их соседей.

Чего библиотека не делает

GameAI.Net сознательно не включает:

  • Навигацию — вы используете A*, NavMesh, потоковые поля отдельно.

  • Анимации — это ответственность вашей анимационной системы.

  • Сеть — синхронизация состояний NPC остаётся за вами.

  • Редактор — вы определяете деревья поведения в коде или генерируете их из визуального редактора сами.

  • Готовые шаблоны — библиотека даёт кирпичи, а не дом.

Масштабирование за пределы одного ядра

Параллельный ScoreAllParallel уже использует Parallel.For. Для остальных модулей многопоточность пока не встроена — вы можете сами разбить массив NPC на диапазоны и запустить несколько задач.

var options = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount };
Parallel.For(0, _blackboard.AgentCount, options, i =>
{
    _tree.TickFast(deltaTime, _blackboard, i);
});

Предупреждение: Parallel.For создаёт аллокации (разделители диапазонов, делегаты). Для строгой zero-alloc-многопоточности используйте свой пул воркеров или Unity Jobs.

Бесплатное тестирование — без ограничений. Коммерческое использование требует лицензии.

GameAI.Net решает конкретную задачу: обслуживание тысяч NPC с предсказуемой латентностью без GC-спайков.

Она не пытается заменить ваш код. Она даёт проверенные алгоритмические кирпичи, которые вы встраиваете в свою архитектуру. Хотите деревья поведения — берите. Нужен только SIMD-скоринг — берите его. Граф эмоций — пожалуйста. Селектор с квотами — забирайте.

Попробуйте на своих данных. Если ваш AI тормозит и фризит — возможно, вы нашли ответ.

Комментарии (4)


  1. Dhwtj
    29.05.2026 04:03

    Не читая могу предложить: неписи должны не выделять память в процессе своей работы

    zero-alloc-многопоточности

    Угадал


  1. domix32
    29.05.2026 04:03

    Крупнейшей проблемой с моими ИИ всегда в первую очередь был поиск путей. Второй крупнейшей проблемой была проверка физических коллизий. Правда у нас жанр был другой и GOAP вместо Behavior Tree.


    1. Jijiki
      29.05.2026 04:03

      а в чем проблема поиска путей? я так 10000 нпц не запускал на поиск путей в 3д по хейтмапе(и/или брашам потомучто, только террейн не интересно я считаю, я бы хотел разнородный мир, включая подземелья в реал тайме - тоесть не те, которые через екран прогрузки ...), но, там вроде должен быть делегатор и я предположу чото типо очереди или очереди с приоритетом, в потоке возможно, а если предположить, что юзаем инстанс вроде все чотко по пачкам получается, получается если в пачке 3-10 типо мобов там чото можно подумать, плюс флаг на дистанцию, возможно тогда, надо выше выходить и выделять конкретно зоны(зона - координата и обьем(аабб возможно - ну тоесть коробка, суть в том что по грани тригеримся и бежим назад в пак) на поверхности, обьем для тригера на возврат в точку патрулирования ) ) групп с айдишками, но опять же через делегатор мб даже поточный... а пачки возможно делает билдер, в реалтайме по шаблону функции, живёт пока жива пачка - но это предположение покачто.

      причем я смотрел не А*, а дейкстру.

      кароче получается это отдельный сервер навигации как не крути. изолированный прям.


      1. domix32
        29.05.2026 04:03

        У нас там карта постоянно менялась - стены умирали и воскресали, всякие ловушки раскидывали. Где-то сокомандники гоняют в которых тоже желательно не втыкаться и не шмалять в снарядов в бок. До динамической перестройки навмешей руки так и не дошли, поэтому расчёт вели по тайлово. Не очень быстро получалось, особенно если карта 40х40