OpenXR 合成圖層
前言
在 XR 遊戲中,通常希望建立發生於 3D 空間的使用者互動,讓玩家如同在現實生活中一樣觸碰物件。
然而有時仍無法避免要製作較傳統的 2D 介面。但在 XR 中,不能直接將 2D 元件加入場景。Godot 需要深度資訊來正確地擺放這些元素,使它們顯示在適合使用者觀看的位置。即使有深度資訊,有些頭戴式顯示器因為螢幕傾斜,標準 2D 管線仍無法正確渲染這些 2D 元素。
因此解決方式是將 UI 繪製到 SubViewport,再用 ViewportTexture 作為 3D 網格材質來顯示。這裡推薦使用 QuadMesh 作為承載。
備註
關於此作法的範例,請參考 GUI in 3D 範例專案。
用這種方式顯示 Viewport 的問題在於,XR 執行階段會對渲染結果進行鏡頭畸變取樣,導致品質降低,使 UI 文字變得難以閱讀。
OpenXR 針對這個問題提供了「合成圖層」解決方案。透過合成圖層,可以在經過鏡頭畸變之後再將 Viewport 內容投影到表面,讓最終呈現的畫質大幅提升。
備註
由於並非所有 XR 執行環境都支援所有合成圖層類型,Godot 提供了備用方案:將 Viewport 當作一般場景的一部分來渲染,但會有前述的畫質限制。
警告
若 XR 執行環境支援合成圖層,則 SubViewport 會由 XR 執行階段負責顯示。這代表 UI 僅會在頭戴式裝置中可見,Godot 本身無法存取,因此在桌面端的旁觀者模式下也不會顯示。
目前有三種節點可用來實現這項功能:
OpenXRCompositionLayerCylinder 會將 SubViewport 的內容顯示於圓柱內部(或圓柱的一個「切片」)上。
OpenXRCompositionLayerEquirect 會將 SubViewport 的內容顯示於球體內部(或球體的一個「切片」)上。
OpenXRCompositionLayerQuad 會將 SubViewport 的內容顯示於平面矩形上。
設定 SubViewport
第一步是替 2D UI 新增一個 SubViewport,這不需要特別設定。在此範例中,會將 Viewport 標記為透明。
接下來可以像平常一樣,將 2D 介面的子節點加入 SubViewport。建議將 UI 製作成子場景,這樣更方便排版與管理。
警告
「僅在可見時更新」這個更新模式在這裡無法運作,因為 Godot 無法判斷 Viewport 是否對用戶可見。當將 Viewport 指派給合成圖層時,Godot 會自動調整這個設定。
新增合成圖層
第二步是新增合成圖層。請將正確的合成圖層節點作為 XROrigin3D 的子節點。這非常重要,因為 XR 執行環境會以原點為基準來定位所有物件。
建議將合成圖層設置在視線高度,並離玩家約 1 至 1.5 公尺遠的位置。
接著將 SubViewport 指定到「Layer Viewport」屬性,並啟用 Alpha Blend(透明混合)。
備註
由於玩家可能會離開原點,建議在玩家重設視角時重新定位合成圖層。若使用「參考空間」為 Local Floor,這個邏輯會自動處理。
讓介面可互動
目前我們只是顯示 UI,若要讓介面可互動,需要加入一些程式碼。本範例會簡化處理,讓其中一隻控制器作為指標,並用此指標模擬滑鼠操作。
這段程式也需要在 OpenXRCompositionLayerQuad 節點下加入名為 Pointer 的 MeshInstance3D 節點。其 Mesh 使用 SphereMesh,半徑設為 0.01 公尺。這個指標用來幫助視覺化使用者指向的位置。
這項功能的核心在於合成圖層節點的 intersects_ray 方法。該方法會傳入指標的全域位置和方向,並回傳光線與 Viewport 相交的 UV 座標。如果沒有指向 Viewport,則會回傳 Vector2(-1.0, -1.0)。
首先,先設定一些變數,最重要的是 export 變數,用以指定將用來指向畫面的控制器節點。
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 值轉換為 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)
...
主要邏輯會寫在 _process 函式:先隱藏指標,然後確認控制器與 Viewport 有效,接著用控制器的位置和方向呼叫 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)
...
接著檢查是否與 Viewport 相交。若有,則判斷按鈕是否被按下,並將指標放到交點。
...
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 事件,模擬滑鼠移動,並傳送給 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)
...
如果剛釋放按鈕,也會產生 InputEventMouseButton 事件,模擬滑鼠按鈕釋放,並傳送給 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)
...
如果剛按下按鈕,則產生 InputEventMouseButton 事件,模擬滑鼠按下,並傳送給 Viewport 處理。
...
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)
由於合成圖層疊加在渲染結果之上,有時會導致它出現在理論上應在 Viewport 前方的物件之上。
啟用 Hole Punching(打洞)功能後,Godot 會在 Viewport 顯示的位置渲染一個透明物件,這個物件會填滿深度緩衝區並清除目前的渲染結果。如此一來,Viewport 後方的物件會被清除,而前方的物件則正常渲染。
同時需將 Sort Order 設為負值,這樣 XR 合成器會先繪製 Viewport,再疊加其他渲染結果。
若未啟用 Hole Punching,使用者的手可能會被合成圖層錯誤地遮蔽。