mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 14:00:26 +02:00
Implement OpenXR Settings Panel and Stereo Overlay
This commit is contained in:
1 parent
f222eedf6b
commit
1346e53cd6
22 files changed
+1466
-101
No files matched your search
@@ -182,6 +182,9 @@ cursor for 100 ms before it disappears, so tracking spikes during fast motion do
|
||||
Raw IR camera dots in `KPADGetUnifiedWpadStatus` stay invalid; the game reads the pointer from
|
||||
`KPADStatus`.
|
||||
|
||||
**Settings in the headset.** Clicking both thumbsticks together opens the settings panel described
|
||||
below; while it is open the controllers operate the panel and the game sees them idle.
|
||||
|
||||
`"gamepad"` keeps the controllers one ordinary gamepad read through PAD as a GameCube controller:
|
||||
A/B → South/East, X/Y → West/North, index triggers → trigger axes, grips → shoulders, thumbsticks
|
||||
→ sticks (clicks → stick buttons), left menu → Start. Every binding in the F10 controller menu
|
||||
@@ -191,6 +194,45 @@ Bindings are suggested for `oculus/touch_controller` (Quest 2, 3 and Pro) and
|
||||
`khr/simple_controller`. `mkw_vr_wii_remote_tests` checks the accelerometer frame, the pointer
|
||||
raycast and debounce, the picture placement and the button profile without a headset.
|
||||
|
||||
## Settings in the headset
|
||||
|
||||
The F10 settings bar is only visible on the desktop window, so the same settings are also offered on
|
||||
a panel inside the headset, in menus and during an immersive race alike, including on the Quest.
|
||||
**Click both thumbsticks together** to open it, and again to close it; the left controller's menu
|
||||
button and the panel's *Close* button also close it. It can be opened from the desktop as well, with
|
||||
**F10 → VR → Show these settings in the headset**.
|
||||
|
||||
The panel has the F10 bar's menus as tabs (VR, Graphics, Controllers, Audio, Diagnostics) and a
|
||||
*Recenter view* button. Aim a controller at it: the cursor goes where you aim, a trigger (or A / X)
|
||||
selects and drags sliders, and a thumbstick scrolls. Whichever hand last pulled its trigger does the
|
||||
pointing. Changes apply exactly as they do from the F10 bar, and the two stay in step.
|
||||
|
||||
While the panel is open, and until every button has been released after it closes, the game sees
|
||||
the VR controllers idle: no buttons, no pointer and a remote at rest. Nothing reaches the game from
|
||||
the chord, the trigger that clicked *Close*, or the menu press that closed the panel. The game is
|
||||
not paused, so a race carries on while you change settings. Other controllers (keyboard, desktop
|
||||
gamepads, Bluetooth remotes) are not affected.
|
||||
|
||||
The panel sits centred on the virtual screen, three quarters of its width across (1.8 m with the
|
||||
default `hud_width_meters`). On a menu that is the anchored menu quad; in a race it is the 2D layer's
|
||||
screen, `hud_distance_meters` ahead of the latched race origin and turned by the lean-back angle,
|
||||
whether or not `hud_virtual_screen` places the HUD there. **Recenter view** brings both back in front
|
||||
of you.
|
||||
|
||||
How it is drawn: `settings_overlay.cpp` builds the panel with a second Dear ImGui context of its own,
|
||||
a 1440 × 1080 canvas at twice the desktop menu's scale with its own font atlas, fed by the pointer
|
||||
that `openxr_input.cpp` publishes through `vr/openxr_settings_panel.h`. Aurora renders that draw data
|
||||
into a panel texture once per sealed frame and lays it over each eye after the eye is finished
|
||||
(`aurora-main/lib/stereo_overlay.cpp`): through the eye's frustum and `viewFromCenter` onto the
|
||||
screen rectangle for an immersive eye (including headset-rate interpolated eyes, which reuse the
|
||||
texture), and as a centred rectangle on a virtual-screen eye image. The eye images the OpenXR
|
||||
backends already submit carry it, so no extra swapchain or composition layer is involved. The
|
||||
ImGui backend keeps a single projection uniform, so the panel's pass is submitted on its own command
|
||||
buffer before the desktop's ImGui pass of the same frame is recorded.
|
||||
|
||||
`mkw_vr_settings_panel_tests` covers the chord, the release latch, selection, scrolling and the
|
||||
canvas mapping; `gx_fifo_tests` covers where the panel lands in each eye.
|
||||
|
||||
## The first-person camera
|
||||
|
||||
By default the headset sits where Mario Kart's own chase camera sits, and `world_units_per_meter`
|
||||
@@ -448,8 +490,10 @@ ends, including mid-frame flushes, so live setting changes cannot invalidate pen
|
||||
- The Quest build (`android/`, `docs/quest-port.md`) runs on a Quest 3 through menus and races.
|
||||
Lifecycle events and performance (about 43 game FPS) are still open. Apple visionOS packaging
|
||||
is not implemented.
|
||||
- Scene-specific comfort options, culling fixes, replay/spectator classification, and a broader VR
|
||||
settings UI beyond the current enable/replay controls are future work.
|
||||
- Scene-specific comfort options, culling fixes and replay/spectator classification are future work.
|
||||
- The headset settings panel is drawn into the eye images rather than submitted as its own quad
|
||||
layer, so its text is resampled once more than a compositor layer's would be. It has no laser
|
||||
beam, only the cursor on the panel itself, and text fields cannot be typed into without a keyboard.
|
||||
- The desktop window remains available as a mirror/fallback.
|
||||
|
||||
OpenXR diagnostics are written to the normal run log under
|
||||
|
||||
@@ -26,7 +26,7 @@ elseif (APPLE)
|
||||
endif ()
|
||||
|
||||
if (AURORA_ENABLE_GX)
|
||||
target_sources(aurora_core PRIVATE lib/imgui.cpp)
|
||||
target_sources(aurora_core PRIVATE lib/imgui.cpp lib/stereo_overlay.cpp)
|
||||
target_link_libraries(aurora_core PUBLIC imgui)
|
||||
endif ()
|
||||
|
||||
|
||||
@@ -13,6 +13,19 @@ extern "C" {
|
||||
|
||||
ImTextureID aurora_imgui_add_texture(uint32_t width, uint32_t height, const void* rgba8);
|
||||
|
||||
// A panel shown only in the headset, drawn by the host with a Dear ImGui context of its own (for
|
||||
// instance a settings menu reachable without the desktop window). It is laid over whatever the eyes
|
||||
// carry: centred on the virtual screen, `widthFraction` of that screen's width, its height following
|
||||
// the draw data's aspect. On a menu frame the screen is the quad the eye image is shown on; in an
|
||||
// immersive race it is the 2D layer's screen (aurora_set_stereo_hud_screen's width and distance,
|
||||
// whether or not the 2D layer is placed on it).
|
||||
//
|
||||
// The draw data belongs to the host's context and is rendered with Aurora's ImGui backend, so its
|
||||
// texture ids must be views that backend can bind (aurora_imgui_add_texture). Call this from the
|
||||
// producer before the frame is sealed, and leave the draw data untouched until the frame worker is
|
||||
// done with that frame (aurora_wait_for_frame_worker). Null hides the panel.
|
||||
void aurora_imgui_set_stereo_overlay(ImDrawData* drawData, float widthFraction);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "stereo.hpp"
|
||||
#include "stereo_mirror.hpp"
|
||||
#include "stereo_interpolation.hpp"
|
||||
#include "stereo_overlay.hpp"
|
||||
#include "webgpu/gpu.hpp"
|
||||
#include <webgpu/webgpu_cpp.h>
|
||||
#endif
|
||||
@@ -1613,6 +1614,7 @@ void shutdown() noexcept {
|
||||
g_stereoEyeTargets = {};
|
||||
g_stereoMirrorState.Reset();
|
||||
g_presentationImagePools = {};
|
||||
stereo_overlay::shutdown();
|
||||
imgui::shutdown();
|
||||
gfx::shutdown();
|
||||
webgpu::shutdown();
|
||||
@@ -1734,6 +1736,7 @@ struct SealedFrameContext {
|
||||
bool replayInterpolatedFrames = false;
|
||||
std::optional<AuroraStereoFrame> stereoInput;
|
||||
bool retainStereo = false;
|
||||
imgui::StereoOverlay stereoOverlay;
|
||||
};
|
||||
|
||||
// Worker-owned scene state. A separate buffer generation check protects against
|
||||
@@ -1779,6 +1782,9 @@ void run_retained_stereo_frame(gfx::SealedFrame& sealedFrame) noexcept {
|
||||
return;
|
||||
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
||||
gfx::render_stereo_eye(sealedFrame, encoder, replay, eye, false);
|
||||
// The panel texture from the last sealed frame; its draw data is not ours to touch here.
|
||||
stereo_overlay::composite_immersive(encoder, g_stereoEyeTargets[eye].output().view, replay.eyes[eye].projection,
|
||||
replay.eyes[eye].viewFromCenter, eye);
|
||||
}
|
||||
const auto sink = run_stereo_sink(encoder, input->frameToken, g_retainedStereo.logicalFrame, input->mode);
|
||||
const auto buffer = encoder.Finish();
|
||||
@@ -1847,6 +1853,8 @@ void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, u
|
||||
// ImGui draw lists are built once per frame and replayed by each slot's ImGui pass, which is why
|
||||
// the next ImGui frame cannot start until the encode phase is done.
|
||||
imgui::render_frame_data();
|
||||
// The headset panel's draw data follows the same rule on the host's side.
|
||||
ctx.stereoOverlay = imgui::latch_stereo_overlay();
|
||||
// Drop the sealed frame's lazy RAM-readback requests while the producer is still excluded; it
|
||||
// starts registering the next frame's as soon as SEALED is published.
|
||||
gfx::efb_ram::cancel();
|
||||
@@ -1923,6 +1931,15 @@ std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame,
|
||||
}
|
||||
};
|
||||
|
||||
// Ahead of every other ImGui pass of this frame: the ImGui backend keeps one projection uniform,
|
||||
// and the headset panel's canvas is not the desktop's size, so its pass has to reach the queue
|
||||
// before a desktop pass rewrites that uniform. The eyes below sample the texture it fills.
|
||||
if (const auto panel = stereo_overlay::prepare(stereo_frame_provider_active() ? ctx.stereoOverlay.drawData : nullptr,
|
||||
ctx.stereoOverlay.widthFraction)) {
|
||||
std::lock_guard submitLock(g_queueSubmitMutex);
|
||||
g_queue.Submit(1, &panel);
|
||||
}
|
||||
|
||||
if (ctx.replayInterpolatedFrames) {
|
||||
for (uint32_t interpolatedFrame = 0; interpolatedFrame < ctx.interpolatedFrameCount; ++interpolatedFrame) {
|
||||
gfx::render(sealedFrame, encoder, static_cast<int32_t>(interpolatedFrame), false);
|
||||
@@ -1976,6 +1993,9 @@ std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame,
|
||||
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
||||
gfx::render_stereo_eye(sealedFrame, encoder, *ctx.stereoReplay, eye,
|
||||
!ctx.retainStereo && eye + 1 == AURORA_STEREO_EYE_COUNT);
|
||||
stereo_overlay::composite_immersive(encoder, g_stereoEyeTargets[eye].output().view,
|
||||
ctx.stereoReplay->eyes[eye].projection,
|
||||
ctx.stereoReplay->eyes[eye].viewFromCenter, eye);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2003,6 +2023,8 @@ std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame,
|
||||
};
|
||||
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
||||
encode_virtual_screen_eye(encoder, completedMono, eye);
|
||||
const auto& output = g_stereoEyeTargets[eye].output();
|
||||
stereo_overlay::composite_flat(encoder, output.view, output.size, eye);
|
||||
}
|
||||
if (mirrorPlan == MirrorPlan::Black && !headsetOnly) {
|
||||
encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true, MirrorPlan::Black);
|
||||
|
||||
@@ -238,6 +238,10 @@ void set_stereo_hud_screen(bool enabled, float width, float distance) noexcept {
|
||||
g_stereoHudScreenEnabled.store(enabled, std::memory_order_relaxed);
|
||||
}
|
||||
bool get_stereo_hud_screen_enabled() noexcept { return g_stereoHudScreenEnabled.load(std::memory_order_relaxed); }
|
||||
void get_stereo_hud_screen_size(float& width, float& distance) noexcept {
|
||||
width = g_stereoHudScreenWidth.load(std::memory_order_relaxed);
|
||||
distance = g_stereoHudScreenDistance.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// The desktop mirror choice. Normal is the ordinary mono presentation, so a
|
||||
// build that never touches this setting presents exactly as it did before.
|
||||
|
||||
@@ -400,6 +400,8 @@ bool get_stereo_skip_copy_clears() noexcept;
|
||||
// the game's presented aspect ratio. Also live.
|
||||
void set_stereo_hud_screen(bool enabled, float width, float distance) noexcept;
|
||||
bool get_stereo_hud_screen_enabled() noexcept;
|
||||
// The screen's width and distance in world units, kept while the 2D layer is off it.
|
||||
void get_stereo_hud_screen_size(float& width, float& distance) noexcept;
|
||||
|
||||
// What the desktop window presents while a stereo provider is feeding a
|
||||
// headset. Live, and read once per presentation group by the frame worker.
|
||||
|
||||
@@ -306,4 +306,62 @@ inline Mat4x4<float> compose_hud_screen_projection(const Mat4x4<float>& eyeFrust
|
||||
return out;
|
||||
}
|
||||
|
||||
// The headset settings panel (aurora_imgui_set_stereo_overlay): a rectangle
|
||||
// centred on the virtual screen, in the same world units as the screen.
|
||||
struct OverlayPanel {
|
||||
float halfWidth = 0.0f;
|
||||
float halfHeight = 0.0f;
|
||||
float distance = 0.0f;
|
||||
|
||||
[[nodiscard]] bool valid() const noexcept { return halfWidth > 0.0f && halfHeight > 0.0f && distance > 0.0f; }
|
||||
};
|
||||
|
||||
// `widthFraction` of a virtual screen `screenWidth` across and `screenDistance`
|
||||
// ahead, its height following the panel's own aspect.
|
||||
inline OverlayPanel overlay_panel_on_screen(float screenWidth, float screenDistance, float widthFraction,
|
||||
float panelAspect) noexcept {
|
||||
if (!(panelAspect > 0.0f)) {
|
||||
return {};
|
||||
}
|
||||
const float halfWidth = 0.5f * screenWidth * widthFraction;
|
||||
return {.halfWidth = halfWidth, .halfHeight = halfWidth / panelAspect, .distance = screenDistance};
|
||||
}
|
||||
|
||||
// Clip position of a point on the panel for one eye of an immersive frame, as a
|
||||
// matrix applied to (x, y, 0, 1) with x and y running -1..1 across the panel,
|
||||
// +y up. It is the 2D layer's chain with the panel's corners in place of a
|
||||
// game draw's NDC, so the panel sits exactly where the race HUD's screen does.
|
||||
// The panel is drawn over the finished eye with no depth test, so its depth is
|
||||
// simply parked mid-volume.
|
||||
inline Mat4x4<float> compose_overlay_panel_projection(const Mat4x4<float>& eyeFrustum,
|
||||
const Mat3x4<float>& viewFromCenter,
|
||||
const OverlayPanel& panel) noexcept {
|
||||
Mat4x4<float> corners{};
|
||||
corners.m0 = {1.0f, 0.0f, 0.0f, 0.0f};
|
||||
corners.m1 = {0.0f, 1.0f, 0.0f, 0.0f};
|
||||
corners.m3 = {0.0f, 0.0f, 0.0f, 1.0f};
|
||||
const HudScreen screen{.halfWidth = panel.halfWidth, .halfHeight = panel.halfHeight, .distance = panel.distance};
|
||||
auto out = compose_hud_screen_projection(eyeFrustum, viewFromCenter, screen, corners);
|
||||
for (size_t i = 0; i < 4; ++i) {
|
||||
out.m2[i] = 0.5f * out.m3[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The same panel on a virtual-screen eye image, which the runtime shows as a
|
||||
// quad layer the screen's width across: a centred rectangle `widthFraction` of
|
||||
// the image's width, with the panel's aspect.
|
||||
inline Mat4x4<float> overlay_panel_flat_projection(float widthFraction, float panelAspect,
|
||||
float imageAspect) noexcept {
|
||||
Mat4x4<float> out{};
|
||||
if (!(panelAspect > 0.0f) || !(imageAspect > 0.0f)) {
|
||||
return out;
|
||||
}
|
||||
out.m0 = {widthFraction, 0.0f, 0.0f, 0.0f};
|
||||
out.m1 = {0.0f, widthFraction * imageAspect / panelAspect, 0.0f, 0.0f};
|
||||
out.m2 = {0.0f, 0.0f, 0.0f, 0.5f};
|
||||
out.m3 = {0.0f, 0.0f, 0.0f, 1.0f};
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace aurora::gfx::stereo_replay
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <cstddef>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -32,6 +33,10 @@ static bool g_frameDataBuilt = false;
|
||||
static std::vector<SDL_Texture*> g_sdlTextures;
|
||||
static std::vector<wgpu::Texture> g_wgpuTextures;
|
||||
|
||||
// Set by the producer, latched by the seal.
|
||||
static std::mutex g_stereoOverlayMutex;
|
||||
static StereoOverlay g_stereoOverlay;
|
||||
|
||||
void remove_legacy_ini_file(const char* basePath) noexcept {
|
||||
if (basePath == nullptr || *basePath == '\0') {
|
||||
return;
|
||||
@@ -200,6 +205,23 @@ void render(const wgpu::RenderPassEncoder& pass) noexcept {
|
||||
}
|
||||
}
|
||||
|
||||
StereoOverlay latch_stereo_overlay() noexcept {
|
||||
std::lock_guard lock(g_stereoOverlayMutex);
|
||||
return g_stereoOverlay;
|
||||
}
|
||||
|
||||
bool render_draw_data(const wgpu::RenderPassEncoder& pass, ImDrawData* data) noexcept {
|
||||
ZoneScoped;
|
||||
// The SDL renderer fallback has no render passes to draw into.
|
||||
if (g_useSdlRenderer || data == nullptr || ImGui::GetCurrentContext() == nullptr) {
|
||||
return false;
|
||||
}
|
||||
pass.PushDebugGroup("Aurora: Dear Imgui headset panel");
|
||||
ImGui_ImplWGPU_RenderDrawData(data, pass.Get());
|
||||
pass.PopDebugGroup();
|
||||
return true;
|
||||
}
|
||||
|
||||
ImTextureID add_texture(uint32_t width, uint32_t height, const uint8_t* data) noexcept {
|
||||
if (SDL_Renderer* renderer = window::get_sdl_renderer()) {
|
||||
SDL_Texture* texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_STATIC, width, height);
|
||||
@@ -251,4 +273,12 @@ extern "C" {
|
||||
ImTextureID aurora_imgui_add_texture(uint32_t width, uint32_t height, const void* rgba8) {
|
||||
return aurora::imgui::add_texture(width, height, static_cast<const uint8_t*>(rgba8));
|
||||
}
|
||||
|
||||
void aurora_imgui_set_stereo_overlay(ImDrawData* drawData, float widthFraction) {
|
||||
std::lock_guard lock(aurora::imgui::g_stereoOverlayMutex);
|
||||
aurora::imgui::g_stereoOverlay = {
|
||||
.drawData = drawData,
|
||||
.widthFraction = drawData != nullptr ? widthFraction : 0.f,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <aurora/event.h>
|
||||
|
||||
union SDL_Event;
|
||||
struct ImDrawData;
|
||||
|
||||
namespace wgpu {
|
||||
class RenderPassEncoder;
|
||||
@@ -20,4 +21,15 @@ void new_frame(const AuroraWindowSize& size) noexcept;
|
||||
// once and every presentation slot replays them. Reset by new_frame.
|
||||
void render_frame_data() noexcept;
|
||||
void render(const wgpu::RenderPassEncoder& pass) noexcept;
|
||||
|
||||
// The headset panel as aurora_imgui_set_stereo_overlay last set it.
|
||||
struct StereoOverlay {
|
||||
ImDrawData* drawData = nullptr;
|
||||
float widthFraction = 0.f;
|
||||
};
|
||||
StereoOverlay latch_stereo_overlay() noexcept;
|
||||
// Renders another context's draw data with this context's backend. The backend keeps one projection
|
||||
// uniform for every pass, so a pass whose display size differs from the desktop's must be submitted
|
||||
// before the next pass is recorded.
|
||||
bool render_draw_data(const wgpu::RenderPassEncoder& pass, ImDrawData* data) noexcept;
|
||||
} // namespace aurora::imgui
|
||||
@@ -0,0 +1,343 @@
|
||||
#include "stereo_overlay.hpp"
|
||||
|
||||
#include "gfx/common.hpp"
|
||||
#include "gfx/stereo_replay.hpp"
|
||||
#include "imgui.hpp"
|
||||
#include "webgpu/gpu.hpp"
|
||||
|
||||
#include <aurora/aurora.h>
|
||||
#include <imgui.h>
|
||||
|
||||
#include "tracy/Tracy.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
namespace aurora::stereo_overlay {
|
||||
namespace {
|
||||
|
||||
using webgpu::g_device;
|
||||
using webgpu::g_queue;
|
||||
|
||||
// Uploaded as-is into a WGSL mat4x4<f32>, like the GX uniforms' matrices.
|
||||
static_assert(sizeof(Mat4x4<float>) == 64);
|
||||
|
||||
// The panel's corners, (-1, 1) top left to (1, -1) bottom right, carried through
|
||||
// one clip-from-panel matrix per eye. UVs are interpolated perspective-correct,
|
||||
// so the texture stays straight on a panel seen at an angle. The texture holds
|
||||
// premultiplied colour, which is what ImGui's blending leaves in a cleared target.
|
||||
constexpr const char* kShader = R"""(
|
||||
struct Panel {
|
||||
clip_from_panel: mat4x4<f32>,
|
||||
};
|
||||
@group(0) @binding(0)
|
||||
var<uniform> panel: Panel;
|
||||
@group(0) @binding(1)
|
||||
var panel_sampler: sampler;
|
||||
@group(0) @binding(2)
|
||||
var panel_texture: texture_2d<f32>;
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
var<private> corners: array<vec2<f32>, 4> = array<vec2<f32>, 4>(
|
||||
vec2(-1.0, 1.0),
|
||||
vec2(-1.0, -1.0),
|
||||
vec2(1.0, 1.0),
|
||||
vec2(1.0, -1.0),
|
||||
);
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vtxIdx: u32) -> VertexOutput {
|
||||
let corner = corners[vtxIdx];
|
||||
var out: VertexOutput;
|
||||
// Row-vector convention, like the GX shaders: m0..m3 are the clip x/y/z/w rows.
|
||||
out.pos = vec4<f32>(corner, 0.0, 1.0) * panel.clip_from_panel;
|
||||
out.uv = vec2<f32>(0.5 + 0.5 * corner.x, 0.5 - 0.5 * corner.y);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
return textureSample(panel_texture, panel_sampler, in.uv);
|
||||
}
|
||||
)""";
|
||||
|
||||
struct State {
|
||||
webgpu::TextureWithSampler panel;
|
||||
wgpu::RenderPipeline pipeline;
|
||||
wgpu::BindGroupLayout bindGroupLayout;
|
||||
wgpu::TextureFormat pipelineFormat = wgpu::TextureFormat::Undefined;
|
||||
std::array<wgpu::Buffer, AURORA_STEREO_EYE_COUNT> uniforms;
|
||||
std::array<wgpu::BindGroup, AURORA_STEREO_EYE_COUNT> bindGroups;
|
||||
float widthFraction = 0.f;
|
||||
bool visible = false;
|
||||
};
|
||||
State g_state;
|
||||
|
||||
bool ensure_pipeline() {
|
||||
auto& state = g_state;
|
||||
const auto format = webgpu::g_graphicsConfig.surfaceConfiguration.format;
|
||||
if (state.pipeline && state.pipelineFormat == format) {
|
||||
return true;
|
||||
}
|
||||
state.pipeline = {};
|
||||
state.bindGroups = {};
|
||||
|
||||
wgpu::ShaderSourceWGSL source{};
|
||||
source.code = kShader;
|
||||
const wgpu::ShaderModuleDescriptor moduleDescriptor{
|
||||
.nextInChain = &source,
|
||||
.label = "Headset panel module",
|
||||
};
|
||||
const auto module = g_device.CreateShaderModule(&moduleDescriptor);
|
||||
|
||||
const std::array layoutEntries{
|
||||
wgpu::BindGroupLayoutEntry{
|
||||
.binding = 0,
|
||||
.visibility = wgpu::ShaderStage::Vertex,
|
||||
.buffer =
|
||||
wgpu::BufferBindingLayout{
|
||||
.type = wgpu::BufferBindingType::Uniform,
|
||||
.minBindingSize = sizeof(Mat4x4<float>),
|
||||
},
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry{
|
||||
.binding = 1,
|
||||
.visibility = wgpu::ShaderStage::Fragment,
|
||||
.sampler =
|
||||
wgpu::SamplerBindingLayout{
|
||||
.type = wgpu::SamplerBindingType::Filtering,
|
||||
},
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry{
|
||||
.binding = 2,
|
||||
.visibility = wgpu::ShaderStage::Fragment,
|
||||
.texture =
|
||||
wgpu::TextureBindingLayout{
|
||||
.sampleType = wgpu::TextureSampleType::Float,
|
||||
.viewDimension = wgpu::TextureViewDimension::e2D,
|
||||
},
|
||||
},
|
||||
};
|
||||
const wgpu::BindGroupLayoutDescriptor layoutDescriptor{
|
||||
.label = "Headset panel bind group layout",
|
||||
.entryCount = layoutEntries.size(),
|
||||
.entries = layoutEntries.data(),
|
||||
};
|
||||
state.bindGroupLayout = g_device.CreateBindGroupLayout(&layoutDescriptor);
|
||||
const wgpu::PipelineLayoutDescriptor pipelineLayoutDescriptor{
|
||||
.label = "Headset panel pipeline layout",
|
||||
.bindGroupLayoutCount = 1,
|
||||
.bindGroupLayouts = &state.bindGroupLayout,
|
||||
};
|
||||
const auto pipelineLayout = g_device.CreatePipelineLayout(&pipelineLayoutDescriptor);
|
||||
|
||||
constexpr wgpu::BlendComponent kPremultipliedOver{
|
||||
.operation = wgpu::BlendOperation::Add,
|
||||
.srcFactor = wgpu::BlendFactor::One,
|
||||
.dstFactor = wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
};
|
||||
const wgpu::BlendState blend{
|
||||
.color = kPremultipliedOver,
|
||||
.alpha = kPremultipliedOver,
|
||||
};
|
||||
const std::array colorTargets{wgpu::ColorTargetState{
|
||||
.format = format,
|
||||
.blend = &blend,
|
||||
.writeMask = wgpu::ColorWriteMask::All,
|
||||
}};
|
||||
const wgpu::FragmentState fragmentState{
|
||||
.module = module,
|
||||
.entryPoint = "fs_main",
|
||||
.targetCount = colorTargets.size(),
|
||||
.targets = colorTargets.data(),
|
||||
};
|
||||
const wgpu::RenderPipelineDescriptor pipelineDescriptor{
|
||||
.label = "Headset panel pipeline",
|
||||
.layout = pipelineLayout,
|
||||
.vertex =
|
||||
wgpu::VertexState{
|
||||
.module = module,
|
||||
.entryPoint = "vs_main",
|
||||
},
|
||||
.primitive =
|
||||
wgpu::PrimitiveState{
|
||||
.topology = wgpu::PrimitiveTopology::TriangleStrip,
|
||||
.cullMode = wgpu::CullMode::None,
|
||||
},
|
||||
.multisample =
|
||||
wgpu::MultisampleState{
|
||||
.count = 1,
|
||||
.mask = UINT32_MAX,
|
||||
},
|
||||
.fragment = &fragmentState,
|
||||
};
|
||||
state.pipeline = g_device.CreateRenderPipeline(&pipelineDescriptor);
|
||||
if (!state.pipeline) {
|
||||
return false;
|
||||
}
|
||||
for (auto& uniform : state.uniforms) {
|
||||
if (!uniform) {
|
||||
const wgpu::BufferDescriptor bufferDescriptor{
|
||||
.label = "Headset panel uniform",
|
||||
.usage = wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst,
|
||||
.size = sizeof(Mat4x4<float>),
|
||||
};
|
||||
uniform = g_device.CreateBuffer(&bufferDescriptor);
|
||||
}
|
||||
}
|
||||
state.pipelineFormat = format;
|
||||
return true;
|
||||
}
|
||||
|
||||
void composite(const wgpu::CommandEncoder& encoder, const wgpu::TextureView& target, const Mat4x4<float>& clipFromPanel,
|
||||
uint32_t eyeIndex) noexcept {
|
||||
auto& state = g_state;
|
||||
if (!state.visible || !state.pipeline || eyeIndex >= AURORA_STEREO_EYE_COUNT || !target) {
|
||||
return;
|
||||
}
|
||||
auto& bindGroup = state.bindGroups[eyeIndex];
|
||||
if (!bindGroup) {
|
||||
const std::array entries{
|
||||
wgpu::BindGroupEntry{
|
||||
.binding = 0,
|
||||
.buffer = state.uniforms[eyeIndex],
|
||||
.size = sizeof(Mat4x4<float>),
|
||||
},
|
||||
wgpu::BindGroupEntry{
|
||||
.binding = 1,
|
||||
.sampler = state.panel.sampler,
|
||||
},
|
||||
wgpu::BindGroupEntry{
|
||||
.binding = 2,
|
||||
.textureView = state.panel.view,
|
||||
},
|
||||
};
|
||||
const wgpu::BindGroupDescriptor descriptor{
|
||||
.label = "Headset panel bind group",
|
||||
.layout = state.bindGroupLayout,
|
||||
.entryCount = entries.size(),
|
||||
.entries = entries.data(),
|
||||
};
|
||||
bindGroup = g_device.CreateBindGroup(&descriptor);
|
||||
}
|
||||
// Each eye has its own uniform, and every pass that reads it is submitted
|
||||
// before this worker writes it again.
|
||||
g_queue.WriteBuffer(state.uniforms[eyeIndex], 0, &clipFromPanel, sizeof(clipFromPanel));
|
||||
|
||||
const std::array attachments{
|
||||
wgpu::RenderPassColorAttachment{
|
||||
.view = target,
|
||||
.loadOp = wgpu::LoadOp::Load,
|
||||
.storeOp = wgpu::StoreOp::Store,
|
||||
},
|
||||
};
|
||||
const wgpu::RenderPassDescriptor descriptor{
|
||||
.label = eyeIndex == 0 ? "Headset panel left eye" : "Headset panel right eye",
|
||||
.colorAttachmentCount = attachments.size(),
|
||||
.colorAttachments = attachments.data(),
|
||||
};
|
||||
const auto pass = encoder.BeginRenderPass(&descriptor);
|
||||
pass.SetPipeline(state.pipeline);
|
||||
pass.SetBindGroup(0, bindGroup, 0, nullptr);
|
||||
pass.Draw(4);
|
||||
pass.End();
|
||||
}
|
||||
|
||||
float panel_aspect() noexcept {
|
||||
const auto& size = g_state.panel.size;
|
||||
return size.height != 0 ? static_cast<float>(size.width) / static_cast<float>(size.height) : 0.f;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
wgpu::CommandBuffer prepare(ImDrawData* drawData, float widthFraction) noexcept {
|
||||
ZoneScoped;
|
||||
auto& state = g_state;
|
||||
state.visible = false;
|
||||
if (drawData == nullptr || !(widthFraction > 0.f)) {
|
||||
return {};
|
||||
}
|
||||
const auto width = static_cast<uint32_t>(std::lround(drawData->DisplaySize.x * drawData->FramebufferScale.x));
|
||||
const auto height = static_cast<uint32_t>(std::lround(drawData->DisplaySize.y * drawData->FramebufferScale.y));
|
||||
if (width == 0 || height == 0 || !ensure_pipeline()) {
|
||||
return {};
|
||||
}
|
||||
const auto format = webgpu::g_graphicsConfig.surfaceConfiguration.format;
|
||||
if (!state.panel.texture || state.panel.size.width != width || state.panel.size.height != height ||
|
||||
state.panel.format != format) {
|
||||
state.panel = webgpu::create_render_texture(width, height, false);
|
||||
state.bindGroups = {};
|
||||
}
|
||||
// The ImGui backend sets its viewport from the draw data, so a texture the
|
||||
// device clamped to a smaller size cannot hold the pass.
|
||||
if (state.panel.size.width != width || state.panel.size.height != height) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const wgpu::CommandEncoderDescriptor encoderDescriptor{
|
||||
.label = "Headset panel encoder",
|
||||
};
|
||||
auto encoder = g_device.CreateCommandEncoder(&encoderDescriptor);
|
||||
const std::array attachments{
|
||||
wgpu::RenderPassColorAttachment{
|
||||
.view = state.panel.view,
|
||||
.loadOp = wgpu::LoadOp::Clear,
|
||||
.storeOp = wgpu::StoreOp::Store,
|
||||
.clearValue = {.r = 0.0, .g = 0.0, .b = 0.0, .a = 0.0},
|
||||
},
|
||||
};
|
||||
const wgpu::RenderPassDescriptor passDescriptor{
|
||||
.label = "Headset panel ImGui pass",
|
||||
.colorAttachmentCount = attachments.size(),
|
||||
.colorAttachments = attachments.data(),
|
||||
};
|
||||
bool drawn = false;
|
||||
{
|
||||
const auto pass = encoder.BeginRenderPass(&passDescriptor);
|
||||
drawn = imgui::render_draw_data(pass, drawData);
|
||||
pass.End();
|
||||
}
|
||||
if (!drawn) {
|
||||
return {};
|
||||
}
|
||||
state.widthFraction = widthFraction;
|
||||
state.visible = true;
|
||||
return encoder.Finish();
|
||||
}
|
||||
|
||||
void composite_immersive(const wgpu::CommandEncoder& encoder, const wgpu::TextureView& eye,
|
||||
const Mat4x4<float>& eyeFrustum, const Mat3x4<float>& viewFromCenter,
|
||||
uint32_t eyeIndex) noexcept {
|
||||
if (!g_state.visible) {
|
||||
return;
|
||||
}
|
||||
float screenWidth = 0.f;
|
||||
float screenDistance = 0.f;
|
||||
gfx::get_stereo_hud_screen_size(screenWidth, screenDistance);
|
||||
const auto panel = gfx::stereo_replay::overlay_panel_on_screen(screenWidth, screenDistance, g_state.widthFraction,
|
||||
panel_aspect());
|
||||
if (!panel.valid()) {
|
||||
return;
|
||||
}
|
||||
composite(encoder, eye, gfx::stereo_replay::compose_overlay_panel_projection(eyeFrustum, viewFromCenter, panel),
|
||||
eyeIndex);
|
||||
}
|
||||
|
||||
void composite_flat(const wgpu::CommandEncoder& encoder, const wgpu::TextureView& eye, const wgpu::Extent3D& size,
|
||||
uint32_t eyeIndex) noexcept {
|
||||
if (!g_state.visible || size.width == 0 || size.height == 0) {
|
||||
return;
|
||||
}
|
||||
const float imageAspect = static_cast<float>(size.width) / static_cast<float>(size.height);
|
||||
composite(encoder, eye,
|
||||
gfx::stereo_replay::overlay_panel_flat_projection(g_state.widthFraction, panel_aspect(), imageAspect),
|
||||
eyeIndex);
|
||||
}
|
||||
|
||||
void shutdown() noexcept { g_state = {}; }
|
||||
|
||||
} // namespace aurora::stereo_overlay
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <aurora/math.hpp>
|
||||
#include <webgpu/webgpu_cpp.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
struct ImDrawData;
|
||||
|
||||
// The headset-only panel of aurora_imgui_set_stereo_overlay. Everything here
|
||||
// belongs to the frame worker, like the eye targets it draws into.
|
||||
namespace aurora::stereo_overlay {
|
||||
|
||||
// Renders the panel's draw data into the panel texture and returns the command
|
||||
// buffer holding that pass, which the caller submits before recording any other
|
||||
// ImGui pass: the ImGui backend's projection uniform is shared by every pass, and
|
||||
// the panel's canvas is not the desktop's size. Null draw data hides the panel,
|
||||
// and so does any failure; both return a null command buffer.
|
||||
wgpu::CommandBuffer prepare(ImDrawData* drawData, float widthFraction) noexcept;
|
||||
|
||||
// Draws the most recently prepared panel over a finished immersive eye, on the
|
||||
// 2D layer's virtual screen seen through that eye's frustum and view.
|
||||
void composite_immersive(const wgpu::CommandEncoder& encoder, const wgpu::TextureView& eye,
|
||||
const Mat4x4<float>& eyeFrustum, const Mat3x4<float>& viewFromCenter,
|
||||
uint32_t eyeIndex) noexcept;
|
||||
|
||||
// Draws it over a virtual-screen eye image, which is shown flat as a quad.
|
||||
void composite_flat(const wgpu::CommandEncoder& encoder, const wgpu::TextureView& eye, const wgpu::Extent3D& size,
|
||||
uint32_t eyeIndex) noexcept;
|
||||
|
||||
void shutdown() noexcept;
|
||||
|
||||
} // namespace aurora::stereo_overlay
|
||||
@@ -245,6 +245,70 @@ TEST(StereoReplayTest, HudScreenParksRasterDepthAtMidrangeUnderHeadMotion) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST(StereoReplayTest, OverlayPanelIsCentredOnTheVirtualScreen) {
|
||||
// Three quarters of a 1200-unit screen, 1000 ahead, with a 4:3 panel.
|
||||
const auto panel = overlay_panel_on_screen(1200.0f, 1000.0f, 0.75f, 4.0f / 3.0f);
|
||||
EXPECT_FLOAT_EQ(panel.halfWidth, 450.0f);
|
||||
EXPECT_FLOAT_EQ(panel.halfHeight, 337.5f);
|
||||
EXPECT_FLOAT_EQ(panel.distance, 1000.0f);
|
||||
EXPECT_TRUE(panel.valid());
|
||||
EXPECT_FALSE(overlay_panel_on_screen(1200.0f, 1000.0f, 0.75f, 0.0f).valid());
|
||||
EXPECT_FALSE(overlay_panel_on_screen(1200.0f, 0.0f, 0.75f, 4.0f / 3.0f).valid());
|
||||
}
|
||||
|
||||
TEST(StereoReplayTest, OverlayPanelCornersFollowTheEyeChain) {
|
||||
Mat4x4<float> eyeFrustum{};
|
||||
eyeFrustum.m0 = {1.15f, 0.0f, 0.08f, 0.0f};
|
||||
eyeFrustum.m1 = {0.0f, 1.02f, -0.03f, 0.0f};
|
||||
const float angle = 0.3f;
|
||||
const float c = std::cos(angle);
|
||||
const float s = std::sin(angle);
|
||||
Mat3x4<float> viewFromCenter{};
|
||||
viewFromCenter.m0 = {c, 0.0f, s, 15.0f};
|
||||
viewFromCenter.m1 = {0.0f, 1.0f, 0.0f, -4.0f};
|
||||
viewFromCenter.m2 = {-s, 0.0f, c, 7.0f};
|
||||
|
||||
const OverlayPanel panel{.halfWidth = 450.0f, .halfHeight = 337.5f, .distance = 1000.0f};
|
||||
const auto composed = compose_overlay_panel_projection(eyeFrustum, viewFromCenter, panel);
|
||||
|
||||
const std::array<Vec4<float>, 5> corners{{
|
||||
{-1.0f, 1.0f, 0.0f, 1.0f},
|
||||
{1.0f, 1.0f, 0.0f, 1.0f},
|
||||
{-1.0f, -1.0f, 0.0f, 1.0f},
|
||||
{1.0f, -1.0f, 0.0f, 1.0f},
|
||||
{0.0f, 0.0f, 0.0f, 1.0f},
|
||||
}};
|
||||
for (const auto& corner : corners) {
|
||||
// Top left is (-1, +1): the panel's +y is up, like the screen it sits on.
|
||||
const Vec4<float> centerPoint{corner[0] * panel.halfWidth, corner[1] * panel.halfHeight, -panel.distance, 1.0f};
|
||||
const float eyeX = dot4(viewFromCenter.m0, centerPoint);
|
||||
const float eyeY = dot4(viewFromCenter.m1, centerPoint);
|
||||
const float eyeZ = dot4(viewFromCenter.m2, centerPoint);
|
||||
const float w = dot4(composed.m3, corner);
|
||||
EXPECT_NEAR(dot4(composed.m0, corner), eyeFrustum.m0[0] * eyeX + eyeFrustum.m0[2] * eyeZ, 1e-2f);
|
||||
EXPECT_NEAR(dot4(composed.m1, corner), eyeFrustum.m1[1] * eyeY + eyeFrustum.m1[2] * eyeZ, 1e-2f);
|
||||
EXPECT_NEAR(w, -eyeZ, 1e-2f);
|
||||
ASSERT_GT(w, 0.0f);
|
||||
EXPECT_NEAR(dot4(composed.m2, corner) / w, 0.5f, 1e-5f);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(StereoReplayTest, OverlayPanelOnAFlatEyeKeepsItsAspect) {
|
||||
// A 4:3 panel three quarters across a 2064x2208 eye image.
|
||||
const float imageAspect = 2064.0f / 2208.0f;
|
||||
const auto flat = overlay_panel_flat_projection(0.75f, 4.0f / 3.0f, imageAspect);
|
||||
const Vec4<float> topRight{1.0f, 1.0f, 0.0f, 1.0f};
|
||||
const float ndcX = dot4(flat.m0, topRight) / dot4(flat.m3, topRight);
|
||||
const float ndcY = dot4(flat.m1, topRight) / dot4(flat.m3, topRight);
|
||||
EXPECT_FLOAT_EQ(ndcX, 0.75f);
|
||||
// In pixels: 0.75 * 2064 wide over ndcY * 2208 tall is the panel's 4:3.
|
||||
EXPECT_NEAR((ndcX * 2064.0f) / (ndcY * 2208.0f), 4.0f / 3.0f, 1e-4f);
|
||||
EXPECT_FLOAT_EQ(dot4(flat.m2, topRight), 0.5f);
|
||||
const Vec4<float> centre{0.0f, 0.0f, 0.0f, 1.0f};
|
||||
EXPECT_FLOAT_EQ(dot4(flat.m0, centre), 0.0f);
|
||||
EXPECT_FLOAT_EQ(dot4(flat.m1, centre), 0.0f);
|
||||
}
|
||||
|
||||
Mat3x4<float> identity3x4() {
|
||||
Mat3x4<float> m{};
|
||||
m.m0 = {1.0f, 0.0f, 0.0f, 0.0f};
|
||||
|
||||
+1
-1
@@ -256,7 +256,7 @@ the app:
|
||||
| --- | --- |
|
||||
| `debug.wiicompiled.vtxpad 0` | Turns the stride padding off, to re-check a driver update |
|
||||
| `debug.wiicompiled.validation 1` | Keeps WebGPU validation and robustness on in release builds |
|
||||
| `debug.wiicompiled.inject <n>:<button>` | Presses `a`, `b`, `x`, `y`, `start`, `up`, `down`, `left` or `right` for 12 XR frames each time `<n>` changes. As a Wii Remote, `x`/`y`/`start` are 1/2/+, the directions push the Nunchuk stick, and `home`, `c` and `z` also exist |
|
||||
| `debug.wiicompiled.inject <n>:<button>` | Presses `a`, `b`, `x`, `y`, `start`, `up`, `down`, `left` or `right` for 12 XR frames each time `<n>` changes. As a Wii Remote, `x`/`y`/`start` are 1/2/+, the directions push the Nunchuk stick, and `home`, `c` and `z` also exist. `panel` clicks both thumbsticks, opening or closing the settings panel (see `OPENXR.md`) |
|
||||
| `debug.wiicompiled.fpslog 1` | Logs the game's rendered frame rate every 5 s. The compositor's `VrApi` log line gives headset FPS, `GPU%`, `CPU%` and app GPU time (`App=`) |
|
||||
|
||||
The injector makes headset tests possible with nobody wearing the headset.
|
||||
|
||||
@@ -404,6 +404,14 @@ target_include_directories(mkw_vr_wii_remote_tests PRIVATE "${CMAKE_CURRENT_LIST
|
||||
target_compile_features(mkw_vr_wii_remote_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_vr_wii_remote_tests COMMAND mkw_vr_wii_remote_tests)
|
||||
|
||||
# The in-headset settings panel's controller chord, release latch, selection,
|
||||
# scrolling and canvas mapping, plus the thread bridge they publish through.
|
||||
add_executable(mkw_vr_settings_panel_tests tests/vr_settings_panel_tests.cpp src/vr/openxr_settings_panel.cpp)
|
||||
target_include_directories(mkw_vr_settings_panel_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(mkw_vr_settings_panel_tests PRIVATE cxx_std_17)
|
||||
set_target_properties(mkw_vr_settings_panel_tests PROPERTIES UNITY_BUILD OFF)
|
||||
add_test(NAME mkw_vr_settings_panel_tests COMMAND mkw_vr_settings_panel_tests)
|
||||
|
||||
# Resolve the real local-kart pointer walk against synthetic offline/online rosters.
|
||||
add_executable(mkw_vr_player_tests "${CMAKE_CURRENT_LIST_DIR}/tests/vr_player_tests.cpp")
|
||||
target_include_directories(mkw_vr_player_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#if defined(MKW_ENABLE_OPENXR)
|
||||
|
||||
#include "vr/openxr_runtime.h"
|
||||
#include "vr/openxr_settings_panel.h"
|
||||
#include "vr/openxr_wii_remote.h"
|
||||
|
||||
#include <array>
|
||||
@@ -54,6 +55,11 @@ struct OpenXRPointerScreen {
|
||||
// Both are bound for the Oculus Touch profile; khr/simple_controller gets
|
||||
// select/menu and the poses so an unknown runtime still offers something.
|
||||
//
|
||||
// Clicking both thumbsticks opens the in-headset settings panel
|
||||
// (openxr_settings_panel.h). While it is open, and until every button has been
|
||||
// released after it closes, the game sees idle controllers: the chord, the
|
||||
// pointer and the triggers belong to the panel.
|
||||
//
|
||||
// Lifetime: Create after the session exists (attaches the action set, which
|
||||
// OpenXR permits once per session), Sync once per xrWaitFrame, Idle while the
|
||||
// session is not running, Destroy before the session is destroyed. All of them
|
||||
@@ -75,8 +81,10 @@ public:
|
||||
|
||||
// xrSyncActions + state reads, then publishes to the virtual gamepad and
|
||||
// the Wii Remote bridge. predicted_display_time is the frame's XrTime;
|
||||
// screen is where the pointer can land this frame.
|
||||
void Sync(XrTime predicted_display_time, const OpenXRPointerScreen& screen);
|
||||
// screen is where the Wii Remote pointer can land this frame, and
|
||||
// settings_panel where the settings panel is (its whole rectangle).
|
||||
void Sync(XrTime predicted_display_time, const OpenXRPointerScreen& screen,
|
||||
const OpenXRPointerScreen& settings_panel);
|
||||
|
||||
// Publishes a remote with nothing held, at rest and not pointing, and stops
|
||||
// the haptics, for frames without focused input.
|
||||
@@ -100,8 +108,13 @@ private:
|
||||
XrTime InputSampleTime(XrTime predicted_display_time) const;
|
||||
bool AttachVirtualGamepad();
|
||||
void DetachVirtualGamepad();
|
||||
void PublishWiiRemote(XrTime predicted_display_time, const OpenXRPointerScreen& screen,
|
||||
const std::array<wii_remote::HandInputs, kHands>& hands, uint32_t injected_buttons);
|
||||
// `withheld` publishes a remote at rest with nothing held and no pointer,
|
||||
// while still tracking motion so releasing it does not read as a jolt.
|
||||
void PublishWiiRemote(XrTime input_time, const OpenXRPointerScreen& screen,
|
||||
const std::array<wii_remote::HandInputs, kHands>& hands, uint32_t injected_buttons,
|
||||
bool withheld);
|
||||
void PublishSettingsPanel(XrTime input_time, const OpenXRPointerScreen& panel,
|
||||
const settings_panel::Frame& frame);
|
||||
void UpdateRumble();
|
||||
void StopRumble();
|
||||
bool Check(XrResult result, const char* operation);
|
||||
@@ -128,6 +141,9 @@ private:
|
||||
PFN_xrVoidFunction m_convert_now_to_xr_time = nullptr;
|
||||
wii_remote::MotionTracker m_motion[kHands];
|
||||
wii_remote::PointerFilter m_pointer;
|
||||
settings_panel::Controls m_panel_controls;
|
||||
XrTime m_last_input_time = 0;
|
||||
bool m_panel_select_held = false;
|
||||
std::array<float, 2> m_horizon{1.0f, 0.0f};
|
||||
bool m_haptics_active[kHands]{};
|
||||
uint32_t m_joystick_id = 0; // SDL_JoystickID; 0 when detached
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "vr/openxr_wii_remote.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace mkw::vr {
|
||||
|
||||
// The settings panel shown inside the headset: the F10 settings, drawn by the
|
||||
// settings overlay with an ImGui context of its own and laid over the eyes by
|
||||
// Aurora (aurora_imgui_set_stereo_overlay). It sits on the virtual screen, the
|
||||
// menu quad or the race's 2D screen, and is operated with the tracked
|
||||
// controllers, which are withheld from the game while it is open.
|
||||
//
|
||||
// The XR pacing thread owns the controllers: it opens and closes the panel from
|
||||
// the controller chord, aims the pointer and publishes it here. The game thread
|
||||
// draws the panel, may close it (its Close button) or open it (the F10 bar), and
|
||||
// reads the pointer once per presented frame. Nothing in this header depends on
|
||||
// OpenXR, so the settings overlay compiles the same in builds without it.
|
||||
|
||||
// The panel's canvas in ImGui pixels, drawn at twice the desktop menu's scale.
|
||||
inline constexpr float kSettingsPanelWidthPixels = 1440.0f;
|
||||
inline constexpr float kSettingsPanelHeightPixels = 1080.0f;
|
||||
inline constexpr float kSettingsPanelUiScale = 2.0f;
|
||||
// Its width as a fraction of the virtual screen's.
|
||||
inline constexpr float kSettingsPanelWidthFraction = 0.75f;
|
||||
|
||||
struct OpenXRSettingsPanelPointer {
|
||||
bool valid = false;
|
||||
float x = 0.0f; // canvas pixels, +x right
|
||||
float y = 0.0f; // canvas pixels, +y down
|
||||
bool select = false;
|
||||
float wheel = 0.0f; // ImGui wheel steps since the last read, +up
|
||||
};
|
||||
|
||||
// Either thread.
|
||||
void OpenXRSetSettingsPanelOpen(bool open) noexcept;
|
||||
bool OpenXRSettingsPanelOpen() noexcept;
|
||||
// XR thread: this frame's pointer, and wheel steps to add to what is pending.
|
||||
void OpenXRPublishSettingsPanelPointer(bool valid, float x, float y, bool select, float wheel) noexcept;
|
||||
// Game thread: the latest pointer, taking the wheel steps accumulated since the last call.
|
||||
OpenXRSettingsPanelPointer OpenXRTakeSettingsPanelPointer() noexcept;
|
||||
|
||||
namespace settings_panel {
|
||||
|
||||
using wii_remote::HandInputs;
|
||||
|
||||
// Thumbstick deflection below this does not scroll.
|
||||
inline constexpr float kScrollDeadzone = 0.25f;
|
||||
// Wheel steps per second at full deflection. ImGui scrolls about five lines a step.
|
||||
inline constexpr float kScrollStepsPerSecond = 8.0f;
|
||||
|
||||
// The panel's half extents, in metres, on a virtual screen `screen_width` metres across.
|
||||
inline std::array<float, 2> HalfExtents(float screen_width) noexcept {
|
||||
const float half_width = 0.5f * screen_width * kSettingsPanelWidthFraction;
|
||||
return {half_width, half_width * kSettingsPanelHeightPixels / kSettingsPanelWidthPixels};
|
||||
}
|
||||
|
||||
// A hit on the panel (-1..1 across it, +v up) in canvas pixels.
|
||||
inline std::array<float, 2> CanvasPoint(const wii_remote::ScreenHit& hit) noexcept {
|
||||
return {0.5f * (hit.u + 1.0f) * kSettingsPanelWidthPixels, 0.5f * (1.0f - hit.v) * kSettingsPanelHeightPixels};
|
||||
}
|
||||
|
||||
// What the controllers do this frame.
|
||||
struct Frame {
|
||||
bool open = false; // the panel is showing
|
||||
bool withheld = false; // the game must not see the controllers
|
||||
uint32_t pointing_hand = 1;
|
||||
bool select = false;
|
||||
float wheel = 0.0f;
|
||||
};
|
||||
|
||||
// The controller side of the panel, one Update per XR frame:
|
||||
//
|
||||
// - Clicking both thumbsticks together opens or closes it. Nothing else opens
|
||||
// it from the controllers: every other button already means something to the
|
||||
// game.
|
||||
// - While it is open, the left menu button also closes it, both triggers and
|
||||
// the A / X buttons select, the thumbsticks scroll, and the hand whose trigger
|
||||
// was pulled last is the one pointing.
|
||||
// - The game gets the controllers back only once everything is released, so the
|
||||
// press that closed the panel never lands in the game as well.
|
||||
class Controls {
|
||||
public:
|
||||
// `open` is the shared flag: the chord flips it, and the game thread may
|
||||
// have changed it since the last frame. `dt_seconds` is the time since the
|
||||
// previous frame.
|
||||
Frame Update(const std::array<HandInputs, 2>& hands, bool& open, float dt_seconds) noexcept {
|
||||
const bool chord = hands[0].thumbstick_click && hands[1].thumbstick_click;
|
||||
if (chord && !m_chord_held) {
|
||||
open = !open;
|
||||
}
|
||||
m_chord_held = chord;
|
||||
|
||||
if (open && hands[0].menu && !m_menu_held && m_was_open) {
|
||||
open = false;
|
||||
}
|
||||
m_menu_held = hands[0].menu;
|
||||
|
||||
for (uint32_t hand = 0; hand < 2; ++hand) {
|
||||
const bool pulled = hands[hand].trigger > wii_remote::kPressThreshold;
|
||||
if (pulled && !m_trigger_held[hand]) {
|
||||
m_pointing_hand = hand;
|
||||
}
|
||||
m_trigger_held[hand] = pulled;
|
||||
}
|
||||
|
||||
if (open != m_was_open) {
|
||||
// Whatever is held across the change belongs to that change.
|
||||
m_release_pending = true;
|
||||
}
|
||||
if (m_release_pending && !AnyHeld(hands)) {
|
||||
m_release_pending = false;
|
||||
}
|
||||
m_was_open = open;
|
||||
|
||||
Frame frame{};
|
||||
frame.open = open;
|
||||
frame.withheld = open || m_release_pending;
|
||||
frame.pointing_hand = m_pointing_hand;
|
||||
if (!open) {
|
||||
return frame;
|
||||
}
|
||||
// Opening held nothing that selects, but a trigger still down from the
|
||||
// game must not click the first thing under the pointer.
|
||||
frame.select = !m_release_pending && (m_trigger_held[0] || m_trigger_held[1] || hands[0].primary ||
|
||||
hands[1].primary);
|
||||
const float stick = std::fabs(hands[1].stick_y) >= std::fabs(hands[0].stick_y) ? hands[1].stick_y
|
||||
: hands[0].stick_y;
|
||||
if (std::fabs(stick) > kScrollDeadzone) {
|
||||
const float magnitude = (std::fabs(stick) - kScrollDeadzone) / (1.0f - kScrollDeadzone);
|
||||
frame.wheel = std::copysign(magnitude, stick) * kScrollStepsPerSecond * std::clamp(dt_seconds, 0.0f, 0.1f);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
void Reset() noexcept { *this = Controls{}; }
|
||||
|
||||
static bool AnyHeld(const std::array<HandInputs, 2>& hands) noexcept {
|
||||
for (const HandInputs& hand : hands) {
|
||||
if (hand.primary || hand.secondary || hand.menu || hand.thumbstick_click ||
|
||||
hand.trigger > wii_remote::kPressThreshold || hand.squeeze > wii_remote::kPressThreshold) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
bool m_chord_held = false;
|
||||
bool m_menu_held = false;
|
||||
std::array<bool, 2> m_trigger_held{};
|
||||
bool m_was_open = false;
|
||||
bool m_release_pending = false;
|
||||
uint32_t m_pointing_hand = 1;
|
||||
};
|
||||
|
||||
} // namespace settings_panel
|
||||
|
||||
} // namespace mkw::vr
|
||||
+34
-16
@@ -962,6 +962,31 @@ constexpr DWORD kCppExceptionCodeMsvc = 0xE06D7363;
|
||||
constexpr DWORD kAsanFatalAppExit = 0x40000015; // STATUS_FATAL_APP_EXIT
|
||||
LONG ReportFatalSehAndExit(EXCEPTION_POINTERS* info);
|
||||
|
||||
// Exceptions the processor itself raises for the faulting instruction.
|
||||
bool IsCpuFaultException(DWORD code) noexcept {
|
||||
switch (code) {
|
||||
case EXCEPTION_ACCESS_VIOLATION:
|
||||
case EXCEPTION_IN_PAGE_ERROR:
|
||||
case EXCEPTION_ILLEGAL_INSTRUCTION:
|
||||
case EXCEPTION_PRIV_INSTRUCTION:
|
||||
case EXCEPTION_INT_DIVIDE_BY_ZERO:
|
||||
case EXCEPTION_INT_OVERFLOW:
|
||||
case EXCEPTION_ARRAY_BOUNDS_EXCEEDED:
|
||||
case EXCEPTION_DATATYPE_MISALIGNMENT:
|
||||
case EXCEPTION_STACK_OVERFLOW:
|
||||
case EXCEPTION_FLT_DENORMAL_OPERAND:
|
||||
case EXCEPTION_FLT_DIVIDE_BY_ZERO:
|
||||
case EXCEPTION_FLT_INEXACT_RESULT:
|
||||
case EXCEPTION_FLT_INVALID_OPERATION:
|
||||
case EXCEPTION_FLT_OVERFLOW:
|
||||
case EXCEPTION_FLT_STACK_CHECK:
|
||||
case EXCEPTION_FLT_UNDERFLOW:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void ReportStructuredException(EXCEPTION_POINTERS* info) {
|
||||
if (!info || !info->ExceptionRecord) {
|
||||
RT_LOG(RT_TAG_RUNTIME) << "Structured exception occurred, but no diagnostic info was captured." << std::endl;
|
||||
@@ -1051,22 +1076,15 @@ LONG CALLBACK SehLogger(EXCEPTION_POINTERS* info) {
|
||||
if (g_suppressSehReporting) {
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
// Let C++ exceptions propagate to std::terminate so we can log their what().
|
||||
if (info->ExceptionRecord->ExceptionCode == kCppExceptionCodeGcc ||
|
||||
info->ExceptionRecord->ExceptionCode == kCppExceptionCodeMsvc) {
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
if (info->ExceptionRecord->ExceptionCode == 0x40010006 || // DBG_PRINTEXCEPTION_C
|
||||
info->ExceptionRecord->ExceptionCode == 0x4001000A || // DBG_PRINTEXCEPTION_WIDE_C (OutputDebugStringW)
|
||||
info->ExceptionRecord->ExceptionCode == 0x406D1388 || // SetThreadName
|
||||
info->ExceptionRecord->ExceptionCode == kAsanFatalAppExit) { // ASan reporting - let it print first
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
// Software-raised exceptions (customer bit set) are used for internal control flow by
|
||||
// system DLLs (e.g. msxml6 while mscms parses a display colour profile) and are caught
|
||||
// by their own frame handlers. Only hardware faults are fatal at first chance; anything
|
||||
// else that truly goes unhandled reaches UnhandledSehFilter.
|
||||
if ((info->ExceptionRecord->ExceptionCode & 0x20000000u) != 0) {
|
||||
// This handler sees every exception on every thread before any frame handler
|
||||
// does, so only CPU faults are fatal here. Everything else is raised in
|
||||
// software and is often caught by its own frames: C++ exceptions (left to
|
||||
// reach std::terminate so their what() is logged), OutputDebugString,
|
||||
// SetThreadName, ASan reports, msxml6's customer codes while mscms parses a
|
||||
// display colour profile, and RPC's 0x6BA (RPC_S_SERVER_UNAVAILABLE) inside
|
||||
// the shell folder picker behind F10 > Diagnostics > Export Logs. Any of
|
||||
// them that truly goes unhandled still reaches UnhandledSehFilter.
|
||||
if (!IsCpuFaultException(info->ExceptionRecord->ExceptionCode)) {
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
return ReportFatalSehAndExit(info);
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
#include "vr/mkw_vr_policy.h"
|
||||
#include "vr/openxr_diagnostics.h"
|
||||
#include "vr/openxr_integration.h"
|
||||
#include "vr/openxr_settings_panel.h"
|
||||
#include "vr/openxr_wii_remote.h"
|
||||
#include "wii_remote_input.h"
|
||||
|
||||
#include <aurora/imgui.h>
|
||||
#include <imgui.h>
|
||||
#include <SDL3/SDL_dialog.h>
|
||||
#include <SDL3/SDL_error.h>
|
||||
@@ -28,6 +30,7 @@
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <cfloat>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
@@ -81,6 +84,12 @@ const char* GraphicsApiDisplayName() {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// Fixed widths in the menus are written for the desktop bar's 13 px font. The
|
||||
// headset's settings panel draws the same menus with a larger one.
|
||||
float Scaled(float pixels) {
|
||||
return pixels * ImGui::GetFontSize() / 13.0f;
|
||||
}
|
||||
|
||||
bool g_topBarVisible = false;
|
||||
bool g_rumbleEnabled = RuntimeConfigFile::RumbleEnabled(true);
|
||||
int g_controllerPort = 0;
|
||||
@@ -364,7 +373,7 @@ void DrawWiiRemoteAccelerometer(uint32_t port) {
|
||||
// and it falls back to a nominal zero point, leaving a small per-axis bias;
|
||||
// measured here with the remote at rest.
|
||||
if (WiiRemoteInput::IsAccelCalibrating()) {
|
||||
ImGui::ProgressBar(WiiRemoteInput::AccelCalibrationProgress(), ImVec2(220.0f, 0.0f), "Hold still...");
|
||||
ImGui::ProgressBar(WiiRemoteInput::AccelCalibrationProgress(), ImVec2(Scaled(220.0f), 0.0f), "Hold still...");
|
||||
} else if (ImGui::Button("Calibrate (remote lying flat, buttons up)")) {
|
||||
WiiRemoteInput::StartAccelCalibration(port);
|
||||
}
|
||||
@@ -492,7 +501,7 @@ int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) {
|
||||
|
||||
void DrawExpressionSettings() {
|
||||
ImGui::SeparatorText("Expressions (Dolphin syntax)");
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 440.0f);
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(440.0f));
|
||||
ImGui::TextDisabled(
|
||||
"Optional. An expression overrides nothing: its result is combined with the "
|
||||
"button mapping above. Operators ! & | ^ and functions if, min, max, clamp, "
|
||||
@@ -538,7 +547,7 @@ void DrawExpressionSettings() {
|
||||
for (size_t i = 0; i < InputBindings::kControls.size(); ++i) {
|
||||
ImGui::PushID(static_cast<int>(i) + 2000);
|
||||
std::string& text = buffers[i];
|
||||
ImGui::SetNextItemWidth(300.0f);
|
||||
ImGui::SetNextItemWidth(Scaled(300.0f));
|
||||
if (ImGui::InputText(InputBindings::kControls[i].label, text.data(), text.capacity() + 1,
|
||||
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackResize,
|
||||
ExpressionResizeCallback, &text)) {
|
||||
@@ -708,7 +717,7 @@ void DrawControllerSettings() {
|
||||
|
||||
const NativeButtonItem& current = NativeButtonForValue(mappingIt->nativeButton);
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
ImGui::SetNextItemWidth(190.0f);
|
||||
ImGui::SetNextItemWidth(Scaled(190.0f));
|
||||
if (ImGui::BeginCombo("##primary", current.label)) {
|
||||
for (const auto& candidate : kNativeButtons) {
|
||||
const bool selected = candidate.nativeButton == mappingIt->nativeButton;
|
||||
@@ -741,7 +750,7 @@ void DrawControllerSettings() {
|
||||
ImGui::TextUnformatted("or");
|
||||
ImGui::SameLine();
|
||||
const char* altLabel = altBound ? NativeButtonForValue(altIt->nativeButton).label : "None";
|
||||
ImGui::SetNextItemWidth(190.0f);
|
||||
ImGui::SetNextItemWidth(Scaled(190.0f));
|
||||
if (ImGui::BeginCombo("##alt", altLabel)) {
|
||||
for (const auto& candidate : kNativeButtons) {
|
||||
const bool isNone = candidate.nativeButton == PAD_NATIVE_BUTTON_INVALID;
|
||||
@@ -772,7 +781,7 @@ void DrawControllerSettings() {
|
||||
}
|
||||
|
||||
void DrawAudioSettings() {
|
||||
ImGui::SetNextItemWidth(220.0f);
|
||||
ImGui::SetNextItemWidth(Scaled(220.0f));
|
||||
if (ImGui::SliderInt("Master", &g_audioVolumePercent, 0, 100, "%d%%")) {
|
||||
const float volume = static_cast<float>(g_audioVolumePercent) / 100.0f;
|
||||
AudioBackend::Instance().SetMasterVolume(volume);
|
||||
@@ -918,7 +927,7 @@ void DrawGraphicsSettings() {
|
||||
aurora_set_display_mode(AURORA_DISPLAY_MODE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
|
||||
ImGui::TextDisabled("Frame interpolation is experimental, you might find visual artifacts");
|
||||
ImGui::PopTextWrapPos();
|
||||
if (ImGui::Checkbox("Disable copy filter", &g_disableCopyFilter)) {
|
||||
@@ -963,6 +972,19 @@ void DrawVrSettings() {
|
||||
"and None leaves the window black. Menus reach the headset as a screen showing this "
|
||||
"same desktop image, so the eye choices only differ from Normal during a race.");
|
||||
}
|
||||
ImGui::BeginDisabled(!mkw::vr::OpenXRIsRunning());
|
||||
bool settingsPanelOpen = mkw::vr::OpenXRSettingsPanelOpen();
|
||||
if (ImGui::Checkbox("Show these settings in the headset", &settingsPanelOpen)) {
|
||||
mkw::vr::OpenXRSetSettingsPanelOpen(settingsPanelOpen);
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
||||
ImGui::SetTooltip(
|
||||
"Opens these settings on a panel in front of you, in menus and races alike.\n"
|
||||
"In the headset, clicking both thumbsticks together opens and closes it too.\n"
|
||||
"Aim at it and pull a trigger to change a setting; push a thumbstick to scroll.\n"
|
||||
"While it is open the game does not see the VR controllers.");
|
||||
}
|
||||
if (ImGui::Combo("VR controllers", &g_vrControllerMode, kVrControllerModeLabels.data(),
|
||||
static_cast<int>(kVrControllerModeLabels.size()))) {
|
||||
mkw::vr::OpenXRSetControllerMode(static_cast<mkw::vr::OpenXRControllerMode>(g_vrControllerMode));
|
||||
@@ -1035,7 +1057,7 @@ void DrawVrSettings() {
|
||||
"Wii's reused EFB for the next frame; an eye attachment is built fresh, so "
|
||||
"replaying it only erases the eye.");
|
||||
}
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
|
||||
ImGui::TextDisabled(
|
||||
"Both apply on the next frame. Turning either off restores the raw replay and is "
|
||||
"expected to black out the eyes.");
|
||||
@@ -1139,7 +1161,7 @@ void DrawVrSettings() {
|
||||
RuntimeConfigFile::SetVrFirstPersonHeadRightMeters(g_vrFirstPersonHeadRight);
|
||||
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
|
||||
}
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
|
||||
ImGui::TextDisabled("Where the head sits in the kart's own frame.");
|
||||
ImGui::PopTextWrapPos();
|
||||
constexpr std::array<const char*, 3> kRotationLabels{"Yaw only", "Yaw + Pitch", "Full rotation"};
|
||||
@@ -1310,7 +1332,7 @@ void DrawDiagnosticsSettings() {
|
||||
"Turn it on to report stutter or black frames in VR, and off again afterwards.\n"
|
||||
"Off by default. Applies immediately and is remembered.");
|
||||
}
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
|
||||
if (g_openxrDiagnosticsLogging && !mkw::vr::OpenXRIsRunning()) {
|
||||
ImGui::TextDisabled("OpenXR is not running, so nothing is logged until a VR session starts.");
|
||||
}
|
||||
@@ -1337,7 +1359,7 @@ void DrawDiagnosticsSettings() {
|
||||
failed = g_logExport.failed;
|
||||
}
|
||||
if (!message.empty()) {
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
|
||||
if (failed) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.35f, 1.0f), "%s", message.c_str());
|
||||
} else {
|
||||
@@ -1444,13 +1466,7 @@ void DrawStartupScreen() {
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
|
||||
void DrawTopBar() {
|
||||
if (!g_topBarVisible || !ImGui::BeginMainMenuBar()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::TextUnformatted("WiiCompiled");
|
||||
ImGui::Separator();
|
||||
void DrawResolutionMenu() {
|
||||
const auto resolutionIt = std::find_if(kResolutions.begin(), kResolutions.end(), [](const ResolutionItem& item) {
|
||||
return std::fabs(item.scale - g_resolutionScale) < 0.001f;
|
||||
});
|
||||
@@ -1469,6 +1485,16 @@ void DrawTopBar() {
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
}
|
||||
|
||||
void DrawTopBar() {
|
||||
if (!g_topBarVisible || !ImGui::BeginMainMenuBar()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::TextUnformatted("WiiCompiled");
|
||||
ImGui::Separator();
|
||||
DrawResolutionMenu();
|
||||
|
||||
if (ImGui::BeginMenu("Graphics")) {
|
||||
DrawGraphicsSettings();
|
||||
@@ -1574,6 +1600,143 @@ void PersistDisplayModeIfChanged() {
|
||||
g_displayMode = active;
|
||||
RuntimeConfigFile::SetDisplayMode(std::string(kDisplayModeConfigNames[static_cast<size_t>(active)]));
|
||||
}
|
||||
|
||||
// The headset's settings panel (vr/openxr_settings_panel.h): the same menus as
|
||||
// the F10 bar, drawn with an ImGui context of its own at a size fixed in panel
|
||||
// pixels, operated by the VR controllers' pointer and handed to Aurora, which
|
||||
// lays it over the eyes. The desktop context is untouched, so the F10 bar and
|
||||
// the panel can both be open.
|
||||
struct VrSettingsPanel {
|
||||
ImGuiContext* context = nullptr;
|
||||
Clock::time_point lastFrame{};
|
||||
bool selectHeld = false;
|
||||
};
|
||||
VrSettingsPanel g_vrSettingsPanel;
|
||||
|
||||
ImGuiContext* CreateVrSettingsPanelContext() {
|
||||
ImGuiContext* const desktop = ImGui::GetCurrentContext();
|
||||
ImGuiContext* const context = ImGui::CreateContext();
|
||||
ImGui::SetCurrentContext(context);
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.IniFilename = nullptr;
|
||||
io.LogFilename = nullptr;
|
||||
// No platform backend draws a cursor for it, and nothing else shows where
|
||||
// the controller is aiming.
|
||||
io.MouseDrawCursor = true;
|
||||
// Aurora's WebGPU backend, which renders this context's draw data, honours it.
|
||||
io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset;
|
||||
// The default font rasterised at the panel's scale rather than magnified,
|
||||
// with an atlas of its own so the desktop font texture is left alone.
|
||||
ImFontConfig font;
|
||||
font.SizePixels = 13.0f * mkw::vr::kSettingsPanelUiScale;
|
||||
io.Fonts->AddFontDefault(&font);
|
||||
unsigned char* pixels = nullptr;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
|
||||
io.Fonts->SetTexID(aurora_imgui_add_texture(static_cast<uint32_t>(width), static_cast<uint32_t>(height), pixels));
|
||||
io.Fonts->ClearTexData();
|
||||
ImGui::StyleColorsDark();
|
||||
ImGuiStyle& style = ImGui::GetStyle();
|
||||
style.ScaleAllSizes(mkw::vr::kSettingsPanelUiScale);
|
||||
// Nothing behind the panel is meant to be read through it.
|
||||
style.Colors[ImGuiCol_WindowBg].w = 0.97f;
|
||||
style.Colors[ImGuiCol_PopupBg].w = 0.98f;
|
||||
ImGui::SetCurrentContext(desktop);
|
||||
return context;
|
||||
}
|
||||
|
||||
void DrawVrSettingsPanelWindow() {
|
||||
const ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(viewport->Pos, ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(viewport->Size, ImGuiCond_Always);
|
||||
constexpr ImGuiWindowFlags kFlags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
if (ImGui::Begin("WiiCompiled settings", nullptr, kFlags)) {
|
||||
ImGui::TextUnformatted("WiiCompiled settings");
|
||||
const ImGuiStyle& style = ImGui::GetStyle();
|
||||
const float recenterWidth = ImGui::CalcTextSize("Recenter view").x + style.FramePadding.x * 2.0f;
|
||||
const float closeWidth = ImGui::CalcTextSize("Close").x + style.FramePadding.x * 2.0f;
|
||||
ImGui::SameLine(ImGui::GetContentRegionMax().x - recenterWidth - closeWidth - style.ItemSpacing.x);
|
||||
if (ImGui::Button("Recenter view")) {
|
||||
mkw::vr::OpenXRRequestRecenter();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Close")) {
|
||||
mkw::vr::OpenXRSetSettingsPanelOpen(false);
|
||||
}
|
||||
ImGui::TextDisabled("Aim and pull a trigger to change a setting, push a thumbstick to scroll.");
|
||||
ImGui::TextDisabled("Click both thumbsticks or press Menu to close. The game does not see the controllers meanwhile.");
|
||||
ImGui::Separator();
|
||||
if (ImGui::BeginTabBar("Settings")) {
|
||||
const auto tab = [](const char* label, void (*draw)()) {
|
||||
if (ImGui::BeginTabItem(label)) {
|
||||
// Its own scrolling region, so the header stays in view.
|
||||
ImGui::BeginChild("Contents");
|
||||
draw();
|
||||
ImGui::EndChild();
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
};
|
||||
tab("VR", DrawVrSettings);
|
||||
tab("Graphics", [] {
|
||||
DrawResolutionMenu();
|
||||
ImGui::Separator();
|
||||
DrawGraphicsSettings();
|
||||
});
|
||||
tab("Controllers", DrawControllerSettings);
|
||||
tab("Audio", DrawAudioSettings);
|
||||
tab("Diagnostics", DrawDiagnosticsSettings);
|
||||
ImGui::EndTabBar();
|
||||
}
|
||||
}
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// Called once per presented frame after the desktop menus, with the frame
|
||||
// worker done: the draw data handed to Aurora must stay put until the next
|
||||
// frame is encoded, and only the next call here rebuilds it.
|
||||
void DrawVrSettingsPanel() {
|
||||
if (!mkw::vr::OpenXRIsRunning() || !mkw::vr::OpenXRSettingsPanelOpen()) {
|
||||
aurora_imgui_set_stereo_overlay(nullptr, 0.0f);
|
||||
return;
|
||||
}
|
||||
VrSettingsPanel& panel = g_vrSettingsPanel;
|
||||
if (panel.context == nullptr) {
|
||||
panel.context = CreateVrSettingsPanelContext();
|
||||
}
|
||||
const mkw::vr::OpenXRSettingsPanelPointer pointer = mkw::vr::OpenXRTakeSettingsPanelPointer();
|
||||
const Clock::time_point now = Clock::now();
|
||||
float deltaSeconds = 1.0f / 60.0f;
|
||||
if (panel.lastFrame != Clock::time_point{}) {
|
||||
deltaSeconds = std::clamp(std::chrono::duration<float>(now - panel.lastFrame).count(), 1.0e-4f, 0.25f);
|
||||
}
|
||||
panel.lastFrame = now;
|
||||
|
||||
ImGuiContext* const desktop = ImGui::GetCurrentContext();
|
||||
ImGui::SetCurrentContext(panel.context);
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.DisplaySize = ImVec2(mkw::vr::kSettingsPanelWidthPixels, mkw::vr::kSettingsPanelHeightPixels);
|
||||
io.DeltaTime = deltaSeconds;
|
||||
if (pointer.valid) {
|
||||
io.AddMousePosEvent(pointer.x, pointer.y);
|
||||
} else {
|
||||
io.AddMousePosEvent(-FLT_MAX, -FLT_MAX);
|
||||
}
|
||||
if (pointer.select != panel.selectHeld) {
|
||||
io.AddMouseButtonEvent(ImGuiMouseButton_Left, pointer.select);
|
||||
panel.selectHeld = pointer.select;
|
||||
}
|
||||
if (pointer.wheel != 0.0f) {
|
||||
io.AddMouseWheelEvent(0.0f, pointer.wheel);
|
||||
}
|
||||
ImGui::NewFrame();
|
||||
DrawVrSettingsPanelWindow();
|
||||
ImGui::Render();
|
||||
ImDrawData* const drawData = ImGui::GetDrawData();
|
||||
ImGui::SetCurrentContext(desktop);
|
||||
aurora_imgui_set_stereo_overlay(drawData, mkw::vr::kSettingsPanelWidthFraction);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void InitializeRuntimeSettings() noexcept {
|
||||
@@ -1685,6 +1848,7 @@ void Draw() noexcept {
|
||||
PADBlockInput(inputBlocked);
|
||||
InputBindings::SetInputBlocked(inputBlocked);
|
||||
DrawStartupScreen();
|
||||
DrawVrSettingsPanel();
|
||||
}
|
||||
|
||||
bool StartupScreenVisible() noexcept {
|
||||
|
||||
+105
-18
@@ -40,8 +40,9 @@ namespace {
|
||||
// A new sequence number holds the button for kInjectHoldFrames XR frames.
|
||||
// Buttons: a, b, x, y, start, up, down, left, right, and for the Wii Remote
|
||||
// presentation also home, c and z (x/y/start press 1/2/+ there, and the
|
||||
// directions push the Nunchuk stick). The property is unset in normal use, so
|
||||
// this costs one property read every few frames.
|
||||
// directions push the Nunchuk stick). `panel` clicks both thumbsticks, opening
|
||||
// or closing the settings panel, where `a` then selects. The property is unset
|
||||
// in normal use, so this costs one property read every few frames.
|
||||
constexpr uint32_t kInjectHoldFrames = 12;
|
||||
constexpr uint32_t kInjectPollFrames = 4;
|
||||
|
||||
@@ -468,6 +469,9 @@ void OpenXRInput::Destroy() {
|
||||
}
|
||||
m_pointer.Reset();
|
||||
m_horizon = {1.0f, 0.0f};
|
||||
m_panel_controls.Reset();
|
||||
m_last_input_time = 0;
|
||||
m_panel_select_held = false;
|
||||
m_created = false;
|
||||
m_runtime = nullptr;
|
||||
}
|
||||
@@ -482,6 +486,11 @@ void OpenXRInput::Idle() {
|
||||
m_pointer.Reset();
|
||||
m_horizon = {1.0f, 0.0f};
|
||||
OpenXRPublishWiiRemote(m_joystick_id, OpenXRWiiRemoteSample{});
|
||||
// The panel stays as it was; only what the controllers were holding is forgotten.
|
||||
m_panel_controls.Reset();
|
||||
m_last_input_time = 0;
|
||||
m_panel_select_held = false;
|
||||
OpenXRPublishSettingsPanelPointer(false, 0.0f, 0.0f, false, 0.0f);
|
||||
StopRumble();
|
||||
// Nothing stays held on the gamepad either while input is away.
|
||||
if (m_joystick != nullptr) {
|
||||
@@ -495,7 +504,8 @@ void OpenXRInput::Idle() {
|
||||
}
|
||||
}
|
||||
|
||||
void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen& screen) {
|
||||
void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen& screen,
|
||||
const OpenXRPointerScreen& settings_panel) {
|
||||
if (!m_created || m_runtime == nullptr) {
|
||||
return;
|
||||
}
|
||||
@@ -566,18 +576,48 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen&
|
||||
}
|
||||
|
||||
PollInjection();
|
||||
wii_remote::HandInputs& left = hands[0];
|
||||
const wii_remote::HandInputs& right = hands[1];
|
||||
if (Injected("up")) {
|
||||
left.stick_y = 1.0f;
|
||||
hands[0].stick_y = 1.0f;
|
||||
} else if (Injected("down")) {
|
||||
left.stick_y = -1.0f;
|
||||
hands[0].stick_y = -1.0f;
|
||||
} else if (Injected("left")) {
|
||||
left.stick_x = -1.0f;
|
||||
hands[0].stick_x = -1.0f;
|
||||
} else if (Injected("right")) {
|
||||
left.stick_x = 1.0f;
|
||||
hands[0].stick_x = 1.0f;
|
||||
}
|
||||
|
||||
const XrTime input_time = InputSampleTime(predicted_display_time);
|
||||
const float dt_seconds =
|
||||
m_last_input_time != 0 && input_time > m_last_input_time
|
||||
? static_cast<float>(input_time - m_last_input_time) * 1.0e-9f
|
||||
: 0.0f;
|
||||
m_last_input_time = input_time;
|
||||
std::array<wii_remote::HandInputs, kHands> panel_hands = hands;
|
||||
if (Injected("panel")) {
|
||||
panel_hands[0].thumbstick_click = true;
|
||||
panel_hands[1].thumbstick_click = true;
|
||||
}
|
||||
if (Injected("a")) {
|
||||
panel_hands[1].primary = true;
|
||||
}
|
||||
// The game thread may open or close the panel too; only a change made here
|
||||
// is written back.
|
||||
const bool was_open = OpenXRSettingsPanelOpen();
|
||||
bool open = was_open;
|
||||
const settings_panel::Frame panel = m_panel_controls.Update(panel_hands, open, dt_seconds);
|
||||
// Pointer first: the game thread reads it as soon as it sees the panel open.
|
||||
PublishSettingsPanel(input_time, settings_panel, panel);
|
||||
if (open != was_open) {
|
||||
OpenXRSetSettingsPanelOpen(open);
|
||||
}
|
||||
|
||||
// While the panel has the controllers, the game sees them idle.
|
||||
static const std::array<wii_remote::HandInputs, kHands> kIdleHands{};
|
||||
const auto& game_hands = panel.withheld ? kIdleHands : hands;
|
||||
const wii_remote::HandInputs& left = game_hands[0];
|
||||
const wii_remote::HandInputs& right = game_hands[1];
|
||||
const auto injected = [&panel](const char* button) { return !panel.withheld && Injected(button); };
|
||||
|
||||
if (m_joystick != nullptr) {
|
||||
auto* joystick = static_cast<SDL_Joystick*>(m_joystick);
|
||||
// OpenXR thumbsticks report +Y up; SDL gamepads report +Y down.
|
||||
@@ -588,27 +628,65 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen&
|
||||
SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, ToTrigger(left.trigger));
|
||||
SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, ToTrigger(right.trigger));
|
||||
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_SOUTH, right.primary || Injected("a"));
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_EAST, right.secondary || Injected("b"));
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_WEST, left.primary || Injected("x"));
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_NORTH, left.secondary || Injected("y"));
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_START, left.menu || Injected("start"));
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_SOUTH, right.primary || injected("a"));
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_EAST, right.secondary || injected("b"));
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_WEST, left.primary || injected("x"));
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_NORTH, left.secondary || injected("y"));
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_START, left.menu || injected("start"));
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_LEFT_STICK, left.thumbstick_click);
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_RIGHT_STICK, right.thumbstick_click);
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, left.squeeze > 0.5f);
|
||||
SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, right.squeeze > 0.5f);
|
||||
}
|
||||
|
||||
PublishWiiRemote(predicted_display_time, screen, hands, InjectedWiiRemoteButtons());
|
||||
PublishWiiRemote(input_time, screen, game_hands, panel.withheld ? 0u : InjectedWiiRemoteButtons(),
|
||||
panel.withheld);
|
||||
UpdateRumble();
|
||||
}
|
||||
|
||||
void OpenXRInput::PublishWiiRemote(XrTime predicted_display_time, const OpenXRPointerScreen& screen,
|
||||
// The pointing hand's aim ray against the whole panel, in canvas pixels, with a
|
||||
// short tick in that hand when a selection starts.
|
||||
void OpenXRInput::PublishSettingsPanel(XrTime input_time, const OpenXRPointerScreen& panel,
|
||||
const settings_panel::Frame& frame) {
|
||||
if (!frame.open) {
|
||||
// Nothing may still read as held when the panel next opens.
|
||||
m_panel_select_held = false;
|
||||
OpenXRPublishSettingsPanelPointer(false, 0.0f, 0.0f, false, 0.0f);
|
||||
return;
|
||||
}
|
||||
constexpr XrSpaceLocationFlags kPoseValid =
|
||||
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
|
||||
bool valid = false;
|
||||
std::array<float, 2> point{};
|
||||
const XrSpace aim_space = m_aim_spaces[frame.pointing_hand];
|
||||
if (panel.valid && aim_space != XR_NULL_HANDLE) {
|
||||
XrSpaceLocation location{XR_TYPE_SPACE_LOCATION};
|
||||
if (XR_SUCCEEDED(xrLocateSpace(aim_space, m_runtime->AppSpace(), input_time, &location)) &&
|
||||
(location.locationFlags & kPoseValid) == kPoseValid) {
|
||||
wii_remote::Screen target{};
|
||||
target.pose = ToWiiRemotePose(panel.pose);
|
||||
target.half_width = panel.half_width_meters;
|
||||
target.half_height = panel.half_height_meters;
|
||||
const wii_remote::ScreenHit hit = wii_remote::RaycastScreen(ToWiiRemotePose(location.pose), target);
|
||||
if (hit.valid) {
|
||||
valid = true;
|
||||
point = settings_panel::CanvasPoint(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (frame.select && !m_panel_select_held) {
|
||||
constexpr XrDuration kTickNs = 15'000'000;
|
||||
ApplyHaptic(frame.pointing_hand, 0.35f, kTickNs);
|
||||
}
|
||||
m_panel_select_held = frame.select;
|
||||
OpenXRPublishSettingsPanelPointer(valid, point[0], point[1], frame.select, frame.wheel);
|
||||
}
|
||||
|
||||
void OpenXRInput::PublishWiiRemote(XrTime input_time, const OpenXRPointerScreen& screen,
|
||||
const std::array<wii_remote::HandInputs, kHands>& hands,
|
||||
uint32_t injected_buttons) {
|
||||
uint32_t injected_buttons, bool withheld) {
|
||||
constexpr XrSpaceLocationFlags kPoseValid =
|
||||
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
|
||||
const XrTime input_time = InputSampleTime(predicted_display_time);
|
||||
|
||||
OpenXRWiiRemoteSample sample{};
|
||||
sample.hold = wii_remote::RemoteButtons(hands[0], hands[1]) | injected_buttons;
|
||||
@@ -648,6 +726,15 @@ void OpenXRInput::PublishWiiRemote(XrTime predicted_display_time, const OpenXRPo
|
||||
(hand == 0 ? sample.nunchuk_acc : sample.acc) = acc;
|
||||
}
|
||||
|
||||
if (withheld) {
|
||||
// Waving a controller around the panel must not trick or wheelie.
|
||||
sample.acc = OpenXRWiiRemoteSample{}.acc;
|
||||
sample.nunchuk_acc = OpenXRWiiRemoteSample{}.nunchuk_acc;
|
||||
m_pointer.Reset();
|
||||
OpenXRPublishWiiRemote(m_joystick_id, sample);
|
||||
return;
|
||||
}
|
||||
|
||||
wii_remote::Screen target{};
|
||||
wii_remote::ScreenHit hit{};
|
||||
if (screen.valid && aim_valid[1]) {
|
||||
|
||||
@@ -741,7 +741,8 @@ private:
|
||||
if (input_ != nullptr) {
|
||||
// After the screen is placed, so the pointer aims at this
|
||||
// frame's screen rather than the previous one's.
|
||||
input_->Sync(frame.xr_frame.predicted_display_time, PointerScreen(frame, policy, immersive));
|
||||
input_->Sync(frame.xr_frame.predicted_display_time, PointerScreen(frame, policy, immersive),
|
||||
SettingsPanelScreen(frame, policy, immersive));
|
||||
}
|
||||
|
||||
if (!frame.expects_gpu_submission) {
|
||||
@@ -951,55 +952,19 @@ private:
|
||||
if (!aurora_get_stereo_screen_aspects(&picture_aspect, &snapshot_aspect)) {
|
||||
return screen;
|
||||
}
|
||||
const OpenXRFrame& xr_frame = frame.xr_frame;
|
||||
const bool views_usable = xr_frame.views_valid &&
|
||||
(xr_frame.view_state_flags & XR_VIEW_STATE_ORIENTATION_VALID_BIT) != 0;
|
||||
const bool position_usable =
|
||||
views_usable && (xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0;
|
||||
|
||||
if (immersive) {
|
||||
const float distance = policy.config.hud_distance_meters;
|
||||
const float width = policy.config.hud_width_meters;
|
||||
if (!aurora_get_stereo_hud_screen_enabled() || !(distance > 0.0f) || !(width > 0.0f)) {
|
||||
if (!aurora_get_stereo_hud_screen_enabled() || !(policy.config.hud_width_meters > 0.0f) ||
|
||||
!RaceScreenPose(frame, policy, screen.pose)) {
|
||||
return screen;
|
||||
}
|
||||
std::array<float, 3> base{};
|
||||
if (base_position_valid_ && last_immersive_) {
|
||||
base = base_position_;
|
||||
} else if (position_usable) {
|
||||
// BuildPublishedFrame latches exactly this for the frame.
|
||||
base = CenterPosition(xr_frame);
|
||||
} else {
|
||||
return screen;
|
||||
}
|
||||
const float half_angle =
|
||||
0.5f * lean_back_degrees_.load(std::memory_order_relaxed) * kDegreesToRadians;
|
||||
const Quaternion lean{std::sin(half_angle), 0.0f, 0.0f, std::cos(half_angle)};
|
||||
const std::array<float, 3> ahead = Rotate(lean, {0.0f, 0.0f, -distance});
|
||||
screen.pose.orientation = {lean.x, lean.y, lean.z, lean.w};
|
||||
screen.pose.position = {base[0] + ahead[0], base[1] + ahead[1], base[2] + ahead[2]};
|
||||
screen.half_width_meters = 0.5f * width;
|
||||
screen.half_width_meters = 0.5f * policy.config.hud_width_meters;
|
||||
screen.half_height_meters = screen.half_width_meters / picture_aspect;
|
||||
screen.valid = true;
|
||||
return screen;
|
||||
}
|
||||
|
||||
if (frame.presentation.mode != OpenXRFrameMode::VirtualScreen || frame.render_width[0] == 0 ||
|
||||
frame.render_height[0] == 0) {
|
||||
return screen;
|
||||
}
|
||||
if (frame.presentation.quad_anchored) {
|
||||
screen.pose = frame.presentation.quad_pose;
|
||||
} else if (position_usable) {
|
||||
// Head-locked in the view space: straight ahead of the head.
|
||||
const auto& head = xr_frame.views[0].pose.orientation;
|
||||
const Quaternion orientation = Normalize({head.x, head.y, head.z, head.w});
|
||||
const std::array<float, 3> center = CenterPosition(xr_frame);
|
||||
const std::array<float, 3> ahead = Rotate(
|
||||
orientation, {0.0f, 0.0f, -std::max(0.25f, frame.presentation.quad_distance_meters)});
|
||||
screen.pose.orientation = {orientation.x, orientation.y, orientation.z, orientation.w};
|
||||
screen.pose.position = {center[0] + ahead[0], center[1] + ahead[1], center[2] + ahead[2]};
|
||||
} else {
|
||||
if (frame.render_width[0] == 0 || frame.render_height[0] == 0 || !MenuScreenPose(frame, screen.pose)) {
|
||||
return screen;
|
||||
}
|
||||
const float eye_aspect =
|
||||
@@ -1012,6 +977,81 @@ private:
|
||||
return screen;
|
||||
}
|
||||
|
||||
// The settings panel's rectangle: centred on the same screen, a fixed
|
||||
// fraction of its width, the way Aurora lays it over the eyes
|
||||
// (aurora_imgui_set_stereo_overlay). Unlike the pointer's picture it is
|
||||
// there in a race even when the 2D layer is not on the screen.
|
||||
OpenXRPointerScreen SettingsPanelScreen(const OpenXRBackendFrame& frame, const MkwVRPolicySnapshot& policy,
|
||||
bool immersive) const noexcept {
|
||||
OpenXRPointerScreen screen{};
|
||||
const float screen_width =
|
||||
immersive ? policy.config.hud_width_meters : std::max(0.25f, frame.presentation.quad_width_meters);
|
||||
if (!(screen_width > 0.0f) ||
|
||||
!(immersive ? RaceScreenPose(frame, policy, screen.pose) : MenuScreenPose(frame, screen.pose))) {
|
||||
return screen;
|
||||
}
|
||||
const std::array<float, 2> extents = settings_panel::HalfExtents(screen_width);
|
||||
screen.half_width_meters = extents[0];
|
||||
screen.half_height_meters = extents[1];
|
||||
screen.valid = true;
|
||||
return screen;
|
||||
}
|
||||
|
||||
// Centre of the race's 2D screen. ViewFromBase maps a point p of the
|
||||
// recorded centre-eye space (in metres) to base + lean * p in the
|
||||
// application space, so the screen Aurora hangs hud_distance_meters ahead
|
||||
// sits at base + lean * (0, 0, -distance), turned by the lean.
|
||||
bool RaceScreenPose(const OpenXRBackendFrame& frame, const MkwVRPolicySnapshot& policy,
|
||||
XrPosef& pose) const noexcept {
|
||||
const OpenXRFrame& xr_frame = frame.xr_frame;
|
||||
const float distance = policy.config.hud_distance_meters;
|
||||
if (!(distance > 0.0f)) {
|
||||
return false;
|
||||
}
|
||||
std::array<float, 3> base{};
|
||||
if (base_position_valid_ && last_immersive_) {
|
||||
base = base_position_;
|
||||
} else if (xr_frame.views_valid &&
|
||||
(xr_frame.view_state_flags & XR_VIEW_STATE_ORIENTATION_VALID_BIT) != 0 &&
|
||||
(xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0) {
|
||||
// BuildPublishedFrame latches exactly this for the frame.
|
||||
base = CenterPosition(xr_frame);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
const float half_angle = 0.5f * lean_back_degrees_.load(std::memory_order_relaxed) * kDegreesToRadians;
|
||||
const Quaternion lean{std::sin(half_angle), 0.0f, 0.0f, std::cos(half_angle)};
|
||||
const std::array<float, 3> ahead = Rotate(lean, {0.0f, 0.0f, -distance});
|
||||
pose.orientation = {lean.x, lean.y, lean.z, lean.w};
|
||||
pose.position = {base[0] + ahead[0], base[1] + ahead[1], base[2] + ahead[2]};
|
||||
return true;
|
||||
}
|
||||
|
||||
// Centre of the menu quad: where UpdateVirtualScreenPose anchored it, or
|
||||
// its head-locked fallback straight ahead of the head.
|
||||
bool MenuScreenPose(const OpenXRBackendFrame& frame, XrPosef& pose) const noexcept {
|
||||
if (frame.presentation.mode != OpenXRFrameMode::VirtualScreen) {
|
||||
return false;
|
||||
}
|
||||
if (frame.presentation.quad_anchored) {
|
||||
pose = frame.presentation.quad_pose;
|
||||
return true;
|
||||
}
|
||||
const OpenXRFrame& xr_frame = frame.xr_frame;
|
||||
if (!xr_frame.views_valid || (xr_frame.view_state_flags & XR_VIEW_STATE_ORIENTATION_VALID_BIT) == 0 ||
|
||||
(xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) == 0) {
|
||||
return false;
|
||||
}
|
||||
const auto& head = xr_frame.views[0].pose.orientation;
|
||||
const Quaternion orientation = Normalize({head.x, head.y, head.z, head.w});
|
||||
const std::array<float, 3> center = CenterPosition(xr_frame);
|
||||
const std::array<float, 3> ahead =
|
||||
Rotate(orientation, {0.0f, 0.0f, -std::max(0.25f, frame.presentation.quad_distance_meters)});
|
||||
pose.orientation = {orientation.x, orientation.y, orientation.z, orientation.w};
|
||||
pose.position = {center[0] + ahead[0], center[1] + ahead[1], center[2] + ahead[2]};
|
||||
return true;
|
||||
}
|
||||
|
||||
void ApplyPendingReferenceSpaceChange(const OpenXRFrame& frame) noexcept {
|
||||
if (runtime_->ConsumeAppSpaceChangesThrough(frame.predicted_display_time)) {
|
||||
ResetTrackingOrigin();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "vr/openxr_settings_panel.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
|
||||
namespace mkw::vr {
|
||||
// Named rather than anonymous: runtime sources are unity-built in groups.
|
||||
namespace settings_panel_bridge {
|
||||
|
||||
std::atomic_bool g_open{false};
|
||||
|
||||
struct PublishedPointer {
|
||||
std::mutex mutex;
|
||||
OpenXRSettingsPanelPointer pointer{};
|
||||
};
|
||||
|
||||
PublishedPointer& Published() {
|
||||
static PublishedPointer published;
|
||||
return published;
|
||||
}
|
||||
|
||||
} // namespace settings_panel_bridge
|
||||
|
||||
void OpenXRSetSettingsPanelOpen(bool open) noexcept {
|
||||
settings_panel_bridge::g_open.store(open, std::memory_order_release);
|
||||
}
|
||||
|
||||
bool OpenXRSettingsPanelOpen() noexcept {
|
||||
return settings_panel_bridge::g_open.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
void OpenXRPublishSettingsPanelPointer(bool valid, float x, float y, bool select, float wheel) noexcept {
|
||||
auto& published = settings_panel_bridge::Published();
|
||||
std::lock_guard lock(published.mutex);
|
||||
published.pointer.valid = valid;
|
||||
published.pointer.x = x;
|
||||
published.pointer.y = y;
|
||||
published.pointer.select = select;
|
||||
published.pointer.wheel += wheel;
|
||||
}
|
||||
|
||||
OpenXRSettingsPanelPointer OpenXRTakeSettingsPanelPointer() noexcept {
|
||||
auto& published = settings_panel_bridge::Published();
|
||||
std::lock_guard lock(published.mutex);
|
||||
OpenXRSettingsPanelPointer pointer = published.pointer;
|
||||
published.pointer.wheel = 0.0f;
|
||||
return pointer;
|
||||
}
|
||||
|
||||
} // namespace mkw::vr
|
||||
@@ -0,0 +1,190 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// The in-headset settings panel's controller handling, tested without a
|
||||
// headset: the thumbstick chord that opens and closes it, the release latch that
|
||||
// keeps a closing press out of the game, selection, scrolling, and where a hit
|
||||
// lands on the panel's canvas.
|
||||
|
||||
#include "vr/openxr_settings_panel.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
using namespace mkw::vr;
|
||||
using namespace mkw::vr::settings_panel;
|
||||
|
||||
int g_failures = 0;
|
||||
|
||||
void Check(bool condition, const char* what) {
|
||||
if (!condition) {
|
||||
++g_failures;
|
||||
std::cerr << "FAILED: " << what << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
void CheckNear(float actual, float expected, const char* what, float tolerance = 1.0e-3f) {
|
||||
if (!(std::fabs(actual - expected) <= tolerance)) {
|
||||
++g_failures;
|
||||
std::cerr << "FAILED: " << what << " (expected " << expected << ", got " << actual << ")\n";
|
||||
}
|
||||
}
|
||||
|
||||
constexpr float kDt = 1.0f / 90.0f;
|
||||
|
||||
std::array<HandInputs, 2> Released() {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::array<HandInputs, 2> Chord() {
|
||||
std::array<HandInputs, 2> hands{};
|
||||
hands[0].thumbstick_click = true;
|
||||
hands[1].thumbstick_click = true;
|
||||
return hands;
|
||||
}
|
||||
|
||||
void ChordOpensAndClosesOnce() {
|
||||
Controls controls;
|
||||
bool open = false;
|
||||
|
||||
auto one = Released();
|
||||
one[0].thumbstick_click = true;
|
||||
Frame frame = controls.Update(one, open, kDt);
|
||||
Check(!open && !frame.withheld, "one thumbstick click alone neither opens nor withholds");
|
||||
|
||||
frame = controls.Update(Chord(), open, kDt);
|
||||
Check(open && frame.open && frame.withheld, "clicking both thumbsticks opens the panel");
|
||||
frame = controls.Update(Chord(), open, kDt);
|
||||
Check(open, "holding the chord does not toggle again");
|
||||
frame = controls.Update(Released(), open, kDt);
|
||||
Check(open && frame.withheld, "the panel stays open and keeps the controllers after the chord is released");
|
||||
|
||||
frame = controls.Update(Chord(), open, kDt);
|
||||
Check(!open && !frame.open, "the chord closes the panel again");
|
||||
Check(frame.withheld, "the closing chord is still withheld from the game");
|
||||
frame = controls.Update(Released(), open, kDt);
|
||||
Check(!frame.withheld, "the game gets the controllers back once everything is released");
|
||||
}
|
||||
|
||||
void MenuClosesAndItsPressStaysOutOfTheGame() {
|
||||
Controls controls;
|
||||
bool open = false;
|
||||
controls.Update(Chord(), open, kDt);
|
||||
controls.Update(Released(), open, kDt);
|
||||
|
||||
auto menu = Released();
|
||||
menu[0].menu = true;
|
||||
Frame frame = controls.Update(menu, open, kDt);
|
||||
Check(!open, "the left menu button closes an open panel");
|
||||
frame = controls.Update(menu, open, kDt);
|
||||
Check(frame.withheld, "HOME held across the close does not reach the game");
|
||||
Check(!open, "a held menu button does not reopen the panel");
|
||||
frame = controls.Update(Released(), open, kDt);
|
||||
Check(!frame.withheld, "released, the controllers go back to the game");
|
||||
|
||||
frame = controls.Update(menu, open, kDt);
|
||||
Check(!open && !frame.withheld, "with the panel closed the menu button is the game's");
|
||||
}
|
||||
|
||||
void SelectWaitsForAReleaseAndFollowsTheTrigger() {
|
||||
Controls controls;
|
||||
bool open = false;
|
||||
// Opened from the settings bar while a trigger is held for the game.
|
||||
auto trigger = Released();
|
||||
trigger[1].trigger = 1.0f;
|
||||
controls.Update(trigger, open, kDt);
|
||||
open = true;
|
||||
Frame frame = controls.Update(trigger, open, kDt);
|
||||
Check(frame.open && !frame.select, "a trigger held from before the panel opened does not click");
|
||||
frame = controls.Update(Released(), open, kDt);
|
||||
Check(!frame.select, "nothing held, nothing selected");
|
||||
frame = controls.Update(trigger, open, kDt);
|
||||
Check(frame.select && frame.pointing_hand == 1, "a fresh right trigger selects and points with the right hand");
|
||||
|
||||
auto left = Released();
|
||||
left[0].trigger = 0.9f;
|
||||
controls.Update(Released(), open, kDt);
|
||||
frame = controls.Update(left, open, kDt);
|
||||
Check(frame.select && frame.pointing_hand == 0, "pulling the left trigger hands the pointer to the left hand");
|
||||
|
||||
auto button = Released();
|
||||
button[1].primary = true;
|
||||
controls.Update(Released(), open, kDt);
|
||||
frame = controls.Update(button, open, kDt);
|
||||
Check(frame.select && frame.pointing_hand == 0, "A selects without moving the pointer to another hand");
|
||||
}
|
||||
|
||||
void ThumbstickScrolls() {
|
||||
Controls controls;
|
||||
bool open = true;
|
||||
controls.Update(Released(), open, kDt);
|
||||
|
||||
auto small = Released();
|
||||
small[1].stick_y = 0.2f;
|
||||
Check(controls.Update(small, open, kDt).wheel == 0.0f, "a resting thumbstick does not scroll");
|
||||
|
||||
auto up = Released();
|
||||
up[1].stick_y = 1.0f;
|
||||
CheckNear(controls.Update(up, open, 0.5f).wheel, kScrollStepsPerSecond * 0.1f,
|
||||
"full deflection scrolls up at the full rate, with a long frame clamped");
|
||||
auto down = Released();
|
||||
down[0].stick_y = -1.0f;
|
||||
down[1].stick_y = 0.3f;
|
||||
Check(controls.Update(down, open, kDt).wheel < 0.0f, "the more deflected stick decides the direction");
|
||||
|
||||
bool closed = false;
|
||||
Controls idle;
|
||||
Check(idle.Update(up, closed, kDt).wheel == 0.0f, "a closed panel does not scroll");
|
||||
}
|
||||
|
||||
void HitsMapOntoTheCanvas() {
|
||||
wii_remote::ScreenHit hit{};
|
||||
hit.valid = true;
|
||||
hit.u = -1.0f;
|
||||
hit.v = 1.0f;
|
||||
auto point = CanvasPoint(hit);
|
||||
CheckNear(point[0], 0.0f, "the panel's left edge is canvas x 0");
|
||||
CheckNear(point[1], 0.0f, "the panel's top edge is canvas y 0");
|
||||
hit.u = 1.0f;
|
||||
hit.v = -1.0f;
|
||||
point = CanvasPoint(hit);
|
||||
CheckNear(point[0], kSettingsPanelWidthPixels, "the right edge is the canvas width");
|
||||
CheckNear(point[1], kSettingsPanelHeightPixels, "the bottom edge is the canvas height");
|
||||
|
||||
const auto extents = HalfExtents(2.4f);
|
||||
CheckNear(extents[0], 0.9f, "three quarters of a 2.4 m screen is 1.8 m across");
|
||||
CheckNear(extents[1], 0.675f, "and keeps the canvas's 4:3");
|
||||
}
|
||||
|
||||
void BridgeAccumulatesWheelUntilTaken() {
|
||||
OpenXRSetSettingsPanelOpen(true);
|
||||
Check(OpenXRSettingsPanelOpen(), "the open flag is shared");
|
||||
OpenXRPublishSettingsPanelPointer(true, 10.0f, 20.0f, false, 0.5f);
|
||||
OpenXRPublishSettingsPanelPointer(true, 11.0f, 21.0f, true, 0.25f);
|
||||
OpenXRSettingsPanelPointer pointer = OpenXRTakeSettingsPanelPointer();
|
||||
Check(pointer.valid && pointer.select, "the latest pointer state is read");
|
||||
CheckNear(pointer.x, 11.0f, "the latest x is read");
|
||||
CheckNear(pointer.wheel, 0.75f, "wheel steps from every XR frame are kept");
|
||||
pointer = OpenXRTakeSettingsPanelPointer();
|
||||
CheckNear(pointer.wheel, 0.0f, "taking the pointer consumes its wheel steps");
|
||||
OpenXRSetSettingsPanelOpen(false);
|
||||
Check(!OpenXRSettingsPanelOpen(), "and can be cleared again");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
ChordOpensAndClosesOnce();
|
||||
MenuClosesAndItsPressStaysOutOfTheGame();
|
||||
SelectWaitsForAReleaseAndFollowsTheTrigger();
|
||||
ThumbstickScrolls();
|
||||
HitsMapOntoTheCanvas();
|
||||
BridgeAccumulatesWheelUntilTaken();
|
||||
if (g_failures != 0) {
|
||||
std::cerr << g_failures << " check(s) failed\n";
|
||||
return 1;
|
||||
}
|
||||
std::cout << "vr_settings_panel_tests: all checks passed\n";
|
||||
return 0;
|
||||
}
|
||||
Reference in new issue
Block a user