Camera Shake или как передать ощущения игры

Доброго времени суток. Представлюсь, для тех, кто меня еще не знает. Меня зовут Дима. Я работаю C++ разработчиком уже более 5 лет. На данный момент работаю в крупной Gamedev-студии. Помимо работы увлекаюсь созданием образовательного контента для YouTube и Twitch каналов.

Эта статья является продолжением статьи Unity3D – Camera Shake. Как сделать подвижную камеру, чтобы улучшить отдачу от вашей игры.
Я принял во внимание сделанное замечание по поводу того, что лучше использовать тряску через вибрацию, а затем обнаружил, что первый вариант моего решения всё равно не является безопасным с точки зрения частых вызовов функций вибрации и "ударного вращения". Под частым я подразумеваю запуск тряски в то время, когда предыдущая тряска еще не закончилась. И на реальных проектах такое допущение будет неприемлимым.

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

public void ShakeRotateCamera(Vector2 direction, float angleDeg, float degVelocity) { //Тут я столкнулся с ошибкой, отклонение происходило в противоположном от ожидаемого направлении //Решилне разбираться и домножил вектор на -1 direction *= -1; //Задаём новую угловую скорость _degVelocity = degVelocity; //Делаем направляющий вектор единичной длинны //от длины входного праметра не должно зависеть отклонение direction = direction.normalized; //Домножаем вектор на тангенс угла direction *= Mathf.Tan(angleDeg * Mathf.Deg2Rad); //Вычисляем вектор направления взгляда Vector3 resDirection = ((Vector3)direction + transform.forward).normalized; //Вычисляем новое вращение для камеры _targetRotation = Quaternion.FromToRotation(transform.forward, resDirection); } //Каждый кадр шейкер двигает камеру private void Update() { //Существует ли таргет ротейшн //Если нет то двигаемся в сторону исходного вращения Quaternion target = _targetRotation == null ? _startRotation : _targetRotation.Value; //Если в данный момент мы в исходном вращении //То ничего не вращаем if (target == transform.localRotation) { return; } //Из угловой скорости получаем смещение, которое сделаем за данный кадр //И получаем коэффициент для функции Quaternion.Lerp float t = (Time.deltaTime * _degVelocity) / Quaternion.Angle(transform.localRotation, target); transform.localRotation = Quaternion.Lerp(transform.localRotation, target, t); //Если достигли таргет, то сбрасываем, и в следующем кадре будем вращаться в сторону исходного вращения if (transform.localRotation == _targetRotation) { _targetRotation = null; } }

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

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

public void ShakeRotateCamera(Vector2 direction, float angleDeg, float degVelocity) { if (_isShaking) { return; } ShakeRotateCameraInternal(direction, angleDeg, degVelocity); } private void ShakeRotateCameraInternal(Vector2 direction, float angleDeg, float degVelocity) { direction *= -1; _degVelocity = degVelocity; direction = direction.normalized; direction *= Mathf.Tan(angleDeg * Mathf.Deg2Rad); Vector3 resDirection = ((Vector3)direction + transform.forward).normalized; _targetRotation = Quaternion.FromToRotation(transform.forward, resDirection); }

Тряска камеры

Дабы не перегружать логику функции Update, сделаем тряску камеры через корутину:

//Публичный метода для запуска public void ShakeCamera(float duration, float maxAngle, float degVelocity) { //Проверка на первый запуск if (_shakeCoroutine != null) { //Останавливаем предыдущую вибрацию StopCoroutine(_shakeCoroutine); } //Сбрасываем флаг о том, что идёт тряска _isShaking = false; //Запускаем вибрацию _shakeCoroutine = StartCoroutine(VibrateCameraCor(duration, maxAngle, degVelocity)); } private IEnumerator VibrateCameraCor(float duration, float maxAngle, float degVelocity) { //Взводим флаг о тряске _isShaking = true; //Счётчик прошедшего времени float elapsed = 0f; //Время прошедшее с предыдущей итерации корутины float timePassed = Time.realtimeSinceStartup; //пока время не вышло while (elapsed < duration) { //Считаем время float currentTime = Time.realtimeSinceStartup; elapsed += currentTime - timePassed; timePassed = currentTime; //Запускаем ударное вращение в случайном направлении, со случайны углом от нуля до максимального, и угловой скоростью ShakeRotateCameraInternal(Random.insideUnitCircle, Random.Range(0, maxAngle), degVelocity); //ожидаем 50 милисекунд yield return new WaitForSeconds(0.05f); } _isShaking = false; }

Примеры

Приведу пару примеров хорошо подобранных параметров, дабы можно было данное решение красиво продемонстрировать в деле:

//Метод Update другого скрипта private void Update() { if (Input.GetMouseButtonDown(0)) { //на левую кнопку вызываем ударное вращение в случайном направлении Vector2 direction = Random.insideUnitCircle; _cameraShaker.ShakeRotateCamera(direction, 10f, 100f); } if (Input.GetMouseButtonDown(1)) { //Запуск тряски на 10 секунд _cameraShaker.ShakeCamera(10f, 10, 100); } if (Input.GetMouseButtonDown(2)) { //Пример параметров дабы передать ударное вращение //После падения персонажа с высоты _cameraShaker.ShakeRotateCamera((Vector2.down + Vector2.right * 0.2f).normalized, 10f, 100f); } }

Недостатки

Главным недостатком я считаю то, что вращение будет работать корректно только в диапазоне углов (0..90) ибо tg (90°) = tg (π/2) = +∞.

Код

Приведу полный код скрипта:

public class CameraShaker : MonoBehaviour { private Quaternion _startRotation; private Quaternion? _targetRotation; private float _degVelocity; private bool _isShaking; private Coroutine _shakeCoroutine; public void ShakeRotateCamera(Vector2 direction, float angleDeg, float degVelocity) { if (_isShaking) { return; } ShakeRotateCameraInternal(direction, angleDeg, degVelocity); } public void ShakeCamera(float duration, float maxAngle, float degVelocity) { if (_shakeCoroutine != null) { StopCoroutine(_shakeCoroutine); } _isShaking = false; _shakeCoroutine = StartCoroutine(VibrateCameraCor(duration, maxAngle, degVelocity)); } private void Start() { _startRotation = transform.localRotation; } private void Update() { Quaternion target = _targetRotation == null ? _startRotation : _targetRotation.Value; if (target == transform.localRotation) { return; } float t = (Time.deltaTime * _degVelocity) / Quaternion.Angle(transform.localRotation, target); transform.localRotation = Quaternion.Lerp(transform.localRotation, target, t); if (transform.localRotation == _targetRotation) { _targetRotation = null; } } private IEnumerator VibrateCameraCor(float duration, float maxAngle, float degVelocity) { _isShaking = true; float elapsed = 0f; float timePassed = Time.realtimeSinceStartup; while (elapsed < duration) { float currentTime = Time.realtimeSinceStartup; elapsed += currentTime - timePassed; timePassed = currentTime; ShakeRotateCameraInternal(Random.insideUnitCircle, Random.Range(0, maxAngle), degVelocity); yield return new WaitForSeconds(0.05f); } _isShaking = false; } private void ShakeRotateCameraInternal(Vector2 direction, float angleDeg, float degVelocity) { direction *= -1; _degVelocity = degVelocity; direction = direction.normalized; direction *= Mathf.Tan(angleDeg * Mathf.Deg2Rad); Vector3 resDirection = ((Vector3)direction + transform.forward).normalized; _targetRotation = Quaternion.FromToRotation(transform.forward, resDirection); } }

Ссылки

Для полного ознакомления с кодом и наглядной демонстрацией рекомендую ознакомиться с видео на Youtube-канале:

Мы на других ресурсах:

3232
45 комментариев

Мне вот интересно, кто вам "разаработчикам" сказал, что тряска камеры передает ощущение для игрока? Меня всегда бесила и будет бесить тряска камеры, так же как и размытие. Кроме как морального раздражения эти вещи не могут передать при игре.

11
Ответить

Не пойму, к чему тут эти кавычки. А сказали мне об этом тенденции развития индустрии. Любой инструмент, если его использовать чересчур активно, будет раздражать. Я же не говорю о вибрации постоянной, либо с большим разбросом углов, что сделает картинку дёрганной. Как вы будете передавать игроку отдачу оружия или ощущение падения? В видео я привожу в пример геймплей CS 1.6, где данные механизмы используются в меру и к месту. Вы считаете игра была бы лучше без них?

9
Ответить

Комментарий недоступен

1
Ответить

Комментарий недоступен

Ответить

Quaternion*Вьетнамские флешбеки

2
Ответить

Хорошее видео про тряску камеры с GDC.

2
Ответить

Какая причина может быть в вылетах игры? Только не подумай, что я прошу про какую-то конкретную игру. Я пишу рассказ ,в котором инди-разработчик "допиливает" свою игру, исправляя баги. Рассказ не о играх, не о разработке игр, так что мне не нужны подробности. Меня вполне удовлетворил бы ответ по типу: "Игра влетает потому что буфер обмена переполнен спрайтами взрыва". То есть максимально простой ответ в принципе о любой игре. 
Если любишь конкретику, то по каким причинам вылетали на рабочий стол твои игры? Опиши простым языком?

Ответить