Камера для авиасимулятора на Unity.

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

Лирическое отступление

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

Сам я летал на самолёте только в GTA, и заранее держал в голове, что камеру и управление буду перенимать оттуда. Однако для чистоты эксперимента решил попробовать представителей жанра авиасимов. Вот их перечень: ИЛ-2 Штурмовик, Project Wingman, X-Plane 12, Sky Rogue, Aviassembly.

Спектр оказался очень широким, и выбирать было из чего. Управление, в целом, было схожим (кроме сверххардкорного Project Wingman). В итоге за образец управления самолётом и камерой я всё таки взял полёты в GTA V.

Реализация

За основу я взял код из учебника Unity in Action. Знакомство с движком я начал с изучения этой книги, и у меня остались учебные примеры оттуда.

Управление камерой там совсем примитивное, но для начала сойдёт.

private void MoveCamera(float _x, float _y) { //Движение мышью по горизонтали - Это поворот вокруг вертикальной оси _rotationY += _x * sensitivity; //Движение мышью по вертикали - поворот вокруг поперечной горизонтальной оси _rotationX -= _y * sensitivity; //Поворот вокруг X ограничиваем, чтобы камера не крутилась вокруг объекта _rotationX = Mathf.Clamp(_rotationX, -clampAngle, clampAngle); //Получаем кватернион по углам эйлера. Z = 0 поскольку нам не нужно //наклонять камеру вокруг продольной оси (пока что) Quaternion mouseRotation = Quaternion.Euler(_rotationX, _rotationY, 0); //Применяем поворот камеры transform.rotation = mouseRotation; //Камеру двигаем в пространстве от цели на вектор отступа, который повернут в сторону камеры transform.position = target.position - transform.rotation * _offset; }
Камера летает вокруг объекта

Мы видим, что камера летает вокруг самолёта, но проходит сквозь текстуры. Это сильно портит впечатление от игры.

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

private void checkClips() { //Вектор от цели до камеры Vector3 lookToCameraVector = transform.position - target.transform.position; float raycastDistance = lookToCameraVector.magnitude; Vector3 raycastOrigin = target.position; //Объекты, которые относятся к слою PlayerAircraft - части самолёта, их в расчет не берем LayerMask obstacleMask = ~LayerMask.GetMask("PlayerAircraft"); Ray ray = new Ray(raycastOrigin, lookToCameraVector); RaycastHit hit; //Если лучем уперлись во что-то что не самолёт if (Physics.Raycast(ray, out hit, raycastDistance, obstacleMask)) { //То двигаем туда камеру transform.position = hit.point + hit.normal * 0.03f; } }
Камера учитывает препятствия перед объектом

Уже намного лучше. Но теперь появился ещё один недостаток: в глобальных координатах камера не двигается относительно объекта, если не шевелить мышью. Из-за этого во время манёвра она может оказаться против хода движения, и мы не увидим, куда летит самолёт. Нужно, чтобы камера выравнивалась по ходу движения.

Чтобы решить эту задачу, я завёл переменную inputTimer, которая каждый Update уменьшается на Time.deltaTime, но если игрок шевелит мышью — таймер восстанавливается до изначального значения.

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

private void MoveCamera(float _x, float _y) { //hasInput - был ввод от игрока или нет bool hasInput = Mathf.Abs(_x) > 0.01f || Mathf.Abs(_y) > 0.01f; if (hasInput || inputTimer > 0) { //Если был ввод от игрока //либо таймер ожидания камеры ещё тикает выполняем старый код //Таймер ожидания мы обновляем когда игрок шевелит мышью _rotationY += _x * sensitivity; _rotationX -= _y * sensitivity; _rotationX = Mathf.Clamp(_rotationX, -clampAngle, clampAngle); Quaternion mouseRotation = Quaternion.Euler(_rotationX, _rotationY, 0); transform.rotation = mouseRotation; transform.position = target.position - transform.rotation * _offset; } else if (targetRb.linearVelocity.magnitude > 10f) { //Если таймер ожидания истек, и скорость больше 10м/с //Берем вектор скорости rigidBody цели Vector3 cameraDir = targetRb.linearVelocity; //Вычисляем ориентацию направления в сторону скорости //с вертикальным положением - локальный верх цели Quaternion autoRot = Quaternion.LookRotation(cameraDir, target.up); //Применяем поворот через Slerp - функция интерполяции рассчитывает //поворот в направлении от transform.rotation к autoRot за время Time.deltaTime * mainRotationSpeed transform.rotation = Quaternion.Slerp(transform.rotation, autoRot, Time.deltaTime * mainRotationSpeed); //Позицию камеры отодвигаем как раньше transform.position = target.position - transform.rotation * _offset; } checkClips(); }
Камера выравнивается по вектору скорости

Стало намного приятнее летать! Теперь камера плавно следует за самолётом и в виражах повторяет его повороты. Но вскрылась ещё одна проблема. Если после автоматического следования камеры пошевелить мышью, она резко скачет.

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

Чтобы исправить это поведение, нужно рассчитывать текущие углы камеры в момент начала ввода. После этого камера будет вращаться корректно.

private void MoveCamera(float _x, float _y) { bool hasInput = Mathf.Abs(_x) > 0.01f || Mathf.Abs(_y) > 0.01f; if (hasInput || inputTimer > 0) { //добавили флаг isManualControl //Так мы можем ловить первое движение мышью после авто-следования //И запоминать текущее положение камеры if (!isManualControl) { //Если это первое движение мышью //Рассчитываем _rotationX и _rotationY //Так мы избежим скачка после того, как игрок пошевелит мышью в полёте Vector3 localDir = target.InverseTransformDirection(transform.forward); Vector3 localUp = target.InverseTransformDirection(transform.up); _rotationX = Mathf.Asin(localDir.y) * Mathf.Rad2Deg; _rotationY = Mathf.Atan2(localDir.x, localDir.z) * Mathf.Rad2Deg; //Ставим флаг ручного управления isManualControl = true; } _rotationY += _x * sensitivity; _rotationX -= _y * sensitivity; _rotationX = Mathf.Clamp(_rotationX, -clampAngle, clampAngle); Quaternion mouseRotation = Quaternion.Euler(_rotationX, _rotationY, 0); //directionRotation - вынесено в поле класса //для единообразия поворот мышью не присваивается напрямую, //А интерполируется через Slerp directionRotation = mouseRotation; } else if (targetRb.linearVelocity.magnitude > 10f) { //Здесь снимаем флаг ручного управления isManualControl = false; Vector3 cameraDir = targetRb.linearVelocity; Quaternion autoRot = Quaternion.LookRotation(cameraDir, target.up); directionRotation = Quaternion.Slerp(directionRotation, autoRot, Time.deltaTime * autoRotationSpeed * targetRb.linearVelocity.magnitude); } //Эти две строчки вынесены для единообразия transform.rotation = Quaternion.Slerp(transform.rotation, directionRotation, Time.deltaTime * mainRotationSpeed); transform.position = target.position - transform.rotation * _offset; checkClips(); }
Плавное перемещение камеры

Почти идеально. Осталось ещё кое-что: если самолёт кренится или уходит в пике, камера начинает вести себя странно. При вращении мышью продольная ориентация камеры перестаёт совпадать с самолётом.

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

private void MoveCamera(float _x, float _y) { bool hasInput = Mathf.Abs(_x) > 0.01f || Mathf.Abs(_y) > 0.01f; if (hasInput || inputTimer > 0) { if (!isManualControl) { Vector3 localDir = target.InverseTransformDirection(transform.forward); Vector3 localUp = target.InverseTransformDirection(transform.up); _rotationX = Mathf.Asin(localDir.y) * Mathf.Rad2Deg; _rotationY = Mathf.Atan2(localDir.x, localDir.z) * Mathf.Rad2Deg; isManualControl = true; } _rotationY += _x * sensitivity; _rotationX -= _y * sensitivity; _rotationX = Mathf.Clamp(_rotationX, -clampAngle, clampAngle); Quaternion mouseRotation = Quaternion.Euler(_rotationX, _rotationY, 0); //При ручном повороте мышью добавлена поправка //на положение цели в пространстве. //Умножая текущий кватернион поворота мыши на кватернион //положения цели в пространстве //Мы получаем поворот мыши пересчитанный в локальные координаты цели directionRotation = target.rotation * mouseRotation; } else if(targetRb.linearVelocity.magnitude > 10f) { isManualControl = false; Vector3 cameraDir = targetRb.linearVelocity; Quaternion autoRot = Quaternion.LookRotation(cameraDir, target.up); directionRotation = Quaternion.Slerp(directionRotation, autoRot, Time.deltaTime * autoRotationSpeed * targetRb.linearVelocity.magnitude); } transform.rotation = Quaternion.Slerp(transform.rotation, directionRotation, Time.deltaTime * mainRotationSpeed); transform.position = target.position - transform.rotation * _offset; checkClips(); }

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

Спасибо за внимание!

Если интересно, подписывайтесь на мой телеграм-канал. Там я регулярно делюсь прогрессом по своему проекту.

25
3
1
16 комментариев