Fixed "Mirror Mode" Gran Prix

This commit is contained in:
iChris4 committed 2026-09-10 22:50:50 +02:00
1 parent 6467c6390c
commit 850d157481
5 files changed
+188 -3

No files matched your search

+6
View File
@@ -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;
}
+19 -3
View File
@@ -1302,7 +1302,16 @@ static void write_stereo_uniform(std::span<uint8_t> 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<uint8_t> uniform, const gx::UniformRe
const size_t offset = layout.positionOffset + matrix * sizeof(Mat3x4<float>);
Mat3x4<float> 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<float>);
Mat3x4<float> 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();
+5
View File
@@ -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<float> 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<float> viewFromSceneMirrored;
};
struct StereoReplayFrame {
+52
View File
@@ -19,6 +19,58 @@ inline Mat4x4<float> compose_projection(const Mat4x4<float>& 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<float>& 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<float> mirror_projection_x(const Mat4x4<float>& projection) noexcept {
Mat4x4<float> 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<float> mirror_view_delta_x(const Mat3x4<float>& viewFromCenter) noexcept {
Mat3x4<float> 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 *
+106
View File
@@ -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<float> eye_frustum(float offsetX) {
Mat4x4<float> 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<float> clip_of(const Mat4x4<float>& projection, const Mat3x4<float>& viewFromScene,
const Mat3x4<float>& objectToCenter, const Vec4<float>& object) {
const auto placed = compose_affine(viewFromScene, objectToCenter);
const Vec4<float> 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<float, 3> 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<float> 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<float> objectToCenter = identity3x4();
objectToCenter.m2[3] = -500.0f; // Straight ahead of the camera.
const Vec4<float> object{0.0f, 0.0f, 0.0f, 1.0f};
std::array<float, 2> 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