23 декабря 2016 в 10:25

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.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. Я решил все объединить в одном месте, чтобы код было проще портировать под другие фреймворки.

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.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 метода в соответствии с теоретической частью:

  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.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();
	}

Полный код стартер-класса:

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.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 очки и погрузиться в виртуальный мир, только что созданный своими руками:


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



Источники


  1. Quaternions & IMU Sensor Fusion with Complementary Filtering
  2. Help! My Cockpit Is Drifting Away
@SinuX
карма
4,0
рейтинг 0,0
Пользователь
Похожие публикации
Самое читаемое Разработка

Комментарии (13)

  • 0
    В упомянутом «уже с ежом» есть дисторшен, у вас же просто две «плоские» картинки. Вы запускали пример в очках, комфортно смотреть или не хватает искажений?
    Я сейчас как раз планирую делать VR игрушку и собирался как раз упомянутую библиотеку использовать. Именно поддержкой искажений привлекла.
    • 0

      Я экспериментировал с разными способами коррекции дисторсии и пришел к выводу, что лучше не использовать их вообще. Дисторсия — больше особенность cardboard, в более-менее нормальных шлемах ее практически нет, так как телефон удален от линз на большее расстояние, чем в cardboard. Не стоит жертвовать FPS ради этого, мозг по мере игры сам по себе адаптируется даже к сильным искажениям.

      • 0
        Т.е. cardboard отличается от пластмассовых очков не только картонностью корпуса, но и кучей других отличий? Я сперва хочу игру написатЬ, а потом уже тратиться на VR-очки и пока не вдавался в различия между ними. Был уверен, что разница только в качестве линз и удобстве ношения.
        • +1

          Не совсем кучей, но отличия есть) Как уже говорил, в cardboard расстояние между телефоном и линзами намного меньше, поэтому в картонке стоят линзы с большим радиусом кривизны, из-за этого сильная дисторсия и размытие картинки по краям поля зрения. В пластиковых шлемах типа vr box линзы с меньшим радиусом, поэтому искажений практически нет, и поле зрения больше. Поэтому я не стал накладывать маску на итоговую картинку, она создает эффект туннельного зрения)

          • 0
            Спасибо за ответ! Скорее всего сильно упростили мне разработку.)
  • 0
    Хах, только сейчас опознал в скриншоте комнаты игру про комарика.)
    Ваша игра про комарика обошла моего «Red ninja» в игре месяца, сами знаете где. В следующий раз тоже VR выкачу. На вашем же движке.)))
  • +1

    Мне не жалко) я пока решил отойти от VR, аудитория еще не слишком большая, и не совсем понятна дальнейшая судьба этой затеи)

    • 0
      ИМХО, рынок VR сейчас пуст и с любой чушью можно «выстрелить».

      Я вот с нетерпением жду интро из Халф Лайф для VR. Сам пока не осилю такое, хотя уже портированная игра выпускалась (надо код погуглить). Нужно только VR к ней прикрутить.
      • 0
        У меня лично на кардборде глаза устают и голова кружится… А потом и шея болит… Надо что-то комплексное. Возможно, какие-то специальные кресла, в которых будут полулежать пользователи.
        • 0
          Если верить отзывам, то это в основном на дешёвые очки реакция, где мутные линзы и не удаётся межосевое расстояние на линзах регулировать. Т.е. те же проблемы, что и с неправильно подобранными очками для зрения.
          Но я пока не купил VR очков, опыт только пассивных и активных 3D очков есть, могу сильно заблуждаться.)
          Для себя сделал галочку, что сильно жмотиться не надо и картонные очки брать не буду.
          • 0
            я брал наши отечественные, но дешевые. Не кардбоард, пластиковые.
            • 0
              Тут больше играют роль индивидуальные особенности. Мне после 2х часов игры в HL1 нормально, а кто-то роллер-демку на 2 минуты еле выдерживает
  • 0
    Карбордов сейчас вообще много появилось, везде клепают, о качестве особо не думают ибо копейки стоит, как по мне mrcardboard лучшие но цена завышена, кто какие ещё знает?

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.