Как сделать ИИ для гонок

Пост является, по сути, продолжением статьи Как сделать простой автосимулятор.

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

Не будет преувеличением сказать, что ИИ — это самое сложное в играх. О графике в Интернете говорят очень много, о физике — намного меньше, а об игровом искусственном интеллекте почти не говорят. До этой территории мало кто добирается, а кто добирается — в основном, крупные студии — держат свои ноу-хау в секрете. Поэтому скажу честно: я не знаю, как написаны контроллеры оппонентов в коммерческих гоночных играх, и давно бросил попытки выяснить. Я пошел своим путем.

У классических гонок есть большое преимущество — линейность и детерминированность. Машины должны ехать в одном направлении по заранее известному маршруту. Это позволяет хранить статическую информацию обо всей трассе в памяти и использовать ее для расчетов. Думаю, все гонки хранят трассу в виде последовательности маршрутных точек (waypoint’ов), а затем каким-то образом пускают по этой траектории ботов. Простейший, наивный вариант — просто двигать машину по сплайну, образованному waypoint’ами (можно использовать кубический сплайн, кривую Катмулла-Рома или что-то подобное). Но, поскольку это кинематический подход, он не будет дружить с физикой. Кроме того, придется думать, как реалистично изображать поворот, как избежать «эффекта трамвая», позволяя машине отклоняться от курса, и т.д. В результате простое, на первый взгляд, решение превратится в монстра из костылей. Поэтому я сразу решил отказаться от кинематики и сделать такой алгоритм, который управлял бы машиной тем же способом, что и игрок — рулем и газом, что будет физично и честно. Базово эта задача решается элегантным, хотя и не самым тривиальным в реализации методом.

Алгоритм Pure Pursuit

Алгоритм «чистого преследования» — это популярный в робототехнике метод автоматического следования траектории. Его суть в том, что автомат в каждый момент времени преследует целевую точку на траектории, находящуюся на некотором расстоянии от себя (lookahead distance, Ld). Эта точка всегда впереди и указывает в нужную сторону, как морковка перед осликом.

Сначала нужно найти ближайшую к машине точку на траектории. От нее нужно двигаться вперед вдоль траектории до тех пор, пока расстояние до нее не станет равно Ld. Если же расстояние изначально больше, чем Ld, то это значит, что машина находится слишком далеко от трассы, и нужно на нее вернуться, прежде чем ехать дальше (то есть, просто берем в качестве целевой ближайшую точку). Ld — самый важный параметр алгоритма, потому что нам важно двигаться к некой цели, а в гонках эта цель должна быть не просто на маршруте, но и продвигать машину дальше по маршруту.

Поиск целевой точки — самая сложная часть алгоритма, потому что сам по себе Pure Pursuit не описывает наиболее эффективный способ делать это. Поскольку трасса у нас хранится в виде последовательности waypoint’ов, в нашем случае задача сводится к перебору отрезков и нахождению их точек пересечения с окружностью радиуса Ld и с машиной в центре. Пересечений может быть много, но нас интересует то, которое находится дальше по маршруту. Для оптимизации я храню индекс ближайшего к машине waypoint’а по направлению маршрута. Это позволяет сильно сократить количество потенциальных пересечений, ведь нас интересует не вся трасса, а только несколько ее сегментов в непосредственной близости от машины.

Мою реализацию алгоритма вы можете найти в репозитории Chillwave Drive.

Как рулить?

Получив целевую точку, нужно вычислить кривизну пути (curvature) — это необходимый параметр для всей дальнейшей логики.

Кривизна — это радиус дуги, которая соединит текущую позицию машины с целевой точкой:

// Локальный вектор от машины к целевой точке
Vector2f localTarget = targetPoint - car.position;

// Расстояние от машины до целевой точки
float distanceToTarget = localTarget.length;

// Мировой угол вектора к целевой точке
float angleToTarget = atan2(localTarget.x, localTarget.y);

// Мировой угол вектора текущей ориентации машины
float carFacingAngle = atan2(car.direction.x, car.direction.y);

// Угол между ориентацией машины и вектором к целевой точке
float alpha = angleToTarget - carFacingAngle;

// Угол нужно нормировать, чтобы избежать физически невозможной кривизны
while (alpha > PI)  alpha -= 2.0 * PI;
while (alpha < -PI) alpha += 2.0 * PI;

// Вычисляем кривизну
float curvature = (2.0 * sin(alpha)) / distanceToTarget;

Наконец, вычисляем угол руления для передних колес:

float steeringAngle = atan2(curvature * wheelbase, 1.0);

wheelbase — это колесная база автомобиля, расстояние между передней и задней осями.

У меня в движке положение руля задается числом от -1 до 1 (чтобы не привязываться к конкретным пороговым значениям угла), поэтому полученный угол я перенормирую:

float steeringInput = clamp(steeringAngle, -maxSteeringAngle, maxSteeringAngle) / maxSteeringAngle;
steeringInput = clamp(steeringInput * steeringForce, -1.0f, 1.0f);

Коэффициент steeringForce зависит от физики машины, целевой скорости и желаемого характера руления. Я использую около 10.0, но вам, скорее всего, придется подгонять этот параметр под особенности вашей физической модели. Чем выше steeringForce, тем сильнее будет oversteer на поворотах, и, как следствие, может получиться эффектный дрифт. Но для этого обязательно нужно ограничивать скорость в зависимости от кривизны, о чем как раз дальше и пойдет речь.

Как давить на газ?

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

float targetSpeed = maxSpeed;
float absCurvature = abs(curvature);

if (absCurvature > 0.001f)
{
    float safeSpeed = sqrt(maxLateralAcceleration / absCurvature);
    if (safeSpeed < targetSpeed)
        targetSpeed = safeSpeed;
}

// Минимальный порог скорости, чтобы машина не остановилась совсем
if (targetSpeed < 5.0)
    targetSpeed = 5.0;

float speedError = targetSpeed - currentSpeed;

if (speedError > 0.0)
{
    // Если машина едет медленее, чем нужно, ускоряемся
    float throttle = clamp(speedError * accelerationCoef, 0.0f, 1.0f);
}
else
{
    // Если машина едет слишком быстро, тормозим
    float brake = clamp(abs(speedError) * brakingCoef, 0.0f, 1.0f);
}

Скорость задается в метрах в секунду.

maxLateralAcceleration (максимальное боковое ускорение) — интересный параметр, который, вкупе со steeringForce, позволяет контролировать «агрессивность» ИИ на поворотах. При низком значении сброс скорости будет сильнее зависеть от кривизны, что делает поведение бота более осторожным. При высоком значении, напротив, бот будет вести себя «безумнее» и даже может не вписаться в крутой поворот. Этот параметр опять-таки можно сделать динамическим, чтобы тактика зависела от ситуации на дороге. Например, если бот вырвался вперед, ему не нужно рисковать, а если он догоняет, то пытается проходить повороты дрифтом. Из-за некоторой непредсказуемости физики, такое вождение может быть успешным, но иногда приводит к вылету с трассы, что выглядит очень реалистично.

Дополнения

Описанный подход рассчитан на управление в идеальных условиях — когда машина находится на трассе, и перед ней нет препятствий. Если бот вылетел и врезался, нужно временно игнорировать Pure Pursuit и активировать отдельный «режим эвакуации». Если текущая скорость машины меньше порогового минимума в течение определенного промежутка времени, это значит, что движению что-то мешает, и нужно дать назад. Длительность реверса тоже ограничивается таймером, по истечении которого мы возвращаемся в режим Pure Pursuit и пытаемся ехать дальше.

Также можно расширить модель поддержкой динамического уклонения от препятствий, чтобы бот мог обгонять другие машины на трассе — это уже тема для отдельной статьи.

Соперники в Chillwave Drive

После релиза Dagon 1.1 я решил вернуться к демке автосимулятора Chillwave Drive — собираюсь превратить ее в полноценную гоночную игру. Этому препятствовало отсутствие ИИ для соперников, и на днях наконец-то эта задача была решена — я написал алгоритм следования траектории на базе Pure Pursiut, популярного метода из робототехники (но почему-то практически не известного в среде инди-разработчиков). В ближайшее время будет подробная статья на эту тему, а пока вы можете скачать свежий билд Chillwave Drive и увидеть ИИ в действии, либо просто посмотреть видео:

Блог восстановлен!

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

Обновления

Выпустил первое крупное обновление Dagon после стабильного релиза — 1.1.0. В этой версии исправлены некоторые баги, добавлен модуль проверки столкновений в 2D и в целом несколько упрощена работа с 2D-играми. Во всех встроенных шейдерах сабрутины GLSL заменены на ветвления для лучшей совместимости со старыми видеокартами. Стартовала работа над редактором сцен для движка.

К этому релизу я приурочил выпуск переиздания моей старой игры 2048×2 — версии популярной головоломки 2048 для двух игроков. Игра портирована на Dagon, что обеспечило поддержку x86_64 и улучшило качество рендеринга шрифтов. Также я добавил переводы игры на немецкий, французский, испанский, португальский и другие языки. Графический стиль и механика не изменились.

Сборку для Windows можно скачать тут, исходники и linux-версия будут на днях.

Подробности об инциденте 3 июня

Итак, что же произошло? 3 июня 2026 года нидерландский дата-центр nLighten без предупреждения обесточил серверы в Дронтене, которые арендовала компания MIRhosting. Этот инцидент стал продолжением массовой волны отключений: ранее с этим столкнулся еще один крупный российский хостинг VDSina.

Началась эта история с преследования двух лиц, которые, по версии следствия, причастны к кибератакам и операциям по вмешательству в выборы в Дании в конце 2025 года — в частности, обеспечивая инфраструктуру для атак хакерской группы NoName057(16). Тогда в Дании перестали работать сайты политических партий, датского парламента и издания The Copenhagen Post, а также была нарушена работа системы MitID, через которую граждане получают доступ к государственным сервисам.

По информации европейских информагентств, серверы компаний MIRhosting и WorkTitans наиболее активно использовались для осуществления незаконных действий на территории ЕС. Руководство MIRhosting, впрочем, все обвинения отрицает. MIRhosting — крупный оптовый провайдер, ориентированный преимущественно на B2B. Среди его клиентов множество мелких хостингов, в том числе и тот, услугами которого я пользовался (по соображениям безопасности, не буду его пока раскрывать).

Я пока не понимаю, какие юридические основания были для отключения со стороны дата-центра. Исходя из отписки техподдержки хостинга, есть вероятность, что это не более чем перестраховка, и все вернут (то есть, серверы не конфискованы полицией, а просто на всякий случай отключены). Но чисто интуитивно оснований для оптимизма я не вижу. В таком вот непредсказуемом мире мы сейчас живем.

Блог частично восстановлен

Хорошие новости: мне удалось восстановить большую часть постов 2026 года (правда, без картинок) из локального архива и сохранившихся копий Wayback Machine. В архивах сохранилось многое, но, к сожалению, все это практически невозможно импортировать в WordPress без ручной правки. В ближайшее время я восстановлю материалы за 2025 год, а остальным буду заниматься по мере сил.

Вскоре напишу подробный пост о том, что произошло с сервером.

Блог утерян :(

Из-за серьезных проблем с датацентром, на котором в последнее время был размещен блог, мне пришлось мигрировать на новый VPS. К сожалению, восстановить базу постов не удалось. Все, что я пока смог воссоздать — тема и некоторые страницы в меню (их версии по состоянию на май 2025 года).

Я не оставляю надежду, что еще удастся вернуться на старый сервер, где сохранились все данные, но большого оптимизма по этому поводу нет. Вероятнее всего, придется начать все с нуля.

Нововведения в GScript3

Давненько не писал о GScript! Между тем, VM языка уже пригодна для использования в играх, а спецификация пополнилась множеством интересных фич, о которых я сейчас и расскажу.

Изоляция памяти потоков

О потоках в GScript3 я уже писал в отдельном посте. Разрабатывая модель памяти языка, я придумал совершенно новую парадигму, которую в существующих реализациях не видел. Каждый поток имеет свою изолированную арену для динамических аллокаций (с размером блока в 1024 байта). Ссылочные типы — объекты, массивы, динамически созданные строки — в контексте потока создаются в этой арене и по умолчанию помечаются как принадлежащие этому потоку (у каждой переменной GsDynamic есть поле владельца — owner). VM запрещает ссылке покидать изолированный контекст, проверяя владельца при операциях внешней передачи — например, при записи в глобальную переменную. Исключение делается только для главного потока, который имеет право писать куда угодно без ограничений.

Такой подход гарантирует, что данные, созданные потоком, не переживут его самого, и в памяти машины не будет висячих ссылок, что очень полезно для платформы, не имеющей сборщика мусора. Но это несколько ограничивает полезность потоков, поэтому я предусмотрел два вида исключений из этого правила. Если создать данные со спецификатором shared, они будут размещены в арене главного потока и, таким образом, будут валидны вплоть до завершения работы скрипта (в терминах GScript — будут разделяемыми):

const thread = spawn func
{
    const arr1 = shared [10, 5, 6];
    const arr2 = [10, 5, 6];

    global.arr = arr1; // ok
    global.arr = arr2; // error!
};

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

const s = shared("a" ~ "b");

Помимо этого, есть ключевое слово escape. Оно, как и shared, применяется к результату выражения. Оно берет неразделяемые данные и делает их разделяемыми — то есть, якобы принадлежащими основному потоку. Это позволяет передать ссылку во внешний контекст, даже если она указывает на изолированную арену.

func test(value)
{
    print value;
}

const thread = spawn func
{
    const arr = [0, 1, 2];
    test(escape arr);
};

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

Каналы

Канал — это концепция из Go, примитив, реализующий одновременно синхронизацию и межпоточный обмен данными. При вызове метода send поток-производитель блокируется до тех пор, пока сообщение не будет получено другим потоком. При вызове метода receive поток-потребитель блокируется до тех пор, пока не появится доступное сообщение.

const ch = global.channel();

const thread1 = spawn func
{
    print ch.receive();
    ch.send("world");
};

const thread2 = spawn func
{
    ch.send("hello");
    print ch.receive();
};

Приведенная программа выведет строку «hello», затем строку «world». Порядок гарантируется, потому что сначала будет запущен первый поток, который ждет сообщения «hello» от второго, а затем отправляет ему «world».

Проверка типов и обработка ошибок

Тип динамического значения можно сравнить с константой типа с помощью оператора :

const x = 10;
if (x: Number)
{
    // ...
}

Альтернативно, тип можно получить в качестве числового значения, используя ключевое слово type, и сравнить с константой с помощью обычных операторов сравнения. Это полезно для логики типов:

if (type(x) != Null)
{
    // ...
}

if (type(x) == type(y))
{
    // ...
}

Константы типов в языке следующие:

Null = 0
Number = 1
String = 2
Array = 3
Object = 4
NativeMethod = 5
NativeFunction = 6
Error = 7
Function = 8
Vector = 9

Вы, наверное, заметили, что отдельной константой типа является Error — «ошибка». Ошибка в GScript3 действительно является отдельным типом переменной. Это позволяет не использовать пресловутый Null для обработки ошибок (хотя отказ от Null вообще — фича крайне спорная и даже безумная, я этого не стал делать) и одновременно — реализовать очень простую, не перегружающую синтаксис обработку ошибок на уровне VM.

Простейший случай — функция просто возвращает ошибку:

func myFunc()
{
    return error("BADBEAF");
}

Значением ошибки является строка, которую вызывающий код может вывести в консоль.

const thread = spawn myFunc;

let running = true;
while(running)
{
    running = thread.running;
    const result = await thread;
    if (result: Error)
    {
        print result;
    }
}

В некоторых тяжелых случаях поток должен экстренно завершить выполнение не в основной функции, а где-то в недрах контекста. Для этого я предусмотрел оператор raise. Это что-то напоминающее исключение, но сходство лишь синтаксическое. raise преждевременно завершает работу потока, сохранив значение ошибки в специальный слот. Вызывающий код отлавливает это состояние и реагирует на него. Отличие от исключений в том, что мой подход не имеет специального синтаксиса обработки ошибок — она делается в рамках обычной синхронизации через await:

func test()
{
    raise error("Something");
}

func threadFunc()
{
    test();
}

const thread = spawn threadFunc;

let running = true;
while(running)
{
    running = thread.running;
    const result = await thread;
    if (result: Error)
    {
        print result;
    }
}

Векторы

Свежее нововведение. Поскольку GScript3 создан с прицелом на игры, в языке не помешает векторный тип данных, и теперь он есть. Поддерживаются арифметические операции и встроенные функции (интринсики) для ускорения вычислений:

const v = vector(3, 2, 1) + vector(1, 2, 3);
print v;
print normalize(v);

Вектор является стековой структурой и хранится в стандартном GsDynamic.

VM реализует следующие интринсики для векторов: dot, cross, normalize, distance.

В этом посте я не затронул еще две важные темы — AST-макросы и библиотеки байт-кода. Они заслуживают отдельных статей.

Формат DAF

В предыдущем посте я уже писал о том, почему glTF нельзя использовать в качестве полноценного формата игровых ассетов, и вот альтернатива, которую я специально проектирую для Dagon 2.0 — Dagon Asset Format, сокращенно DAF.

DAF хранит, в первую очередь, вершинные буферы для прямой передачи в VRAM. Формат бинарный, архитектурно соответствующий API Dagon 2.0 и расширяемый: в нем могут быть объявлены любые дополнительные структуры данных и даже динамические свойства, не ломая обратную совместимость.

Основные задачи формата:

  • Хранение данных в форме, подходящей для прямой загрузки в видеопамять без накладных расходов на обработку (zero overhead). В отличие от glTF, в DAF вершинные буферы имеют фиксированный формат, согласованный с пайплайном движка, не требуя конверсии;
  • Максимальная эффективность десериализации. glTF требует парсинга JSON и динамического построения довольно сложных объектов в памяти (списков, словарей), а загрузка DAF – это просто реинтерпретация слайсов байтового буфера в массивы POD-структур. DAF экономит память и сокращает риск утечек, поскольку не требует множества аллокаций;
  • Частичная десериализация. Декодер может читать из DAF только те данные, которые ему нужны, не разбирая остальные.
  • Формат «все в одном». Файл DAF может хранить как отдельный меш, так и целую сцену. Все структуры формата поддерживают пользовательские свойства, что позволяет хранить в DAF метаданные редактора. Фактически, DAF может быть использован как простая NoSQL база данных для различных целей.
  • Поддержка семантики данных. Все объекты имеют список классов, что позволяет движку группировать их для задач игровой логики. Все текстуры помечаются как baseColor, normal, height, roughness-metallic, emission, для того, чтобы движок мог выбрать оптимальный BCn формат сжатия.
  • Поддержка данных для физики и проверки столкновений (в разработке).

Спецификация DAF находится здесь.

Недостатки формата glTF

В Dagon 1.0 я очень много времени потратил на поддержку этого монструозного формата моделей, и спустя пять лет работы с ним у меня не осталось ничего, кроме разочарования. Главный вывод: glTF — не для игровых ассетов (т.е. карт и отдельных переиспользуемых моделей). Это формат, предназначенный исключительно для просмотра, причем в основном в браузере, на WebGL, при помощи вьюверов типа Sketchfab. В этом он свою задачу выполняет, хотя и тоже неидеально. Но то, что его все начали использовать как формат обмена данными между программами моделирования и игровыми движками — это фундаментальная ошибка, которую я и сам по наивности допустил. Ниже мой личный список недостатков glTF, актуальных, прежде всего, в контексте игрового рендеринга.

  • Трансформация нод в glTF хранится либо в виде комбинации позиции, поворота и масштаба (TRS), либо в виде матрицы 4×4. Такая вариативность сильно усложняет игровую логику, так как без декомпозиции матрицы обратно в TRS вы не можете ничего анимировать. Декомпозиция — задача нетривиальная, у нее нет стопроцентно надежных решений, и это делает glTF-сцены в общем случае неподходящими для интерактивных приложений. Намного оптимальнее всегда хранить трансформацию в виде TRS и конвертировать в матрицу в движке, что является простейшей задачей.
  • Нефиксированный лейаут вершин — то есть, физическое расположение атрибутов в общем массиве данных может вариьироваться от одного меша к другому, равно как и формат чисел в буферах. Это совместимо с OpenGL/WebGL, но очень плохо для низкоуровневых API, где формат вершин фиксирован на уровне PSO и не может меняться в проходе. Движок вынужден конвертировать буфер в свое внутреннее представление, что убивает саму идею формата — прямую загрузку данных в видеопамять без препроцессинга.
  • Поддержка моделей без некоторых атрибутов — например, без текстурных координат или нормалей. В игровом движке не может быть такого, что нормали отсутствуют, для PBR-пайплайна их все равно нужно предоставить шейдеру. Поэтому отсутствующие атрибуты приходится генерировать — а как это делать, glTF не регламентирует, приходится изобретать свою логику. Это, опять-таки, противоречит идее «эффективного GPU-формата».
  • Кости для скелетной анимации определены как обычные ноды сцены, хотя они логически принадлежат не сцене, а отдельному виду объектов — собственно скелету. Скелет не вставляется в граф сцены, а используется как источник данных для построения pose-матриц для каждой индивидуально анимированной модели в сцене. То есть, с точки зрения архитектуры движка, glTF-подход к скелетке очень далек от эффективного решения несложной в принципе задачи.
  • Поддержка отрицательного масштаба. Для рендеринга это зло, потому что меняет winding и влияет на отбор видимости. Иногда при рендеринге это используется целенаправленно, но в форматах 3D-моделей практически всегда приводит к проблемам совместимости.
  • Использование web-first форматов текстур — PNG и JPEG. В AAA-движках эти форматы практически не используются из-за того, что они требуют декодирования на стороне CPU. Кроме того, для игр с большими мирами жизненно важно блочное сжатие текстур, это индустриальный стандарт. А с glTF получается, что нужен отдельный этап декодирования и сжатия изображений в GPU-френдли форматы. Это может и не быть большой проблемой, если движок поддерживает такой процесс, но все равно возникает много сложностей. Основной вопрос — в какой формат сжимать? Если это base color, то, конечно, достаточно BC1/BC3 (в зависимости от наличия альфа-канала), но для карт нормалей уже желателен BC7, а для черно-белых изображений эффективнее BC4. glTF никак не регламентирует семантику текстур, она определяется на уровне материалов, что в общем случае не позволяет реализовать полностью автоматический алгоритм выбора формата.

В общем, для Dagon 2.0 я обязательно буду пилить собственный формат ассетов, учитывая весь этот опыт. Поддержка glTF сохранится благодаря библиотеке Assimp, но формат больше не будет позиционироваться как основной.