From 02e5cb60a46f5381b9afaf13f61498a287dd18ca Mon Sep 17 00:00:00 2001 From: iChris4 Date: Fri, 4 Sep 2026 23:11:22 +0200 Subject: [PATCH] Added First Person Camera Option --- OPENXR.md | 39 +++ aurora-main/include/aurora/aurora.h | 28 +- aurora-main/lib/aurora.cpp | 97 ++++++- aurora-main/lib/gfx/common.cpp | 7 +- aurora-main/lib/gfx/common.hpp | 8 + aurora-main/tests/stereo_replay_test.cpp | 96 +++++++ runtime/CMakeLists.txt | 7 + runtime/include/runtime_config.h | 92 ++++++- runtime/include/settings_overlay.h | 4 + runtime/include/vr/mkw_vr_first_person.h | 205 +++++++++++++++ runtime/include/vr/mkw_vr_policy.h | 24 ++ runtime/src/hle/vi.cpp | 23 ++ runtime/src/settings_overlay.cpp | 61 ++++- runtime/src/vr/mkw_vr_first_person.cpp | 298 ++++++++++++++++++++++ runtime/src/vr/mkw_vr_instrumentation.cpp | 16 ++ runtime/src/vr/mkw_vr_policy.cpp | 21 ++ runtime/src/vr/openxr_integration.cpp | 9 +- runtime/tests/vr_first_person_tests.cpp | 207 +++++++++++++++ 18 files changed, 1219 insertions(+), 23 deletions(-) create mode 100644 runtime/include/vr/mkw_vr_first_person.h create mode 100644 runtime/src/vr/mkw_vr_first_person.cpp create mode 100644 runtime/tests/vr_first_person_tests.cpp diff --git a/OPENXR.md b/OPENXR.md index e546b9a..54ce7bf 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -29,6 +29,11 @@ hud_width_meters = 2.4 hud_virtual_screen = true stop_at_display_copy = true skip_copy_clears = true +first_person = false +first_person_units_per_meter = 10.0 +first_person_head_up_meters = 1.0 +first_person_head_forward_meters = 0.0 +first_person_head_right_meters = 0.0 ``` Set `enabled = true`, close the game completely, and start it again. These settings are read only @@ -48,6 +53,40 @@ it is live and can be flipped from the F10 settings bar. `stop_at_display_copy` ends eye replay at the final `GXCopyDisp`, matching the frame shown on the desktop. `skip_copy_clears` independently suppresses the EFB reset performed after a copy. Both default on and can be changed live from the F10 settings bar for diagnostics. +`first_person` and the `first_person_*` values are the first-person camera described below. All +four are live and are also exposed in the F10 settings bar. + +## The first-person camera + +By default the headset sits where Mario Kart's own chase camera sits, and `world_units_per_meter` +of 500 presents the race as a small diorama on a table. Turning on `first_person` moves the camera +to the Player 1 driver's head instead, and switches the world scale to +`first_person_units_per_meter`, which defaults to the 10 units per metre Mario Kart Wii is +authored at, so the race reads life-size. + +The game's own transforms are never modified. Each guest frame the runtime reads the race camera's +view matrix and the player kart's physics pose and derives one affine transform from the recorded +view space into the space to render from. That transform is published with the sealed frame, and +the renderer composes it onto every perspective draw's model-view matrix, alongside the headset's +own per-eye delta. The kart's *physics* pose is used deliberately, not the animated model: an +animated frame would bob and lurch the camera. + +Only the camera's heading is taken from the game. Its pitch and roll are dropped, so the horizon +stays level through a chase-camera tilt or a banked corner, and the headset owns pitch, roll, and +free look outright. The head's place in the kart is `first_person_head_up_meters` and its two +companions, measured in the kart's own frame; the F10 sliders exist because the comfortable value +is a matter of taste and is best judged from inside the headset. + +The mode engages only in a single-screen race, the same content that already qualifies for +immersive stereo. Menus, split-screen, and the virtual-screen fallback are unaffected, and so is +the desktop mirror, which keeps showing the game's ordinary third-person view. If the kart or +camera cannot be read the camera stays where the game put it rather than guessing. + +Two limitations are worth knowing. Mario Kart still culls the scene from its own chase camera, so +a wide head turn in first person can reveal the edge of what the game decided to draw. And the +driver's own head is still rendered; nudge `first_person_head_forward_meters` if it intrudes. As +with the rest of the race instrumentation, the object offsets this reads are specific to the +project's supported PAL `RMCP01` translation. ## Presentation policy diff --git a/aurora-main/include/aurora/aurora.h b/aurora-main/include/aurora/aurora.h index be21c84..a473a08 100644 --- a/aurora-main/include/aurora/aurora.h +++ b/aurora-main/include/aurora/aurora.h @@ -87,10 +87,12 @@ enum { AURORA_STEREO_EYE_COUNT = 2 }; * applies those four values to each perspective GX draw while preserving the * draw's own depth mapping and renderer depth-range adjustment. * - * viewFromCenter is a row-major affine 3x4 transform from the game's - * recorded center-eye view space into this eye's view space. Identity keeps - * the recorded view and is useful when the game has already applied the eye - * transform before issuing GX commands. + * viewFromCenter is a row-major affine 3x4 transform from the center-eye view + * space into this eye's view space. Identity keeps the recorded view and is + * useful when the game has already applied the eye transform before issuing GX + * commands. That center-eye space is the game's recorded view space unless + * aurora_set_stereo_scene_anchor() relocated the camera for the sealed frame, + * in which case the anchor is composed in for world draws only. * * Both transforms are ignored in AURORA_STEREO_FRAME_VIRTUAL_SCREEN mode. */ @@ -209,6 +211,24 @@ void aurora_end_frame(); // Seal the current frame with an opaque application safety tag. Aurora rejects // an immersive provider packet unless its contentTag matches this exact frame. void aurora_end_frame_tagged(uint64_t contentTag); +/** + * Relocates the immersive camera for the frame about to be sealed. + * + * anchorFromScene is a row-major affine 3x4 transform from the game's recorded + * view space into the view space the headset should render from, in world + * units. Identity keeps the recorded camera, which is the default and the + * behaviour of every frame that does not call this. + * + * Perspective draws carry the recorded camera in their own position matrices, + * so they are replayed through viewFromCenter * anchorFromScene. The 2D virtual + * screen is defined in the relocated camera's space and keeps viewFromCenter. + * + * This is latched by the next aurora_end_frame*(), then cleared: the anchor + * belongs to the guest frame that produced the GX content, so it must be + * published per frame from the producer thread rather than by the stereo + * provider, which cannot know which frame will consume its packet. + */ +void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]); typedef void (*AuroraFrameWorkerWaitCallback)(); // Called from the producer thread at bounded intervals while Aurora waits for // the asynchronous frame worker. The callback must not enter Aurora. diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index f54f19b..0917245 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -3,6 +3,7 @@ #ifdef AURORA_ENABLE_GX #include "gfx/common.hpp" #include "gfx/efb_ram_copy.hpp" +#include "gfx/stereo_replay.hpp" #include "gx/fifo.hpp" #include "gx/shader_info.hpp" #include "imgui.hpp" @@ -87,6 +88,22 @@ std::atomic_bool g_stereoProviderActive{false}; StereoSinkRegistration g_stereoSink; #endif +// First-person camera relocation for one sealed frame: a row-major affine 3x4 +// from the game's recorded view space into the space to render from. `active` +// false means the identity transform, i.e. render from the recorded camera. +struct StereoSceneAnchor { + std::array anchorFromScene{ + 1.f, 0.f, 0.f, 0.f, // + 0.f, 1.f, 0.f, 0.f, // + 0.f, 0.f, 1.f, 0.f, + }; + bool active = false; +}; +// Producer thread only, between aurora_set_stereo_scene_anchor() and the seal +// that consumes it. Cleared at every seal so a producer that stops publishing +// falls back to the recorded camera instead of freezing on a stale anchor. +StereoSceneAnchor g_pendingSceneAnchor; + using PresentClock = std::chrono::steady_clock; struct PresentTimingSample { @@ -237,7 +254,7 @@ enum class ImGuiFramePolicy { bool begin_frame_impl(bool pumpEvents, ImGuiFramePolicy imguiPolicy = ImGuiFramePolicy::Immediate, bool* imguiNewFrameOwed = nullptr) noexcept; bool begin_frame_render_state_impl(ImGuiFramePolicy imguiPolicy, bool* imguiNewFrameOwed) noexcept; -void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag) noexcept; +void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag, const StereoSceneAnchor& sceneAnchor) noexcept; // The two publication points of a frame-worker cycle, cleared together under `mutex`. Sealed: // producer-shared renderer state is free again. Done: slots encoded, presented, ImGui restarted. @@ -254,9 +271,10 @@ struct FrameWorkerState { bool started = false; bool stop = false; bool jobPending = false; - // Written with jobPending and copied by the worker under this mutex. It - // belongs to that exact queued frame, not to the producer's next frame. + // Written with jobPending and copied by the worker under this mutex. They + // belong to that exact queued frame, not to the producer's next frame. uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN; + StereoSceneAnchor sceneAnchor{}; // Readiness is polled thousands of times per frame, so these flags double as a publication // barrier. `sealed` is released before `ready`, and both are cleared under `mutex`. std::atomic_bool sealed{true}; @@ -305,7 +323,8 @@ bool frame_worker_requested() noexcept { #ifdef AURORA_ENABLE_GX // Returns false when a stop request was observed mid-cycle. -bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag) noexcept; +bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag, + const StereoSceneAnchor& sceneAnchor) noexcept; #endif void frame_worker_main() noexcept { @@ -322,6 +341,7 @@ void frame_worker_main() noexcept { for (;;) { uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN; + StereoSceneAnchor sceneAnchor{}; { std::unique_lock lock(g_frameWorker.mutex); g_frameWorker.cv.wait(lock, [] { return g_frameWorker.stop || g_frameWorker.jobPending; }); @@ -330,17 +350,20 @@ void frame_worker_main() noexcept { } contentTag = g_frameWorker.contentTag; g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN; + sceneAnchor = g_frameWorker.sceneAnchor; + g_frameWorker.sceneAnchor = {}; g_frameWorker.jobPending = false; } // The CPU already decoded the sealed frame at its GX boundary; the worker only owns // encode/submit/present, so it never touches the producer's next FIFO buffer. #ifdef AURORA_ENABLE_GX - if (!run_frame_worker_cycle(sealedFrame, contentTag)) { + if (!run_frame_worker_cycle(sealedFrame, contentTag, sceneAnchor)) { break; } #else (void)contentTag; + (void)sceneAnchor; #endif } @@ -365,6 +388,7 @@ void ensure_frame_worker_started() noexcept { g_frameWorker.stop = false; g_frameWorker.jobPending = false; g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN; + g_frameWorker.sceneAnchor = {}; g_frameWorker.sealed.store(true, std::memory_order_release); g_frameWorker.ready.store(true, std::memory_order_release); g_frameWorker.prepareAllowed = false; @@ -433,6 +457,7 @@ void stop_frame_worker() noexcept { g_frameWorker.framePrepared = false; g_frameWorker.jobPending = false; g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN; + g_frameWorker.sceneAnchor = {}; } uint32_t align_to(uint32_t value, uint32_t alignment) noexcept { return (value + alignment - 1) & ~(alignment - 1); } @@ -608,7 +633,9 @@ std::optional request_stereo_frame(uint32_t logicalFrame, uin return frame; } -gfx::StereoReplayFrame make_stereo_replay_frame(const AuroraStereoFrame& input) { +gfx::StereoReplayFrame make_stereo_replay_frame(const AuroraStereoFrame& input, const StereoSceneAnchor& sceneAnchor) { + Mat3x4 anchorFromScene; + std::memcpy(&anchorFromScene, sceneAnchor.anchorFromScene.data(), sizeof(anchorFromScene)); gfx::StereoReplayFrame replay{}; for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) { ensure_stereo_eye_target(eye, input.eyes[eye].width, input.eyes[eye].height); @@ -627,6 +654,12 @@ gfx::StereoReplayFrame make_stereo_replay_frame(const AuroraStereoFrame& input) }; std::memcpy(&view.projection, input.eyes[eye].projection, sizeof(view.projection)); std::memcpy(&view.viewFromCenter, input.eyes[eye].viewFromCenter, sizeof(view.viewFromCenter)); + // World draws already carry the recorded camera, so they need the anchor + // folded in; the virtual screen is authored in the anchored camera's space + // and keeps viewFromCenter. + view.viewFromScene = sceneAnchor.active + ? gfx::stereo_replay::compose_affine(view.viewFromCenter, anchorFromScene) + : view.viewFromCenter; } return replay; } @@ -1556,7 +1589,8 @@ struct SealedFrameContext { // Phase 1: everything that touches producer-shared renderer state. Needs g_rendererGpuMutex and // a FIFO already drained into the recorded pass list. -void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, uint64_t contentTag) { +void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, uint64_t contentTag, + const StereoSceneAnchor& sceneAnchor) { ZoneScopedN("Seal frame"); const auto encoderDescriptor = wgpu::CommandEncoderDescriptor{ .label = "Redraw encoder", @@ -1571,7 +1605,7 @@ void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, u if (const auto stereoInput = request_stereo_frame(ctx.logicalFrame, contentTag)) { ctx.stereoFrameToken = stereoInput->frameToken; ctx.stereoFrameMode = stereoInput->mode; - ctx.stereoReplay = make_stereo_replay_frame(*stereoInput); + ctx.stereoReplay = make_stereo_replay_frame(*stereoInput, sceneAnchor); } if (ctx.stereoReplay && ctx.stereoFrameMode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY) { // Keep the accepted frame alive even if the additional eye-uniform copies @@ -1852,7 +1886,8 @@ void record_frame_telemetry() { // One complete frame-worker cycle. The scene encode only leaves the renderer mutex when // interpolation actually inserts slots; otherwise both phases publish together. -bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag) noexcept { +bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag, + const StereoSceneAnchor& sceneAnchor) noexcept { ZoneScopedN("Frame worker cycle"); webgpu::fail_if_device_lost(); SealedFrameContext ctx; @@ -1860,7 +1895,7 @@ bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag) bool overlapEncode = false; { std::lock_guard gpuLock(g_rendererGpuMutex); - seal_frame_locked(sealedFrame, ctx, contentTag); + seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor); overlapEncode = ctx.interpolationActive; if (!overlapEncode) { presentationJobs = encode_sealed_frame(sealedFrame, ctx); @@ -1919,7 +1954,8 @@ bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag) // Synchronous frame submission: seal, encode and present inline on the calling thread. Used when // the frame worker is disabled (RenderDoc captures) and on the boot path. -void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag) noexcept { +void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag, + const StereoSceneAnchor& sceneAnchor) noexcept { ZoneScoped; #ifdef AURORA_ENABLE_GX webgpu::fail_if_device_lost(); @@ -1934,7 +1970,7 @@ void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag) noexce if (drainFifo) { gx::fifo::drain(); } - seal_frame_locked(sealedFrame, ctx, contentTag); + seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor); presentationJobs = encode_sealed_frame(sealedFrame, ctx); } publish_presentations(std::move(presentationJobs), ctx.interpolationActive); @@ -1943,6 +1979,7 @@ void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag) noexce (void)pumpEvents; (void)drainFifo; (void)contentTag; + (void)sceneAnchor; #endif } @@ -2013,8 +2050,12 @@ void end_frame(uint64_t contentTag) noexcept { #ifdef AURORA_ENABLE_GX webgpu::fail_if_device_lost(); #endif + // Claim the anchor published for this frame. Clearing it here is what makes a + // producer that stops publishing fall back to the recorded camera. + const StereoSceneAnchor sceneAnchor = g_pendingSceneAnchor; + g_pendingSceneAnchor = {}; if (!frame_worker_requested()) { - end_frame_impl(true, true, contentTag); + end_frame_impl(true, true, contentTag, sceneAnchor); return; } @@ -2035,6 +2076,7 @@ void end_frame(uint64_t contentTag) noexcept { g_frameWorker.sealed.store(false, std::memory_order_release); g_frameWorker.ready.store(false, std::memory_order_release); g_frameWorker.contentTag = contentTag; + g_frameWorker.sceneAnchor = sceneAnchor; g_frameWorker.jobPending = true; g_frameWorker.prepareAllowed = false; } @@ -2085,6 +2127,32 @@ void set_stereo_frame_provider(AuroraStereoFrameProvider provider, void* userdat g_stereoProviderActive.store(provider != nullptr, std::memory_order_release); } +void set_stereo_scene_anchor(const float anchorFromScene[12]) noexcept { + if (anchorFromScene == nullptr) { + g_pendingSceneAnchor = {}; + return; + } + // aurora_core is built with -ffast-math, so std::isfinite may be folded to + // true. Inspect the IEEE-754 exponent, matching request_stereo_frame(). + for (size_t i = 0; i < 12; ++i) { + uint32_t bits = 0; + std::memcpy(&bits, &anchorFromScene[i], sizeof(bits)); + if ((bits & 0x7f800000u) == 0x7f800000u) { + static bool rejectionLogged = false; + if (!rejectionLogged) { + rejectionLogged = true; + Log.warn("Rejected a non-finite stereo scene anchor; keeping the recorded camera"); + } + g_pendingSceneAnchor = {}; + return; + } + } + StereoSceneAnchor anchor{}; + std::memcpy(anchor.anchorFromScene.data(), anchorFromScene, sizeof(anchor.anchorFromScene)); + anchor.active = true; + g_pendingSceneAnchor = anchor; +} + #ifdef AURORA_ENABLE_GX namespace stereo { void set_sink(SinkCallback callback, SubmitCallback submitted, void* userdata) noexcept { @@ -2108,6 +2176,9 @@ const AuroraEvent* aurora_update() { return aurora::update(); } bool aurora_begin_frame() { return aurora::begin_frame(); } void aurora_end_frame() { aurora::end_frame(AURORA_STEREO_CONTENT_TAG_UNKNOWN); } void aurora_end_frame_tagged(uint64_t contentTag) { aurora::end_frame(contentTag); } +void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]) { + aurora::set_stereo_scene_anchor(anchorFromScene); +} void aurora_set_frame_worker_wait_callback(AuroraFrameWorkerWaitCallback callback) { aurora::g_frameWorkerWaitCallback.store(callback, std::memory_order_release); } diff --git a/aurora-main/lib/gfx/common.cpp b/aurora-main/lib/gfx/common.cpp index 48d86d6..d951aa0 100644 --- a/aurora-main/lib/gfx/common.cpp +++ b/aurora-main/lib/gfx/common.cpp @@ -1366,19 +1366,22 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame) const size_t offset = layout.positionOffset + matrix * sizeof(Mat3x4); Mat3x4 source; std::memcpy(&source, uniform.data() + offset, sizeof(source)); - const auto transformed = stereo_replay::compose_affine(eye.viewFromCenter, source); + const auto transformed = stereo_replay::compose_affine(eye.viewFromScene, source); std::memcpy(uniform.data() + offset, &transformed, sizeof(transformed)); } for (uint32_t matrix = 0; matrix < layout.normalMatrixCount; ++matrix) { const size_t offset = layout.normalOffset + matrix * sizeof(Mat3x4); Mat3x4 source; std::memcpy(&source, uniform.data() + offset, sizeof(source)); - const auto transformed = stereo_replay::compose_normal(eye.viewFromCenter, source); + const auto transformed = stereo_replay::compose_normal(eye.viewFromScene, source); std::memcpy(uniform.data() + offset, &transformed, sizeof(transformed)); } } else { // 2D content reaches the eye entirely through its projection: the // draw's own position matrices lay the element out in screen space. + // The screen rectangle is built in the VR-neutral view space, so this + // path uses viewFromCenter, not viewFromScene: folding the anchor in + // would leave the screen behind at the camera the anchor replaced. // First lift viewport-local NDC into displayed-frame NDC; replay will // use a full-eye viewport so sub-pane elements are not transformed by // the recorded viewport a second time. diff --git a/aurora-main/lib/gfx/common.hpp b/aurora-main/lib/gfx/common.hpp index 5777a71..e1958d7 100644 --- a/aurora-main/lib/gfx/common.hpp +++ b/aurora-main/lib/gfx/common.hpp @@ -295,7 +295,15 @@ struct ReplayTarget { struct StereoReplayEye { ReplayTarget target; Mat4x4 projection; + // The headset's eye delta, from the VR-neutral view space into this eye's. + // The virtual screen is defined in that neutral space, so 2D reprojection + // uses this transform directly. Mat3x4 viewFromCenter; + // The same delta with the first-person scene anchor folded in, i.e. from the + // game's *recorded* view space into this eye's. World draws carry the game's + // camera in their position matrices and therefore need this one. It equals + // viewFromCenter whenever the anchor is identity. + Mat3x4 viewFromScene; }; struct StereoReplayFrame { diff --git a/aurora-main/tests/stereo_replay_test.cpp b/aurora-main/tests/stereo_replay_test.cpp index 397a134..d4fe854 100644 --- a/aurora-main/tests/stereo_replay_test.cpp +++ b/aurora-main/tests/stereo_replay_test.cpp @@ -155,5 +155,101 @@ TEST(StereoReplayTest, HudScreenParksRasterDepthAtMidrangeUnderHeadMotion) { } } +Mat3x4 identity3x4() { + Mat3x4 m{}; + m.m0 = {1.0f, 0.0f, 0.0f, 0.0f}; + m.m1 = {0.0f, 1.0f, 0.0f, 0.0f}; + m.m2 = {0.0f, 0.0f, 1.0f, 0.0f}; + return m; +} + +Mat3x4 head_tracking_delta() { + const float angle = 0.21f; + const float c = std::cos(angle); + const float s = std::sin(angle); + Mat3x4 m{}; + m.m0 = {c, 0.0f, s, 11.0f}; + m.m1 = {0.0f, 1.0f, 0.0f, -3.0f}; + m.m2 = {-s, 0.0f, c, 6.0f}; + return m; +} + +TEST(StereoReplayTest, IdentitySceneAnchorLeavesTheEyeDeltaUnchanged) { + const auto viewFromCenter = head_tracking_delta(); + + const auto viewFromScene = compose_affine(viewFromCenter, identity3x4()); + + EXPECT_EQ(viewFromScene, viewFromCenter); +} + +TEST(StereoReplayTest, TranslatingSceneAnchorMovesTheWorldByTheAnchorOffset) { + // A first-person anchor with no levelling is translate(-a): the camera moves + // to a, so every world point must arrive a units closer to the eye origin. + const std::array a{40.0f, -12.0f, -260.0f}; + auto anchor = identity3x4(); + anchor.m0[3] = -a[0]; + anchor.m1[3] = -a[1]; + anchor.m2[3] = -a[2]; + const auto viewFromCenter = head_tracking_delta(); + const auto viewFromScene = compose_affine(viewFromCenter, anchor); + + // An object matrix placing a vertex somewhere in the recorded view space. + Mat3x4 objectToCenter{}; + objectToCenter.m0 = {1.0f, 0.0f, 0.0f, 130.0f}; + objectToCenter.m1 = {0.0f, 1.0f, 0.0f, 55.0f}; + objectToCenter.m2 = {0.0f, 0.0f, 1.0f, -900.0f}; + + const auto anchored = compose_affine(viewFromScene, objectToCenter); + const auto recorded = compose_affine(viewFromCenter, objectToCenter); + + // Rotation is untouched, and the eye-space displacement is exactly the eye + // delta's rotation applied to -a. + for (size_t row = 0; row < 3; ++row) { + const auto& anchoredRow = *(&anchored.m0 + row); + const auto& recordedRow = *(&recorded.m0 + row); + const auto& viewRow = *(&viewFromCenter.m0 + row); + for (size_t column = 0; column < 3; ++column) { + EXPECT_FLOAT_EQ(anchoredRow[column], recordedRow[column]); + } + const float expected = + recordedRow[3] - (viewRow[0] * a[0] + viewRow[1] * a[1] + viewRow[2] * a[2]); + EXPECT_NEAR(anchoredRow[3], expected, 1e-3f); + } +} + +TEST(StereoReplayTest, VirtualScreenStaysAheadOfTheAnchoredCamera) { + // The screen rectangle is authored in the anchored camera's space and so + // composes with viewFromCenter, while world geometry composes with + // viewFromScene. The two agree exactly when a world object placed `distance` + // ahead of the anchored camera lands on the screen's centre. + const std::array a{40.0f, -12.0f, -260.0f}; + const float distance = 20.0f; + auto anchor = identity3x4(); + anchor.m0[3] = -a[0]; + anchor.m1[3] = -a[1]; + anchor.m2[3] = -a[2]; + const auto viewFromCenter = head_tracking_delta(); + const auto viewFromScene = compose_affine(viewFromCenter, anchor); + + // The screen's centre: (0, 0, -distance) in the anchored camera's space, + // carried into eye space by viewFromCenter alone. + const Vec4 screenCentre{0.0f, 0.0f, -distance, 1.0f}; + const float centreX = dot4(viewFromCenter.m0, screenCentre); + const float centreY = dot4(viewFromCenter.m1, screenCentre); + const float centreZ = dot4(viewFromCenter.m2, screenCentre); + + // A world object at the same place, expressed the way a GX draw carries it: + // in the *recorded* view space, hence offset by the anchor position. + Mat3x4 objectToCenter = identity3x4(); + objectToCenter.m0[3] = a[0]; + objectToCenter.m1[3] = a[1]; + objectToCenter.m2[3] = a[2] - distance; + const auto placed = compose_affine(viewFromScene, objectToCenter); + + EXPECT_NEAR(placed.m0[3], centreX, 1e-3f); + EXPECT_NEAR(placed.m1[3], centreY, 1e-3f); + EXPECT_NEAR(placed.m2[3], centreZ, 1e-3f); +} + } // namespace } // namespace aurora::gfx::stereo_replay diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 3ba72f0..87da111 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -335,6 +335,13 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform) target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17) add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests) +# The first-person VR camera's transform is deliberately header-only and free of +# guest access so it can be checked here, without a headset or a running game. +add_executable(mkw_vr_first_person_tests "${CMAKE_CURRENT_LIST_DIR}/tests/vr_first_person_tests.cpp") +target_include_directories(mkw_vr_first_person_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") +target_compile_features(mkw_vr_first_person_tests PRIVATE cxx_std_17) +add_test(NAME mkw_vr_first_person_tests COMMAND mkw_vr_first_person_tests) + # HostContext deliberately keeps the platform-specific context primitive out # of fiber_manager.cpp. Exercise the Linux libco handoff directly so future # refactors cannot silently remove its headers, implementation, or link edge. diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index a44dde7..66a65fb 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -55,6 +55,11 @@ struct RuntimeUserConfig { std::optional vrHudVirtualScreen; std::optional vrStopAtDisplayCopy; std::optional vrSkipCopyClears; + std::optional vrFirstPerson; + std::optional vrFirstPersonUnitsPerMeter; + std::optional vrFirstPersonHeadUpMeters; + std::optional vrFirstPersonHeadForwardMeters; + std::optional vrFirstPersonHeadRightMeters; std::optional audioVolume; std::optional audioMusicVolume; std::optional audioSoundEffectsVolume; @@ -339,7 +344,18 @@ inline void EnsureConfigFile() { "# reset a GX copy performs afterwards. Both keep that reset\n" "# from erasing the eye, and both are safe to turn off.\n" "stop_at_display_copy = true\n" - "skip_copy_clears = true\n\n" + "skip_copy_clears = true\n" + "# Put the camera at the Player 1 driver's head instead of behind\n" + "# the kart, with the horizon kept level. Changeable live from the\n" + "# F10 menu, and only during a single-screen race. The world scale\n" + "# below replaces world_units_per_meter while it is engaged: 10 is\n" + "# life-size, where the 500 above makes the race a small diorama.\n" + "first_person = false\n" + "first_person_units_per_meter = 10.0\n" + "# Where the head sits in the kart's own frame, in metres.\n" + "first_person_head_up_meters = 1.0\n" + "first_person_head_forward_meters = 12.0\n" + "first_person_head_right_meters = 0.0\n\n" "[audio]\n" "volume = 1.0\n" "music_volume = 1.0\n" @@ -500,6 +516,23 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { config.vrHudVirtualScreen = FindConfigValue(document, "vr", "hud_virtual_screen"); config.vrStopAtDisplayCopy = FindConfigValue(document, "vr", "stop_at_display_copy"); config.vrSkipCopyClears = FindConfigValue(document, "vr", "skip_copy_clears"); + config.vrFirstPerson = FindConfigValue(document, "vr", "first_person"); + if (auto value = FindConfigFloat(document, "vr", "first_person_units_per_meter"); + value && *value >= 1.0f && *value <= 10000.0f) { + config.vrFirstPersonUnitsPerMeter = *value; + } + if (auto value = FindConfigFloat(document, "vr", "first_person_head_up_meters"); + value && *value >= -3.0f && *value <= 3.0f) { + config.vrFirstPersonHeadUpMeters = *value; + } + if (auto value = FindConfigFloat(document, "vr", "first_person_head_forward_meters"); + value && *value >= -3.0f && *value <= 3.0f) { + config.vrFirstPersonHeadForwardMeters = *value; + } + if (auto value = FindConfigFloat(document, "vr", "first_person_head_right_meters"); + value && *value >= -3.0f && *value <= 3.0f) { + config.vrFirstPersonHeadRightMeters = *value; + } auto readVolume = [&](std::string_view key) -> std::optional { auto value = FindConfigFloat(document, "audio", key); @@ -742,6 +775,43 @@ inline bool SetVrSkipCopyClears(bool value) { return WriteSetting("vr", "skip_copy_clears", value ? "true" : "false"); } +inline bool SetVrFirstPerson(bool value) { + Mutable().vrFirstPerson = value; + return WriteSetting("vr", "first_person", value ? "true" : "false"); +} + +inline bool SetVrFirstPersonUnitsPerMeter(float value) { + value = std::clamp(value, 1.0f, 10000.0f); + Mutable().vrFirstPersonUnitsPerMeter = value; + std::ostringstream formatted; + formatted << value; + return WriteSetting("vr", "first_person_units_per_meter", formatted.str()); +} + +inline bool SetVrFirstPersonHeadUpMeters(float value) { + value = std::clamp(value, -3.0f, 3.0f); + Mutable().vrFirstPersonHeadUpMeters = value; + std::ostringstream formatted; + formatted << value; + return WriteSetting("vr", "first_person_head_up_meters", formatted.str()); +} + +inline bool SetVrFirstPersonHeadForwardMeters(float value) { + value = std::clamp(value, -3.0f, 3.0f); + Mutable().vrFirstPersonHeadForwardMeters = value; + std::ostringstream formatted; + formatted << value; + return WriteSetting("vr", "first_person_head_forward_meters", formatted.str()); +} + +inline bool SetVrFirstPersonHeadRightMeters(float value) { + value = std::clamp(value, -3.0f, 3.0f); + Mutable().vrFirstPersonHeadRightMeters = value; + std::ostringstream formatted; + formatted << value; + return WriteSetting("vr", "first_person_head_right_meters", formatted.str()); +} + inline bool SetControllerButton(size_t index, std::string value) { if (index >= kControllerButtonKeys.size()) { return false; @@ -990,6 +1060,26 @@ inline bool VrSkipCopyClears(bool fallback = true) { return Get().vrSkipCopyClears.value_or(fallback); } +inline bool VrFirstPerson(bool fallback = false) { + return Get().vrFirstPerson.value_or(fallback); +} + +inline float VrFirstPersonUnitsPerMeter(float fallback = 10.0f) { + return std::clamp(Get().vrFirstPersonUnitsPerMeter.value_or(fallback), 1.0f, 10000.0f); +} + +inline float VrFirstPersonHeadUpMeters(float fallback = 1.0f) { + return std::clamp(Get().vrFirstPersonHeadUpMeters.value_or(fallback), -3.0f, 3.0f); +} + +inline float VrFirstPersonHeadForwardMeters(float fallback = 0.0f) { + return std::clamp(Get().vrFirstPersonHeadForwardMeters.value_or(fallback), -20.0f, 20.0f); +} + +inline float VrFirstPersonHeadRightMeters(float fallback = 0.0f) { + return std::clamp(Get().vrFirstPersonHeadRightMeters.value_or(fallback), -3.0f, 3.0f); +} + inline std::string GraphicsApi(std::string fallback = "auto") { return Get().graphicsApi.value_or(std::move(fallback)); } diff --git a/runtime/include/settings_overlay.h b/runtime/include/settings_overlay.h index b706989..de95a9d 100644 --- a/runtime/include/settings_overlay.h +++ b/runtime/include/settings_overlay.h @@ -12,4 +12,8 @@ void Draw() noexcept; bool StartupScreenVisible() noexcept; void NotifyStrapInputAccepted() noexcept; void AdvancePresentedFrame() noexcept; +// Re-sends the VR virtual screen's placement to Aurora. Its metres are +// converted with the world scale currently in effect, so switching the +// first-person camera on or off has to repeat it. +void RefreshVrHudVirtualScreen() noexcept; } // namespace settings_overlay diff --git a/runtime/include/vr/mkw_vr_first_person.h b/runtime/include/vr/mkw_vr_first_person.h new file mode 100644 index 0000000..9e558f9 --- /dev/null +++ b/runtime/include/vr/mkw_vr_first_person.h @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +namespace mkw::vr { + +// A row-major affine 3x4, the same shape and convention as an NW4R/GX Mtx and +// as Aurora's Mat3x4: a point is transformed as out = M * (p, 1). +using Mtx34 = std::array; + +inline constexpr Mtx34 kIdentityMtx34{ + 1.0f, 0.0f, 0.0f, 0.0f, // + 0.0f, 1.0f, 0.0f, 0.0f, // + 0.0f, 0.0f, 1.0f, 0.0f, +}; + +// Where the driver's head sits in the kart's own frame, in metres. The kart +// frame is the EGG convention: +x right, +y up, +z forward. +struct FirstPersonHeadOffsets { + float right = 0.0f; + float up = 1.0f; + float forward = 0.0f; +}; + +// The camera relocation published to Aurora for one guest frame: a transform +// from the game's recorded view space into the space the headset renders from. +struct FirstPersonAnchor { + Mtx34 anchor_from_scene = kIdentityMtx34; + bool valid = false; + uint64_t guest_frame_index = 0; +}; + +// --------------------------------------------------------------------------- +// Pure math. Header-only and free of guest access, so it is directly testable. +// --------------------------------------------------------------------------- + +namespace detail { + +inline constexpr float kAnchorEpsilon = 1.0e-6f; + +inline bool IsFiniteFloat(const float* value) noexcept { + // The runtime is built with -ffast-math, which permits the compiler to fold + // std::isfinite to true. Inspect the object representation instead, the way + // the presentation policy validates its own floats. + uint32_t bits = 0; + std::memcpy(&bits, value, sizeof(bits)); + return (bits & 0x7F800000u) != 0x7F800000u; +} + +inline bool IsFiniteMtx34(const Mtx34& value) noexcept { + for (const float& element : value) { + if (!IsFiniteFloat(&element)) { + return false; + } + } + return true; +} + +struct Vec3 { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; +}; + +inline float Dot(const Vec3& a, const Vec3& b) noexcept { + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +inline Vec3 Cross(const Vec3& a, const Vec3& b) noexcept { + return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; +} + +inline bool Normalize(Vec3& value) noexcept { + const float length_squared = Dot(value, value); + if (!IsFiniteFloat(&length_squared) || !(length_squared > kAnchorEpsilon)) { + return false; + } + const float inverse_length = 1.0f / std::sqrt(length_squared); + value.x *= inverse_length; + value.y *= inverse_length; + value.z *= inverse_length; + return true; +} + +// out = matrix * (x, y, z, 1) +inline Vec3 TransformPoint(const Mtx34& matrix, float x, float y, float z) noexcept { + return { + matrix[0] * x + matrix[1] * y + matrix[2] * z + matrix[3], + matrix[4] * x + matrix[5] * y + matrix[6] * z + matrix[7], + matrix[8] * x + matrix[9] * y + matrix[10] * z + matrix[11], + }; +} + +} // namespace detail + +// Builds the anchor from the game's view matrix (world -> recorded view space), +// the kart's pose (kart-local -> world), and head offsets already converted to +// world units. +// +// The translation moves the camera onto the head. With level_horizon the +// rotation keeps the recorded camera's heading but drops its pitch and roll, so +// the headset owns pitch and roll outright; without it the recorded camera's +// orientation is kept whole and only the eye moves. Returns false and leaves +// `out` untouched when the inputs cannot produce an orthonormal frame. +inline bool ComputeFirstPersonAnchor(const Mtx34& view_from_world, const Mtx34& kart_from_local, + float head_right_units, float head_up_units, + float head_forward_units, bool level_horizon, + Mtx34& out) noexcept { + using namespace detail; + if (!IsFiniteMtx34(view_from_world) || !IsFiniteMtx34(kart_from_local)) { + return false; + } + const Vec3 head_world = + TransformPoint(kart_from_local, head_right_units, head_up_units, head_forward_units); + const Vec3 a = TransformPoint(view_from_world, head_world.x, head_world.y, head_world.z); + if (!IsFiniteFloat(&a.x) || !IsFiniteFloat(&a.y) || !IsFiniteFloat(&a.z)) { + return false; + } + + // Rows of the anchor's rotation. Identity keeps the recorded camera's own + // orientation and moves the eye only. + Vec3 rows[3]{{1.0f, 0.0f, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f, 1.0f}}; + if (level_horizon) { + // World +Y in view coordinates: the column of the view rotation that + // the world up axis selects. + Vec3 up{view_from_world[1], view_from_world[5], view_from_world[9]}; + if (!Normalize(up)) { + return false; + } + // Level the recorded camera's forward (-Z in its own space) onto the + // horizon plane. Looking near-straight up or down leaves nothing to + // project, so recover the heading from the camera's up axis instead. + const Vec3 camera_forward{0.0f, 0.0f, -1.0f}; + float along = Dot(camera_forward, up); + Vec3 forward{camera_forward.x - up.x * along, camera_forward.y - up.y * along, + camera_forward.z - up.z * along}; + if (!Normalize(forward)) { + const Vec3 camera_up{0.0f, 1.0f, 0.0f}; + along = Dot(camera_up, up); + forward = {camera_up.x - up.x * along, camera_up.y - up.y * along, + camera_up.z - up.z * along}; + if (!Normalize(forward)) { + return false; + } + } + Vec3 right = Cross(forward, up); + if (!Normalize(right)) { + return false; + } + // Re-derive up from the orthonormalized pair so a slightly non-rigid + // view matrix cannot leave a skewed frame behind. + rows[0] = right; + rows[1] = Cross(right, forward); + rows[2] = {-forward.x, -forward.y, -forward.z}; + } + + Mtx34 anchor{}; + for (uint32_t row = 0; row < 3; ++row) { + anchor[row * 4 + 0] = rows[row].x; + anchor[row * 4 + 1] = rows[row].y; + anchor[row * 4 + 2] = rows[row].z; + anchor[row * 4 + 3] = -Dot(rows[row], a); + } + if (!IsFiniteMtx34(anchor)) { + return false; + } + out = anchor; + return true; +} + +// --------------------------------------------------------------------------- +// Per-frame observation. Called from the translated-code observers on the guest +// thread; the anchor is consumed by the producer at its Aurora frame seal. +// --------------------------------------------------------------------------- + +// Enables anchor computation and sets the head offsets and world scale used to +// convert them. Called whenever the configuration or the F10 toggle changes. +void MkwVRFirstPersonConfigure(bool enabled, const FirstPersonHeadOffsets& offsets, + float units_per_meter) noexcept; + +// Reads the current [vr] first-person settings and applies them here and to the +// presentation policy's world scale. The single place those settings are +// interpreted, shared by startup and the F10 settings bar. +void MkwVRFirstPersonApplyConfiguredSettings() noexcept; + +// Reads the race camera and the player's kart and republishes the anchor. Call +// once per guest frame, after the kart and camera updates and before the draws. +// race_camera_address is the frame's own RaceCamera, or zero if none was seen. +void MkwVRFirstPersonUpdate(uint64_t guest_frame_index, uint32_t race_camera_address) noexcept; + +// Drops every captured pointer and the held anchor. Call on race entry/exit. +void MkwVRFirstPersonReset() noexcept; + +// Producer-side read. Thread-safe. A valid anchor is also what marks the mode +// as engaged, and so what selects the first-person world scale: it is invalid +// whenever the mode is off, the race has not produced a usable anchor, or the +// anchor has been missing long enough to give up holding the last one. +FirstPersonAnchor MkwVRFirstPersonGetAnchor() noexcept; + +} // namespace mkw::vr diff --git a/runtime/include/vr/mkw_vr_policy.h b/runtime/include/vr/mkw_vr_policy.h index cb0440a..4f369af 100644 --- a/runtime/include/vr/mkw_vr_policy.h +++ b/runtime/include/vr/mkw_vr_policy.h @@ -78,6 +78,11 @@ struct MkwVRPolicyConfig { // immersive race HUD so 2D content keeps its place across the transition. float hud_width_meters = 2.4f; float hud_scale = 1.0f; + // World scale used while the first-person camera is engaged. Mario Kart + // Wii is authored at roughly this many units per metre, so it is what + // makes the race read life-size; the third-person default deliberately + // does not, presenting the race as a small diorama instead. + float first_person_units_per_meter = 10.0f; }; struct MkwVRSceneObservation { @@ -108,6 +113,10 @@ struct MkwVRPolicySnapshot { MkwVRCameraObservation camera{}; uint32_t available_bindings = MkwVRBindingNone; bool session_active = false; + // A first-person camera is actually driving the view this frame. Set by the + // integration layer once the anchor it publishes to the renderer is valid, + // so the world scale can never disagree with where the camera is. + bool first_person_engaged = false; // Changes whenever the stable presentation-safety state changes. Ordinary // per-frame scene/camera publication does not advance it. uint64_t safety_generation = 1; @@ -115,6 +124,13 @@ struct MkwVRPolicySnapshot { // the current presentation mode, so a transient scene/camera mismatch // cannot accept an immersive packet from an adjacent asynchronous frame. uint64_t content_tag = 0; + + // The scale headset translation and IPD are converted at. Head offsets in + // metres must use the same value, or the camera and the world disagree. + float EffectiveUnitsPerMeter() const noexcept { + return first_person_engaged ? config.first_person_units_per_meter + : config.world_units_per_meter; + } }; // All policy functions are thread-safe. Publishing functions are intended for @@ -127,6 +143,14 @@ void MkwVRPolicySetAvailableBindings(uint32_t bindings) noexcept; void MkwVRPolicyPublishScene(const MkwVRSceneObservation& scene) noexcept; void MkwVRPolicyPublishRaceCamera(const MkwVRCameraObservation& camera) noexcept; void MkwVRPolicyInvalidateRaceCamera() noexcept; +// Reported by the integration layer each time the first-person anchor engages +// or disengages. It selects the world scale and nothing else: presentation +// safety is unaffected, so this never advances the safety generation. +void MkwVRPolicySetFirstPersonEngaged(bool engaged) noexcept; +// Live world scale for the first-person camera. Separate from +// MkwVRPolicyConfigure so the F10 slider can retune it during a race without +// republishing (and revalidating) the whole configuration. +void MkwVRPolicySetFirstPersonUnitsPerMeter(float units_per_meter) noexcept; MkwVRPolicySnapshot MkwVRPolicyGetSnapshot() noexcept; // This classifier is deliberately structural rather than heuristic: future diff --git a/runtime/src/hle/vi.cpp b/runtime/src/hle/vi.cpp index a18279c..4a58f2d 100644 --- a/runtime/src/hle/vi.cpp +++ b/runtime/src/hle/vi.cpp @@ -8,6 +8,7 @@ #include "fiber_manager.h" #include "platform/host_platform.h" #include "runtime_log.h" +#include "vr/mkw_vr_first_person.h" #include "vr/mkw_vr_policy.h" #include "vr/openxr_integration.h" @@ -516,6 +517,27 @@ void PaceToRetraceBoundary(Clock::time_point deadline) { VI_HLE_ProcessRetracesDeferred(1); } +// Hands Aurora the first-person camera relocation observed while this frame's +// GX commands were produced. It has to be published here rather than by the XR +// pacing thread: the anchor only makes sense against the recorded camera of +// this exact frame, and the pacing thread does not know which frame its packet +// will be paired with. +void PublishVrSceneAnchor() { + static bool s_engaged = false; + const mkw::vr::FirstPersonAnchor anchor = mkw::vr::MkwVRFirstPersonGetAnchor(); + aurora_set_stereo_scene_anchor(anchor.valid ? anchor.anchor_from_scene.data() : nullptr); + + const bool engaged = anchor.valid; + if (engaged == s_engaged) { + return; + } + s_engaged = engaged; + // The world scale changes with the camera, and the virtual screen's metres + // are converted at that scale, so the two have to move together. + mkw::vr::MkwVRPolicySetFirstPersonEngaged(engaged); + settings_overlay::RefreshVrHudVirtualScreen(); +} + } // namespace // Single owner of the Aurora frame presentation sequence: seals the active frame, optionally paces the @@ -584,6 +606,7 @@ void VI_HLE_PresentFrame(bool presentedXfb, bool paceToRetrace) { } mkw::vr::OpenXRServiceProducerFrameBoundary(); + PublishVrSceneAnchor(); // Latch the current policy safety state into this exact Aurora job. The // asynchronous worker may ask for an XR packet after the guest has already // begun the next frame, so immersive replay is accepted only when both diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index d1cbee1..39260ae 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -5,6 +5,8 @@ #include "music_attenuation.h" #include "runtime_config.h" #include "runtime_log.h" +#include "vr/mkw_vr_first_person.h" +#include "vr/mkw_vr_policy.h" #include "wii_remote_input.h" #include @@ -103,6 +105,11 @@ bool g_vrEnabled = RuntimeConfigFile::VrEnabled(false); bool g_vrStopAtDisplayCopy = RuntimeConfigFile::VrStopAtDisplayCopy(true); bool g_vrSkipCopyClears = RuntimeConfigFile::VrSkipCopyClears(true); bool g_vrHudVirtualScreen = RuntimeConfigFile::VrHudVirtualScreen(true); +bool g_vrFirstPerson = RuntimeConfigFile::VrFirstPerson(false); +float g_vrFirstPersonUnitsPerMeter = RuntimeConfigFile::VrFirstPersonUnitsPerMeter(10.0f); +float g_vrFirstPersonHeadUp = RuntimeConfigFile::VrFirstPersonHeadUpMeters(1.0f); +float g_vrFirstPersonHeadForward = RuntimeConfigFile::VrFirstPersonHeadForwardMeters(0.0f); +float g_vrFirstPersonHeadRight = RuntimeConfigFile::VrFirstPersonHeadRightMeters(0.0f); uint32_t g_disabledPostProcessingPaths = RuntimeConfigFile::DisabledPostProcessingPaths(0); std::array g_configuredControllerIndices = [] { std::array indices{}; @@ -708,9 +715,11 @@ void DrawAudioSettings() { // The virtual screen's placement comes from the launch-time [vr] geometry, the // same metres the menu quad is built from, converted into the world units the -// eye replay works in. +// eye replay works in. Those units follow the camera: the first-person view +// renders at its own scale, and the screen has to be sized at the same one or +// it would not stay 2 m across in front of the player. void ApplyVrHudVirtualScreen() { - const float unitsPerMeter = RuntimeConfigFile::VrWorldUnitsPerMeter(500.0f); + const float unitsPerMeter = mkw::vr::MkwVRPolicyGetSnapshot().EffectiveUnitsPerMeter(); aurora_set_stereo_hud_screen(g_vrHudVirtualScreen, RuntimeConfigFile::VrHudWidthMeters(2.4f) * unitsPerMeter, RuntimeConfigFile::VrHudDistanceMeters(2.0f) * unitsPerMeter); @@ -851,6 +860,51 @@ void DrawGraphicsSettings() { "the whole view. Its size and distance are the [vr] hud_width_meters and " "hud_distance_meters read at launch."); } + ImGui::Separator(); + ImGui::Text("VR camera"); + if (ImGui::Checkbox("First-person camera", &g_vrFirstPerson)) { + RuntimeConfigFile::SetVrFirstPerson(g_vrFirstPerson); + mkw::vr::MkwVRFirstPersonApplyConfiguredSettings(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Moves the camera to the Player 1 driver's head and keeps the horizon level, " + "instead of riding behind the kart. Applies during a single-screen race; menus " + "and split-screen are unaffected. The world scale below replaces " + "world_units_per_meter while it is engaged."); + } + // These are the tuning loop for the anchor: the right head height is a + // per-taste value that can only really be judged from inside the headset. + if (ImGui::SliderFloat("World units per metre (first person)", &g_vrFirstPersonUnitsPerMeter, + 1.0f, 200.0f, "%.1f")) { + RuntimeConfigFile::SetVrFirstPersonUnitsPerMeter(g_vrFirstPersonUnitsPerMeter); + mkw::vr::MkwVRFirstPersonApplyConfiguredSettings(); + // The virtual screen's metres are converted at this same scale. + ApplyVrHudVirtualScreen(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Mario Kart Wii is authored at about 10 units per metre, which is what makes the " + "race read life-size. Raising this shrinks the world around you."); + } + bool headOffsetsChanged = false; + headOffsetsChanged |= + ImGui::SliderFloat("Head height (m)", &g_vrFirstPersonHeadUp, -1.0f, 3.0f, "%.2f"); + headOffsetsChanged |= + ImGui::SliderFloat("Head forward (m)", &g_vrFirstPersonHeadForward, -20.0f, 20.0f, "%.2f"); + headOffsetsChanged |= + ImGui::SliderFloat("Head sideways (m)", &g_vrFirstPersonHeadRight, -3.0f, 3.0f, "%.2f"); + if (headOffsetsChanged) { + RuntimeConfigFile::SetVrFirstPersonHeadUpMeters(g_vrFirstPersonHeadUp); + RuntimeConfigFile::SetVrFirstPersonHeadForwardMeters(g_vrFirstPersonHeadForward); + RuntimeConfigFile::SetVrFirstPersonHeadRightMeters(g_vrFirstPersonHeadRight); + mkw::vr::MkwVRFirstPersonApplyConfiguredSettings(); + } + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); + ImGui::TextDisabled( + "Where the head sits in the kart's own frame. Nudge it forward if the driver's own " + "head intrudes on the view."); + ImGui::PopTextWrapPos(); } void DrawFpsOverlay() { @@ -1072,11 +1126,14 @@ void InitializeRuntimeSettings() noexcept { aurora_set_stereo_skip_copy_clears(g_vrSkipCopyClears); ApplyVrHudVirtualScreen(); aurora_set_skip_unready_pipelines(g_skipUnreadyPipelines); + mkw::vr::MkwVRFirstPersonApplyConfiguredSettings(); g_strapInputAccepted.store(false, std::memory_order_relaxed); g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed); PADBlockInput(false); } +void RefreshVrHudVirtualScreen() noexcept { ApplyVrHudVirtualScreen(); } + void HandleEvents(const AuroraEvent* events) noexcept { if (!events) { return; diff --git a/runtime/src/vr/mkw_vr_first_person.cpp b/runtime/src/vr/mkw_vr_first_person.cpp new file mode 100644 index 0000000..aea41f6 --- /dev/null +++ b/runtime/src/vr/mkw_vr_first_person.cpp @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "vr/mkw_vr_first_person.h" + +#include "memory.h" +#include "runtime_config.h" +#include "runtime_log.h" +#include "vr/mkw_vr_policy.h" + +#include + +extern "C" void func_805A6C58(CpuContext* context); + +namespace mkw::vr { +namespace { + +// --------------------------------------------------------------------------- +// PAL RMCP01 object layout. +// +// Derived from the shipped StaticR.rel and cross-checked against the mkw +// decompilation. Each constant names the accessor that proves it, so a future +// region or a mod that moves these can be re-derived the same way. Keep in +// sync with projects/mkwii/MAP.txt and the generated translations. +// --------------------------------------------------------------------------- + +// RaceCamera::GetViewMtx (0x805A6C58) writes the authoritative view matrix to +// its r4 output buffer. The adjacent RaceCamera fields are state vectors, not +// a view matrix, so call the game's getter instead of guessing an object offset. +constexpr uint32_t kRaceCameraScratchBytes = 0x300u; + +// Kart::Manager's instance pointer. Its CreateInstance (0x8058FAA8) resolves +// the slot as 0x809C0000 + 6392 in the generated translation. Read directly +// rather than observed from Kart::Manager::Update's r3, so enabling the camera +// needs no change to the translated output: an entry observer only exists in a +// build whose translation was regenerated for it, and its absence is silent. +// This mirrors how the race scene's instance slot is reached in +// mkw_vr_instrumentation.cpp. +constexpr uint32_t kKartManagerInstanceAddress = 0x809C18F8u; +// Kart::Manager::GetKartPlayer (0x80590100): `lwz r3,0x20(r3)` then indexes. +constexpr uint32_t kKartManagerPlayersOffset = 0x20u; +// Kart::Link::GetKartPosition (0x8059020C) walks proxy -> accessor -> body -> +// physics -> dynamics; the first three links are shared by every kart accessor. +constexpr uint32_t kKartProxyAccessorOffset = 0x00u; +constexpr uint32_t kKartAccessorBodyOffset = 0x08u; +constexpr uint32_t kKartBodyPhysicsOffset = 0x90u; +// KartPhysics::pose (Kart::Link::GetMtx 0x80590264). This is the physics-driven +// pose, deliberately not the visual one: an animated frame would bob the +// camera. Kart::Link::GetKartBodyMtx (0x80590278) returns KartBody+0x1C, the +// visual pose, and is the alternative to try if the seat ever looks detached. +constexpr uint32_t kKartPhysicsPoseOffset = 0x9Cu; + +// Offline Mario Kart Wii puts the local racer first, and immersive +// presentation already requires exactly one on-screen player. +constexpr uint32_t kLocalPlayerIndex = 0; + +// Frames the last good anchor survives a failed read before the camera returns +// to the game's own. Rides out a transient null during a respawn or transition +// without letting a genuinely broken anchor persist. +constexpr int kHoldFrames = 10; + +// --------------------------------------------------------------------------- +// Guest reads. Everything is bounds-checked and exception-guarded so a pointer +// caught mid-teardown can only cost this frame's anchor. +// --------------------------------------------------------------------------- + +bool ReadGuestPointer(uint32_t address, uint32_t& out) noexcept { + return Memory::TryRead32(address, out) && out != 0; +} + +constexpr uint32_t kMtx34Bytes = 12u * sizeof(float); + +bool ReadGuestMtx34(uint32_t address, Mtx34& out) noexcept { + if (address == 0 || !Memory::Contains(address, kMtx34Bytes)) { + return false; + } + try { + for (uint32_t i = 0; i < out.size(); ++i) { + out[i] = Memory::ReadFloat32(address + i * static_cast(sizeof(float))); + } + } catch (const Memory::AccessViolation&) { + return false; + } + return detail::IsFiniteMtx34(out); +} + +bool ReadRaceCameraViewMatrix(const CpuContext* context, uint32_t camera_address, + Mtx34& out) noexcept { + if (context == nullptr || camera_address == 0 || + context->gpr[1] < kRaceCameraScratchBytes) { + return false; + } + + CpuContext call_context = *context; + const uint32_t scratch = context->gpr[1] - kRaceCameraScratchBytes; + call_context.gpr[3] = camera_address; + call_context.gpr[4] = scratch; + call_context.gpr[5] = scratch + 48u; + try { + CpuContextScope scope(&call_context); + func_805A6C58(&call_context); + return ReadGuestMtx34(scratch, out); + } catch (const Memory::AccessViolation&) { + return false; + } +} + +// The pointer walk, kept inspectable: on failure `failed_step` names the link +// that broke and the resolved pointers before it are still filled in. One log +// line then says exactly which offset needs revisiting. +struct KartPoseRead { + const char* failed_step = nullptr; + uint32_t manager = 0; + uint32_t players = 0; + uint32_t proxy = 0; + uint32_t accessor = 0; + uint32_t body = 0; + uint32_t physics = 0; +}; + +KartPoseRead ReadPlayerKartPose(Mtx34& out) noexcept { + KartPoseRead read{}; + if (!ReadGuestPointer(kKartManagerInstanceAddress, read.manager)) { + read.failed_step = "Kart::Manager instance"; + } else if (!ReadGuestPointer(read.manager + kKartManagerPlayersOffset, read.players)) { + read.failed_step = "Kart::Manager players array"; + } else if (!ReadGuestPointer(read.players + kLocalPlayerIndex * 4u, read.proxy)) { + read.failed_step = "player kart object"; + } else if (!ReadGuestPointer(read.proxy + kKartProxyAccessorOffset, read.accessor)) { + read.failed_step = "kart accessor"; + } else if (!ReadGuestPointer(read.accessor + kKartAccessorBodyOffset, read.body)) { + read.failed_step = "kart body"; + } else if (!ReadGuestPointer(read.body + kKartBodyPhysicsOffset, read.physics)) { + read.failed_step = "kart physics"; + } else if (!ReadGuestMtx34(read.physics + kKartPhysicsPoseOffset, out)) { + read.failed_step = "kart pose matrix"; + } + return read; +} + +// --------------------------------------------------------------------------- + +struct FirstPersonState { + bool enabled = false; + FirstPersonHeadOffsets offsets{}; + float units_per_meter = 10.0f; + + uint32_t camera_address = 0; + + FirstPersonAnchor anchor{}; + int hold_frames = 0; + bool ever_valid_this_race = false; + bool failure_logged = false; + uint64_t logged_frame = 0; +}; + +std::mutex g_mutex; +FirstPersonState g_state; + +void LogAnchorLocked(uint64_t frame, const Mtx34& anchor, const Mtx34& view_from_world, + const KartPoseRead& kart, const Mtx34& kart_from_local) noexcept { + // One line per second at 60 Hz: enough to confirm the offsets on-device + // without drowning the log during a race. + if (g_state.logged_frame != 0 && frame - g_state.logged_frame < 60) { + return; + } + g_state.logged_frame = frame; + // The anchor's translation is -R*a, so negating it gives the head's offset + // from the recorded camera measured in the levelled camera's own axes. + // While driving it should stay roughly constant: a little to the side, a + // little below the chase camera, and well in front of it. + RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] first-person anchor: frame=" << frame << ", camera=0x" + << std::hex << g_state.camera_address << std::dec + << ", head from camera (right, up, forward)=(" << -anchor[3] << ", " + << -anchor[7] << ", " << anchor[11] << ") units" << std::endl; + RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] first-person view: rows=(" << view_from_world[0] << ", " + << view_from_world[1] << ", " << view_from_world[2] << "; " + << view_from_world[4] << ", " << view_from_world[5] << ", " + << view_from_world[6] << "; " << view_from_world[8] << ", " + << view_from_world[9] << ", " << view_from_world[10] + << "), translation=(" << view_from_world[3] << ", " + << view_from_world[7] << ", " << view_from_world[11] << ")" + << std::endl; + RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] first-person pose: physics=0x" << std::hex << kart.physics + << ", pose=0x" << (kart.physics + kKartPhysicsPoseOffset) << std::dec + << ", rows=(" << kart_from_local[0] << ", " << kart_from_local[1] + << ", " << kart_from_local[2] << "; " << kart_from_local[4] << ", " + << kart_from_local[5] << ", " << kart_from_local[6] << "; " + << kart_from_local[8] << ", " << kart_from_local[9] << ", " + << kart_from_local[10] << "), translation=(" << kart_from_local[3] + << ", " << kart_from_local[7] << ", " << kart_from_local[11] << ")" + << std::endl; + RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] first-person pose bits: translation=(0x" + << std::hex << std::bit_cast(kart_from_local[3]) << ", 0x" + << std::bit_cast(kart_from_local[7]) << ", 0x" + << std::bit_cast(kart_from_local[11]) << ")" << std::dec + << std::endl; +} + +} // namespace + +void MkwVRFirstPersonConfigure(bool enabled, const FirstPersonHeadOffsets& offsets, + float units_per_meter) noexcept { + std::lock_guard lock(g_mutex); + g_state.enabled = enabled; + g_state.offsets = offsets; + if (detail::IsFiniteFloat(&units_per_meter) && units_per_meter > 0.0f) { + g_state.units_per_meter = units_per_meter; + } + if (!enabled) { + g_state.anchor = {}; + g_state.hold_frames = 0; + } +} + +void MkwVRFirstPersonApplyConfiguredSettings() noexcept { + const float units_per_meter = RuntimeConfigFile::VrFirstPersonUnitsPerMeter(10.0f); + const FirstPersonHeadOffsets offsets{ + RuntimeConfigFile::VrFirstPersonHeadRightMeters(0.0f), + RuntimeConfigFile::VrFirstPersonHeadUpMeters(1.0f), + RuntimeConfigFile::VrFirstPersonHeadForwardMeters(0.0f), + }; + MkwVRFirstPersonConfigure(RuntimeConfigFile::VrFirstPerson(false), offsets, units_per_meter); + MkwVRPolicySetFirstPersonUnitsPerMeter(units_per_meter); +} + +void MkwVRFirstPersonReset() noexcept { + std::lock_guard lock(g_mutex); + g_state.camera_address = 0; + g_state.anchor = {}; + g_state.hold_frames = 0; + g_state.ever_valid_this_race = false; + g_state.failure_logged = false; + g_state.logged_frame = 0; +} + +void MkwVRFirstPersonUpdate(uint64_t guest_frame_index, uint32_t race_camera_address) noexcept { + std::lock_guard lock(g_mutex); + if (!g_state.enabled) { + g_state.anchor = {}; + g_state.hold_frames = 0; + return; + } + g_state.camera_address = race_camera_address; + + Mtx34 view_from_world{}; + Mtx34 kart_from_local{}; + Mtx34 anchor{}; + KartPoseRead kart{}; + const char* failed_step = nullptr; + if (race_camera_address == 0) { + failed_step = "race camera (none updated this frame)"; + } else if (!ReadRaceCameraViewMatrix(TryGetCpuContext(), race_camera_address, + view_from_world)) { + failed_step = "race camera view matrix"; + } else if (kart = ReadPlayerKartPose(kart_from_local); kart.failed_step != nullptr) { + failed_step = kart.failed_step; + } else if (!ComputeFirstPersonAnchor(view_from_world, kart_from_local, + g_state.offsets.right * g_state.units_per_meter, + g_state.offsets.up * g_state.units_per_meter, + g_state.offsets.forward * g_state.units_per_meter, + /*level_horizon=*/true, anchor)) { + failed_step = "anchor math (degenerate camera or kart frame)"; + } + + if (failed_step == nullptr) { + g_state.anchor = {anchor, true, guest_frame_index}; + g_state.hold_frames = kHoldFrames; + g_state.ever_valid_this_race = true; + LogAnchorLocked(guest_frame_index, anchor, view_from_world, kart, kart_from_local); + return; + } + + if (g_state.hold_frames > 0) { + --g_state.hold_frames; + g_state.anchor.guest_frame_index = guest_frame_index; + return; + } + if (!g_state.ever_valid_this_race && !g_state.failure_logged) { + // Once per race, naming the exact link that broke: every address below + // is a PAL RMCP01 constant, so this is what says which one to revisit. + g_state.failure_logged = true; + RT_LOG(RT_TAG_RUNTIME) + << "[mkw-vr] first-person camera is enabled but could not resolve the " + << failed_step << "; staying on the game's own camera (camera=0x" << std::hex + << race_camera_address << ", manager=0x" << kart.manager << ", players=0x" + << kart.players << ", kart=0x" << kart.proxy << ", accessor=0x" << kart.accessor + << ", body=0x" << kart.body << ", physics=0x" << kart.physics << std::dec << ")" + << std::endl; + } + g_state.anchor = {}; +} + +FirstPersonAnchor MkwVRFirstPersonGetAnchor() noexcept { + std::lock_guard lock(g_mutex); + return g_state.anchor; +} + +} // namespace mkw::vr diff --git a/runtime/src/vr/mkw_vr_instrumentation.cpp b/runtime/src/vr/mkw_vr_instrumentation.cpp index 941bb2a..b80c4eb 100644 --- a/runtime/src/vr/mkw_vr_instrumentation.cpp +++ b/runtime/src/vr/mkw_vr_instrumentation.cpp @@ -5,6 +5,7 @@ #include "memory.h" #include "ppc_runtime.h" #include "runtime_log.h" +#include "vr/mkw_vr_first_person.h" #include "vr/mkw_vr_policy.h" #include @@ -69,6 +70,15 @@ uint32_t CameraCount(uint64_t frame) noexcept { return g_instrumentation.camera_count; } +// The first RaceCamera updated this frame. The game also updates cameras for +// transitions and effects, so first-wins is what keeps the first-person anchor +// deterministic: Mario Kart updates the racers' cameras before those. +uint32_t FirstCamera(uint64_t frame) noexcept { + std::lock_guard lock(g_instrumentation_mutex); + ResetCamerasForFrameLocked(frame); + return g_instrumentation.camera_count != 0 ? g_instrumentation.cameras[0] : 0; +} + uint32_t RaceScreenCount() noexcept { uint32_t race_scene = 0; if (!Memory::TryRead32(kRaceSceneInstanceAddress, race_scene) || race_scene == 0 || @@ -150,6 +160,7 @@ extern "C" void MkwVRObserveTranslatedFunctionEntry(uint32_t address, } PublishRaceScene(frame, 0); MkwVRPolicyInvalidateRaceCamera(); + MkwVRFirstPersonReset(); RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] entered RaceScene at frame " << frame << std::endl; break; @@ -169,6 +180,10 @@ extern "C" void MkwVRObserveTranslatedFunctionEntry(uint32_t address, const uint32_t screen_count = RaceScreenCount(); LogRaceEvidenceIfChanged(frame, screen_count, camera_count); PublishRaceScene(frame, screen_count); + // Every kart and camera has been updated for this frame and none of + // the frame's draws have been issued yet, so the values behind these + // pointers are exactly the ones those draws will use. + MkwVRFirstPersonUpdate(frame, FirstCamera(frame)); break; } case kRaceSceneOnExit: { @@ -177,6 +192,7 @@ extern "C" void MkwVRObserveTranslatedFunctionEntry(uint32_t address, scene.guest_frame_index = frame; MkwVRPolicyPublishScene(scene); MkwVRPolicyInvalidateRaceCamera(); + MkwVRFirstPersonReset(); RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] exited RaceScene at frame " << frame << std::endl; break; diff --git a/runtime/src/vr/mkw_vr_policy.cpp b/runtime/src/vr/mkw_vr_policy.cpp index 2752f3b..8f3a17f 100644 --- a/runtime/src/vr/mkw_vr_policy.cpp +++ b/runtime/src/vr/mkw_vr_policy.cpp @@ -17,6 +17,7 @@ struct PolicyState { MkwVRCameraObservation camera{}; uint32_t available_bindings = MkwVRBindingNone; bool session_active = false; + bool first_person_engaged = false; uint64_t safety_generation = 1; }; @@ -56,6 +57,9 @@ MkwVRPolicyConfig SanitizeConfig(const MkwVRPolicyConfig& config) noexcept { if (!IsFinitePositive(&sanitized.hud_scale)) { sanitized.hud_scale = kDefaultConfig.hud_scale; } + if (!IsFinitePositive(&sanitized.first_person_units_per_meter)) { + sanitized.first_person_units_per_meter = kDefaultConfig.first_person_units_per_meter; + } return sanitized; } @@ -231,6 +235,21 @@ void MkwVRPolicyInvalidateRaceCamera() noexcept { ApplyPolicyMutation([&] { g_policy.camera.valid = false; }); } +void MkwVRPolicySetFirstPersonEngaged(bool engaged) noexcept { + std::lock_guard lock(g_policy_mutex); + // Not routed through ApplyPolicyMutation: where the camera sits does not + // change which content is safe to present, and advancing the safety + // generation here would drop a frame to mono on every engage. + g_policy.first_person_engaged = engaged; +} + +void MkwVRPolicySetFirstPersonUnitsPerMeter(float units_per_meter) noexcept { + std::lock_guard lock(g_policy_mutex); + if (IsFinitePositive(&units_per_meter)) { + g_policy.config.first_person_units_per_meter = units_per_meter; + } +} + MkwVRPolicySnapshot MkwVRPolicyGetSnapshot() noexcept { std::lock_guard lock(g_policy_mutex); MkwVRPolicySnapshot snapshot; @@ -240,6 +259,8 @@ MkwVRPolicySnapshot MkwVRPolicyGetSnapshot() noexcept { snapshot.camera = g_policy.camera; snapshot.available_bindings = g_policy.available_bindings; snapshot.session_active = g_policy.session_active; + snapshot.first_person_engaged = + g_policy.first_person_engaged && snapshot.presentation == VRPresentationMode::ImmersiveRace; snapshot.safety_generation = g_policy.safety_generation; snapshot.content_tag = MakeContentTag(g_policy, snapshot.presentation); return snapshot; diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp index 9a073b0..bb9a31c 100644 --- a/runtime/src/vr/openxr_integration.cpp +++ b/runtime/src/vr/openxr_integration.cpp @@ -8,6 +8,7 @@ #include "runtime_config.h" #include "runtime_log.h" +#include "vr/mkw_vr_first_person.h" #include "vr/mkw_vr_policy.h" #include "vr/mkw_vr_instrumentation.h" @@ -44,8 +45,10 @@ void ConfigurePolicy(bool enabled) noexcept { config.world_units_per_meter = RuntimeConfigFile::VrWorldUnitsPerMeter(500.0f); config.hud_distance_meters = RuntimeConfigFile::VrHudDistanceMeters(2.0f); config.hud_width_meters = RuntimeConfigFile::VrHudWidthMeters(2.4f); + config.first_person_units_per_meter = RuntimeConfigFile::VrFirstPersonUnitsPerMeter(10.0f); MkwVRPolicyConfigure(config); MkwVRInstrumentationInitialize(); + MkwVRFirstPersonApplyConfiguredSettings(); } #if defined(MKW_ENABLE_OPENXR) && defined(_WIN32) @@ -487,7 +490,11 @@ private: { std::lock_guard lock(published_mutex_); - BuildPublishedFrame(frame, immersive, policy.config.world_units_per_meter, + // First person renders at life-size scale, third person at the + // configured diorama scale. Head translation and IPD are the + // only things this multiplies, so a one-frame disagreement with + // the camera's own switch is not observable. + BuildPublishedFrame(frame, immersive, policy.EffectiveUnitsPerMeter(), policy.content_tag); published_.store(&published_frame_, std::memory_order_release); } diff --git a/runtime/tests/vr_first_person_tests.cpp b/runtime/tests/vr_first_person_tests.cpp new file mode 100644 index 0000000..74d13b0 --- /dev/null +++ b/runtime/tests/vr_first_person_tests.cpp @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// The first-person VR camera's transform, tested without a guest. Everything +// here exercises ComputeFirstPersonAnchor, which turns the game's own view and +// kart matrices into the relocation Aurora composes onto each eye. + +#include "vr/mkw_vr_first_person.h" + +#include +#include +#include +#include + +namespace { + +using mkw::vr::ComputeFirstPersonAnchor; +using mkw::vr::kIdentityMtx34; +using mkw::vr::Mtx34; + +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"; + } +} + +// out = matrix * (x, y, z, 1) +void Apply(const Mtx34& matrix, float x, float y, float z, float out[3]) { + out[0] = matrix[0] * x + matrix[1] * y + matrix[2] * z + matrix[3]; + out[1] = matrix[4] * x + matrix[5] * y + matrix[6] * z + matrix[7]; + out[2] = matrix[8] * x + matrix[9] * y + matrix[10] * z + matrix[11]; +} + +// A view matrix for a camera at `eye` looking along -Z with no pitch or roll. +Mtx34 LevelViewAt(float x, float y, float z) { + Mtx34 view = kIdentityMtx34; + view[3] = -x; + view[7] = -y; + view[11] = -z; + return view; +} + +// The same, pitched down by `radians` about the view's X axis. Rows are the +// camera's axes in world space, which is what a world -> view matrix holds. +Mtx34 PitchedViewAt(float x, float y, float z, float radians) { + const float c = std::cos(radians); + const float s = std::sin(radians); + Mtx34 view{}; + view[0] = 1.0f; + view[5] = c; + view[6] = s; + view[9] = -s; + view[10] = c; + view[3] = -(view[0] * x + view[1] * y + view[2] * z); + view[7] = -(view[4] * x + view[5] * y + view[6] * z); + view[11] = -(view[8] * x + view[9] * y + view[10] * z); + return view; +} + +Mtx34 KartAt(float x, float y, float z) { + Mtx34 pose = kIdentityMtx34; + pose[3] = x; + pose[7] = y; + pose[11] = z; + return pose; +} + +void TestNeutralInputsProduceIdentity() { + Mtx34 anchor{}; + Check(ComputeFirstPersonAnchor(kIdentityMtx34, kIdentityMtx34, 0.0f, 0.0f, 0.0f, + /*level_horizon=*/true, anchor), + "a camera already at the head must produce an anchor"); + for (size_t i = 0; i < anchor.size(); ++i) { + CheckNear(anchor[i], kIdentityMtx34[i], "neutral inputs must produce the identity anchor"); + } +} + +void TestUnlevelledAnchorIsPureTranslation() { + // Camera 5 m behind and 2 m above the origin, kart at the origin, head 1 m up. + const Mtx34 view = LevelViewAt(0.0f, 2.0f, 5.0f); + const Mtx34 kart = KartAt(0.0f, 0.0f, 0.0f); + Mtx34 anchor{}; + Check(ComputeFirstPersonAnchor(view, kart, 0.0f, 1.0f, 0.0f, /*level_horizon=*/false, anchor), + "an unlevelled anchor must be computable"); + + // The head sits at (0, -1, -5) in view space, so the anchor's translation + // is its negation. + CheckNear(anchor[3], 0.0f, "no lateral offset"); + CheckNear(anchor[7], 1.0f, "the anchor cancels the head's -1 view-space height"); + CheckNear(anchor[11], 5.0f, "the anchor cancels the head's -5 view-space depth"); + + // Rotation untouched, so a world point keeps its orientation and only shifts. + float moved[3]; + Apply(anchor, 0.0f, -1.0f, -5.0f, moved); + CheckNear(moved[0], 0.0f, "the head lands at the eye origin (x)"); + CheckNear(moved[1], 0.0f, "the head lands at the eye origin (y)"); + CheckNear(moved[2], 0.0f, "the head lands at the eye origin (z)"); +} + +void TestLevellingRemovesCameraPitch() { + // A chase camera looking down at the kart, which is the ordinary Mario Kart + // Wii case: first person must not inherit that downward tilt. + const float pitch = 0.35f; + const Mtx34 view = PitchedViewAt(0.0f, 2.0f, 5.0f, pitch); + const Mtx34 kart = KartAt(0.0f, 0.0f, 0.0f); + Mtx34 anchor{}; + Check(ComputeFirstPersonAnchor(view, kart, 0.0f, 1.0f, 0.0f, /*level_horizon=*/true, anchor), + "a pitched camera must still produce an anchor"); + + // The anchored camera's axes, expressed in world space: rows of A_rot times + // the view rotation. Its forward is -row2, and it must be horizontal. + const float worldUp[3]{0.0f, 1.0f, 0.0f}; + float rowInWorld[3][3]; + for (size_t row = 0; row < 3; ++row) { + for (size_t axis = 0; axis < 3; ++axis) { + // view's rows are the camera axes in world space, so a view-space + // vector returns to world space through view's transpose. + rowInWorld[row][axis] = anchor[row * 4 + 0] * view[0 * 4 + axis] + + anchor[row * 4 + 1] * view[1 * 4 + axis] + + anchor[row * 4 + 2] * view[2 * 4 + axis]; + } + } + const float forwardDotUp = -(rowInWorld[2][0] * worldUp[0] + rowInWorld[2][1] * worldUp[1] + + rowInWorld[2][2] * worldUp[2]); + CheckNear(forwardDotUp, 0.0f, "the levelled forward axis must be horizontal"); + const float rightDotUp = rowInWorld[0][0] * worldUp[0] + rowInWorld[0][1] * worldUp[1] + + rowInWorld[0][2] * worldUp[2]; + CheckNear(rightDotUp, 0.0f, "the levelled right axis must be horizontal"); + const float upDotUp = rowInWorld[1][0] * worldUp[0] + rowInWorld[1][1] * worldUp[1] + + rowInWorld[1][2] * worldUp[2]; + CheckNear(upDotUp, 1.0f, "the levelled up axis must be world up"); + + // The head still lands exactly at the eye origin. + float head[3]; + Apply(view, 0.0f, 1.0f, 0.0f, head); + float moved[3]; + Apply(anchor, head[0], head[1], head[2], moved); + CheckNear(moved[0], 0.0f, "the head lands at the eye origin under levelling (x)"); + CheckNear(moved[1], 0.0f, "the head lands at the eye origin under levelling (y)"); + CheckNear(moved[2], 0.0f, "the head lands at the eye origin under levelling (z)"); +} + +void TestAnchorRotationStaysOrthonormal() { + // Straight down at the kart: the camera's own forward projects to nothing on + // the horizon plane, so the heading has to be recovered from its up axis. + const float kHalfPi = 1.57079632679f; + for (const float pitch : {0.0f, 0.35f, kHalfPi, -kHalfPi, 3.0f}) { + const Mtx34 view = PitchedViewAt(3.0f, 12.0f, -7.0f, pitch); + Mtx34 anchor{}; + Check(ComputeFirstPersonAnchor(view, KartAt(3.0f, 0.0f, -20.0f), 0.1f, 1.0f, 0.2f, + /*level_horizon=*/true, anchor), + "every camera pitch must produce an anchor"); + for (size_t row = 0; row < 3; ++row) { + for (size_t other = row; other < 3; ++other) { + float dot = 0.0f; + for (size_t axis = 0; axis < 3; ++axis) { + dot += anchor[row * 4 + axis] * anchor[other * 4 + axis]; + } + CheckNear(dot, row == other ? 1.0f : 0.0f, + "the anchor's rotation must stay orthonormal"); + } + } + } +} + +void TestNonFiniteInputIsRejected() { + Mtx34 broken = kIdentityMtx34; + broken[3] = std::numeric_limits::infinity(); + Mtx34 anchor = kIdentityMtx34; + anchor[3] = 1234.0f; + Check(!ComputeFirstPersonAnchor(broken, kIdentityMtx34, 0.0f, 1.0f, 0.0f, true, anchor), + "a non-finite view matrix must be rejected"); + CheckNear(anchor[3], 1234.0f, "a rejected anchor must leave the output untouched"); +} + +void TestDegenerateKartPoseIsRejected() { + Mtx34 collapsed{}; + Mtx34 anchor{}; + // A zeroed view matrix has no world up to level against. + Check(!ComputeFirstPersonAnchor(collapsed, kIdentityMtx34, 0.0f, 1.0f, 0.0f, true, anchor), + "a collapsed view matrix must be rejected"); +} + +} // namespace + +int main() { + TestNeutralInputsProduceIdentity(); + TestUnlevelledAnchorIsPureTranslation(); + TestLevellingRemovesCameraPitch(); + TestAnchorRotationStaysOrthonormal(); + TestNonFiniteInputIsRejected(); + TestDegenerateKartPoseIsRejected(); + if (g_failures != 0) { + std::cerr << g_failures << " check(s) failed\n"; + return 1; + } + return 0; +}