
Геймплей моей игры Star Versus основан на поворотах. Объекты отслеживают направление, в котором они смотрят, и на каждом цикле движка двигаются в этом направлении. В такой ситуации требуется много тригонометрии, бо?льшую часть которой можно ради эффективности вычислить заранее, однако иногда это невозможно. В частности, паре геймплейных элементов нужно находить арктангенсы, и делать это быстро.

Определение арктангенса: в прямоугольном треугольнике arctan вычисляет один из непрямых углов, используя в качестве входных данных длину стороны, противоположной этому углу, разделённую на длину прилежащей стороны. В случае Star Versus сторонами треугольника являются расстояния X/Y между двумя объектами, например, снарядом и кораблём, а угол — это направление, в котором должен двигаться первый, чтобы достичь второго.


Поначалу арктангенс нужен был для того, чтобы понять, когда один корабль ударяет по другому своим мечом. Мечи не имеют собственной информации о коллизиях, из-за необходимости анимаций они существуют исключительно как артефакты рендеринга. Поэтому они используют код, распознающий коллизии между двумя кораблями с увеличенным хитбоксом для учёта радиуса удара меча. Это позволяет движку экономить драгоценное время процессора, вычисляя дельты X/Y всего один раз. Однако для этого нужно гарантировать, что размахивающий мечом смотрит в нужном направлении, а не совершает удар в сторону от цели, и здесь нам пригождается арктангенс.
Реализация
Важным упрощением в Star Versus является то, что направление не непрерывно, а дискретно. Объекты могут двигаться только в 24 возможных направлениях. Перемещение вправо соответствует 0, вверх — 6, влево — 12, а вниз — 18. Каждый инкремент направления представляет собой угол в ? / 24 радиан. (? — это фундаментальная константа окружности, равная 6.2831853…)
После выполнения кода распознавания коллизий у нас уже есть дельты позиций X/Y для функции arctan. Нам достаточно одних знаков, чтобы понять, в каком из четырёх квадрантов находится результат, поэтому остальная часть кода должна выяснить при помощи абсолютных значений дельт, какое из 6 направлений квадранта является правильным.
Процессор 6502 слишком медленный, чтобы вычислять arctan при помощи стандартных способов, например рядов Тейлора, но поскольку результат должен быть правильным в рамках ? / 24, мы можем сжульничать и использовать аппроксимацию. В целом план заключался в том, чтобы сначала найти соотношение X/Y, затем представить набор линий, разделяющих пространство в соответствии с возможными направлениями, затем найти наклон этих линий, и сравнить соотношение, чтобы понять, к какому из направлений угол ближе всего.

Нам нужно быть внимательными к тем углам, которые равномерно делят пространство на области, окружающие направления, которые должен возвращать arctan. Это ?/48, 3?/48, 5?/48, … 11?/48. Тангенс каждого из них равен:
tan( 1 * τ / 48) = 0.131652497587
tan( 3 * τ / 48) = 0.414213562373
tan( 5 * τ / 48) = 0.767326987979
tan( 7 * τ / 48) = 1.30322537284
tan( 9 * τ / 48) = 2.41421356237
tan(11 * τ / 48) = 7.59575411273
Поскольку у процессора 6502 нет команд умножения и деления, дробные значения нежелательны. Однако у него есть битовые сдвиги, с помощью которых можно малозатратно делить или умножать на 2. К счастью, три значения тангенсов выше диагонали довольно близки к 1.25, 2.5 и 7.5, а эти значения довольно легко найти при помощи битовых сдвигов [1]. Другие углы ниже диагонали просто являются их отражениями, поэтому мы можем найти их, поменяв местами X и Y.


Сравнивая соотношение X/Y с этими значениями, мы получим номер области от 0 и 3. Будет ли эта область находиться над диагональю, зависит от того, поменяли ли мы местами X и Y. Вот псевдокод алгоритма:
small, large = x, y
if y < x:
small, large = y,x
half = small / 2
// compare to 2.5 slope
if small * 2 + half > large:
// compare to 1.25
quarter = half / 2
if small + quarter > large:
region = 1
else:
region = 0
else:
// compare to 7.5
if small * 8 - half > large:
region = 3
else:
region = 2
// Use region, whether X/Y were swapped, and quadrant in a lookup table.
Полный код на ассемблере выложен здесь вместе с юнит-тестами, использующими nes_unit_testing.
Применение
После реализации arctan он применяется во всех аспектах игры. Кроме коллизий мечей нужно также рассчитывать движение бомб, которые должны попадать в свои цели. Для этого они периодически проверяют направление в сторону движения к другому кораблю, и изменяя его, если отклонились от курса [2]. Почти так же arctan используется при стрейфе по кругу, поворачивая корабль в направлении другого корабля, пока он движется вбок, создавая круговое движение. Хотя реализация arctan неидеальна, на практике погрешность достаточно мала, поэтому самонаведение бомб и круговой стрейф работают хорошо.
При создании кампании на одного игрока arctan очень пригодился для создания вражеского ИИ. В базовом поведении врагов присутствует стрельба по игроку, и arctan определяет, как это нужно делать. У разных врагов есть различные паттерны разлёта пуль, но все они основаны на arctan.


Что касается конкретных врагов, то череп движется в направлении игрока, используя arctan для определения своего курса. Летучая мышь предпочитает оставаться на постоянном расстоянии до игрока, двигаясь по кругу вокруг него. Это реализовано получением arctan и перемещением на четверть поворота от него. Направление четверти поворота определяется случайным образом и периодически переворачивается, что создаёт хорошую симуляцию полёта летучей мыши.
В процессе разработки игры я понял, что создание многократно используемых вспомогательных функций необходимо не только для снижения сложности, но и может служить как источник вдохновения для придумывания новых возможностей. Функция, созданная для решения одной проблемы, может повлиять на дальнейшее развитие всего проекта, если она хорошо соответствует области решаемой задачи.
Примечания
[1] Умножение меньшего числа вместо деления большего имеет и ещё одно полезное преимущество — оно позволяет избежать числовых искажений, снижая вероятность ошибок.
[2] Самонаводящиеся снаряды не меняют своё направление в каждом кадре и не меняют его, если слишком отклонились от цели. В противном случае они были бы слишком точными, что отрицательно сказалось бы на игровом балансе.
aamonster
Кто-нибудь понял, зачем автору вообще понадобился арктангенс? Ведь чтобы узнать, направлены ли два вектора в одну сторону, достаточно посчитать векторное произведение x1y2–y1x2 (ну, чтобы увидеть, насколько именно отличается направление – понадобится его нормировать).
static_cast
Потому что ему нужно узнать численное значение угла между вектором и базисному к нему (не знаю зачем, скорее всего, экономит байты, это ведь NES все таки). Для использования формулы векторного произведения нужно
1) все равно реализовать формулу вычисления арк-фукнции (только арккосинуса),
2) вычислить модули, точнее, модуль вектора (второй уже известен, так как является проекцией на базисную ось). Если бы направления кораблей хранились в виде нормализованных векторов, то это можно было бы опустить, но это NES, байты и такты важны, и автор вынужден использовать только экранную системы координат.
Вычисление модуля — это два возведения в квадрат и квадратный корень, в дополнении к вычислению аркфункции.
aamonster
Не надо ему знать значение угла. Надо знать, угол больше или меньше заданного.
Итого, если вектора нормированные – векторное произведение и сравнить его с threshold. Всё.
Если вектора не нормированные – позаморочнее, но всё равно модуль считать не надо. Можно, к примеру, сравнить векторное произведение со скалярным, умноженным на заранее посчитанный коэффициент. Можно и иначе. Но гарантированно ничего сложнее умножения.
static_cast
> Не надо ему знать значение угла. Надо знать, угол больше или меньше заданного.
Из чего это следует? По статье, это универсальная функция, используемая несколько раз, в том числе, с конкретными вычисляемыми значениями.
> Итого, если вектора нормированные – векторное произведение и сравнить его с threshold.
Если бы все было так просто. Конечно, вектор не нормализован (это вообще расстояния между проекциями точек по осям).
> Можно, к примеру, сравнить векторное произведение со скалярным, умноженным на заранее посчитанный коэффициент.
А какой смысл будет это делать? Скаляр найти легко, но чтобы вычислить угол, все равно нужно нормализовать вектора и считать модули. И коэффициент ничего не даст.
aamonster
В последнем случае – не надо ничего нормализовывать. Размерность у скалярного и векторного произведения одинакова. Надо проверить |A?B| < a A•B.
В общем, меня удивляет, что автор вообще начинает думать с формул, использующих тригонометрию. Для меня это плохо с самого начала, я это буду делать в самом крайнем случае.
Я могу прийти к итоговой оптимизации с LUT для получения угла (или к одной из предложенных тут), но я даже думать не буду в терминах "посчитать арктангенс", это сразу зашквар.
static_cast
Скаляр — это модули, умноженные на косинус. Векторное произведение на плоскости — это модули, умноженные на синус. Приравнивая или решая неравенства с вектором и скаляром в разных частях неравенства, вы получите, внезапно, синус, деленный на косинус. Т.е., ровно тот же самый тангенс (ну или котангенс, естественно), от решения с которым пытаетесь уйти.
И зря. Я примерно представляю себе ход ваших мыслей, но все дело в ограничениях платформы, всякая обобщенная матрично-векторная математика на которой неприменима, от слова вообще.
aamonster
Я не получаю тангенс, так как не делю: я косвенно сравниваю его с threshold.
В общем, у меня рефлексы, выработанные ещё на 8080: я избегаю вычисления тригонометрических функций.
Повторюсь: использовать LUT – ok, если я увижу, что мне не нужно точное решение – конечно, я им воспользуюсь. Но обдумывать использование арктангенса там, где мне даже не нужно его значение?! Это всё равно что перспективную проекцию строить через вычисление угла зрения, или поворот через углы Эйлера.