Added Yaw only / Yaw + Pitch / Full rotation options to first-person

This commit is contained in:
iChris4 committed 2026-09-08 22:17:50 +02:00
1 parent bd106dd1fe
commit 0e3e2c3dc0
6 files changed
+283 -42

No files matched your search

+12 -5
View File
@@ -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
+28
View File
@@ -62,6 +62,7 @@ struct RuntimeUserConfig {
std::optional<float> vrFirstPersonHeadRightMeters;
std::optional<bool> vrFirstPersonHideDriver;
std::optional<int32_t> vrFirstPersonHiddenModel;
std::optional<std::string> vrFirstPersonRotation;
std::optional<std::string> vrRecenterKey;
std::optional<float> vrLeanBackDegrees;
std::optional<float> 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<float>(*value);
}
if (auto value = FindConfigValue<std::string>(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<int32_t>(*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);
}
+78 -25
View File
@@ -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
+28
View File
@@ -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<const char*, 3> 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<int>(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<const char*, 3> kRotationLabels{"Yaw only", "Yaw + Pitch", "Full rotation"};
if (ImGui::Combo("View rotation", &g_vrFirstPersonRotation, kRotationLabels.data(),
static_cast<int>(kRotationLabels.size()))) {
RuntimeConfigFile::SetVrFirstPersonRotation(
kVrFirstPersonRotationNames[static_cast<size_t>(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);
+11 -3
View File
@@ -8,6 +8,7 @@
#include "vr/mkw_vr_policy.h"
#include <mutex>
#include <string>
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)";
}
+126 -9
View File
@@ -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<float>::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) {