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.

Livelli di composizione OpenXR

Introduzione

In XR games you generally want to create user interactions that happen in 3D space and involve users touching objects as if they are touching them in real life.

A volte, tuttavia, creare un'interfaccia 2D più tradizionale è inevitabile. Nell'XR, però, non è possibile aggiungere componenti 2D alla scena e basta. Godot necessita di informazioni sulla profondità per posizionare correttamente questi elementi, così da apparire in un posto comodo per l'utente. Anche con le informazioni sulla profondità, esistono visori con schermi inclinati che rendono impossibile per la pipeline 2D standard renderizzare correttamente gli elementi 2D.

La soluzione consiste quindi nel renderizzare l'interfaccia utente su una SubViewport e visualizzare il risultato tramite un ViewportTexture su una mesh 3D. La QuadMesh è un'opzione adatta per questo.

Nota

Consulta il progetto di esempio GUI in 3D per un esempio di questo approccio.

The problem with displaying the viewport in this way is that the rendered result is sampled for lens distortion by the XR runtime and the resulting quality loss can make UI text hard to read.

OpenXR offers a solution to this problem through composition layers. With composition layers it is possible for the contents of a viewport to be projected on a surface after lens distortion resulting in a much higher quality end result.

Nota

As not all XR runtimes support all composition layer types, Godot implements a fallback solution where we render the viewport as part of the normal scene but with the aforementioned quality limitations.

Avvertimento

Quando il livello di composizione è supportato, è il runtime XR a presentare la sotto-viewport. Ciò significa che l'interfaccia utente è visibile solo nel visore, non sarà accessibile da Godot e quindi non verrà mostrata se hai una vista spettatore sul desktop.

Attualmente esistono 3 nodi che espongono questa funzionalità:

Preparazione della SubViewport

Il primo passo è aggiungere una SubViewport per la nostra interfaccia utente 2D, il che non richiede passaggi specifici. Per il nostro esempio segniamo la viewport come trasparente.

Ora puoi creare l'interfaccia utente 2D aggiungendo nodi figlio alla SubViewport come faresti normalmente. Si consiglia di salvare l'interfaccia utente 2D in una sotto-scena, così da facilitare la creazione del tuo layout.

../../_images/openxr_composition_layer_subviewport.webp

Avvertimento

La modalità di aggiornamento "When Visible" non funzionerà perché Godot non è in grado di determinare se la viewport è visibile all'utente. Quando si assegna la viewport a un livello di composizione, Godot la regolerà automaticamente.

Aggiunta di uno strato di composizione

Il secondo passo consiste nell'aggiungere il nostro livello di composizione. Possiamo aggiungere il nodo del livello di composizione corretto come nodo figlio del nostro nodo XROrigin3D. Questo è molto importante perché il runtime XR posiziona tutto in relazione alla nostra origine.

Vogliamo posizionare il livello di composizione all'altezza degli occhi e a una distanza di circa 1-1,5 metri dal giocatore.

Ora assegniamo la SubViewport alla proprietà Layer Viewport e abilitiamo Alpha Blend.

../../_images/openxr_composition_layer_quad.webp

Nota

Poiché il giocatore può allontanarsi dal punto di origine, sarà necessario riposizionare il livello di composizione quando il giocatore ricentra la visuale. Utilizzando lo spazio di riferimento Local Floor si applicherà automaticamente questa logica.

Rendere funzionante l'interfaccia

Finora abbiamo solo visualizzato l'interfaccia utente. Per farla funzionare, dobbiamo aggiungere un po' di codice. Per questo esempio, manterremo le cose semplici e faremo in modo che uno dei controller funzioni come un puntatore. Poi simuleremo le azioni del mouse con questo puntatore.

Questo codice richiede inoltre che un nodo MeshInstance3D chiamato Pointer sia aggiunto come figlio al nostro nodo OpenXRCompositionLayerQuad. Configuriamo una SphereMesh con un raggio di 0.01 metri. La useremo come ausilio per visualizzare dove l'utente sta puntando.

La funzione principale che gestisce questa funzionalità è la funzione intersects_ray sul nodo del nostro livello di composizione. Questa funzione prende la posizione e l'orientamento globali del nostro puntatore e restituisce le coordinate UV nel punto in cui il raggio interseca la nostra viewport. Restituisce Vector2(-1.0, -1.0) se non stiamo puntando alla viewport.

Cominciamo impostando alcune variabili. Qui sono importanti le variabili esportate che identificano il nostro nodo controller, tramite il quale puntiamo alla nostra schermata.

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

...

Successivamente definiamo una funzione ausiliare che prende il valore restituito da intersects_ray e ci fornisce la posizione globale per quel punto di intersezione. Questa implementazione funziona solo per il nostro nodo 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()

...

Definiamo anche una funzione ausiliare che prende il nostro valore intersect e restituisce la nostra posizione nel sistema di coordinate locale della 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)

...

La logica principale avviene nella nostra funzione _process. Qui iniziamo nascondendo il nostro puntatore, poi controlliamo se abbiamo un controller e una viewport validi e chiamiamo intersects_ray con la posizione e l'orientamento del nostro controller:

...

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

...

Successivamente controlliamo se stiamo intersecando la nostra viewport. Se sì, verifichiamo se il pulsante è premuto e posizioniamo il puntatore nel punto di intersezione.

...

        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 abbiamo intersecato durante la precedente chiamata a process e il nostro puntatore si è mosso, prepariamo un oggetto InputEventMouseMotion per simulare il movimento del mouse e lo inviamo alla nostra viewport per elaborarlo ulteriormente.

...

            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 abbiamo appena rilasciato il pulsante, prepariamo anche un oggetto InputEventMouseButton per simulare il rilascio di un pulsante e lo inviamo alla nostra viewport per elaborarlo ulteriormente.

...

            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)

...

Oppure, se abbiamo appena premuto il pulsante, prepariamo un oggetto InputEventMouseButton per simulare la pressione di un pulsante e lo inviamo alla nostra viewport per elaborarlo ulteriormente.

...

            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)

...

Successivamente memorizziamo il nostro stato per il prossimo frame.

...

            was_pressed = is_pressed
            was_intersect = intersect

...

Infine, se non ci sono intersezioni, cancelliamo il nostro stato.

...

        else:
            was_pressed = false
            was_intersect = NO_INTERSECTION

Perforazione (Hole punching)

Poiché il livello di composizione è sovrapposto al risultato del rendering, si può renderizzare davanti a oggetti che in realtà si trovano davanti alla viewport.

Abilitando la perforazione ("hole punching"), si indica a Godot di renderizzare un oggetto trasparente nella posizione in cui è visualizzata la viewport. Questo avviene in modo da riempire il buffer di profondità e cancellare il risultato attuale di rendering. Tutto ciò che si trova dietro la viewport sarà quindi cancellato, mentre tutto ciò che si trova davanti sarà renderizzato come al solito.

È inoltre necessario impostare Sort Order su un valore negativo, così che il compositore XR disegnerà prima la viewport e poi sovrapporrà il nostro risultato del rendering.

../../_images/openxr_composition_layer_hole_punch.webp

Caso d'uso che mostra come la mano dell'utente sia oscurata incorrettamente da un livello di composizione quando non si utilizza la perforazione.