VR-додаток з нуля на libgdx: частина 1


Віртуальна реальність стрімко набирає популярність серед користувачів, але все ще залишається недоступною для багатьох розробників. Причина банальна — багато пишуть гри в фреймворках, до яких не можна прикрутити Cardboard SDK, а вчитися працювати в іншому фреймворку немає можливості або просто лінь. Так і з Libgdx, де незважаючи на спроби схрестити вужа з їжаком, все ще досі немає можливості створювати VR ігри і програми. Пару місяців тому я загорівся бажанням створити власну VR іграшку, а оскільки я добре знайомий з Libgdx і давно з ним працюю, у мене залишався тільки один шлях: вивчити все самому і реалізувати свій власний VR велосипед движок в рамках Libgdx. Очі бояться — руки роблять, і через місяць нічних посиденьок гра була готова. Буквально через пару днів після публікації мені почали завалювати лічку проханнями поділитися кодом або хоча б пояснити, як воно працює. Я не жадібний, тому вирішив замутити пару статей з прикладами додатків, і в цій частині я розповім про те, як з показань датчиків смартфона отримати його орієнтацію (т. зв. head tracking), а так само виводити на екран стереопару.

Disclaimer
Незважаючи на те, що Libgdx позиціонується як багатоплатформовий фреймворк, у даній статті наведено приклад програми, яка спроектована тільки під Android. Причини переходу на платформо-залежний код дві:

1) Стандартний Gdx.input у Libgdx не дає можливості отримати «сирі» дані з магнітометра (компаса) смартфона. У чому була проблема додати 3 методу за аналогією з гіроскопом і акселерометром я не в курсі, але саме це послужило причиною виведення всієї роботи з датчиками в android-модуль.

2) У вікі написано, що Libgdx не підтримує гіроскоп на iOS, наскільки ця інформація актуальна в даний момент я не в курсі.




Датчики
Отже, у нас є смартфон, обладнаний трьома датчиками (в ідеалі). Потрібно перетворити і відфільтрувати ці дані, щоб отримати кватернион для обертання камери в OpenGL. Що таке кватернион, і чим він корисний добре описано тут. Пропоную для початку коротко розглянути кожний тип датчиків окремо, щоб зрозуміти, з чим взагалі ми маємо справу.

Гіроскоп
Гіроскоп – пристрій, який може реагувати на зміну кутів орієнтації тіла, до якого він прикріплений. Механічні гіроскопи дуже давно і добре відомі, використовуються вони в основному в різних інерційних системах для стабілізації курсу і навігації.
image
В сучасних смарфтони використовуються 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 файлу:

  1. VRSensorManager — інтерфейс сенсорного менеджера
  2. VRSensorManagerAndroid — його реалізація
  3. VRCamera — простенька камера для відтворення
І внести зміни в 2 файлу проекту:

  1. AndroidLauncher — стартер-клас android проекту
  2. GdxVR — головний клас програми
Ісходник проекту я залив в репозиторій на гітхабі, код я постарався максимально задокументувати, тому в рамках статті поясню лише основні моменти.

VRSensorManager
Всю роботу з датчиками і обчислення кватерниона я вивів в модуль android, для отримання кватерниона в модулі core використовуємо даний інтерфейс.

VRSensorManager.java
package com.sinuxvr.sample;
import com.badlogic.gdx.math.Кватерніонів;
/** Інтерфейс для взаємодії з платформо-залежним кодом */
interface VRSensorManager {
/** Перевірка наявності гіроскопа */
boolean isGyroAvailable();
/** Перевірка наявності магнітометра */
boolean isMagAvailable();
/** Реєстрація листенеров */
void startTracking();
/** Відключення листенеров */
void endTracking();
/** Включення-виключення корекції дріфту на льоту
* @param use - true - включено, false - вимкнено */
void useDriftCorrection(boolean use);
/** Отримання обчисленого кватерниона орієнтації голови
* @return кватернион для обертання камери */
Кватерніонів getHeadQuaternion();
}


Всі методи тут інтуїтивно зрозумілі, думаю ні в кого не виникло питань. Методи isGyroAvailable і isMagAvailable в прикладі ніде не задіяні, але вони можуть комусь стати в нагоді, у своїй грі я їх використовую.

VRSensorManagerAndroid
Теоретично в модулі android можна лише отримувати значення з датчиків, а кватернион за ним обчислювати вже core. Я вирішив об'єднати в одному місці, щоб код було простіше перенести під інші фреймворки.

VRSensorManagerAndroid.java
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.Кватерніонів;
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 Кватерніонів gyroQuaternion;
private final Кватерніонів deltaQuaternion;
private final Vector3 accInVector;
private final Vector3 accInVectorTilt;
private final Vector3 magInVector;
private final Кватерніонів 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 Кватерніонів(0, 0, 0, 1);
deltaQuaternion = new Кватерніонів(0, 0, 0, 1);
accInVector = new Vector3(0, 10, 0);
accInVectorTilt = new Vector3(0, 0, 0);
magInVector = new Vector3(1, 0, 0);
headQuaternion = new Кватерніонів(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 Кватерніонів getHeadQuaternion() {
// Обираємо послідовність дій залежно від режиму управління
switch (vrControlMode) {
// Управління одним акселерометром
case ACC_ONLY: updateAccData(0.1 f);
// Обертання за Yaw нахилами голови з боку в бік (як у всіх гонках)
headQuaternion.setFromAxisRad(0, 1, 0, -MathUtils.sin(accelerometerValues[0] / 200f)).mul(gyroQuaternion).nor();
gyroQuaternion.set(headQuaternion);
break;

// Акселерометр + магнітометр (якщо в телефоні стоїть нормальний компас, то дана комбінація
// веде себе майже як гіроскоп, виходить така собі емуляція гіро)
case ACC_MAG: updateAccData(0.2 f);
if (!useDC) {
headQuaternion.setFromAxisRad(0, 1, 0, -MathUtils.sin(accelerometerValues[0] / 200f)).mul(gyroQuaternion).nor();
gyroQuaternion.set(headQuaternion);
} else updateMagData(1f, 0.05 f);
break;

// Гіроскоп + акселерометр
case ACC_GYRO: updateGyroData(0.1 f);
updateAccData(0.02 f);
break;

// Всі три датчика - must have, але тільки якщо компас відкалібрований
case ACC_GYRO_MAG: float dQLen = updateGyroData(0.1 f);
updateAccData(0.02 f);
if (useDC) updateMagData(dQLen, 0.005 f);
}

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.5 f;
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.1 f) 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 методу у відповідності з теоретичною частиною:

  1. updateGyroData — інтегрування кутових швидкостей гіроскопа
  2. updateAccData — стабілізація горизонту акселерометром
  3. 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.

VRCamera.java
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.Кватерніонів;
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() {
Кватерніонів 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 z = headQuaternion.z * s;

// Обертання лівої камери
leftCam.view.іdt(); // Скидання матриці виду
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.іdt(); // Скидання матриці виду
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();
}

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

AndroidLauncher.java
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;
}

Код цілком:

GdxVR.java

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.4 f, 0.1 f, 30f);
vrCamera.setPosition(-1.7 f, 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.4 f, 0.1 f, 30f);
vrCamera.setPosition(-1.7 f, 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 окуляри і поринути у віртуальний світ, тільки що створений своїми руками:


Ласкаво просимо в нову реальність! Про роботу зі звуків я розповім у другій частині, а сьогодні у мене все. Спасибі за увагу, якщо виникнуть питання — я постараюся на них відповісти в коментарях.



Джерела
  1. Кватерніонів & IMU Sensor Fusion with Complementary Filtering
  2. Help! My Cockpit Is Drifting Away
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.