From 566ffc4fb52ffdd73f00c0b49a869470e8b3d9e3 Mon Sep 17 00:00:00 2001 From: Maksymilian Dendura Date: Tue, 22 Sep 2026 00:39:04 +0200 Subject: [PATCH] VR: Add Mono rendering method Adds a fourth rendering method that renders a single view from the point between the eyes and submits that image to both eyes every frame. It costs about as much as Synchronized Sequential, but both eyes get a new image each frame, so there is no halved refresh rate or ghosting. There is no stereo depth. Mono reuses the single view layout that AFR already uses, so is_using_afr() stays true for it. The new is_using_alternating_eyes() covers the places that depend on left/right frame parity, which mono skips. Horizontal Symmetric projection is forced (and Vertical Matched unless the user picked Symmetric) so both eyes share one frustum and the existing per eye view bounds crop the image at submit. Work that used to run only on the left or right pass (pose update, roomscale movement, aim rotation, UObjectHook attachments) now runs on the single mono pass. Tested on D3D12 with OpenXR (VDXR). The D3D11 and OpenVR paths compile but are untested. --- src/mods/UObjectHook.cpp | 3 ++- src/mods/VR.cpp | 16 +++++++++----- src/mods/VR.hpp | 28 ++++++++++++++++++++++-- src/mods/vr/D3D11Component.cpp | 17 ++++++++++---- src/mods/vr/D3D12Component.cpp | 20 +++++++++++++---- src/mods/vr/FFakeStereoRenderingHook.cpp | 28 +++++++++++++++--------- src/mods/vr/OverlayComponent.cpp | 4 ++-- src/mods/vr/runtimes/OpenXR.cpp | 4 ++-- 8 files changed, 89 insertions(+), 31 deletions(-) diff --git a/src/mods/UObjectHook.cpp b/src/mods/UObjectHook.cpp index 8386852da..3819cce07 100644 --- a/src/mods/UObjectHook.cpp +++ b/src/mods/UObjectHook.cpp @@ -612,7 +612,8 @@ void UObjectHook::on_pre_calculate_stereo_view_offset(void* stereo_device, const } // else todo? } - if ((view_index + 1) % 2 == 0) { + // Mono only has a single view pass, so there is no second view to wait for + if ((view_index + 1) % 2 == 0 || VR::get()->is_using_mono()) { tick_attachments(view_rotation, world_to_meters, view_location, is_double); } } diff --git a/src/mods/VR.cpp b/src/mods/VR.cpp index 0b5c53def..f63bbb308 100644 --- a/src/mods/VR.cpp +++ b/src/mods/VR.cpp @@ -1561,7 +1561,7 @@ void VR::update_hmd_state(bool from_view_extensions, uint32_t frame_count) { } } - if (frame_count != 0 && is_using_afr() && frame_count % 2 == 0) { + if (frame_count != 0 && is_using_alternating_eyes() && frame_count % 2 == 0) { if (runtime->is_openxr()) { std::scoped_lock __{ m_openxr->sync_assignment_mtx }; @@ -2060,7 +2060,7 @@ void VR::on_present() { m_present_thread_id = GetCurrentThreadId(); utility::ScopeGuard _guard {[&]() { - if (!is_using_afr() || (m_render_frame_count + 1) % 2 == m_left_eye_interval) { + if (!is_using_alternating_eyes() || (m_render_frame_count + 1) % 2 == m_left_eye_interval) { SetEvent(m_present_finished_event); } @@ -2069,7 +2069,7 @@ void VR::on_present() { m_frame_count = get_runtime()->internal_render_frame_count; - if (!is_using_afr() || m_render_frame_count % 2 == m_left_eye_interval) { + if (!is_using_alternating_eyes() || m_render_frame_count % 2 == m_left_eye_interval) { ResetEvent(m_present_finished_event); } @@ -2126,7 +2126,7 @@ void VR::on_present() { const auto renderer = g_framework->get_renderer_type(); vr::EVRCompositorError e = vr::EVRCompositorError::VRCompositorError_None; - const auto is_left_eye_frame = is_using_afr() ? (m_render_frame_count % 2 == m_left_eye_interval) : true; + const auto is_left_eye_frame = is_using_alternating_eyes() ? (m_render_frame_count % 2 == m_left_eye_interval) : true; if (is_left_eye_frame && get_synchronize_stage() == VR::SynchronizeStage::LATE) { const auto had_sync = runtime->got_first_sync; @@ -2217,7 +2217,7 @@ void VR::on_post_present() { detect_controllers(); - const auto is_left_eye_frame = is_using_afr() ? (is_same_frame || (m_render_frame_count % 2 == m_left_eye_interval)) : true; + const auto is_left_eye_frame = is_using_alternating_eyes() ? (is_same_frame || (m_render_frame_count % 2 == m_left_eye_interval)) : true; if (is_left_eye_frame) { if (get_synchronize_stage() == VR::SynchronizeStage::VERY_LATE || !runtime->got_first_sync) { @@ -2411,7 +2411,11 @@ void VR::on_draw_sidebar_entry(std::string_view name) { } if (selected_page == PAGE_UNREAL) { - m_rendering_method->draw("Rendering Method"); + if (m_rendering_method->draw("Rendering Method")) { + // Mono forces its own projection overrides, so the eye projections need to be regenerated when switching. + get_runtime()->should_recalculate_eye_projections = true; + } + m_synced_afr_method->draw("Synced Sequential Method"); m_world_scale->draw("World Scale"); diff --git a/src/mods/VR.hpp b/src/mods/VR.hpp index 941840c7f..58bb65b52 100644 --- a/src/mods/VR.hpp +++ b/src/mods/VR.hpp @@ -29,6 +29,7 @@ class VR : public Mod { NATIVE_STEREO = 0, SYNCHRONIZED = 1, ALTERNATING = 2, + MONO = 3, }; enum SynchronizeStage { @@ -408,9 +409,22 @@ class VR : public Mod { bool is_using_afr() const { return m_rendering_method->value() == RenderingMethod::ALTERNATING || m_rendering_method->value() == RenderingMethod::SYNCHRONIZED || + m_rendering_method->value() == RenderingMethod::MONO || m_extreme_compat_mode->value() == true; } + // Mono renders a single centered view every frame and submits it to both eyes. + // It shares the single view/single wide render target layout with AFR (is_using_afr() is true) + // but does not alternate eyes between frames. + bool is_using_mono() const { + return m_rendering_method->value() == RenderingMethod::MONO; + } + + // Whether the left and right eyes are rendered on separate (alternating) frames. + bool is_using_alternating_eyes() const { + return is_using_afr() && !is_using_mono(); + } + bool is_using_synchronized_afr() const { return m_rendering_method->value() == RenderingMethod::SYNCHRONIZED || (m_extreme_compat_mode->value() && m_rendering_method->value() == RenderingMethod::NATIVE_STEREO); @@ -624,11 +638,20 @@ class VR : public Mod { return m_extreme_compat_mode->value(); } - auto get_horizontal_projection_override() const { + // Mono needs both eyes to share the exact same frustum, the per-eye view bounds then crop the shared image. + int32_t get_horizontal_projection_override() const { + if (is_using_mono()) { + return HORIZONTAL_PROJECTION_OVERRIDE::HORIZONTAL_SYMMETRIC; + } + return m_horizontal_projection_override->value(); } - auto get_vertical_projection_override() const { + int32_t get_vertical_projection_override() const { + if (is_using_mono() && m_vertical_projection_override->value() == VERTICAL_PROJECTION_OVERRIDE::VERTICAL_DEFAULT) { + return VERTICAL_PROJECTION_OVERRIDE::VERTICAL_MATCHED; + } + return m_vertical_projection_override->value(); } @@ -837,6 +860,7 @@ class VR : public Mod { "Native Stereo", "Synchronized Sequential", "Alternating/AFR", + "Mono", }; static const inline std::vector s_sync_mode_names{ diff --git a/src/mods/vr/D3D11Component.cpp b/src/mods/vr/D3D11Component.cpp index 24b0a05aa..8fb9ebbd2 100644 --- a/src/mods/vr/D3D11Component.cpp +++ b/src/mods/vr/D3D11Component.cpp @@ -259,8 +259,10 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { const auto is_same_frame = m_last_rendered_frame > 0 && m_last_rendered_frame == vr->m_render_frame_count; m_last_rendered_frame = vr->m_render_frame_count; + // Mono uses the AFR texture layout, but every frame is submitted to both eyes (like the is_same_frame case) + const auto is_mono = vr->is_using_mono(); const auto is_actually_afr = vr->is_using_afr(); - const auto is_afr = !is_same_frame && vr->is_using_afr(); + const auto is_afr = !is_same_frame && vr->is_using_alternating_eyes(); const auto is_left_eye_frame = is_afr && vr->m_render_frame_count % 2 == vr->m_left_eye_interval; const auto is_right_eye_frame = !is_afr || vr->m_render_frame_count % 2 == vr->m_right_eye_interval; @@ -423,7 +425,14 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { if (!is_afr) { // Render right side to right screen tex - if (m_scene_capture_tex_ref.has_texture() && m_scene_capture_tex_ref.has_srv()) { + if (is_mono) { + render_srv_to_rtv( + m_game_batch.get(), + m_engine_tex_ref, + m_2d_screen_tex[1], + RECT{0, 0, (LONG)((float)m_backbuffer_size[0] / 2.0f), (LONG)m_backbuffer_size[1]} + ); + } else if (m_scene_capture_tex_ref.has_texture() && m_scene_capture_tex_ref.has_srv()) { render_srv_to_rtv( m_game_batch.get(), m_scene_capture_tex_ref, @@ -630,7 +639,7 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { if (is_actually_afr) { D3D11_BOX src_box{}; if (!vr->is_extreme_compatibility_mode_enabled()) { - if (!is_afr) { + if (!is_afr && !is_mono) { src_box.left = m_backbuffer_size[0] / 2; src_box.right = m_backbuffer_size[0]; src_box.top = 0; @@ -770,7 +779,7 @@ vr::EVRCompositorError D3D11Component::on_frame(VR* vr) { if (!m_scene_capture_tex_ref.has_texture()) { D3D11_BOX src_box{}; if (!vr->is_extreme_compatibility_mode_enabled()) { - if (!is_afr) { + if (!is_afr && !is_mono) { src_box.left = m_backbuffer_size[0] / 2; src_box.right = m_backbuffer_size[0]; src_box.top = 0; diff --git a/src/mods/vr/D3D12Component.cpp b/src/mods/vr/D3D12Component.cpp index 11fdce508..006af1754 100644 --- a/src/mods/vr/D3D12Component.cpp +++ b/src/mods/vr/D3D12Component.cpp @@ -79,8 +79,10 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { const auto is_same_frame = m_last_rendered_frame > 0 && m_last_rendered_frame == vr->m_render_frame_count; m_last_rendered_frame = vr->m_render_frame_count; + // Mono uses the AFR texture layout, but every frame is submitted to both eyes (like the is_same_frame case) + const auto is_mono = vr->is_using_mono(); const auto is_actually_afr = vr->is_using_afr(); - const auto is_afr = !is_same_frame && vr->is_using_afr(); + const auto is_afr = !is_same_frame && vr->is_using_alternating_eyes(); const auto is_left_eye_frame = is_afr && vr->m_render_frame_count % 2 == vr->m_left_eye_interval; const auto is_right_eye_frame = !is_afr || vr->m_render_frame_count % 2 == vr->m_right_eye_interval; @@ -307,7 +309,17 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { if (!is_afr) { // Render right side to right screen tex - if (m_scene_capture_tex.texture.Get() != nullptr) { + if (is_mono) { + d3d12::render_srv_to_rtv( + m_game_batch.get(), + commands.cmd_list.Get(), + m_game_tex, + m_2d_screen_tex[1], + RECT{0, 0, (LONG)((float)m_backbuffer_size[0] / 2.0f), (LONG)m_backbuffer_size[1]}, + ENGINE_SRC_COLOR, + ENGINE_SRC_COLOR + ); + } else if (m_scene_capture_tex.texture.Get() != nullptr) { d3d12::render_srv_to_rtv( m_game_batch.get(), commands.cmd_list.Get(), @@ -519,7 +531,7 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { D3D12_BOX src_box{}; if (!vr->is_extreme_compatibility_mode_enabled()) { - if (!is_afr) { + if (!is_afr && !is_mono) { src_box.left = m_backbuffer_size[0] / 2; src_box.right = m_backbuffer_size[0]; src_box.top = 0; @@ -591,7 +603,7 @@ vr::EVRCompositorError D3D12Component::on_frame(VR* vr) { } } - if (!is_afr) { + if (!is_afr && !is_mono) { if (m_scene_capture_tex.texture.Get() == nullptr) { m_openvr.copy_right(backbuffer.Get(), D3D12_RESOURCE_STATE_RENDER_TARGET); } else { diff --git a/src/mods/vr/FFakeStereoRenderingHook.cpp b/src/mods/vr/FFakeStereoRenderingHook.cpp index 148f14391..f92a54097 100644 --- a/src/mods/vr/FFakeStereoRenderingHook.cpp +++ b/src/mods/vr/FFakeStereoRenderingHook.cpp @@ -3066,7 +3066,7 @@ sdk::FSceneView* FFakeStereoRenderingHook::sceneview_constructor(sdk::FSceneView last_frame_count = g_frame_count; - const auto true_index = vr->is_using_afr() ? (g_frame_count + last_index) % 2 : last_index; + const auto true_index = vr->is_using_alternating_eyes() ? (g_frame_count + last_index) % 2 : (vr->is_using_mono() ? 0 : last_index); if (vr->is_splitscreen_compatibility_enabled() || vr->is_sceneview_compatibility_enabled()) { int32_t w = vr->get_hmd_width(); @@ -3177,7 +3177,7 @@ sdk::FSceneView* FFakeStereoRenderingHook::sceneview_constructor(sdk::FSceneView } } - if (init_options_scene_state != nullptr && !new_scene_state_inserted_this_frame && vr->is_ghosting_fix_enabled() && !known_scene_states.empty() && vr->is_using_afr() && true_index == 1) { + if (init_options_scene_state != nullptr && !new_scene_state_inserted_this_frame && vr->is_ghosting_fix_enabled() && !known_scene_states.empty() && vr->is_using_alternating_eyes() && true_index == 1) { init_options->set_stereo_pass(EStereoscopicPass::eSSP_PRIMARY); // Set the scene state to the one that isn't the current one @@ -3507,7 +3507,7 @@ void FFakeStereoRenderingHook::begin_render_viewfamily(ISceneViewExtension* exte int32_t x = 0; int32_t y = 0; - const auto true_index = vr->is_using_afr() ? (frame_count + 1) % 2 : view_index; + const auto true_index = vr->is_using_alternating_eyes() ? (frame_count + 1) % 2 : (vr->is_using_mono() ? 0 : view_index); if (!vr->is_using_afr() && true_index == 1) { x += w; @@ -4790,10 +4790,12 @@ __forceinline void FFakeStereoRenderingHook::calculate_stereo_view_offset( const auto has_double_precision = g_hook->m_has_double_precision; const auto rot_d = (Rotator*)view_rotation; + const auto is_mono = vr->is_using_mono(); + if (vr->is_using_afr() && !is_full_pass) { - true_index = g_frame_count % 2; + true_index = is_mono ? 0 : g_frame_count % 2; - if (!vr->is_using_synchronized_afr()) { + if (!vr->is_using_synchronized_afr() && !is_mono) { if (g_hook->m_has_double_precision) { if (true_index == 1) { *rot_d = g_hook->m_last_afr_rotation_double; @@ -4810,6 +4812,9 @@ __forceinline void FFakeStereoRenderingHook::calculate_stereo_view_offset( } } + // Mono only has a single pass per frame, so it has to act as both the first (left) and the last (right) eye pass. + const auto is_last_eye_pass = true_index == 1 || (is_mono && !is_full_pass); + if (true_index == 0 && !is_full_pass) { if (has_double_precision) { g_hook->m_last_pre_rotation_double = *rot_d; @@ -4927,7 +4932,10 @@ __forceinline void FFakeStereoRenderingHook::calculate_stereo_view_offset( 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); - const auto eye_offset = glm::vec3{vr->get_eye_offset((VRRuntime::Eye)(true_index))}; + // Mono renders from the point directly between the eyes. + const auto eye_offset = is_mono ? + (glm::vec3{vr->get_eye_offset(VRRuntime::Eye::LEFT)} + glm::vec3{vr->get_eye_offset(VRRuntime::Eye::RIGHT)}) * 0.5f : + glm::vec3{vr->get_eye_offset((VRRuntime::Eye)(true_index))}; const auto standing_delta = vr->get_position(0) - vr->get_standing_origin(); @@ -4971,7 +4979,7 @@ __forceinline void FFakeStereoRenderingHook::calculate_stereo_view_offset( // Roomscale movement // only do it on the right eye pass // if we did it on the left, there would be eye desyncs when the right eye is rendered - if (true_index == 1 && (vr->is_roomscale_enabled() || vr->is_aim_pawn_control_rotation_enabled())) { + if (is_last_eye_pass && (vr->is_roomscale_enabled() || vr->is_aim_pawn_control_rotation_enabled())) { const auto world = sdk::UEngine::get()->get_world(); if (const auto controller = sdk::UGameplayStatics::get()->get_player_controller(world, 0); controller != nullptr) { @@ -5040,7 +5048,7 @@ __forceinline void FFakeStereoRenderingHook::calculate_stereo_view_offset( } // Modify Player Control Rotation - if (true_index == 1 && vr->is_aim_modify_player_control_rotation_enabled() && vr->is_any_aim_method_active()) { + if (is_last_eye_pass && vr->is_aim_modify_player_control_rotation_enabled() && vr->is_any_aim_method_active()) { if (g_hook->m_tracking_system_hook != nullptr) { g_hook->m_tracking_system_hook->manual_update_control_rotation(); } @@ -5166,7 +5174,7 @@ __forceinline Matrix4x4f* FFakeStereoRenderingHook::calculate_stereo_projection_ auto true_index = index_starts_from_one ? ((view_index + 1) % 2) : (view_index % 2); if (vr->is_using_afr()) { - true_index = g_frame_count % 2; + true_index = vr->is_using_mono() ? 0 : g_frame_count % 2; } auto& double_matrix = *(Matrix4x4d*)out; @@ -5352,7 +5360,7 @@ uint32_t FFakeStereoRenderingHook::get_desired_number_of_views_hook(FFakeStereoR if (!is_stereo_enabled || (vr->is_using_afr() && !vr->is_splitscreen_compatibility_enabled())) { // We need to know about the second scene state to fix ghosting, so set the view count to 2 // after we know about it, we can continue returning 1. - if (is_stereo_enabled && vr->is_ghosting_fix_enabled() && vr->is_using_afr() && + if (is_stereo_enabled && vr->is_ghosting_fix_enabled() && vr->is_using_alternating_eyes() && g_hook->m_sceneview_data.known_scene_states.size() < 2 && g_hook->m_fixed_localplayer_view_count && !!g_hook->m_sceneview_data.constructor_hook && g_hook->m_has_view_extensions_installed) { diff --git a/src/mods/vr/OverlayComponent.cpp b/src/mods/vr/OverlayComponent.cpp index 74b1574ab..c6bc424f2 100644 --- a/src/mods/vr/OverlayComponent.cpp +++ b/src/mods/vr/OverlayComponent.cpp @@ -97,7 +97,7 @@ void OverlayComponent::update_input_mouse_emulation() { if (m_framework_intersect_state.intersecting && VR::get()->is_using_controllers()) { auto vr = VR::get(); auto& io = ImGui::GetIO(); - const auto is_initial_frame = !vr->is_using_afr() || vr->get_frame_count() % 2 == vr->m_left_eye_interval; + const auto is_initial_frame = !vr->is_using_alternating_eyes() || vr->get_frame_count() % 2 == vr->m_left_eye_interval; const auto x = m_framework_intersect_state.swapchain_intersection_point.x; const auto y = m_framework_intersect_state.swapchain_intersection_point.y; @@ -249,7 +249,7 @@ void OverlayComponent::update_input_openvr() { auto vr = VR::get(); auto& io = ImGui::GetIO(); - const auto is_initial_frame = !vr->is_using_afr() || vr->get_frame_count() % 2 == vr->m_left_eye_interval; + const auto is_initial_frame = !vr->is_using_alternating_eyes() || vr->get_frame_count() % 2 == vr->m_left_eye_interval; // Restore the previous frame's input state memcpy(io.KeysDown, m_initial_imgui_input_state.KeysDown, sizeof(io.KeysDown)); diff --git a/src/mods/vr/runtimes/OpenXR.cpp b/src/mods/vr/runtimes/OpenXR.cpp index 2f4b3a997..96fc99f23 100644 --- a/src/mods/vr/runtimes/OpenXR.cpp +++ b/src/mods/vr/runtimes/OpenXR.cpp @@ -168,7 +168,7 @@ VRRuntime::Error OpenXR::synchronize_frame(std::optional frame_count) this->pipeline_states[next_frame].frame_state = this->frame_state; this->pipeline_states[next_frame].prev_frame_count = this->last_submit_state.frame_count; - if (VR::get()->is_using_afr()) { + if (VR::get()->is_using_alternating_eyes()) { const auto afr_frame = (next_frame + 1) % OpenXR::QUEUE_SIZE; this->pipeline_states[afr_frame].frame_state = this->frame_state; this->pipeline_states[afr_frame].prev_frame_count = next_frame; @@ -177,7 +177,7 @@ VRRuntime::Error OpenXR::synchronize_frame(std::optional frame_count) this->pipeline_states[*frame_count % OpenXR::QUEUE_SIZE].frame_state = this->frame_state; this->pipeline_states[*frame_count % OpenXR::QUEUE_SIZE].prev_frame_count = *frame_count; - if (VR::get()->is_using_afr()) { + if (VR::get()->is_using_alternating_eyes()) { this->pipeline_states[(*frame_count + 1) % OpenXR::QUEUE_SIZE].frame_state = this->frame_state; this->pipeline_states[(*frame_count + 1) % OpenXR::QUEUE_SIZE].prev_frame_count = *frame_count; }