Изучение того, как устроены гоночные игры, стало для меня настоящим квестом — с 2017 года я посвятил этому немало времени. В этой статье я поделюсь своим опытом и раскрою ряд неочевидных хитростей, которые упрощают и оптимизируют физическую модель автомобиля.

Стоит сначала оговориться, что я имею в виду под автосимом. Традиционно все гонки делятся на два поджанра — аркадные гонки и автосимуляторы. В аркадных не используется реалистичная динамика, вместо нее — различные формы кинематики и визуальные эффекты, имитирующие физику автомобиля: например, видимость управляемого заноса — машина «смотрит» в одну сторону, но едет в другую, и игрок может управлять этим поведением при помощи какого-то простого механизма ввода (например, мобильный NFS: No Limits позволяет войти в дрифт свайпом по экрану в нужную сторону). Симулятор же в обязательном порядке использует настоящий физический движок — динамику твердых тел. В нем все честно: силы, моменты, ускорение, моделирование подвесок и трения в шинах. Как следствие, управлять симулируемым транспортом заметно сложнее, но мы получаем невероятное преимущество — практически все феномены, наблюдаемые на дороге в реальности, возникают как следствие модели, и их не нужно специально имитировать. Классический пример симулятора — Assetto Corsa.

Где-то в промежутке между чистой аркадой и строгой симуляцией затесались так называемые симкейды (simcade). Этим термином называют упрощенные симуляторы, где параметров меньше, и вождение не требует понимания автомобильной механики — при этом они настолько физичны, насколько это важно для сохранения реализма. Типичные примеры — франшизы Gran Turismo и Forza Motorsport, но, по моему мнению, любые «физичные» гонки, для которых необязателен руль и достаточно контроллера с аналоговыми стиками, можно считать симкейдом. В эпоху первой PlayStation мне дико нравилась TOCA World Touring Cars — хрестоматийный симкейд, причем, по тем временам, на удивление реалистичный: у машин правдоподобно деформировались любые части кузова, отваливался бампер, можно было даже потерять колесо. Создавая свой автомобильный движок, я держал в голове стиль именно таких игр. Следуя этой статье, конкурент Assetto Corsa вы, конечно, вряд ли сделаете, но, тем не менее, физика получится очень близкая к реальности — не хуже (а может быть, где-то даже лучше), чем во многих коммерческих играх.

Ходовая часть

Минимальная фича, которая является «пропуском» автомобильной физики в симуляторы и симкейды — моделирование динамики каждого колеса по отдельности. Вопреки мифу, распространенному даже среди программистов, автомобильное колесо не моделируется твердым телом — оно им не является. Колесо — сложная неоднородная конструкция с мягким телом в своей основе, и точно рассчитать его поведение в риалтайме, учитывая все параметры, абсолютно нереально. Поэтому используется модель, известная как raycast vehicle. Контакт виртуального колеса с дорогой рассчитывается на основе пересечения луча с геометрией. Базовый алгоритм очень простой: если расстояние по вертикали от точки крепления колеса до поверхности дороги меньше или равно радиусу колеса, то колесо опирается на дорогу и, соответственно, генерирует на шасси силу реакции опоры (в терминологии автосимов — вертикальную силу).

Шасси — это обычное твердое тело, для симуляции которого подойдет абсолютно любой трехмерный физдвижок, который позволяет прикладывать к телам силы и моменты в заданных точках. Шасси является объектом с 6 степенями свободы в трехмерном пространстве: оно может перемещаться и поворачиваться по трем осям. Все силы, действующие на авто, применяются к шасси. Может показаться, что шасси менее важный элемент, чем колеса, но это не совсем так: например, очень важно правильно рассчитать тензор инерции шасси, чтобы машина адекватно себя вела при поворотах. В базовом варианте можно использовать тензор инерции прямоугольного параллелепипеда, подогнанного по габаритам к раме — задача в том, чтобы аппроксимировать распределение массы в реальном автомобиле. Многие любительские симы этим пренебрегают, и в результате машины ведут себя странно.

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

float suspensionToGround = distance(suspension.worldPosition, groundPosition);
suspension.lengthPrevious = suspension.lengthCurrent;
suspension.lengthCurrent = max(0.0f, suspensionToGround - radius);
suspension.compression = suspension.maxLength - suspension.length;

// равномерное распределение нагрузки на 4 колеса
float load = chassis.mass * 0.25;

float springForce = suspension.compression * suspension.stiffness;
float compressionSpeed = suspension.lengthPrevious - suspension.lengthCurrent;
float dampingForce = (compressionSpeed * suspension.damping) / dt;
float normalForce = (springForce + dampingForce) * load;

chassis.addForceAtPos(groundNormal * normalForce, forcePosition);

groundPosition и groundNormal вы можете найти при помощи алгоритма рейкаста — физдвижки обычно включают такую функцию. forcePosition — это точка приложения силы к шасси, которая находится в точке контакта шины с дорогой.

Метод addForceAtPos должен прикладывать одновременно и силу, и момент, который вычисляется как векторное произведение силы и радиус-вектора ее приложения:

Vector3f torque = cross(forcePosition - worldCenterOfMass, force);

Если сделать это для всех колес, то шасси будет «парить» над дорогой на четырех воображаемых пружинах. Базовый метод использует один луч на каждое колесо, но можно использовать мультисэмплинг, чтобы учесть объем шины и, соответственно, ситуации, когда опора находится не строго под точкой крепления — то есть, когда машина едет по неровной поверхности. Если ваш физический движок поддерживает convex cast (то есть, обнаружение пересечения поверхности с «объемным» лучом), то это еще лучше. Идеальный вариант для реалистичной игры (и, пожалуй, маст хэв для симулятора внедорожника) — cylinder cast, который очень точно моделирует контакт колеса с неровным ландшафтом.

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

Если ваша машина странно себя ведет, начните с подгонки трех параметров — жесткости подвески, демпфирования и центра масс шасси. В реальном автомобиле центр масс находится не в точном геометрическом центре кузова, а чуть ниже — условно на высоте верхнего края колес. Правило следующее: чем ниже ЦМ, тем меньше крен и лучше сцепление в поворотах, поэтому проектировщики гоночных авто постоянно пытаются его понизить. Советую и вам поступать так же: для хорошего симкейда нужно просто подобрать оптимальную высоту ЦМ с учетом ситуаций, которые вы хотите моделировать — для ралли и внедорожников это будет одно, для болидов — другое. Излишне заниженный ЦМ — тоже плохо: это приведет к тому, что машина будет странно вести себя в воздухе при подлетах, а они зачастую важны. В любительских симкейдах часто делают безбожно заниженный ЦМ, в результате чего машина вообще не может перевернуться, и это не прикольно.

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

Боковое трение генерирует боковую силу, которая действует на шасси противоположно направлению скольжения колеса. Вектор этой силы рассчитать легко, но с величиной все куда сложнее. Многие симы используют формулу Пацейки (Pacejka), известную также как «Волшебная формула», которую вывел в 90-х годах нидерландский физик Ханс Пацейка. Кривая Пацейки описывает боковую силу, действующую в точке контакта колеса с дорогой, в зависимости от вертикальной силы и угла скольжения (slip angle) — угла между продольным и поперечным векторами его скорости:

Vector3f wheelVelocity = chassis.localPointVelocity(forcePosition);

// скорость колеса в поперечном направлении
float lateralSpeed = dot(wheelVelocity, sideAxis);

// скорость колеса в продольном направлении
float longitudinalSpeed = dot(wheelVelocity, forwardAxis);

float slipAngle = atan2(lateralSpeed, abs(longitudinalSpeed));

Этот угол вы просто подставляете в функцию и получаете на выходе величину боковой силы:

float dynamicLateralFrictionForce = pacejka94(normalForce, slipAngle, camberAngle);
chassis.addForceAtPos(-sideAxis * dynamicLateralFrictionForce, forcePosition);

Саму формулу вы можете найти, например, на сайте Edy, известного Unity-разработчика автосимуляторов. Эта страничка хороша тем, что там даны объяснения всем константам формулы и приводятся их возможные диапазоны. Для полноценного понимания модели нужно быть, по меньшей мере, выпускником мехмата, но, к счастью, можно просто использовать формулу, не вникая в ее смысл. Главное задавать параметры в верных единицах: в классическом виде формула принимает вертикальную силу в килоньютонах и угол скольжения в градусах, так что могут потребоваться преобразования входных данных. Параметр camberAngle (угол развала) улучшает сцепление при резких поворотах. Я обычно задаю -2°..-4° на передней паре и 0° на задней, но с этими значениями можно играть в зависимости от желаемого эффекта.

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

Но это еще не все. Модель шины описывает динамическое трение, возникающее при движении. Если машина стоит на месте, то из-за мелких погрешностей float-арифметики динамическая боковая сила будет нестабильной, и покоящаяся на склоне машина будет слегка дрожать. Поэтому имеет смысл ввести дополнительное статическое трение, стабильно препятствующее боковому скольжению. Его можно рассчитать по закону Кулона (хотя, конечно, это будет упрощенным решением, допускающим, что колесо в покое является твердым телом).

float staticLateralFrictionForce = lateralSpeed / dt * load * staticFrictionCoefficient;

Коэффициент статического трения подбираем экспериментально в зависимости от типа поверхности. Если вам надо, чтобы машина просто стояла, то можно задать 0.99. Ровно 1.0 лучше не задавать, это нестабильно.

Как перейти от статического трения к динамическому? Скажу честно, я не знаю точного способа, потому что это нефизичный хак, но для игр отлично работает интерполяция в зависимости от скорости:

float wheelSpeed = wheelVelocity.length;
float speedFactor = clamp(wheelSpeed / dynamicSpeedThreshold, 0.0f, 1.0f);
float lateralFrictionForce = lerp(staticLateralFrictionForce, dynamicLateralFrictionForce, speedFactor);
chassis.addForceAtPos(-sideAxis * lateralFrictionForce, forcePosition);

dynamicSpeedThreshold — пороговая скорость колеса, по достижении которой трение на 100% динамическое.

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

if (abs(engineTorque) > 0.0f)
{
    float tractionForce = torque / radius * inverseInertia;
    chassis.addForceAtPos(forwardAxis * tractionForce, forcePosition);
}

Интересные ситуации возникают, когда меняешь компоновку авто. Я долгое время тестировал свои разработки на переднем приводе, и когда ради интереса сделал задний — удивился, как все резко стало хуже работать. Всплыли баги и недочеты, которые раньше не проявлялись. Так что один важных критериев «правильности» симулятора — предсказуемая работа при всех возможных видах компоновки. Стабилизировать управление с передним приводом проще всего, но RWD очень важен, если вы создаете реальный сим, а не просто возможность хоть как-то ездить. А это зверь весьма своеобразный. Еще один отдельный мир — 4×4, там нужно дополнительно симулировать раздаточную коробку.

Двигатель и трансмиссия

Двигатель в игровом симуляторе — это своего рода черный ящик, задача которого — выдавать некоторые обороты (RPM). Это значение — количество оборотов коленвала в минуту — переводится в соответствующий ему момент (torque), который затем пропускается через серию множителей, которые симулируют трансмиссию, и, наконец, передается ведущим колесам.

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

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

1) Задать две константы — rpmIdle и rpmMax (холостые обороты и максимальные). Для примера — 800 и 6000. Вся логика будет плясать от этих значений, работающий двигатель может выдавать обороты строго в этом диапазоне.
2) Ход сцепления будем симулировать числом от 0 до 1. При разомкнутом сцеплении (0) двигатель не имеет нагрузки и выдает свободные обороты, линейно зависящие от дросселя (тоже значение от 0 до 1). Можно вычислить их как rpmFree = lerp(rpmIdle, rpmMax, throttle). В реальности, скорее всего, эта зависимость вряд ли линейна, но я в такие дебри лезть не стал. При замкнутом сцеплении (1) двигатель имеет полную нагрузку и не может вращаться быстрее, чем обороты трансмиссии. Назовем их rpmDrivetrain. Сначала нужно узнать скорость, с которой вращаются колеса — можно просто взять продольную скорость колеса и перевести ее в угловую путем умножения на радиус — затем это значение конвертируется в обороты в минуту:

float rpmFree = lerp(rpmIdle, rpmMax, throttle);
float rpmWheel = wheel.longitudinalSpeed * wheel.radius * 60 / (2 * PI);
float rpmDrivetrain = rpmWheel * finalDrive * gearRatio * drivetrainEfficiency;

Эта формула описывает идеальный случай, когда нет пробуксовки.

3) Теперь можно вычислить RPM коленвала. Я делаю это простейшим способом, не особо физичным, зато эффективным (вероятно, можно и лучше):

float effectiveClutch = pow(clutch, 2.0f);
float engineInertia = 4.5f;
float rpm = lerp(rpmIdle, max3(rpmIdle, (rpmFree - rpmDrivetrain) / engineInertia, rpmDrivetrain), effectiveClutch);

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

Я использую степенную функцию вместо линейного clutch, так как сцепление имеет нелинейное «плечо» — сначала оно действует слабо, а потом резко сильнее.

4) Значение rpm подставляем в функцию момента двигателя:

Vector3f engineTorque = torqueCurve(rpm) * throttle;

Реализация torqueCurve — отдельная история, она зависит от того, насколько точно вам нужно симулировать эффективность двигателя внутреннего сгорания. Я использую такую гауссиану:

float torqueCurve(float rpm)
{
    const float A = maxTorque;
    const float B = 0.0f;
    const float C = 0.95f;
    const float D = rpmPeakTorquePoint;
    const float F = 3000.0f;
    return (A - B) * exp(-pow((rpm - D) * C, 2.0f) / (F * F)) + B;
}

Важнейшие константы — максимальный момент (maxTorque) в ньютон-метрах, пиковое значение RPM (rpmPeakTorquePoint) и кривизна (C). Первые два можно взять из спецификации реального двигателя, а кривизну — подобрать на глаз.

5) Полученный момент умножаем на передаточные числа и КПД трансмиссии:

Vector3f axleTorque = engineTorque * effectiveClutch * gearRatio * finalDrive * drivetrainEfficiency;

Тут можно запутаться: почему при передаче момента от двигателя к колесам мы умножаем на передаточные числа, и при обратной связи тоже умножаем? А дело в том, что скорость в трансмиссии обратно пропорциональна моменту. Если бы мы хотели передать RPM от двигателя колесам, то пришлось бы делить на передаточные числа, а не умножать. Соответственно, при обратной передаче нужно умножать.

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

Игровая АКП

Пытаться симулировать работу реальной автоматической коробки я не рекомендую — намного проще и практичнее сделать что-то вроде имитации ручного переключения передач. Логика простая: когда обороты поднимаются до эффективного значения (rpmPeakTorquePoint), делаем апшифт. Сцепление при этом выжимается (обнуляется), после чего плавно отпускается (растет до единицы). В результате обороты временно упадут, а потом вновь будут повышаться, как и в реальной жизни. Дауншифт делается аналогично — если обороты упали ниже минимального порога, например 3000.

if (accelerating)
{
    if (throttle < 1.0f)
        throttle += dt;
    else
        throttle = 1.0f;

    if (rpm >= rpmPeakTorquePoint && gear < gears.length - 1)
    {
        gear++;
        gearRatio = gears[gear];
        clutch = 0.0f;
    }

    clutch += dt;
    if (clutch > 1.0f)
        clutch = 1.0f;
}
else
{
    throttle -= dt;
    if (throttle < 0.0f)
        throttle = 0.0f;

    if (rpm <= 3000.0f && gear > 0)
    {
        gear--;
        gearRatio = gears[gear];
        clutch = 0.0f;
    }
}

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

Дополнительно

Игра — это не только чистый код автосмулятора. Нужно еще предусмотреть нештатные ситуации: что будет, если машина перевернется? Я определяю переворот следующим алгоритмом:

const float velocityThreshold = 2.0f;
const float angularThreshold = degtorad(5.0f);
bool stopped = car.velocity.length < velocityThreshold && car.angularVelocity.length < angularThreshold;

if (stopped && dot(car.up, Vector3f(0, 1, 0)) < 0.5f)
{
    // Перевернулись!
}

В этом случае можно, например, респаунить машину на дороге или просто приподнять чуть выше и сбросить кватернион поворота. Конечно, если вы делаете аналог BeamNG, то это, наверное, не обязательно.

Советы

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

В Интернете можно встретить немало материалов, где авторы пытаются искусственно моделировать различные феномены автомобильной физики — например, дрифт или перенос веса. Если у вас физика честная, то это вручную делать не нужно! Перенос веса в системе, где учитываются тензоры инерции, возникает сам собой, если к телу прикладываются правильные моменты сил. Так же и занос — он возникает, когда сила трения, вычисляемая моделью шины, перестает уравновешивать боковое скольжение колеса. Поэтому в реалистичном движке дрифт не имитируется специально — он работает естественным образом, как эффект модели шины. Справедлив лозунг «нормально делай — нормально будет».

Кстати, очень хорошая практика — разделять физику и визуал. То, что игрок видит на экране, не обязано на 100% соответствовать положению вещей в физдвижке. Например, ход подвески можно визуально сгладить, чтобы колесо меньше дергалось при резких подъемах и спусках. То же самое касается любых микротрансформаций. Если у вас что-то дергается, дрожит, не может устаканиться — сделайте отдельно значение для визуала и останавливайте принудительно, если дельта меньше определенного порога. Это неприемлемо в научных симуляциях, но для игр работает превосходно.

Что дальше?

Автосим можно дорабатывать бесконечно. Важные темы, которых я в этой статье не коснулся:

  • ABS;
  • Автомобильные звуки — шум двигателя, скрип шин, звуки при столкновениях;
  • «Гоночная» камера от третьего лица;
  • Симуляция повреждений.

Оставить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *