Implement OpenXR Settings Panel and Stereo Overlay

This commit is contained in:
iChris4 committed 2026-09-17 04:32:22 +02:00
1 parent f222eedf6b
commit 1346e53cd6
22 files changed
+1466 -101

No files matched your search

+46 -2
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+22
View File
@@ -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);
+4
View File
@@ -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.
+2
View File
@@ -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.
+58
View File
@@ -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
+30
View File
@@ -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,
};
}
}
+12
View File
@@ -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
+343
View File
@@ -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
+33
View File
@@ -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
+64
View File
@@ -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
View File
@@ -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.
+8
View File
@@ -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")
+20 -4
View File
@@ -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
+165
View File
@@ -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
View File
@@ -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);
+182 -18
View File
@@ -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
View File
@@ -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]) {
+81 -41
View File
@@ -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();
+52
View File
@@ -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
+190
View File
@@ -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;
}