OpenXR-Kompositionsschichten
Einführung
In XR-Spielen möchte man in der Regel Benutzerinteraktionen schaffen, die im 3D-Raum stattfinden und bei denen die Benutzer Objekte so berühren, als ob man sie im echten Leben berühren würde.
Manchmal ist es jedoch unvermeidlich, eine traditionellere 2D-Benutzeroberfläche zu erstellen. In XR können Sie jedoch nicht einfach 2D-Komponenten zu Ihrer Szene hinzufügen. Godot benötigt Tiefeninformationen, um diese Elemente richtig zu positionieren, damit sie an einer für den Benutzer angenehmen Stelle erscheinen. Selbst mit Tiefeninformationen gibt es Headsets mit schrägen Displays, die es der Standard 2D-Pipeline unmöglich machen, die 2D-Elemente korrekt zu rendern.
Die Lösung ist dann, das UI in ein SubViewport zu rendern und das Ergebnis mit einer ViewportTexture auf einem 3D-Mesh darzustellen. Das QuadMesh ist hierfür eine geeignete Option.
Bemerkung
Ein Beispiel für diesen Ansatz finden Sie im Beispielprojekt GUI in 3D".
Das Problem bei der Anzeige des Viewports auf diese Weise ist, dass das gerenderte Ergebnis von der XR Runtime für die Linsenverzerrung abgetastet wird und der daraus resultierende Qualitätsverlust dazu führen kann, dass der Text auf der Benutzeroberfläche schwer zu lesen ist.
OpenXR bietet mit Kompositionsebenen eine Lösung für dieses Problem. Mit Kompositionsebenen ist es möglich, den Inhalt eines Viewports nach der Linsenverzerrung auf eine Oberfläche zu projizieren, was zu einem wesentlich hochwertigeren Endergebnis führt.
Bemerkung
Da nicht alle XR Runtimes alle Kompositionsebenen-Typen unterstützen, implementiert Godot eine Fallback-Lösung, die den Viewport als Teil der normalen Szene rendert, allerdings mit den oben genannten Qualitätseinschränkungen.
Warnung
Wenn die Kompositionsebene unterstützt wird, ist es die XR Runtime, die das Subviewport präsentiert. Das bedeutet, dass die Benutzeroberfläche nur im Headset sichtbar ist. Sie ist für Godot nicht zugänglich und wird daher nicht angezeigt, wenn Sie einen Zuschauer-View auf dem Desktop haben.
Derzeit gibt es 3 Nodes, die diese Funktion anbieten:
OpenXRCompositionLayerCylinder zeigt den Inhalt des SubViewports auf der Innenseite eines Zylinders (oder "Slice" eines Zylinders).
OpenXRCompositionLayerEquirect zeigt den Inhalt des SubViewports auf der Innenseite einer Kugel (oder "Scheibe" einer Kugel).
OpenXRCompositionLayerQuad zeigt den Inhalt des SubViewports auf einem flachen Rechteck.
Einrichten des SubViewports
Der erste Schritt ist das Hinzufügen eines SubViewports für unsere 2D-Benutzeroberfläche, dies erfordert keine besonderen Schritte. Für unser Beispiel markieren wir den Viewport als transparent.
Sie können nun die 2D-Benutzeroberfläche erstellen, indem Sie dem SubViewport Child-Nodes hinzufügen, wie Sie es normalerweise tun würden. Es ist ratsam, die 2D-Benutzeroberfläche in einer Unterszene zu speichern, da dies das Layout erleichtert.
Warnung
Der Aktualisierungsmodus "Wenn sichtbar" wird nicht funktionieren, da Godot nicht feststellen kann, ob der Viewport für den Benutzer sichtbar ist. Wenn wir unseren Viewpoert einer Kompositionsebene zuweisen, wird Godot dies automatisch anpassen.
Hinzufügen einer Kompositionsebene
The second step is adding our composition layer. We can add the correct composition layer node as a child node of our XROrigin3D node. This is very important as the XR runtime positions everything in relation to our origin.
Wir wollen die Kompositionsebene so positionieren, dass sie sich auf Augenhöhe und etwa 1 bis 1,5 Meter vom Spieler entfernt befindet.
Wir weisen nun den SubViewport der Property Layer Viewport zu und aktivieren Alpha Blend.
Bemerkung
Da der Spieler sich vom Ursprungspunkt entfernen kann, sollten Sie die Kompositionsebene neu positionieren, wenn der Spieler die Ansicht erneut ändert. Die Verwendung des Referenzraums Localer Boden wird diese Logik automatisch anwenden.
Die Benutzeroberfläche zum Laufen bringen
Bis jetzt haben wir nur unsere Benutzeroberfläche angezeigt, damit sie funktioniert, müssen wir etwas Code hinzufügen. Für dieses Beispiel werden wir die Dinge einfach halten und einen der Controller als Zeiger verwenden. Wir werden dann Mausaktionen mit diesem Zeiger simulieren.
Dieser Code erfordert auch einen MeshInstance3D-Node namens Pointer, der als Child-Node unseres OpenXRCompositionLayerQuad-Nodes hinzugefügt wird. Wir konfigurieren ein SphereMesh mit einem Radius von 0.01 Metern. Wir werden dies als Hilfsmittel benutzen, um zu visualisieren, wohin der Benutzer zeigt.
Die Hauptfunktion, die diese Funktionalität steuert, ist die Funktion intersects_ray auf unserem Kompositionsebenen-Node. Diese Funktion nimmt die globale Position und Orientierung unseres Zeigers und gibt das UV zurück, in dem unser Strahl unseren Viewport schneidet. Sie gibt Vector2(-1.0, -1.0) zurück, wenn wir nicht auf unseren Viewport zeigen.
Wir beginnen mit dem Einrichten einiger Variablen, wichtig sind hier die Exportvariablen, die unseren Controller-Node identifizieren, mit dem wir auf unseren Bildschirm zeigen.
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
...
Als nächstes definieren wir eine Hilfsfunktion, die den von intersects_ray zurückgegebenen Wert nimmt und uns die globale Position für diesen Schnittpunkt gibt. Diese Implementierung funktioniert nur für unseren OpenXRCompositionLayerQuad-Node.
...
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()
...
Wir definieren auch eine Hilfsfunktion, die unseren Intersect-Wert nimmt und unsere Position im lokalen Koordinatensystem des Viewports zurückgibt:
...
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)
...
Die Hauptlogik geschieht in unserer _process-Funktion. Hier fangen wir an, indem wir unseren Zeiger verstecken, dann prüfen wir, ob wir einen gültigen Controller und Viewport haben, und wir rufen intersects_ray mit der Position und Orientierung unseres Controllers auf:
...
# 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)
...
Als Nächstes prüfen wir, ob wir uns mit unserem Viewport schneiden. Wenn ja, prüfen wir, ob unser Button gedrückt ist und platzieren unseren Zeiger an unserem Schnittpunkt.
...
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
...
Wenn wir uns in unserem vorherigen Prozessaufruf überschnitten haben und unser Zeiger sich bewegt hat, bereiten wir ein InputEventMouseMotion-Objekt vor, um unsere Mausbewegung zu simulieren, und senden es zur weiteren Verarbeitung an unser Viewport.
...
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)
...
Wenn wir gerade unseren Button losgelassen haben, bereiten wir auch ein InputEventMouseButton-Objekt vor, um das Loslassen eines Buttons zu simulieren, und senden es zur weiteren Verarbeitung an unseren Viewport.
...
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)
...
Oder wenn wir gerade unseren Button gedrückt haben, bereiten wir ein InputEventMouseButton-Objekt vor, um das Drücken eines Buttons zu simulieren, und senden dieses an unseren Viewport zur weiteren Verarbeitung.
...
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)
...
Als Nächstes merken wir uns unseren Zustand für das nächste Frame.
...
was_pressed = is_pressed
was_intersect = intersect
...
Finally, if we aren't intersecting, we clear our state.
...
else:
was_pressed = false
was_intersect = NO_INTERSECTION
Hole Punching
Da die Kompositionsebene über dem Renderergebnis zusammengesetzt wird, kann sie vor Objekten gerendert werden, die sich eigentlich vor dem Viewport befinden.
Wenn Sie Hole Punch aktivieren, weisen Sie Godot an, ein transparentes Objekt dort zu rendern, wo unser Viewport angezeigt wird. Dies geschieht auf eine Weise, die den Tiefenpuffer füllt und das aktuelle Rendering-Ergebnis löscht. Alles, was sich hinter unserem Viewport befindet, wird nun gelöscht, während alles vor unserem Viewport wie gewohnt gerendert wird.
Sie müssen auch Sortierreihenfolge auf einen negativen Wert setzen, der XR-Compositor wird nun zuerst den Viewport zeichnen und dann unser Rendering-Ergebnis überlagern.
Anwendungsfall, der zeigt, wie die Hand des Benutzers fälschlicherweise von einer Kompositionsebene verdeckt wird, wenn Hole Punch nicht verwendet wird.