diff --git a/.github/workflows/pr_build.yml b/.github/workflows/pr_build.yml index 2bd3b629c40..c11ff90f59f 100644 --- a/.github/workflows/pr_build.yml +++ b/.github/workflows/pr_build.yml @@ -64,7 +64,7 @@ jobs: runner: [self_hosted, type-cx52, image-x86-app-docker-ce] arch: amd64 build_type: full - apt-dependencies: pkg-config libxext-dev libdouble-conversion-dev libpcre2-16-0 libpulse0 libharfbuzz-dev libnss3 libnspr4 libxdamage1 libasound2 # add missing dependencies to docker image when convenient + apt-dependencies: pkg-config libxext-dev libdouble-conversion-dev libpcre2-16-0 libpulse0 libharfbuzz-dev libnss3 libnspr4 libxdamage1 libasound2 libxcb-glx0-dev # add missing dependencies to docker image when convenient image: docker.io/overte/overte-full-build:0.1.1-ubuntu-20.04-amd64 # Android builds are currently failing #- os: ubuntu-18.04 @@ -75,6 +75,7 @@ jobs: runner: [self_hosted, type-cax41, image-arm-app-docker-ce] arch: aarch64 build_type: full + apt-dependencies: libxcb-glx0-dev # add missing dependencies to docker image when convenient image: docker.io/overte/overte-full-build:0.1.1-ubuntu-22.04-aarch64 fail-fast: false runs-on: ${{matrix.runner}} @@ -189,6 +190,16 @@ jobs: echo "Installing apt packages" sudo apt install -y ${{ matrix.apt-dependencies }} || exit 1 + echo "Adding Toolchain test PPA" + apt install -y software-properties-common + add-apt-repository ppa:ubuntu-toolchain-r/test + + echo "Installing gcc-13" + apt install -y gcc-13 g++-13 || exit 1 + + # Set GCC 13 as default + update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-13 100 --slave /usr/bin/g++ g++ /usr/bin/g++-13 --slave /usr/bin/gcov gcov /usr/bin/gcov-13 + else # macOS echo "Downloading MacOSX10.12 SDK.." curl --progress-bar -L -o macOS_SDK10.12.4.tar.xz "https://data.moto9000.moe/overte_packages/macOS_SDK10.12.4.tar.xz" || exit 1 diff --git a/cmake/ports/hifi-client-deps/CONTROL b/cmake/ports/hifi-client-deps/CONTROL index afee6a5d487..c951768a4d9 100644 --- a/cmake/ports/hifi-client-deps/CONTROL +++ b/cmake/ports/hifi-client-deps/CONTROL @@ -1,4 +1,4 @@ Source: hifi-client-deps Version: 0.1 Description: Collected dependencies for High Fidelity applications -Build-Depends: hifi-deps, aristo (windows), glslang, liblo (windows), nlohmann-json, openvr ((linux&!arm)|windows), quazip (!android), sdl2 (!android), spirv-cross (!android), spirv-tools (!android), sranipal (windows), vulkanmemoryallocator, discord-rpc (!android) +Build-Depends: hifi-deps, aristo (windows), glslang, liblo (windows), nlohmann-json, openvr ((linux&!arm)|windows), openxr-loader, quazip (!android), sdl2 (!android), spirv-cross (!android), spirv-tools (!android), sranipal (windows), vulkanmemoryallocator, discord-rpc (!android) diff --git a/cmake/ports/jsoncpp/portfile.cmake b/cmake/ports/jsoncpp/portfile.cmake new file mode 100644 index 00000000000..e257f7637da --- /dev/null +++ b/cmake/ports/jsoncpp/portfile.cmake @@ -0,0 +1,34 @@ +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO open-source-parsers/jsoncpp + REF "${VERSION}" + SHA512 1d06e044759b1e1a4cc4960189dd7e001a0a4389d7239a6d59295af995a553518e4e0337b4b4b817e70da5d9731a4c98655af90791b6287870b5ff8d73ad8873 + HEAD_REF master +) + +string(COMPARE EQUAL "${VCPKG_LIBRARY_LINKAGE}" "static" JSONCPP_STATIC) +string(COMPARE EQUAL "${VCPKG_CRT_LINKAGE}" "static" STATIC_CRT) + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + OPTIONS + -DJSONCPP_WITH_CMAKE_PACKAGE=ON + -DBUILD_STATIC_LIBS=${JSONCPP_STATIC} + -DJSONCPP_STATIC_WINDOWS_RUNTIME=${STATIC_CRT} + -DJSONCPP_WITH_PKGCONFIG_SUPPORT=ON + -DJSONCPP_WITH_POST_BUILD_UNITTEST=OFF + -DJSONCPP_WITH_TESTS=OFF + -DJSONCPP_WITH_EXAMPLE=OFF + -DBUILD_OBJECT_LIBS=OFF +) + +vcpkg_cmake_install() + +vcpkg_cmake_config_fixup(CONFIG_PATH lib/cmake/jsoncpp) + +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") + +vcpkg_copy_pdbs() +vcpkg_fixup_pkgconfig() + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") diff --git a/cmake/ports/jsoncpp/vcpkg.json b/cmake/ports/jsoncpp/vcpkg.json new file mode 100644 index 00000000000..878449f0af7 --- /dev/null +++ b/cmake/ports/jsoncpp/vcpkg.json @@ -0,0 +1,18 @@ +{ + "name": "jsoncpp", + "version": "1.9.5", + "port-version": 4, + "description": "JsonCpp is a C++ library that allows manipulating JSON values, including serialization and deserialization to and from strings. It can also preserve existing comment in unserialization/serialization steps, making it a convenient format to store user input files.", + "homepage": "https://github.com/open-source-parsers/jsoncpp", + "license": "MIT", + "dependencies": [ + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + } + ] +} diff --git a/cmake/ports/openxr-loader/fix-jinja2.patch b/cmake/ports/openxr-loader/fix-jinja2.patch new file mode 100644 index 00000000000..5d77cb4e461 --- /dev/null +++ b/cmake/ports/openxr-loader/fix-jinja2.patch @@ -0,0 +1,23 @@ +From d80c7dc3f4810fc49e4444590d39ef71e8a9b01c Mon Sep 17 00:00:00 2001 +From: Adam Johnson +Date: Sat, 19 Feb 2022 19:42:31 -0500 +Subject: [PATCH] Fix bad import in jinja2 + +--- + external/python/jinja2/utils.py | 6 +++++- + 1 file changed, 5 insertions(+), 1 deletion(-) + +diff --git a/external/python/jinja2/utils.py b/external/python/jinja2/utils.py +index db9c5d06..f198e3ef 100644 +--- a/external/python/jinja2/utils.py ++++ b/external/python/jinja2/utils.py +@@ -639,4 +639,8 @@ def __repr__(self): + + + # Imported here because that's where it was in the past +-from markupsafe import Markup, escape, soft_unicode ++from markupsafe import Markup, escape ++try: ++ from markupsafe import soft_unicode ++except ImportError: ++ from markupsafe import soft_str as soft_unicode diff --git a/cmake/ports/openxr-loader/fix-openxr-sdk-jsoncpp.patch b/cmake/ports/openxr-loader/fix-openxr-sdk-jsoncpp.patch new file mode 100644 index 00000000000..758d55e0f83 --- /dev/null +++ b/cmake/ports/openxr-loader/fix-openxr-sdk-jsoncpp.patch @@ -0,0 +1,30 @@ +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index c75b145..386494c 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -89,7 +89,7 @@ if(NOT VULKAN_INCOMPATIBLE) + endif() + + find_package(Threads REQUIRED) +-find_package(JsonCpp) ++find_package(jsoncpp CONFIG REQUIRED) + + ### All options defined here + option(BUILD_LOADER "Build loader" ON) +diff --git a/src/loader/CMakeLists.txt b/src/loader/CMakeLists.txt +index 6a88cf4..0821a3d 100644 +--- a/src/loader/CMakeLists.txt ++++ b/src/loader/CMakeLists.txt +@@ -68,7 +68,11 @@ add_library(openxr_loader ${LIBRARY_TYPE} + ${openxr_loader_RESOURCE_FILE} + ) + if(BUILD_WITH_SYSTEM_JSONCPP) +- target_link_libraries(openxr_loader PRIVATE JsonCpp::JsonCpp) ++ if(BUILD_SHARED_LIBS) ++ target_link_libraries(openxr_loader PRIVATE jsoncpp_lib) ++ else() ++ target_link_libraries(openxr_loader PRIVATE jsoncpp_static) ++ endif() + else() + target_sources(openxr_loader + PRIVATE diff --git a/cmake/ports/openxr-loader/portfile.cmake b/cmake/ports/openxr-loader/portfile.cmake new file mode 100644 index 00000000000..4d1127e56e6 --- /dev/null +++ b/cmake/ports/openxr-loader/portfile.cmake @@ -0,0 +1,79 @@ + +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO KhronosGroup/OpenXR-SDK + REF "release-${VERSION}" + SHA512 6efc7596e707f95366dbcdbac9bd7d0c20735a2175b4edf56a9e8a112cf0ab8b664069fe942313164a37119032ddbf5671bc88ab5f276005dd36e4a4dabba1c7 + HEAD_REF master + PATCHES + fix-openxr-sdk-jsoncpp.patch +) + +vcpkg_from_github( + OUT_SOURCE_PATH SDK_SOURCE_PATH + REPO KhronosGroup/OpenXR-SDK-Source + REF "release-${VERSION}" + SHA512 04bdb0f16078209b5edd175a3396f70e1ceb8cfa382c65b8fda388e565480e3844daf68e0d987e72ed8c21d3148af0b41a2170911ec1660565887e0e5ae6d2bf + HEAD_REF master + PATCHES + fix-openxr-sdk-jsoncpp.patch + fix-jinja2.patch +) + +vcpkg_from_github( + OUT_SOURCE_PATH HPP_SOURCE_PATH + REPO KhronosGroup/OpenXR-hpp + REF 63db9919822f8af6f7bf7416ba6a015d4617202e + SHA512 9e768f485d1631f8e74f35f028a64e2d64e33d362c53ae1c54427a10786e3befdd24089927319aa1a4b4c3e010247bd6cb3394bcee460c467c637ab6bc7bec90 + HEAD_REF master + PATCHES + python3_8_compatibility.patch +) + +# Weird behavior inside the OpenXR loader. On Windows they force shared libraries to use static crt, and +# vice-versa. Might be better in future iterations to patch the CMakeLists.txt for OpenXR +if (VCPKG_TARGET_IS_UWP OR VCPKG_TARGET_IS_WINDOWS) + if(VCPKG_LIBRARY_LINKAGE STREQUAL static) + set(DYNAMIC_LOADER OFF) + set(VCPKG_CRT_LINKAGE dynamic) + else() + set(DYNAMIC_LOADER ON) + set(VCPKG_CRT_LINKAGE static) + endif() +endif() + +vcpkg_find_acquire_program(PYTHON3) + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + OPTIONS + -DBUILD_API_LAYERS=OFF + -DBUILD_TESTS=OFF + -DBUILD_CONFORMANCE_TESTS=OFF + -DDYNAMIC_LOADER=${DYNAMIC_LOADER} + -DPYTHON_EXECUTABLE="${PYTHON3}" + -DBUILD_WITH_SYSTEM_JSONCPP=ON +) + +vcpkg_cmake_install() + +# Generate the OpenXR C++ bindings +set(ENV{OPENXR_REPO} "${SDK_SOURCE_PATH}") +vcpkg_execute_required_process( + COMMAND ${PYTHON3} "${HPP_SOURCE_PATH}/scripts/hpp_genxr.py" -quiet -registry "${SDK_SOURCE_PATH}/specification/registry/xr.xml" -o "${CURRENT_PACKAGES_DIR}/include/openxr" + WORKING_DIRECTORY "${HPP_SOURCE_PATH}" + LOGNAME "openxr-hpp" +) + +if(VCPKG_TARGET_IS_WINDOWS) + vcpkg_cmake_config_fixup(PACKAGE_NAME OpenXR CONFIG_PATH cmake) +else() + vcpkg_cmake_config_fixup(PACKAGE_NAME OpenXR CONFIG_PATH lib/cmake/openxr) +endif() + +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/share") + +vcpkg_fixup_pkgconfig() +vcpkg_copy_pdbs() +file(INSTALL "${SOURCE_PATH}/LICENSE" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}" RENAME copyright) diff --git a/cmake/ports/openxr-loader/python3_8_compatibility.patch b/cmake/ports/openxr-loader/python3_8_compatibility.patch new file mode 100644 index 00000000000..657bb2b7ab8 --- /dev/null +++ b/cmake/ports/openxr-loader/python3_8_compatibility.patch @@ -0,0 +1,13 @@ +diff --git a/scripts/hpp_genxr.py b/scripts/hpp_genxr.py +index ce419b0..23e1d3d 100644 +--- a/scripts/hpp_genxr.py ++++ b/scripts/hpp_genxr.py +@@ -36,7 +36,7 @@ from xrconventions import OpenXRConventions + from data import EXCLUDED_EXTENSIONS + + +-def makeREstring(strings: Iterable[str], default: typing.Optional[str] = None) -> str: ++def makeREstring(strings, default: typing.Optional[str] = None) -> str: + """Turn a list of strings into a regexp string matching exactly those strings.""" + if strings or default is None: + return f"^({'|'.join(re.escape(s) for s in strings)})$" diff --git a/cmake/ports/openxr-loader/vcpkg.json b/cmake/ports/openxr-loader/vcpkg.json new file mode 100644 index 00000000000..a45e3c9199f --- /dev/null +++ b/cmake/ports/openxr-loader/vcpkg.json @@ -0,0 +1,27 @@ +{ + "name": "openxr-loader", + "version": "1.0.31", + "description": "A royalty-free, open standard that provides high-performance access to Augmented Reality (AR) and Virtual Reality (VR)—collectively known as XR—platforms and devices", + "homepage": "https://github.com/KhronosGroup/OpenXR-SDK", + "license": "Apache-2.0", + "supports": "!uwp & !osx", + "dependencies": [ + "jsoncpp", + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + } + ], + "features": { + "vulkan": { + "description": "Vulkan functionality for OpenXR", + "dependencies": [ + "vulkan" + ] + } + } +} diff --git a/interface/resources/controllers/openxr_index.json b/interface/resources/controllers/openxr_index.json new file mode 100644 index 00000000000..44e1ae0a97d --- /dev/null +++ b/interface/resources/controllers/openxr_index.json @@ -0,0 +1,35 @@ +{ + "name": "OpenXR Index to Actions", + "channels": [ + { "from": "Index.LeftHand", "to": "Standard.LeftHand" }, + { "from": "Index.RightHand", "to": "Standard.RightHand" }, + { "from": "Index.Head", "to" : "Standard.Head", "when" : [ "Application.InHMD"] }, + + { "from": "Index.LeftPrimaryThumb", "to": "Actions.Down" }, + { "from": "Index.LeftPrimaryThumbTouch", "to": "Standard.LeftPrimaryThumbTouch" }, + { "from": "Index.LeftSecondaryThumb", "to": "Actions.ContextMenu" }, + { "from": "Index.LeftSecondaryThumbTouch", "to": "Standard.LeftSecondaryThumbTouch" }, + + { "from": "Index.RightPrimaryThumb", "to": "Actions.Up" }, + { "from": "Index.RightPrimaryThumbTouch", "to": "Standard.RightPrimaryThumbTouch" }, + { "from": "Index.RightSecondaryThumb", "to": "Actions.Sprint" }, + { "from": "Index.RightSecondaryThumbTouch", "to": "Standard.RightSecondaryThumbTouch" }, + + { "from": "Index.LY", "to": "Actions.TranslateZ", "filters": ["invert"] }, + { "from": "Index.LX", "to": "Actions.TranslateX" }, + { "from": "Index.RY", "to": "Standard.RY" }, + { "from": "Index.RX", "to": "Standard.RX" }, + { "from": "Index.LS", "to": "Standard.LS" }, + { "from": "Index.RS", "to": "Actions.CycleCamera" }, + { "from": "Index.LSTouch", "to": "Standard.LSTouch" }, + { "from": "Index.RSTouch", "to": "Standard.RSTouch" }, + + { "from": "Index.RT", "to": "Standard.RT" }, + { "from": "Index.LT", "to": "Standard.LT" }, + { "from": "Index.RTClick", "to": "Standard.RTClick" }, + { "from": "Index.LTClick", "to": "Standard.LTClick" }, + + { "from": "Index.LeftPrimaryIndexTouch", "to": "Standard.LeftPrimaryIndexTouch" }, + { "from": "Index.RightPrimaryIndexTouch", "to": "Standard.RightPrimaryIndexTouch" } + ] +} diff --git a/interface/src/Application.cpp b/interface/src/Application.cpp index f630bea8637..dec19a6c2f7 100644 --- a/interface/src/Application.cpp +++ b/interface/src/Application.cpp @@ -6942,51 +6942,40 @@ void Application::updateRenderArgs(float deltaTime) { appRenderArgs._eyeToWorld = _myCamera.getTransform(); appRenderArgs._isStereo = false; - { + if (getActiveDisplayPlugin()->isStereo()) { auto hmdInterface = DependencyManager::get(); - float ipdScale = hmdInterface->getIPDScale(); - // scale IPD by sensorToWorldScale, to make the world seem larger or smaller accordingly. - ipdScale *= sensorToWorldScale; + float ipdScale = hmdInterface->getIPDScale() * sensorToWorldScale; auto baseProjection = appRenderArgs._renderArgs.getViewFrustum().getProjection(); - if (getActiveDisplayPlugin()->isStereo()) { - // Stereo modes will typically have a larger projection matrix overall, - // so we ask for the 'mono' projection matrix, which for stereo and HMD - // plugins will imply the combined projection for both eyes. - // - // This is properly implemented for the Oculus plugins, but for OpenVR - // and Stereo displays I'm not sure how to get / calculate it, so we're - // just relying on the left FOV in each case and hoping that the - // overall culling margin of error doesn't cause popping in the - // right eye. There are FIXMEs in the relevant plugins - _myCamera.setProjection(getActiveDisplayPlugin()->getCullingProjection(baseProjection)); - appRenderArgs._isStereo = true; - - auto& eyeOffsets = appRenderArgs._eyeOffsets; - auto& eyeProjections = appRenderArgs._eyeProjections; - - // FIXME we probably don't need to set the projection matrix every frame, - // only when the display plugin changes (or in non-HMD modes when the user - // changes the FOV manually, which right now I don't think they can. - for_each_eye([&](Eye eye) { - // For providing the stereo eye views, the HMD head pose has already been - // applied to the avatar, so we need to get the difference between the head - // pose applied to the avatar and the per eye pose, and use THAT as - // the per-eye stereo matrix adjustment. - mat4 eyeToHead = getActiveDisplayPlugin()->getEyeToHeadTransform(eye); - // Grab the translation - vec3 eyeOffset = glm::vec3(eyeToHead[3]); - // Apply IPD scaling - mat4 eyeOffsetTransform = glm::translate(mat4(), eyeOffset * -1.0f * ipdScale); - eyeOffsets[eye] = eyeOffsetTransform; - eyeProjections[eye] = getActiveDisplayPlugin()->getEyeProjection(eye, baseProjection); - }); + // Stereo modes will typically have a larger projection matrix overall, + // so we ask for the 'mono' projection matrix, which for stereo and HMD + // plugins will imply the combined projection for both eyes. + // + // This is properly implemented for the Oculus plugins, but for OpenVR + // and Stereo displays I'm not sure how to get / calculate it, so we're + // just relying on the left FOV in each case and hoping that the + // overall culling margin of error doesn't cause popping in the + // right eye. There are FIXMEs in the relevant plugins + _myCamera.setProjection(getActiveDisplayPlugin()->getCullingProjection(baseProjection)); + appRenderArgs._isStereo = true; + + auto& eyeOffsets = appRenderArgs._eyeOffsets; + auto& eyeProjections = appRenderArgs._eyeProjections; + + // FIXME we probably don't need to set the projection matrix every frame, + // only when the display plugin changes (or in non-HMD modes when the user + // changes the FOV manually, which right now I don't think they can. + for_each_eye([&](Eye eye) { + eyeOffsets[eye] = getActiveDisplayPlugin()->getEyeToHeadTransform(eye); + // Apply IPD scaling + eyeOffsets[eye][3][0] *= ipdScale; + eyeProjections[eye] = getActiveDisplayPlugin()->getEyeProjection(eye, baseProjection); + }); - // Configure the type of display / stereo - appRenderArgs._renderArgs._displayMode = (isHMDMode() ? RenderArgs::STEREO_HMD : RenderArgs::STEREO_MONITOR); - } + // Configure the type of display / stereo + appRenderArgs._renderArgs._displayMode = (isHMDMode() ? RenderArgs::STEREO_HMD : RenderArgs::STEREO_MONITOR); } appRenderArgs._renderArgs._stencilMaskMode = getActiveDisplayPlugin()->getStencilMaskMode(); diff --git a/libraries/controllers/src/controllers/UserInputMapper.cpp b/libraries/controllers/src/controllers/UserInputMapper.cpp index 40c388ac745..e2314c56d9e 100644 --- a/libraries/controllers/src/controllers/UserInputMapper.cpp +++ b/libraries/controllers/src/controllers/UserInputMapper.cpp @@ -1195,9 +1195,9 @@ Mapping::Pointer UserInputMapper::parseMapping(const QString& json) { QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8(), &error); // check validity of the document if (doc.isNull()) { - qCDebug(controllers) << "Invalid JSON...\n"; - qCDebug(controllers) << error.errorString(); - qCDebug(controllers) << "JSON was:\n" << json << Qt::endl; + qCCritical(controllers) << "Invalid JSON...\n"; + qCCritical(controllers) << error.errorString(); + qCCritical(controllers) << "JSON was:\n" << json << Qt::endl; return Mapping::Pointer(); } diff --git a/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.cpp b/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.cpp index 6ea10f6327a..93f1b933d82 100644 --- a/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.cpp +++ b/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.cpp @@ -746,6 +746,13 @@ void OpenGLDisplayPlugin::present(const std::shared_ptr& } gpu::Backend::freeGPUMemSize.set(gpu::gl::getFreeDedicatedMemory()); + + // Drop current frame after presenting it once. + // This is required for the OpenXR frame cycle, since we call xrEndFrame after presenting. + // xrEndFrame must not be called multiple times. + if (_presentOnlyOnce) { + _currentFrame.reset(); + } } else if (alwaysPresent()) { refreshRateController->clockEndTime(); internalPresent(); diff --git a/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.h b/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.h index 0df0d9ac3e8..a7ce2180f21 100644 --- a/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.h +++ b/libraries/display-plugins/src/display-plugins/OpenGLDisplayPlugin.h @@ -206,6 +206,8 @@ class OpenGLDisplayPlugin : public DisplayPlugin { QImage getScreenshot(float aspectRatio); QImage getSecondaryCameraScreenshot(); + bool _presentOnlyOnce = false; + private: static Setting::Handle _extraLinearToSRGBConversionSetting; }; diff --git a/libraries/display-plugins/src/display-plugins/hmd/HmdDisplayPlugin.cpp b/libraries/display-plugins/src/display-plugins/hmd/HmdDisplayPlugin.cpp index 2493f074365..615827f1040 100644 --- a/libraries/display-plugins/src/display-plugins/hmd/HmdDisplayPlugin.cpp +++ b/libraries/display-plugins/src/display-plugins/hmd/HmdDisplayPlugin.cpp @@ -56,16 +56,16 @@ QRect HmdDisplayPlugin::getRecommendedHUDRect() const { return CompositorHelper::VIRTUAL_SCREEN_RECOMMENDED_OVERLAY_RECT; } -glm::mat4 HmdDisplayPlugin::getEyeToHeadTransform(Eye eye) const { - return _eyeOffsets[eye]; +glm::mat4 HmdDisplayPlugin::getEyeToHeadTransform(Eye eye) const { + return _eyeOffsets[eye]; } -glm::mat4 HmdDisplayPlugin::getEyeProjection(Eye eye, const glm::mat4& baseProjection) const { - return _eyeProjections[eye]; +glm::mat4 HmdDisplayPlugin::getEyeProjection(Eye eye, const glm::mat4& baseProjection) const { + return _eyeProjections[eye]; } -glm::mat4 HmdDisplayPlugin::getCullingProjection(const glm::mat4& baseProjection) const { - return _cullingProjection; +glm::mat4 HmdDisplayPlugin::getCullingProjection(const glm::mat4& baseProjection) const { + return _cullingProjection; } glm::ivec4 HmdDisplayPlugin::eyeViewport(Eye eye) const { diff --git a/libraries/gpu/src/gpu/Context.cpp b/libraries/gpu/src/gpu/Context.cpp index 8dee120555c..e5d1f87cd49 100644 --- a/libraries/gpu/src/gpu/Context.cpp +++ b/libraries/gpu/src/gpu/Context.cpp @@ -238,10 +238,14 @@ const Backend::TransformCamera& Backend::TransformCamera::recomputeDerived(const Backend::TransformCamera Backend::TransformCamera::getEyeCamera(int eye, const StereoState& _stereo, const Transform& xformView, Vec2 normalizedJitter) const { TransformCamera result = *this; Transform offsetTransform = xformView; - if (!_stereo._skybox) { - offsetTransform.postTranslate(-Vec3(_stereo._eyeViews[eye][3])); + glm::vec3 eyePosition = extractTranslation(_stereo._eyeViews[eye]); + glm::quat eyeOrientation = glmExtractRotation(_stereo._eyeViews[eye]); + if (!_stereo._skybox) + { + offsetTransform.postRotate(eyeOrientation).postTranslate(eyePosition); } else { // FIXME: If "skybox" the ipd is set to 0 for now, let s try to propose a better solution for this in the future + offsetTransform.postRotate(eyeOrientation); } result._projection = _stereo._eyeProjections[eye]; normalizedJitter.x *= 2.0f; diff --git a/libraries/shared/src/GLMHelpers.h b/libraries/shared/src/GLMHelpers.h index cfb4bb63983..c74820e4a05 100644 --- a/libraries/shared/src/GLMHelpers.h +++ b/libraries/shared/src/GLMHelpers.h @@ -212,9 +212,11 @@ T toNormalizedDeviceScale(const T& value, const T& size) { #define ROLL(euler) euler.z // float - linear interpolate +#if !defined(DONT_REDEFINE_LERP) inline float lerp(float x, float y, float a) { return x * (1.0f - a) + (y * a); } +#endif // vec2 lerp - linear interpolate template diff --git a/libraries/shared/src/shared/Camera.h b/libraries/shared/src/shared/Camera.h index 1c3de2b8e95..54dfea77cf8 100644 --- a/libraries/shared/src/shared/Camera.h +++ b/libraries/shared/src/shared/Camera.h @@ -267,11 +267,11 @@ public slots: CameraMode _mode{ CAMERA_MODE_LOOK_AT }; glm::mat4 _transform; - glm::mat4 _projection; + glm::mat4 _projection = glm::mat4(1.f); // derived glm::vec3 _position { 0.0f, 0.0f, 0.0f }; - glm::quat _orientation; + glm::quat _orientation { 1.f, 0.f, 0.f, 0.f }; bool _isKeepLookingAt{ false }; glm::vec3 _lookingAt; diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index c68abefa770..5e22dfc41db 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -26,6 +26,9 @@ if (NOT SERVER_ONLY AND NOT ANDROID) add_subdirectory(${DIR}) endif() + set(DIR "openxr") + add_subdirectory(${DIR}) + set(DIR "hifiSdl2") add_subdirectory(${DIR}) diff --git a/plugins/openxr/CMakeLists.txt b/plugins/openxr/CMakeLists.txt new file mode 100644 index 00000000000..847a41880ea --- /dev/null +++ b/plugins/openxr/CMakeLists.txt @@ -0,0 +1,31 @@ +# +# Copyright 2024 Lubosz Sarnecki +# Copyright 2024 Overte e.V. +# +# SPDX-License-Identifier: Apache-2.0 +# + +find_package(OpenXR REQUIRED) +if (NOT OpenXR_FOUND) + MESSAGE(FATAL_ERROR "OpenXR not found!") +endif() + +set(TARGET_NAME openxr) +setup_hifi_plugin(Gui Qml Multimedia) +link_hifi_libraries(shared task gl qml networking controllers ui + plugins display-plugins ui-plugins input-plugins + audio-client render-utils graphics shaders gpu render + material-networking model-networking model-baker hfm + model-serializers ktx image procedural ${PLATFORM_GL_BACKEND} OpenXR::openxr_loader) +include_hifi_library_headers(octree) +include_hifi_library_headers(script-engine) + +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU") + # Silence GCC warnings + target_compile_options(openxr PRIVATE -Wno-missing-field-initializers) + + # Fix build issue where lerp is already defined on C++20 / GCC + target_compile_definitions(openxr PRIVATE -DDONT_REDEFINE_LERP) +endif() + +set_property(TARGET openxr PROPERTY CXX_STANDARD 20) \ No newline at end of file diff --git a/plugins/openxr/src/OpenXrContext.cpp b/plugins/openxr/src/OpenXrContext.cpp new file mode 100644 index 00000000000..2664b49bcf0 --- /dev/null +++ b/plugins/openxr/src/OpenXrContext.cpp @@ -0,0 +1,404 @@ +// +// Overte OpenXR Plugin +// +// Copyright 2024 Lubosz Sarnecki +// Copyright 2024 Overte e.V. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#include "OpenXrContext.h" +#include + +#include + +Q_DECLARE_LOGGING_CATEGORY(xr_context_cat) +Q_LOGGING_CATEGORY(xr_context_cat, "openxr.context") + +// Checks XrResult, returns false on errors and logs the error as qCritical. +bool xrCheck(XrInstance instance, XrResult result, const char* message) { + if (XR_SUCCEEDED(result)) + return true; + + char errorName[XR_MAX_RESULT_STRING_SIZE]; + if (instance != XR_NULL_HANDLE) { + xrResultToString(instance, result, errorName); + } else { + sprintf(errorName, "%d", result); + } + + qCCritical(xr_context_cat, "%s: %s", errorName, message); + + return false; +} + +// Extension functions must be loaded with xrGetInstanceProcAddr +static PFN_xrGetOpenGLGraphicsRequirementsKHR pfnGetOpenGLGraphicsRequirementsKHR = nullptr; +static bool initFunctionPointers(XrInstance instance) { + XrResult result = xrGetInstanceProcAddr(instance, "xrGetOpenGLGraphicsRequirementsKHR", + (PFN_xrVoidFunction*)&pfnGetOpenGLGraphicsRequirementsKHR); + return xrCheck(instance, result, "Failed to get OpenGL graphics requirements function!"); +} + +OpenXrContext::OpenXrContext() { + _isSupported = initPreGraphics(); + if (!_isSupported) { + qCWarning(xr_context_cat, "OpenXR is not supported."); + } +} + +OpenXrContext::~OpenXrContext() { + if (_instance == XR_NULL_HANDLE) { + return; + } + XrResult res = xrDestroyInstance(_instance); + if (res != XR_SUCCESS) { + qCCritical(xr_context_cat, "Failed to destroy OpenXR instance"); + } + qCDebug(xr_context_cat, "Destroyed instance."); +} + +bool OpenXrContext::initInstance() { + uint32_t count = 0; + XrResult result = xrEnumerateInstanceExtensionProperties(nullptr, 0, &count, nullptr); + + // Since this is the first OpenXR call we do, check here if RUNTIME_UNAVAILABLE is returned. + if (result == XR_ERROR_RUNTIME_UNAVAILABLE) { + qCCritical(xr_context_cat, "XR_ERROR_RUNTIME_UNAVAILABLE: Is XR_RUNTIME_JSON set correctly?"); + return false; + } + + if (!xrCheck(XR_NULL_HANDLE, result, "Failed to enumerate number of extensions.")) + return false; + + std::vector properties; + for (uint32_t i = 0; i < count; i++) { + XrExtensionProperties props = { .type = XR_TYPE_EXTENSION_PROPERTIES }; + properties.push_back(props); + } + + result = xrEnumerateInstanceExtensionProperties(nullptr, count, &count, properties.data()); + if (!xrCheck(XR_NULL_HANDLE, result, "Failed to enumerate extensions.")) + return false; + + bool openglSupported = false; + + qCInfo(xr_context_cat, "Runtime supports %d extensions:", count); + for (uint32_t i = 0; i < count; i++) { + qCInfo(xr_context_cat, "%s v%d", properties[i].extensionName, properties[i].extensionVersion); + if (strcmp(XR_KHR_OPENGL_ENABLE_EXTENSION_NAME, properties[i].extensionName) == 0) { + openglSupported = true; + } + } + + if (!openglSupported) { + qCCritical(xr_context_cat, "Runtime does not support OpenGL!"); + return false; + } + + std::vector enabled = { XR_KHR_OPENGL_ENABLE_EXTENSION_NAME }; + + XrInstanceCreateInfo info = { + .type = XR_TYPE_INSTANCE_CREATE_INFO, + .applicationInfo = { + .applicationName = "overte", + .applicationVersion = 1, + .engineName = "overte", + .engineVersion = 0, + .apiVersion = XR_CURRENT_API_VERSION, + }, + .enabledExtensionCount = (uint32_t)enabled.size(), + .enabledExtensionNames = enabled.data(), + }; + + result = xrCreateInstance(&info, &_instance); + + if (result == XR_ERROR_RUNTIME_FAILURE) { + qCCritical(xr_context_cat, "XR_ERROR_RUNTIME_FAILURE: Is the OpenXR runtime up and running?"); + return false; + } + + if (!xrCheck(XR_NULL_HANDLE, result, "Failed to create OpenXR instance.")) + return false; + + if (!initFunctionPointers(_instance)) + return false; + + xrStringToPath(_instance, "/user/hand/left", &_handPaths[0]); + xrStringToPath(_instance, "/user/hand/right", &_handPaths[1]); + + return true; +} + +bool OpenXrContext::initSystem() { + XrSystemGetInfo info = { + .type = XR_TYPE_SYSTEM_GET_INFO, + .formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY, + }; + + XrResult result = xrGetSystem(_instance, &info, &_systemId); + if (!xrCheck(_instance, result, "Failed to get system for HMD form factor.")) + return false; + + XrSystemProperties props = { + .type = XR_TYPE_SYSTEM_PROPERTIES, + }; + + result = xrGetSystemProperties(_instance, _systemId, &props); + if (!xrCheck(_instance, result, "Failed to get System properties")) + return false; + + _systemName = QString::fromUtf8(props.systemName); + + qCInfo(xr_context_cat, "System name : %s", props.systemName); + qCInfo(xr_context_cat, "Max layers : %d", props.graphicsProperties.maxLayerCount); + qCInfo(xr_context_cat, "Max swapchain size : %dx%d", props.graphicsProperties.maxSwapchainImageHeight, + props.graphicsProperties.maxSwapchainImageWidth); + qCInfo(xr_context_cat, "Orientation Tracking: %d", props.trackingProperties.orientationTracking); + qCInfo(xr_context_cat, "Position Tracking : %d", props.trackingProperties.positionTracking); + + return true; +} + +bool OpenXrContext::initGraphics() { + XrGraphicsRequirementsOpenGLKHR requirements = { .type = XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_KHR }; + XrResult result = pfnGetOpenGLGraphicsRequirementsKHR(_instance, _systemId, &requirements); + return xrCheck(_instance, result, "Failed to get OpenGL graphics requirements!"); +} + +bool OpenXrContext::requestExitSession() { + XrResult result = xrRequestExitSession(_session); + return xrCheck(_instance, result, "Failed to request exit session!"); +} + +bool OpenXrContext::initSession() { +#if defined(Q_OS_LINUX) + XrGraphicsBindingOpenGLXlibKHR binding = { + .type = XR_TYPE_GRAPHICS_BINDING_OPENGL_XLIB_KHR, + .xDisplay = XOpenDisplay(nullptr), + .glxDrawable = glXGetCurrentDrawable(), + .glxContext = glXGetCurrentContext(), + }; +#elif defined(Q_OS_WIN) + XrGraphicsBindingOpenGLWin32KHR binding = { + .type = XR_TYPE_GRAPHICS_BINDING_OPENGL_WIN32_KHR, + .hDC = wglGetCurrentDC(), + .hGLRC = wglGetCurrentContext(), + }; +#else + #error "Unsupported platform" +#endif + XrSessionCreateInfo info = { + .type = XR_TYPE_SESSION_CREATE_INFO, + .next = &binding, + .systemId = _systemId, + }; + + XrResult result = xrCreateSession(_instance, &info, &_session); + return xrCheck(_instance, result, "Failed to create session"); +} + +bool OpenXrContext::initSpaces() { + // TODO: Do xrEnumerateReferenceSpaces before assuming stage space is available. + XrReferenceSpaceCreateInfo stageSpaceInfo = { + .type = XR_TYPE_REFERENCE_SPACE_CREATE_INFO, + .referenceSpaceType = XR_REFERENCE_SPACE_TYPE_STAGE, + .poseInReferenceSpace = XR_INDENTITY_POSE, + }; + + XrResult result = xrCreateReferenceSpace(_session, &stageSpaceInfo, &_stageSpace); + if (!xrCheck(_instance, result, "Failed to create stage space!")) + return false; + + XrReferenceSpaceCreateInfo viewSpaceInfo = { + .type = XR_TYPE_REFERENCE_SPACE_CREATE_INFO, + .referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW, + .poseInReferenceSpace = XR_INDENTITY_POSE, + }; + + result = xrCreateReferenceSpace(_session, &viewSpaceInfo, &_viewSpace); + return xrCheck(_instance, result, "Failed to create view space!"); +} + +#define ENUM_TO_STR(r) \ + case r: \ + return #r + +static std::string xrSessionStateStr(XrSessionState state) { + switch (state) { + ENUM_TO_STR(XR_SESSION_STATE_UNKNOWN); + ENUM_TO_STR(XR_SESSION_STATE_IDLE); + ENUM_TO_STR(XR_SESSION_STATE_READY); + ENUM_TO_STR(XR_SESSION_STATE_SYNCHRONIZED); + ENUM_TO_STR(XR_SESSION_STATE_VISIBLE); + ENUM_TO_STR(XR_SESSION_STATE_FOCUSED); + ENUM_TO_STR(XR_SESSION_STATE_STOPPING); + ENUM_TO_STR(XR_SESSION_STATE_LOSS_PENDING); + ENUM_TO_STR(XR_SESSION_STATE_EXITING); + default: { + std::ostringstream ss; + ss << "UNKNOWN STATE " << state; + return ss.str(); + } + } +} + +// Called before restarting a new session +void OpenXrContext::reset() { + _shouldQuit = false; + _lastSessionState = XR_SESSION_STATE_UNKNOWN; +} + +bool OpenXrContext::updateSessionState(XrSessionState newState) { + qCDebug(xr_context_cat, "Session state changed %s -> %s", xrSessionStateStr(_lastSessionState).c_str(), + xrSessionStateStr(newState).c_str()); + _lastSessionState = newState; + + switch (newState) { + // Don't run frame cycle but keep polling events + case XR_SESSION_STATE_IDLE: + case XR_SESSION_STATE_UNKNOWN: { + _shouldRunFrameCycle = false; + break; + } + + // Run frame cycle and poll events + case XR_SESSION_STATE_FOCUSED: + case XR_SESSION_STATE_SYNCHRONIZED: + case XR_SESSION_STATE_VISIBLE: { + _shouldRunFrameCycle = true; + break; + } + + // Begin the session + case XR_SESSION_STATE_READY: { + if (!_isSessionRunning) { + XrSessionBeginInfo session_begin_info = { + .type = XR_TYPE_SESSION_BEGIN_INFO, + .primaryViewConfigurationType = XR_VIEW_CONFIG_TYPE, + }; + XrResult result = xrBeginSession(_session, &session_begin_info); + if (!xrCheck(_instance, result, "Failed to begin session!")) + return false; + qCDebug(xr_context_cat, "Session started!"); + _isSessionRunning = true; + } + _shouldRunFrameCycle = true; + break; + } + + // End the session, don't render, but keep polling for events + case XR_SESSION_STATE_STOPPING: { + if (_isSessionRunning) { + XrResult result = xrEndSession(_session); + if (!xrCheck(_instance, result, "Failed to end session!")) + return false; + _isSessionRunning = false; + } + _shouldRunFrameCycle = false; + break; + } + + // Destroy session, skip run frame cycle, quit + case XR_SESSION_STATE_LOSS_PENDING: + case XR_SESSION_STATE_EXITING: { + XrResult result = xrDestroySession(_session); + if (!xrCheck(_instance, result, "Failed to destroy session!")) + return false; + _shouldQuit = true; + _shouldRunFrameCycle = false; + qCDebug(xr_context_cat, "Destroyed session"); + break; + } + default: + qCWarning(xr_context_cat, "Unhandled session state: %d", newState); + } + + return true; +} + +bool OpenXrContext::pollEvents() { + XrEventDataBuffer event = { .type = XR_TYPE_EVENT_DATA_BUFFER }; + XrResult result = xrPollEvent(_instance, &event); + while (result == XR_SUCCESS) { + switch (event.type) { + case XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING: { + XrEventDataInstanceLossPending* instanceLossPending = (XrEventDataInstanceLossPending*)&event; + qCCritical(xr_context_cat, "Instance loss pending at %lu! Destroying instance.", instanceLossPending->lossTime); + _shouldQuit = true; + continue; + } + case XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED: { + XrEventDataSessionStateChanged* sessionStateChanged = (XrEventDataSessionStateChanged*)&event; + if (!updateSessionState(sessionStateChanged->state)) { + return false; + } + break; + } + case XR_TYPE_EVENT_DATA_INTERACTION_PROFILE_CHANGED: { + for (int i = 0; i < HAND_COUNT; i++) { + XrInteractionProfileState state = { .type = XR_TYPE_INTERACTION_PROFILE_STATE }; + XrResult res = xrGetCurrentInteractionProfile(_session, _handPaths[i], &state); + if (!xrCheck(_instance, res, "Failed to get interaction profile")) + continue; + + uint32_t bufferCountOutput; + char profilePath[XR_MAX_PATH_LENGTH]; + res = xrPathToString(_instance, state.interactionProfile, XR_MAX_PATH_LENGTH, &bufferCountOutput, + profilePath); + if (!xrCheck(_instance, res, "Failed to get interaction profile path.")) + continue; + + qCInfo(xr_context_cat, "Controller %d: Interaction profile changed to '%s'", i, profilePath); + } + break; + } + default: + qCWarning(xr_context_cat, "Unhandled event type %d", event.type); + } + + event.type = XR_TYPE_EVENT_DATA_BUFFER; + result = xrPollEvent(_instance, &event); + } + + if (result != XR_EVENT_UNAVAILABLE) { + qCCritical(xr_context_cat, "Failed to poll events!"); + return false; + } + + return true; +} + +bool OpenXrContext::beginFrame() { + XrFrameBeginInfo info = { .type = XR_TYPE_FRAME_BEGIN_INFO }; + XrResult result = xrBeginFrame(_session, &info); + return xrCheck(_instance, result, "failed to begin frame!"); +} + +bool OpenXrContext::initPreGraphics() { + if (!initInstance()) { + return false; + } + + if (!initSystem()) { + return false; + } + + return true; +} + +bool OpenXrContext::initPostGraphics() { + if (!initGraphics()) { + return false; + } + + if (!initSession()) { + return false; + } + + if (!initSpaces()) { + return false; + } + + return true; +} diff --git a/plugins/openxr/src/OpenXrContext.h b/plugins/openxr/src/OpenXrContext.h new file mode 100644 index 00000000000..82baf098f50 --- /dev/null +++ b/plugins/openxr/src/OpenXrContext.h @@ -0,0 +1,104 @@ +// +// Overte OpenXR Plugin +// +// Copyright 2024 Lubosz Sarnecki +// Copyright 2024 Overte e.V. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#pragma once + +#include + +#include + +#include "gpu/gl/GLBackend.h" + +#if defined(Q_OS_LINUX) + #define XR_USE_PLATFORM_XLIB + #include + // Unsorted from glx.h conflicts with qdir.h + #undef Unsorted + // MappingPointer from X11 conflicts with one from controllers/Forward.h + #undef MappingPointer +#elif defined(Q_OS_WIN) + #define XR_USE_PLATFORM_WIN32 + #include + #include +#else + #error "Unimplemented platform" +#endif + + +#define XR_USE_GRAPHICS_API_OPENGL +#include + +#include +#include + +#include "controllers/Pose.h" + +#define HAND_COUNT 2 + +constexpr XrPosef XR_INDENTITY_POSE = { + .orientation = { .x = 0, .y = 0, .z = 0, .w = 1.0 }, + .position = { .x = 0, .y = 0, .z = 0 }, +}; + +constexpr XrViewConfigurationType XR_VIEW_CONFIG_TYPE = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + +class OpenXrContext { +public: + XrInstance _instance = XR_NULL_HANDLE; + XrSession _session = XR_NULL_HANDLE; + XrSystemId _systemId = XR_NULL_SYSTEM_ID; + + XrSpace _stageSpace = XR_NULL_HANDLE; + XrSpace _viewSpace = XR_NULL_HANDLE; + XrPath _handPaths[HAND_COUNT]; + + controller::Pose _lastHeadPose; + std::optional _lastPredictedDisplayTime; + + bool _shouldQuit = false; + bool _shouldRunFrameCycle = false; + + bool _isSupported = false; + + QString _systemName; + bool _isSessionRunning = false; + +private: + XrSessionState _lastSessionState = XR_SESSION_STATE_UNKNOWN; + +public: + OpenXrContext(); + ~OpenXrContext(); + + bool initPostGraphics(); + bool beginFrame(); + bool pollEvents(); + bool requestExitSession(); + void reset(); + +private: + bool initPreGraphics(); + bool initInstance(); + bool initSystem(); + bool initGraphics(); + bool initSession(); + bool initSpaces(); + + bool updateSessionState(XrSessionState newState); +}; + +inline static glm::vec3 xrVecToGlm(const XrVector3f& v) { + return glm::vec3(v.x, v.y, v.z); +} + +inline static glm::quat xrQuatToGlm(const XrQuaternionf& q) { + return glm::quat(q.w, q.x, q.y, q.z); +} + +bool xrCheck(XrInstance instance, XrResult result, const char* message); \ No newline at end of file diff --git a/plugins/openxr/src/OpenXrDisplayPlugin.cpp b/plugins/openxr/src/OpenXrDisplayPlugin.cpp new file mode 100644 index 00000000000..29843bddebc --- /dev/null +++ b/plugins/openxr/src/OpenXrDisplayPlugin.cpp @@ -0,0 +1,554 @@ +// +// Overte OpenXR Plugin +// +// Copyright 2024 Lubosz Sarnecki +// Copyright 2024 Overte e.V. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#include "OpenXrDisplayPlugin.h" +#include + +#include "ViewFrustum.h" + +#include +#include +#include +#include +#include + +#if defined(Q_OS_WIN) +#undef near +#undef far +#endif + +Q_DECLARE_LOGGING_CATEGORY(xr_display_cat) +Q_LOGGING_CATEGORY(xr_display_cat, "openxr.display") + +constexpr GLint XR_PREFERRED_COLOR_FORMAT = GL_SRGB8_ALPHA8; + +OpenXrDisplayPlugin::OpenXrDisplayPlugin(std::shared_ptr c) { + _context = c; + _presentOnlyOnce = true; +} + +bool OpenXrDisplayPlugin::isSupported() const { + return _context->_isSupported; +} + +// Slightly differs from glm::ortho +inline static glm::mat4 fovToProjection(const XrFovf fov, const float near, const float far) { + const float left = tanf(fov.angleLeft); + const float right = tanf(fov.angleRight); + const float down = tanf(fov.angleDown); + const float up = tanf(fov.angleUp); + + const float width = right - left; + const float height = up - down; + + const float m11 = 2 / width; + const float m22 = 2 / height; + const float m33 = -(far + near) / (far - near); + + const float m31 = (right + left) / width; + const float m32 = (up + down) / height; + const float m43 = -(far * (near + near)) / (far - near); + + // clang-format off + const float mat[16] = { + m11, 0 , 0 , 0, + 0 , m22, 0 , 0, + m31, m32, m33, -1, + 0 , 0 , m43, 0, + }; + // clang-format on + + return glm::make_mat4(mat); +} + +glm::mat4 OpenXrDisplayPlugin::getEyeProjection(Eye eye, const glm::mat4& baseProjection) const { + if (!_views.has_value()) { + return baseProjection; + } + + ViewFrustum frustum; + frustum.setProjection(baseProjection); + return fovToProjection(_views.value()[(eye == Left) ? 0 : 1].fov, frustum.getNearClip(), frustum.getFarClip()); +} + +// TODO: This apparently wasn't right in the OpenVR plugin, but this is what it basically did. +glm::mat4 OpenXrDisplayPlugin::getCullingProjection(const glm::mat4& baseProjection) const { + return getEyeProjection(Left, baseProjection); +} + +// TODO: This should not be explicilty known by the application. +// Let's just render as fast as we can and OpenXR will dictate the pace. +float OpenXrDisplayPlugin::getTargetFrameRate() const { + return std::numeric_limits::max(); +} + +bool OpenXrDisplayPlugin::initViews() { + XrInstance instance = _context->_instance; + XrSystemId systemId = _context->_systemId; + + XrResult result = xrEnumerateViewConfigurationViews(instance, systemId, XR_VIEW_CONFIG_TYPE, 0, &_viewCount, nullptr); + if (!xrCheck(instance, result, "Failed to get view configuration view count!")) { + qCCritical(xr_display_cat, "Failed to get view configuration view count!"); + return false; + } + + assert(_viewCount != 0); + + _views = std::vector(); + + for (uint32_t i = 0; i < _viewCount; i++) { + XrView view = { .type = XR_TYPE_VIEW }; + _views.value().push_back(view); + + XrViewConfigurationView viewConfig = { .type = XR_TYPE_VIEW_CONFIGURATION_VIEW }; + _viewConfigs.push_back(viewConfig); + } + + _swapChains.resize(_viewCount); + _swapChainLengths.resize(_viewCount); + _swapChainIndices.resize(_viewCount); + _images.resize(_viewCount); + + result = xrEnumerateViewConfigurationViews(instance, systemId, XR_VIEW_CONFIG_TYPE, _viewCount, &_viewCount, + _viewConfigs.data()); + if (!xrCheck(instance, result, "Failed to enumerate view configuration views!")) { + qCCritical(xr_display_cat, "Failed to enumerate view configuration views!"); + return false; + } + + return true; +} + +#define ENUM_TO_STR(r) \ + case r: \ + return #r + +static std::string glFormatStr(GLenum source) { + switch (source) { + ENUM_TO_STR(GL_RGBA16); + ENUM_TO_STR(GL_RGBA16F); + ENUM_TO_STR(GL_SRGB8_ALPHA8); + default: + return std::format("0x{:X}", source); + } +} + +static int64_t chooseSwapChainFormat(XrInstance instance, XrSession session, int64_t preferred) { + uint32_t formatCount; + XrResult result = xrEnumerateSwapchainFormats(session, 0, &formatCount, nullptr); + if (!xrCheck(instance, result, "Failed to get number of supported swapchain formats")) + return -1; + + qCInfo(xr_display_cat, "Runtime supports %d swapchain formats", formatCount); + std::vector formats(formatCount); + + result = xrEnumerateSwapchainFormats(session, formatCount, &formatCount, formats.data()); + if (!xrCheck(instance, result, "Failed to enumerate swapchain formats")) + return -1; + + int64_t chosen = formats[0]; + + for (uint32_t i = 0; i < formatCount; i++) { + qCInfo(xr_display_cat, "Supported GL format: %s", glFormatStr(formats[i]).c_str()); + if (formats[i] == preferred) { + chosen = formats[i]; + qCInfo(xr_display_cat, "Using preferred swapchain format %s", glFormatStr(chosen).c_str()); + break; + } + } + if (chosen != preferred) { + qCWarning(xr_display_cat, "Falling back to non preferred swapchain format %s", glFormatStr(chosen).c_str()); + } + + return chosen; +} + +bool OpenXrDisplayPlugin::initSwapChains() { + XrInstance instance = _context->_instance; + XrSession session = _context->_session; + + int64_t format = chooseSwapChainFormat(instance, session, XR_PREFERRED_COLOR_FORMAT); + + for (uint32_t i = 0; i < _viewCount; i++) { + _images[i].clear(); + + XrSwapchainCreateInfo info = { + .type = XR_TYPE_SWAPCHAIN_CREATE_INFO, + .createFlags = 0, + .usageFlags = XR_SWAPCHAIN_USAGE_SAMPLED_BIT | XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT, + .format = format, + .sampleCount = _viewConfigs[i].recommendedSwapchainSampleCount, + .width = _viewConfigs[i].recommendedImageRectWidth, + .height = _viewConfigs[i].recommendedImageRectHeight, + .faceCount = 1, + .arraySize = 1, + .mipCount = 1, + }; + + XrResult result = xrCreateSwapchain(session, &info, &_swapChains[i]); + if (!xrCheck(instance, result, "Failed to create swapchain!")) + return false; + + result = xrEnumerateSwapchainImages(_swapChains[i], 0, &_swapChainLengths[i], nullptr); + if (!xrCheck(instance, result, "Failed to enumerate swapchains")) + return false; + + for (uint32_t j = 0; j < _swapChainLengths[i]; j++) { + XrSwapchainImageOpenGLKHR image = { .type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_KHR }; + _images[i].push_back(image); + } + result = xrEnumerateSwapchainImages(_swapChains[i], _swapChainLengths[i], &_swapChainLengths[i], + (XrSwapchainImageBaseHeader*)_images[i].data()); + if (!xrCheck(instance, result, "Failed to enumerate swapchain images")) + return false; + } + + return true; +} + +bool OpenXrDisplayPlugin::initLayers() { + for (uint32_t i = 0; i < _viewCount; i++) { + XrCompositionLayerProjectionView layer = { + .type = XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW, + .subImage = { + .swapchain = _swapChains[i], + .imageRect = { + .offset = { + .x = 0, + .y = 0, + }, + .extent = { + .width = (int32_t)_viewConfigs[i].recommendedImageRectWidth, + .height = (int32_t)_viewConfigs[i].recommendedImageRectHeight, + }, + }, + .imageArrayIndex = 0, + }, + }; + _projectionLayerViews.push_back(layer); + }; + + return true; +} + +void OpenXrDisplayPlugin::init() { + Plugin::init(); + + if (!initViews()) { + qCCritical(xr_display_cat, "View init failed."); + return; + } + + for (const XrViewConfigurationView& view : _viewConfigs) { + assert(view.recommendedImageRectWidth != 0); + qCDebug(xr_display_cat, "Swapchain dimensions: %dx%d", view.recommendedImageRectWidth, view.recommendedImageRectHeight); + // TODO: Don't render side-by-side but use multiview (texture arrays). This probably won't work with GL. + _renderTargetSize.x = view.recommendedImageRectWidth * 2; + _renderTargetSize.y = view.recommendedImageRectHeight; + } + + emit deviceConnected(getName()); +} + +const QString OpenXrDisplayPlugin::getName() const { + return QString("OpenXR: %1").arg(_context->_systemName); +} + +bool OpenXrDisplayPlugin::internalActivate() { + _context->reset(); + return HmdDisplayPlugin::internalActivate(); +} + +void OpenXrDisplayPlugin::internalDeactivate() { + // We can get into a state where activate -> deactivate -> activate is called in a chain. + // We are probably gonna have a bad time then. At least check if the session is already running. + // This happens when the application decides to switch display plugins back and forth. This should + // probably be fixed there. + if (_context->_isSessionRunning) { + if (!_context->requestExitSession()) { + qCCritical(xr_display_cat, "Failed to request exit session"); + } else { + // Poll events until runtime wants to quit + while (!_context->_shouldQuit) { + _context->pollEvents(); + } + } + } + HmdDisplayPlugin::internalDeactivate(); +} + +void OpenXrDisplayPlugin::customizeContext() { + gl::initModuleGl(); + HmdDisplayPlugin::customizeContext(); + + if (!_context->initPostGraphics()) { + qCCritical(xr_display_cat, "Post graphics init failed."); + return; + } + + if (!initSwapChains()) { + qCCritical(xr_display_cat, "Swap chain init failed."); + return; + } + + if (!initLayers()) { + qCCritical(xr_display_cat, "Layer init failed."); + return; + } + + // Create swap chain images for _compositeFramebuffer + for (size_t i = 0; i < _swapChainLengths[0]; ++i) { + gpu::TexturePointer texture = + gpu::Texture::createRenderBuffer(gpu::Element::COLOR_SRGBA_32, _renderTargetSize.x, _renderTargetSize.y, + gpu::Texture::SINGLE_MIP, gpu::Sampler(gpu::Sampler::FILTER_MIN_MAG_POINT)); + _compositeSwapChain.push_back(texture); + } +} + +void OpenXrDisplayPlugin::uncustomizeContext() { + _compositeSwapChain.clear(); + _projectionLayerViews.clear(); + for (uint32_t i = 0; i < _viewCount; i++) { + _images[i].clear(); + } + HmdDisplayPlugin::uncustomizeContext(); +} + +void OpenXrDisplayPlugin::resetSensors() { +} + +bool OpenXrDisplayPlugin::beginFrameRender(uint32_t frameIndex) { + _context->pollEvents(); + + if (_context->_shouldQuit) { + QMetaObject::invokeMethod(qApp, "quit"); + return false; + } + + if (!_context->_shouldRunFrameCycle) { + qCWarning(xr_display_cat, "beginFrameRender: Shoudln't run frame cycle. Skipping renderin frame %d", frameIndex); + return true; + } + + // Wait for present thread + // Actually wait for xrEndFrame to happen. + bool haveFrameToSubmit = true; + { + std::unique_lock lock(_haveFrameMutex); + haveFrameToSubmit = _haveFrameToSubmit; + } + + while (haveFrameToSubmit) { + std::this_thread::sleep_for(std::chrono::microseconds(10)); + { + std::unique_lock lock(_haveFrameMutex); + haveFrameToSubmit = _haveFrameToSubmit; + } + } + + _lastFrameState = { .type = XR_TYPE_FRAME_STATE }; + XrResult result = xrWaitFrame(_context->_session, nullptr, &_lastFrameState); + + if (!xrCheck(_context->_instance, result, "xrWaitFrame failed")) + return false; + + if (!_context->beginFrame()) + return false; + + _context->_lastPredictedDisplayTime = _lastFrameState.predictedDisplayTime; + + std::vector eye_views(_viewCount); + for (uint32_t i = 0; i < _viewCount; i++) { + eye_views[i].type = XR_TYPE_VIEW; + } + + // TODO: Probably shouldn't call xrLocateViews twice. Use only view space views? + XrViewLocateInfo eyeViewLocateInfo = { + .type = XR_TYPE_VIEW_LOCATE_INFO, + .viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, + .displayTime = _lastFrameState.predictedDisplayTime, + .space = _context->_viewSpace, + }; + + XrViewState eyeViewState = { .type = XR_TYPE_VIEW_STATE }; + + result = xrLocateViews(_context->_session, &eyeViewLocateInfo, &eyeViewState, _viewCount, &_viewCount, eye_views.data()); + if (!xrCheck(_context->_instance, result, "Could not locate views")) + return false; + + for (uint32_t i = 0; i < 2; i++) { + vec3 eyePosition = xrVecToGlm(eye_views[i].pose.position); + quat eyeOrientation = xrQuatToGlm(eye_views[i].pose.orientation); + _eyeOffsets[i] = controller::Pose(eyePosition, eyeOrientation).getMatrix(); + } + + _lastViewState = { .type = XR_TYPE_VIEW_STATE }; + + XrViewLocateInfo viewLocateInfo = { + .type = XR_TYPE_VIEW_LOCATE_INFO, + .viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, + .displayTime = _lastFrameState.predictedDisplayTime, + .space = _context->_stageSpace, + }; + + result = xrLocateViews(_context->_session, &viewLocateInfo, &_lastViewState, _viewCount, &_viewCount, _views.value().data()); + if (!xrCheck(_context->_instance, result, "Could not locate views")) + return false; + + for (uint32_t i = 0; i < _viewCount; i++) { + _projectionLayerViews[i].pose = _views.value()[i].pose; + _projectionLayerViews[i].fov = _views.value()[i].fov; + } + + XrSpaceLocation headLocation = { + .type = XR_TYPE_SPACE_LOCATION, + .pose = XR_INDENTITY_POSE, + }; + xrLocateSpace(_context->_viewSpace, _context->_stageSpace, _lastFrameState.predictedDisplayTime, &headLocation); + + glm::vec3 headPosition = xrVecToGlm(headLocation.pose.position); + glm::quat headOrientation = xrQuatToGlm(headLocation.pose.orientation); + _context->_lastHeadPose = controller::Pose(headPosition, headOrientation); + + _currentRenderFrameInfo = FrameInfo(); + _currentRenderFrameInfo.renderPose = _context->_lastHeadPose.getMatrix(); + _currentRenderFrameInfo.presentPose = _currentRenderFrameInfo.renderPose; + _frameInfos[frameIndex] = _currentRenderFrameInfo; + + return HmdDisplayPlugin::beginFrameRender(frameIndex); +} + +void OpenXrDisplayPlugin::submitFrame(const gpu::FramePointer& newFrame) { + OpenGLDisplayPlugin::submitFrame(newFrame); + { + std::unique_lock lock(_haveFrameMutex); + _haveFrameToSubmit = true; + } +} + +void OpenXrDisplayPlugin::compositeLayers() { + if (!_context->_shouldRunFrameCycle) { + return; + } + + if (_lastFrameState.shouldRender) { + _compositeFramebuffer->setRenderBuffer(0, _compositeSwapChain[_swapChainIndices[0]]); + HmdDisplayPlugin::compositeLayers(); + } +} + +void OpenXrDisplayPlugin::hmdPresent() { + if (!_context->_shouldRunFrameCycle) { + qCWarning(xr_display_cat, "hmdPresent: Shoudln't run frame cycle. Skipping renderin frame %d", + _currentFrame->frameIndex); + return; + } + + if (_lastFrameState.shouldRender) { + // TODO: Use multiview swapchain + for (uint32_t i = 0; i < 2; i++) { + XrSwapchainImageAcquireInfo acquireInfo = { .type = XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO }; + + XrResult result = xrAcquireSwapchainImage(_swapChains[i], &acquireInfo, &_swapChainIndices[i]); + if (!xrCheck(_context->_instance, result, "failed to acquire swapchain image!")) + return; + + XrSwapchainImageWaitInfo waitInfo = { .type = XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO, .timeout = 1000 }; + result = xrWaitSwapchainImage(_swapChains[i], &waitInfo); + if (!xrCheck(_context->_instance, result, "failed to wait for swapchain image!")) + return; + } + + GLuint glTexId = getGLBackend()->getTextureID(_compositeFramebuffer->getRenderBuffer(0)); + + glCopyImageSubData(glTexId, GL_TEXTURE_2D, 0, 0, 0, 0, _images[0][_swapChainIndices[0]].image, GL_TEXTURE_2D, 0, 0, 0, + 0, _renderTargetSize.x / 2, _renderTargetSize.y, 1); + + glCopyImageSubData(glTexId, GL_TEXTURE_2D, 0, _renderTargetSize.x / 2, 0, 0, _images[1][_swapChainIndices[1]].image, + GL_TEXTURE_2D, 0, 0, 0, 0, _renderTargetSize.x / 2, _renderTargetSize.y, 1); + + for (uint32_t i = 0; i < 2; i++) { + XrSwapchainImageReleaseInfo releaseInfo = { .type = XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO }; + XrResult result = xrReleaseSwapchainImage(_swapChains[i], &releaseInfo); + if (!xrCheck(_context->_instance, result, "failed to release swapchain image!")) { + assert(false); + return; + } + } + } + + endFrame(); + + _presentRate.increment(); + + { + std::unique_lock lock(_haveFrameMutex); + _haveFrameToSubmit = false; + } +} + +bool OpenXrDisplayPlugin::endFrame() { + XrCompositionLayerProjection projectionLayer = { + .type = XR_TYPE_COMPOSITION_LAYER_PROJECTION, + .layerFlags = 0, + .space = _context->_stageSpace, + .viewCount = _viewCount, + .views = _projectionLayerViews.data(), + }; + + std::vector layers = { + (const XrCompositionLayerBaseHeader*)&projectionLayer, + }; + + XrFrameEndInfo info = { + .type = XR_TYPE_FRAME_END_INFO, + .displayTime = _lastFrameState.predictedDisplayTime, + .environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE, + .layerCount = (uint32_t)layers.size(), + .layers = layers.data(), + }; + + if ((_lastViewState.viewStateFlags & XR_VIEW_STATE_ORIENTATION_VALID_BIT) == 0) { + qCWarning(xr_display_cat, "Not submitting layers because orientation is invalid."); + info.layerCount = 0; + } + + if (!_lastFrameState.shouldRender) { + info.layerCount = 0; + } + + XrResult result = xrEndFrame(_context->_session, &info); + if (!xrCheck(_context->_instance, result, "failed to end frame!")) { + return false; + } + + return true; +} + +void OpenXrDisplayPlugin::postPreview() { +} + +bool OpenXrDisplayPlugin::isHmdMounted() const { + return true; +} + +void OpenXrDisplayPlugin::updatePresentPose() { +} + +int OpenXrDisplayPlugin::getRequiredThreadCount() const { + return HmdDisplayPlugin::getRequiredThreadCount(); +} + +QRectF OpenXrDisplayPlugin::getPlayAreaRect() { + return QRectF(0, 0, 10, 10); +} + +DisplayPlugin::StencilMaskMeshOperator OpenXrDisplayPlugin::getStencilMaskMeshOperator() { + return nullptr; +} diff --git a/plugins/openxr/src/OpenXrDisplayPlugin.h b/plugins/openxr/src/OpenXrDisplayPlugin.h new file mode 100644 index 00000000000..01df5fcf274 --- /dev/null +++ b/plugins/openxr/src/OpenXrDisplayPlugin.h @@ -0,0 +1,87 @@ +// +// Overte OpenXR Plugin +// +// Copyright 2024 Lubosz Sarnecki +// Copyright 2024 Overte e.V. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#pragma once + +#include +#include + +#include "OpenXrContext.h" + +class OpenXrDisplayPlugin : public HmdDisplayPlugin { +public: + OpenXrDisplayPlugin(std::shared_ptr c); + bool isSupported() const override; + const QString getName() const override; + bool getSupportsAutoSwitch() override final { return true; } + + glm::mat4 getEyeProjection(Eye eye, const glm::mat4& baseProjection) const override; + glm::mat4 getCullingProjection(const glm::mat4& baseProjection) const override; + + void init() override; + + float getTargetFrameRate() const override; + bool hasAsyncReprojection() const override { return true; } + + void customizeContext() override; + void uncustomizeContext() override; + + void resetSensors() override; + bool beginFrameRender(uint32_t frameIndex) override; + void submitFrame(const gpu::FramePointer& newFrame) override; + void cycleDebugOutput() override { _lockCurrentTexture = !_lockCurrentTexture; } + + int getRequiredThreadCount() const override; + + QRectF getPlayAreaRect() override; + + virtual StencilMaskMode getStencilMaskMode() const override { return StencilMaskMode::MESH; } + virtual StencilMaskMeshOperator getStencilMaskMeshOperator() override; + + glm::mat4 getSensorResetMatrix() const { return glm::mat4(1.0f); } + +protected: + bool internalActivate() override; + void internalDeactivate() override; + void updatePresentPose() override; + + void compositeLayers() override; + void hmdPresent() override; + bool isHmdMounted() const override; + void postPreview() override; + +private: + std::vector _compositeSwapChain; + + XrViewState _lastViewState; + + std::shared_ptr _context; + + uint32_t _viewCount = 0; + std::vector _projectionLayerViews; + + std::optional> _views; + + std::vector _viewConfigs; + + std::vector _swapChains; + std::vector _swapChainLengths; + std::vector _swapChainIndices; + std::vector> _images; + + XrFrameState _lastFrameState; + + bool initViews(); + bool initSwapChains(); + bool initLayers(); + bool endFrame(); + + bool _haveFrameToSubmit = false; + std::mutex _haveFrameMutex; +}; diff --git a/plugins/openxr/src/OpenXrInputPlugin.cpp b/plugins/openxr/src/OpenXrInputPlugin.cpp new file mode 100644 index 00000000000..f867efd1c3b --- /dev/null +++ b/plugins/openxr/src/OpenXrInputPlugin.cpp @@ -0,0 +1,563 @@ +// +// Overte OpenXR Plugin +// +// Copyright 2024 Lubosz Sarnecki +// Copyright 2024 Overte e.V. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#include "OpenXrInputPlugin.h" + +#include "AvatarConstants.h" +#include "PathUtils.h" + +#include "controllers/UserInputMapper.h" + +Q_DECLARE_LOGGING_CATEGORY(xr_input_cat) +Q_LOGGING_CATEGORY(xr_input_cat, "openxr.input") + +OpenXrInputPlugin::OpenXrInputPlugin(std::shared_ptr c) { + _context = c; + _inputDevice = std::make_shared(_context); +} + +// TODO: Make a config UI +static const QString XR_CONFIGURATION_LAYOUT = QString(""); + +void OpenXrInputPlugin::calibrate() { +} + +bool OpenXrInputPlugin::uncalibrate() { + return true; +} + +bool OpenXrInputPlugin::isSupported() const { + return _context->_isSupported; +} + +void OpenXrInputPlugin::setConfigurationSettings(const QJsonObject configurationSettings) { +} + +QJsonObject OpenXrInputPlugin::configurationSettings() { + return QJsonObject(); +} + +QString OpenXrInputPlugin::configurationLayout() { + return XR_CONFIGURATION_LAYOUT; +} + +bool OpenXrInputPlugin::activate() { + InputPlugin::activate(); + + loadSettings(); + + // register with UserInputMapper + auto userInputMapper = DependencyManager::get(); + userInputMapper->registerDevice(_inputDevice); + _registeredWithInputMapper = true; + + return true; +} + +void OpenXrInputPlugin::deactivate() { + InputPlugin::deactivate(); + + _inputDevice->_poseStateMap.clear(); + + // unregister with UserInputMapper + auto userInputMapper = DependencyManager::get(); + userInputMapper->removeDevice(_inputDevice->_deviceID); + _registeredWithInputMapper = false; + + saveSettings(); +} + +void OpenXrInputPlugin::pluginUpdate(float deltaTime, const controller::InputCalibrationData& inputCalibrationData) { + if (_context->_shouldQuit) { + deactivate(); + return; + } + + auto userInputMapper = DependencyManager::get(); + userInputMapper->withLock([&, this]() { _inputDevice->update(deltaTime, inputCalibrationData); }); + + if (_inputDevice->_trackedControllers == 0 && _registeredWithInputMapper) { + userInputMapper->removeDevice(_inputDevice->_deviceID); + _registeredWithInputMapper = false; + _inputDevice->_poseStateMap.clear(); + } + + if (!_registeredWithInputMapper && _inputDevice->_trackedControllers > 0) { + userInputMapper->registerDevice(_inputDevice); + _registeredWithInputMapper = true; + } +} + +void OpenXrInputPlugin::loadSettings() { +} + +void OpenXrInputPlugin::saveSettings() const { +} + +OpenXrInputPlugin::InputDevice::InputDevice(std::shared_ptr c) : controller::InputDevice("Index") { + _context = c; +} + +void OpenXrInputPlugin::InputDevice::focusOutEvent() { + _axisStateMap.clear(); + _buttonPressedMap.clear(); +}; + +bool OpenXrInputPlugin::InputDevice::triggerHapticPulse(float strength, float duration, uint16_t index) { + if (index > 2) { + return false; + } + + std::unique_lock locker(_lock); + + // TODO: Haptic values in overte are always strengh 1.0 and duration only 13.0 or 16.0. So it's not really used. + // The duration does not seem to map to a time unit. 16ms seems quite short for a haptic vibration. + // Let's assume the duration is in 10 milliseconds. + // Let's also assume strength 1.0 is the middle value, which is 0.5 in OpenXR. + using namespace std::chrono; + nanoseconds durationNs = duration_cast(milliseconds(static_cast(duration * 10.0f))); + XrDuration xrDuration = durationNs.count(); + + if (!_actions.at("/output/haptic")->applyHaptic(index, xrDuration, XR_FREQUENCY_UNSPECIFIED, 0.5f * strength)) { + qCCritical(xr_input_cat, "Failed to apply haptic feedback!"); + } + + return true; +} + +bool OpenXrInputPlugin::Action::init(XrActionSet actionSet) { + XrInstance instance = _context->_instance; + XrActionCreateInfo info = { + .type = XR_TYPE_ACTION_CREATE_INFO, + .actionType = _type, + .countSubactionPaths = HAND_COUNT, + .subactionPaths = _context->_handPaths, + }; + + QString name = QString::fromStdString(_path); + name.replace("/input/", ""); + name.replace("/", "-"); + strcpy(info.actionName, name.toUtf8().data()); + name.replace("-", " "); + strcpy(info.localizedActionName, name.toUtf8().data()); + + XrResult result = xrCreateAction(actionSet, &info, &_action); + if (!xrCheck(instance, result, "Failed to create action")) + return false; + + // Pose actions need spaces + if (_type == XR_ACTION_TYPE_POSE_INPUT) { + if (!createPoseSpaces()) { + return false; + } + } + + return true; +} + +const std::vector HAND_PATHS = { "left", "right" }; + +std::vector OpenXrInputPlugin::Action::getBindings() { + assert(_action != XR_NULL_HANDLE); + + std::vector bindings; + for (uint32_t i = 0; i < HAND_COUNT; i++) { + XrPath path; + std::string pathString = "/user/hand/" + HAND_PATHS[i] + _path; + xrStringToPath(_context->_instance, pathString.c_str(), &path); + XrActionSuggestedBinding binding = { .action = _action, .binding = path }; + bindings.push_back(binding); + } + return bindings; +} + +XrActionStateFloat OpenXrInputPlugin::Action::getFloat(uint32_t handId) { + XrActionStateFloat state = { + .type = XR_TYPE_ACTION_STATE_FLOAT, + }; + + XrActionStateGetInfo info = { + .type = XR_TYPE_ACTION_STATE_GET_INFO, + .action = _action, + .subactionPath = _context->_handPaths[handId], + }; + + XrResult result = xrGetActionStateFloat(_context->_session, &info, &state); + xrCheck(_context->_instance, result, "Failed to get float state!"); + + return state; +} + +XrActionStateBoolean OpenXrInputPlugin::Action::getBool(uint32_t handId) { + XrActionStateBoolean state = { + .type = XR_TYPE_ACTION_STATE_BOOLEAN, + }; + + XrActionStateGetInfo info = { + .type = XR_TYPE_ACTION_STATE_GET_INFO, + .action = _action, + .subactionPath = _context->_handPaths[handId], + }; + + XrResult result = xrGetActionStateBoolean(_context->_session, &info, &state); + xrCheck(_context->_instance, result, "Failed to get float state!"); + + return state; +} + +XrSpaceLocation OpenXrInputPlugin::Action::getPose(uint32_t handId) { + XrActionStatePose state = { + .type = XR_TYPE_ACTION_STATE_POSE, + }; + XrActionStateGetInfo info = { + .type = XR_TYPE_ACTION_STATE_GET_INFO, + .action = _action, + .subactionPath = _context->_handPaths[handId], + }; + + XrResult result = xrGetActionStatePose(_context->_session, &info, &state); + xrCheck(_context->_instance, result, "failed to get pose value!"); + + XrSpaceLocation location = { + .type = XR_TYPE_SPACE_LOCATION, + }; + + if (_context->_lastPredictedDisplayTime.has_value()) { + result = xrLocateSpace(_poseSpaces[handId], _context->_stageSpace, _context->_lastPredictedDisplayTime.value(), &location); + xrCheck(_context->_instance, result, "Failed to locate hand space!"); + } + + return location; +} + +bool OpenXrInputPlugin::Action::applyHaptic(uint32_t handId, XrDuration duration, float frequency, float amplitude) { + XrHapticVibration vibration = { + .type = XR_TYPE_HAPTIC_VIBRATION, + .duration = duration, + .frequency = frequency, + .amplitude = amplitude, + }; + + XrHapticActionInfo haptic_action_info = { + .type = XR_TYPE_HAPTIC_ACTION_INFO, + .action = _action, + .subactionPath = _context->_handPaths[handId], + }; + XrResult result = xrApplyHapticFeedback(_context->_session, &haptic_action_info, (const XrHapticBaseHeader*)&vibration); + + return xrCheck(_context->_instance, result, "Failed to apply haptic feedback!"); +} + +bool OpenXrInputPlugin::Action::createPoseSpaces() { + assert(_action != XR_NULL_HANDLE); + + for (int hand = 0; hand < HAND_COUNT; hand++) { + XrActionSpaceCreateInfo info = { + .type = XR_TYPE_ACTION_SPACE_CREATE_INFO, + .action = _action, + .subactionPath = _context->_handPaths[hand], + .poseInActionSpace = XR_INDENTITY_POSE, + }; + + XrResult result = xrCreateActionSpace(_context->_session, &info, &_poseSpaces[hand]); + if (!xrCheck(_context->_instance, result, "Failed to create hand pose space")) + return false; + } + + return true; +} + +bool OpenXrInputPlugin::InputDevice::initBindings(const std::string& profileName, + const std::vector& actionsToBind) { + XrPath profilePath; + XrResult result = xrStringToPath(_context->_instance, profileName.c_str(), &profilePath); + if (!xrCheck(_context->_instance, result, "Failed to get interaction profile")) + return false; + + std::vector bindings; + for (const std::string& path : actionsToBind) { + std::vector actionBindings = _actions.at(path)->getBindings(); + bindings.insert(std::end(bindings), std::begin(actionBindings), std::end(actionBindings)); + } + + const XrInteractionProfileSuggestedBinding suggestedBinding = { + .type = XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING, + .interactionProfile = profilePath, + .countSuggestedBindings = (uint32_t)bindings.size(), + .suggestedBindings = bindings.data(), + }; + + result = xrSuggestInteractionProfileBindings(_context->_instance, &suggestedBinding); + + return xrCheck(_context->_instance, result, "Failed to suggest bindings"); +} + +controller::Input::NamedVector OpenXrInputPlugin::InputDevice::getAvailableInputs() const { + using namespace controller; + + // clang-format off + QVector availableInputs{ + // Poses + makePair(LEFT_HAND, "LeftHand"), + makePair(RIGHT_HAND, "RightHand"), + makePair(HEAD, "Head"), + // Sticks + makePair(LX, "LX"), + makePair(LY, "LY"), + makePair(LS, "LS"), + makePair(LS_TOUCH, "LSTouch"), + makePair(RX, "RX"), + makePair(RY, "RY"), + makePair(RS, "RS"), + makePair(RS_TOUCH, "RSTouch"), + // Face buttons + makePair(RIGHT_PRIMARY_THUMB, "RightPrimaryThumb"), + makePair(RIGHT_PRIMARY_THUMB_TOUCH, "RightPrimaryThumbTouch"), + makePair(RIGHT_SECONDARY_THUMB, "RightSecondaryThumb"), + makePair(RIGHT_SECONDARY_THUMB_TOUCH, "RightSecondaryThumbTouch"), + + makePair(LEFT_PRIMARY_THUMB, "LeftPrimaryThumb"), + makePair(LEFT_PRIMARY_THUMB_TOUCH, "LeftPrimaryThumbTouch"), + makePair(LEFT_SECONDARY_THUMB, "LeftSecondaryThumb"), + makePair(LEFT_SECONDARY_THUMB_TOUCH, "LeftSecondaryThumbTouch"), + // Triggers + makePair(RT, "RT"), + makePair(LT, "LT"), + makePair(RT_CLICK, "RTClick"), + makePair(LT_CLICK, "LTClick"), + makePair(LEFT_PRIMARY_INDEX_TOUCH, "LeftPrimaryIndexTouch"), + makePair(RIGHT_PRIMARY_INDEX_TOUCH, "RightPrimaryIndexTouch"), + // Menu buttons + // TODO: Add this to button channel + // Input::NamedPair(Input(_deviceID, LEFT_APP_MENU, ChannelType::BUTTON), "LeftApplicationMenu"), + // Input::NamedPair(Input(_deviceID, RIGHT_APP_MENU, ChannelType::BUTTON), "RightApplicationMenu"), + }; + // clang-format on + + return availableInputs; +} + +QString OpenXrInputPlugin::InputDevice::getDefaultMappingConfig() const { + return PathUtils::resourcesPath() + "/controllers/openxr_index.json"; +} + +bool OpenXrInputPlugin::InputDevice::initActions() { + if (_actionsInitialized) + return true; + + assert(_context->_session != XR_NULL_HANDLE); + + XrInstance instance = _context->_instance; + + XrActionSetCreateInfo actionSetInfo = { + .type = XR_TYPE_ACTION_SET_CREATE_INFO, + .actionSetName = "action_set", + .localizedActionSetName = "Action Set", + .priority = 0, + }; + XrResult result = xrCreateActionSet(instance, &actionSetInfo, &_actionSet); + if (!xrCheck(instance, result, "Failed to create action set.")) + return false; + + // clang-format off + std::map actionsToInit = { + { "/input/thumbstick/x", XR_ACTION_TYPE_FLOAT_INPUT }, + { "/input/thumbstick/y", XR_ACTION_TYPE_FLOAT_INPUT }, + { "/input/thumbstick/touch", XR_ACTION_TYPE_BOOLEAN_INPUT }, + { "/input/thumbstick/click", XR_ACTION_TYPE_BOOLEAN_INPUT }, + { "/input/a/click", XR_ACTION_TYPE_BOOLEAN_INPUT }, + { "/input/a/touch", XR_ACTION_TYPE_BOOLEAN_INPUT }, + { "/input/b/click", XR_ACTION_TYPE_BOOLEAN_INPUT }, + { "/input/b/touch", XR_ACTION_TYPE_BOOLEAN_INPUT }, + { "/input/trigger/value", XR_ACTION_TYPE_FLOAT_INPUT }, + { "/input/trigger/click", XR_ACTION_TYPE_BOOLEAN_INPUT }, + { "/input/trigger/touch", XR_ACTION_TYPE_BOOLEAN_INPUT }, + { "/output/haptic", XR_ACTION_TYPE_VIBRATION_OUTPUT }, + { "/input/grip/pose", XR_ACTION_TYPE_POSE_INPUT }, + { "/input/select/click", XR_ACTION_TYPE_BOOLEAN_INPUT }, + { "/input/system/click", XR_ACTION_TYPE_BOOLEAN_INPUT }, + }; + // clang-format on + + for (const auto& [path, type] : actionsToInit) { + std::shared_ptr action = std::make_shared(_context, type, path); + if (!action->init(_actionSet)) { + qCCritical(xr_input_cat, "Creating action %s failed!", path.c_str()); + } else { + _actions.emplace(path, action); + } + } + + // Khronos Simple Controller + std::vector simpleBindings = { + "/input/grip/pose", + "/input/select/click", + "/output/haptic", + }; + + if (!initBindings("/interaction_profiles/khr/simple_controller", simpleBindings)) { + qCCritical(xr_input_cat, "Failed to init bindings."); + } + + // Valve Index Controller + // clang-format off + std::vector indexBindings = { + "/input/grip/pose", + "/input/thumbstick/x", + "/input/thumbstick/y", + "/input/thumbstick/touch", + "/input/thumbstick/click", + "/input/a/click", + "/input/a/touch", + "/input/b/click", + "/input/b/touch", + "/input/trigger/value", + "/input/trigger/click", + "/input/trigger/touch", + "/output/haptic", + "/input/system/click", + }; + // clang-format on + + if (!initBindings("/interaction_profiles/valve/index_controller", indexBindings)) { + qCCritical(xr_input_cat, "Failed to init bindings."); + } + + XrSessionActionSetsAttachInfo attachInfo = { + .type = XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO, + .countActionSets = 1, + .actionSets = &_actionSet, + }; + result = xrAttachSessionActionSets(_context->_session, &attachInfo); + if (!xrCheck(_context->_instance, result, "Failed to attach action set")) + return false; + + _actionsInitialized = true; + + return true; +} + +void OpenXrInputPlugin::InputDevice::update(float deltaTime, const controller::InputCalibrationData& inputCalibrationData) { + _poseStateMap.clear(); + _buttonPressedMap.clear(); + _trackedControllers = 2; + + if (_context->_session == XR_NULL_HANDLE) { + return; + } + + if (!initActions()) { + qCCritical(xr_input_cat, "Could not initialize actions!"); + return; + } + + const XrActiveActionSet active_actionset = { + .actionSet = _actionSet, + }; + + XrActionsSyncInfo syncInfo = { + .type = XR_TYPE_ACTIONS_SYNC_INFO, + .countActiveActionSets = 1, + .activeActionSets = &active_actionset, + }; + + XrInstance instance = _context->_instance; + XrSession session = _context->_session; + + XrResult result = xrSyncActions(session, &syncInfo); + xrCheck(instance, result, "failed to sync actions!"); + + glm::mat4 sensorToAvatar = glm::inverse(inputCalibrationData.avatarMat) * inputCalibrationData.sensorToWorldMat; + + static const glm::quat yFlip = glm::angleAxis(PI, Vectors::UNIT_Y); + static const glm::quat quarterX = glm::angleAxis(PI_OVER_TWO, Vectors::UNIT_X); + static const glm::quat touchToHand = yFlip * quarterX; + + static const glm::quat leftQuarterZ = glm::angleAxis(-PI_OVER_TWO, Vectors::UNIT_Z); + static const glm::quat rightQuarterZ = glm::angleAxis(PI_OVER_TWO, Vectors::UNIT_Z); + static const glm::quat eighthX = glm::angleAxis(PI / 4.0f, Vectors::UNIT_X); + + static const glm::quat leftRotationOffset = glm::inverse(leftQuarterZ * eighthX) * touchToHand; + static const glm::quat rightRotationOffset = glm::inverse(rightQuarterZ * eighthX) * touchToHand; + + for (int i = 0; i < HAND_COUNT; i++) { + XrSpaceLocation handLocation = _actions.at("/input/grip/pose")->getPose(i); + bool locationValid = (handLocation.locationFlags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT) != 0; + if (locationValid) { + vec3 translation = xrVecToGlm(handLocation.pose.position); + quat rotation = xrQuatToGlm(handLocation.pose.orientation); + auto pose = controller::Pose(translation, rotation); + glm::mat4 handOffset = i == 0 ? glm::toMat4(leftRotationOffset) : glm::toMat4(rightRotationOffset); + _poseStateMap[i == 0 ? controller::LEFT_HAND : controller::RIGHT_HAND] = + pose.postTransform(handOffset).transform(sensorToAvatar); + } + } + + glm::mat4 defaultHeadOffset = createMatFromQuatAndPos(-DEFAULT_AVATAR_HEAD_ROT, -DEFAULT_AVATAR_HEAD_TO_MIDDLE_EYE_OFFSET); + _poseStateMap[controller::HEAD] = _context->_lastHeadPose.postTransform(defaultHeadOffset).transform(sensorToAvatar); + + std::map axesToUpdate[2] = { + { + { controller::LX, "/input/thumbstick/x" }, + { controller::LY, "/input/thumbstick/y" }, + { controller::LT, "/input/trigger/value" }, + }, + { + { controller::RX, "/input/thumbstick/x" }, + { controller::RY, "/input/thumbstick/y" }, + { controller::RT, "/input/trigger/value" }, + }, + }; + + for (uint32_t i = 0; i < HAND_COUNT; i++) { + for (const auto& [channel, path] : axesToUpdate[i]) { + _axisStateMap[channel].value = _actions.at(path)->getFloat(i).currentState; + + // if (_axisStateMap[channel].value != 0) { + // qCDebug(xr_input_cat, "🐸 Controller %d: %s (%d): %f", i, path.c_str(), channel, + // (double)_axisStateMap[channel].value); + // } + } + } + + // TODO: Figure out why LEFT_APP_MENU is misssing in StandardButtonChannel + std::map buttonsToUpdate[2] = { + { + { controller::LEFT_PRIMARY_THUMB, "/input/a/click" }, + { controller::LEFT_PRIMARY_THUMB_TOUCH, "/input/a/touch" }, + { controller::LEFT_SECONDARY_THUMB, "/input/b/click" }, + { controller::LEFT_SECONDARY_THUMB_TOUCH, "/input/b/touch" }, + { controller::LT_CLICK, "/input/trigger/click" }, + { controller::LEFT_PRIMARY_INDEX_TOUCH, "/input/trigger/touch" }, + { controller::LS, "/input/thumbstick/click" }, + { controller::LS_TOUCH, "/input/thumbstick/touch" }, + //{ LEFT_APP_MENU, "/input/system/click" }, + }, + { + { controller::RIGHT_PRIMARY_THUMB, "/input/a/click" }, + { controller::RIGHT_PRIMARY_THUMB_TOUCH, "/input/a/touch" }, + { controller::RIGHT_SECONDARY_THUMB, "/input/b/click" }, + { controller::RIGHT_SECONDARY_THUMB_TOUCH, "/input/b/touch" }, + { controller::RT_CLICK, "/input/trigger/click" }, + { controller::RIGHT_PRIMARY_INDEX_TOUCH, "/input/trigger/touch" }, + { controller::RS, "/input/thumbstick/click" }, + { controller::RS_TOUCH, "/input/thumbstick/touch" }, + //{ RIGHT_APP_MENU, "/input/system/click" }, + }, + }; + + for (uint32_t i = 0; i < HAND_COUNT; i++) { + for (const auto& [channel, path] : buttonsToUpdate[i]) { + if (_actions.at(path)->getBool(i).currentState == XR_TRUE) { + _buttonPressedMap.insert(channel); + // qCDebug(xr_input_cat, "🐸 Controller %d: %s (%d)", i, path.c_str(), channel); + } + } + } +} \ No newline at end of file diff --git a/plugins/openxr/src/OpenXrInputPlugin.h b/plugins/openxr/src/OpenXrInputPlugin.h new file mode 100644 index 00000000000..a3c29794e0f --- /dev/null +++ b/plugins/openxr/src/OpenXrInputPlugin.h @@ -0,0 +1,105 @@ +// +// Overte OpenXR Plugin +// +// Copyright 2024 Lubosz Sarnecki +// Copyright 2024 Overte e.V. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#pragma once + +#include "plugins/InputPlugin.h" +#include "controllers/InputDevice.h" +#include "OpenXrContext.h" + +#define HAND_COUNT 2 + +class OpenXrInputPlugin : public InputPlugin { + Q_OBJECT +public: + OpenXrInputPlugin(std::shared_ptr c); + bool isSupported() const override; + const QString getName() const override { return "OpenXR"; } + + bool isHandController() const override { return true; } + bool configurable() override { return true; } + + QString configurationLayout() override; + void setConfigurationSettings(const QJsonObject configurationSettings) override; + QJsonObject configurationSettings() override; + void calibrate() override; + bool uncalibrate() override; + bool isHeadController() const override { return true; } + + bool activate() override; + void deactivate() override; + + QString getDeviceName() override { return _context.get()->_systemName; } + + void pluginFocusOutEvent() override { _inputDevice->focusOutEvent(); } + + void pluginUpdate(float deltaTime, const controller::InputCalibrationData& inputCalibrationData) override; + + virtual void saveSettings() const override; + virtual void loadSettings() override; + +private: + class Action { + public: + Action(std::shared_ptr c, XrActionType type, const std::string& path) { + _context = c; + _path = path; + _type = type; + } + + bool init(XrActionSet actionSet); + std::vector getBindings(); + XrActionStateFloat getFloat(uint32_t handId); + XrActionStateBoolean getBool(uint32_t handId); + XrSpaceLocation getPose(uint32_t handId); + bool applyHaptic(uint32_t handId, XrDuration duration, float frequency, float amplitude); + + private: + bool createPoseSpaces(); + XrAction _action = XR_NULL_HANDLE; + std::shared_ptr _context; + std::string _path; + XrActionType _type; + XrSpace _poseSpaces[HAND_COUNT] = { XR_NULL_HANDLE, XR_NULL_HANDLE }; + }; + + class InputDevice : public controller::InputDevice { + public: + InputDevice(std::shared_ptr c); + + private: + controller::Input::NamedVector getAvailableInputs() const override; + QString getDefaultMappingConfig() const override; + void update(float deltaTime, const controller::InputCalibrationData& inputCalibrationData) override; + void focusOutEvent() override; + bool triggerHapticPulse(float strength, float duration, uint16_t index) override; + + mutable std::recursive_mutex _lock; + template + void withLock(F&& f) { + std::unique_lock locker(_lock); + f(); + } + + friend class OpenXrInputPlugin; + + uint32_t _trackedControllers = 0; + XrActionSet _actionSet; + std::map> _actions; + std::shared_ptr _context; + bool _actionsInitialized = false; + + bool initActions(); + bool initBindings(const std::string& profileName, const std::vector& actionsToBind); + }; + + bool _registeredWithInputMapper = false; + std::shared_ptr _context; + std::shared_ptr _inputDevice; +}; diff --git a/plugins/openxr/src/OpenXrProvider.cpp b/plugins/openxr/src/OpenXrProvider.cpp new file mode 100644 index 00000000000..ba23a882cba --- /dev/null +++ b/plugins/openxr/src/OpenXrProvider.cpp @@ -0,0 +1,60 @@ +// +// Overte OpenXR Plugin +// +// Copyright 2024 Lubosz Sarnecki +// Copyright 2024 Overte e.V. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#include "plugins/RuntimePlugin.h" +#include "OpenXrDisplayPlugin.h" +#include "OpenXrInputPlugin.h" + +class OpenXrProvider : public QObject, public DisplayProvider, InputProvider { + Q_OBJECT + Q_PLUGIN_METADATA(IID DisplayProvider_iid FILE "plugin.json") + Q_INTERFACES(DisplayProvider) + Q_PLUGIN_METADATA(IID InputProvider_iid FILE "plugin.json") + Q_INTERFACES(InputProvider) + +public: + OpenXrProvider(QObject* parent = nullptr) : QObject(parent) {} + virtual ~OpenXrProvider() {} + std::shared_ptr context = std::make_shared(); + + virtual DisplayPluginList getDisplayPlugins() override { + static std::once_flag once; + std::call_once(once, [&] { + DisplayPluginPointer plugin(std::make_shared(context)); + if (plugin->isSupported()) { + _displayPlugins.push_back(plugin); + } + }); + + return _displayPlugins; + } + + virtual InputPluginList getInputPlugins() override { + static std::once_flag once; + + std::call_once(once, [&] { + InputPluginPointer plugin(std::make_shared(context)); + if (plugin->isSupported()) { + _inputPlugins.push_back(plugin); + } + }); + + return _inputPlugins; + } + + virtual void destroyInputPlugins() override { _inputPlugins.clear(); } + + virtual void destroyDisplayPlugins() override { _displayPlugins.clear(); } + +private: + DisplayPluginList _displayPlugins; + InputPluginList _inputPlugins; +}; + +#include "OpenXrProvider.moc" diff --git a/plugins/openxr/src/plugin.json b/plugins/openxr/src/plugin.json new file mode 100644 index 00000000000..5a3df6e7360 --- /dev/null +++ b/plugins/openxr/src/plugin.json @@ -0,0 +1,4 @@ +{ + "name":"OpenXR", + "version":1 +}