
Виртуальная реальность стремительно набирает популярность среди пользователей, но все еще остается недоступной для многих разработчиков. Причина банальная — многие пишут игры в фреймворках, к которым нельзя прикрутить Cardboard SDK, а учиться работать в другом фреймворке нет возможности или просто лень. Так и с Libgdx, где несмотря на попытки скрестить ужа с ежом, все еще до сих пор нет возможности создавать VR игры и приложения. Пару месяцев назад я загорелся желанием создать собственную VR игрушку, а поскольку я хорошо знаком с Libgdx и давно с ним работаю, то у меня оставался только один путь: изучить все самому и реализовать свой собственный VR велосипед движок в рамках Libgdx. Глаза боятся — руки делают, и через месяц ночных посиделок игра была готова. Буквально через пару дней после публикации мне начали заваливать личку просьбами поделиться кодом или хотя бы объяснить, как оно работает. Я не жадный, поэтому решил замутить пару статей с примерами приложений, и в этой части я расскажу о том, как из показаний датчиков смартфона получить его ориентацию (т.н. head tracking), а так же выводить на экран стереопару.
Disclaimer
Несмотря на то, что Libgdx позиционируется как кроссплатформенный фреймворк, в данной статье приведен пример приложения, которое спроектировано только под Android. Причины перехода на платформо-зависимый код две:
1) Стандартный Gdx.input у Libgdx не дает возможности получить «сырые» данные с магнитометра (компаса) смартфона. В чем была проблема добавить 3 метода по аналогии с гироскопом и акселерометром я не в курсе, но именно это послужило причиной вывода всей работы с датчиками в android-модуль.
2) В вики написано, что Libgdx не поддерживает гироскоп на iOS, насколько эта информация актуальна в данный момент я не в курсе.
Датчики
Итак, у нас имеется смартфон, оборудованный тремя датчиками (в идеале). Нужно преобразовать и отфильтровать эти данные, чтобы получить кватернион для вращения камеры в OpenGL. Что такое кватернион, и чем он полезен хорошо описано здесь. Предлагаю для начала кратко рассмотреть каждый тип датчиков в отдельности, чтобы понять, с чем вообще мы имеем дело.
Гироскоп
Гироскоп – устройство, которое может реагировать на изменение углов ориентации тела, к которому оно прикреплено. Механические гироскопы очень давно и хорошо известны, используются они в основном в различных инерциальных системах для стабилизации курса и навигации.
В современных смарфтонах используются MEMS гироскопы, которые предоставляют угловые скорости вращения по трем осям в виде вектора
.

Для нас не важно, в каких единицах измерения приходят данные (радианы или градусы), важно лишь то, что они прямо пропорциональны угловым скоростям вращения устройства. Очевидно, что идеальный гироскоп в состоянии покоя должен выдавать нули:
, но в случае с MEMS гироскопом это не так. Вообще, MEMS гироскопы – самые дешевые и неточные из всех существующих, в состоянии покоя у них наблюдается сильный дрейф нуля. При интегрировании этих скачущих около нуля угловых скоростей в углы ориентации ошибка начинает накапливаться, в результате это приводит к так называемому дрифту гироскопа, который хорошо знаком многим любителям поиграть в VR игрушки. Для уменьшения дрейфа нуля применяют специальные фильтры сигналов и пороговые значения угловых скоростей, но это не панацея, потому что во-первых, от этого сильно портится т.н. VR experience (появляется инерция картинки и рывки), а во-вторых, полностью искоренить дрейф все равно не удастся. В этом случае на помощь приходят другие два датчика смартфона, с их помощью можно практически полностью устранить дрифт, сохранив при этом VR experience.
Акселерометр
Акселерометр – устройство, которое реагирует на ускорения тела, к которому прикреплено. Акселерометр смартфона выдает вектор ускорений по осям
, единица измерения чаще всего м/с, но для нас это так же не критично. В состоянии покоя акселерометр выдает направление вектора гравитации, эту особенность мы можем задействовать для стабилизации горизонта (Tilt correction). У акселерометра тоже есть недостатки. Если гироскоп шумит в основном в состоянии покоя, то акселерометр наоборот больше врет в движении, поэтому к объединению данных с этих двух датчиков нужно подходить с умом. В различных ИНС для квадрокоптеров используется фильтр Калмана, но я считаю, что в случае VR можно обойтись обычным комплементарником, здесь и так есть чем нагрузить процессор смартфона.

В результате связка гироскоп + акселерометр позволяет нам уже создавать игры, тот же Cardboard SDK работает именно так. Но остается дрифт вокруг вертикальной оси, убрать который можно при помощи магнитометра. В Cardboard SDK магнитометр отдан на работу с магнитной кнопкой, поэтому во всех Cardboard играх всегда присутствует курсовой дрифт.
Магнитометр
Магнитометр – устройство, реагирующее на магнитные поля. В состоянии покоя при отсутствии электромагнитных и магнитных помех магнитометр смартфона выдает направление вектора магнитной индукции поля Земли
, значения обычно в микротеслах (μT).

Эта невидимая опора в виде магнитного поля планеты позволяет нам устранить произвольное вращение вокруг вертикальной оси, тем самым полностью устранив весь дрифт. Стоит отметить, что магнитная коррекция дрифта работает не всегда и не везде так, как нам этого хочется. Во-первых, любые внешние малейшие поля от магнитов в чехле смартфона или в крышке VR шлема приведут к непредсказуемому результату. Во-вторых, напряженность магнитного поля разная в разных уголках планеты, как и направление вектора магнитной индукции. Это означает, что коррекция дрифта при помощи магнитометра не будет работать возле полюсов, поскольку там силовые линии магнитного поля практически перпендикулярны поверхности земли и не несут никакой полезной инфы относительно ориентации сторон света. Надеюсь, среди нас нет полярников?
Теория
Для получения кватерниона текущей ориентации телефона нам необходимо циклически получать информацию со всех датчиков и выполнять на ее основе операции над кватернионом, полученным в предыдущий момент времени. Пусть
— искомый кватернион ориентации, перед стартом цикла присвоим ему начальное значение
.
1. Интегрируем показания гироскопа
Как я уже говорил, гироскоп предоставляет вектор угловых скоростей. Чтобы получить из угловых скоростей угловые координаты, нам необходимо их проинтегрировать. Делается это следующим образом:
1.1. Объявим кватернион
и зададим его как:

где
— время, прошедшее с предыдущей итерации цикла;
1.2. Обновим q при помощи полученного
:
.
В результате описанных действий кватернион q уже можно использовать для вращения, однако из-за очень низкой точности смартфонного гироскопа он ужасно плывет по всем трем осям.
2. Выравниваем плоскость горизонта (Tilt Correction)
В этом нам поможет акселерометр. Вкратце, для этого нам нужно найти корректирующий кватернион и умножить его на полученный на предыдущем этапе. Корректирующий кватернион в свою очередь формируется при помощи вектора-оси вращения и угла поворота.
2.1. Берем вектор акселерометра как кватернион: 
2.2. Поворачиваем этот кватернион акселерометра нашим кватернионом гироскопа: 
2.3. Берем нормализованную векторную часть кватерниона
: 
2.4. С помощью нее находим вектор, задающий ось вращения: 
2.5. Теперь остается найти угол: 
2.6. И скорректировать кватернион от гироскопа:
, где
— коэффициент сглаживания, чем он меньше — тем плавнее и дольше будет стабилизироваться горизонт, оптимальное значение в большинстве случаев — 0.1.
Все, теперь q не будет переворачивать камеру вверх ногами, возможен лишь небольшой дрифт вокруг оси Y.
3. Убираем дрифт вокруг оси Y при помощи магнитометра (Yaw Correction)
Компас смартфона — довольно капризная вещь, его необходимо калибровать после каждой перезагрузки, поднесения к массивным железкам или магнитам. Потеря калибровки в случае VR приводит к непредсказуемому отклику камеры на вращение головы. В 99% случаев компас у среднестатистического пользователя не откалиброван, поэтому я настоятельно рекомендую держать фичу коррекции дрифта по-умолчанию выключенной, иначе можно нахватать негативных отзывов. Кроме того, неплохо было бы выводить предупреждение о необходимости калибровки при каждом запуске приложения с включенной коррекцией. Непосредственно саму калибровку берет на себя Android, для ее вызова необходимо несколько раз нарисовать смартфоном в воздухе цифру «8» или "∞".

Жаль, что Android не предоставляет никакого способа проверить статус калибровки компаса и выдать сообщение типа «всё, достаточно махать», здесь приходится полагаться на интеллектуальные способности самого пользователя. В принципе, можно заморочиться и считать взмахи акселерометром, но делать мы это, конечно, не будем. Перейдем лучше к алгоритму, который не сильно отличается от коррекции горизонта акселерометром:
3.1. Так же оформляем вектор компаса в виде кватерниона: 
3.2. И поворачиваем: 
3.3. Осью вращения в данном случае является Y (0, 1, 0), поэтому нам нужен только угол: 
3.4. Корректируем:
, где
— такой же коэффициент сглаживания, как
выше
Теперь дрифт будет полностью отсутствовать, если магнитометр нормально откалиброван, и пользователь географически не находится слишком близко к полюсам Земли. Стоит отметить, что мой способ несколько отличается от способа, применяемого в Oculus Rift. Там суть заключается в следующем: для последних нескольких итераций цикла запоминаются кватернион вращения и соответствующие ему показания магнитометра (создаются т.н. reference points); дальше смотрим: если показания магнитометра не меняются, а кватернион при этом «едет» — то вычисляется угол дрифта, и кватернион доворачивается на него в обратную сторону. Такой подход хорошо работает на Oculus, но неприменим на смартфонах из-за слишком малой точности их магнитометров. Я пробовал реализовать метод из статьи — на смартфонах он дергает камеру и толком не убирает дрифт при этом.
Реализация
Для начала создадим пустой android проект при помощи gdx-setup.jar.

Типичный android проект libgdx разделен на два модуля: android и core. В первом модуле находится платформо-зависимый код, а во втором обычно содержится логика игры и производится отрисовка. Взаимодействие между модулем core и android осуществляется через интерфейсы, исходя из этого нам понадобится создать 3 файла:
- VRSensorManager — интерфейс сенсорного менеджера
- VRSensorManagerAndroid — его реализация
- VRCamera — простенькая камера для отрисовки
И внести изменения в 2 файла проекта:
- AndroidLauncher — стартер-класс android проекта
- GdxVR — главный класс приложения
Исходник проекта я залил в репозиторий на гитхабе, код я постарался максимально задокументировать, поэтому в рамках статьи поясню лишь основные моменты.
VRSensorManager
Всю работу с датчиками и вычисление кватерниона я вывел в модуль android, для получения кватерниона в модуле core используем данный интерфейс.
package com.sinuxvr.sample; import com.badlogic.gdx.math.Quaternion; /** Интерфейс для взаимодействия с платформо-зависимым кодом */ interface VRSensorManager { /** Проверка наличия гироскопа */ boolean isGyroAvailable(); /** Проверка наличия магнитометра */ boolean isMagAvailable(); /** Регистрация листенеров */ void startTracking(); /** Отключение листенеров */ void endTracking(); /** Включение-выключение коррекции дрифта на лету * @param use - true - включено, false - отключено */ void useDriftCorrection(boolean use); /** Получение вычисленного кватерниона ориентации головы * @return кватернион для вращения камеры */ Quaternion getHeadQuaternion(); }
Все методы здесь интуитивно понятны, думаю ни у кого не возникло вопросов. Методы isGyroAvailable и isMagAvailable в примере нигде не задействованы, но они могут кому-нибудь пригодиться, в своей игре я их использую.
VRSensorManagerAndroid
Теоретически в модуле android можно лишь получать значения с датчиков, а кватернион по ним вычислять уже в core. Я решил все объединить в одном месте, чтобы код было проще портировать под другие фреймворки.
package com.sinuxvr.sample; import android.content.Context; import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorEventListener; import android.hardware.SensorManager; import com.badlogic.gdx.Gdx; import com.badlogic.gdx.math.MathUtils; import com.badlogic.gdx.math.Quaternion; import com.badlogic.gdx.math.Vector2; import com.badlogic.gdx.math.Vector3; /** Реализация листенера датчиков под Android. Вычисляет и предоставляет готовый кватернион * ориентации устройства в пространстве для камеры в зависимости от имеющихся датчиков в телефоне. * Поддерживаемые варианты: акселерометр, акселерометр + магнитометр, гироскоп + акселерометр, * гироскоп + акселерометр + магнитометр */ class VRSensorManagerAndroid implements VRSensorManager { /** Перечень режимов работы в зависимости от наличия датчиков */ private enum VRControlMode { ACC_ONLY, ACC_GYRO, ACC_MAG, ACC_GYRO_MAG } private SensorManager sensorManager; // Сенсорный менеджер private SensorEventListener accelerometerListener; // Листенер акселерометра private SensorEventListener gyroscopeListener; // Листенер гироскопа private SensorEventListener compassListener; // Листенер магнитометра private Context context; // Контекст приложения /** Массивы для получения данных */ private final float[] accelerometerValues = new float[3]; // Акселерометр private final float[] gyroscopeValues = new float[3]; // Гироскоп private final float[] magneticFieldValues = new float[3]; // Магнитометр private final boolean gyroAvailable; // Флаг наличия гироскопа private final boolean magAvailable; // Флаг наличия магнитометра private volatile boolean useDC; // Использовать ли магнитометр /** Кватернионы и векторы для нахождения ориентации, итоговый результат в headOrientation */ private final Quaternion gyroQuaternion; private final Quaternion deltaQuaternion; private final Vector3 accInVector; private final Vector3 accInVectorTilt; private final Vector3 magInVector; private final Quaternion headQuaternion; private VRControlMode vrControlMode; /** Конструктор */ VRSensorManagerAndroid(Context context) { this.context = context; // Получение сенсорного менеджера sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); // Проверка наличия датчиков (акселерометр есть всегда 100%, наверное) magAvailable = (sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) != null); gyroAvailable = (sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null); useDC = false; // Определение режима работы в зависимости от имеющихся датчиков vrControlMode = VRControlMode.ACC_ONLY; if (gyroAvailable) vrControlMode = VRControlMode.ACC_GYRO; if (magAvailable) vrControlMode = VRControlMode.ACC_MAG; if (gyroAvailable && magAvailable) vrControlMode = VRControlMode.ACC_GYRO_MAG; // Инициализация кватернионов gyroQuaternion = new Quaternion(0, 0, 0, 1); deltaQuaternion = new Quaternion(0, 0, 0, 1); accInVector = new Vector3(0, 10, 0); accInVectorTilt = new Vector3(0, 0, 0); magInVector = new Vector3(1, 0, 0); headQuaternion = new Quaternion(0, 0, 0, 1); // Регистрация датчиков startTracking(); } /** Возврат наличия гироскопа */ @Override public boolean isGyroAvailable() { return gyroAvailable; } /** Возврат наличия магнитометра */ @Override public boolean isMagAvailable() { return magAvailable; } /** Старт трекинга - регистрация листенеров */ @Override public void startTracking() { // Акселерометр инициализируется при любом раскладе sensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE); Sensor accelerometer = sensorManager.getSensorList(Sensor.TYPE_ACCELEROMETER).get(0); accelerometerListener = new SensorListener(this.accelerometerValues, this.magneticFieldValues, this.gyroscopeValues); sensorManager.registerListener(accelerometerListener, accelerometer, SensorManager.SENSOR_DELAY_GAME); // Магнитометр if (magAvailable) { sensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE); Sensor compass = sensorManager.getSensorList(Sensor.TYPE_MAGNETIC_FIELD).get(0); compassListener = new SensorListener(this.accelerometerValues, this.magneticFieldValues, this.gyroscopeValues); sensorManager.registerListener(compassListener, compass, SensorManager.SENSOR_DELAY_GAME); } // Гироскоп if (gyroAvailable) { sensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE); Sensor gyroscope = sensorManager.getSensorList(Sensor.TYPE_GYROSCOPE).get(0); gyroscopeListener = new SensorListener(this.gyroscopeValues, this.magneticFieldValues, this.gyroscopeValues); sensorManager.registerListener(gyroscopeListener, gyroscope, SensorManager.SENSOR_DELAY_GAME); } } /** Остановка трекинга - отключение листенеров */ @Override public void endTracking() { if (sensorManager != null) { if (accelerometerListener != null) { sensorManager.unregisterListener(accelerometerListener); accelerometerListener = null; } if (gyroscopeListener != null) { sensorManager.unregisterListener(gyroscopeListener); gyroscopeListener = null; } if (compassListener != null) { sensorManager.unregisterListener(compassListener); compassListener = null; } sensorManager = null; } } /** Включение-выключение использования магнитометра на лету */ @Override public void useDriftCorrection(boolean useDC) { // Реально листенер магнитометра не отключается, просто игнорируем его при вычислениях this.useDC = useDC; } /** Вычисление и возврат кватерниона ориентации */ @Override public synchronized Quaternion getHeadQuaternion() { // Выбираем последовательность действий в зависимости от режима управления switch (vrControlMode) { // Управление одним акселерометром case ACC_ONLY: updateAccData(0.1f); // Вращение по Yaw наклонами головы из стороны в сторону (как во всяких гонках) headQuaternion.setFromAxisRad(0, 1, 0, -MathUtils.sin(accelerometerValues[0] / 200f)).mul(gyroQuaternion).nor(); gyroQuaternion.set(headQuaternion); break; // Акселерометр + магнитометр (если в телефоне стоит вменяемый компас, то данная комбинация // ведет себя почти как гироскоп, получается этакая эмуляция гиро) case ACC_MAG: updateAccData(0.2f); if (!useDC) { headQuaternion.setFromAxisRad(0, 1, 0, -MathUtils.sin(accelerometerValues[0] / 200f)).mul(gyroQuaternion).nor(); gyroQuaternion.set(headQuaternion); } else updateMagData(1f, 0.05f); break; // Гироскоп + акселерометр case ACC_GYRO: updateGyroData(0.1f); updateAccData(0.02f); break; // Все три датчика - must have, но только если компас откалиброван case ACC_GYRO_MAG: float dQLen = updateGyroData(0.1f); updateAccData(0.02f); if (useDC) updateMagData(dQLen, 0.005f); } return headQuaternion; } /** Логика определения ориентации * Интегрирование показаний гироскопа в кватернион * @param driftThreshold - порог для отсечения дрифта покоя * @return - длина кватерниона deltaQuaternion */ private synchronized float updateGyroData(float driftThreshold) { float wX = gyroscopeValues[0]; float wY = gyroscopeValues[1]; float wZ = gyroscopeValues[2]; // Интегрирование показаний гироскопа float l = Vector3.len(wX, wY, wZ); float dtl2 = Gdx.graphics.getDeltaTime() * l * 0.5f; if (l > driftThreshold) { float sinVal = MathUtils.sin(dtl2) / l; deltaQuaternion.set(sinVal * wX, sinVal * wY, sinVal * wZ, MathUtils.cos(dtl2)); } else deltaQuaternion.set(0, 0, 0, 1); gyroQuaternion.mul(deltaQuaternion); return l; } /** Коррекция Tilt при помощи акселерометра * @param filterAlpha - коэффициент фильтрации */ private synchronized void updateAccData(float filterAlpha) { // Преобразование значений акселерометра в инерциальные координаты accInVector.set(accelerometerValues[0], accelerometerValues[1], accelerometerValues[2]); gyroQuaternion.transform(accInVector); accInVector.nor(); // Вычисление нормализованной оси вращения между accInVector и UP(0, 1, 0) float xzLen = 1f / Vector2.len(accInVector.x, accInVector.z); accInVectorTilt.set(-accInVector.z * xzLen, 0, accInVector.x * xzLen); // Вычисление угла между вектором accInVector и UP(0, 1, 0) float fi = (float)Math.acos(accInVector.y); // Получение Tilt-скорректированного кватерниона по данным акселерометра headQuaternion.setFromAxisRad(accInVectorTilt, filterAlpha * fi).mul(gyroQuaternion).nor(); gyroQuaternion.set(headQuaternion); } /** Коррекция угла по Yaw магнитометром * @param dQLen - длина кватерниона deltaQuaternion * @param filterAlpha - коэффициент фильтрации * Коррекция производится только в движении */ private synchronized void updateMagData(float dQLen, float filterAlpha) { // Проверка длины deltaQuaternion для коррекции только в движении if (dQLen < 0.1f) return; // Преобразование значений магнитометра в инерциальные координаты magInVector.set(magneticFieldValues[0], magneticFieldValues[1], magneticFieldValues[2]); gyroQuaternion.transform(magInVector); // Вычисление корректирующего Yaw угла с магнитометра float theta = MathUtils.atan2(magInVector.z, magInVector.x); // Коррекция ориентации headQuaternion.setFromAxisRad(0, 1, 0, filterAlpha * theta).mul(gyroQuaternion).nor(); gyroQuaternion.set(headQuaternion); } /** Своя имплементация класса сенсорного листенера (копипаст из AndroidInput) */ private class SensorListener implements SensorEventListener { final float[] accelerometerValues; final float[] magneticFieldValues; final float[] gyroscopeValues; SensorListener (float[] accelerometerValues, float[] magneticFieldValues, float[] gyroscopeValues) { this.accelerometerValues = accelerometerValues; this.magneticFieldValues = magneticFieldValues; this.gyroscopeValues = gyroscopeValues; } // Смена точности (нас не интересует) @Override public void onAccuracyChanged (Sensor arg0, int arg1) { } // Получение данных от датчиков @Override public synchronized void onSensorChanged (SensorEvent event) { if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) { accelerometerValues[0] = -event.values[1]; accelerometerValues[1] = event.values[0]; accelerometerValues[2] = event.values[2]; } if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) { magneticFieldValues[0] = -event.values[1]; magneticFieldValues[1] = event.values[0]; magneticFieldValues[2] = event.values[2]; } if (event.sensor.getType() == Sensor.TYPE_GYROSCOPE) { gyroscopeValues[0] = -event.values[1]; gyroscopeValues[1] = event.values[0]; gyroscopeValues[2] = event.values[2]; } } } }
Здесь, пожалуй, сделаю пару пояснений. Данные датчиков получаем с помощью обычных листенеров, на этот счет в интернете полно руководств. Работу с кватернионом я разбил на 3 метода в соответствии с теоретической частью:
- updateGyroData — интегрирование угловых скоростей гироскопа
- updateAccData — стабилизация горизонта акселерометром
- updateMagData — коррекция дрифта компасом
Если считать, что акселерометр в телефоне точно есть всегда, то остается всего 4 возможные комбинации датчиков, все они определены в перечислении VRControlMode:
private enum VRControlMode { ACC_ONLY, ACC_GYRO, ACC_MAG, ACC_GYRO_MAG }
Комбинация датчиков устройства определяется в конструкторе, затем при вызове метода getHeadQuaternion в зависимости от нее осуществляется формирование кватерниона по тому или иному пути. Прелесть такого подхода в том, что он позволяет комбинировать вызовы методов updateGyroData/updateAccData/updateMagData в зависимости от имеющихся датчиков и обеспечивать работоспособность приложения даже если в телефоне имеется один лишь акселерометр. Еще лучше, если кроме акселерометра в телефоне есть компас — тогда эта связка способна вести себя почти как гироскоп, позволяя вращать головой на 360°. Хоть ни о каком нормальном VR experience в данном случае не может быть и речи, все же это лучше, чем просто бездушная надпись «Your phone doesn’t have a gyroscope», не так ли? Еще интересен метод useDriftCorrection, он позволяет на лету включать/выключать использование магнитометра, не затрагивая листенеры (технически просто перестает вызываться updateMagData).
VRCamera
Для вывода изображения в виде стереопары нам нужны 2 камеры, разнесенные на некоторое расстояние друг от друга, называемое базой параллакса. Поэтому VRCamera содержит 2 экземпляра PerspectiveCamera. Вообще в этом классе осуществляется только работа с камерами (поворот кватернионом и перемещение), непосредственно отрисовку стереопары я разместил в главном классе GdxVR.
package com.sinuxvr.sample; import com.badlogic.gdx.Gdx; import com.badlogic.gdx.graphics.PerspectiveCamera; import com.badlogic.gdx.math.Matrix4; import com.badlogic.gdx.math.Quaternion; import com.badlogic.gdx.math.Vector3; /** Класс VR камеры * Данные об ориентации берутся из VRSensorManager при вызове update() */ class VRCamera { private PerspectiveCamera leftCam; // Левая камера private PerspectiveCamera rightCam; // Правая камера private Vector3 position; // Позиция VR камеры private float parallax; // Расстояние между камерами private Vector3 direction; // Вектор направления VR камеры private Vector3 up; // Вектор UP VR камеры private Vector3 upDirCross; // Векторное произведение up и direction (понадобится в части 2, сейчас не трогаем) /** Конструктор */ VRCamera(float fov, float parallax, float near, float far) { this.parallax = parallax; leftCam = new PerspectiveCamera(fov, Gdx.graphics.getWidth() / 2, Gdx.graphics.getHeight()); leftCam.near = near; leftCam.far = far; leftCam.update(); rightCam = new PerspectiveCamera(fov, Gdx.graphics.getWidth() / 2, Gdx.graphics.getHeight()); rightCam.near = near; rightCam.far = far; rightCam.update(); position = new Vector3(0, 0, 0); direction = new Vector3(0, 0, 1); up = new Vector3(0, 1, 0); upDirCross = new Vector3().set(direction).crs(up).nor(); } /** Обновление ориентации камеры */ void update() { Quaternion headQuaternion = GdxVR.vrSensorManager.getHeadQuaternion(); // Из-за обхода стандартного механизма вращения камеры необходимо вручную // получать векторы ее направления из кватерниона direction.set(0, 0, 1); headQuaternion.transform(direction); up.set(0, 1, 0); headQuaternion.transform(up); upDirCross.set(direction); upDirCross.crs(up).nor(); // Вычисление углов вращения камер из кватерниона float angle = 2 * (float)Math.acos(headQuaternion.w); float s = 1f / (float)Math.sqrt(1 - headQuaternion.w * headQuaternion.w); float vx = headQuaternion.x * s; float vy = headQuaternion.y * s; float vz = headQuaternion.z * s; // Вращение левой камеры leftCam.view.idt(); // Сброс матрицы вида leftCam.view.translate(parallax, 0, 0); // Перенос в начало координат + parallax по X leftCam.view.rotateRad(vx, vy, vz, -angle); // Поворот кватернионом leftCam.view.translate(-position.x, -position.y, -position.z); // Смещение в position leftCam.combined.set(leftCam.projection); Matrix4.mul(leftCam.combined.val, leftCam.view.val); // Вращение правой камеры rightCam.view.idt(); // Сброс матрицы вида rightCam.view.translate(-parallax, 0, 0); // Перенос в начало координат + parallax по X rightCam.view.rotateRad(vx, vy, vz, -angle); // Поворот кватернионом rightCam.view.translate(-position.x, -position.y, -position.z); // Смещение в position rightCam.combined.set(rightCam.projection); Matrix4.mul(rightCam.combined.val, rightCam.view.val); } /** Изменение местоположения камеры */ void setPosition(float x, float y, float z) { position.set(x, y, z); } /** Возврат левой камеры */ PerspectiveCamera getLeftCam() { return leftCam; } /** Возврат правой камеры */ PerspectiveCamera getRightCam() { return rightCam; } /** Возврат позиции, направления и вектора UP камеры, а так же их векторного произведения*/ public Vector3 getPosition() { return position; } public Vector3 getDirection() { return direction; } public Vector3 getUp() { return up; } public Vector3 getUpDirCross() { return upDirCross; } }
Самые интересные методы здесь — это конструктор и update. Конструктор принимает угол поля зрения (fov), расстояние между камерами (parallax), а так же расстояния до ближней и дальней плоскостей отсечения (near, far):
VRCamera(float fov, float parallax, float near, float far)
В методе update мы берем кватернион из VRSensorManager, перемещаем камеры в (±parallax, 0, 0), поворачиваем их, а затем перемещаем обратно в исходную позицию. При таком подходе между камерами всегда будет заданная база параллакса, и пользователь будет видеть стереоскопическую картинку при любой ориентации головы. Обратите внимание, что мы напрямую работаем с view матрицами камер, а значит векторы direction и up у камер не обновляются. Поэтому в VRCamera введены свои 2 вектора, и их значения вычисляются при помощи кватерниона.
AndroidLauncher
В стартер-классе при инициализации приложения необходимо создать экземпляр VRSensorManagerAndroid и передать главному классу игры (в моем случае GdxVR):
@Override protected void onCreate (Bundle savedInstanceState) { super.onCreate(savedInstanceState); AndroidApplicationConfiguration config = new AndroidApplicationConfiguration(); config.useWakelock = true; config.useAccelerometer = false; config.useGyroscope = false; config.useCompass = false; vrSensorManagerAndroid = new VRSensorManagerAndroid(this.getContext()); initialize(new GdxVR(vrSensorManagerAndroid), config); }
Также не забываем отключать/регистрировать листенеры при скрытии/разворачивании приложения:
@Override public void onPause() { vrSensorManagerAndroid.endTracking(); super.onPause(); } @Override public void onResume() { super.onResume(); vrSensorManagerAndroid.startTracking(); }
Полный код стартер-класса:
package com.sinuxvr.sample; import android.os.Bundle; import com.badlogic.gdx.backends.android.AndroidApplication; import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration; public class AndroidLauncher extends AndroidApplication { private VRSensorManagerAndroid vrSensorManagerAndroid; // Менеджер датчиков /** Инициализация приложения */ @Override protected void onCreate (Bundle savedInstanceState) { super.onCreate(savedInstanceState); AndroidApplicationConfiguration config = new AndroidApplicationConfiguration(); // Запрет на отключение экрана и использование датчиков имплементацией libgdx config.useWakelock = true; config.useAccelerometer = false; config.useGyroscope = false; config.useCompass = false; config.numSamples = 2; // Создание своего листенера данных с датчиков (поэтому useAccelerometer и т.п. не нужны) vrSensorManagerAndroid = new VRSensorManagerAndroid(this.getContext()); initialize(new GdxVR(vrSensorManagerAndroid), config); } /** Обработка паузы приложения - отключение листенера датчиков */ @Override public void onPause() { vrSensorManagerAndroid.endTracking(); super.onPause(); } /** При возвращении - снова зарегистрировать листенеры датчиков */ @Override public void onResume() { super.onResume(); vrSensorManagerAndroid.startTracking(); } }
Не забудьте закинуть в папку assets файл модели room.g3db и текстуру texture.png, они нам пригодятся на следующем этапе. Скачать их вы можете отсюда. Подойдет любая другая модель какой-либо сцены, я решил особо не заморачиваться и взял готовую модель от уровня своей же игры, в ней хорошо ощущается эффект 3D из-за наличия множества мелких деталей.
GdxVR
Наконец, мы подошли к главному классу. Для начала нам нужно объявить в нем наш VRSensorManager и конструктор, принимающий ссылку на экземпляр этого класса от AndroidLauncher:
static VRSensorManager vrSensorManager; GdxVR(VRSensorManager vrSensorManager) { GdxVR.vrSensorManager = vrSensorManager; }
Код целиком:
package com.sinuxvr.sample; import com.badlogic.gdx.ApplicationAdapter; import com.badlogic.gdx.Gdx; import com.badlogic.gdx.assets.AssetManager; import com.badlogic.gdx.graphics.GL20; import com.badlogic.gdx.graphics.g3d.Model; import com.badlogic.gdx.graphics.g3d.ModelBatch; import com.badlogic.gdx.graphics.g3d.ModelInstance; /** Главный класс приложения, здесь производим инициализацию камеры, модели и выполняем отрисовку */ class GdxVR extends ApplicationAdapter { static VRSensorManager vrSensorManager; // Менеджер для получения данных с датчиков private int scrHeight, scrHalfWidth; // Для хранения размеров viewport private AssetManager assets; // Загрузчик ресурсов private ModelBatch modelBatch; // Пакетник для модели private ModelInstance roomInstance; // Экземпляр модели комнаты private VRCamera vrCamera; // VR камера /** Конструктор */ GdxVR(VRSensorManager vrSensorManager) { GdxVR.vrSensorManager = vrSensorManager; } /** Инициализация и загрузка ресурсов */ @Override public void create () { // Размеры экрана scrHalfWidth = Gdx.graphics.getWidth() / 2; scrHeight = Gdx.graphics.getHeight(); // Загрузка модели из файла modelBatch = new ModelBatch(); assets = new AssetManager(); assets.load("room.g3db", Model.class); assets.finishLoading(); Model roomModel = assets.get("room.g3db"); roomInstance = new ModelInstance(roomModel); // Создание камеры (fov, parallax, near, far) и установка позиции vrCamera = new VRCamera(90, 0.4f, 0.1f, 30f); vrCamera.setPosition(-1.7f, 3f, 3f); // Разрешаем коррекцию дрифта при помощи компаса vrSensorManager.useDriftCorrection(true); } /** Отрисовка стереопары осуществляется при помощи изменения viewport-а */ @Override public void render () { // Очистка экрана Gdx.gl.glClearColor(0f, 0f, 0f, 1f); Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT | GL20.GL_DEPTH_BUFFER_BIT); // Обновление параметров камеры vrCamera.update(); // Отрисовка сцены для левого глаза Gdx.gl.glViewport(0, 0, scrHalfWidth, scrHeight); modelBatch.begin(vrCamera.getLeftCam()); modelBatch.render(roomInstance); modelBatch.end(); // Отрисовка сцены для правого глаза Gdx.gl.glViewport(scrHalfWidth, 0, scrHalfWidth, scrHeight); modelBatch.begin(vrCamera.getRightCam()); modelBatch.render(roomInstance); modelBatch.end(); } /** Высвобождение ресурсов */ @Override public void dispose () { modelBatch.dispose(); assets.dispose(); } }
В методе create мы узнаем размеры экрана (ширина делится на 2, сами знаете зачем), грузим модель сцены, а затем создаем и позиционируем камеру:
vrCamera = new VRCamera(90, 0.4f, 0.1f, 30f); vrCamera.setPosition(-1.7f, 3f, 3f);
Еще в примере я включил коррекцию дрифта, если у кого-то после запуска возникают проблемы с камерой — ищите причину в калибровке компаса:
vrSensorManager.useDriftCorrection(true);
В методе render перед всеми отрисовками необходимо вызывать обновление камеры:
vrCamera.update();
Стереопара реализована при помощи стандартного viewport-а. Подгоняем viewport под левую половину экрана и рисуем картинку для левого глаза:
Gdx.gl.glViewport(0, 0, scrHalfWidth, scrHeight); modelBatch.begin(vrCamera.getLeftCam()); modelBatch.render(roomInstance); modelBatch.end();
Затем точно так же для правого:
Gdx.gl.glViewport(scrHalfWidth, 0, scrHalfWidth, scrHeight); modelBatch.begin(vrCamera.getRightCam()); modelBatch.render(roomInstance); modelBatch.end();
Заключение
Если все было сделано правильно, то вы сможете вставить смартфон в VR очки и погрузиться в виртуальный мир, только что созданный своими руками:

Добро пожаловать в новую реальность! Про работу со звукам я расскажу во второй части, а сегодня у меня все. Спасибо за внимание, если возникнут вопросы — я постараюсь на них ответить в комментариях.
Источники
ссылка на оригинал статьи https://habrahabr.ru/post/318278/
Добавить комментарий