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.
Checking the stable version of the documentation...
OpenXR 合成层
介绍
在 XR 游戏中,用户交互一般发生在 3D 空间中,需要用户像在真实世界中一样触摸游戏中物体。
然而有时候,制作一个更传统的 2D 界面是不可避免的。但在 XR 开发中,你不能直接把 2D 控件(2D components)加到你的场景里。Godot 需要深度信息(depth information),才能把这些元素放在对用户来说视觉舒适的位置。而且,即使有了深度信息,有些头显的显示屏本身就是倾斜的,这也会导致标准的 2D 渲染管线根本无法正确地把 2D 元素渲染出来。
那么,解决方案就是:先把 UI 界面渲染到一个 SubViewport 上,然后把渲染出来的结果,通过 ViewportTexture 贴到一个 3D 网格(mesh)的表面来显示。在这方面, QuadMesh 就是一个非常合适的选择。
备注
关于这种实现方法,可以去看看 GUI in 3D 这个示例项目作为参考。
用这种方式(即直接在 3D 场景中渲染视口)来显示画面,问题在于 XR 运行时(XR runtime)会对渲染结果进行‘镜头畸变(lens distortion)’采样,而由此导致的画质损耗,往往会让 UI 上的文字变得难以辨认。
OpenXR 通过合成层(composition layers)为这个问题提供了解决方案。借助合成层,视口(viewport)的内容可以在‘镜头畸变(lens distortion)’处理之后,再被投射到表面上,从而呈现出画质高得多的最终效果。
备注
由于并非所有的 XR 运行时(XR runtime)都支持所有类型的合成层,Godot 专门实现了一套备用方案(fallback solution):它会把视口(viewport)作为普通场景的一部分来渲染,但(采用这种备用方案时)会存在前面提到过的那些画质限制。
警告
当合成层(composition layer)被支持时,是由 XR 运行时(XR runtime)来负责呈现子视口(subviewport)的。这意味着 UI 界面只会在 VR 头显里可见,Godot 引擎本身无法直接访问它,因此当你在电脑桌面上使用旁观者视角(spectator view)时,这个 UI 是不会被显示出来的。
目前有三个节点可以直接使用这些功能:
OpenXRCompositionLayerCylinder 将 SubViewport(子视口)的内容,投射到一个圆柱体(或者圆柱体的一部分“切片”)的内表面上进行展示。
OpenXRCompositionLayerEquirect 将 SubViewport(子视口)的内容,投射到一个球体(或者球体的一部分“切片”)的内表面上进行展示。
OpenXRCompositionLayerQuad 将一个平坦的矩形(flat rectangle)作为画板,用来展示 SubViewport(子视口)里的内容。
设置 SubViewport
第一步是为我们的 2D UI 界面添加一个 SubViewport(子视口),这一步不需要什么特殊的操作。不过在我们的示例中,我们会把这个视口标记为‘透明’(transparent)。
现在,你可以像在普通 2D 游戏里一样,通过往 SubViewport(子视口)里添加子节点来制作你的 2D UI 界面了。建议把这个 2D UI 单独保存成一个子场景(subscene),这样排版布局起来会方便很多。
警告
"When Visible" (当可见时)这种更新模式在这里是行不通的,因为 Godot 无法判断这个视口是否真的被用户看见了。不过别担心,当我们把这个视口分配给合成层(composition layer)时,Godot 会自动帮我们调整好这个设置。
添加合成层
第二步是添加我们的合成层(composition layer)。我们可以将正确的合成层节点,作为 XROrigin3D 节点的子节点添加进去。这一点非常重要,因为 XR 运行时会以我们的原点(origin)为基准,来定位空间中的所有物体。
我们希望将合成层(composition layer)定位在眼睛的高度,并且距离玩家大约 1 到 1.5 米远的地方。
现在,我们将 SubViewport(子视口)分配给 Layer Viewport (层视口)属性,并开启 Alpha Blend(阿尔法混合/透明混合)。
备注
由于玩家可能会从初始原点走开,所以当玩家重新校准(recenter)视角时,你肯定希望合成层(composition layer)也能跟着重新定位。使用 Local Floor (本地地面)参考空间,就会自动帮你应用这套逻辑。
制作界面
到目前为止,我们只是把 UI 界面显示了出来。要想让它真正能用,我们还需要加点代码。在这个示例中,我们会尽量保持简单,让其中一个手柄(控制器)充当指针,然后用这个指针来模拟鼠标的操作。
这段代码还需要我们在 OpenXRCompositionLayerQuad 节点下,添加一个名为 Pointer 的 MeshInstance3D (网格实例3D)子节点。然后,我们给它配置一个半径为 0.01 米的 SphereMesh (球体网格)。我们会用它来作为一个辅助工具,直观地显示出用户当前正指向哪里。
驱动这一功能的核心函数,就是我们合成层(composition layer)节点上的 intersects_ray 函数。这个函数会接收我们指针的全局位置和朝向,然后返回射线与视口相交处的 UV 坐标。如果我们并没有指向视口,它就会返回 Vector2(-1.0, -1.0) 。
我们先来设置一些变量。这里最重要的是几个导出变量(export variables),它们用来指定我们的控制器节点,也就是我们用来指向屏幕的那个手柄。
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 对象来模拟鼠标的移动,并将其发送到视口(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
打洞
由于合成层(composition layer)是直接叠加在渲染结果之上的,因此它可能会被绘制在那些实际上位于视口前方的物体前面。
开启‘镂空(hole punch)’功能,就是指示 Godot 在我们视口显示的位置渲染一个透明物体。它的实现原理是:填充深度缓冲区(depth buffer)并清除当前的渲染结果。这样一来,位于我们视口后方的所有内容都会被清空,而位于视口前方的内容则会照常渲染。
你还需要将 Sort Order (排序顺序)设置为一个负值,这样 XR 合成器就会优先绘制该视口,然后再把我们的渲染结果覆盖在上面。
一个演示案例,展示了在未使用‘镂空(hole punching)’技术时,用户的手部是如何被合成层错误遮挡的。