Files
2026-07-23 20:12:02 +02:00

370 lines
14 KiB
C++

// Copyright 2026 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include "VideoCommon/VR/VRFrameRegion.h"
#include <algorithm>
#include <array>
#include <cmath>
#include "Common/Logging/Log.h"
#include "VideoCommon/VideoCommon.h"
#include "VideoCommon/XFMemory.h"
namespace VR
{
namespace
{
// Union of the XFB copy rects seen since the current frame's first copy.
VRFrameRegion s_pending{};
// Previous completed frame's display region — what consumers read.
VRFrameRegion s_current{};
struct SceneRegionSample
{
VRFrameRegion region{};
u32 draw_count = 0;
};
// A game can change viewport several times in one frame (world, weapon, HUD, effect
// buffers). Counting actual draws makes the world viewport win without relying on shader
// hashes or game-specific dimensions.
constexpr size_t MAX_SCENE_REGION_SAMPLES = 8;
std::array<SceneRegionSample, MAX_SCENE_REGION_SAMPLES> s_scene_region_samples{};
size_t s_scene_region_sample_count = 0;
VRFrameRegion s_last_presentation_source{};
VRFrameRegion s_last_presentation_xfb{};
bool SameRegion(const VRFrameRegion& a, const VRFrameRegion& b)
{
return a.left == b.left && a.top == b.top && a.width == b.width && a.height == b.height;
}
} // namespace
void NotifyVRXFBCopyRegion(int left, int top, int right, int bottom)
{
const int width = right - left;
const int height = bottom - top;
if (width <= 0 || height <= 0)
return;
if (!s_pending.valid || top <= s_pending.top)
{
// First copy of a new frame (games emit strips top-down; a copy starting at or above
// the pending union restarts the accumulation). Commit the finished union.
if (s_pending.valid)
{
if (!SameRegion(s_pending, s_current))
{
INFO_LOG_FMT(VIDEO, "VR_FRAME: display region {}x{} at ({},{})", s_pending.width,
s_pending.height, s_pending.left, s_pending.top);
}
s_current = s_pending;
s_current.valid = true;
}
s_pending = VRFrameRegion{left, top, width, height, true};
}
else
{
// Additional strip of the same frame (hybrid-XFB games copy the display in pieces).
const int new_left = std::min(s_pending.left, left);
const int new_top = std::min(s_pending.top, top);
const int new_right = std::max(s_pending.left + s_pending.width, right);
const int new_bottom = std::max(s_pending.top + s_pending.height, bottom);
s_pending = VRFrameRegion{new_left, new_top, new_right - new_left, new_bottom - new_top, true};
}
}
VRFrameRegion GetVRFrameRegion()
{
return s_current;
}
void ResetVRFrameRegion()
{
s_pending = VRFrameRegion{};
s_current = VRFrameRegion{};
s_scene_region_samples = {};
s_scene_region_sample_count = 0;
s_last_presentation_source = VRFrameRegion{};
s_last_presentation_xfb = VRFrameRegion{};
}
// Port of Hydra's SetViewportType (VertexShaderManager.cpp), measured against the XFB
// display region instead of Hydra's g_final_screen_region. Thresholds kept identical —
// they are field-proven across Hydra's game library:
// - "full" means >= 90% of the frame extent, anchored within the remaining 10%.
// - Full-width bands that aren't split-screen halves are LETTERBOXED (this includes
// Metroid Prime's morph-ball 640x358 viewport, which must stay head-tracked).
// - Square, edge-anchored, multiple-of-8 viewports are render-to-texture passes.
VRViewportClass ClassifyVRViewport(const Viewport& v, int x_off, int y_off)
{
const VRFrameRegion frame = GetVRFrameRegion();
if (!frame.valid)
return VRViewportClass::MainScene;
const float width = 2.0f * std::fabs(v.wd);
const float height = 2.0f * std::fabs(v.ht);
// Position relative to the frame region's origin (EFB coords).
const float left = (v.xOrig - std::fabs(v.wd)) - static_cast<float>(x_off + frame.left);
const float top = (v.yOrig - std::fabs(v.ht)) - static_cast<float>(y_off + frame.top);
const float screen_width = static_cast<float>(frame.width);
const float screen_height = static_cast<float>(frame.height);
const float min_screen_width = 0.9f * screen_width;
const float min_screen_height = 0.9f * screen_height;
const float max_left = screen_width - min_screen_width;
const float max_top = screen_height - min_screen_height;
// Square texture on any screen edge with size a multiple of 8 (Hydra's relaxed rule:
// catches shadow/env maps incl. Twilight Princess's 216x216 and 384x384), except the
// 512x512-on-512x512 games that really render like that.
if (width == height &&
(width == 1.0f || width == 2.0f || width == 4.0f ||
(width >= 8.0f && std::fmod(width, 8.0f) == 0.0f)) &&
(left == 0.0f || top == 0.0f || top == screen_height - height ||
left == screen_width - width) &&
!(width == 512.0f && screen_width == 512.0f && screen_height == 512.0f))
{
return VRViewportClass::RenderToTexture;
}
// Zelda Twilight Princess renders the map screen's coloured highlights with this
// strange viewport (makes no sense as a real one).
if (width == 457.0f && height == 341.0f && left == 0.0f && top == 0.0f)
return VRViewportClass::RenderToTexture;
// Full width: fullscreen, letterboxed, or top/bottom split-screen.
if (width >= min_screen_width)
{
if (left > max_left)
return VRViewportClass::Offscreen;
if (height >= min_screen_height)
{
if (top > max_top)
return VRViewportClass::Offscreen;
if (width == screen_width && height == screen_height)
return VRViewportClass::MainScene;
return VRViewportClass::Letterboxed;
}
if (height >= min_screen_height * 0.5f && height <= screen_height * 0.5f)
{
if (top <= max_top)
return VRViewportClass::SplitScreen; // top half
if (top >= height && top <= height + max_top)
return VRViewportClass::SplitScreen; // bottom half
return VRViewportClass::Letterboxed; // band across the middle
}
return VRViewportClass::Letterboxed; // full-width band (cinematic bars, morph ball)
}
// Full height: left/right split-screen or a column.
if (height >= min_screen_height)
{
if (top > max_top)
return VRViewportClass::Offscreen;
if (width >= min_screen_width * 0.5f)
{
if (left <= max_left)
return VRViewportClass::SplitScreen; // left half
if (left >= width)
return VRViewportClass::SplitScreen; // right half
return VRViewportClass::HudElement; // column down the middle
}
return VRViewportClass::Letterboxed; // narrow column (Hydra kept these head-tracked)
}
// Quadrants (4-player split-screen) — must be corner-anchored half-size viewports.
if (width >= min_screen_width * 0.5f && height >= min_screen_height * 0.5f &&
width <= screen_width * 0.5f && height <= screen_height * 0.5f)
{
const bool left_col = left <= max_left;
const bool right_col = left >= width;
const bool top_row = top <= max_top;
const bool bottom_row = top >= height;
if ((left_col || right_col) && (top_row || bottom_row))
return VRViewportClass::SplitScreen;
return VRViewportClass::HudElement;
}
// Entirely outside the displayed region.
if (left >= screen_width || top >= screen_height || left + width <= 0.0f ||
top + height <= 0.0f)
{
return VRViewportClass::Offscreen;
}
return VRViewportClass::HudElement;
}
void ObserveVRPerspectiveViewport(const Viewport& v, int x_off, int y_off)
{
const VRViewportClass vclass = ClassifyVRViewport(v, x_off, y_off);
if (vclass != VRViewportClass::MainScene && vclass != VRViewportClass::Letterboxed)
return;
const float half_width = std::fabs(v.wd);
const float half_height = std::fabs(v.ht);
const int left = static_cast<int>(std::lround(v.xOrig - half_width)) - x_off;
const int top = static_cast<int>(std::lround(v.yOrig - half_height)) - y_off;
const int right = static_cast<int>(std::lround(v.xOrig + half_width)) - x_off;
const int bottom = static_cast<int>(std::lround(v.yOrig + half_height)) - y_off;
VRFrameRegion region{std::clamp(left, 0, static_cast<int>(EFB_WIDTH)),
std::clamp(top, 0, static_cast<int>(EFB_HEIGHT)), 0, 0, true};
const int clamped_right = std::clamp(right, 0, static_cast<int>(EFB_WIDTH));
const int clamped_bottom = std::clamp(bottom, 0, static_cast<int>(EFB_HEIGHT));
region.width = clamped_right - region.left;
region.height = clamped_bottom - region.top;
// Before the first XFB region has latched, ClassifyVRViewport deliberately returns
// MainScene for everything. Retain only plausible full-width display draws here.
if (region.width < static_cast<int>(EFB_WIDTH) * 9 / 10 || region.height <= 0)
return;
for (size_t i = 0; i < s_scene_region_sample_count; ++i)
{
if (SameRegion(s_scene_region_samples[i].region, region))
{
++s_scene_region_samples[i].draw_count;
return;
}
}
if (s_scene_region_sample_count < s_scene_region_samples.size())
s_scene_region_samples[s_scene_region_sample_count++] = SceneRegionSample{region, 1};
}
bool ConsumeVRPresentationSourceRegion(const VRFrameRegion& xfb_source,
VRFrameRegion* presentation_source)
{
if (!presentation_source)
return false;
const SceneRegionSample* best = nullptr;
for (size_t i = 0; i < s_scene_region_sample_count; ++i)
{
const SceneRegionSample& sample = s_scene_region_samples[i];
const int max_horizontal_error = std::max(2, xfb_source.width / 10);
const int height_delta = std::abs(sample.region.height - xfb_source.height);
const int min_contract_delta = std::max(8, xfb_source.height / 20);
const int max_contract_delta = std::max(min_contract_delta, xfb_source.height / 10);
const bool same_horizontal_extent =
std::abs(sample.region.left - xfb_source.left) <= max_horizontal_error &&
std::abs(sample.region.width - xfb_source.width) <= max_horizontal_error;
const bool top_edge_aligned = std::abs(sample.region.top - xfb_source.top) <= 2;
const bool plausible_contract_height =
height_delta <= 2 ||
(height_delta >= min_contract_delta && height_delta <= max_contract_delta);
// This correction is for a close raster-contract mismatch (448 vs 480), not an
// arbitrary letterboxed or animated full-width viewport. In particular, MKDD's
// character-select viewport is 608x348 at y=100 and must not move the composed screen.
if (!same_horizontal_extent || !top_edge_aligned || !plausible_contract_height)
continue;
if (!best || sample.draw_count > best->draw_count ||
(sample.draw_count == best->draw_count && sample.region.height > best->region.height))
{
best = &sample;
}
}
const VRFrameRegion best_region = best ? best->region : VRFrameRegion{};
s_scene_region_samples = {};
s_scene_region_sample_count = 0;
if (!best_region.valid)
return false;
const int height_delta = std::abs(best_region.height - xfb_source.height);
const int min_contract_delta = std::max(8, xfb_source.height / 20);
const int max_contract_delta = std::max(min_contract_delta, xfb_source.height / 10);
if (height_delta < min_contract_delta || height_delta > max_contract_delta)
return false;
// Preserve the game's requested horizontal copy exactly. The observed viewport is
// used only to repair the vertical scene/display contract (Trilogy 448->480, Golf
// 480->448), avoiding unrelated aspect or split-screen changes.
*presentation_source = xfb_source;
presentation_source->top = best_region.top;
presentation_source->height = best_region.height;
presentation_source->valid = true;
if (SameRegion(*presentation_source, xfb_source))
return false;
if (!SameRegion(*presentation_source, s_last_presentation_source) ||
!SameRegion(xfb_source, s_last_presentation_xfb))
{
INFO_LOG_FMT(VIDEO,
"VR_FRAME: presentation samples EFB {}x{} at ({},{}) into XFB {}x{} at ({},{})",
presentation_source->width, presentation_source->height,
presentation_source->left, presentation_source->top, xfb_source.width,
xfb_source.height, xfb_source.left, xfb_source.top);
s_last_presentation_source = *presentation_source;
s_last_presentation_xfb = xfb_source;
}
return true;
}
std::array<float, 4> CalculateVRPaneRemap(const Viewport& viewport, int x_off, int y_off,
const VRFrameRegion& frame)
{
if (!frame.valid || frame.width <= 0 || frame.height <= 0)
return {1.0f, 1.0f, 0.0f, 0.0f};
const float frame_half_width = static_cast<float>(frame.width) * 0.5f;
const float frame_half_height = static_cast<float>(frame.height) * 0.5f;
const float frame_center_x = static_cast<float>(frame.left) + frame_half_width;
const float frame_center_y = static_cast<float>(frame.top) + frame_half_height;
const float viewport_center_x = viewport.xOrig - static_cast<float>(x_off);
const float viewport_center_y = viewport.yOrig - static_cast<float>(y_off);
return {viewport.wd / frame_half_width, -viewport.ht / frame_half_height,
(viewport_center_x - frame_center_x) / frame_half_width,
-(viewport_center_y - frame_center_y) / frame_half_height};
}
float CalculateVRPaneReferenceW(float model_origin_view_z, float projection_z,
float projection_w)
{
if (std::isfinite(model_origin_view_z) && model_origin_view_z < -1.0e-4f)
return -model_origin_view_z;
if (std::isfinite(projection_z) && std::isfinite(projection_w) && projection_z != 0.0f &&
projection_z != 1.0f)
{
const float far_distance = projection_w / projection_z;
const float near_distance = far_distance * projection_z / (projection_z - 1.0f);
if (std::isfinite(near_distance) && std::isfinite(far_distance) &&
near_distance > 1.0e-4f && far_distance > near_distance)
{
return std::sqrt(near_distance * far_distance);
}
}
return 1.0f;
}
const char* GetVRViewportClassName(VRViewportClass vclass)
{
switch (vclass)
{
case VRViewportClass::MainScene:
return "MainScene";
case VRViewportClass::Letterboxed:
return "Letterboxed";
case VRViewportClass::SplitScreen:
return "SplitScreen";
case VRViewportClass::HudElement:
return "HudElement";
case VRViewportClass::RenderToTexture:
return "RenderToTexture";
case VRViewportClass::Offscreen:
return "Offscreen";
}
return "Unknown";
}
} // namespace VR