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 구성요소를 추가할 수 없습니다. Godot는 이러한 요소를 적절하게 배치하여 사용자에게 편안한 위치에 나타나도록 깊이 정보가 필요합니다. 깊이 정보가 있어도 표준 2D 파이프라인이 2D 요소를 올바르게 렌더링하는 것을 불가능하게 만드는 기울어진 디스플레이가 있는 헤드셋이 있습니다.

그런 다음 해결책은 UI를 :ref:`SubViewport <class_subviewport>`로 렌더링하고 3D 메쉬에 :ref:`ViewportTexture <class_viewporttexture>`을 사용하여 결과를 표시하는 것입니다. :ref:`QuadMesh <class_quadmesh>`가 이에 적합한 옵션입니다.

참고

이 문서 외에 여러가지 Godot 데모 프로젝트들도 살펴보면 좋습니다.

이러한 방식으로 뷰포트를 표시할 때의 문제점은 렌더링된 결과가 XR 런타임에 의한 렌즈 왜곡에 대해 샘플링되고 결과적인 품질 손실로 인해 UI 텍스트를 읽기 어렵게 만들 수 있다는 것입니다.

OpenXR은 컴포지션 레이어를 통해 이 문제에 대한 솔루션을 제공합니다. 컴포지션 레이어를 사용하면 렌즈 왜곡 후 뷰포트의 콘텐츠를 표면에 투영하여 훨씬 더 높은 품질의 최종 결과를 얻을 수 있습니다.

참고

모든 XR 런타임이 모든 구성 레이어 유형을 지원하는 것은 아니기 때문에 Godot는 뷰포트를 일반 씬의 일부로 렌더링하는 폴백 솔루션을 구현하지만 앞서 언급한 품질 제한이 있습니다.

경고

컴포지션 레이어가 지원되는 경우 하위 뷰포트를 제공하는 것은 XR 런타임입니다. 즉, UI는 헤드셋에서만 볼 수 있고 Godot에서는 접근할 수 없으므로 데스크탑에 관전자 보기가 있을 때 표시되지 않습니다.

There are currently 3 노드 that expose this functionality:

  • :ref:`OpenXRCompositionLayerCylinder <class_OpenXRCompositionLayerCylinder>`는 원통(또는 원통의 "슬라이스") 내부에 있는 SubViewport의 내용을 표시합니다.

  • :ref:`OpenXRCompositionLayerEquirect <class_OpenXRCompositionLayerEquirect>`는 구(또는 구의 "슬라이스") 내부에 있는 SubViewport의 내용을 표시합니다.

  • :ref:`OpenXRCompositionLayerQuad <class_OpenXRCompositionLayerQuad>`는 평평한 직사각형에 SubViewport의 내용을 표시합니다.

SubViewport 설정하기

첫 번째 단계는 2D UI용 SubViewport를 추가하는 것입니다. 여기에는 특정 단계가 필요하지 않습니다. 이 예에서는 뷰포트를 투명으로 표시합니다.

이제 평소처럼 SubViewport에 자식 노드를 추가하여 2D UI를 생성할 수 있습니다. 2D UI를 하위 장면에 저장하는 것이 좋습니다. 이렇게 하면 레이아웃을 더 쉽게 수행할 수 있습니다.

../../_images/openxr_composition_layer_subviewport.webp

경고

Godot는 뷰포트가 사용자에게 보이는지 여부를 결정할 수 없기 때문에 "보이는 경우" 업데이트 모드는 작동하지 않습니다. 뷰포트를 컴포지션 레이어에 할당할 때 Godot는 이를 자동으로 조정합니다.

컴포지션 레이어 추가하기

두 번째 단계는 컴포지션 레이어를 추가하는 것입니다. XROrigin3D 노드의 자식 노드로 올바른 컴포지션 레이어 노드를 추가할 수 있습니다. XR 런타임은 원점과 관련하여 모든 것을 배치하므로 이는 매우 중요합니다.

우리는 컴포지션 레이어를 눈 높이에 위치시키고 플레이어로부터 약 1~1.5미터 떨어진 곳에 배치하려고 합니다.

이제 SubViewport를 Layer Viewport 속성에 할당하고 Alpha Blend를 활성화합니다.

../../_images/openxr_composition_layer_quad.webp

참고

플레이어가 원점에서 멀어질 수 있으므로 플레이어가 뷰의 중심을 조정할 때 컴포지션 레이어의 위치를 변경하는 것이 좋습니다. 참조 공간 ``Local Floor``를 사용하면 이 논리가 자동으로 적용됩니다.

빌드 커스터마이징하기

지금까지는 UI만 표시하고 있으므로 작동하려면 몇 가지 코드를 추가해야 합니다. 이 예에서는 일을 단순하게 유지하고 컨트롤러 중 하나가 포인터로 작동하도록 하겠습니다. 그런 다음 이 포인터를 사용하여 마우스 동작을 시뮬레이션합니다.

또한 이 코드에서는 OpenXRCompositionLayerQuad 노드에 하위 항목으로 추가하려면 Pointer``라는 ``MeshInstance3D 노드가 필요합니다. 반경 0.01 미터로 ``SphereMesh``를 구성합니다. 우리는 이것을 사용자가 가리키는 곳을 시각화하기 위한 도우미로 사용할 것입니다.

이 기능을 구동하는 주요 기능은 컴포지션 레이어 노드의 intersects_ray 기능입니다. 이 함수는 포인터의 전역 위치와 방향을 가져와 광선이 뷰포트와 교차하는 UV를 반환합니다. 뷰포트를 가리키지 않으면 ``Vector2(-1.0, -1.0)``를 반환합니다.

몇 가지 변수를 설정하는 것으로 시작합니다. 여기서 중요한 것은 화면을 가리키는 컨트롤러 노드를 식별하는 내보내기 변수입니다.

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 개체를 준비하여 마우스 이동을 시뮬레이션하고 추가 처리를 위해 이를 뷰포트로 보냅니다.

...

            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 객체를 준비하여 버튼 릴리스를 시뮬레이션하고 추가 처리를 위해 뷰포트로 보냅니다.

...

            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 객체를 준비하여 버튼 누르기를 시뮬레이션하고 추가 처리를 위해 뷰포트로 보냅니다.

...

            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

홀 펀칭

컴포지션 레이어는 렌더링 결과 위에 합성되므로 실제로 뷰포트보다 앞에 있는 개체 앞에 렌더링될 수 있습니다.

홀 펀치를 활성화하면 Godot가 뷰포트가 표시되는 투명한 개체를 렌더링하도록 지시할 수 있습니다. 이는 깊이 버퍼를 채우고 현재 렌더링 결과를 지우는 방식으로 수행됩니다. 이제 뷰포트 뒤의 모든 항목이 지워지고, 뷰포트 앞의 모든 항목은 평소대로 렌더링됩니다.

또한 ``Sort Order``를 음수 값으로 설정해야 합니다. 이제 XR 합성기는 먼저 뷰포트를 그린 다음 렌더링 결과를 오버레이합니다.

../../_images/openxr_composition_layer_hole_punch.webp

홀 펀칭을 사용하지 않을 때 컴포지션 레이어에 의해 사용자의 손이 어떻게 잘못 가려지는지 보여주는 사용 사례입니다.