Последние рекорды серверов
Pro Nub

Физика стрейфов

Опубликовано Kpoluk 13 Мая 2023 в 11:20
Понимать физику стрейфов мы будем, используя находящийся в открытом доступе код Half-Life. А конкретно, нам понадобятся файлы input.cpp, pm_shared.c и pm_math.c

Скорость (velocity) – векторная величина. Мы можем задать её тремя компонентами (проекциями на оси системы координат). Либо можно представить её как пару – модуль скорости (speed) и её направление (единичный вектор).
При 100 fps движок CS 100 раз в секунду обрабатывает движения мыши и нажатия кнопок, после чего, исходя из этих данных, вычисляет, как должна вести себя моделька игрока. Если быть точнее, то движок знает не про нажатия кнопок, а просто получает команды «идти вправо», «присесть», «прыгнуть» и т. д., но для простоты будем говорить о кнопках.

Получение вектора скорости игрока на каждом таком такте происходит в несколько этапов:

  1. Получить состояние кнопок движения (WASD)

  2. На основе полученного состояния получить вектор желаемой скорости (то есть той, которую игрок стремится приобрести) в системе координат, связанной с моделькой игрока

  3. Зная направление, в котором смотрит игрок в данный момент, найти связь (а точнее матрицу перехода) между системой координат модельки и системой координат внешнего мира

  4. В случае наличия трения (к примеру, когда игрок идёт по земле) уменьшить модуль текущей скорости

  5. Используя полученную на этапе 3. матрицу, перевести вектор желаемой скорости в систему координат внешнего мира. Затем определённым образом сложить эту скорость с вектором текущей скорости. Причём способ сложения будет различаться в зависимости от того, находится игрок на земле или в воздухе. Таким образом, будет получен искомый вектор скорости.


Этап 1


Прежде всего, взглянем на эту функцию из input.cpp:


Как видите, она возвращает значение val, характеризующее состояние некой клавиши. Например, если кнопка была зажата в каком-то предыдущем фрейме, и всё ещё зажата в текущем фрейме, то val будет равен 1. А если, к примеру, кнопка не была нажата и не нажимается в текущем фрейме, то val равен 0.


Этап 2


Теперь в том же input.cpp рассмотрим следующий участок кода, исходя из нажатых клавиш (а точнее отправленных команд) формирующий вектор желаемой скорости (forwardmove, sidemove, upmove), то есть то направление, в котором мы хотим двигаться в данный фрейм. К концу статьи станет понятно, куда мы будем двигаться в итоге после всех расчётов.


При вычислении компонент использовались значения cl_upspeed (320 по умолчанию), cl_forwardspeed (400), cl_backspeed (400) и cl_sidespeed (400). При зажатой клавише Shift скорость в Half-Life умножается на 0.3, в CS 1.6 на 0.52. Если вектор после этого оказался по длине больше, чем maxspeed (250 с ножом/пистолетом по умолчанию), то масштабируем его так, что он становится равным 250.


Этап 3


Теперь посмотрим на интересующую нас часть функции PM_PlayerMove из файла pm_shared.c


Что это за AngleVectors там стоит? Всё довольно просто. Введём обозначения:

  • АСК – абсолютная система координат, связанная с внешним миром (картой)
  • ССК – связанная с моделькой игрока система координат
Тот вектор желаемой скорости, который мы получали в input.cpp, записан в ССК, причём эта система левосторонняя (ось X смотрит прямо вперёд, ось Y вправо, ось Z вверх - именно в этих направлениях мы получаем положительные значения forwardmove, rightmove и upmove соответственно). Мы же хотим использовать этот вектор для вычисления итоговой скорости, а та записана в правосторонней АСК. Функция AngleVectors даст нам матрицу перехода от АСК к ССК.


На вход поступают три угла, которые задают направление, в котором мы смотрим, то есть положение ССК относительно АСК. Эти углы называются углами Крылова и повсеместно используются в навигации:

  1. рыск (yaw) - поворот направо-налево (ось Z)
  2. тангаж (pitch) - наклон вверх-вниз (ось Y)
  3. крен (roll) - наклон вправо-влево (ось X).
Для краткости обозначим их следующим образом:


Для нахождения матрицы перехода от АСК к ССК нужно перемножить матрицы поворота, соответствующие углам Крылова. Плюс ещё одна матрица задаст отражение оси Y, дабы из правосторонней получить левостороннюю систему координат:


После перемножения получатся как раз те формулы, которые вы видели в коде функции AngleVectors. Итоговая матрица перехода от АСК к ССК имеет вид:


Здесь forward, right и up – единичные вектора, задающие оси ССК в проекциях на оси АСК.


Этап 4


Вернёмся в PM_PlayerMove. Там у нас далее вызывается функция PM_Friction (в том случае, если мы находимся на земле). Интересующая нас часть этой функции выглядит так:


При sv_friction 4 и при 100 fps (откуда длительность фрейма будет 0.01 секунды) получаем drop = control * 0.04. Если наша скорость больше значения sv_stopspeed (по умолчанию 75), то control равен нашей скорости, а drop составляет 4 процента от неё. Именно на эти 4 процента и замедлится наша скорость на выходе из PM_Friction.


Этап 5


После PM_Friction вызывается либо PM_WalkMove (если мы на земле), либо PM_AirMove (если мы не на земле). Рассмотрим эти случаи отдельно.

Движение на земле


Вот что представляет из себя PM_WalkMove :


Здесь мы получаем forwardmove и sidemove из input.cpp, затем используем forward и right из полученной нами выше матрицы для того, чтобы перевести вектор желаемой скорости в АСК, как мы и планировали.


Чтобы исключить влияние клавиш движения на вертикальную скорость игрока (в системе ССК), мы не используем upmove и обнуляем вертикальные компоненты forward и right (что вынуждает нас нормировать эти вектора, дабы они не повлияли на величину желаемой скорости). Далее скорость обрезается до значения maxspeed, и вызывается функция PM_Accelerate, в которую передаются значение и направление (единичный вектор) желаемой скорости, а также значение sv_accelerate (по умолчанию 5).


DotProduct это не что иное, как скалярное произведение векторов velocity и wishdir. Программно оно вычисляется с использованием их компонент, но нас интересует именно физический смысл. Так как wishdir единичный вектор, задающий желаемое направление движения, а velocity это текущий вектор скорости, то мы фактически проецируем velocity на направление wishdir. Чем больше между ними угол, тем меньше будет проекция, то есть переменная currentspeeed.

Далее вычисляется addspeed как разница желаемой скорости и currentspeed, а также accelspeed (accel * pmove->frametime * pmove->friction можно оценить как 5 * 0.01 * 1 = 0.05, тогда accelspeed составит 5 процентов от желаемой скорости).

В зависимости от соотношения между addspeed и accelspeed одна из этих величин становится длиной той самой прибавки в скорости, которую мы так желали получить. К вектору исходной скорости прибавляется вектор, направленный вдоль wishdir и равный по величине accelspeed (мы можем их складывать, так как заведомо перевели их в одну систему координат):


Попробуем теперь прочувствовать, что же всё это значит.

Эксперимент 1. Мы зажали W и просто бежим вперёд с ножом или пистолетом. Функция PM_Friction в данный рассматриваемый фрейм обрежет скорость на 4 процента, теперь вместо 250 мы имеем 240 юнитов/сек. Так как зажата только W, то в input.cpp её состояние равно 1, состояние остальных клавиш движения 0. Мы сначала получаем cmd->forwardmove = 400, cmd->sidemove = 0, а затем после масштабирования (так как мы превысили максимальную скорость 250) cmd->forwardmove = 250, cmd->sidemove = 0. В функции PM_WalkMove вектор (fmove, smove) задаст направление, в котором мы смотрим. Направление wishdir совпадает с направлением нашей текущей скорости, величина желаемой скорости wishspeed равна 250, и мы заходим в PM_Accelerate. Здесь

currentspeed = 240 * cos(0) = 240
addspeed = 250 - 240 = 10
accelspeed = 250 * 0.05 = 12.5 > 10

поэтому accelspeed становится равным 10. Далее мы прибавляем к нашей скорости 240 юнитов/сек ещё 10 юнитов/сек в том же направлении, и скорость вновь становится равной 250. Вот так мы просто бежим вперёд по земле.

Эксперимент 2. Мы, как и раньше, бежим вперёд и вдруг нажимаем A. Скорость после PM_Friction также 240 юнитов/сек. W была зажата и всё ещё зажата, поэтому её состояние 1, клавиша A зажата только что, её состояние 0.5. Поэтому сначала cmd->forwardmove = 400, cmd->sidemove = 200, а затем после масштабирования cmd->forwardmove = 223.6, cmd->sidemove = 111.8. Внутри PM_WalkMove после получения желаемой скорости мы передаём в PM_Accelerate wishspeed = 250 и единичный вектор желаемого направления wishdir, составляющий с нашей текущей скоростью угол arctg(200/400) = 26.565 градуса. В PM_Accelerate находим:

currentspeed = 240 * cos(26.565) = 214.66
addspeed = 250 - 214.66 = 35.34
accelspeed = 250 * 0.05 = 12.5 < 35.34

поэтому accelspeed остаётся равным 12.5. К нашим 240 юнитам/сек прибавляем 12.5 юнитов/сек в направлении wishdir. По теореме косинусов находим:

x^2 = 240^2 + 12.5^2 + 2* 240 * 12.5 * cos(26.565) = 63122.77
x = 251.24

Итак, наша скорость чуть повернулась влево и увеличилась примерно на 1.24 юнита/сек.
В последующие фреймы состояние клавиши A будет равно 1, поэтому wishdir всё время будет составлять 45 градусов с тем направлением, в котором мы смотрим. Схематично сложение скоростей происходит следующим образом:


Вектор скорости всё время будет поворачиваться влево, а её величина будет меняться в силу наличия трения интересным образом - несколько фреймов будет уменьшаться примерно до 249, затем где-то за 14 фреймов вырастет до 262, а после будет плавно уменьшаться в течение полусотни фреймов (напомню, при 100 fps каждый фрейм длится 0.01 секунды). Вот мы и получили объяснение такого явления, как fastrun.

В конце концов, направление скорости совпадёт с wishdir. При этом угол u между скоростью и wishdir сразу после нажатия клавиши A равен 26.565 градуса, затем подскакивает почти до 45 и постепенно уменьшается до нуля. И тут у нас возникает любопытный вопрос – а что если всё время уводить wishdir влево, тем самым поддерживая некий постоянный угол u – как будет зависеть скорость от u?

Эксперимент 3. Уводить wishdir влево – значит поворачивать влево направление взгляда. Чтобы делать это с постоянной угловой скоростью, воспользуемся «стрелочками» на клавиатуре (команды +left и +right). Будем бежать, зажав A, W и левую стрелку. Через несколько секунд вы обнаружите, что бежите по кругу с постоянной скоростью. При этом PM_Friction будет каждый фрейм уменьшать скорость, а PM_Accelerate поворачивать её и увеличивать ровно настолько же.

Скорость поворота на стрелочках задаётся кваром cl_yawspeed, который по умолчанию равен 210. Уменьшим cl_yawspeed до 180. Радиус круга, по которому мы бегаем, увеличился. Следовательно, угол u стал меньше. Кроме того, addspeed всё ещё больше accelspeed, поэтому величина прибавляемого вектора та же. Отсюда получаем, что величина установившейся скорости стала больше.

Будем и дальше уменьшать cl_yawspeed. Всё идёт неплохо, где-то в районе 118 мы получаем уверенные 277 юнитов/сек, а вот уже при cl_yawspeed 117 скорость стала падать. Таким образом, мы обнаружили, что при некотором угле u прирост скорости за один фрейм будет максимальным. Давайте при помощи MATLAB построим график, который наглядно покажет нам зависимость прироста скорости от угла u и от величины текущей скорости:


В третьем эксперименте мы сначала разгонялись, то есть были где-то в красной зоне графика, а затем выходили на постоянный угол u с нулевым приростом скорости, то есть оказывались в жёлтой зоне. При этом хорошо видно, что чем меньше мы делали u, тем большую скорость нам удавалось поддерживать. Однако при скоростях больше 250 юнитов/сек нас встречает яма, которая не позволяет нам набирать скорость при малых значениях u. Дальше 277-278 юнитов/сек продолжать график смысла нет, так как ни один из возможных углов u уже не сможет дать нам прирост скорости.

Движение в воздухе


Ну что ж, оставим землю в покое. Посмотрим, что происходит в воздухе.

PM_AirMove отличается от PM_WalkMove разве лишь тем, что вместо PM_Accelerate вызывается функция PM_AirAccelerate, и передаётся в неё не sv_accelerate, а sv_airaccelerate (по умолчанию 10). Сама же PM_AirAccelerate практически один в один повторяет PM_Accelerate:


Однако здесь есть два важных для физики отличия. Во-первых, в воздухе на нас не действует трение, и во-вторых мы используем переменную wishspd вместо wishspeed, которая не может превышать 30. Таким образом, мы не можем использовать те же тактики, что на земле. Действительно, наш currenspeed не должен превышать 30, иначе никакого ускорения мы заведомо не получим. И тут мы идём на хитрость - отказываемся от использования W, будем нажимать только A либо D. А угол u между текущей скоростью и желаемым направлением мы будем делать таким, что скалярное произведение DotProduct окажется меньше 30.

Но не будем гнать лошадей, сначала сориентируемся в цифрах.

Разбежимся на W, затем прыгнем, отпустим W и нажмём A. Никакое трение на нас теперь не действует, поэтому текущая скорость равна 250 юнитов/сек и в первый фрейм в воздухе направлена прямо туда, куда мы смотрим. В PM_AirAccelerate мы зайдем, имея направление wishdir, смотрящее влево, и wishspeed 250.

wishspd = 30
currentspeed = 0 (cos(90) = 0)
addspeed = 30 - 0 = 30 > 0
accelspeed = 10 * 250 * 0.01 * 1 = 25 < 30

поэтому accelspeed остаётся равным 25. Итоговая скорость будет чуть отклонена влево и по теореме Пифагора составит

x^2 = 25^2 + 250^2 = 63125
x = 251.25

Таким образом, наша скорость окажется чуть больше, и мы пролетим большую дистанцию. Хотя, по сути, вперёд мы пролетели ровно настолько же, как если бы не нажимали кнопок вообще - дистанция больше только за счёт отклонения влево во время полёта.

В проведённом эксперименте угол u между wishdir и текущей скоростью составлял почти 90 градусов. Пусть теперь мы делаем lj и где-то в начале полёта имеем скорость 275 юнитов/сек. Как и на земле, попробуем прикинуть, как будет зависеть прирост скорости от угла u.

Для начала, найдём u, при котором DotProduct достигнет 30. Из cos(u) = 30 / 275 получаем u = 83.74 градуса. При u меньших скорость не меняется, а вот если u больше этого значения, мы будем получать приращение скорости. Поначалу оно будет небольшое, но с увеличением угла величина DotProduct достигнет значения 5 (это произойдет при u = arccos(5 / 275) = 88.96 градуса), addspeed станет равным 25, и дальнейшее увеличение u уже не повлияет на длину прибавляемого вектора - accelspeed будет всё время равным 25. Мало того, это положение является максимумом в плане прибавляемой скорости - чем больше мы продолжаем увеличивать угол, тем меньше становится вектор результирующей скорости в силу векторного сложения. Когда угол u превышает 90 градусов, DotProduct оказывается отрицательным, addspeed гарантированно больше accelspeed, и мы всё ещё имеем прибавку к скорости. Сложение векторов при этом выглядит следующим образом (схематично):


При угле 92.61 градуса мы получим, что вектор скорости лишь повернётся, а по величине останется прежним. При u больших 92.61 градуса мы будем просто-напросто терять скорость, причём чем дальше, тем всё больше.

Итого возможны три случая:

  1. u <= 83.74
    Никакого изменения скорости не происходит (даже по направлению). Если в воздухе повернёте мышь влево, нажмёте D и продолжите вести мышь влево, ваша скорость никак не изменится. Если попробуете делать стрейфы, зажав W, ваша скорость никак не изменится.

  2. 83.74 < u < 92.61
    Ваша скорость меняет направление в сторону клавиши стрейфа и растёт по величине, причём максимум прироста происходит при угле 88.96 градуса. Полученный прирост вы видите в столбике Gain в lj статистике.

  3. u >= 92.61
    Скорость меняет направление и уменьшается (или при u = 92.61 остаётся неизменной) по величине . Именно эти потери скорости показывает lj статистика в столбике Loss.

Получается, что для набора скорости угол u должен попасть в довольно узкий зазор. Если вы наберёте к концу полёта скорость 340 юнитов/сек (очень приличный результат для lj), то зазор уменьшится до 84.94 < u < 92.11, причём максимум прироста придётся на u = 89.16. Кроме того, сам прирост станет меньше – если в начале прыжка за один фрейм можно набрать более 1.5 юнитов/сек, то под конец прирост будет не больше 1.3 юнитов/сек. Таким образом, чем больше скорость, тем сложнее её набирать.

Зададимся целью поддерживать угол u, при котором прирост скорости будет максимальным (назовём такой угол оптимальным). Попытаемся понять, с какой угловой скоростью нужно вести мышь. Пусть x – это угол, на который повернётся вектор скорости за один фрейм, а V – текущая скорость. Из теоремы синусов получаем

sin(x) / 25 = sin(180 - x - (180 - u)) / V
V * sin(x) = 25 * (sin(u) * cos(x) - cos(u) * sin(x))
tg(x) = sin(u) / (V / 25 + cos(u))


При V = 275, u = 88.96 находим, что x = 5.186 градуса, а при V = 340, u = 89.16 получаем x = 5.035 градуса.

Значит, если бы мы могли поддерживать оптимальный u, то стрейфы по мере увеличения скорости следовало бы делать более плавными. Однако, как видим, этот эффект настолько мал, что вспомнить о нём имеет смысл лишь на действительно больших скоростях. При обычном же lj важнее понимать, как происходит переход от престрейфа на земле к стрейфам в воздухе, а также что происходит при переключении с одного стрейфа на другой.

Об этих вещах мы поговорим в следующей статье, посвящённой физике lj. А пока что давайте, как и для движения по земле, построим график зависимости прироста скорости от угла u и от величины текущей скорости при полёте в воздухе:


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

На этом пока всё, продолжим разбираться с lj в следующей статье.