Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

Camadas de composição OpenXR

Introdução

Em jogos de XR, geralmente você deseja criar interações de usuário que acontecem no espaço 3D e envolvem os usuários tocando em objetos como se os estivessem tocando na vida real.

Às vezes, no entanto, a criação de uma interface 2D mais tradicional é inevitável. Em XR, contudo, você não pode simplesmente adicionar componentes 2D à sua cena. O Godot precisa de informações de profundidade para posicionar adequadamente esses elements para que apareçam em um local confortável para o usuário. Mesmo com informações de profundidade, existem headsets com telas inclinadas que impossibilitam que o pipeline 2D padrão renderize corretamente os elementos 2D.

A solução então é renderizar a interface de usuário (UI) em um SubViewport e exibir o resultado disso usando uma ViewportTexture em uma malha 3D. O QuadMesh é uma opção adequada para isso.

Nota

Veja o projeto de exemplo GUI em 3D para um exemplo desta abordagem.

O problema de exibir o viewport dessa maneira é que o resultado renderizado é amostrado para distorção de lente pelo runtime de XR e a perda de qualidade resultante pode tornar o texto da UI difícil de ler.

O OpenXR oferece uma solução para esse problema por meio de camadas de composição (composition layers). Com as camadas de composição, é possível que o conteúdo de um viewport seja projetado em uma superfície após a distorção de lente, resultando em um resultado final de qualidade muito superior.

Nota

Como nem todos os runtimes de XR suportam todos os tipos de camadas de composição, o Godot implementa uma solução de contingência (fallback) na qual renderizamos o viewport como parte da cena normal, mas com as limitações de qualidade mencionadas anteriormente.

Aviso

Quando a camada de composição é suportada, é o runtime de XR que apresenta o subviewport. Isso significa que a UI só fica visível no headset, não estará acessível pelo Godot e, portanto, não será mostrada quando você tiver uma visão de espectador no desktop.

Atualmente, existem 3 nós que expõem esta funcionalidade:

Configurando o SubViewport

O primeiro passo é adicionar um SubViewport para nossa UI 2D, isso não requer nenhum passo específico. Para o nosso exemplo, marcamos o viewport como transparente.

Agora você pode criar a UI 2D adicionando nós filhos ao SubViewport como faria normalmente. É aconselhável salvar a UI 2D em uma subcena, isso facilita a criação do seu layout.

../../_images/openxr_composition_layer_subviewport.webp

Aviso

O modo de atualização "When Visible" não funcionará, pois o Godot não pode determinar se o viewport está visível para o usuário. Ao atribuir nosso viewport a uma camada de composição, o Godot ajustará isso automaticamente.

Adicionando uma camada de composição

O segundo passo é adicionar nossa camada de composição. Podemos adicionar o nó de camada de composição correto como um nó filho do nosso nó XROrigin3D. Isso é muito importante, pois o runtime de XR posiciona tudo em relação à nossa origem.

Queremos posicionar a camada de composição de modo que fique na altura dos olhos e a aproximadamente 1 a 1,5 metros de distância do jogador.

Agora atribuímos o SubViewport à propriedade Layer Viewport e ativamos a Mistura Alfa (Alpha Blend).

../../_images/openxr_composition_layer_quad.webp

Nota

Como o jogador pode se afastar do ponto de origem, você desejará reposicionar a camada de composição quando o jogador recentralizar a visão. Usar o espaço de referência Local Floor aplicará essa lógica automaticamente.

Fazendo a interface funcionar

Até agora estamos apenas exibindo nossa UI, para fazê-la funcionar precisamos adicionar algum código. Para este exemplo, vamos manter as coisas simples e fazer com que um dos controles funcione como um ponteiro. Em seguida, simularemos ações do mouse com este ponteiro.

Este código também requer que um nó MeshInstance3D chamado Pointer seja adicionado como filho ao nosso nó OpenXRCompositionLayerQuad. Configuramos uma SphereMesh com um raio de 0.01 metros. Usaremos isso como um auxiliar para visualizar para onde o usuário está apontando.

A principal função que impulsiona essa funcionalidade é a função intersects_ray em nosso nó de camada de composição. Esta função recebe a posição global e a orientação do nosso ponteiro e retorna o UV onde nosso raio intercepta nosso viewport. Ela retorna Vector2(-1.0, -1.0) se não estivermos apontando para o nosso viewport.

Começamos configurando algumas variáveis, importantes aqui são as variáveis de exportação que identificam nosso nó de controle com o qual apontamos para nossa tela.

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

...

Em seguida, definimos uma função auxiliar que recebe o valor retornado de intersects_ray e nos dá a posição global para esse ponto de interseção. Esta implementação só funciona para o nosso nó 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()

...

Também definimos uma função auxiliar que recebe nosso valor de intersect e retorna nossa localização no sistema de coordenadas locais do viewport:

...

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)

...

A lógica principal acontece em nossa função _process. Aqui começamos escondendo nosso ponteiro, então checamos se temos um controlador e uma viewport válidos, e chamamos intersects_ray com a posição e orientação do nosso controlador:

...

# 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)

...

Em seguida, checamos se estamos intersectando com nossa viewport. Se sim, checamos se nosso botão está pressionado e posicionamos nosso ponteiro no ponto de intersecção.

...

        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

...

Se estávamos intersectando na chamada anterior do process e nosso ponteiro se moveu, preparamos um objeto InputEventMouseMotion para simular o movimento do mouse e o enviamos para nossa viewport para processamento posterior.

...

            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)

...

Se acabamos de soltar nosso botão, também preparamos um objeto InputEventMouseButton para simular a liberação de um botão e o enviamos para nossa viewport para processamento posterior.

...

            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)

...

Ou, se acabamos de pressionar nosso botão, preparamos um objeto InputEventMouseButton para simular o pressionamento de um botão e o enviamos para nossa viewport para processamento posterior.

...

            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)

...

Em seguida, lembramos do nosso estado para o próximo quadro.

...

            was_pressed = is_pressed
            was_intersect = intersect

...

Finalmente, se não estivermos intersectando, limpamos nosso estado.

...

        else:
            was_pressed = false
            was_intersect = NO_INTERSECTION

Hole punching

Como a camada de composição é composta por cima do resultado da renderização, ela pode ser renderizada à frente de objetos que estão, na verdade, à frente da viewport.

Ao ativar o hole punch, você instrui o Godot a renderizar um objeto transparente onde a sua viewport é exibida. Ele faz isso de uma forma que preenche o buffer de profundidade (depth buffer) e limpa o resultado da renderização atual. Qualquer coisa atrás da nossa viewport será limpa agora, enquanto qualquer coisa na frente da nossa viewport será renderizada como de costume.

Você também precisa definir o Sort Order para um valor negativo; o compositor XR agora desenhará a viewport primeiro e, depois, sobreporá o nosso resultado de renderização.

../../_images/openxr_composition_layer_hole_punch.webp

Caso de uso mostrando como a mão do usuário é incorretamente obscurecida por uma camada de composição quando o hole punching não é usado.