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.

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),这样排版布局起来会方便很多。

../../_images/openxr_composition_layer_subviewport.webp

警告

"When Visible" (当可见时)这种更新模式在这里是行不通的,因为 Godot 无法判断这个视口是否真的被用户看见了。不过别担心,当我们把这个视口分配给合成层(composition layer)时,Godot 会自动帮我们调整好这个设置。

添加合成层

第二步是添加我们的合成层(composition layer)。我们可以将正确的合成层节点,作为 XROrigin3D 节点的子节点添加进去。这一点非常重要,因为 XR 运行时会以我们的原点(origin)为基准,来定位空间中的所有物体。

我们希望将合成层(composition layer)定位在眼睛的高度,并且距离玩家大约 1 到 1.5 米远的地方。

现在,我们将 SubViewport(子视口)分配给 Layer Viewport (层视口)属性,并开启 Alpha Blend(阿尔法混合/透明混合)。

../../_images/openxr_composition_layer_quad.webp

备注

由于玩家可能会从初始原点走开,所以当玩家重新校准(recenter)视角时,你肯定希望合成层(composition layer)也能跟着重新定位。使用 Local Floor (本地地面)参考空间,就会自动帮你应用这套逻辑。

制作界面

到目前为止,我们只是把 UI 界面显示了出来。要想让它真正能用,我们还需要加点代码。在这个示例中,我们会尽量保持简单,让其中一个手柄(控制器)充当指针,然后用这个指针来模拟鼠标的操作。

这段代码还需要我们在 OpenXRCompositionLayerQuad 节点下,添加一个名为 PointerMeshInstance3D (网格实例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 合成器就会优先绘制该视口,然后再把我们的渲染结果覆盖在上面。

../../_images/openxr_composition_layer_hole_punch.webp

一个演示案例,展示了在未使用‘镂空(hole punching)’技术时,用户的手部是如何被合成层错误遮挡的。