Added First Person Camera Option

This commit is contained in:
iChris4 committed 2026-09-04 23:11:22 +02:00
1 parent 7898a76a22
commit 02e5cb60a4
18 files changed
+1219 -23

No files matched your search

+39
View File
@@ -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
+24 -4
View File
@@ -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.
+84 -13
View File
@@ -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<float, 12> 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<AuroraStereoFrame> 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<float> 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);
}
+5 -2
View File
@@ -1366,19 +1366,22 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame)
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.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<float>);
Mat3x4<float> 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.
+8
View File
@@ -295,7 +295,15 @@ struct ReplayTarget {
struct StereoReplayEye {
ReplayTarget target;
Mat4x4<float> 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<float> 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<float> viewFromScene;
};
struct StereoReplayFrame {
+96
View File
@@ -155,5 +155,101 @@ TEST(StereoReplayTest, HudScreenParksRasterDepthAtMidrangeUnderHeadMotion) {
}
}
Mat3x4<float> identity3x4() {
Mat3x4<float> 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<float> head_tracking_delta() {
const float angle = 0.21f;
const float c = std::cos(angle);
const float s = std::sin(angle);
Mat3x4<float> 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<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);
// An object matrix placing a vertex somewhere in the recorded view space.
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};
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<float, 3> 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<float> 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<float> 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
+7
View File
@@ -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.
+91 -1
View File
@@ -55,6 +55,11 @@ struct RuntimeUserConfig {
std::optional<bool> vrHudVirtualScreen;
std::optional<bool> vrStopAtDisplayCopy;
std::optional<bool> vrSkipCopyClears;
std::optional<bool> vrFirstPerson;
std::optional<float> vrFirstPersonUnitsPerMeter;
std::optional<float> vrFirstPersonHeadUpMeters;
std::optional<float> vrFirstPersonHeadForwardMeters;
std::optional<float> vrFirstPersonHeadRightMeters;
std::optional<float> audioVolume;
std::optional<float> audioMusicVolume;
std::optional<float> 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<bool>(document, "vr", "hud_virtual_screen");
config.vrStopAtDisplayCopy = FindConfigValue<bool>(document, "vr", "stop_at_display_copy");
config.vrSkipCopyClears = FindConfigValue<bool>(document, "vr", "skip_copy_clears");
config.vrFirstPerson = FindConfigValue<bool>(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<float> {
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));
}
+4
View File
@@ -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
+205
View File
@@ -0,0 +1,205 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <array>
#include <cmath>
#include <cstdint>
#include <cstring>
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<float, 12>;
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
+24
View File
@@ -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
+23
View File
@@ -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
+59 -2
View File
@@ -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 <imgui.h>
@@ -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<int32_t, PAD_MAX_CONTROLLERS> g_configuredControllerIndices = [] {
std::array<int32_t, PAD_MAX_CONTROLLERS> 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;
+298
View File
@@ -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 <mutex>
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<uint32_t>(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<uint32_t>(kart_from_local[3]) << ", 0x"
<< std::bit_cast<uint32_t>(kart_from_local[7]) << ", 0x"
<< std::bit_cast<uint32_t>(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
+16
View File
@@ -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 <algorithm>
@@ -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;
+21
View File
@@ -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<std::mutex> 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<std::mutex> 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<std::mutex> 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;
+8 -1
View File
@@ -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);
}
+207
View File
@@ -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 <cmath>
#include <initializer_list>
#include <iostream>
#include <limits>
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<float>::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;
}