Слои композиции OpenXR

Введение

В XR-играх обычно требуется создавать пользовательские взаимодействия, которые происходят в трехмерном пространстве и предполагают прикосновение пользователей к объектам так, как будто они касаются их в реальной жизни.

Однако иногда создание более традиционного 2D-интерфейса неизбежно. В XR, однако, нельзя просто добавить 2D-компоненты в сцену. Godot нужна информация о глубине для правильного расположения этих элементов, чтобы они отображались в удобном для пользователя месте. Даже с информацией о глубине существуют гарнитуры с наклонными дисплеями, из-за чего стандартный конвейер 2D-графики не может корректно отрисовывать 2D-элементы.

Решение заключается в том, чтобы визуализировать пользовательский интерфейс в SubViewport и отобразить результат с помощью ViewportTexture на 3D-сетке. Для этого хорошо подходит QuadMesh.

Примечание

Пример такого подхода см. в примере проекта GUI in 3D.

Проблема с отображением области просмотра таким способом заключается в том, что визуализированный результат подвергается выборке на предмет искажения объектива средой выполнения XR, и в результате потери качества текст пользовательского интерфейса может стать трудночитаемым.

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

Примечание

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

Предупреждение

При поддержке слоя композиции подокно просмотра отображается в среде выполнения XR. Это означает, что пользовательский интерфейс виден только в гарнитуре, он недоступен для Godot и, следовательно, не отображается в режиме зрителя на десктопе.

В настоящее время эту функциональность реализуют 3 узла:

  • OpenXRCompositionLayerCylinder отображает содержимое SubViewport на внутренней стороне цилиндра (или "части" цилиндра).

  • OpenXRCompositionLayerEquirect отображает содержимое SubViewport на внутренней стороне сферы (или "части" сферы).

  • OpenXRCompositionLayerQuad отображает содержимое SubViewport на плоском прямоугольнике.

Настройка SubViewport

Первый шаг — добавление подокна просмотра (SubViewport) для нашего 2D-интерфейса. Это не требует никаких специальных действий. В нашем примере мы делаем область просмотра прозрачной.

Теперь вы можете создать 2D-интерфейс, добавляя дочерние узлы в SubViewport, как обычно. Рекомендуется сохранить 2D-интерфейс в подсцене, это облегчит создание макета.

../../_images/openxr_composition_layer_subviewport.webp

Предупреждение

Режим обновления "Когда Видна" не будет работать, поскольку Godot не сможет определить, видна ли область просмотра пользователю. При назначении области просмотра композиционному слою Godot автоматически настроит это.

Добавление композиционного слоя

Вторым шагом является добавление композиционного слоя. Мы можем добавить нужный узел композиционного слоя как дочерний узел нашего узла XROrigin3D. Это очень важно, поскольку среда выполнения XR позиционирует все объекты относительно нашего начала координат.

Мы хотим расположить слой композиции так, чтобы он находился на уровне глаз и примерно на расстоянии 1–1,5 метра от игрока.

Теперь мы назначаем SubViewport свойству Layer Viewport и включаем Alpha Blend.

../../_images/openxr_composition_layer_quad.webp

Примечание

Поскольку игрок может отойти от исходной точки, вам потребуется изменить положение слоя композиции, когда игрок центрирует вид. Использование опорного пространства Local Floor автоматически применит эту логику.

Заставить интерфейс работать

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

Этот код также требует добавления узла MeshInstance3D с именем Pointer в качестве дочернего к нашему узлу OpenXRCompositionLayerQuad. Мы настраиваем SphereMesh с радиусом 0,01 метра. Мы будем использовать это как вспомогательный элемент для визуализации направления указателя пользователя.

Основная функция, управляющая этой функциональностью, — это функция intersects_ray в узле композиционного слоя. Эта функция принимает глобальные координаты и ориентацию указателя и возвращает UV-координату точки пересечения луча с областью просмотра. Если луч не указывает на область просмотра, она возвращает Vector2(-1.0, -1.0).

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

extends OpenXRCompositionLayerQuad

const NO_INTERSECTION = Vector2(-1.0, -1.0)

@export var controller : XRController3D
@export var button_action : String = "trigger_click"

var was_pressed : bool = false
var was_intersect : Vector2 = NO_INTERSECTION

...

Затем мы определяем вспомогательную функцию, которая принимает значение, возвращаемое intersects_ray, и возвращает нам глобальную позицию этой точки пересечения. Эта реализация работает только для нашего узла OpenXRCompositionLayerQuad.

...

func _intersect_to_global_pos(intersect : Vector2) -> Vector3:
    if intersect != NO_INTERSECTION:
        var local_pos : Vector2 = (intersect - Vector2(0.5, 0.5)) * quad_size
        return global_transform * Vector3(local_pos.x, -local_pos.y, 0.0)
    else:
        return Vector3()

...

Мы также определяем вспомогательную функцию, которая принимает наше значение intersect и возвращает наше местоположение в локальной системе координат области просмотра:

...

func _intersect_to_viewport_pos(intersect : Vector2) -> Vector2i:
    if layer_viewport and intersect != NO_INTERSECTION:
        var pos : Vector2 = intersect * Vector2(layer_viewport.size)
        return Vector2i(pos)
    else:
        return Vector2i(-1, -1)

...

Основная логика происходит в нашей функции _process. Здесь мы сначала скрываем указатель, затем проверяем наличие корректного контроллера и области просмотра, а затем вызываем intersects_ray, указывая положение и ориентацию нашего контроллера:

...

# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(_delta):
    # Hide our pointer, we'll make it visible if we're interacting with the viewport.
    $Pointer.visible = false

    if controller and layer_viewport:
        var controller_t : Transform3D = controller.global_transform
        var intersect : Vector2 = intersects_ray(controller_t.origin, -controller_t.basis.z)

...

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

...

        if intersect != NO_INTERSECTION:
            var is_pressed : bool = controller.is_button_pressed(button_action)

            # Place our pointer where we're pointing
            var pos : Vector3 = _intersect_to_global_pos(intersect)
            $Pointer.visible = true
            $Pointer.global_position = pos

...

Если в нашем предыдущем вызове процесса произошло пересечение и наш указатель переместился, мы подготавливаем объект InputEventMouseMotion для имитации движения нашей мыши и отправляем его в нашу область просмотра для дальнейшей обработки.

...

            if was_intersect != NO_INTERSECTION and intersect != was_intersect:
                # Pointer moved
                var event : InputEventMouseMotion = InputEventMouseMotion.new()
                var from : Vector2 = _intersect_to_viewport_pos(was_intersect)
                var to : Vector2 = _intersect_to_viewport_pos(intersect)
                if was_pressed:
                    event.button_mask = MOUSE_BUTTON_MASK_LEFT
                event.relative = to - from
                event.position = to
                layer_viewport.push_input(event)

...

Если мы только что отпустили кнопку, мы также подготавливаем объект InputEventMouseButton для имитации отпускания кнопки и отправляем его в нашу область просмотра для дальнейшей обработки.

...

            if not is_pressed and was_pressed:
                # Button was let go?
                var event : InputEventMouseButton = InputEventMouseButton.new()
                event.button_index = 1
                event.pressed = false
                event.position = _intersect_to_viewport_pos(intersect)
                layer_viewport.push_input(event)

...

Или, если мы только что нажали кнопку, мы подготавливаем объект InputEventMouseButton для имитации нажатия кнопки и отправляем его в область просмотра для дальнейшей обработки.

...

            elif is_pressed and not was_pressed:
                # Button was pressed?
                var event : InputEventMouseButton = InputEventMouseButton.new()
                event.button_index = 1
                event.button_mask = MOUSE_BUTTON_MASK_LEFT
                event.pressed = true
                event.position = _intersect_to_viewport_pos(intersect)
                layer_viewport.push_input(event)

...

Далее мы запоминаем наше состояние для следующего кадра.

...

            was_pressed = is_pressed
            was_intersect = intersect

...

Наконец, если мы не пересекаемся, мы очищаем наше состояние.

...

        else:
            was_pressed = false
            was_intersect = NO_INTERSECTION

Hole punching (Пробивка отверстий)

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

Включая функцию "hole punch", вы указываете Godot визуализировать прозрачный объект там, где отображается наша область просмотра. При этом заполняется буфер глубины и очищается текущий результат рендеринга. Всё, что находится за нашей областью просмотра, теперь будет очищено, а всё, что находится перед ней, будет визуализироваться как обычно.

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

../../_images/openxr_composition_layer_hole_punch.webp

Пример использования, демонстрирующий, как рука пользователя неправильно закрывается композиционным слоем, когда перфорация не используется.