mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 06:00:25 +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
|
hud_virtual_screen = true
|
||||||
stop_at_display_copy = true
|
stop_at_display_copy = true
|
||||||
skip_copy_clears = 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
|
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
|
`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
|
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.
|
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
|
## Presentation policy
|
||||||
|
|
||||||
|
|||||||
@@ -87,10 +87,12 @@ enum { AURORA_STEREO_EYE_COUNT = 2 };
|
|||||||
* applies those four values to each perspective GX draw while preserving the
|
* applies those four values to each perspective GX draw while preserving the
|
||||||
* draw's own depth mapping and renderer depth-range adjustment.
|
* draw's own depth mapping and renderer depth-range adjustment.
|
||||||
*
|
*
|
||||||
* viewFromCenter is a row-major affine 3x4 transform from the game's
|
* viewFromCenter is a row-major affine 3x4 transform from the center-eye view
|
||||||
* recorded center-eye view space into this eye's view space. Identity keeps
|
* space into this eye's view space. Identity keeps the recorded view and is
|
||||||
* the recorded view and is useful when the game has already applied the eye
|
* useful when the game has already applied the eye transform before issuing GX
|
||||||
* transform before issuing GX commands.
|
* 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.
|
* 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
|
// Seal the current frame with an opaque application safety tag. Aurora rejects
|
||||||
// an immersive provider packet unless its contentTag matches this exact frame.
|
// an immersive provider packet unless its contentTag matches this exact frame.
|
||||||
void aurora_end_frame_tagged(uint64_t contentTag);
|
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)();
|
typedef void (*AuroraFrameWorkerWaitCallback)();
|
||||||
// Called from the producer thread at bounded intervals while Aurora waits for
|
// Called from the producer thread at bounded intervals while Aurora waits for
|
||||||
// the asynchronous frame worker. The callback must not enter Aurora.
|
// the asynchronous frame worker. The callback must not enter Aurora.
|
||||||
|
|||||||
+84
-13
@@ -3,6 +3,7 @@
|
|||||||
#ifdef AURORA_ENABLE_GX
|
#ifdef AURORA_ENABLE_GX
|
||||||
#include "gfx/common.hpp"
|
#include "gfx/common.hpp"
|
||||||
#include "gfx/efb_ram_copy.hpp"
|
#include "gfx/efb_ram_copy.hpp"
|
||||||
|
#include "gfx/stereo_replay.hpp"
|
||||||
#include "gx/fifo.hpp"
|
#include "gx/fifo.hpp"
|
||||||
#include "gx/shader_info.hpp"
|
#include "gx/shader_info.hpp"
|
||||||
#include "imgui.hpp"
|
#include "imgui.hpp"
|
||||||
@@ -87,6 +88,22 @@ std::atomic_bool g_stereoProviderActive{false};
|
|||||||
StereoSinkRegistration g_stereoSink;
|
StereoSinkRegistration g_stereoSink;
|
||||||
#endif
|
#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;
|
using PresentClock = std::chrono::steady_clock;
|
||||||
|
|
||||||
struct PresentTimingSample {
|
struct PresentTimingSample {
|
||||||
@@ -237,7 +254,7 @@ enum class ImGuiFramePolicy {
|
|||||||
bool begin_frame_impl(bool pumpEvents, ImGuiFramePolicy imguiPolicy = ImGuiFramePolicy::Immediate,
|
bool begin_frame_impl(bool pumpEvents, ImGuiFramePolicy imguiPolicy = ImGuiFramePolicy::Immediate,
|
||||||
bool* imguiNewFrameOwed = nullptr) noexcept;
|
bool* imguiNewFrameOwed = nullptr) noexcept;
|
||||||
bool begin_frame_render_state_impl(ImGuiFramePolicy imguiPolicy, bool* imguiNewFrameOwed) 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:
|
// 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.
|
// producer-shared renderer state is free again. Done: slots encoded, presented, ImGui restarted.
|
||||||
@@ -254,9 +271,10 @@ struct FrameWorkerState {
|
|||||||
bool started = false;
|
bool started = false;
|
||||||
bool stop = false;
|
bool stop = false;
|
||||||
bool jobPending = false;
|
bool jobPending = false;
|
||||||
// Written with jobPending and copied by the worker under this mutex. It
|
// Written with jobPending and copied by the worker under this mutex. They
|
||||||
// belongs to that exact queued frame, not to the producer's next frame.
|
// belong to that exact queued frame, not to the producer's next frame.
|
||||||
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
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
|
// 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`.
|
// barrier. `sealed` is released before `ready`, and both are cleared under `mutex`.
|
||||||
std::atomic_bool sealed{true};
|
std::atomic_bool sealed{true};
|
||||||
@@ -305,7 +323,8 @@ bool frame_worker_requested() noexcept {
|
|||||||
|
|
||||||
#ifdef AURORA_ENABLE_GX
|
#ifdef AURORA_ENABLE_GX
|
||||||
// Returns false when a stop request was observed mid-cycle.
|
// 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
|
#endif
|
||||||
|
|
||||||
void frame_worker_main() noexcept {
|
void frame_worker_main() noexcept {
|
||||||
@@ -322,6 +341,7 @@ void frame_worker_main() noexcept {
|
|||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
||||||
|
StereoSceneAnchor sceneAnchor{};
|
||||||
{
|
{
|
||||||
std::unique_lock lock(g_frameWorker.mutex);
|
std::unique_lock lock(g_frameWorker.mutex);
|
||||||
g_frameWorker.cv.wait(lock, [] { return g_frameWorker.stop || g_frameWorker.jobPending; });
|
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;
|
contentTag = g_frameWorker.contentTag;
|
||||||
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
||||||
|
sceneAnchor = g_frameWorker.sceneAnchor;
|
||||||
|
g_frameWorker.sceneAnchor = {};
|
||||||
g_frameWorker.jobPending = false;
|
g_frameWorker.jobPending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The CPU already decoded the sealed frame at its GX boundary; the worker only owns
|
// 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.
|
// encode/submit/present, so it never touches the producer's next FIFO buffer.
|
||||||
#ifdef AURORA_ENABLE_GX
|
#ifdef AURORA_ENABLE_GX
|
||||||
if (!run_frame_worker_cycle(sealedFrame, contentTag)) {
|
if (!run_frame_worker_cycle(sealedFrame, contentTag, sceneAnchor)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
(void)contentTag;
|
(void)contentTag;
|
||||||
|
(void)sceneAnchor;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +388,7 @@ void ensure_frame_worker_started() noexcept {
|
|||||||
g_frameWorker.stop = false;
|
g_frameWorker.stop = false;
|
||||||
g_frameWorker.jobPending = false;
|
g_frameWorker.jobPending = false;
|
||||||
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
||||||
|
g_frameWorker.sceneAnchor = {};
|
||||||
g_frameWorker.sealed.store(true, std::memory_order_release);
|
g_frameWorker.sealed.store(true, std::memory_order_release);
|
||||||
g_frameWorker.ready.store(true, std::memory_order_release);
|
g_frameWorker.ready.store(true, std::memory_order_release);
|
||||||
g_frameWorker.prepareAllowed = false;
|
g_frameWorker.prepareAllowed = false;
|
||||||
@@ -433,6 +457,7 @@ void stop_frame_worker() noexcept {
|
|||||||
g_frameWorker.framePrepared = false;
|
g_frameWorker.framePrepared = false;
|
||||||
g_frameWorker.jobPending = false;
|
g_frameWorker.jobPending = false;
|
||||||
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
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); }
|
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;
|
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{};
|
gfx::StereoReplayFrame replay{};
|
||||||
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
||||||
ensure_stereo_eye_target(eye, input.eyes[eye].width, input.eyes[eye].height);
|
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.projection, input.eyes[eye].projection, sizeof(view.projection));
|
||||||
std::memcpy(&view.viewFromCenter, input.eyes[eye].viewFromCenter, sizeof(view.viewFromCenter));
|
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;
|
return replay;
|
||||||
}
|
}
|
||||||
@@ -1556,7 +1589,8 @@ struct SealedFrameContext {
|
|||||||
|
|
||||||
// Phase 1: everything that touches producer-shared renderer state. Needs g_rendererGpuMutex and
|
// Phase 1: everything that touches producer-shared renderer state. Needs g_rendererGpuMutex and
|
||||||
// a FIFO already drained into the recorded pass list.
|
// 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");
|
ZoneScopedN("Seal frame");
|
||||||
const auto encoderDescriptor = wgpu::CommandEncoderDescriptor{
|
const auto encoderDescriptor = wgpu::CommandEncoderDescriptor{
|
||||||
.label = "Redraw encoder",
|
.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)) {
|
if (const auto stereoInput = request_stereo_frame(ctx.logicalFrame, contentTag)) {
|
||||||
ctx.stereoFrameToken = stereoInput->frameToken;
|
ctx.stereoFrameToken = stereoInput->frameToken;
|
||||||
ctx.stereoFrameMode = stereoInput->mode;
|
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) {
|
if (ctx.stereoReplay && ctx.stereoFrameMode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY) {
|
||||||
// Keep the accepted frame alive even if the additional eye-uniform copies
|
// 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
|
// One complete frame-worker cycle. The scene encode only leaves the renderer mutex when
|
||||||
// interpolation actually inserts slots; otherwise both phases publish together.
|
// 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");
|
ZoneScopedN("Frame worker cycle");
|
||||||
webgpu::fail_if_device_lost();
|
webgpu::fail_if_device_lost();
|
||||||
SealedFrameContext ctx;
|
SealedFrameContext ctx;
|
||||||
@@ -1860,7 +1895,7 @@ bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag)
|
|||||||
bool overlapEncode = false;
|
bool overlapEncode = false;
|
||||||
{
|
{
|
||||||
std::lock_guard gpuLock(g_rendererGpuMutex);
|
std::lock_guard gpuLock(g_rendererGpuMutex);
|
||||||
seal_frame_locked(sealedFrame, ctx, contentTag);
|
seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor);
|
||||||
overlapEncode = ctx.interpolationActive;
|
overlapEncode = ctx.interpolationActive;
|
||||||
if (!overlapEncode) {
|
if (!overlapEncode) {
|
||||||
presentationJobs = encode_sealed_frame(sealedFrame, ctx);
|
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
|
// 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.
|
// 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;
|
ZoneScoped;
|
||||||
#ifdef AURORA_ENABLE_GX
|
#ifdef AURORA_ENABLE_GX
|
||||||
webgpu::fail_if_device_lost();
|
webgpu::fail_if_device_lost();
|
||||||
@@ -1934,7 +1970,7 @@ void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag) noexce
|
|||||||
if (drainFifo) {
|
if (drainFifo) {
|
||||||
gx::fifo::drain();
|
gx::fifo::drain();
|
||||||
}
|
}
|
||||||
seal_frame_locked(sealedFrame, ctx, contentTag);
|
seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor);
|
||||||
presentationJobs = encode_sealed_frame(sealedFrame, ctx);
|
presentationJobs = encode_sealed_frame(sealedFrame, ctx);
|
||||||
}
|
}
|
||||||
publish_presentations(std::move(presentationJobs), ctx.interpolationActive);
|
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)pumpEvents;
|
||||||
(void)drainFifo;
|
(void)drainFifo;
|
||||||
(void)contentTag;
|
(void)contentTag;
|
||||||
|
(void)sceneAnchor;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2013,8 +2050,12 @@ void end_frame(uint64_t contentTag) noexcept {
|
|||||||
#ifdef AURORA_ENABLE_GX
|
#ifdef AURORA_ENABLE_GX
|
||||||
webgpu::fail_if_device_lost();
|
webgpu::fail_if_device_lost();
|
||||||
#endif
|
#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()) {
|
if (!frame_worker_requested()) {
|
||||||
end_frame_impl(true, true, contentTag);
|
end_frame_impl(true, true, contentTag, sceneAnchor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2035,6 +2076,7 @@ void end_frame(uint64_t contentTag) noexcept {
|
|||||||
g_frameWorker.sealed.store(false, std::memory_order_release);
|
g_frameWorker.sealed.store(false, std::memory_order_release);
|
||||||
g_frameWorker.ready.store(false, std::memory_order_release);
|
g_frameWorker.ready.store(false, std::memory_order_release);
|
||||||
g_frameWorker.contentTag = contentTag;
|
g_frameWorker.contentTag = contentTag;
|
||||||
|
g_frameWorker.sceneAnchor = sceneAnchor;
|
||||||
g_frameWorker.jobPending = true;
|
g_frameWorker.jobPending = true;
|
||||||
g_frameWorker.prepareAllowed = false;
|
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);
|
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
|
#ifdef AURORA_ENABLE_GX
|
||||||
namespace stereo {
|
namespace stereo {
|
||||||
void set_sink(SinkCallback callback, SubmitCallback submitted, void* userdata) noexcept {
|
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(); }
|
bool aurora_begin_frame() { return aurora::begin_frame(); }
|
||||||
void aurora_end_frame() { aurora::end_frame(AURORA_STEREO_CONTENT_TAG_UNKNOWN); }
|
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_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) {
|
void aurora_set_frame_worker_wait_callback(AuroraFrameWorkerWaitCallback callback) {
|
||||||
aurora::g_frameWorkerWaitCallback.store(callback, std::memory_order_release);
|
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>);
|
const size_t offset = layout.positionOffset + matrix * sizeof(Mat3x4<float>);
|
||||||
Mat3x4<float> source;
|
Mat3x4<float> source;
|
||||||
std::memcpy(&source, uniform.data() + offset, sizeof(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));
|
std::memcpy(uniform.data() + offset, &transformed, sizeof(transformed));
|
||||||
}
|
}
|
||||||
for (uint32_t matrix = 0; matrix < layout.normalMatrixCount; ++matrix) {
|
for (uint32_t matrix = 0; matrix < layout.normalMatrixCount; ++matrix) {
|
||||||
const size_t offset = layout.normalOffset + matrix * sizeof(Mat3x4<float>);
|
const size_t offset = layout.normalOffset + matrix * sizeof(Mat3x4<float>);
|
||||||
Mat3x4<float> source;
|
Mat3x4<float> source;
|
||||||
std::memcpy(&source, uniform.data() + offset, sizeof(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));
|
std::memcpy(uniform.data() + offset, &transformed, sizeof(transformed));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 2D content reaches the eye entirely through its projection: the
|
// 2D content reaches the eye entirely through its projection: the
|
||||||
// draw's own position matrices lay the element out in screen space.
|
// 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
|
// First lift viewport-local NDC into displayed-frame NDC; replay will
|
||||||
// use a full-eye viewport so sub-pane elements are not transformed by
|
// use a full-eye viewport so sub-pane elements are not transformed by
|
||||||
// the recorded viewport a second time.
|
// the recorded viewport a second time.
|
||||||
|
|||||||
@@ -295,7 +295,15 @@ struct ReplayTarget {
|
|||||||
struct StereoReplayEye {
|
struct StereoReplayEye {
|
||||||
ReplayTarget target;
|
ReplayTarget target;
|
||||||
Mat4x4<float> projection;
|
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;
|
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 {
|
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
|
||||||
} // namespace aurora::gfx::stereo_replay
|
} // 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)
|
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
|
||||||
add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests)
|
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
|
# HostContext deliberately keeps the platform-specific context primitive out
|
||||||
# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future
|
# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future
|
||||||
# refactors cannot silently remove its headers, implementation, or link edge.
|
# refactors cannot silently remove its headers, implementation, or link edge.
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ struct RuntimeUserConfig {
|
|||||||
std::optional<bool> vrHudVirtualScreen;
|
std::optional<bool> vrHudVirtualScreen;
|
||||||
std::optional<bool> vrStopAtDisplayCopy;
|
std::optional<bool> vrStopAtDisplayCopy;
|
||||||
std::optional<bool> vrSkipCopyClears;
|
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> audioVolume;
|
||||||
std::optional<float> audioMusicVolume;
|
std::optional<float> audioMusicVolume;
|
||||||
std::optional<float> audioSoundEffectsVolume;
|
std::optional<float> audioSoundEffectsVolume;
|
||||||
@@ -339,7 +344,18 @@ inline void EnsureConfigFile() {
|
|||||||
"# reset a GX copy performs afterwards. Both keep that reset\n"
|
"# reset a GX copy performs afterwards. Both keep that reset\n"
|
||||||
"# from erasing the eye, and both are safe to turn off.\n"
|
"# from erasing the eye, and both are safe to turn off.\n"
|
||||||
"stop_at_display_copy = true\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"
|
"[audio]\n"
|
||||||
"volume = 1.0\n"
|
"volume = 1.0\n"
|
||||||
"music_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.vrHudVirtualScreen = FindConfigValue<bool>(document, "vr", "hud_virtual_screen");
|
||||||
config.vrStopAtDisplayCopy = FindConfigValue<bool>(document, "vr", "stop_at_display_copy");
|
config.vrStopAtDisplayCopy = FindConfigValue<bool>(document, "vr", "stop_at_display_copy");
|
||||||
config.vrSkipCopyClears = FindConfigValue<bool>(document, "vr", "skip_copy_clears");
|
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 readVolume = [&](std::string_view key) -> std::optional<float> {
|
||||||
auto value = FindConfigFloat(document, "audio", key);
|
auto value = FindConfigFloat(document, "audio", key);
|
||||||
@@ -742,6 +775,43 @@ inline bool SetVrSkipCopyClears(bool value) {
|
|||||||
return WriteSetting("vr", "skip_copy_clears", value ? "true" : "false");
|
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) {
|
inline bool SetControllerButton(size_t index, std::string value) {
|
||||||
if (index >= kControllerButtonKeys.size()) {
|
if (index >= kControllerButtonKeys.size()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -990,6 +1060,26 @@ inline bool VrSkipCopyClears(bool fallback = true) {
|
|||||||
return Get().vrSkipCopyClears.value_or(fallback);
|
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") {
|
inline std::string GraphicsApi(std::string fallback = "auto") {
|
||||||
return Get().graphicsApi.value_or(std::move(fallback));
|
return Get().graphicsApi.value_or(std::move(fallback));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,8 @@ void Draw() noexcept;
|
|||||||
bool StartupScreenVisible() noexcept;
|
bool StartupScreenVisible() noexcept;
|
||||||
void NotifyStrapInputAccepted() noexcept;
|
void NotifyStrapInputAccepted() noexcept;
|
||||||
void AdvancePresentedFrame() 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
|
} // 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.
|
// immersive race HUD so 2D content keeps its place across the transition.
|
||||||
float hud_width_meters = 2.4f;
|
float hud_width_meters = 2.4f;
|
||||||
float hud_scale = 1.0f;
|
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 {
|
struct MkwVRSceneObservation {
|
||||||
@@ -108,6 +113,10 @@ struct MkwVRPolicySnapshot {
|
|||||||
MkwVRCameraObservation camera{};
|
MkwVRCameraObservation camera{};
|
||||||
uint32_t available_bindings = MkwVRBindingNone;
|
uint32_t available_bindings = MkwVRBindingNone;
|
||||||
bool session_active = false;
|
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
|
// Changes whenever the stable presentation-safety state changes. Ordinary
|
||||||
// per-frame scene/camera publication does not advance it.
|
// per-frame scene/camera publication does not advance it.
|
||||||
uint64_t safety_generation = 1;
|
uint64_t safety_generation = 1;
|
||||||
@@ -115,6 +124,13 @@ struct MkwVRPolicySnapshot {
|
|||||||
// the current presentation mode, so a transient scene/camera mismatch
|
// the current presentation mode, so a transient scene/camera mismatch
|
||||||
// cannot accept an immersive packet from an adjacent asynchronous frame.
|
// cannot accept an immersive packet from an adjacent asynchronous frame.
|
||||||
uint64_t content_tag = 0;
|
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
|
// 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 MkwVRPolicyPublishScene(const MkwVRSceneObservation& scene) noexcept;
|
||||||
void MkwVRPolicyPublishRaceCamera(const MkwVRCameraObservation& camera) noexcept;
|
void MkwVRPolicyPublishRaceCamera(const MkwVRCameraObservation& camera) noexcept;
|
||||||
void MkwVRPolicyInvalidateRaceCamera() 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;
|
MkwVRPolicySnapshot MkwVRPolicyGetSnapshot() noexcept;
|
||||||
|
|
||||||
// This classifier is deliberately structural rather than heuristic: future
|
// This classifier is deliberately structural rather than heuristic: future
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include "fiber_manager.h"
|
#include "fiber_manager.h"
|
||||||
#include "platform/host_platform.h"
|
#include "platform/host_platform.h"
|
||||||
#include "runtime_log.h"
|
#include "runtime_log.h"
|
||||||
|
#include "vr/mkw_vr_first_person.h"
|
||||||
#include "vr/mkw_vr_policy.h"
|
#include "vr/mkw_vr_policy.h"
|
||||||
#include "vr/openxr_integration.h"
|
#include "vr/openxr_integration.h"
|
||||||
|
|
||||||
@@ -516,6 +517,27 @@ void PaceToRetraceBoundary(Clock::time_point deadline) {
|
|||||||
VI_HLE_ProcessRetracesDeferred(1);
|
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
|
} // namespace
|
||||||
|
|
||||||
// Single owner of the Aurora frame presentation sequence: seals the active frame, optionally paces the
|
// 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();
|
mkw::vr::OpenXRServiceProducerFrameBoundary();
|
||||||
|
PublishVrSceneAnchor();
|
||||||
// Latch the current policy safety state into this exact Aurora job. The
|
// 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
|
// 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
|
// begun the next frame, so immersive replay is accepted only when both
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#include "music_attenuation.h"
|
#include "music_attenuation.h"
|
||||||
#include "runtime_config.h"
|
#include "runtime_config.h"
|
||||||
#include "runtime_log.h"
|
#include "runtime_log.h"
|
||||||
|
#include "vr/mkw_vr_first_person.h"
|
||||||
|
#include "vr/mkw_vr_policy.h"
|
||||||
#include "wii_remote_input.h"
|
#include "wii_remote_input.h"
|
||||||
|
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
@@ -103,6 +105,11 @@ bool g_vrEnabled = RuntimeConfigFile::VrEnabled(false);
|
|||||||
bool g_vrStopAtDisplayCopy = RuntimeConfigFile::VrStopAtDisplayCopy(true);
|
bool g_vrStopAtDisplayCopy = RuntimeConfigFile::VrStopAtDisplayCopy(true);
|
||||||
bool g_vrSkipCopyClears = RuntimeConfigFile::VrSkipCopyClears(true);
|
bool g_vrSkipCopyClears = RuntimeConfigFile::VrSkipCopyClears(true);
|
||||||
bool g_vrHudVirtualScreen = RuntimeConfigFile::VrHudVirtualScreen(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);
|
uint32_t g_disabledPostProcessingPaths = RuntimeConfigFile::DisabledPostProcessingPaths(0);
|
||||||
std::array<int32_t, PAD_MAX_CONTROLLERS> g_configuredControllerIndices = [] {
|
std::array<int32_t, PAD_MAX_CONTROLLERS> g_configuredControllerIndices = [] {
|
||||||
std::array<int32_t, PAD_MAX_CONTROLLERS> indices{};
|
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
|
// 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
|
// 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() {
|
void ApplyVrHudVirtualScreen() {
|
||||||
const float unitsPerMeter = RuntimeConfigFile::VrWorldUnitsPerMeter(500.0f);
|
const float unitsPerMeter = mkw::vr::MkwVRPolicyGetSnapshot().EffectiveUnitsPerMeter();
|
||||||
aurora_set_stereo_hud_screen(g_vrHudVirtualScreen,
|
aurora_set_stereo_hud_screen(g_vrHudVirtualScreen,
|
||||||
RuntimeConfigFile::VrHudWidthMeters(2.4f) * unitsPerMeter,
|
RuntimeConfigFile::VrHudWidthMeters(2.4f) * unitsPerMeter,
|
||||||
RuntimeConfigFile::VrHudDistanceMeters(2.0f) * 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 "
|
"the whole view. Its size and distance are the [vr] hud_width_meters and "
|
||||||
"hud_distance_meters read at launch.");
|
"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() {
|
void DrawFpsOverlay() {
|
||||||
@@ -1072,11 +1126,14 @@ void InitializeRuntimeSettings() noexcept {
|
|||||||
aurora_set_stereo_skip_copy_clears(g_vrSkipCopyClears);
|
aurora_set_stereo_skip_copy_clears(g_vrSkipCopyClears);
|
||||||
ApplyVrHudVirtualScreen();
|
ApplyVrHudVirtualScreen();
|
||||||
aurora_set_skip_unready_pipelines(g_skipUnreadyPipelines);
|
aurora_set_skip_unready_pipelines(g_skipUnreadyPipelines);
|
||||||
|
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
|
||||||
g_strapInputAccepted.store(false, std::memory_order_relaxed);
|
g_strapInputAccepted.store(false, std::memory_order_relaxed);
|
||||||
g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed);
|
g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed);
|
||||||
PADBlockInput(false);
|
PADBlockInput(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RefreshVrHudVirtualScreen() noexcept { ApplyVrHudVirtualScreen(); }
|
||||||
|
|
||||||
void HandleEvents(const AuroraEvent* events) noexcept {
|
void HandleEvents(const AuroraEvent* events) noexcept {
|
||||||
if (!events) {
|
if (!events) {
|
||||||
return;
|
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 "memory.h"
|
||||||
#include "ppc_runtime.h"
|
#include "ppc_runtime.h"
|
||||||
#include "runtime_log.h"
|
#include "runtime_log.h"
|
||||||
|
#include "vr/mkw_vr_first_person.h"
|
||||||
#include "vr/mkw_vr_policy.h"
|
#include "vr/mkw_vr_policy.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -69,6 +70,15 @@ uint32_t CameraCount(uint64_t frame) noexcept {
|
|||||||
return g_instrumentation.camera_count;
|
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 RaceScreenCount() noexcept {
|
||||||
uint32_t race_scene = 0;
|
uint32_t race_scene = 0;
|
||||||
if (!Memory::TryRead32(kRaceSceneInstanceAddress, race_scene) || 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);
|
PublishRaceScene(frame, 0);
|
||||||
MkwVRPolicyInvalidateRaceCamera();
|
MkwVRPolicyInvalidateRaceCamera();
|
||||||
|
MkwVRFirstPersonReset();
|
||||||
RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] entered RaceScene at frame " << frame
|
RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] entered RaceScene at frame " << frame
|
||||||
<< std::endl;
|
<< std::endl;
|
||||||
break;
|
break;
|
||||||
@@ -169,6 +180,10 @@ extern "C" void MkwVRObserveTranslatedFunctionEntry(uint32_t address,
|
|||||||
const uint32_t screen_count = RaceScreenCount();
|
const uint32_t screen_count = RaceScreenCount();
|
||||||
LogRaceEvidenceIfChanged(frame, screen_count, camera_count);
|
LogRaceEvidenceIfChanged(frame, screen_count, camera_count);
|
||||||
PublishRaceScene(frame, screen_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;
|
break;
|
||||||
}
|
}
|
||||||
case kRaceSceneOnExit: {
|
case kRaceSceneOnExit: {
|
||||||
@@ -177,6 +192,7 @@ extern "C" void MkwVRObserveTranslatedFunctionEntry(uint32_t address,
|
|||||||
scene.guest_frame_index = frame;
|
scene.guest_frame_index = frame;
|
||||||
MkwVRPolicyPublishScene(scene);
|
MkwVRPolicyPublishScene(scene);
|
||||||
MkwVRPolicyInvalidateRaceCamera();
|
MkwVRPolicyInvalidateRaceCamera();
|
||||||
|
MkwVRFirstPersonReset();
|
||||||
RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] exited RaceScene at frame " << frame
|
RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] exited RaceScene at frame " << frame
|
||||||
<< std::endl;
|
<< std::endl;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ struct PolicyState {
|
|||||||
MkwVRCameraObservation camera{};
|
MkwVRCameraObservation camera{};
|
||||||
uint32_t available_bindings = MkwVRBindingNone;
|
uint32_t available_bindings = MkwVRBindingNone;
|
||||||
bool session_active = false;
|
bool session_active = false;
|
||||||
|
bool first_person_engaged = false;
|
||||||
uint64_t safety_generation = 1;
|
uint64_t safety_generation = 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,6 +57,9 @@ MkwVRPolicyConfig SanitizeConfig(const MkwVRPolicyConfig& config) noexcept {
|
|||||||
if (!IsFinitePositive(&sanitized.hud_scale)) {
|
if (!IsFinitePositive(&sanitized.hud_scale)) {
|
||||||
sanitized.hud_scale = kDefaultConfig.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;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +235,21 @@ void MkwVRPolicyInvalidateRaceCamera() noexcept {
|
|||||||
ApplyPolicyMutation([&] { g_policy.camera.valid = false; });
|
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 {
|
MkwVRPolicySnapshot MkwVRPolicyGetSnapshot() noexcept {
|
||||||
std::lock_guard<std::mutex> lock(g_policy_mutex);
|
std::lock_guard<std::mutex> lock(g_policy_mutex);
|
||||||
MkwVRPolicySnapshot snapshot;
|
MkwVRPolicySnapshot snapshot;
|
||||||
@@ -240,6 +259,8 @@ MkwVRPolicySnapshot MkwVRPolicyGetSnapshot() noexcept {
|
|||||||
snapshot.camera = g_policy.camera;
|
snapshot.camera = g_policy.camera;
|
||||||
snapshot.available_bindings = g_policy.available_bindings;
|
snapshot.available_bindings = g_policy.available_bindings;
|
||||||
snapshot.session_active = g_policy.session_active;
|
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.safety_generation = g_policy.safety_generation;
|
||||||
snapshot.content_tag = MakeContentTag(g_policy, snapshot.presentation);
|
snapshot.content_tag = MakeContentTag(g_policy, snapshot.presentation);
|
||||||
return snapshot;
|
return snapshot;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include "runtime_config.h"
|
#include "runtime_config.h"
|
||||||
#include "runtime_log.h"
|
#include "runtime_log.h"
|
||||||
|
#include "vr/mkw_vr_first_person.h"
|
||||||
#include "vr/mkw_vr_policy.h"
|
#include "vr/mkw_vr_policy.h"
|
||||||
#include "vr/mkw_vr_instrumentation.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.world_units_per_meter = RuntimeConfigFile::VrWorldUnitsPerMeter(500.0f);
|
||||||
config.hud_distance_meters = RuntimeConfigFile::VrHudDistanceMeters(2.0f);
|
config.hud_distance_meters = RuntimeConfigFile::VrHudDistanceMeters(2.0f);
|
||||||
config.hud_width_meters = RuntimeConfigFile::VrHudWidthMeters(2.4f);
|
config.hud_width_meters = RuntimeConfigFile::VrHudWidthMeters(2.4f);
|
||||||
|
config.first_person_units_per_meter = RuntimeConfigFile::VrFirstPersonUnitsPerMeter(10.0f);
|
||||||
MkwVRPolicyConfigure(config);
|
MkwVRPolicyConfigure(config);
|
||||||
MkwVRInstrumentationInitialize();
|
MkwVRInstrumentationInitialize();
|
||||||
|
MkwVRFirstPersonApplyConfiguredSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
|
#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
|
||||||
@@ -487,7 +490,11 @@ private:
|
|||||||
|
|
||||||
{
|
{
|
||||||
std::lock_guard lock(published_mutex_);
|
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);
|
policy.content_tag);
|
||||||
published_.store(&published_frame_, std::memory_order_release);
|
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