mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 03:00:14 +02:00
Added First Person Camera Option
This commit is contained in:
1 parent
7898a76a22
commit
02e5cb60a4
18 files changed
+1219
-23
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user