Виртуальная реальность стремительно набирает популярность среди пользователей, но все еще остается недоступной для многих разработчиков. Причина банальная — многие пишут игры в фреймворках, к которым нельзя прикрутить 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 очки и погрузиться в виртуальный мир, только что созданный своими руками:
Добро пожаловать в новую реальность! Про работу со звукам я расскажу во второй части, а сегодня у меня все. Спасибо за внимание, если возникнут вопросы — я постараюсь на них ответить в комментариях.
Источники
Автор: SinuX