![](https://habrastorage.org/files/96a/166/968/96a16696860d4ac7a72452c672c5add2.png)
Виртуальная реальность стремительно набирает популярность среди пользователей, но все еще остается недоступной для многих разработчиков. Причина банальная — многие пишут игры в фреймворках, к которым нельзя прикрутить Cardboard SDK, а учиться работать в другом фреймворке нет возможности или просто лень. Так и с Libgdx, где несмотря на попытки скрестить ужа с ежом, все еще до сих пор нет возможности создавать VR игры и приложения. Пару месяцев назад я загорелся желанием создать собственную VR игрушку, а поскольку я хорошо знаком с Libgdx и давно с ним работаю, то у меня оставался только один путь: изучить все самому и реализовать свой собственный VR
Disclaimer
Несмотря на то, что Libgdx позиционируется как кроссплатформенный фреймворк, в данной статье приведен пример приложения, которое спроектировано только под Android. Причины перехода на платформо-зависимый код две:
1) Стандартный Gdx.input у Libgdx не дает возможности получить «сырые» данные с магнитометра (компаса) смартфона. В чем была проблема добавить 3 метода по аналогии с гироскопом и акселерометром я не в курсе, но именно это послужило причиной вывода всей работы с датчиками в android-модуль.
2) В вики написано, что Libgdx не поддерживает гироскоп на iOS, насколько эта информация актуальна в данный момент я не в курсе.
Датчики
Итак, у нас имеется смартфон, оборудованный тремя датчиками (в идеале). Нужно преобразовать и отфильтровать эти данные, чтобы получить кватернион для вращения камеры в OpenGL. Что такое кватернион, и чем он полезен хорошо описано здесь. Предлагаю для начала кратко рассмотреть каждый тип датчиков в отдельности, чтобы понять, с чем вообще мы имеем дело.
Гироскоп
Гироскоп – устройство, которое может реагировать на изменение углов ориентации тела, к которому оно прикреплено. Механические гироскопы очень давно и хорошо известны, используются они в основном в различных инерциальных системах для стабилизации курса и навигации.
![image](https://upload.wikimedia.org/wikipedia/commons/thumb/d/d5/Gyroscope_operation.gif/200px-Gyroscope_operation.gif)
В современных смарфтонах используются MEMS гироскопы, которые предоставляют угловые скорости вращения по трем осям в виде вектора
![](https://habrastorage.org/files/7f3/8fd/9d5/7f38fd9d5e914636849a91f747dc98d1.png)
![](https://habrastorage.org/files/582/707/c13/582707c13f8649cdb55168043092b1b2.png)
Для нас не важно, в каких единицах измерения приходят данные (радианы или градусы), важно лишь то, что они прямо пропорциональны угловым скоростям вращения устройства. Очевидно, что идеальный гироскоп в состоянии покоя должен выдавать нули:
![](https://habrastorage.org/files/30e/10a/b28/30e10ab28cbd4d78b18cb06e48501e07.png)
Акселерометр
Акселерометр – устройство, которое реагирует на ускорения тела, к которому прикреплено. Акселерометр смартфона выдает вектор ускорений по осям
![](https://habrastorage.org/files/cbf/9c2/a26/cbf9c2a26fd449a79bdd2953b7e9cac6.png)
![](https://habrastorage.org/files/991/750/e93/991750e93fbd487a8be1aeee9b8bac54.png)
В результате связка гироскоп + акселерометр позволяет нам уже создавать игры, тот же Cardboard SDK работает именно так. Но остается дрифт вокруг вертикальной оси, убрать который можно при помощи магнитометра. В Cardboard SDK магнитометр отдан на работу с магнитной кнопкой, поэтому во всех Cardboard играх всегда присутствует курсовой дрифт.
Магнитометр
Магнитометр – устройство, реагирующее на магнитные поля. В состоянии покоя при отсутствии электромагнитных и магнитных помех магнитометр смартфона выдает направление вектора магнитной индукции поля Земли
![](https://habrastorage.org/files/4c4/bb9/f49/4c4bb9f49fb74d438151297ce1077be5.png)
![](https://habrastorage.org/files/cca/968/da3/cca968da3ec147e9b9644a7af727a61a.png)
Эта невидимая опора в виде магнитного поля планеты позволяет нам устранить произвольное вращение вокруг вертикальной оси, тем самым полностью устранив весь дрифт. Стоит отметить, что магнитная коррекция дрифта работает не всегда и не везде так, как нам этого хочется. Во-первых, любые внешние малейшие поля от магнитов в чехле смартфона или в крышке VR шлема приведут к непредсказуемому результату. Во-вторых, напряженность магнитного поля разная в разных уголках планеты, как и направление вектора магнитной индукции. Это означает, что коррекция дрифта при помощи магнитометра не будет работать возле полюсов, поскольку там силовые линии магнитного поля практически перпендикулярны поверхности земли и не несут никакой полезной инфы относительно ориентации сторон света. Надеюсь, среди нас нет полярников?
Теория
Для получения кватерниона текущей ориентации телефона нам необходимо циклически получать информацию со всех датчиков и выполнять на ее основе операции над кватернионом, полученным в предыдущий момент времени. Пусть
![](https://habrastorage.org/files/a68/4eb/3e0/a684eb3e0fa444639ad845b153baaab1.png)
![](https://habrastorage.org/files/1bf/81f/35d/1bf81f35d4bc46aaaece7aab208b1cbe.png)
1. Интегрируем показания гироскопа
Как я уже говорил, гироскоп предоставляет вектор угловых скоростей. Чтобы получить из угловых скоростей угловые координаты, нам необходимо их проинтегрировать. Делается это следующим образом:
1.1. Объявим кватернион
![](https://habrastorage.org/files/7df/d74/de3/7dfd74de32bb4d169866a670cb7cf4ab.png)
![](https://habrastorage.org/files/a89/ba0/9b5/a89ba09b528c4590a7e10b924d7ee6f8.png)
где
![](https://habrastorage.org/files/b48/56b/0ed/b4856b0edc35477eb7fa44e3bf97abbb.png)
1.2. Обновим q при помощи полученного
![](https://habrastorage.org/files/7df/d74/de3/7dfd74de32bb4d169866a670cb7cf4ab.png)
![](https://habrastorage.org/files/772/05a/628/77205a6289f041f78a526fcf92953e73.png)
В результате описанных действий кватернион q уже можно использовать для вращения, однако из-за очень низкой точности смартфонного гироскопа он ужасно плывет по всем трем осям.
2. Выравниваем плоскость горизонта (Tilt Correction)
В этом нам поможет акселерометр. Вкратце, для этого нам нужно найти корректирующий кватернион и умножить его на полученный на предыдущем этапе. Корректирующий кватернион в свою очередь формируется при помощи вектора-оси вращения и угла поворота.
2.1. Берем вектор акселерометра как кватернион:
![](https://habrastorage.org/files/234/355/430/2343554309444371b64e892bd426515a.png)
2.2. Поворачиваем этот кватернион акселерометра нашим кватернионом гироскопа:
![](https://habrastorage.org/files/28b/b41/86d/28bb4186d0fb4a45b8c53f5171fcc9ea.png)
2.3. Берем нормализованную векторную часть кватерниона
![](https://habrastorage.org/files/2ea/0b2/716/2ea0b271604645049964a6a529d45693.png)
![](https://habrastorage.org/files/ab5/df9/615/ab5df9615f1e48b28c3d955498f1f9ea.png)
2.4. С помощью нее находим вектор, задающий ось вращения:
![](https://habrastorage.org/files/45f/fb8/282/45ffb82822604fad86c2a983ec84e906.png)
2.5. Теперь остается найти угол:
![](https://habrastorage.org/files/770/4fe/7ef/7704fe7ef93b438fbaf17deb1f685602.png)
2.6. И скорректировать кватернион от гироскопа:
![](https://habrastorage.org/files/666/4ec/a53/6664eca53a644f5b8c4dc20c029cc307.png)
![](https://habrastorage.org/files/823/8da/d15/8238dad15fa241d1bd59dbc5df185218.png)
Все, теперь q не будет переворачивать камеру вверх ногами, возможен лишь небольшой дрифт вокруг оси Y.
3. Убираем дрифт вокруг оси Y при помощи магнитометра (Yaw Correction)
Компас смартфона — довольно капризная вещь, его необходимо калибровать после каждой перезагрузки, поднесения к массивным железкам или магнитам. Потеря калибровки в случае VR приводит к непредсказуемому отклику камеры на вращение головы. В 99% случаев компас у среднестатистического пользователя не откалиброван, поэтому я настоятельно рекомендую держать фичу коррекции дрифта по-умолчанию выключенной, иначе можно нахватать негативных отзывов. Кроме того, неплохо было бы выводить предупреждение о необходимости калибровки при каждом запуске приложения с включенной коррекцией. Непосредственно саму калибровку берет на себя Android, для ее вызова необходимо несколько раз нарисовать смартфоном в воздухе цифру «8» или "?".
![](https://habrastorage.org/files/db5/a7f/22a/db5a7f22a5a14db390886b58f0ccf3e4.png)
Жаль, что Android не предоставляет никакого способа проверить статус калибровки компаса и выдать сообщение типа «всё, достаточно махать», здесь приходится полагаться на интеллектуальные способности самого пользователя. В принципе, можно заморочиться и считать взмахи акселерометром, но делать мы это, конечно, не будем. Перейдем лучше к алгоритму, который не сильно отличается от коррекции горизонта акселерометром:
3.1. Так же оформляем вектор компаса в виде кватерниона:
![](https://habrastorage.org/files/d99/023/e15/d99023e159d74b469e215b9013f545a7.png)
3.2. И поворачиваем:
![](https://habrastorage.org/files/d12/26a/f45/d1226af452774de697265bc363a77dea.png)
3.3. Осью вращения в данном случае является Y (0, 1, 0), поэтому нам нужен только угол:
![](https://habrastorage.org/files/eb6/fe1/afc/eb6fe1afc9294ce2aa1afd10b2ef06b4.png)
3.4. Корректируем:
![](https://habrastorage.org/files/32f/a5b/7fe/32fa5b7fe8c64e7fb03a51e3d69e1c5d.png)
![](https://habrastorage.org/files/f31/150/8eb/f311508ebe84402eb45ccd693119faa4.png)
![](https://habrastorage.org/files/823/8da/d15/8238dad15fa241d1bd59dbc5df185218.png)
Теперь дрифт будет полностью отсутствовать, если магнитометр нормально откалиброван, и пользователь географически не находится слишком близко к полюсам Земли. Стоит отметить, что мой способ несколько отличается от способа, применяемого в Oculus Rift. Там суть заключается в следующем: для последних нескольких итераций цикла запоминаются кватернион вращения и соответствующие ему показания магнитометра (создаются т.н. reference points); дальше смотрим: если показания магнитометра не меняются, а кватернион при этом «едет» — то вычисляется угол дрифта, и кватернион доворачивается на него в обратную сторону. Такой подход хорошо работает на Oculus, но неприменим на смартфонах из-за слишком малой точности их магнитометров. Я пробовал реализовать метод из статьи — на смартфонах он дергает камеру и толком не убирает дрифт при этом.
Реализация
Для начала создадим пустой android проект при помощи gdx-setup.jar.
![](https://habrastorage.org/files/e59/54b/b7a/e5954bb7a98044068aa75bab3c2dbd4c.png)
Типичный 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://habrastorage.org/files/9fc/77c/ffb/9fc77cffbf9e47e5b35c6856177ed8b6.png)
Добро пожаловать в новую реальность! Про работу со звукам я расскажу во второй части, а сегодня у меня все. Спасибо за внимание, если возникнут вопросы — я постараюсь на них ответить в комментариях.
Источники
Комментарии (12)
maaGames
23.12.2016 14:26Хах, только сейчас опознал в скриншоте комнаты игру про комарика.)
Ваша игра про комарика обошла моего «Red ninja» в игре месяца, сами знаете где. В следующий раз тоже VR выкачу. На вашем же движке.)))
SinuX
23.12.2016 14:46+1Мне не жалко) я пока решил отойти от VR, аудитория еще не слишком большая, и не совсем понятна дальнейшая судьба этой затеи)
maaGames
23.12.2016 14:54ИМХО, рынок VR сейчас пуст и с любой чушью можно «выстрелить».
Я вот с нетерпением жду интро из Халф Лайф для VR. Сам пока не осилю такое, хотя уже портированная игра выпускалась (надо код погуглить). Нужно только VR к ней прикрутить.Simplevolk
26.12.2016 11:16У меня лично на кардборде глаза устают и голова кружится… А потом и шея болит… Надо что-то комплексное. Возможно, какие-то специальные кресла, в которых будут полулежать пользователи.
maaGames
26.12.2016 14:17Если верить отзывам, то это в основном на дешёвые очки реакция, где мутные линзы и не удаётся межосевое расстояние на линзах регулировать. Т.е. те же проблемы, что и с неправильно подобранными очками для зрения.
Но я пока не купил VR очков, опыт только пассивных и активных 3D очков есть, могу сильно заблуждаться.)
Для себя сделал галочку, что сильно жмотиться не надо и картонные очки брать не буду.Simplevolk
26.12.2016 14:50я брал наши отечественные, но дешевые. Не кардбоард, пластиковые.
SinuX
26.12.2016 15:07Тут больше играют роль индивидуальные особенности. Мне после 2х часов игры в HL1 нормально, а кто-то роллер-демку на 2 минуты еле выдерживает
maaGames
В упомянутом «уже с ежом» есть дисторшен, у вас же просто две «плоские» картинки. Вы запускали пример в очках, комфортно смотреть или не хватает искажений?
Я сейчас как раз планирую делать VR игрушку и собирался как раз упомянутую библиотеку использовать. Именно поддержкой искажений привлекла.
SinuX
Я экспериментировал с разными способами коррекции дисторсии и пришел к выводу, что лучше не использовать их вообще. Дисторсия — больше особенность cardboard, в более-менее нормальных шлемах ее практически нет, так как телефон удален от линз на большее расстояние, чем в cardboard. Не стоит жертвовать FPS ради этого, мозг по мере игры сам по себе адаптируется даже к сильным искажениям.
maaGames
Т.е. cardboard отличается от пластмассовых очков не только картонностью корпуса, но и кучей других отличий? Я сперва хочу игру написатЬ, а потом уже тратиться на VR-очки и пока не вдавался в различия между ними. Был уверен, что разница только в качестве линз и удобстве ношения.
SinuX
Не совсем кучей, но отличия есть) Как уже говорил, в cardboard расстояние между телефоном и линзами намного меньше, поэтому в картонке стоят линзы с большим радиусом кривизны, из-за этого сильная дисторсия и размытие картинки по краям поля зрения. В пластиковых шлемах типа vr box линзы с меньшим радиусом, поэтому искажений практически нет, и поле зрения больше. Поэтому я не стал накладывать маску на итоговую картинку, она создает эффект туннельного зрения)
maaGames
Спасибо за ответ! Скорее всего сильно упростили мне разработку.)