From 0e3e2c3dc0c7c01ba1f3053afb53182d237d2703 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Tue, 8 Sep 2026 22:17:50 +0200 Subject: [PATCH] Added Yaw only / Yaw + Pitch / Full rotation options to first-person --- OPENXR.md | 17 ++- runtime/include/runtime_config.h | 28 +++++ runtime/include/vr/mkw_vr_first_person.h | 103 ++++++++++++----- runtime/src/settings_overlay.cpp | 28 +++++ runtime/src/vr/mkw_vr_first_person.cpp | 14 ++- runtime/tests/vr_first_person_tests.cpp | 135 +++++++++++++++++++++-- 6 files changed, 283 insertions(+), 42 deletions(-) diff --git a/OPENXR.md b/OPENXR.md index 8c4e7ff..19bcc50 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -36,6 +36,7 @@ first_person_head_forward_meters = 0.0 first_person_head_right_meters = 0.0 first_person_hide_driver = true first_person_hidden_model = 0 +first_person_rotation = "yaw" ``` Set `enabled = true`, close the game completely, and start it again. These settings are read only @@ -73,11 +74,17 @@ the renderer composes it onto every perspective draw's model-view matrix, alongs 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. +`first_person_rotation` decides where the view's orientation comes from, mirroring DolphinXR's +camera-anchor modes. `"yaw"`, the default, keeps the horizon level through a chase-camera tilt or a +banked corner. `"yaw_pitch"` adds the kart's climb, so a slope or a wheelie tips the view while a +banked corner still never rolls it. `"full"` takes the kart's whole orientation, banking included. +All three are the same construction from a forward and an up axis, differing only in which pair +they take: pairing a forward with world up is what removes roll. The headset always adds free look +on top of whichever is chosen, and only the translation onto the head is common to all three. + +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 diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index 07d5766..fcf96d6 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -62,6 +62,7 @@ struct RuntimeUserConfig { std::optional vrFirstPersonHeadRightMeters; std::optional vrFirstPersonHideDriver; std::optional vrFirstPersonHiddenModel; + std::optional vrFirstPersonRotation; std::optional vrRecenterKey; std::optional vrLeanBackDegrees; std::optional audioVolume; @@ -148,6 +149,12 @@ inline constexpr float kVrFirstPersonHeadRightDefault = 0.0f; inline constexpr bool kVrFirstPersonHideDriverDefault = true; inline constexpr int32_t kVrFirstPersonHiddenModelDefault = 0; inline constexpr float kVrFirstPersonHeadOffsetLimit = 10.0f; +// "yaw", "yaw_pitch" or "full", matching FirstPersonRotation. +inline constexpr const char* kVrFirstPersonRotationDefault = "yaw"; + +inline bool IsSupportedVrFirstPersonRotation(std::string_view value) { + return value == "yaw" || value == "yaw_pitch" || value == "full"; +} // SDL scancode name, spelled the way SDL_GetScancodeName produces it. An // empty string leaves the recenter hotkey unbound, menu button only. inline constexpr std::string_view kVrRecenterKeyDefault = "F9"; @@ -385,6 +392,10 @@ inline void EnsureConfigFile() { "# 0 is the driver, which is the usual choice. -1 hides every\n" "# model of your kart, the vehicle included.\n" "first_person_hidden_model = 0\n" + "# Where the view's orientation comes from: \"yaw\" levels the\n" + "# horizon, \"yaw_pitch\" adds the kart's climb but no roll, and\n" + "# \"full\" takes the kart's whole orientation so the view banks.\n" + "first_person_rotation = \"yaw\"\n\n" "# Keyboard shortcut that recenters the VR view, naming the key the\n" "# way SDL does (F9, Home, Keypad 5, ...). It moves the race view to\n" "# where you are sitting now and brings the menu screen back upright in\n" @@ -587,6 +598,10 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { value && *value >= -kVrLeanBackDegreesLimit && *value <= kVrLeanBackDegreesLimit) { config.vrLeanBackDegrees = static_cast(*value); } + if (auto value = FindConfigValue(document, "vr", "first_person_rotation"); + value && IsSupportedVrFirstPersonRotation(*value)) { + config.vrFirstPersonRotation = *value; + } if (auto value = FindConfigInt(document, "vr", "first_person_hidden_model"); value && *value >= -1 && *value <= 31) { config.vrFirstPersonHiddenModel = static_cast(*value); @@ -880,6 +895,14 @@ inline bool SetVrFirstPersonHideDriver(bool value) { return WriteSetting("vr", "first_person_hide_driver", value ? "true" : "false"); } +inline bool SetVrFirstPersonRotation(std::string value) { + if (!IsSupportedVrFirstPersonRotation(value)) { + return false; + } + Mutable().vrFirstPersonRotation = value; + return WriteSetting("vr", "first_person_rotation", FormatString(value)); +} + inline bool SetVrFirstPersonHiddenModel(int32_t value) { value = std::clamp(value, -1, 31); Mutable().vrFirstPersonHiddenModel = value; @@ -1180,6 +1203,11 @@ inline bool VrFirstPersonHideDriver(bool fallback = kVrFirstPersonHideDriverDefa return Get().vrFirstPersonHideDriver.value_or(fallback); } +inline std::string VrFirstPersonRotation(std::string fallback = kVrFirstPersonRotationDefault) { + const auto& value = Get().vrFirstPersonRotation; + return value && IsSupportedVrFirstPersonRotation(*value) ? *value : std::move(fallback); +} + inline int32_t VrFirstPersonHiddenModel(int32_t fallback = kVrFirstPersonHiddenModelDefault) { return std::clamp(Get().vrFirstPersonHiddenModel.value_or(fallback), -1, 31); } diff --git a/runtime/include/vr/mkw_vr_first_person.h b/runtime/include/vr/mkw_vr_first_person.h index dc5e6cc..0ed3c63 100644 --- a/runtime/include/vr/mkw_vr_first_person.h +++ b/runtime/include/vr/mkw_vr_first_person.h @@ -27,6 +27,19 @@ struct FirstPersonHeadOffsets { float forward = 0.0f; }; +// Where the anchored camera's orientation comes from, mirroring DolphinXR's +// camera-anchor modes. The headset always adds free look on top of whichever +// is chosen; this only decides the frame it looks around from. +enum class FirstPersonRotation : uint8_t { + // The horizon is kept level and only a heading is taken. Comfort default. + YawOnly, + // The kart's heading and its climb, with roll dropped: slopes and wheelies + // tip the view, but a banked corner never rolls the horizon. + YawPitch, + // The kart's whole orientation, so the view banks and pitches with it. + Full, +}; + // 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 { @@ -87,6 +100,32 @@ inline bool Normalize(Vec3& value) noexcept { return true; } +// out = matrix's 3x3 * (x, y, z). Directions ignore the translation column. +inline Vec3 TransformDirection(const Mtx34& matrix, const Vec3& v) noexcept { + return { + matrix[0] * v.x + matrix[1] * v.y + matrix[2] * v.z, + matrix[4] * v.x + matrix[5] * v.y + matrix[6] * v.z, + matrix[8] * v.x + matrix[9] * v.y + matrix[10] * v.z, + }; +} + +// Fills the three basis rows from a forward and an up that need not be exactly +// perpendicular, in the -Z-forward convention view space uses. +inline bool BasisFromForwardUp(const Vec3& forward_in, const Vec3& up_in, Vec3 rows[3]) noexcept { + Vec3 forward = forward_in; + if (!Normalize(forward)) { + return false; + } + Vec3 right = Cross(forward, up_in); + if (!Normalize(right)) { + return false; + } + rows[0] = right; + rows[1] = Cross(right, forward); + rows[2] = {-forward.x, -forward.y, -forward.z}; + return true; +} + // out = matrix * (x, y, z, 1) inline Vec3 TransformPoint(const Mtx34& matrix, float x, float y, float z) noexcept { return { @@ -102,14 +141,12 @@ inline Vec3 TransformPoint(const Mtx34& matrix, float x, float y, float z) noexc // 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. +// The translation always moves the camera onto the head; `rotation` decides the +// frame it looks around from. 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, + float head_forward_units, FirstPersonRotation rotation, Mtx34& out) noexcept { using namespace detail; if (!IsFiniteMtx34(view_from_world) || !IsFiniteMtx34(kart_from_local)) { @@ -124,39 +161,55 @@ inline bool ComputeFirstPersonAnchor(const Mtx34& view_from_world, const Mtx34& // Rows of the anchor's rotation. Identity keeps the recorded camera's own // orientation and moves the eye only. + // Every mode is the same construction from a forward and an up; they differ + // only in which pair they take. Pairing a forward with world up is what + // removes roll, since the resulting right axis is then always horizontal. 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)) { + // World +Y in view coordinates: the column of the view rotation that the + // world up axis selects. + Vec3 world_up{view_from_world[1], view_from_world[5], view_from_world[9]}; + const bool world_up_valid = Normalize(world_up); + // Columns 2 and 1 of the kart pose are its forward and up. The pose may + // carry scale, so the pair is re-orthonormalized rather than trusted. + const Vec3 kart_forward = TransformDirection( + view_from_world, {kart_from_local[2], kart_from_local[6], kart_from_local[10]}); + const Vec3 kart_up = TransformDirection( + view_from_world, {kart_from_local[1], kart_from_local[5], kart_from_local[9]}); + + if (rotation == FirstPersonRotation::YawOnly) { + if (!world_up_valid) { 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}; + float along = Dot(camera_forward, world_up); + Vec3 forward{camera_forward.x - world_up.x * along, camera_forward.y - world_up.y * along, + camera_forward.z - world_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}; + along = Dot(camera_up, world_up); + forward = {camera_up.x - world_up.x * along, camera_up.y - world_up.y * along, + camera_up.z - world_up.z * along}; if (!Normalize(forward)) { return false; } } - Vec3 right = Cross(forward, up); - if (!Normalize(right)) { + if (!BasisFromForwardUp(forward, world_up, rows)) { 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}; + } else if (rotation == FirstPersonRotation::YawPitch) { + // The kart's heading and climb, levelled against world up so no roll + // survives. Pointing straight up or down leaves nothing to level + // against, so that frame falls back to the kart's own up. + if (!world_up_valid || !BasisFromForwardUp(kart_forward, world_up, rows)) { + if (!BasisFromForwardUp(kart_forward, kart_up, rows)) { + return false; + } + } + } else if (!BasisFromForwardUp(kart_forward, kart_up, rows)) { + return false; } Mtx34 anchor{}; @@ -181,7 +234,7 @@ inline bool ComputeFirstPersonAnchor(const Mtx34& view_from_world, const Mtx34& // 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; + float units_per_meter, FirstPersonRotation rotation) noexcept; // While the anchor is driving the view the player's own models can be removed, // since the driver otherwise sits exactly where the eyes are. This uses the diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 490484c..959df95 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -113,6 +113,16 @@ float g_vrFirstPersonHeadForward = RuntimeConfigFile::VrFirstPersonHeadForwardMe float g_vrFirstPersonHeadRight = RuntimeConfigFile::VrFirstPersonHeadRightMeters(); bool g_vrFirstPersonHideDriver = RuntimeConfigFile::VrFirstPersonHideDriver(); int g_vrFirstPersonHiddenModel = RuntimeConfigFile::VrFirstPersonHiddenModel(); +constexpr std::array kVrFirstPersonRotationNames{"yaw", "yaw_pitch", "full"}; +int g_vrFirstPersonRotation = [] { + const std::string mode = RuntimeConfigFile::VrFirstPersonRotation(); + for (size_t i = 0; i < kVrFirstPersonRotationNames.size(); ++i) { + if (mode == kVrFirstPersonRotationNames[i]) { + return static_cast(i); + } + } + return 0; +}(); // SDL_SCANCODE_UNKNOWN means unbound, which is also what an unrecognised // name in the config file resolves to rather than silently picking a key. SDL_Scancode g_vrRecenterScancode = [] { @@ -967,6 +977,21 @@ void DrawVrSettings() { ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); ImGui::TextDisabled("Where the head sits in the kart's own frame."); ImGui::PopTextWrapPos(); + constexpr std::array kRotationLabels{"Yaw only", "Yaw + Pitch", "Full rotation"}; + if (ImGui::Combo("View rotation", &g_vrFirstPersonRotation, kRotationLabels.data(), + static_cast(kRotationLabels.size()))) { + RuntimeConfigFile::SetVrFirstPersonRotation( + kVrFirstPersonRotationNames[static_cast(g_vrFirstPersonRotation)]); + mkw::vr::MkwVRFirstPersonApplyConfiguredSettings(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Where the view's orientation comes from. Yaw only keeps the horizon level " + "and is the comfortable choice. Yaw + Pitch adds the kart's climb, so slopes " + "and wheelies tip the view without ever rolling it. Full rotation takes the " + "kart's whole orientation, banking included. The headset always adds free look " + "on top."); + } // Two presentations of one setting: which models go, or none at all. // Ticking either replaces the other, and unticking both shows everything. const auto applyHiding = [](bool enabled, int model) { @@ -1002,6 +1027,9 @@ void DrawVrSettings() { g_vrFirstPersonHeadRight = RuntimeConfigFile::kVrFirstPersonHeadRightDefault; g_vrFirstPersonHideDriver = RuntimeConfigFile::kVrFirstPersonHideDriverDefault; g_vrFirstPersonHiddenModel = RuntimeConfigFile::kVrFirstPersonHiddenModelDefault; + g_vrFirstPersonRotation = 0; + RuntimeConfigFile::SetVrFirstPersonRotation( + RuntimeConfigFile::kVrFirstPersonRotationDefault); RuntimeConfigFile::SetVrFirstPersonUnitsPerMeter(g_vrFirstPersonUnitsPerMeter); RuntimeConfigFile::SetVrFirstPersonHeadUpMeters(g_vrFirstPersonHeadUp); RuntimeConfigFile::SetVrFirstPersonHeadForwardMeters(g_vrFirstPersonHeadForward); diff --git a/runtime/src/vr/mkw_vr_first_person.cpp b/runtime/src/vr/mkw_vr_first_person.cpp index 77a46e4..a99ac0d 100644 --- a/runtime/src/vr/mkw_vr_first_person.cpp +++ b/runtime/src/vr/mkw_vr_first_person.cpp @@ -8,6 +8,7 @@ #include "vr/mkw_vr_policy.h" #include +#include extern "C" void func_805A6C58(CpuContext* context); extern "C" void func_8056A470(CpuContext* context); @@ -236,6 +237,7 @@ struct FirstPersonState { bool enabled = false; FirstPersonHeadOffsets offsets{}; float units_per_meter = RuntimeConfigFile::kVrFirstPersonUnitsPerMeterDefault; + FirstPersonRotation rotation = FirstPersonRotation::YawOnly; uint32_t camera_address = 0; // Armed by the draw boundary, consumed by the frame seal. @@ -494,10 +496,11 @@ void LogAnchorLocked(uint64_t frame, const Mtx34& anchor, const Mtx34& view_from } // namespace void MkwVRFirstPersonConfigure(bool enabled, const FirstPersonHeadOffsets& offsets, - float units_per_meter) noexcept { + float units_per_meter, FirstPersonRotation rotation) noexcept { std::lock_guard lock(g_mutex); g_state.enabled = enabled; g_state.offsets = offsets; + g_state.rotation = rotation; if (detail::IsFiniteFloat(&units_per_meter) && units_per_meter > 0.0f) { g_state.units_per_meter = units_per_meter; } @@ -514,7 +517,12 @@ void MkwVRFirstPersonApplyConfiguredSettings() noexcept { RuntimeConfigFile::VrFirstPersonHeadUpMeters(), RuntimeConfigFile::VrFirstPersonHeadForwardMeters(), }; - MkwVRFirstPersonConfigure(RuntimeConfigFile::VrFirstPerson(false), offsets, units_per_meter); + const std::string mode = RuntimeConfigFile::VrFirstPersonRotation(); + const FirstPersonRotation rotation = mode == "full" ? FirstPersonRotation::Full + : mode == "yaw_pitch" ? FirstPersonRotation::YawPitch + : FirstPersonRotation::YawOnly; + MkwVRFirstPersonConfigure(RuntimeConfigFile::VrFirstPerson(false), offsets, units_per_meter, + rotation); MkwVRPolicySetFirstPersonUnitsPerMeter(units_per_meter); { // Same lock the guest thread applies these under. @@ -589,7 +597,7 @@ void MkwVRFirstPersonCommit() noexcept { 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)) { + g_state.rotation, anchor)) { failed_step = "anchor math (degenerate camera or kart frame)"; } diff --git a/runtime/tests/vr_first_person_tests.cpp b/runtime/tests/vr_first_person_tests.cpp index 74d13b0..a0b7bf0 100644 --- a/runtime/tests/vr_first_person_tests.cpp +++ b/runtime/tests/vr_first_person_tests.cpp @@ -15,6 +15,7 @@ namespace { using mkw::vr::ComputeFirstPersonAnchor; using mkw::vr::kIdentityMtx34; +using mkw::vr::FirstPersonRotation; using mkw::vr::Mtx34; int g_failures = 0; @@ -77,20 +78,23 @@ Mtx34 KartAt(float x, float y, float z) { void TestNeutralInputsProduceIdentity() { Mtx34 anchor{}; Check(ComputeFirstPersonAnchor(kIdentityMtx34, kIdentityMtx34, 0.0f, 0.0f, 0.0f, - /*level_horizon=*/true, anchor), + FirstPersonRotation::YawOnly, 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() { +void TestLevelCameraGivesPureTranslation() { // Camera 5 m behind and 2 m above the origin, kart at the origin, head 1 m up. + // A camera that is already level needs no rotation, so the anchor reduces to + // the translation and the head-placement math is visible on its own. 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"); + Check(ComputeFirstPersonAnchor(view, kart, 0.0f, 1.0f, 0.0f, FirstPersonRotation::YawOnly, + anchor), + "a level camera must produce an anchor"); // The head sits at (0, -1, -5) in view space, so the anchor's translation // is its negation. @@ -113,7 +117,7 @@ void TestLevellingRemovesCameraPitch() { 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), + Check(ComputeFirstPersonAnchor(view, kart, 0.0f, 1.0f, 0.0f, FirstPersonRotation::YawOnly, anchor), "a pitched camera must still produce an anchor"); // The anchored camera's axes, expressed in world space: rows of A_rot times @@ -157,7 +161,7 @@ void TestAnchorRotationStaysOrthonormal() { 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), + FirstPersonRotation::YawOnly, anchor), "every camera pitch must produce an anchor"); for (size_t row = 0; row < 3; ++row) { for (size_t other = row; other < 3; ++other) { @@ -177,7 +181,7 @@ void TestNonFiniteInputIsRejected() { 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), + Check(!ComputeFirstPersonAnchor(broken, kIdentityMtx34, 0.0f, 1.0f, 0.0f, FirstPersonRotation::YawOnly, anchor), "a non-finite view matrix must be rejected"); CheckNear(anchor[3], 1234.0f, "a rejected anchor must leave the output untouched"); } @@ -186,17 +190,130 @@ 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), + Check(!ComputeFirstPersonAnchor(collapsed, kIdentityMtx34, 0.0f, 1.0f, 0.0f, FirstPersonRotation::YawOnly, anchor), "a collapsed view matrix must be rejected"); } +// A kart pitched up by `pitch` and rolled by `roll`, heading toward -Z so it +// points away from a level camera. Only columns 1 and 2 are read by the anchor. +Mtx34 KartPitchedAndRolled(float pitch, float roll) { + const float cp = std::cos(pitch), sp = std::sin(pitch); + const float cr = std::cos(roll), sr = std::sin(roll); + const float forward[3]{0.0f, sp, -cp}; + const float upUnrolled[3]{0.0f, cp, sp}; + const float rightUnrolled[3]{1.0f, 0.0f, 0.0f}; + Mtx34 pose{}; + for (size_t row = 0; row < 3; ++row) { + pose[row * 4 + 1] = -rightUnrolled[row] * sr + upUnrolled[row] * cr; + pose[row * 4 + 2] = forward[row]; + } + return pose; +} + +void TestYawPitchKeepsClimbAndDropsRoll() { + const float pitch = 0.4f, roll = 0.5f; + const Mtx34 view = LevelViewAt(0.0f, 2.0f, 5.0f); + Mtx34 anchor{}; + Check(ComputeFirstPersonAnchor(view, KartPitchedAndRolled(pitch, roll), 0.0f, 0.0f, 0.0f, + FirstPersonRotation::YawPitch, anchor), + "yaw+pitch must be computable"); + + // The climb survives: the anchor's forward is the kart's forward. + CheckNear(-anchor[9], std::sin(pitch), "yaw+pitch keeps the kart's climb (y)"); + CheckNear(-anchor[10], -std::cos(pitch), "yaw+pitch keeps the kart's heading (z)"); + // The roll does not: the right axis stays horizontal. + CheckNear(anchor[1], 0.0f, "yaw+pitch leaves the right axis horizontal"); + + // Full rotation on the same kart does keep the roll, so the two differ. + Mtx34 full{}; + Check(ComputeFirstPersonAnchor(view, KartPitchedAndRolled(pitch, roll), 0.0f, 0.0f, 0.0f, + FirstPersonRotation::Full, full), + "full rotation must be computable"); + Check(std::fabs(full[1]) > 0.1f, "full rotation keeps the roll yaw+pitch drops"); + + 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, "yaw+pitch stays orthonormal"); + } + } +} + +// A kart yawed by `yaw` and rolled by `roll`, as a kart-local -> world pose. +Mtx34 KartOriented(float yaw, float roll) { + const float cy = std::cos(yaw), sy = std::sin(yaw); + const float cr = std::cos(roll), sr = std::sin(roll); + // Columns are the kart's right, up and forward axes in world space. + const float right[3]{cy * cr, sr, -sy * cr}; + const float up[3]{-cy * sr, cr, sy * sr}; + const float forward[3]{sy, 0.0f, cy}; + Mtx34 pose{}; + for (size_t row = 0; row < 3; ++row) { + pose[row * 4 + 0] = right[row]; + pose[row * 4 + 1] = up[row]; + pose[row * 4 + 2] = forward[row]; + } + return pose; +} + +void TestFullRotationFollowsTheKart() { + // A level camera, and a kart yawed and rolled away from it. Full rotation + // must adopt the kart's frame, not the camera's. + const Mtx34 view = LevelViewAt(0.0f, 2.0f, 5.0f); + const float yaw = 0.6f, roll = 0.4f; + Mtx34 anchor{}; + Check(ComputeFirstPersonAnchor(view, KartOriented(yaw, roll), 0.0f, 0.0f, 0.0f, + FirstPersonRotation::Full, anchor), + "full rotation must be computable"); + + // With an identity view rotation the anchor rows are the kart's axes + // directly, so the third row is the kart's backward axis. + CheckNear(anchor[8], -std::sin(yaw), "full rotation takes the kart's heading (x)"); + CheckNear(anchor[10], -std::cos(yaw), "full rotation takes the kart's heading (z)"); + // Roll survives: the anchor's up is the kart's up, not world up. + CheckNear(anchor[5], std::cos(roll), "full rotation keeps the kart's roll"); + + 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, "full rotation stays orthonormal"); + } + } +} + +void TestYawOnlyIgnoresKartRoll() { + // The same rolled kart, but yaw-only must leave the horizon level. + const Mtx34 view = LevelViewAt(0.0f, 2.0f, 5.0f); + Mtx34 rolled{}; + Mtx34 upright{}; + Check(ComputeFirstPersonAnchor(view, KartOriented(0.6f, 0.4f), 0.0f, 0.0f, 0.0f, + FirstPersonRotation::YawOnly, rolled), + "yaw-only must be computable for a rolled kart"); + Check(ComputeFirstPersonAnchor(view, KartOriented(0.6f, 0.0f), 0.0f, 0.0f, 0.0f, + FirstPersonRotation::YawOnly, upright), + "yaw-only must be computable for an upright kart"); + for (size_t i = 0; i < 3; ++i) { + CheckNear(rolled[4 + i], upright[4 + i], "yaw-only ignores the kart's roll"); + } + CheckNear(rolled[5], 1.0f, "yaw-only keeps the horizon level"); +} + } // namespace int main() { TestNeutralInputsProduceIdentity(); - TestUnlevelledAnchorIsPureTranslation(); + TestLevelCameraGivesPureTranslation(); TestLevellingRemovesCameraPitch(); TestAnchorRotationStaysOrthonormal(); + TestFullRotationFollowsTheKart(); + TestYawPitchKeepsClimbAndDropsRoll(); + TestYawOnlyIgnoresKartRoll(); TestNonFiniteInputIsRejected(); TestDegenerateKartPoseIsRejected(); if (g_failures != 0) {