Давным-давно, ... в общем появился у Sony PlayStation шлем VR. Штука оказалась интересная и позволяла не только играть в vr-игры, но и смотреть фильмы.

Правда, сразу выяснились некоторые "тонкости": нормальное использование возможно было только при использовании с Sony PlayStation (что, в общем-то, очевидно) и через специализированную программу Rad (бывший LittlStar). Причём особого разнообразия программ-проигрывателей не было, использование же программы Rad требовало оплаты подписки. Сначала всё было хорошо: и подписка платилась, и кино смотрелось. Потом появились санкции и, вдруг, оказалось, что заплатить из России нельзя. И вообще вы ничего не можете, "... until those restrictions and sanctions have been lifted ...".

Конечно, такое отношение не может радовать пользователей. С другой стороны, это является хорошим поводом постучать по клавиатуре и сделать немного импортозамещения.

Чтобы шлем работал, необходимо было отвязать функцию просмотра фильмов от специализированного / платного / не-российского программного обеспечения. И ниже описан процесс такого "отвязывания".

Эта статья по составу содержит примеры кода и решений, которые нужно добавить в проигрыватель VR, чтобы им можно было нормально пользоваться. Приведённые здесь сведения не являются какими-либо новыми или особенными знаниями. Всё, что здесь приведено вы можете самостоятельно найти в интернете или посчитать на калькуляторе.

Для мнеленьчитать результат здесь: https://github.com/evgenykislov/psvr_player.

Во-первых, определимся с тем, что нам нужно:

  • просмотр 3D фильмов: в режиме полусферы и в режиме плоского экрана;

  • использование программ с открытым исходным кодом. Желательно вообще бесплатных;

  • поддержка Linux;

  • функционал плейера (перемотки, паузы, стоп).

Что не требовалось:

  • вывод звука на наушники шлема.

Поиск по интернету показал, что есть пара жизнеспособных подходов:

  • использование Steam, протокола SteamVR и драйвера OpenPSVR;

  • отдельный проигрыватель с нужными функциями.

После короткого исследования первый вариант отвалился: использование драйверов под SteamVR в Linux-е это слегка нетривиально. Кроме того, привязываешься не только к Steam, но к проигрывателю из Steam (это уже плохо: сегодня он есть, завтра его нет или требует подписки).

Со вторым вариантом тоже всё не сахарно: много проигрывателей в стадии заготовок: умеем выводить картинку и иногда считывать сенсоры.

В результате выбор пал на проигрыватель psvr от Florian Märkl, который и был подвергнут доработке. Проигрыватель обладал рядом "плюшек":

  • реализован функционал проигрывания видеофайлов с использованием библиотеки vlc;

  • вывод изображения "на сферу" с использованием шейдеров;

  • отслеживание положения шлема и корректировка изображения;

  • проигрыватель заявлен как кросс-платформенный: Windows, Linux;

  • для использования шлема не требовалась сама Sony PlayStation;

  • код открыт и бесплатен (лицензия GPL3).

В общем, много чего уже было сделано. Хотя и осталось тоже достаточно:

  • запуск без прав root (актуально для Linux);

  • включение режима VR;

  • добавить клавиатурные комбинации и вывести дополнительную информацию;

  • добавить плоский вид: для просмотра "обычных" 3D фильмов;

  • реализовать компенсацию дисторсии;

  • реализовать компенсацию хроматической абберации.

Запуск без root прав

Шлем PS VR подключается к компьютеру (как на Windows, так и на Linux) посредством процессорного модуля через 2 разъёма: hdmi для передачи видеопотока и usb для получения данных с шлема и управления режимами.

При подключении шлема как usb устройства появляются несколько устройств. Устройства имеют идентификатор производителя (idVendor) 054с и идентификатор продукта (idProduct) 09af, находятся глубоко в дереве устройств и по умолчанию у них права только для root.

Для проигрывателя интересны интерфейсы с номерами:

  • 4 - используется для вычитывания данных сенсоров (используется для вычисления положения шлема: наклон / вращение);

  • 5 - используется для управления режимами шлема.

Использование прав root для запуска проигрывателя это довольно нехорошая идея. Правильнее выставить права на устройства. И для этого используется механизм udev. Механизм реализуется посредством правил, содержащихся в папке /etc/udev/rules.d. В нашем случае создаём правила для смены режима устройств при их создании:

Создаём (под sudo) файл /etc/udev/rules.d/99-psvr.rules с содержимым:

SUBSYSTEM=="usb",ATTRS{idVendor}=="054c",ATTRS{idProduct}=="09af",MODE="0666"

Здесь usb, idVendor и idProduct - тип устройств, идентификаторы производителя и продукта, для которых применяется правило: выставляется режим 0666 - позволяем всем читать и записывать в устройства.

Для обновления правил либо перезагружаем компьютер, либо используем пару команд (под sudo):

sudo udevadm control --reload-rules
sudo udevadm trigger

Включаем режим VR

Включение VR режима почему-то оказалось функцией, которая мало где реализована (я таких проигрывателей не нашёл :( ). Хотя сама функция не представляет больших сложностей. Управление режимом осуществляется через библиотеку hidapi.

Для включения / выключения режима VR необходимо найти устройство для управления. Находим все устройства с требуемым vendorid и productid и среди них выбираем имя устройства, заканчивающееся на ":05".

const unsigned short kPsvrVendorID = 0x054c;
const unsigned short kPsvrProductID = 0x09af;
auto devs = hid_enumerate(kPsvrVendorID, kPsvrProductID);
const char kPsvrControlInterface[4] = ":05";
std::string p = dev->path;
if (p.substr(p.length() - 3) == kPsvrControlInterface) {
...
}

Обратите внимание, что имена устройств меняются, даже если шлем остаётся подключенным к компьютеру: выключение или засыпание может привести к смене имени устройства. Поэтому сохранять их смысла нет, и каждый раз имя ищем заново.

По найденному имени открываем устройство и прописываем в него "правильный" пакет:

device_ = hid_open_path(dev_name.c_str());
buffer_[0] = 0x23;
buffer_[1] = 0x00;
buffer_[2] = 0xaa;
buffer_[3] = 0x04;
buffer_[4] = vrmode ? 0x01 : 0x00;
buffer_[5] = 0x00;
buffer_[6] = 0x00;
buffer_[7] = 0x00;
return hid_write((hid_device*)device_, buffer_, 8) != -1;

Буфер команды для шлема содержит 8 байт:
0x00 - 0x23 - команда управления. В данном случае управление режимом VR;
0x01 - 0x00, 0xaa - магические числа;
0x03 - 0x04 - длина данных команды;
0x04 - 0x00000001/0x00000000 - флаг включения или выключения режима VR.

Подробнее про команды можно почитать здесь: github gusmanb: там есть и код фреймворка для работы с PSVR, и раздел wiki, содержащий информацию по управлению.

Также для управления был создан класс PsvrControl здесь: github.

Добавляем клавиатурные комбинации и выводим дополнительную информацию

Так как просматривают фильмы в vr шлеме, то наличие клавиатурных комбинаций является очень-очень полезной штукой. Основное требование: возможность "нащупать" нужные клавиши вслепую.

В Qt обработка клавиатурных комбинаций делается стандартными способами:

Создаём класс наследник QObject и перегружаем функцию

virtual bool eventFilter(QObject*, QEvent* event) override;

В реализации создаём массив описаний клавишных комбинаций и по ним вызываем сигналы:

struct KeyEvent {
  int key;
  bool ctrl;
  bool shift;
  bool alt;
  std::function<void(KeyFilter*)> processor;
};
KeyEvent g_keys[] = {
{Qt::Key_Space, false, false, false, [](KeyFilter* kf){ emit kf->Pause(); }},
{Qt::Key_Space, true,  false, false, [](KeyFilter* kf){ emit kf->Stop(); }},
...
for (auto it = std::begin(g_keys); it != std::end(g_keys); ++it) {
if (it->key == key && it->ctrl == ctrl && it->shift == shift && it->alt == alt) {
it->processor(this);
return true;
}

Из очень полезных клавиш оказалась комбинация Стоп (не Пауза): по стопу выключается режим VR и можно выбирать новый файл для проигрывания или менять настройки, не снимая шлема. Включение/выключение же режима vr при нажатии паузы не очень хорошая идея, т.к. выключение занимает существенное время: требуется несколько секунд и при этом показывается крутящийся бегунок прогресса.

Из выводимой информации оказалась полезной длина фильма по времени и текущая точка проигрывания. Вроде очевидно, но ценить начинаешь только когда проигрыватель без них.

Само получение данных сделано через функции vlc асинхронно и это очень правильно: файлы с фильмами могут быть большими, находиться на внешних сетевых устройствах и др.

Для получения длины фильма пользуемся библиотекjq vlc: на открытую media (файл с фильмом) получаем менеджер событий и регистрируем в нём обработчик по событию парсинга:

	media = libvlc_media_new_path(libvlc, path);
...
auto media_evman = libvlc_media_event_manager(media);
assert(media_evman);
libvlc_event_attach(media_evman, libvlc_MediaParsedChanged, vlc_event, this);

Далее запрашиваем парсинг открытой media для получения параметров:

  if (libvlc_media_parse_with_options(media, libvlc_media_parse_network,
kParseTimeout) == -1) {
// TODO process parsing error
}

По завершении парсинга вызовется обработчик vlc_event с типом события libvlc_MediaParsedChanged. После этого можно уже получать длительность фильма

auto dur = libvlc_media_get_duration(media);

Текущую точку воспроизведения уже можно посчитать, зная прогресс и общую длительность фильма.

Добавить плоский вид

Для 3D фильмов используются несколько форматов перспективы и вывод на плоскость является одним из часто используемых. В исходном проигрывателе поддержки такого формата не было - делаем.

Хотя вид является "плоским", использовать для вывода лучше немного закруглённый цилиндр: в кинотеатрах экран тоже закругляют, и мониторы уже делают гнутыми.

Для получения картинки на цилиндре у нас есть исходная текстура с кадром и есть вершинный шейдер, который рисует сцену на большом кубе (зритель в центре куба) - всё это было в исходном проигрывателе.

С нашей стороны необходимо сделать фрагментный шейдер, который выдаёт цвет на заданные координаты.

Получение цвета сделаем в виде отдельной функции (потом это понадобится), и в начале зададимся константами (вообще там много констант, которые подбираются исходя из красоты отображения):

vec4 GetCylinderColor(vec3 position) {
  ...
  const float plane_arc = 1.3;
  const float xarc = plane_arc * plane_distance / cylinder_radius; // in radians

Для вывода на цилиндр обязательно нужно определиться с дугой на которую будет растянута сцена. plane_arc - та самая дуга: угол с точки зрения наблюдателя в радианах. И xarc - её аппроксимация на цилиндр экрана.

Далее определяем углы, под которыми находится отображаемый пиксель. Здесь необходимо учитывать, что координаты в шейдере они не декартовы, они однородные. Поэтому всё лучше считать в относительных величинах: position.x / position.z покажет относительную координату x, а фактически это тангенс угла направления в горизонтальной плоскости:

  float relx = position.x / (position.z / plane_distance);
  float rely = position.y / (position.z / plane_distance);
  float anglex = atan(-relx, cylinder_radius);
  float angley = atan(rely, cylinder_radius);

Далее отсекаем лишнее (вне заданной зоны всё чёрное) и приводим координаты к диапазону (0-1;0-1):

if (anglex < -xarc/2 || anglex > xarc/2) {
  return vec4(0.0);
}
if (angley < -yarc/2 || angley > yarc/2) {
return vec4(0.0);
}
vec2 cyl_coor;
cyl_coor.x = 0.5 * anglex / (xarc / 2.0) + 0.5;
cyl_coor.y = 0.5 * angley / (yarc / 2.0) + 0.5;

Далее вытаскиваем с текстуры цвет (texture(...)) с учётом матрицы обзора, которая выбирает, какую половинку (правую/левую) показать для соответствующего глаза (вообще в проигрывателе идёт двойная обработка: сначала для одного глаза (одна половина изображения), потом для другого глаза):

  vec2 uv = min_max_uv_uni.xy + (min_max_uv_uni.zw - min_max_uv_uni.xy) * cyl_coor;
return vec4(texture(tex_uni, uv).rgb, 1.0);

В результате получаем отображение на цилиндр и можем смотреть обычные 3D фильмы. В общем, уже можно начинать пользоваться.

Компенсация дисторсии

Просмотр 3D фильмов, особенно с охватом в 180 градусов, быстро показал одну из проблем VR шлема: дисторсия изображения. Тип: подушка. Проще говоря: углы изображения вытягиваются наружу. В принципе, конечно, смотреть можно. Но есть ощущение, что какой-то перекос в мозгах и зрении. Поэтому - пробуем устранить.

Для дисторсии есть модели, например Брауна-Конради. И, судя по дефекту изображения, нам нужно на "подушку" поставить "бочку" и всё будет ОК. На практике всё оказалось несколько интереснее, так как на изображении на экране компьютера все прямые ровненькие и перпендикулярненькие. А если на это изображение посмотреть через vr шлем, то явно видна дисторсия. Поэтому пришлось подбирать коэффициенты "на глаз". Для компенсации использовалась упрощённая модель:

dis_length = length + k1 * length^2 + k2 * length^4

где length - это расстояние от центра обзора до требуемой точки;

dis_length - это новое расстояние до точки в том же направлении;

k1, k2 - подбираемые коэффициенты.

Длина до точки берётся на некоторых, относительных единицах. В проигрывателе взято максимальное удаление пикселя по диагонали за единицу, и относительно этого размера считалось всё остальное.

Коэффициенты подбирались из "прямизны" картинки: сначала подбирался коэффициент k1, чтобы в целом всё было ровно. Потом подбирался коэффициент k2, чтобы на краю изображения не возникали резкие волны и переходы.

Для компенсации дисторсии был использован вершинный шейдер: это же его задача правильно выставить треугольники на сцене.

Задаёмся константами:

    const float scr_radius = 1.8;
    const float dis_k1 = -0.32; // Manual calibration
    const float dis_k2 = 0.015;
    const float min_radius = 0.01;
    const float max_radius = 3.0;

scr-radius - это наш единичный радиус с привязкой к однородным координатам;

dis_k1, dis_k2 - подобранные коэффициенты;

min_radius - важная константа: минимальный радиус для обсчёта (это маленькая точечка в центре экрана). Всё что меньше (внутри точечки) считается плоским. Тонкость в том, что без этой точечки может произойти деление на ноль и такой треугольник обсчитываться не будет и появится в центре чёрное пятно;

max_radius - тоже важное ограничение: максимальный радиус для дисторсии. Всё, что снаружи считается плоским. Здесь тоже тонкость: если не ставить ограничение по максимуму, то где-то за краями зрения (обсчитывается ведь вся сцена) треугольники (через модель дисторсии) могут сосчитаться и попасть в центр сцены. Этакий завёрнутый бублик. Шейдеру то всё равно, он сосчитает, а у вас будет вырви-глаз.

Далее всё по математике: находим относительные координаты (dispos), считаем длину dislen (относительную), и прикладываем к уравнению (newlen). В результате получим вектор distorsion с корректировками для координат x и y, которым двигаем позицию:

    vec2 dispos;
    vec4 distorsion = vec4(1.0, 1.0, 1.0, 1.0);
    dispos.x = scr_pos.x / scr_pos.z;
    dispos.y = scr_pos.y / scr_pos.z;
    float dislen = length(dispos) / scr_radius;
    if (dislen > min_radius && dislen < max_radius) {
      float dislen2 = dislen * dislen;
      float dislen4 = dislen2 * dislen2;
      float newlen = dislen + dis_k1 * dislen2 + dis_k2 * dislen4;
      float dis_koef = newlen / dislen;
      distorsion = vec4(dis_koef, dis_koef, 1.0, 1.0);
    }
gl_Position = scr_pos * distorsion;

При расчёте дисторсии нужно учитывать один важный момент: вершинный шейдер работает только с вершинами (да, кэп), и далее, при расчёте цвета пикселя, все координаты аппроксимируются с расчётом на плоский треугольник. Поэтому, если вы попытаетесь скомпенсировать дисторсию на треугольнике размером с целую сцену, то результат будет нулевым.

В исходном проигрывателе сцена представляла собой куб об 6-ти гранях, и каждая грань есть два треугольника, каждый из которых может покрыть ваш обзор целиком. И треугольники у шейдера плоские. Поэтому, чтобы вершинный шейдер скомпенсировал дисторсию, необходимо чтобы треугольнички на сцене были маленькие. В проигрывателе это сделано через разбиение каждой квадратной грани на кратное число меньших квадратиков:

  for (size_t i = 0; i < kTriangleFactor; ++i) {
for (size_t j = 0; j < kTriangleFactor; ++j) {
QVector3D s1 = ApproximateVertice(p1, p2, p3, p4,
static_cast<double>(i) / kTriangleFactor,
static_cast<double>(j) / kTriangleFactor);
QVector3D s2 = ApproximateVertice(p1, p2, p3, p4,
static_cast<double>(i + 1) / kTriangleFactor,
static_cast<double>(j) / kTriangleFactor);
QVector3D s3 = ApproximateVertice(p1, p2, p3, p4,
static_cast<double>(i + 1) / kTriangleFactor,
static_cast<double>(j + 1) / kTriangleFactor);
QVector3D s4 = ApproximateVertice(p1, p2, p3, p4,
static_cast<double>(i) / kTriangleFactor,
static_cast<double>(j + 1) / kTriangleFactor);
AddSquareToVertices(s1, s2, s3, s4);
}
}

где kTriangleFactor указывает на сколько квадратиков (по каждой оси) делить один большой квадрат. И для каждого квадрата (4 вершины) создаются 2 треугольника (всего 6 вершин):

void HMDWidget::AddSquareToVertices(QVector3D p1, QVector3D p2, QVector3D p3, QVector3D p4)
{
cube_vertices_.push_back(p1);
cube_vertices_.push_back(p2);
cube_vertices_.push_back(p3);
cube_vertices_.push_back(p3);
cube_vertices_.push_back(p4);
cube_vertices_.push_back(p1);
}

и вот после того, как разрежете сцену на несколько тысяч треугольков, у вас будет всё красиво. В целом, конечно, это можно оптимизировать. Однако для современного компьютера это не очень много, и вершин всё же существенно меньше, чем пикселей.

Компенсация хроматической абберации

Другой проблемой при просмотре фильмов через шлем была хроматическая абберация. Выражалось это в сдвижке цвета при удалении от центра обзора: предметы приобретали сине-зелёные ореолы. Выглядит, конечно, немного фантасмагорично. Но нам же не любоваться - поэтому устраняем.

Изучение интернета показало, что цвет расползается линейно: чем дальше от центра, тем больше расползание. Одна из тонкостей: расползание по горизонтали и по вертикали - различное. И, конечно, разные цвета расползаются по-разному.

Для компенсации абберации достаточно считать красный цвет за эталон и к нему подгонять синий и зелёный.

Для компенсации использовался функционал вершинного и фрагментного шейдера:

  • в вершинном обсчитывалось отклонение от центра обзора;

  • в фрагментном находились цвета по отдельности для красного, синего и зелёного пикселя.

Итак, как всегда, задаёмся константами:

  // Compensate chromatic abberation
  float blue_y = -0.015; // 1.0192
  float blue_x = -0.01; // 1.0224
  float green_y = -0.005; // 1.0078
  float green_x = -0.004; // 1.0091

Далее находим смещение от центра в относительных координатах:

  blue_x_disp = gl_Position.x / gl_Position.z * blue_x;
  blue_y_disp = gl_Position.y / gl_Position.z * blue_y;
  green_x_disp = gl_Position.x / gl_Position.z * green_x;
  green_y_disp = gl_Position.y / gl_Position.z * green_y;

Смещение выдаётся во фрагментный шейдер (не забываем: оно аппроксимируется на треугольник; но треугольники у нас маленькие) и там вычисляются три точки, которые соответствуют цветам одного пикселя:

  // Calculates position of colored point
  vec3 pos_red = position_var; // Red without correction
  vec3 pos_blue = position_var;
  pos_blue.x += pos_blue.z * blue_x_disp;
  pos_blue.y += pos_blue.z * blue_y_disp;
  vec3 pos_green = position_var;
  pos_green.x += pos_green.z * green_x_disp;
  pos_green.y += pos_green.z * green_y_disp;

Далее для трёх точек считаем цвет по пространственной модели (цилиндр или сфера) и выдаём результат:

    color_out = GetCylinderColor(pos_red);
    color_out.b = GetCylinderColor(pos_blue).b;
    color_out.g = GetCylinderColor(pos_green).g;

В результате получаем картинку, которую можно вполне можно смотреть.

Результат

На выходе получился проигрыватель 3D фильмов для шлема Sony PlayStation VR. Результат здесь: https://github.com/evgenykislov/psvr_player.


Примечание:

У меня есть ощущение, что в некоторых вещах был сделан дичайший велосипед на костылях. Кто в теме - напишите, что можно улучшить или облегчить.

Изображение взято из открытого источника.

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


  1. kinjalik
    02.10.2022 18:32
    +3

    Коротковато для статьи, если честно. Тем более учитывая, что ни строки чего-либо нового показано пока не было


    1. Apoheliy Автор
      02.10.2022 20:48
      +1

      Пожалуй соглашусь: сначала хотел разбить на 2-3 части, но получается коротко и малоинформативно. Поэтому буду дополнять эту статью по пунктам: (к сожалению, по моему) делать статьи за один раз в новом редакторе слегка не удобно, поэтому через дополнение.


    1. Apoheliy Автор
      02.10.2022 20:49

      Да, ещё дополнение: здесь вряд ли будет хоть строчка чего-то нового. Всё здесь рассказанное можно найти в интернете или посчитать на калькуляторе. Я укажу это в начале статьи.