From 0bc685c62e6681ca0bd7862af5eddbcadef93f3b Mon Sep 17 00:00:00 2001 From: Jiwen Cai Date: Wed, 5 Aug 2026 16:28:32 +0000 Subject: [PATCH] viz: let apps choose the XR reference space VizSession never set a reference space, so OpenXrSession fell through to its XR_REFERENCE_SPACE_TYPE_LOCAL default -- an origin at head height. Any app drawing world-locked geometry at a known height above the floor gets that height wrong by roughly a whole person, and the symptom is only visible on a headset. VizSessionConfig::xr_reference_space now names the choice (kLocal / kLocalFloor / kStage), exposed to Python as viz.XrReferenceSpace. The default stays kLocal, so no existing consumer changes behaviour. An unavailable space throws naming the space rather than silently substituting a different origin, and create_reference_space logs what the runtime does offer beside what was asked for. The CloudXR WebXR client has the other half of this problem: `auto` prefers local-floor while a -155 cm vertical offset is applied whichever space it lands on, so the two corrections stack. The README records the pairing that works; NVIDIA/IsaacTeleop#871 asks why it is not the default. Signed-off-by: Jiwen Cai --- README.md | 11 +++++ src/python/isaacteleop/viz/__init__.py | 2 + src/viz/python/core_bindings.cpp | 8 ++++ src/viz/python/session_bindings.cpp | 1 + src/viz/session/cpp/CMakeLists.txt | 1 + .../cpp/inc/viz/session/viz_session.hpp | 7 +++ .../inc/viz/session/xr_reference_space.hpp | 33 ++++++++++++++ src/viz/session/cpp/viz_session.cpp | 17 +++++++ src/viz/xr/cpp/openxr_session.cpp | 44 +++++++++++++++++++ 9 files changed, 124 insertions(+) create mode 100644 src/viz/session/cpp/inc/viz/session/xr_reference_space.hpp diff --git a/README.md b/README.md index 5219254a6..923bc2513 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,17 @@ See the [Ecosystem](https://nvidia.github.io/IsaacTeleop/main/overview/ecosystem ## Quick Start +### WebXR client settings for world-locked content + +The CloudXR WebXR client ships two defaults that do not agree with each other, and the combination makes world-locked content sit at a fixed offset from your head instead of staying anchored to the room. Before running anything that draws content at a known height above the floor, set these in the client's config panel: + +| Setting | Default | Use | +|---|---|---| +| Reference Space | `auto` | `local-floor` | +| Y Offset (centimeters): Vertical | `-155` | `0` | + +`auto` walks `local-floor → local → viewer → unbounded` and the −155 cm offset is applied whichever one it lands on. That offset is a hand-rolled floor estimate for a head-height origin, so it double-corrects a `local-floor` space that is already floor-referenced — and if the chain reaches `viewer`, which is head-locked by definition, the scene attaches to your face. Selecting `local-floor` with a zero offset makes the origin a measured floor and applies no second correction. Tracked as [NVIDIA/IsaacTeleop#871](https://github.com/NVIDIA/IsaacTeleop/issues/871). + ### Documentation Our [documentation page](https://nvidia.github.io/IsaacTeleop) provides everything you need to get started, including detailed tutorials and step-by-step guides. Follow these links to learn more: diff --git a/src/python/isaacteleop/viz/__init__.py b/src/python/isaacteleop/viz/__init__.py index 7d1168a41..65932cbf5 100644 --- a/src/python/isaacteleop/viz/__init__.py +++ b/src/python/isaacteleop/viz/__init__.py @@ -38,6 +38,7 @@ EquirectLayerConfig, EquirectLayerPlacement, DisplayMode, + XrReferenceSpace, Fov, FrameInfo, FrameTimingStats, @@ -69,6 +70,7 @@ "EquirectLayerConfig", "EquirectLayerPlacement", "DisplayMode", + "XrReferenceSpace", "Fov", "FrameInfo", "FrameTimingStats", diff --git a/src/viz/python/core_bindings.cpp b/src/viz/python/core_bindings.cpp index 7bbcb424e..71da29b12 100644 --- a/src/viz/python/core_bindings.cpp +++ b/src/viz/python/core_bindings.cpp @@ -13,6 +13,7 @@ #include #include #include // SessionState +#include #include #include @@ -33,6 +34,13 @@ void bind_core(py::module_& m) .value("kWindow", viz::DisplayMode::kWindow) .value("kXr", viz::DisplayMode::kXr); + py::enum_(m, "XrReferenceSpace") + .value("kLocal", viz::XrReferenceSpace::kLocal, "Origin at the headset's start pose. y=0 is HEAD HEIGHT.") + .value("kLocalFloor", viz::XrReferenceSpace::kLocalFloor, + "Origin on the floor below the headset's start pose. y=0 is the FLOOR.") + .value("kStage", viz::XrReferenceSpace::kStage, + "Origin at the runtime's stage centre, on the floor. Does not follow the operator."); + py::enum_(m, "PixelFormat") .value("kRGBA8", viz::PixelFormat::kRGBA8) .value("kD32F", viz::PixelFormat::kD32F); diff --git a/src/viz/python/session_bindings.cpp b/src/viz/python/session_bindings.cpp index ddeddc11f..b0d72d292 100644 --- a/src/viz/python/session_bindings.cpp +++ b/src/viz/python/session_bindings.cpp @@ -69,6 +69,7 @@ void bind_session(py::module_& m) .def_readwrite("xr_system_wait_seconds", &viz::VizSession::Config::xr_system_wait_seconds) .def_readwrite("xr_near_z", &viz::VizSession::Config::xr_near_z) .def_readwrite("xr_far_z", &viz::VizSession::Config::xr_far_z) + .def_readwrite("xr_reference_space", &viz::VizSession::Config::xr_reference_space) .def_readwrite("required_extensions", &viz::VizSession::Config::required_extensions) .def_readwrite("gpu_timing", &viz::VizSession::Config::gpu_timing) .def_property( diff --git a/src/viz/session/cpp/CMakeLists.txt b/src/viz/session/cpp/CMakeLists.txt index ba49c665c..b4b89c0e2 100644 --- a/src/viz/session/cpp/CMakeLists.txt +++ b/src/viz/session/cpp/CMakeLists.txt @@ -28,6 +28,7 @@ add_library(viz_session STATIC inc/viz/session/viz_session.hpp inc/viz/session/window_backend.hpp inc/viz/session/xr_backend.hpp + inc/viz/session/xr_reference_space.hpp ) target_include_directories(viz_session diff --git a/src/viz/session/cpp/inc/viz/session/viz_session.hpp b/src/viz/session/cpp/inc/viz/session/viz_session.hpp index b1ad0a88e..2ad3a38d0 100644 --- a/src/viz/session/cpp/inc/viz/session/viz_session.hpp +++ b/src/viz/session/cpp/inc/viz/session/viz_session.hpp @@ -7,6 +7,7 @@ #include "frame_info.hpp" #include "layer_base.hpp" #include "viz_compositor.hpp" +#include "xr_reference_space.hpp" #include #include @@ -87,6 +88,12 @@ class VizSession float xr_near_z = 0.05f; float xr_far_z = 100.0f; + // kXr-only: where the session's origin sits. kLocal (the default and + // the previous hard-wired behaviour) puts y=0 at head height. An + // unavailable space throws at session creation naming the space rather + // than silently degrading to a different origin. + XrReferenceSpace xr_reference_space = XrReferenceSpace::kLocal; + // Opt-in GPU timestamp queries (render-pass, post-pass, total). // Off by default — production builds shouldn't pay. Read via // get_gpu_timing(). diff --git a/src/viz/session/cpp/inc/viz/session/xr_reference_space.hpp b/src/viz/session/cpp/inc/viz/session/xr_reference_space.hpp new file mode 100644 index 000000000..6da86cf35 --- /dev/null +++ b/src/viz/session/cpp/inc/viz/session/xr_reference_space.hpp @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +namespace viz +{ + +// Which OpenXR reference space a kXr session's world is expressed in -- i.e. +// where the origin of every pose the app sends and receives sits. A viz-level +// enum rather than XrReferenceSpaceType so the public header and the Python +// binding stay free of OpenXR types, matching DisplayMode. +// +// The choice is about the origin's HEIGHT, and it is not cosmetic: an app +// drawing world-locked geometry above the floor gets that height wrong by a +// whole person if it guesses. +enum class XrReferenceSpace +{ + // Origin at the headset's start pose, gravity-aligned. y=0 is HEAD HEIGHT + // and the floor cannot be recovered from it. The default, because it is + // the one space every runtime has. + kLocal, + // Origin on the floor below the headset's start position; core in + // OpenXR 1.1. Keeps kLocal's position and facing and moves y=0 to the + // floor, which is what anything world-locked wants. + kLocalFloor, + // Origin at the runtime's stage centre, on the floor. Floor-referenced but + // does NOT follow the operator: position and facing come from the guardian + // setup. + kStage, +}; + +} // namespace viz diff --git a/src/viz/session/cpp/viz_session.cpp b/src/viz/session/cpp/viz_session.cpp index f67dec71c..51ada88c5 100644 --- a/src/viz/session/cpp/viz_session.cpp +++ b/src/viz/session/cpp/viz_session.cpp @@ -19,6 +19,22 @@ namespace viz namespace { +// A switch with no default, so a value added to the enum fails to compile here +// rather than silently mapping to LOCAL. +XrReferenceSpaceType to_xr_reference_space_type(XrReferenceSpace space) +{ + switch (space) + { + case XrReferenceSpace::kLocal: + return XR_REFERENCE_SPACE_TYPE_LOCAL; + case XrReferenceSpace::kLocalFloor: + return XR_REFERENCE_SPACE_TYPE_LOCAL_FLOOR; + case XrReferenceSpace::kStage: + return XR_REFERENCE_SPACE_TYPE_STAGE; + } + throw std::runtime_error("VizSession: unknown XrReferenceSpace"); +} + std::unique_ptr make_backend(const VizSession::Config& cfg) { switch (cfg.mode) @@ -42,6 +58,7 @@ std::unique_ptr make_backend(const VizSession::Config& cfg) // Env blend mode chosen at runtime from what the system advertises. xc.session_config.near_z = cfg.xr_near_z; xc.session_config.far_z = cfg.xr_far_z; + xc.session_config.reference_space_type = to_xr_reference_space_type(cfg.xr_reference_space); return std::make_unique(std::move(xc)); } } diff --git a/src/viz/xr/cpp/openxr_session.cpp b/src/viz/xr/cpp/openxr_session.cpp index fb0f1ce06..dcb18b6f0 100644 --- a/src/viz/xr/cpp/openxr_session.cpp +++ b/src/viz/xr/cpp/openxr_session.cpp @@ -12,8 +12,10 @@ #include #include #include +#include #include #include +#include namespace viz { @@ -29,6 +31,27 @@ void check_xr(XrResult r, const char* what) } } +// Names for the log line in create_reference_space. Only the four this +// codebase can ask for or be offered; anything else is a runtime extension +// nothing here interprets, so it prints as "other" rather than a raw number +// that would still need looking up. +const char* reference_space_name(XrReferenceSpaceType type) +{ + switch (type) + { + case XR_REFERENCE_SPACE_TYPE_VIEW: + return "VIEW"; + case XR_REFERENCE_SPACE_TYPE_LOCAL: + return "LOCAL"; + case XR_REFERENCE_SPACE_TYPE_STAGE: + return "STAGE"; + case XR_REFERENCE_SPACE_TYPE_LOCAL_FLOOR: + return "LOCAL_FLOOR"; + default: + return "other"; + } +} + } // namespace OpenXrSession::OpenXrSession(const std::string& app_name, @@ -305,6 +328,27 @@ void OpenXrSession::create_session(const VkContext& vk) void OpenXrSession::create_reference_space(XrReferenceSpaceType type) { + // What the runtime offers, logged beside what was asked for. There is no + // fallback on purpose: check_xr throws and names the space, where quietly + // substituting a different origin would render every world-locked thing at + // the wrong height with nothing in the log. + uint32_t space_count = 0; + if (XR_SUCCEEDED(xrEnumerateReferenceSpaces(session_.get(), 0, &space_count, nullptr)) && space_count > 0) + { + std::vector offered(space_count); + if (XR_SUCCEEDED(xrEnumerateReferenceSpaces(session_.get(), space_count, &space_count, offered.data()))) + { + std::string list; + for (const XrReferenceSpaceType t : offered) + { + list += (list.empty() ? "" : " "); + list += reference_space_name(t); + } + std::fprintf(stderr, "OpenXrSession: reference space = %s (runtime offers: %s)\n", + reference_space_name(type), list.c_str()); + } + } + XrReferenceSpaceCreateInfo info{ XR_TYPE_REFERENCE_SPACE_CREATE_INFO }; info.referenceSpaceType = type; info.poseInReferenceSpace.orientation = XrQuaternionf{ 0.0f, 0.0f, 0.0f, 1.0f };