diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index 372301e..ff960cc 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -698,6 +698,12 @@ gfx::StereoReplayFrame make_stereo_replay_frame(const AuroraStereoFrame& input, view.viewFromScene = sceneAnchor.active ? gfx::stereo_replay::compose_affine(view.viewFromCenter, anchorFromScene) : view.viewFromCenter; + // A mirror-mode draw takes the same route from the mirrored eye delta, so the + // reflection its projection carries is taken in the anchored camera's space. + const auto mirroredFromCenter = gfx::stereo_replay::mirror_view_delta_x(view.viewFromCenter); + view.viewFromSceneMirrored = sceneAnchor.active + ? gfx::stereo_replay::compose_affine(mirroredFromCenter, anchorFromScene) + : mirroredFromCenter; } return replay; } diff --git a/aurora-main/lib/gfx/common.cpp b/aurora-main/lib/gfx/common.cpp index f5395f5..727ce3a 100644 --- a/aurora-main/lib/gfx/common.cpp +++ b/aurora-main/lib/gfx/common.cpp @@ -1302,7 +1302,16 @@ static void write_stereo_uniform(std::span uniform, const gx::UniformRe const Viewport& drawViewport, ClipRect displayRegion, const stereo_replay::HudScreen& hudScreen) noexcept { if (layout.perspective) { - const auto projection = stereo_replay::compose_projection(eye.projection, gameProjection); + // A projection that flips X (mirror mode) keeps its flip: the eye frustum + // replaces the X scale's magnitude, and the reflection moves onto the eye + // transform's half of the composition, where it reverses the winding the + // draw's own cull mode already expects. + const bool mirrored = stereo_replay::projection_mirrors_x(gameProjection); + auto projection = stereo_replay::compose_projection(eye.projection, gameProjection); + if (mirrored) { + projection = stereo_replay::mirror_projection_x(projection); + } + const auto& viewFromScene = mirrored ? eye.viewFromSceneMirrored : eye.viewFromScene; std::memcpy(uniform.data() + layout.projectionOffset, &projection, sizeof(projection)); for (uint32_t matrix = 0; matrix < layout.positionMatrixCount; ++matrix) { @@ -1312,14 +1321,14 @@ static void write_stereo_uniform(std::span uniform, const gx::UniformRe 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.viewFromScene, source); + const auto transformed = stereo_replay::compose_affine(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.viewFromScene, source); + const auto transformed = stereo_replay::compose_normal(viewFromScene, source); std::memcpy(uniform.data() + offset, &transformed, sizeof(transformed)); } } else { @@ -1484,6 +1493,13 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame, if (!layout.perspective && !stereo_replay::is_orthographic_projection(gameProjection)) { continue; } + // Producer-side and one-shot, so a mirror-mode race can be confirmed from + // the log without instrumenting a build. + static bool mirroredProjectionLogged = false; + if (layout.perspective && !mirroredProjectionLogged && stereo_replay::projection_mirrors_x(gameProjection)) { + mirroredProjectionLogged = true; + Log.info("Immersive replay: perspective draws flip X (mirror mode); mirroring the eye transform to match"); + } LateStereoUniform* saved = nullptr; if (history != nullptr) { saved = &history->uniforms.emplace_back(); diff --git a/aurora-main/lib/gfx/common.hpp b/aurora-main/lib/gfx/common.hpp index 462db55..83d98b4 100644 --- a/aurora-main/lib/gfx/common.hpp +++ b/aurora-main/lib/gfx/common.hpp @@ -304,6 +304,11 @@ struct StereoReplayEye { // camera in their position matrices and therefore need this one. It equals // viewFromCenter whenever the anchor is identity. Mat3x4 viewFromScene; + // The same transform for a draw whose projection mirrors X (Mario Kart Wii's + // mirror mode), built from the mirrored eye delta so the reflection lands in + // the anchored camera's space rather than in each eye's own. Pairs with + // stereo_replay::mirror_projection_x; see the comment on those helpers. + Mat3x4 viewFromSceneMirrored; }; struct StereoReplayFrame { diff --git a/aurora-main/lib/gfx/stereo_replay.hpp b/aurora-main/lib/gfx/stereo_replay.hpp index d52e0a4..5565869 100644 --- a/aurora-main/lib/gfx/stereo_replay.hpp +++ b/aurora-main/lib/gfx/stereo_replay.hpp @@ -19,6 +19,58 @@ inline Mat4x4 compose_projection(const Mat4x4& eyeFrustum, const M return out; } +// Mario Kart Wii's mirror mode negates the X scale of its projection matrix and +// reverses its cull mode to match the winding that flip produces. compose_projection +// replaces that coefficient with the headset frustum's always-positive X scale, so +// the eye would draw normal winding against a reversed cull mode: every surface +// inside out. +// +// The flip has to survive, but it cannot simply be re-applied to the eye's clip +// position. The eyes are placed by the per-eye view delta, so a reflection taken +// after it mirrors each eye about its own axis and swaps the stereo pair. The +// reflection S = diag(-1, 1, 1) belongs between the delta and the game camera, i.e. +// in the anchored camera's space, which the two helpers below reach by splitting it +// in half around the delta V: +// +// clip = (P . S) . (S . V . S) . A . p = P . V . S . A . p +// +// mirror_projection_x supplies (P . S), mirror_view_delta_x supplies (S . V . S), and +// S . S cancels. Keeping the reflection out of the staged position and normal +// matrices leaves lighting in the game's own unmirrored view space, which is the +// space its light positions are already expressed in. +inline bool projection_mirrors_x(const Mat4x4& gameProjection) noexcept { + return gameProjection.m0[0] < 0.0f; +} + +// P . S: post-multiplying by the reflection negates the matrix's X column, which is +// every coefficient the clip position picks up from the vertex's X. +inline Mat4x4 mirror_projection_x(const Mat4x4& projection) noexcept { + Mat4x4 out = projection; + out.m0[0] = -out.m0[0]; + out.m1[0] = -out.m1[0]; + out.m2[0] = -out.m2[0]; + out.m3[0] = -out.m3[0]; + return out; +} + +// S . V . S: the mirror image of the headset's eye delta, i.e. the pose the eye +// would have if it were reflected along with the world. Conjugating by a reflection +// negates exactly the entries with one X index: the X offset (half the IPD, plus any +// head translation) and the yaw and roll terms that couple X to the other axes, while +// pitch and the Y/Z offsets are left alone. Rendering an eye from this reflected pose +// and flipping the result horizontally - which is what mirror_projection_x does - is +// what that eye should see of the mirrored world, with the stereo pair the right way +// round and head tracking still unmirrored. +inline Mat3x4 mirror_view_delta_x(const Mat3x4& viewFromCenter) noexcept { + Mat3x4 out = viewFromCenter; + out.m0[1] = -out.m0[1]; + out.m0[2] = -out.m0[2]; + out.m0[3] = -out.m0[3]; + out.m1[0] = -out.m1[0]; + out.m2[0] = -out.m2[0]; + return out; +} + // Aurora stores the GX 3x4 matrices row-major. The vertex shader consumes // them as vec4 * mat3x4, which is equivalent to the original column-vector // affine transform. Applying an eye-space delta therefore composes delta * diff --git a/aurora-main/tests/stereo_replay_test.cpp b/aurora-main/tests/stereo_replay_test.cpp index 418bd04..8e929a0 100644 --- a/aurora-main/tests/stereo_replay_test.cpp +++ b/aurora-main/tests/stereo_replay_test.cpp @@ -258,5 +258,111 @@ TEST(StereoReplayTest, VirtualScreenStaysAheadOfTheAnchoredCamera) { EXPECT_NEAR(placed.m2[3], centreZ, 1e-3f); } +// A GX perspective projection with a positive X scale, plus the asymmetric +// frustum offset an OpenXR eye contributes. +Mat4x4 eye_frustum(float offsetX) { + Mat4x4 m{}; + m.m0 = {1.3f, 0.0f, offsetX, 0.0f}; + m.m1 = {0.0f, 1.7f, 0.04f, 0.0f}; + m.m2 = {0.0f, 0.0f, -1.0001f, -0.2f}; + m.m3 = {0.0f, 0.0f, -1.0f, 0.0f}; + return m; +} + +Vec4 clip_of(const Mat4x4& projection, const Mat3x4& viewFromScene, + const Mat3x4& objectToCenter, const Vec4& object) { + const auto placed = compose_affine(viewFromScene, objectToCenter); + const Vec4 view{dot4(placed.m0, object), dot4(placed.m1, object), dot4(placed.m2, object), 1.0f}; + return {dot4(projection.m0, view), dot4(projection.m1, view), dot4(projection.m2, view), dot4(projection.m3, view)}; +} + +TEST(StereoReplayTest, MirrorModeIsRecognizedByANegativeProjectionXScale) { + const auto game = eye_frustum(0.0f); + EXPECT_FALSE(projection_mirrors_x(game)); + + auto mirrored = game; + mirrored.m0[0] = -mirrored.m0[0]; + EXPECT_TRUE(projection_mirrors_x(mirrored)); +} + +TEST(StereoReplayTest, MirroredHalvesComposeIntoOneReflectionOfTheScene) { + // The pair must reproduce exactly P . V . S . A: a world reflected about the + // anchored camera's X plane, with the eyes placed in the reflected world. + 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); + const auto viewFromSceneMirrored = compose_affine(mirror_view_delta_x(viewFromCenter), anchor); + + auto game = eye_frustum(0.0f); + game.m0[0] = -game.m0[0]; // Mirror mode's flip, as the game submits it. + const auto eye = eye_frustum(0.11f); + const auto projection = mirror_projection_x(compose_projection(eye, game)); + // compose_projection takes the X scale from the eye, so this is the ordinary + // unmirrored eye projection P. + const auto reference = compose_projection(eye, game); + + // The reference route: reflect in the anchored camera's space by folding S into + // the anchor, then compose the eye delta over it exactly as an unmirrored draw + // would. This is the ordering the fix has to reproduce - S sits between the eye + // delta and the anchor, not between the anchor and the world. + auto reflectedAnchor = anchor; + reflectedAnchor.m0[0] = -reflectedAnchor.m0[0]; + reflectedAnchor.m0[1] = -reflectedAnchor.m0[1]; + reflectedAnchor.m0[2] = -reflectedAnchor.m0[2]; + reflectedAnchor.m0[3] = -reflectedAnchor.m0[3]; + const auto viewFromSceneReference = compose_affine(viewFromCenter, reflectedAnchor); + // Anchor ordering has to matter, or the test would pass either way. + EXPECT_NE(viewFromSceneReference, viewFromScene); + + 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}; + + for (const auto& v : kVertices) { + const auto mirroredClip = clip_of(projection, viewFromSceneMirrored, objectToCenter, v); + const auto expected = clip_of(reference, viewFromSceneReference, objectToCenter, v); + for (size_t component = 0; component < 4; ++component) { + EXPECT_NEAR(mirroredClip[component], expected[component], 1e-3f); + } + } +} + +TEST(StereoReplayTest, MirroringKeepsEachEyeOnItsOwnSide) { + // The v6 failure this guards against: reflecting the finished clip position + // mirrors every eye about its own axis, which swaps the stereo pair. With the + // reflection taken before the eye delta, an object straight ahead must still + // sit right of centre for the left eye and left of centre for the right. + const float ipd = 3.2f; // Half-IPD in game units. + const auto eyeDelta = [&](float sign) { + auto m = identity3x4(); + m.m0[3] = -sign * ipd; // The eye moves by +sign*ipd, so the world moves back. + return m; + }; + auto game = eye_frustum(0.0f); + game.m0[0] = -game.m0[0]; + + Mat3x4 objectToCenter = identity3x4(); + objectToCenter.m2[3] = -500.0f; // Straight ahead of the camera. + const Vec4 object{0.0f, 0.0f, 0.0f, 1.0f}; + + std::array ndcX{}; + for (size_t eyeIndex = 0; eyeIndex < 2; ++eyeIndex) { + const float sign = eyeIndex == 0 ? -1.0f : 1.0f; + const auto eye = eye_frustum(0.0f); + const auto projection = mirror_projection_x(compose_projection(eye, game)); + const auto view = mirror_view_delta_x(eyeDelta(sign)); + const auto clip = clip_of(projection, view, objectToCenter, object); + ASSERT_GT(clip[3], 0.0f); + ndcX[eyeIndex] = clip[0] / clip[3]; + } + EXPECT_GT(ndcX[0], 0.0f); + EXPECT_LT(ndcX[1], 0.0f); +} + } // namespace } // namespace aurora::gfx::stereo_replay