diff --git a/src/mods/VR.cpp b/src/mods/VR.cpp index 0b5c53def..d6be2f39c 100644 --- a/src/mods/VR.cpp +++ b/src/mods/VR.cpp @@ -1396,6 +1396,11 @@ void VR::on_pre_calculate_stereo_view_offset(void* stereo_device, const int32_t return; } + // Refresh the plane cache on the game thread; render-thread consumers must not take the pose locks. + if (is_using_spatial()) { + update_spatial_ui_plane(); + } + const auto now = std::chrono::high_resolution_clock::now(); const auto delta = std::chrono::duration(now - m_last_lerp_update).count(); @@ -1752,6 +1757,11 @@ void VR::on_config_load(const utility::Config& cfg, bool set_defaults) { option.config_load(cfg, set_defaults); } + // 2D screen and spatial are mutually exclusive; spatial wins if a config has both set. + if (m_spatial_mode->value() && m_2d_screen_mode->value()) { + m_2d_screen_mode->value() = false; + } + if (get_runtime() != nullptr && get_runtime()->loaded) { get_runtime()->on_config_load(cfg, set_defaults); @@ -1772,8 +1782,13 @@ void VR::on_config_load(const utility::Config& cfg, bool set_defaults) { m_overlay_component.on_config_load(cfg, set_defaults); + // Seed the aperture plane if spatial loaded enabled, before anything consumes the default plane. + if (m_spatial_mode->value()) { + update_spatial_ui_plane(); + } + if (m_cvar_manager != nullptr) { - m_cvar_manager->on_config_load(cfg, set_defaults); + m_cvar_manager->on_config_load(cfg, set_defaults); } // Load camera offsets @@ -1950,6 +1965,19 @@ void VR::handle_keybinds() { if (m_keybind_toggle_2d_screen->is_key_down_once()) { m_2d_screen_mode->toggle(); + if (m_2d_screen_mode->value() && m_spatial_mode->value()) { + m_spatial_mode->value() = false; // mutually exclusive + get_runtime()->should_recalculate_eye_projections = true; // drop the stale off-axis projection + } + } + + if (m_keybind_toggle_spatial->is_key_down_once()) { + m_spatial_mode->toggle(); + if (m_spatial_mode->value()) { + m_2d_screen_mode->value() = false; // mutually exclusive + update_spatial_ui_plane(); // seed the aperture now; consumers must never see the default plane + } + get_runtime()->should_recalculate_eye_projections = true; } if (m_keybind_disable_vr->is_key_down_once()) { @@ -2250,7 +2278,7 @@ void VR::on_post_present() { } uint32_t VR::get_hmd_width() const { - if (m_2d_screen_mode->value()) { + if (is_using_2d_screen()) { if (get_runtime()->is_openxr()) { return g_framework->get_rt_size().x * m_openxr->resolution_scale->value(); } @@ -2266,7 +2294,7 @@ uint32_t VR::get_hmd_width() const { } uint32_t VR::get_hmd_height() const { - if (m_2d_screen_mode->value()) { + if (is_using_2d_screen()) { if (get_runtime()->is_openxr()) { return g_framework->get_rt_size().y * m_openxr->resolution_scale->value(); } @@ -2394,7 +2422,26 @@ void VR::on_draw_sidebar_entry(std::string_view name) { m_desktop_fix->draw("Desktop Spectator View"); ImGui::SameLine(); - m_2d_screen_mode->draw("2D Screen Mode"); + if (m_2d_screen_mode->draw("2D Screen Mode") && m_2d_screen_mode->value()) { + // 2D screen and spatial mode are mutually exclusive + if (m_spatial_mode->value()) { + get_runtime()->should_recalculate_eye_projections = true; // drop the stale off-axis projection + } + m_spatial_mode->value() = false; + } + + bool spatial_changed = m_spatial_mode->draw("Spatial Mode"); + if (spatial_changed && m_spatial_mode->value()) { + m_2d_screen_mode->value() = false; + update_spatial_ui_plane(); // seed the aperture now; consumers must never see the default plane + } + if (m_spatial_mode->value()) { + spatial_changed |= m_spatial_size->draw("Spatial Size"); + } + if (spatial_changed) { + // regenerate eye projection matrices so the new framing takes effect + get_runtime()->should_recalculate_eye_projections = true; + } ImGui::TextWrapped("Render Resolution (per-eye): %d x %d", get_runtime()->get_width(), get_runtime()->get_height()); ImGui::TextWrapped("Total Render Resolution: %d x %d", get_runtime()->get_width() * 2, get_runtime()->get_height()); @@ -2578,7 +2625,8 @@ void VR::on_draw_sidebar_entry(std::string_view name) { ImGui::SetNextItemOpen(true, ImGuiCond_::ImGuiCond_Once); if (ImGui::TreeNode("Decoupled Pitch")) { - m_decoupled_pitch->draw("Enabled"); + m_decoupled_pitch->draw("Enabled VR"); + m_spatial_decoupled_pitch->draw("Enabled Spatial"); m_decoupled_pitch_ui_adjust->draw("Auto Adjust UI"); ImGui::TreePop(); @@ -2607,6 +2655,7 @@ void VR::on_draw_sidebar_entry(std::string_view name) { ImGui::SetNextItemOpen(true, ImGuiCond_::ImGuiCond_Once); if (ImGui::TreeNode("Overlay/Runtime Keys")) { m_keybind_toggle_2d_screen->draw("Toggle 2D Screen Mode Key"); + m_keybind_toggle_spatial->draw("Toggle Spatial Mode Key"); m_keybind_toggle_gui->draw("Toggle In-Game UI Key"); m_keybind_disable_vr->draw("Disable VR Key"); diff --git a/src/mods/VR.hpp b/src/mods/VR.hpp index 941840c7f..320f12dbf 100644 --- a/src/mods/VR.hpp +++ b/src/mods/VR.hpp @@ -449,6 +449,11 @@ class VR : public Mod { } bool is_decoupled_pitch_enabled() const { + // Spatial mode gets its own decoupled-pitch toggle, independent of the global VR setting. + if (is_using_spatial()) { + return m_spatial_decoupled_pitch->value(); + } + return m_decoupled_pitch->value(); } @@ -573,7 +578,43 @@ class VR : public Mod { } bool is_using_2d_screen() const { - return m_2d_screen_mode->value(); + // 2D screen and spatial mode are mutually exclusive; spatial takes precedence + // if both flags are somehow set (e.g. a config saved before the toggles were exclusive). + return m_2d_screen_mode->value() && !m_spatial_mode->value(); + } + + bool is_using_spatial() const { + if (!m_spatial_mode->value()) { + return false; + } + + // Combos with no spatial display backend no-op to normal VR: OpenVR+D3D12 (the capture / + // black eye submits are D3D11-only) and extreme compatibility mode (single-eye frame layout). + if (is_extreme_compatibility_mode_enabled()) { + return false; + } + + if (get_runtime()->is_openvr() && g_framework->is_dx12()) { + return false; + } + + return true; + } + + // Spatial Size: display-only shrink of the aperture quad (1.0 = life-size). The same content on + // a smaller quad shrinks world + UI together into a coherent miniature; the render is untouched. + float get_spatial_size() const { return m_spatial_size->value(); } + + // Cached UI/slate plane. Written on the game thread; read by render/present threads which hold + // pose_mtx and must NOT re-lock it. Its own mutex prevents torn reads while the plane moves. + vrmod::OverlayComponent::UIPlaneTransform get_spatial_ui_plane() const { + std::shared_lock _{m_spatial_ui_plane_mtx}; + return m_spatial_ui_plane; + } + void update_spatial_ui_plane() { + const auto plane = m_overlay_component.get_ui_plane(); // takes pose/rotation locks; keep outside ours + std::unique_lock _{m_spatial_ui_plane_mtx}; + m_spatial_ui_plane = plane; } bool is_roomscale_enabled() const { @@ -895,6 +936,11 @@ class VR : public Mod { const ModToggle::Ptr m_decoupled_pitch_ui_adjust{ ModToggle::create(generate_name("DecoupledPitchUIAdjust"), true) }; const ModToggle::Ptr m_load_blueprint_code{ ModToggle::create(generate_name("LoadBlueprintCode"), false, true) }; const ModToggle::Ptr m_2d_screen_mode{ ModToggle::create(generate_name("2DScreenMode"), false) }; + const ModToggle::Ptr m_spatial_mode{ ModToggle::create(generate_name("SpatialMode"), false) }; + const ModSlider::Ptr m_spatial_size{ ModSlider::create(generate_name("SpatialSize"), 0.1f, 1.0f, 1.0f) }; + const ModToggle::Ptr m_spatial_decoupled_pitch{ ModToggle::create(generate_name("SpatialDecoupledPitch"), false) }; + vrmod::OverlayComponent::UIPlaneTransform m_spatial_ui_plane{}; + mutable std::shared_mutex m_spatial_ui_plane_mtx{}; const ModToggle::Ptr m_roomscale_movement{ ModToggle::create(generate_name("RoomscaleMovement"), false) }; const ModToggle::Ptr m_roomscale_sweep{ ModToggle::create(generate_name("RoomscaleMovementSweep"), true) }; const ModToggle::Ptr m_swap_controllers{ ModToggle::create(generate_name("SwapControllerInputs"), false) }; @@ -978,6 +1024,7 @@ class VR : public Mod { const ModKey::Ptr m_keybind_load_camera_2{ ModKey::create(generate_name("LoadCamera2Key")) }; const ModKey::Ptr m_keybind_toggle_2d_screen{ ModKey::create(generate_name("Toggle2DScreenKey")) }; + const ModKey::Ptr m_keybind_toggle_spatial{ ModKey::create(generate_name("ToggleSpatialModeKey")) }; const ModKey::Ptr m_keybind_disable_vr{ ModKey::create(generate_name("DisableVRKey")) }; bool m_disable_vr{false}; // definitely should not be persistent @@ -1041,6 +1088,9 @@ class VR : public Mod { *m_decoupled_pitch_ui_adjust, *m_load_blueprint_code, *m_2d_screen_mode, + *m_spatial_mode, + *m_spatial_size, + *m_spatial_decoupled_pitch, *m_roomscale_movement, *m_roomscale_sweep, *m_swap_controllers, @@ -1085,6 +1135,7 @@ class VR : public Mod { *m_keybind_load_camera_1, *m_keybind_load_camera_2, *m_keybind_toggle_2d_screen, + *m_keybind_toggle_spatial, *m_keybind_disable_vr, *m_keybind_toggle_gui, *m_requested_runtime_name, diff --git a/src/mods/vr/D3D11Component.cpp b/src/mods/vr/D3D11Component.cpp index 94048907e..a7b27f3ca 100644 --- a/src/mods/vr/D3D11Component.cpp +++ b/src/mods/vr/D3D11Component.cpp @@ -390,9 +390,12 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { } const auto is_2d_screen = vr->is_using_2d_screen(); + // Spatial (OpenXR) reuses the 2D-screen frame structure: content on quads, projection layer black. + const auto is_spatial_screen = vr->is_using_spatial() && runtime->is_openxr(); + const auto is_screen_capture = is_2d_screen || is_spatial_screen; auto draw_2d_view = [&]() { - if (!is_2d_screen || !m_engine_tex_ref.has_texture() || !m_engine_tex_ref.has_srv()) { + if (!is_screen_capture || !m_engine_tex_ref.has_texture() || !m_engine_tex_ref.has_srv()) { return; } @@ -413,7 +416,10 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { RECT{0, 0, (LONG)((float)m_backbuffer_size[0] / 2.0f), (LONG)m_backbuffer_size[1]} ); - if (m_engine_ui_ref.has_texture() && m_engine_ui_ref.has_srv()) { + // In spatial mode the GUI toggle hides only the UI; the world stays on the quad. + const bool composite_ui = is_2d_screen || vr->is_gui_enabled(); + + if (composite_ui && m_engine_ui_ref.has_texture() && m_engine_ui_ref.has_srv()) { render_srv_to_rtv( m_game_batch.get(), m_engine_ui_ref, @@ -422,7 +428,8 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { } if (!is_afr) { - // Render right side to right screen tex + // Render right side to right screen tex. With Native Stereo Fix the right eye renders + // into the scene capture target (the double-wide's right half is empty), so prefer it. if (m_scene_capture_tex_ref.has_texture() && m_scene_capture_tex_ref.has_srv()) { render_srv_to_rtv( m_game_batch.get(), @@ -438,7 +445,7 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { ); } - if (m_engine_ui_ref.has_texture() && m_engine_ui_ref.has_srv()) { + if (composite_ui && m_engine_ui_ref.has_texture() && m_engine_ui_ref.has_srv()) { render_srv_to_rtv( m_game_batch.get(), m_engine_ui_ref, @@ -455,7 +462,7 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { } }; - if (is_2d_screen) { + if (is_screen_capture) { draw_2d_view(); } @@ -473,7 +480,7 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { } } else if (runtime->is_openxr() && vr->m_openxr->frame_began) { if (is_right_eye_frame) { - if (is_2d_screen) { + if (is_screen_capture) { if (is_afr) { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::UI_RIGHT, m_2d_screen_tex[0]); } else { @@ -491,7 +498,7 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { if (fw_rt != nullptr && g_framework->is_drawing_anything()) { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::FRAMEWORK_UI, fw_rt.Get()); } - } else if (is_2d_screen) { + } else if (is_screen_capture) { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::UI, m_2d_screen_tex[0]); } } @@ -534,22 +541,28 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { if (runtime->is_openxr() && runtime->ready()) { LOG_VERBOSE("Copying left eye"); //m_openxr.copy(0, backbuffer.Get()); - D3D11_BOX src_box{}; - src_box.left = 0; - src_box.top = 0; - src_box.bottom = m_backbuffer_size[1]; - src_box.front = 0; - src_box.back = 1; - - if (vr->is_extreme_compatibility_mode_enabled()) { - src_box.right = m_backbuffer_size[0]; + if (is_screen_capture) { + // 2D/spatial: the projection layer is black by design -- clear instead of copying. + m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::AFR_LEFT_EYE, nullptr, nullptr, + [&](ID3D11Texture2D* rt) { clear_tex(rt); }); } else { - src_box.right = m_backbuffer_size[0] / 2; - } + D3D11_BOX src_box{}; + src_box.left = 0; + src_box.top = 0; + src_box.bottom = m_backbuffer_size[1]; + src_box.front = 0; + src_box.back = 1; - m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::AFR_LEFT_EYE, backbuffer.Get(), &src_box); + if (vr->is_extreme_compatibility_mode_enabled()) { + src_box.right = m_backbuffer_size[0]; + } else { + src_box.right = m_backbuffer_size[0] / 2; + } - if (scene_depth_tex != nullptr) { + m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::AFR_LEFT_EYE, backbuffer.Get(), &src_box); + } + + if (scene_depth_tex != nullptr && !is_screen_capture) { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::AFR_DEPTH_LEFT_EYE, scene_depth_tex.Get(), nullptr); } @@ -559,28 +572,42 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { } if (runtime->is_openvr()) { - D3D11_BOX src_box{}; - src_box.left = 0; - src_box.top = 0; - src_box.bottom = m_backbuffer_size[1]; - src_box.front = 0; - src_box.back = 1; - - if (vr->is_extreme_compatibility_mode_enabled()) { - src_box.right = m_backbuffer_size[0]; + const auto submit_pose = vr->m_openvr->get_pose_for_submit(); + + if (vr->is_using_spatial() && m_spatial_overlay_tex != nullptr) { + // Spatial (alternating AFR): accumulate this eye into the side-by-side overlay, + // then submit the scene black (the eye copy + postprocess would be discarded). + D3D11_BOX eye_box{ 0, 0, 0, (UINT)(m_backbuffer_size[0] / 2), (UINT)m_backbuffer_size[1], 1 }; + context->CopySubresourceRegion(m_spatial_overlay_tex.Get(), 0, 0, 0, 0, backbuffer.Get(), 0, &eye_box); + + const float clear_color[4]{}; + if (m_left_eye_rtv != nullptr) { + context->ClearRenderTargetView(m_left_eye_rtv.Get(), clear_color); + } else { + clear_tex(m_left_eye_tex.Get()); + } } else { - src_box.right = m_backbuffer_size[0] / 2; - } + D3D11_BOX src_box{}; + src_box.left = 0; + src_box.top = 0; + src_box.bottom = m_backbuffer_size[1]; + src_box.front = 0; + src_box.back = 1; - context->CopySubresourceRegion(m_left_eye_tex.Get(), 0, 0, 0, 0, backbuffer.Get(), 0, &src_box); + if (vr->is_extreme_compatibility_mode_enabled()) { + src_box.right = m_backbuffer_size[0]; + } else { + src_box.right = m_backbuffer_size[0] / 2; + } - const auto submit_pose = vr->m_openvr->get_pose_for_submit(); + context->CopySubresourceRegion(m_left_eye_tex.Get(), 0, 0, 0, 0, backbuffer.Get(), 0, &src_box); - if (m_is_shader_setup) { - ID3D11RenderTargetView* views[] = { m_left_eye_rtv.Get() }; - context->OMSetRenderTargets(1, views, nullptr); - //context->ClearRenderTargetView(m_right_eye_rtv.Get(), clear_color); - invoke_shader(vr->m_frame_count, 0, m_backbuffer_size[0] / 2, m_backbuffer_size[1]); + if (m_is_shader_setup) { + ID3D11RenderTargetView* views[] = { m_left_eye_rtv.Get() }; + context->OMSetRenderTargets(1, views, nullptr); + //context->ClearRenderTargetView(m_right_eye_rtv.Get(), clear_color); + invoke_shader(vr->m_frame_count, 0, m_backbuffer_size[0] / 2, m_backbuffer_size[1]); + } } vr::VRTextureWithPose_t left_eye{ @@ -603,24 +630,35 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { }}; if (runtime->ready() && runtime->is_openxr()) { + // 2D/spatial: the projection layer is black by design -- clear the swapchains instead of + // copying, and skip depth (nothing to reproject). + const auto clear_swapchain = [&](runtimes::OpenXR::SwapchainIndex idx) { + m_openxr.copy((uint32_t)idx, nullptr, nullptr, [&](ID3D11Texture2D* rt) { clear_tex(rt); }); + }; + if (is_actually_afr && !is_afr && !m_submitted_left_eye) { LOG_VERBOSE("Copying left eye"); - D3D11_BOX src_box{}; - src_box.left = 0; - src_box.top = 0; - src_box.bottom = m_backbuffer_size[1]; - src_box.front = 0; - src_box.back = 1; - if (vr->is_extreme_compatibility_mode_enabled()) { - src_box.right = m_backbuffer_size[0]; + if (is_screen_capture) { + clear_swapchain(runtimes::OpenXR::SwapchainIndex::AFR_LEFT_EYE); } else { - src_box.right = m_backbuffer_size[0] / 2; - } + D3D11_BOX src_box{}; + src_box.left = 0; + src_box.top = 0; + src_box.bottom = m_backbuffer_size[1]; + src_box.front = 0; + src_box.back = 1; - m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::AFR_LEFT_EYE, backbuffer.Get(), &src_box); + if (vr->is_extreme_compatibility_mode_enabled()) { + src_box.right = m_backbuffer_size[0]; + } else { + src_box.right = m_backbuffer_size[0] / 2; + } - if (scene_depth_tex != nullptr) { + m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::AFR_LEFT_EYE, backbuffer.Get(), &src_box); + } + + if (scene_depth_tex != nullptr && !is_screen_capture) { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::AFR_DEPTH_LEFT_EYE, scene_depth_tex.Get(), nullptr); } } @@ -628,40 +666,46 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { LOG_VERBOSE("Copying right eye"); if (is_actually_afr) { - D3D11_BOX src_box{}; - if (!vr->is_extreme_compatibility_mode_enabled()) { - if (!is_afr) { - src_box.left = m_backbuffer_size[0] / 2; - src_box.right = m_backbuffer_size[0]; - src_box.top = 0; - src_box.bottom = m_backbuffer_size[1]; - src_box.front = 0; - src_box.back = 1; - } else { // Copy the left eye on AFR + if (is_screen_capture) { + clear_swapchain(runtimes::OpenXR::SwapchainIndex::AFR_RIGHT_EYE); + } else { + D3D11_BOX src_box{}; + if (!vr->is_extreme_compatibility_mode_enabled()) { + if (!is_afr) { + src_box.left = m_backbuffer_size[0] / 2; + src_box.right = m_backbuffer_size[0]; + src_box.top = 0; + src_box.bottom = m_backbuffer_size[1]; + src_box.front = 0; + src_box.back = 1; + } else { // Copy the left eye on AFR + src_box.left = 0; + src_box.right = m_backbuffer_size[0] / 2; + src_box.top = 0; + src_box.bottom = m_backbuffer_size[1]; + src_box.front = 0; + src_box.back = 1; + } + } else { src_box.left = 0; - src_box.right = m_backbuffer_size[0] / 2; + src_box.right = m_backbuffer_size[0]; src_box.top = 0; src_box.bottom = m_backbuffer_size[1]; src_box.front = 0; src_box.back = 1; - } - } else { - src_box.left = 0; - src_box.right = m_backbuffer_size[0]; - src_box.top = 0; - src_box.bottom = m_backbuffer_size[1]; - src_box.front = 0; - src_box.back = 1; - } + } - m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::AFR_RIGHT_EYE, backbuffer.Get(), &src_box); + m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::AFR_RIGHT_EYE, backbuffer.Get(), &src_box); + } - if (scene_depth_tex != nullptr) { + if (scene_depth_tex != nullptr && !is_screen_capture) { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::AFR_DEPTH_RIGHT_EYE, scene_depth_tex.Get(), nullptr); } } else { // Copy over the entire double wide back buffer instead - if (!m_scene_capture_tex_ref.has_texture()) { + if (is_screen_capture) { + clear_swapchain(runtimes::OpenXR::SwapchainIndex::DOUBLE_WIDE); + } else if (!m_scene_capture_tex_ref.has_texture()) { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::DOUBLE_WIDE, backbuffer.Get(), nullptr); } else { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::DOUBLE_WIDE, nullptr, nullptr, [&](ID3D11Texture2D* render_target) { @@ -679,7 +723,7 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { }); } - if (scene_depth_tex != nullptr) { + if (scene_depth_tex != nullptr && !is_screen_capture) { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::DEPTH, scene_depth_tex.Get(), nullptr); } } @@ -689,7 +733,11 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { auto& openxr_overlay = vr->get_overlay_component().get_openxr(); - if (vr->m_2d_screen_mode->value()) { + // Spatial shares 2D-screen mode's layer structure: one content quad per eye (posed at the + // aperture by generate_slate_quad) over a real-but-black projection layer. One quad per + // eye keeps the aperture edge flush; the projection layer keeps runtimes that mishandle + // quad-only frames (Oculus) anchored. + if (vr->is_using_spatial() || vr->m_2d_screen_mode->value()) { const auto left_layer = openxr_overlay.generate_slate_layer(runtimes::OpenXR::SwapchainIndex::UI, XrEyeVisibility::XR_EYE_VISIBILITY_LEFT); const auto right_layer = openxr_overlay.generate_slate_layer(runtimes::OpenXR::SwapchainIndex::UI_RIGHT, XrEyeVisibility::XR_EYE_VISIBILITY_RIGHT); @@ -716,7 +764,7 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { } } - auto result = vr->m_openxr->end_frame(quad_layers, scene_depth_tex != nullptr); + auto result = vr->m_openxr->end_frame(quad_layers, scene_depth_tex != nullptr && !is_screen_capture); vr->m_openxr->needs_pose_update = true; vr->m_submitted = result == XR_SUCCESS; @@ -726,29 +774,62 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { auto openvr = vr->get_runtime(); const auto submit_pose = openvr->get_pose_for_submit(); + // Spatial: capture the world for the overlay quad; the scene below is submitted black. + const bool is_spatial = vr->is_using_spatial(); + if (is_spatial && m_spatial_overlay_tex != nullptr) { + if (!is_afr) { + // Double-wide capture -- except with Native Stereo Fix, where the right eye + // lives in the scene capture target. + if (m_scene_capture_tex_ref.has_texture()) { + D3D11_BOX half_box{ 0, 0, 0, (UINT)(m_backbuffer_size[0] / 2), (UINT)m_backbuffer_size[1], 1 }; + context->CopySubresourceRegion(m_spatial_overlay_tex.Get(), 0, 0, 0, 0, backbuffer.Get(), 0, &half_box); + context->CopySubresourceRegion(m_spatial_overlay_tex.Get(), 0, (UINT)(m_backbuffer_size[0] / 2), 0, 0, m_scene_capture_tex_ref.tex.Get(), 0, &half_box); + } else { + D3D11_BOX full_box{ 0, 0, 0, (UINT)m_backbuffer_size[0], (UINT)m_backbuffer_size[1], 1 }; + context->CopySubresourceRegion(m_spatial_overlay_tex.Get(), 0, 0, 0, 0, backbuffer.Get(), 0, &full_box); + } + } else if (!vr->is_extreme_compatibility_mode_enabled()) { + // Alternating AFR: accumulate this frame's eye into the overlay's right half + // (the left half came from the previous left-eye frame). + D3D11_BOX eye_box{ 0, 0, 0, (UINT)(m_backbuffer_size[0] / 2), (UINT)m_backbuffer_size[1], 1 }; + context->CopySubresourceRegion(m_spatial_overlay_tex.Get(), 0, (UINT)(m_backbuffer_size[0] / 2), 0, 0, backbuffer.Get(), 0, &eye_box); + } + } + vr::EVRCompositorError e = vr::VRCompositorError_None; if (!is_afr) { - D3D11_BOX src_box{}; - src_box.left = 0; - src_box.top = 0; - src_box.bottom = m_backbuffer_size[1]; - src_box.front = 0; - src_box.back = 1; - - if (vr->is_extreme_compatibility_mode_enabled()) { - src_box.right = m_backbuffer_size[0]; + if (is_spatial) { + // Black scene; the world rides the overlay. The skipped eye copy + postprocess + // would be discarded anyway. + const float clear_color[4]{}; + if (m_left_eye_rtv != nullptr) { + context->ClearRenderTargetView(m_left_eye_rtv.Get(), clear_color); + } else { + clear_tex(m_left_eye_tex.Get()); + } } else { - src_box.right = m_backbuffer_size[0] / 2; - } + D3D11_BOX src_box{}; + src_box.left = 0; + src_box.top = 0; + src_box.bottom = m_backbuffer_size[1]; + src_box.front = 0; + src_box.back = 1; - context->CopySubresourceRegion(m_left_eye_tex.Get(), 0, 0, 0, 0, backbuffer.Get(), 0, &src_box); + if (vr->is_extreme_compatibility_mode_enabled()) { + src_box.right = m_backbuffer_size[0]; + } else { + src_box.right = m_backbuffer_size[0] / 2; + } - if (m_is_shader_setup) { - ID3D11RenderTargetView* views[] = { m_left_eye_rtv.Get() }; - context->OMSetRenderTargets(1, views, nullptr); - //context->ClearRenderTargetView(m_right_eye_rtv.Get(), clear_color); - invoke_shader(vr->m_frame_count, 0, m_backbuffer_size[0] / 2, m_backbuffer_size[1]); + context->CopySubresourceRegion(m_left_eye_tex.Get(), 0, 0, 0, 0, backbuffer.Get(), 0, &src_box); + + if (m_is_shader_setup) { + ID3D11RenderTargetView* views[] = { m_left_eye_rtv.Get() }; + context->OMSetRenderTargets(1, views, nullptr); + //context->ClearRenderTargetView(m_right_eye_rtv.Get(), clear_color); + invoke_shader(vr->m_frame_count, 0, m_backbuffer_size[0] / 2, m_backbuffer_size[1]); + } } vr::VRTextureWithPose_t left_eye{ @@ -766,53 +847,63 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { } } - // Copy the back buffer to the right eye texture. - if (!m_scene_capture_tex_ref.has_texture()) { - D3D11_BOX src_box{}; - if (!vr->is_extreme_compatibility_mode_enabled()) { - if (!is_afr) { - src_box.left = m_backbuffer_size[0] / 2; - src_box.right = m_backbuffer_size[0]; - src_box.top = 0; - src_box.bottom = m_backbuffer_size[1]; - src_box.front = 0; - src_box.back = 1; - } else { // Copy the left eye on AFR + if (is_spatial) { + // Black scene; the world rides the overlay. + const float clear_color[4]{}; + if (m_right_eye_rtv != nullptr) { + context->ClearRenderTargetView(m_right_eye_rtv.Get(), clear_color); + } else { + clear_tex(m_right_eye_tex.Get()); + } + } else { + // Copy the back buffer to the right eye texture. + if (!m_scene_capture_tex_ref.has_texture()) { + D3D11_BOX src_box{}; + if (!vr->is_extreme_compatibility_mode_enabled()) { + if (!is_afr) { + src_box.left = m_backbuffer_size[0] / 2; + src_box.right = m_backbuffer_size[0]; + src_box.top = 0; + src_box.bottom = m_backbuffer_size[1]; + src_box.front = 0; + src_box.back = 1; + } else { // Copy the left eye on AFR + src_box.left = 0; + src_box.right = m_backbuffer_size[0] / 2; + src_box.top = 0; + src_box.bottom = m_backbuffer_size[1]; + src_box.front = 0; + src_box.back = 1; + } + } else { src_box.left = 0; - src_box.right = m_backbuffer_size[0] / 2; + src_box.right = m_backbuffer_size[0]; src_box.top = 0; src_box.bottom = m_backbuffer_size[1]; src_box.front = 0; src_box.back = 1; - } + } + + context->CopySubresourceRegion(m_right_eye_tex.Get(), 0, 0, 0, 0, backbuffer.Get(), 0, &src_box); } else { - src_box.left = 0; - src_box.right = m_backbuffer_size[0]; - src_box.top = 0; - src_box.bottom = m_backbuffer_size[1]; - src_box.front = 0; - src_box.back = 1; + D3D11_BOX left_src_box{ + .left = 0, + .top = 0, + .front = 0, + .right = m_backbuffer_size[0] / 2, + .bottom = m_backbuffer_size[1], + .back = 1 + }; + + context->CopySubresourceRegion(m_right_eye_tex.Get(), 0, 0, 0, 0, m_scene_capture_tex_ref.tex.Get(), 0, &left_src_box); } - context->CopySubresourceRegion(m_right_eye_tex.Get(), 0, 0, 0, 0, backbuffer.Get(), 0, &src_box); - } else { - D3D11_BOX left_src_box{ - .left = 0, - .top = 0, - .front = 0, - .right = m_backbuffer_size[0] / 2, - .bottom = m_backbuffer_size[1], - .back = 1 - }; - - context->CopySubresourceRegion(m_right_eye_tex.Get(), 0, 0, 0, 0, m_scene_capture_tex_ref.tex.Get(), 0, &left_src_box); - } - - if (m_is_shader_setup) { - ID3D11RenderTargetView* views[] = { m_right_eye_rtv.Get() }; - context->OMSetRenderTargets(1, views, nullptr); - invoke_shader(vr->m_frame_count, 1, m_backbuffer_size[0] / 2, m_backbuffer_size[1]); - //context->OMSetRenderTargets(1, &prev_rtv, prev_depth_rtv.Get()); + if (m_is_shader_setup) { + ID3D11RenderTargetView* views[] = { m_right_eye_rtv.Get() }; + context->OMSetRenderTargets(1, views, nullptr); + invoke_shader(vr->m_frame_count, 1, m_backbuffer_size[0] / 2, m_backbuffer_size[1]); + //context->OMSetRenderTargets(1, &prev_rtv, prev_depth_rtv.Get()); + } } vr::VRTextureWithPose_t right_eye{ @@ -1014,6 +1105,7 @@ void D3D11Component::on_reset(VR* vr) { m_left_eye_srv.Reset(); m_right_eye_srv.Reset(); m_ui_tex.Reset(); + m_spatial_overlay_tex.Reset(); m_vs_shader_blob.Reset(); m_ps_shader_blob.Reset(); m_vs_shader.Reset(); @@ -1298,6 +1390,17 @@ bool D3D11Component::setup() { backbuffer_desc.Height = (uint32_t)g_framework->get_d3d11_rt_size().y; device->CreateTexture2D(&backbuffer_desc, nullptr, &m_ui_tex); + // World overlay texture for OpenVR spatial mode (OpenXR spatial rides the 2D-screen textures). + if (vr->get_runtime()->is_openvr()) { + D3D11_TEXTURE2D_DESC spatial_desc = backbuffer_desc; + spatial_desc.Width = m_backbuffer_size[0]; + spatial_desc.Height = m_backbuffer_size[1]; + spatial_desc.BindFlags |= D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + if (FAILED(device->CreateTexture2D(&spatial_desc, nullptr, &m_spatial_overlay_tex))) { + spdlog::error("[VR] Failed to create spatial overlay texture (D3D11)."); + } + } + for (auto& ctx : m_2d_screen_tex) { ComPtr tex{}; if (FAILED(device->CreateTexture2D(&backbuffer_desc, nullptr, &tex))) { diff --git a/src/mods/vr/D3D11Component.hpp b/src/mods/vr/D3D11Component.hpp index d9d9e0524..f30f6b1d7 100644 --- a/src/mods/vr/D3D11Component.hpp +++ b/src/mods/vr/D3D11Component.hpp @@ -32,6 +32,7 @@ class D3D11Component { auto& openxr() { return m_openxr; } auto& get_ui_tex() { return m_ui_tex; } + auto& get_spatial_overlay_tex() { return m_spatial_overlay_tex; } bool clear_tex(ID3D11Resource* rsrc, std::optional format = std::nullopt); bool clear_tex(ID3D11Resource* rsrc, float* color, std::optional format = std::nullopt); @@ -133,6 +134,7 @@ class D3D11Component { }; ComPtr m_ui_tex{}; + ComPtr m_spatial_overlay_tex{}; // double-wide (L|R) world render for the spatial-mode overlay TextureContext m_engine_ui_ref{}; TextureContext m_engine_tex_ref{}; TextureContext m_scene_capture_tex_ref{}; diff --git a/src/mods/vr/D3D12Component.cpp b/src/mods/vr/D3D12Component.cpp index 99d70cbcd..cadadfa91 100644 --- a/src/mods/vr/D3D12Component.cpp +++ b/src/mods/vr/D3D12Component.cpp @@ -269,6 +269,9 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { const float clear_color[] = { 0.0f, 0.0f, 0.0f, 0.0f }; const auto is_2d_screen = vr->is_using_2d_screen(); + // Spatial (OpenXR) reuses the 2D-screen frame structure: content on quads, projection layer black. + const auto is_spatial_screen = vr->is_using_spatial() && runtime->is_openxr(); + const auto is_screen_capture = is_2d_screen || is_spatial_screen; auto draw_2d_view = [&](d3d12::CommandContext& commands, ID3D12Resource* render_target) { if (ui_should_invert_alpha && m_game_ui_tex.texture.Get() != nullptr && m_game_ui_tex.srv_heap != nullptr) { @@ -277,7 +280,10 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { draw_spectator_view(commands.cmd_list.Get(), is_right_eye_frame); - if (is_2d_screen && m_game_tex.texture.Get() != nullptr && m_game_tex.srv_heap != nullptr) { + if (is_screen_capture && m_game_tex.texture.Get() != nullptr && m_game_tex.srv_heap != nullptr) { + // In spatial mode the GUI toggle hides only the UI; the world stays on the quad. + const bool composite_ui = is_2d_screen || vr->is_gui_enabled(); + // Clear previous frame for (auto& screen : m_2d_screen_tex) { commands.clear_rtv(screen, clear_color, ENGINE_SRC_COLOR); @@ -294,7 +300,7 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { ENGINE_SRC_COLOR ); - if (m_game_ui_tex.texture.Get() != nullptr && m_game_ui_tex.srv_heap != nullptr) { + if (composite_ui && m_game_ui_tex.texture.Get() != nullptr && m_game_ui_tex.srv_heap != nullptr) { d3d12::render_srv_to_rtv( m_game_batch.get(), commands.cmd_list.Get(), @@ -306,7 +312,8 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { } if (!is_afr) { - // Render right side to right screen tex + // Render right side to right screen tex. With Native Stereo Fix the right eye renders + // into the scene capture target (the double-wide's right half is empty), so prefer it. if (m_scene_capture_tex.texture.Get() != nullptr) { d3d12::render_srv_to_rtv( m_game_batch.get(), @@ -328,7 +335,7 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { ); } - if (m_game_ui_tex.texture.Get() != nullptr && m_game_ui_tex.srv_heap != nullptr) { + if (composite_ui && m_game_ui_tex.texture.Get() != nullptr && m_game_ui_tex.srv_heap != nullptr) { d3d12::render_srv_to_rtv( m_game_batch.get(), commands.cmd_list.Get(), @@ -378,7 +385,7 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { m_openvr.ui_tex.commands.execute(); } else if (runtime->is_openxr() && runtime->ready() && vr->m_openxr->frame_began) { if (is_right_eye_frame) { - if (is_2d_screen) { + if (is_screen_capture) { if (is_afr) { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::UI_RIGHT, m_2d_screen_tex[0].texture.Get(), draw_2d_view, clear_rt, ENGINE_SRC_COLOR); } else { @@ -394,7 +401,7 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { if (fw_rt && g_framework->is_drawing_anything()) { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::FRAMEWORK_UI, g_framework->get_rendertarget_d3d12().Get(), D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); } - } else if (is_2d_screen) { + } else if (is_screen_capture) { m_openxr.copy((uint32_t)runtimes::OpenXR::SwapchainIndex::UI, m_2d_screen_tex[0].texture.Get(), draw_2d_view, clear_rt, ENGINE_SRC_COLOR); } else if (m_game_ui_tex.commands.ready()) { m_game_ui_tex.commands.wait(INFINITE); @@ -648,7 +655,11 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { auto& openxr_overlay = vr->get_overlay_component().get_openxr(); - if (vr->m_2d_screen_mode->value()) { + // Spatial shares 2D-screen mode's layer structure: one content quad per eye (posed at the + // aperture by generate_slate_quad) over a real-but-black projection layer. One quad per + // eye keeps the aperture edge flush; the projection layer keeps runtimes that mishandle + // quad-only frames (Oculus) anchored. + if (vr->is_using_spatial() || vr->m_2d_screen_mode->value()) { const auto left_layer = openxr_overlay.generate_slate_layer(runtimes::OpenXR::SwapchainIndex::UI, XrEyeVisibility::XR_EYE_VISIBILITY_LEFT); const auto right_layer = openxr_overlay.generate_slate_layer(runtimes::OpenXR::SwapchainIndex::UI_RIGHT, XrEyeVisibility::XR_EYE_VISIBILITY_RIGHT); diff --git a/src/mods/vr/FFakeStereoRenderingHook.cpp b/src/mods/vr/FFakeStereoRenderingHook.cpp index 12588b9b1..caabcf4a8 100644 --- a/src/mods/vr/FFakeStereoRenderingHook.cpp +++ b/src/mods/vr/FFakeStereoRenderingHook.cpp @@ -4791,10 +4791,31 @@ __forceinline void FFakeStereoRenderingHook::calculate_stereo_view_offset( const auto is_2d_screen = vr->is_using_2d_screen(); const auto rotation_offset = vr->get_rotation_offset(); - const auto current_hmd_rotation = glm::normalize(rotation_offset * glm::quat{vr->get_rotation(0)}); const auto current_eye_rotation_offset = glm::normalize(glm::quat{vr->get_eye_transform(true_index)}); - const auto new_rotation = glm::normalize(vqi_norm * current_hmd_rotation * current_eye_rotation_offset); + glm::quat new_rotation{}; + glm::quat eye_separation_rotation{}; + + if (vr->is_using_spatial()) { + // Face the cached aperture plane -- the same basis the off-axis frustum is built in, + // so the render camera and the projection cannot drift apart. Head POSITION still + // drives parallax. + const auto plane = vr->get_spatial_ui_plane(); + const auto spatial_facing = glm::quat_cast(glm::mat3{plane.right, plane.up, plane.normal}); + const auto window_rotation = glm::normalize(rotation_offset * spatial_facing); + + // Orientation is frozen window-facing with no eye cant (matching the frustum), but the + // IPD must follow the ACTUAL head orientation or the eye apex drifts off the + // projection's apex and stereo fights at the window edges. + new_rotation = glm::normalize(vqi_norm * window_rotation); // window normal, no cant + const auto actual_hmd_rotation = glm::normalize(rotation_offset * glm::quat{vr->get_rotation(0)}); + eye_separation_rotation = glm::normalize(vqi_norm * actual_hmd_rotation * current_eye_rotation_offset); + } else { + const auto current_hmd_rotation = glm::normalize(rotation_offset * glm::quat{vr->get_rotation(0)}); + new_rotation = glm::normalize(vqi_norm * current_hmd_rotation * current_eye_rotation_offset); + eye_separation_rotation = new_rotation; + } + const auto eye_offset = glm::vec3{vr->get_eye_offset((VRRuntime::Eye)(true_index))}; @@ -4806,7 +4827,7 @@ __forceinline void FFakeStereoRenderingHook::calculate_stereo_view_offset( const auto head_offset = quat_converter * (vqi_norm * (pos * world_scale)); const auto head_offset_flat = quat_converter * (vqi_norm * (pos_flat * world_scale)); - const auto eye_separation = quat_converter * (glm::normalize(new_rotation) * (eye_offset * world_scale)); + const auto eye_separation = quat_converter * (eye_separation_rotation * (eye_offset * world_scale)); if (!has_double_precision) { if (!is_2d_screen) { diff --git a/src/mods/vr/OverlayComponent.cpp b/src/mods/vr/OverlayComponent.cpp index 74b1574ab..e2d48404b 100644 --- a/src/mods/vr/OverlayComponent.cpp +++ b/src/mods/vr/OverlayComponent.cpp @@ -67,6 +67,15 @@ std::optional OverlayComponent::on_initialize_openvr() { } } + // World-locked side-by-side stereo quad for spatial mode (L|R texture halves -> eyes). + if (!create_overlay("SpatialWorld", 2.0f, m_spatial_overlay_handle)) { + vr::VROverlay()->SetOverlayInputMethod(m_spatial_overlay_handle, vr::VROverlayInputMethod_None); + vr::VROverlay()->SetOverlayFlag(m_spatial_overlay_handle, vr::VROverlayFlags_SideBySide_Parallel, true); + // The game's backbuffer alpha is meaningless for the world render; don't blend it. + vr::VROverlay()->SetOverlayFlag(m_spatial_overlay_handle, vr::VROverlayFlags_IgnoreTextureAlpha, true); + vr::VROverlay()->HideOverlay(m_spatial_overlay_handle); // only shown while spatial mode is active + } + return std::nullopt; } @@ -186,6 +195,7 @@ void OverlayComponent::update_input_mouse_emulation() { void OverlayComponent::on_post_compositor_submit() { this->update_overlay_openvr(); this->update_slate_openvr(); + this->update_spatial_overlay_openvr(); } @@ -339,42 +349,43 @@ void OverlayComponent::update_slate_openvr() { vr::VROverlay()->SetOverlayTextureBounds(m_slate_overlay_handle, &bounds); - vr::TrackedDevicePose_t pose{}; - vr::VRSystem()->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0.0f, &pose, 1); - - auto rotation_offset = glm::inverse(vr->get_rotation_offset()); + if (vr->is_using_spatial()) { + // Pin the slate to the same display aperture as the world quad, nudged toward the player. + const auto plane = vr->get_spatial_ui_plane(); + auto glm_matrix = plane.to_matrix(); + glm_matrix[3] += glm::vec4{plane.normal * 0.01f, 0.0f}; - if (vr->is_decoupled_pitch_enabled() && vr->is_decoupled_pitch_ui_adjust_enabled()) { - const auto pre_flat_rotation = vr->get_pre_flattened_rotation(); - const auto pre_flat_pitch = utility::math::pitch_only(pre_flat_rotation); + const auto steamvr_matrix = Matrix3x4f{glm::rowMajor4(glm_matrix)}; + vr::VROverlay()->SetOverlayTransformAbsolute(m_slate_overlay_handle, vr::TrackingUniverseStanding, (vr::HmdMatrix34_t*)&steamvr_matrix); + vr::VROverlay()->SetOverlayWidthInMeters(m_slate_overlay_handle, plane.display_size(vr->get_spatial_size()).x); + } else { + glm::mat4 glm_matrix{1.0f}; - // Add the inverse of the pitch rotation to the rotation offset - rotation_offset = glm::normalize(glm::inverse(pre_flat_pitch * vr->get_rotation_offset())); - } + if (m_ui_follows_view->value()) { + vr::TrackedDevicePose_t pose{}; + vr::VRSystem()->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0.0f, &pose, 1); - //auto glm_matrix = glm::rowMajor4(Matrix4x4f{*(Matrix3x4f*)&pose.mDeviceToAbsoluteTracking}); - auto glm_matrix = Matrix4x4f{rotation_offset}; - if (m_ui_follows_view->value()) { - const auto mat = glm::rowMajor4(Matrix4x4f{*(Matrix3x4f*)&pose.mDeviceToAbsoluteTracking}); - glm_matrix = glm::extractMatrixRotation(mat); - glm_matrix[3] += mat[3]; - } else { - glm_matrix[3] += vr->get_standing_origin(); - } + const auto mat = glm::rowMajor4(Matrix4x4f{*(Matrix3x4f*)&pose.mDeviceToAbsoluteTracking}); + glm_matrix = glm::extractMatrixRotation(mat); + glm_matrix[3] += mat[3]; + glm_matrix[3] -= glm_matrix[2] * m_slate_distance->value(); + glm_matrix[3] += m_slate_x_offset->value() * glm_matrix[0]; + glm_matrix[3] += m_slate_y_offset->value() * glm_matrix[1]; + glm_matrix[3].w = 1.0f; + } else { + // World-locked: the canonical UI plane (single source of the slate placement math). + glm_matrix = get_ui_plane().to_matrix(); + } - glm_matrix[3] -= glm_matrix[2] * m_slate_distance->value(); - glm_matrix[3] += m_slate_x_offset->value() * glm_matrix[0]; - glm_matrix[3] += m_slate_y_offset->value() * glm_matrix[1]; - glm_matrix[3].w = 1.0f; - - const auto steamvr_matrix = Matrix3x4f{glm::rowMajor4(glm_matrix)}; - vr::VROverlay()->SetOverlayTransformAbsolute(m_slate_overlay_handle, vr::TrackingUniverseStanding, (vr::HmdMatrix34_t*)&steamvr_matrix); + const auto steamvr_matrix = Matrix3x4f{glm::rowMajor4(glm_matrix)}; + vr::VROverlay()->SetOverlayTransformAbsolute(m_slate_overlay_handle, vr::TrackingUniverseStanding, (vr::HmdMatrix34_t*)&steamvr_matrix); - const auto is_d3d12 = g_framework->get_renderer_type() == Framework::RendererType::D3D12; - const auto size = is_d3d12 ? g_framework->get_d3d12_rt_size() : g_framework->get_d3d11_rt_size(); - const auto aspect = size.x / size.y; - const auto width_meters = m_slate_size->value() * aspect; - vr::VROverlay()->SetOverlayWidthInMeters(m_slate_overlay_handle, width_meters); + const auto is_d3d12 = g_framework->get_renderer_type() == Framework::RendererType::D3D12; + const auto size = is_d3d12 ? g_framework->get_d3d12_rt_size() : g_framework->get_d3d11_rt_size(); + const auto aspect = size.x / size.y; + const auto width_meters = get_effective_slate_size() * aspect; + vr::VROverlay()->SetOverlayWidthInMeters(m_slate_overlay_handle, width_meters); + } if (is_d3d11) { if (vr->m_d3d11.get_ui_tex().Get() == nullptr) { @@ -399,6 +410,53 @@ void OverlayComponent::update_slate_openvr() { } } +void OverlayComponent::update_spatial_overlay_openvr() { + auto& vr = VR::get(); + + if (!vr->get_runtime()->is_openvr()) { + return; + } + + // Not gated on is_gui_enabled: the GUI toggle hides only the UI slate; the world stays up + // (the eye submits are black whenever spatial is on). + if (!vr->is_using_spatial()) { + vr::VROverlay()->HideOverlay(m_spatial_overlay_handle); + return; + } + + const auto is_d3d11 = g_framework->get_renderer_type() == Framework::RendererType::D3D11; + + // D3D12 parity is a later increment; only D3D11 feeds the world quad. + ID3D11Texture2D* world_tex = is_d3d11 ? vr->m_d3d11.get_spatial_overlay_tex().Get() : nullptr; + if (world_tex == nullptr) { + vr::VROverlay()->HideOverlay(m_spatial_overlay_handle); + return; + } + + vr::VROverlay()->ShowOverlay(m_spatial_overlay_handle); + + // Side-by-side flag splits the full texture L|R, so submit the whole thing. + vr::VRTextureBounds_t bounds{0.0f, 0.0f, 1.0f, 1.0f}; + vr::VROverlay()->SetOverlayTextureBounds(m_spatial_overlay_handle, &bounds); + + const auto plane = vr->get_spatial_ui_plane(); + + const auto steamvr_matrix = Matrix3x4f{glm::rowMajor4(plane.to_matrix())}; + vr::VROverlay()->SetOverlayTransformAbsolute(m_spatial_overlay_handle, vr::TrackingUniverseStanding, (vr::HmdMatrix34_t*)&steamvr_matrix); + + // Stretch the ~square per-eye texels to the content aspect so the window matches the UI slate. + D3D11_TEXTURE2D_DESC desc{}; + world_tex->GetDesc(&desc); + const float tex_aspect_per_eye = ((float)desc.Width * 0.5f) / (float)desc.Height; + const float content_aspect = plane.half_width / glm::max(plane.half_height, 0.0001f); + vr::VROverlay()->SetOverlayTexelAspect(m_spatial_overlay_handle, content_aspect / tex_aspect_per_eye); + + vr::VROverlay()->SetOverlayWidthInMeters(m_spatial_overlay_handle, plane.display_size(vr->get_spatial_size()).x); + + vr::Texture_t tex{(void*)world_tex, vr::TextureType_DirectX, vr::ColorSpace_Auto}; + vr::VROverlay()->SetOverlayTexture(m_spatial_overlay_handle, &tex); +} + bool OverlayComponent::update_wrist_overlay_openvr() { if (!VR::get()->get_runtime()->is_openvr()) { return false; @@ -807,13 +865,53 @@ void OverlayComponent::update_overlay_openvr() { } } +float OverlayComponent::get_effective_slate_size() const { + return m_slate_size->value(); +} + +OverlayComponent::UIPlaneTransform OverlayComponent::get_ui_plane() const { + auto& vr = VR::get(); + UIPlaneTransform plane{}; + + // World-locked placement, mirroring generate_slate_quad's "UI follows view == false" branch. + auto rotation_offset = glm::inverse(vr->get_rotation_offset()); + + if (vr->is_decoupled_pitch_enabled() && vr->is_decoupled_pitch_ui_adjust_enabled()) { + const auto pre_flat_rotation = vr->get_pre_flattened_rotation(); + const auto pre_flat_pitch = utility::math::pitch_only(pre_flat_rotation); + rotation_offset = glm::normalize(glm::inverse(pre_flat_pitch * vr->get_rotation_offset())); + } + + glm::mat4 glm_matrix = Matrix4x4f{rotation_offset}; + glm_matrix[3] += vr->get_standing_origin(); + glm_matrix[3] -= glm_matrix[2] * m_slate_distance->value(); + glm_matrix[3] += m_slate_x_offset->value() * glm_matrix[0]; + glm_matrix[3] += m_slate_y_offset->value() * glm_matrix[1]; + glm_matrix[3].w = 1.0f; + + const auto is_d3d12 = g_framework->get_renderer_type() == Framework::RendererType::D3D12; + const auto rt = is_d3d12 ? g_framework->get_d3d12_rt_size() : g_framework->get_d3d11_rt_size(); + const float aspect = (rt.y != 0) ? ((float)rt.x / (float)rt.y) : (16.0f / 9.0f); + const float size_meters = get_effective_slate_size(); + + plane.center = glm::vec3{glm_matrix[3]}; + plane.right = glm::normalize(glm::vec3{glm_matrix[0]}); + plane.up = glm::normalize(glm::vec3{glm_matrix[1]}); + plane.normal = glm::normalize(glm::vec3{glm_matrix[2]}); + plane.half_width = 0.5f * size_meters * aspect; + plane.half_height = 0.5f * size_meters; + + return plane; +} + std::optional> OverlayComponent::OpenXR::generate_slate_quad( - runtimes::OpenXR::SwapchainIndex swapchain, - XrEyeVisibility eye) + runtimes::OpenXR::SwapchainIndex swapchain, + XrEyeVisibility eye) { auto& vr = VR::get(); - if (!vr->is_gui_enabled()) { + // In spatial mode this quad carries the world, so it stays up when the GUI is hidden. + if (!vr->is_gui_enabled() && !vr->is_using_spatial()) { m_parent->m_intersect_state.intersecting = false; return std::nullopt; } @@ -837,40 +935,45 @@ std::optional> OverlayComponent:: layer.eyeVisibility = eye; auto glm_matrix = glm::identity(); - - if (vr->m_overlay_component.m_ui_follows_view->value()) { - layer.space = vr->m_openxr->view_space; + float meters_w = 0.0f; + float meters_h = 0.0f; + + if (vr->is_using_spatial()) { + // The slate IS the display aperture -- the same cached plane the off-axis projections are + // built from, scaled by Spatial Size. + const auto plane = vr->get_spatial_ui_plane(); + glm_matrix = plane.to_matrix(); + + const auto size = plane.display_size(vr->get_spatial_size()); + meters_w = size.x; + meters_h = size.y; + layer.space = vr->m_openxr->stage_space; } else { - auto rotation_offset = glm::inverse(vr->get_rotation_offset()); - - if (vr->is_decoupled_pitch_enabled() && vr->is_decoupled_pitch_ui_adjust_enabled()) { - const auto pre_flat_rotation = vr->get_pre_flattened_rotation(); - const auto pre_flat_pitch = utility::math::pitch_only(pre_flat_rotation); - - // Add the inverse of the pitch rotation to the rotation offset - rotation_offset = glm::normalize(glm::inverse(pre_flat_pitch * vr->get_rotation_offset())); + const auto size_meters = m_parent->get_effective_slate_size(); + meters_w = (float)ui_swapchain.width / (float)ui_swapchain.height * size_meters; + meters_h = size_meters; + + if (vr->m_overlay_component.m_ui_follows_view->value()) { + layer.space = vr->m_openxr->view_space; + glm_matrix[3] -= glm_matrix[2] * m_parent->m_slate_distance->value(); + glm_matrix[3] += m_parent->m_slate_x_offset->value() * glm_matrix[0]; + glm_matrix[3] += m_parent->m_slate_y_offset->value() * glm_matrix[1]; + glm_matrix[3].w = 1.0f; + } else { + // World-locked: the canonical UI plane (single source of the slate placement math). + glm_matrix = m_parent->get_ui_plane().to_matrix(); + layer.space = vr->m_openxr->stage_space; } - - glm_matrix = Matrix4x4f{rotation_offset}; - glm_matrix[3] += vr->get_standing_origin(); - layer.space = vr->m_openxr->stage_space; } - const auto size_meters = m_parent->m_slate_size->value(); - const auto meters_w = (float)ui_swapchain.width / (float)ui_swapchain.height * size_meters; - const auto meters_h = size_meters; layer.size = {meters_w, meters_h}; - glm_matrix[3] -= glm_matrix[2] * m_parent->m_slate_distance->value(); - glm_matrix[3] += m_parent->m_slate_x_offset->value() * glm_matrix[0]; - glm_matrix[3] += m_parent->m_slate_y_offset->value() * glm_matrix[1]; - glm_matrix[3].w = 1.0f; - layer.pose.orientation = runtimes::OpenXR::to_openxr(glm::quat_cast(glm_matrix)); layer.pose.position = runtimes::OpenXR::to_openxr(glm_matrix[3]); - // Check if the controller pointer intersects with the quad, and we can use this to emulate the mouse - if (vr->is_using_controllers()) { + // Controller mouse emulation. The quad pose is identical for both per-eye layers, so compute + // once per frame on the left/both call; gated on the GUI actually showing. + if (is_left_eye && vr->is_using_controllers() && vr->is_gui_enabled()) { // Right only for now for testing const auto controller_index = !vr->m_swap_controllers->value() ? vr->get_right_controller_index() : vr->get_left_controller_index(); const auto right_controller_rot = glm::quat{vr->get_rotation(controller_index, false)}; @@ -908,7 +1011,7 @@ std::optional> OverlayComponent:: m_parent->m_intersect_state.intersecting = false; } } - } else { + } else if (is_left_eye) { m_parent->m_intersect_state.intersecting = false; } @@ -916,8 +1019,8 @@ std::optional> OverlayComponent:: } std::optional> OverlayComponent::OpenXR::generate_slate_cylinder( - runtimes::OpenXR::SwapchainIndex swapchain, - XrEyeVisibility eye) + runtimes::OpenXR::SwapchainIndex swapchain, + XrEyeVisibility eye) { auto& vr = VR::get(); @@ -963,7 +1066,7 @@ std::optional> OverlayComp layer.space = vr->m_openxr->stage_space; } - const auto size_meters = m_parent->m_slate_size->value(); + const auto size_meters = m_parent->get_effective_slate_size(); const auto meters_w = (float)ui_swapchain.width / (float)ui_swapchain.height * size_meters; const auto meters_h = size_meters; @@ -991,6 +1094,15 @@ std::optional> OverlayCompo runtimes::OpenXR::SwapchainIndex swapchain, XrEyeVisibility eye) { + // Spatial pins the slate to the flat aperture; a cylinder can't fit it. + if (VR::get()->is_using_spatial()) { + if (auto result = generate_slate_quad(swapchain, eye); result.has_value()) { + return *(XrCompositionLayerBaseHeader*)&result.value().get(); + } + + return std::nullopt; + } + switch ((OverlayComponent::OverlayType)m_parent->m_slate_overlay_type->value()) { default: case OverlayComponent::OverlayType::QUAD: diff --git a/src/mods/vr/OverlayComponent.hpp b/src/mods/vr/OverlayComponent.hpp index 1fceacff0..97743ec13 100644 --- a/src/mods/vr/OverlayComponent.hpp +++ b/src/mods/vr/OverlayComponent.hpp @@ -75,6 +75,7 @@ class OverlayComponent : public ModComponent { vr::VROverlayHandle_t m_overlay_handle{}; vr::VROverlayHandle_t m_thumbnail_handle{}; vr::VROverlayHandle_t m_slate_overlay_handle{}; + vr::VROverlayHandle_t m_spatial_overlay_handle{}; // world-locked stereo quad for spatial mode (OpenVR) bool m_closed_ui{false}; bool m_just_closed_ui{false}; @@ -115,7 +116,34 @@ class OverlayComponent : public ModComponent { const ModToggle::Ptr m_framework_mouse_emulation{ ModToggle::create("UI_Framework_MouseEmulation", true) }; public: - OverlayComponent() + // World-locked transform of the game UI (slate) plane in stage space. Mirrors the slate-quad + // placement (UI Distance / UI Size / offsets + recenter). Used to anchor spatial mode. + struct UIPlaneTransform { + glm::vec3 center{0.0f}; + glm::vec3 right{1.0f, 0.0f, 0.0f}; + glm::vec3 up{0.0f, 1.0f, 0.0f}; + glm::vec3 normal{0.0f, 0.0f, 1.0f}; + float half_width{1.0f}; + float half_height{1.0f}; + + // Basis + center as a world pose; every quad pinned to this plane builds its pose here. + glm::mat4 to_matrix() const { + glm::mat4 m{glm::mat3{right, up, normal}}; + m[3] = glm::vec4{center, 1.0f}; + return m; + } + + // Displayed quad size: the full content rectangle scaled by Spatial Size. + glm::vec2 display_size(float scale) const { + return {2.0f * half_width * scale, 2.0f * half_height * scale}; + } + }; + UIPlaneTransform get_ui_plane() const; + + // Content-rectangle size = the user's UI Size. Spatial Size is applied at quad generation, not here. + float get_effective_slate_size() const; + + OverlayComponent() : m_openxr{this} { m_options = { @@ -158,7 +186,7 @@ class OverlayComponent : public ModComponent { XrEyeVisibility eye = XR_EYE_VISIBILITY_BOTH ); std::optional> generate_framework_ui_quad(); - + private: XrCompositionLayerQuad m_slate_layer{}; XrCompositionLayerQuad m_slate_layer_right{}; @@ -176,4 +204,5 @@ class OverlayComponent : public ModComponent { void update_overlay_openvr(); bool update_wrist_overlay_openvr(); void update_slate_openvr(); + void update_spatial_overlay_openvr(); };} \ No newline at end of file diff --git a/src/mods/vr/runtimes/OpenVR.cpp b/src/mods/vr/runtimes/OpenVR.cpp index 28c2f1df5..d7ee2a659 100644 --- a/src/mods/vr/runtimes/OpenVR.cpp +++ b/src/mods/vr/runtimes/OpenVR.cpp @@ -193,6 +193,14 @@ VRRuntime::Error OpenVR::update_matrices(float nearz, float farz) { this->eyes[vr::Eye_Left] = glm::rowMajor4(Matrix4x4f{ *(Matrix3x4f*)&local_left } ); this->eyes[vr::Eye_Right] = glm::rowMajor4(Matrix4x4f{ *(Matrix3x4f*)&local_right } ); + // Spatial: off-axis projections recomputed every frame (parallax), bypassing the cached path. + if (VR::get()->is_using_spatial()) { + this->update_spatial_projections(nearz); + this->last_eye_matrix_nearz = nearz; + this->should_update_eye_matrices = false; + return VRRuntime::Error::SUCCESS; + } + auto get_mat = [&](vr::EVREye eye) { const auto& vr = VR::get(); std::array tan_half_fov{}; @@ -278,6 +286,24 @@ VRRuntime::Error OpenVR::update_matrices(float nearz, float farz) { return VRRuntime::Error::SUCCESS; } +void OpenVR::update_spatial_projections(float nearz) { + const auto& vr = VR::get(); + + // The cached plane and the eye positions below share the TrackingUniverseStanding frame. + const auto plane = vr->get_spatial_ui_plane(); + const SpatialAperture aperture{plane.center, plane.right, plane.up, plane.normal, plane.half_width, plane.half_height}; + + const auto current_hmd = this->get_current_hmd_pose(); + const auto hmd_pose = glm::rowMajor4(Matrix4x4f{ *(Matrix3x4f*)¤t_hmd }); + + for (uint32_t eye = 0; eye < 2; ++eye) { + const auto eye_to_absolute = hmd_pose * this->eyes[eye]; + this->set_spatial_projection(eye, glm::vec3{eye_to_absolute[3]}, aperture, nearz); + } + + this->should_recalculate_eye_projections = false; +} + void OpenVR::destroy() { if (this->loaded) { vr::VR_Shutdown(); diff --git a/src/mods/vr/runtimes/OpenVR.hpp b/src/mods/vr/runtimes/OpenVR.hpp index 90ba85494..265b2d347 100644 --- a/src/mods/vr/runtimes/OpenVR.hpp +++ b/src/mods/vr/runtimes/OpenVR.hpp @@ -34,6 +34,9 @@ struct OpenVR final : public VRRuntime { VRRuntime::Error consume_events(std::function callback) override; VRRuntime::Error update_matrices(float nearz, float farz) override; + // Per-eye off-axis projections from the cached aperture plane (shared VRRuntime helper). + void update_spatial_projections(float nearz); + void destroy() override; void enqueue_render_poses(uint32_t frame_count) override; diff --git a/src/mods/vr/runtimes/OpenXR.cpp b/src/mods/vr/runtimes/OpenXR.cpp index 197465e2a..dc2e17aeb 100644 --- a/src/mods/vr/runtimes/OpenXR.cpp +++ b/src/mods/vr/runtimes/OpenXR.cpp @@ -482,6 +482,15 @@ VRRuntime::Error OpenXR::update_matrices(float nearz, float farz) { this->eyes[1] = Matrix4x4f{OpenXR::to_glm(right_pose.orientation)}; this->eyes[1][3] = Vector4f{*(Vector3f*)&right_pose.position, 1.0f}; + // Spatial: off-axis projections recomputed every frame (parallax), bypassing the cached path. + if (VR::get()->is_using_spatial() && this->stage_views.size() >= 2) { + std::unique_lock __{this->eyes_mtx}; + this->update_spatial_projections(nearz); + this->last_eye_matrix_nearz = nearz; + this->should_update_eye_matrices = false; + return VRRuntime::Error::SUCCESS; + } + auto get_mat = [&](int eye) { const auto& vr = VR::get(); std::array tan_half_fov{}; @@ -578,6 +587,23 @@ VRRuntime::Error OpenXR::update_matrices(float nearz, float farz) { return VRRuntime::Error::SUCCESS; } +void OpenXR::update_spatial_projections(float nearz) { + const auto& vr = VR::get(); + + // The aperture IS the UI/slate plane, so the two stay locked. + // NB: must read the cached plane -- we hold pose_mtx here; get_standing_origin() would deadlock. + const auto plane = vr->get_spatial_ui_plane(); + const SpatialAperture aperture{plane.center, plane.right, plane.up, plane.normal, plane.half_width, plane.half_height}; + + for (uint32_t eye = 0; eye < 2; ++eye) { + const auto& pose = this->stage_views[eye].pose; + const glm::vec3 eye_pos{pose.position.x, pose.position.y, pose.position.z}; + this->set_spatial_projection(eye, eye_pos, aperture, nearz); + } + + this->should_recalculate_eye_projections = false; +} + VRRuntime::Error OpenXR::update_input() { std::scoped_lock _{this->event_mtx}; @@ -1873,11 +1899,13 @@ XrResult OpenXR::end_frame(const std::vector& qua layers.push_back((XrCompositionLayerBaseHeader*)&dummy_projection_layer); } + // Always push the projection layer -- in 2D/spatial it's black (content rides the quads), + // and quad-only frames break per-eye quad compositing on some runtimes (Oculus). for (auto& l : this->projection_layer_cache) { layers.push_back((XrCompositionLayerBaseHeader*)&l); } - for (auto& l : quad_layers) { + for (auto& l : quad_layers) { layers.push_back(l); } } diff --git a/src/mods/vr/runtimes/OpenXR.hpp b/src/mods/vr/runtimes/OpenXR.hpp index 9481c051b..c60e7ac5f 100644 --- a/src/mods/vr/runtimes/OpenXR.hpp +++ b/src/mods/vr/runtimes/OpenXR.hpp @@ -209,6 +209,9 @@ struct OpenXR final : public VRRuntime { std::vector views{}; std::vector stage_views{}; + // Per-eye off-axis projections from the cached aperture plane (shared VRRuntime helper). + void update_spatial_projections(float nearz); + //std::deque> stage_view_queue{}; struct PipelineState { XrFrameState frame_state{XR_TYPE_FRAME_STATE}; diff --git a/src/mods/vr/runtimes/VRRuntime.hpp b/src/mods/vr/runtimes/VRRuntime.hpp index a6e21ff80..9a52c1f4a 100644 --- a/src/mods/vr/runtimes/VRRuntime.hpp +++ b/src/mods/vr/runtimes/VRRuntime.hpp @@ -186,6 +186,55 @@ struct VRRuntime { // used to crop the rendered eye textures to account for projection adjustments float view_bounds[2][4] = {0, 1, 0, 1, 0, 1, 0, 1}; + // The world-locked aperture rectangle spatial mode looks through (mirrors + // OverlayComponent::UIPlaneTransform, which can't be included here without a cycle). + struct SpatialAperture { + glm::vec3 center{}; + glm::vec3 right{}; + glm::vec3 up{}; + glm::vec3 normal{}; // toward the player + float half_width{}; + float half_height{}; + }; + + // Off-axis (fishtank) projection for one eye through the aperture, in the GAME's + // runtime-agnostic convention; shared by OpenVR and OpenXR so the math cannot drift. + // NB: the vertical skew must be (sum_tb * -inv_tb) -- flipping it inverts vertical parallax + // (a swim that tracks head pitch). Also resets view_bounds[eye] to the full texture. + void set_spatial_projection(uint32_t eye, const glm::vec3& eye_pos, const SpatialAperture& ap, float nearz) { + // Eye offset in aperture axes; the render camera faces the aperture normal (frozen in + // calculate_stereo_view_offset), so this is a standard off-axis frustum. + const glm::vec3 e = eye_pos - ap.center; + const float ex = glm::dot(e, ap.right); + const float ey = glm::dot(e, ap.up); + float d = glm::dot(e, ap.normal); // distance from the aperture plane (eye on the +N / player side) + if (d < 0.05f) { + d = 0.05f; + } + + const float left = (-ap.half_width - ex) / d; + const float right = ( ap.half_width - ex) / d; + const float top = ( ap.half_height - ey) / d; + const float bottom = (-ap.half_height - ey) / d; + + const float sum_rl = right + left; + const float sum_tb = top + bottom; + const float inv_rl = 1.0f / (right - left); + const float inv_tb = 1.0f / (top - bottom); + + this->projections[eye] = Matrix4x4f { + (2.0f * inv_rl), 0.0f, 0.0f, 0.0f, + 0.0f, (2.0f * inv_tb), 0.0f, 0.0f, + (sum_rl * -inv_rl), (sum_tb * -inv_tb), 0.0f, 1.0f, + 0.0f, 0.0f, nearz, 0.0f + }; + + this->view_bounds[eye][0] = 0.0f; + this->view_bounds[eye][1] = 1.0f; + this->view_bounds[eye][2] = 0.0f; + this->view_bounds[eye][3] = 1.0f; + } + float last_eye_matrix_nearz = 0.01f; bool should_update_eye_matrices{true}; bool should_recalculate_eye_projections{false};