03.10.2026 23 мин чтения Просмотров:3

Utility AI в Unity: выбор действия по очкам полезности на C#

Как построить ИИ, который оценивает каждое действие числом от 0 до 1 и выбирает лучшее. Кривые оценки, факторы голода, здоровья и дистанции, гистерезис и отладка, с кодом на C#.

Utility AI в Unity: выбор действия по очкам полезности на C#

Конечный автомат для NPC-животного выглядит аккуратно, пока в нём три состояния: Idle, Eat и Flee. Затем появляются Rest, Drink, Attack и Hide, и переходов становится больше десятка. Каждый несёт условие вроде hunger > 70 && !enemyNearby && health > 30, а порядок проверок в Update незаметно решает, как агент поведёт себя в спорных ситуациях. Когда персонаж голоден, ранен и видит врага в двенадцати метрах, ответ на вопрос «что делать» определяется не логикой дизайна, а тем, какой if стоит выше.

Utility AI меняет сам вопрос. Вместо «в каком я состоянии и куда могу перейти» агент спрашивает, насколько полезно каждое доступное действие прямо сейчас. Каждому действию присваивается число от 0 до 1, и побеждает наибольшее. Поведение получается плавным: чем сильнее голод, тем выше оценка еды, а близкий враг понемногу вытесняет все остальные желания и не требует отдельного перехода с приоритетом. Подход давно используется в симуляторах жизни, стратегиях и RPG, потому что хорошо масштабируется: новое действие добавляется в список и не требует переписывать граф переходов.

Ниже собрана рабочая архитектура для Unity: нормализация входных данных, кривые оценки, факторы голода, здоровья и дистанции, объединение оценок и выбор лучшего действия. Отдельно разобраны проблемы, о которых в коротких туториалах молчат: дрожание решений между двумя близкими действиями, стоимость вычислений, отладка и границы применимости метода.

Как устроен Utility AI: действия, соображения и оценка

Система состоит из трёх сущностей. Действие (action) описывает то, что агент может сделать: поесть, убежать, атаковать, отдохнуть, побродить. Соображение (consideration) отвечает на один узкий вопрос об окружающем мире: «насколько я голоден», «насколько далеко враг», «сколько у меня здоровья». И наконец, оценка (score) - итоговое число, получаемое из соображений действия. Агент периодически пересчитывает оценки всех действий, выбирает максимальную и выполняет соответствующее действие до следующего пересчёта.

Принципиальное отличие от конечного автомата в том, что связи между действиями не хранятся нигде. Действие «Есть» не знает про «Бежать» и не содержит условий вида «если враг рядом, не есть». Эту информацию несёт оценка: когда враг приближается, у действия «Бежать» растёт число, а у «Есть» оно остаётся прежним и проигрывает. Комбинаторный взрыв переходов превращается в линейный рост: N действий означают N наборов соображений, а не N² возможных стрелок.

От Behaviour Tree метод отличается тем, что дерево описывает порядок и условия проверки, а Utility AI описывает силу желания. Дерево хорошо задаёт последовательности («подойти, замахнуться, ударить, отступить»), но плохо справляется с компромиссами между конкурирующими мотивами. Utility AI, наоборот, превосходно взвешивает мотивы, но неудобен там, где нужен жёсткий сценарий. На практике оба подхода часто комбинируют, об этом речь пойдёт ближе к концу.

Нормализация входных данных

Все соображения должны выдавать значения в одном диапазоне, иначе их нельзя ни перемножать, ни сравнивать. Стандарт - отрезок от 0 до 1. Голод в диапазоне 0-100 делится на 100, здоровье делится на максимум, дистанция в метрах делится на радиус, за которым объект уже не интересен. Это называется нормализацией входа, и от неё зависит, насколько предсказуемо потом будут работать кривые.

Самая частая ловушка здесь - выбор «потолка» для дистанции. Если нормализовать расстояние до врага на 100 метров, а агент реагирует на угрозы в радиусе 15, то весь осмысленный диапазон уложится в первые 0,15 шкалы, и кривую придётся растягивать неестественными коэффициентами. Правило простое: максимум нормализации равен расстоянию, дальше которого фактор перестаёт влиять на решение. Для еды это может быть 30 метров, для угрозы 15, и это разные параметры разных соображений, а не глобальная константа.

Для величин без естественного верхнего предела (количество золота, число врагов вокруг) используют насыщение: вход делится на «достаточное» значение и обрезается через Mathf.Clamp01. Три врага рядом с точки зрения решения «бежать или нет» вполне могут означать то же, что и десять, и тогда нормализация на тройку честнее, чем на десяток.

Кривые оценки и их форма

Нормализованный вход почти никогда не подаётся в оценку напрямую. Линейная зависимость «голод 50% - желание поесть 50%» плохо отражает поведение живых существ: сытое животное вообще не думает о еде, а на грани истощения не думает ни о чём другом. Чтобы передать такую нелинейность, вход пропускают через кривую отклика (response curve), которая превращает значение из диапазона 0-1 в оценку из того же диапазона.

На практике хватает трёх семейств кривых. Линейная с наклоном и сдвигом подходит для простых зависимостей и для инверсии: дистанция до цели с наклоном -1 и сдвигом по Y +1 даёт «чем ближе, тем лучше». Степенная (полиномиальная) с показателем 2-3 растёт медленно, а затем резко: так удобно описывать голод, усталость и любую потребность, которая долго не беспокоит и становится критичной ближе к концу шкалы. Логистическая (сигмоида) выдаёт почти ноль до порога, быстро переходит к почти единице и подходит для пороговых решений: «здоровье ниже трети - пора спасаться».

public enum CurveKind { Linear, Polynomial, Logistic }

[System.Serializable]
public class ResponseCurve
{
    public CurveKind kind = CurveKind.Linear;
    public float slope = 1f;     // наклон; для Logistic - крутизна перехода
    public float exponent = 2f;  // степень для Polynomial
    public float xShift = 0f;    // сдвиг по оси входа (порог для Logistic)
    public float yShift = 0f;    // сдвиг по оси оценки

    public float Evaluate(float x)
    {
        x = Mathf.Clamp01(x);
        float y;
        switch (kind)
        {
            case CurveKind.Polynomial:
                // Max защищает Pow от отрицательного основания (результат был бы NaN)
                y = slope * Mathf.Pow(Mathf.Max(0f, x - xShift), exponent) + yShift;
                break;
            case CurveKind.Logistic:
                y = 1f / (1f + Mathf.Exp(-slope * (x - xShift))) + yShift;
                break;
            default:
                y = slope * (x - xShift) + yShift;
                break;
        }
        return Mathf.Clamp01(y);
    }
}

Класс помечен [Serializable], поэтому кривая настраивается прямо в инспекторе. Инициализаторы полей сделаны намеренно: если взять структуру с нулевыми значениями по умолчанию, новая запись в массиве даст нулевой наклон и константную оценку, и первые полчаса настройки уйдут на поиск причины. Для логистической кривой типичный стартовый набор: slope = 10, xShift = 0.5 - порог на середине шкалы. Отрицательный наклон зеркально разворачивает кривую, что удобно для «чем меньше здоровья, тем выше желание бежать».

Формулы или AnimationCurve

Альтернатива параметрическим кривым - встроенный AnimationCurve, который позволяет геймдизайнеру нарисовать любую форму мышью. Это быстрее на этапе экспериментов, но у подхода есть цена. Форма рисуется на глаз и плохо воспроизводится числами, поэтому при обсуждении баланса сложно сказать «подними порог на 0,1». Кроме того, по умолчанию AnimationCurve экстраполирует значения за пределами ключей, и без явной настройки WrapMode на границах возможны сюрпризы. Рекомендация такая: параметрические формулы использовать как основу, потому что они воспроизводимы и тестируются, а AnimationCurve оставить для нестандартных случаев. Если кривые рисуются вручную и вычисляются часто, их стоит запечь в таблицу из 32-64 значений и брать ближайшее с интерполяцией.

Контекст агента и факторы голода, здоровья и дистанции

Соображения не должны лезть в сцену самостоятельно. Если каждое из них вызывает FindObjectsOfType или считает расстояния, стоимость решения умножается на число действий и факторов. Правильнее один раз за цикл мышления собрать контекст - структуру с актуальными данными агента и ближайшими объектами - и передать её всем соображениям. Так сенсорная часть отделена от логики оценки, а сами соображения превращаются в чистые функции, которые легко проверять юнит-тестами.

public sealed class AgentContext
{
    public Transform Self;
    public UnityEngine.AI.NavMeshAgent Nav;

    public float Hunger01;          // 0 - сыт, 1 - на грани голодной смерти
    public float Health01;          // текущее здоровье / максимум

    public Transform NearestFood;   // null, если еды поблизости нет
    public Transform NearestThreat;
    public float DistanceToFood = float.MaxValue;   // в метрах
    public float DistanceToThreat = float.MaxValue;
}

Соображения удобно делать ScriptableObject-ассетами: один и тот же «Голод» с одной и той же кривой подключается к нескольким действиям, а правка кривой в одном месте меняет поведение всех агентов. Базовый класс вычисляет вход, а кривая превращает его в оценку.

public abstract class Consideration : ScriptableObject
{
    public ResponseCurve curve = new ResponseCurve();

    // Нормализованный вход 0..1, без кривой
    protected abstract float Input01(AgentContext ctx);

    public float Score(AgentContext ctx) => curve.Evaluate(Input01(ctx));
}

[CreateAssetMenu(menuName = "AI/Considerations/Hunger")]
public sealed class HungerConsideration : Consideration
{
    protected override float Input01(AgentContext ctx) => ctx.Hunger01;
}

[CreateAssetMenu(menuName = "AI/Considerations/Health")]
public sealed class HealthConsideration : Consideration
{
    protected override float Input01(AgentContext ctx) => ctx.Health01;
}

public enum DistanceTarget { Food, Threat }

[CreateAssetMenu(menuName = "AI/Considerations/Distance")]
public sealed class DistanceConsideration : Consideration
{
    [SerializeField] DistanceTarget target;
    [SerializeField] float maxDistance = 30f;   // за этим радиусом фактор не важен

    protected override float Input01(AgentContext ctx)
    {
        float d = target == DistanceTarget.Food ? ctx.DistanceToFood : ctx.DistanceToThreat;
        return Mathf.Clamp01(d / maxDistance);
    }
}

Обратите внимание, что соображение «Дистанция» возвращает именно расстояние, а то, считать ли близость хорошей или плохой, решает кривая. Для действия «Есть» ближайшая еда желательна, и кривая линейная с наклоном -1 и сдвигом +1. Для действия «Бежать» близость угрозы тоже повышает оценку, но может понадобиться крутая логистика: на 15 метрах паниковать рано, на 5 уже пора. Один и тот же класс соображения при разных кривых описывает разные мотивы.

Для голода типична степенная кривая с показателем 2: при голоде 0,3 оценка составляет всего 0,09, при 0,6 уже 0,36, а при 0,9 достигает 0,81. Для здоровья в действии «Бежать» подходит инвертированная линия или логистика с порогом около 0,35. Для действия «Атаковать» здоровье работает в обратную сторону: слабый агент реже лезет в драку, значит, кривая прямая. Эти значения - стартовая точка для итераций, а не готовые константы.

Объединение факторов в одну оценку

У действия обычно два-пять соображений, а выбирать нужно по одному числу. Самый распространённый способ объединения - перемножение. У него есть полезное свойство: нулевая оценка любого соображения обнуляет всё действие. Если еды в зоне видимости нет, действие «Есть» не может получить высокий балл, как бы сильно ни был голоден агент. Умножение работает как логическое «и», сохраняя при этом плавность.

Но у перемножения есть побочный эффект: чем больше соображений, тем ниже итог. Три фактора по 0,8 дают 0,51, два фактора по 0,8 дают 0,64. В результате действия с богатым набором условий систематически проигрывают простым, хотя по смыслу ничего не изменилось. Классическое решение, описанное разработчиком Дэйвом Марком, называется компенсацией (compensation factor): итог слегка подтягивается вверх в зависимости от числа соображений, так что действия с разным числом факторов становятся сопоставимыми.

public abstract class UtilityAction : ScriptableObject
{
    [SerializeField] Consideration[] considerations;
    [SerializeField, Range(0.1f, 2f)] float weight = 1f;   // базовый приоритет действия
    [SerializeField] float minDuration = 1f;               // минимальное время удержания, сек

    public float MinDuration => minDuration;

    public float Evaluate(AgentContext ctx)
    {
        if (considerations == null || considerations.Length == 0) return 0f;

        float score = 1f;
        for (int i = 0; i < considerations.Length; i++)
        {
            score *= considerations[i].Score(ctx);
            if (score <= 0f) return 0f;   // вето: дальше считать нет смысла
        }

        // Компенсация: чем больше соображений, тем сильнее подтягиваем итог
        float modification = 1f - 1f / considerations.Length;
        float makeUp = (1f - score) * modification;
        score += makeUp * score;

        return score * weight;
    }

    public virtual void Begin(AgentContext ctx) { }
    public abstract void Tick(AgentContext ctx, float dt);
    public virtual void End(AgentContext ctx) { }
}

Рассмотрим числовой пример. Агент с голодом 0,8, здоровьем 0,3, едой на половине максимальной дистанции (0,5) и врагом на расстоянии 0,2 от максимума. «Есть»: голод по квадратичной кривой даёт 0,64, дистанция до еды по инверсии 0,5, произведение 0,32, после компенсации для двух факторов около 0,43. «Бежать»: инвертированное здоровье 0,7, близость угрозы 0,8, произведение 0,56, после компенсации около 0,68. «Атаковать»: здоровье по прямой кривой 0,3, близость врага 0,8, итог около 0,33. Побеждает бегство, причём без единой строки вида «если враг рядом и здоровье низкое». Если врага убрать из контекста, у «Бежать» обнулится множитель, и «Есть» естественным образом выйдет вперёд.

Параметр weight нужен для приоритетов, которые нельзя выразить кривыми. Реакция на удар, спасение от смерти, ответ на команду игрока обычно получают вес 1,5-2, чтобы выигрывать у бытовых действий при сопоставимых оценках. Не злоупотребляйте этим: если приходится поднимать вес до 5, значит, кривые подобраны неверно, а вес лишь маскирует проблему.

Действия и мозг агента

Теперь нужны реальные действия и компонент, который их выбирает. Действия в примере тоже сделаны ScriptableObject-ассетами, и здесь скрывается подводный камень: ассет один на всех агентов, поэтому хранить в нём изменяемое состояние нельзя. Таймеры, цели и промежуточные флаги должны жить в контексте агента, иначе поведение одного животного начнёт влиять на другое, а ошибка проявится только при двух и более экземплярах на сцене.

[CreateAssetMenu(menuName = "AI/Actions/Eat")]
public sealed class EatAction : UtilityAction
{
    [SerializeField] float eatRadius = 1.5f;
    [SerializeField] float hungerRestoredPerSecond = 0.25f;

    public override void Tick(AgentContext ctx, float dt)
    {
        if (ctx.NearestFood == null) return;

        if (ctx.DistanceToFood > eatRadius)
        {
            ctx.Nav.isStopped = false;
            ctx.Nav.SetDestination(ctx.NearestFood.position);
            return;
        }

        // Добежали: останавливаемся и утоляем голод
        ctx.Nav.isStopped = true;
        ctx.Hunger01 = Mathf.Max(0f, ctx.Hunger01 - hungerRestoredPerSecond * dt);
    }
}

[CreateAssetMenu(menuName = "AI/Actions/Flee")]
public sealed class FleeAction : UtilityAction
{
    [SerializeField] float fleeDistance = 12f;

    public override void Tick(AgentContext ctx, float dt)
    {
        if (ctx.NearestThreat == null) return;

        Vector3 away = (ctx.Self.position - ctx.NearestThreat.position).normalized;
        ctx.Nav.isStopped = false;
        ctx.Nav.SetDestination(ctx.Self.position + away * fleeDistance);
    }
}

Точка в направлении «от угрозы» может оказаться за пределами NavMesh. SetDestination в этом случае просто не построит путь, и агент замрёт, что для убегающего персонажа выглядит нелепо. В продакшене результат пропускают через NavMesh.SamplePosition и берут ближайшую допустимую точку. Для более естественной траектории стоит подключить Steering Behaviors: Utility AI решает, что делать, а steering отвечает за то, как двигаться. Если же карта размечена сеткой, а не NavMesh, путь строится алгоритмом из статьи про A* на сетке.

Теперь сам мозг. Он периодически собирает контекст, оценивает все действия, выбирает лучшее и вызывает Tick текущего. Цикл мышления запускается не каждый кадр, а с интервалом 0,2-0,3 секунды. Для решений уровня «поесть или убежать» этого хватает с запасом, ведь человеческая реакция на событие сопоставима, а нагрузка падает в десятки раз по сравнению с обработкой каждого кадра при 60 FPS. Начальное время первого цикла рандомизируется, чтобы сотня агентов не думала в одном и том же кадре и не создавала периодический пик.

public sealed class UtilityBrain : MonoBehaviour
{
    [SerializeField] UtilityAction[] actions;
    [SerializeField] float thinkInterval = 0.25f;
    [SerializeField, Range(0f, 0.5f)] float inertia = 0.15f;   // бонус текущему действию
    [SerializeField] float hungerPerSecond = 0.01f;
    [SerializeField] float minScoreToAct = 0.05f;

    readonly AgentContext ctx = new AgentContext();
    float[] scores;
    UtilityAction current;
    float currentSince;
    float nextThink;

    void Awake()
    {
        ctx.Self = transform;
        ctx.Nav = GetComponent<UnityEngine.AI.NavMeshAgent>();
        ctx.Health01 = 1f;
        ctx.Hunger01 = Random.value * 0.3f;

        scores = new float[actions.Length];
        nextThink = Time.time + Random.Range(0f, thinkInterval);   // разносим агентов по кадрам
    }

    void Update()
    {
        if (Time.time < nextThink) return;
        nextThink = Time.time + thinkInterval;
        Think(thinkInterval);
    }

    void Think(float dt)
    {
        Sense(dt);

        int best = -1;
        float bestScore = minScoreToAct;
        for (int i = 0; i < actions.Length; i++)
        {
            float s = actions[i].Evaluate(ctx);
            if (actions[i] == current) s *= 1f + inertia;   // гистерезис, см. ниже
            scores[i] = s;
            if (s > bestScore) { bestScore = s; best = i; }
        }

        if (best >= 0 && actions[best] != current && CanSwitch())
            SwitchTo(actions[best]);

        if (current != null) current.Tick(ctx, dt);
    }

    bool CanSwitch() => current == null || Time.time - currentSince >= current.MinDuration;

    void SwitchTo(UtilityAction next)
    {
        if (current != null) current.End(ctx);
        current = next;
        currentSince = Time.time;
        current.Begin(ctx);
    }

    void Sense(float dt)
    {
        ctx.Hunger01 = Mathf.Clamp01(ctx.Hunger01 + hungerPerSecond * dt);
        // Поиск ближайших объектов - см. следующий фрагмент
    }
}

Простой таймер в Update подходит, пока агентов десятки. Для сотен и тысяч логично вынести тики в общий планировщик, а повторяющиеся интервалы вести отдельным классом, как в статье про таймеры и кулдауны без корутин.

Сенсоры без лишних аллокаций

Заполнение контекста - самая дорогая часть цикла, потому что именно здесь происходят пространственные запросы. Для небольших сцен достаточно реестра: объекты еды и угроз регистрируют себя в статических списках в OnEnable и удаляются в OnDisable, а агент линейно ищет ближайший. Для сотен объектов рядом с агентом лучше использовать Physics.OverlapSphereNonAlloc с заранее выделенным буфером или пространственную сетку. Выбор подходящей структуры данных разобран в статье про коллекции C# в Unity.

public sealed class WorldMarker : MonoBehaviour
{
    public static readonly List<WorldMarker> Foods = new List<WorldMarker>();
    public static readonly List<WorldMarker> Threats = new List<WorldMarker>();

    [SerializeField] bool isThreat;

    void OnEnable()  { (isThreat ? Threats : Foods).Add(this); }
    void OnDisable() { (isThreat ? Threats : Foods).Remove(this); }
}

// Внутри UtilityBrain
static Transform FindNearest(List<WorldMarker> list, Vector3 from, out float distance)
{
    Transform result = null;
    float bestSqr = float.MaxValue;
    for (int i = 0; i < list.Count; i++)
    {
        float sqr = (list[i].transform.position - from).sqrMagnitude;
        if (sqr < bestSqr) { bestSqr = sqr; result = list[i].transform; }
    }
    distance = result != null ? Mathf.Sqrt(bestSqr) : float.MaxValue;
    return result;
}

Сравнение идёт по квадрату расстояния, а корень берётся один раз для победителя. Мелочь, но при ста агентах и сотне маркеров это 10 000 сравнений за цикл, и экономия на ста тысячах вызовов sqrt в секунду ощутима на слабых мобильных устройствах. Для быстрого прототипа с животными подойдут готовые модели вроде Animals FREE - Animated Low Poly 3D Models: на них удобно проверять связку «голод, хищник, бегство» ещё до появления финальной графики.

Выбор лучшего действия: максимум, допуск и случайность

Самая простая стратегия - взять действие с наибольшей оценкой, как в коде выше. Она детерминирована и легко отлаживается: по одинаковому контексту всегда получается одинаковое решение. Но для живых существ детерминизм становится недостатком. Десять одинаковых оленей с похожим голодом будут вести себя синхронно, а игрок быстро выучит, что враг всегда делает одно и то же при одинаковых условиях.

Если оценки двух действий отличаются на пару процентов, то выбор между ними по сути произволен, и можно сделать его случайным, но взвешенным. Схема такая: находим максимум, определяем порог как максимум, уменьшенный на допуск (например, на 10%), и выбираем случайно среди всех действий выше порога, причём шанс пропорционален оценке. Агент по-прежнему не выбирает заведомо плохое, но поведение перестаёт быть механическим.

int PickWithTolerance(float[] scores, float tolerance, float minScore)
{
    int bestIndex = -1;
    float best = minScore;
    for (int i = 0; i < scores.Length; i++)
    {
        if (scores[i] > best) { best = scores[i]; bestIndex = i; }
    }
    if (bestIndex < 0) return -1;

    float threshold = best * (1f - tolerance);   // например, tolerance = 0.1
    float sum = 0f;
    for (int i = 0; i < scores.Length; i++)
        if (scores[i] >= threshold) sum += scores[i];

    float roll = Random.value * sum;
    for (int i = 0; i < scores.Length; i++)
    {
        if (scores[i] < threshold) continue;
        roll -= scores[i];
        if (roll <= 0f) return i;
    }
    return bestIndex;   // страховка от ошибок округления
}

Случайность должна применяться осторожно и только к действиям из одной «весовой категории». Для критических ситуаций (здоровье на нуле, враг вплотную) детерминированный максимум надёжнее: игрок воспринимает странные решения в такие моменты как баг, а не как живость. Практичный компромисс - ввести уровни приоритета (tiers). Каждое действие принадлежит уровню, например «выживание», «потребности», «досуг». Мозг сначала ищет лучшее действие в старшем уровне, и только если там нет оценок выше порога, спускается ниже. Случайный выбор с допуском применяют на нижних уровнях, где ошибка безвредна.

Гистерезис против дрожания решений

Самая коварная проблема Utility AI проявляется не в коде, а в игре: агент начинает метаться. Оценки «Есть» и «Отдыхать» держатся около 0,52 и 0,50, при каждом пересчёте лидер меняется, и персонаж полсекунды идёт к еде, затем разворачивается и бредёт к месту отдыха, потом снова к еде. С точки зрения формул всё корректно, с точки зрения игрока перед ним сломанный ИИ.

Лечится это двумя взаимодополняющими приёмами. Первый - инерция: текущему действию добавляется небольшой бонус, обычно 10-20% к оценке, как в коде мозга выше. Чтобы переключиться, конкурент должен не просто обогнать текущее действие, а превзойти его с запасом. Второй - минимальное время удержания: после старта действие нельзя прервать в течение заданного числа секунд (поле MinDuration). У еды это может быть 3-5 секунд, у отдыха 5-10, у бегства лишь 1-2, чтобы агент быстро реагировал на изменение угрозы.

Исключения из блокировки стоит предусмотреть явно. Если оценка нового действия превышает порог экстренности, например 0,9, то прерывание разрешается независимо от времени удержания. Иначе удерживаемое «Есть» продлится ещё три секунды, пока по агенту стреляет противник. Нужно различать ситуации, в которых стабильность важнее реактивности, и ситуации, где всё наоборот. Единой константы инерции на все случаи нет: её подбирают по каждому действию отдельно, ориентируясь на то, как долго оно должно выглядеть осмысленным.

Ещё один источник дрожания - сами входные данные. Дистанция до цели, которая постоянно меняется при движении, генерирует шум в оценке. Сглаживание входов (экспоненциальное скользящее среднее с коэффициентом около 0,3) или округление дистанции до шага в полметра снимает значительную часть проблем без изменения логики.

Производительность и отладка

Стоимость самих вычислений невелика. Оценка одного действия с тремя соображениями - это три вызова Evaluate с одной степенью или экспонентой, и всё вместе обходится в единицы микросекунд на агента. При восьми действиях и частоте мышления 4 Гц тысяча агентов выполняет порядка 24 000 вычислений кривых в секунду, что на современных телефонах незаметно на профайлере. Дорого обходятся сенсоры: пространственные запросы, расчёт путей и особенно поиск по сцене. Профилируйте именно их, а не математику кривых.

Источником нагрузки по памяти бывает злоупотребление LINQ внутри цикла мышления: OrderByDescending, Where и First создают временные объекты и при тысячах вызовов в секунду нагружают сборщик мусора. Для выбора максимума достаточно обычного цикла for, как в примерах выше. Подробнее о цене подобных удобств сказано в материале про LINQ в Unity. Если агентов тысячи, можно разнести мышление по кадрам: за кадр обрабатывается фиксированный бюджет (скажем, 50 агентов), остальные ждут своей очереди.

Отладка важнее производительности. Система выбирает действие по набору чисел, и без видимости этих чисел понять, почему олень побежал к хищнику, невозможно. Минимум, который нужен с первого дня, - вывод текущих оценок над агентом в редакторе. Код ниже рисует подпись с оценками всех действий и названием текущего.

#if UNITY_EDITOR
    // Добавляется в UtilityBrain
    void OnDrawGizmosSelected()
    {
        if (scores == null) return;

        var sb = new System.Text.StringBuilder();
        sb.Append(current != null ? current.name : "none").Append('\n');
        for (int i = 0; i < actions.Length; i++)
            sb.Append(actions[i].name).Append(' ').Append(scores[i].ToString("F2")).Append('\n');

        UnityEditor.Handles.Label(transform.position + Vector3.up * 2f, sb.ToString());
    }
#endif

Аллокации в редакторе допустимы, а директива #if UNITY_EDITOR гарантирует, что в сборку этот код не попадёт. Если нужно большее, чем подпись над головой, сделайте окно редактора с графиком оценок за последние несколько секунд: по таким графикам видно и дрожание, и «залипание» одного действия. Для кривых полезно кастомное отображение в инспекторе, об этом можно прочитать в материале про Custom Editor и PropertyDrawer. Кроме того, чистые функции кривых и объединения оценок отлично подходят для EditMode-тестов: проверяете, что при голоде 0,9 оценка «Есть» выше, чем при 0,3, и что нулевое соображение обнуляет действие.

Частые ошибки настройки

Первая и самая распространённая - ненормализованные входы. Если одно соображение выдаёт значения до 1, а другое до 30, перемножение превращается в шум, а до баланса дело не доходит. Все соображения должны давать результат строго в диапазоне 0-1, и Mathf.Clamp01 в конце вычисления страхует от случайного выхода за границы.

Вторая ошибка - слишком много соображений у одного действия. Когда у «Атаки» их семь, никто уже не понимает, какое из них перетягивает результат, и настройка превращается в гадание. Практичный предел - три-четыре фактора. Если нужно больше, это сигнал разбить действие на два: «Атаковать вблизи» и «Атаковать издалека».

Третья - отсутствие вето-соображений. Действие «Стрелять» с идеальными оценками по дистанции и мотивации будет выбрано даже при пустом магазине, если не добавить соображение «патроны есть» с кривой, обнуляющей оценку. Условия невозможности должны быть частью оценки, а не проверкой внутри Tick, иначе агент выбирает действие и ничего не делает.

Четвёртая - настройка кривых вслепую. Рисовать графики и наблюдать, как они ведут себя в сцене, обязательно: потратьте час на отладочную панель, и вы сэкономите дни. Часто помогает правило «один агент, два действия»: сначала балансируют пару «Есть» и «Отдыхать», потом добавляют третье, затем четвёртое. Если собрать сразу восемь действий и пытаться исправить поведение целиком, причину странностей найти не удастся.

Пятая - изменяемое состояние в ассетах ScriptableObject. Выше об этом уже шла речь, но ошибка настолько частая, что стоит повторить в виде правила: ассеты хранят только настройки, а любое состояние живёт в контексте агента.

Где Utility AI не подходит и с чем его сочетать

Метод плохо справляется с жёсткими сценариями. Босс с тремя фазами, где в каждой своя последовательность атак, проще и надёжнее описывается деревом или конечным автоматом: дизайнеру нужна предсказуемость, а не взвешенный компромисс. То же относится к скриптовым сценам, туториалам и любому поведению, которое должно воспроизводиться одинаково. Utility AI вводит вероятностную неопределённость в восприятие, и когда игроку нужно показать «вот эта атака происходит всегда после рывка», она вредит.

Вторая граница - масштаб настройки. Пять-десять действий поддаются ручному балансу. При пятидесяти действиях и сотне соображений кривые начинают влиять друг на друга неочевидным образом, и без инструментов визуализации поддержка становится дорогой. В таких проектах команды строят отдельный редактор и автоматические тесты на сценарии вроде «при таком контексте должно выиграть такое действие».

Лучше всего Utility AI работает в связке с другими подходами. Типичная архитектура: на верхнем уровне Utility AI выбирает цель или режим поведения («охота», «отдых», «бегство»), а внутри режима исполнение ведёт дерево поведения или простой автомат. Дерево отвечает за последовательность («подкрасться, прыгнуть, укусить»), а оценки решают, когда начинать и когда бросать. Навигацию и избегание препятствий берут на себя NavMesh или steering, а Utility AI лишь задаёт цель. Для шутеров и экшенов, где реакция должна быть мгновенной, верхний уровень Utility AI обновляется реже кадра, а низкоуровневое прицеливание и укрытия остаются за детерминированным кодом.

Если вам нужны ориентиры для проверки на готовом материале, подойдут шаблоны вроде Survival Island | Template + Editor, где потребности персонажа и окружения уже есть на сцене, и на них удобно навесить собственный мозг с оценками.

Что сделать на практике

Начните с минимального вертикального среза: два-три действия, два-три соображения, один агент. Реализуйте ResponseCurve, контекст и мозг в том виде, как они показаны выше, и добейтесь осмысленного поведения на пустой сцене с одним источником еды и одним врагом. Только после этого добавляйте новые действия.

Сразу заведите отладочную подпись с оценками над агентом и держите её включённой во время всей настройки. Нормализуйте каждый вход и проверяйте его границы: максимум нормализации равен расстоянию, за которым фактор перестаёт влиять на решение. Закладывайте гистерезис с самого начала (инерция 10-20% и минимальное время удержания), а не после жалоб на метание агентов.

Думайте с интервалом 0,2-0,3 секунды, разносите агентов по кадрам случайным смещением и не используйте LINQ внутри цикла. Условия невозможности оформляйте как соображения с обнуляющей кривой. Состояние агента храните в контексте, а не в ассетах. Если дизайн требует жёстких последовательностей, не форсируйте Utility AI: оставьте ему выбор верхнего уровня и отдайте исполнение дереву поведения.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также