Refactor stereo frame worker and interpolation tests for enhanced VR performance

- Updated stereo_frame_worker_smoke.cpp to allow dynamic headset rates and prediction lead time.
- Improved logging to include motion diagnostics and adjusted frame submission logic based on headset frequency.
- Enhanced stereo_interpolation_test.cpp with additional tests for camera motion separation and playback cadence.
- Introduced MkwVRReadSceneView function to read the camera view matrix for improved scene rendering.
- Modified VR first-person logic to support scene view reading and validation.
- Added scene_camera.hpp to encapsulate camera motion handling and inverse view calculations.
- Ensured that the VR integration layer correctly logs motion diagnostics and handles scene playback accurately.
This commit is contained in:
iChris4 committed 2026-10-01 00:37:49 +02:00
1 parent 85fab2fa09
commit e7eab8a6b2
19 files changed
+1978 -114

No files matched your search

+101 -5
View File
@@ -125,13 +125,16 @@ scenes continue at the game's rate; assess interpolation during an immersive rac
VR interpolation is independent of **Graphics > Race frame interpolation**. The simulation,
physics, audio and VI remain at 60 Hz. Scene motion is delayed by one game frame (about 16.7 ms)
to interpolate between known transforms; each rendered eye pair uses a fresh predicted head
pose. This needs enough GPU headroom to render both eyes at the target rate, and carries the
to interpolate between known transforms. A monotonic scene playback clock is anchored when
continuous history starts and follows the VI cadence without following per-frame seal jitter.
Each rendered eye pair still uses a fresh predicted head pose. In particular, a runtime predicting
head poses 40-60 ms ahead must not advance scene playback beyond its known endpoints.
This needs enough GPU headroom to render both eyes at the target rate, and carries the
desktop interpolator's experimental artifacts, especially for unmatched or changing geometry.
Refresh detection uses `XR_FB_display_refresh_rate` when available and the OpenXR predicted
display period otherwise. Interpolation requires `XR_KHR_win32_convert_performance_counter_time`
to relate those display deadlines to the game's clock; the menu reports if it is unavailable.
for the packet's display-time metadata; the menu reports if it is unavailable.
The old Eager Frame Heartbeat option has been removed and existing `eager_frame_heartbeat`
settings are ignored. Completed rendering wakes the XR thread immediately. A 50 ms keep-alive
still protects pauses and window dragging without eager repeats during rendering.
@@ -816,12 +819,53 @@ latency, runtime throttling and visible blackouts.
With VR interpolation enabled, Aurora retains each sealed race's command stream and matched
previous/current transform uniforms. New OpenXR packets wake the frame worker between game
frames. It interpolates at the requested display time, then applies that packet's head pose and
frames. It interpolates at the local scene playback time, then applies that packet's predicted head pose and
the scene anchor to both eyes. Native offscreen effects and the 2D HUD retain their game-frame
updates. A mid-frame EFB readback invalidates retained GPU data; a policy-tag mismatch rejects
the replay. Missing matches use current transforms, and stalls clamp at the last known pose
instead of extrapolating. The ordinary desktop interpolation settings remain independent.
Matched rigid draws retain transform history through rotations over 90 degrees per game frame
(such as spinning kart tires); large spins use spherical interpolation to preserve angular speed
and wheel shape. A one-matrix draw is split into a rotation and an upper-triangular stretch
(scale and shear), so a sheared matrix interpolates too. Lakitu sways by tilting his whole
body's Y axis (`Lakitu::Movement::UpdateScale`): his goggles and eyes ride his face bone as
one-matrix draws, and his head is skinned to that same bone. When the shear was rejected, the
goggles stayed on the game frame while the head moved on, and sank into it. Camera/seat
anchors keep their conservative cut and rigidity guards, and draw translation, matrix validity
and indexed-palette topology checks still apply. Exact mesh/pipeline identity can
also retain motion across texture-pattern swaps, as used by the Waluigi Stadium crowd. The crowd's
image animation itself still updates at the game's cadence; its transform can move at the headset
rate without snapping whenever the image changes.
For single-player races the guest thread also publishes the recorded scene camera with the
frame. VR matching and previous transform endpoints are expressed in the current camera's
coordinates, while replay samples the world-space camera/seat pose separately. Camera turns
therefore do not trip the 1500-unit object-motion guard on distant scenery. Held particles,
new objects and rejected billboard animations also follow that sampled camera instead of
mixing a current-frame view with an interpolated seat. Direct vertices already in view space
with an identity position matrix retain that matrix, avoiding a second camera transform.
Particle simulation and texture/vertex animation still run at the game rate. Actual teleports,
camera cuts and malformed view matrices retain their guards. Multi-camera frames and hosts
that do not publish a view keep the existing interpolation path.
CPU-built particle quads are paired one quad at a time, and only where the pairing is
unambiguous. An emitter draws many look-alike quads: a boost speed line (`rk_koukasen`) is two
crossed 12 × 300 quads, two new lines start on the same ring every frame, and each moves
further per frame than the gap to its neighbours. Pairing nearest centres swapped about half
of them and swept every new line in from one that had just died. Each quad therefore also
records its two edges. A pair must change shape by less than 30%, each side must prefer the
other, and its cost (squared centre distance plus squared edge change) must beat the runner-up
among other particles by 1.5×. The two quads of one cross share a centre and do not count as
rivals. A particle already tracked must land within a quarter of its last step of its
predicted position. A particle without a path is read two ways, fixed in the world or carried
with the camera, and the group follows the reading that fits its tracked particles: carried
ones step less in camera space. A first step is drawn moving only when both readings choose
it; otherwise it just seeds the path. An unpaired quad is held where the game drew it: in
camera space when its group follows the camera (speed lines, kart sparks), in the world
otherwise (smoke left behind). Groups with more than 4096 candidate pairs are held without
matching.
The D3D12 pacing thread retains the last completed projection or virtual-screen layer and
resubmits it during stalls, including while moving the desktop window,
pausing, or minimizing. The scene freezes until rendering resumes; the compositor can still
@@ -1047,6 +1091,31 @@ When it is on, `console.log` receives lines tagged `[runtime] [xr-diag]`
than 0.5° or 0.5 mm. It gives per-eye FOV half-angles, the eye cant (the angle between the two
eyes' forward axes: 0 for parallel displays, non-zero for canted ones such as Pimax without
parallel projections), and the IPD.
- **Scene motion (`[vr-motion]`).** With VR interpolation active, Aurora logs a separate
one-second summary. `blended`, `previous`, and `current` count samples inside the two
known scene endpoints or clamped to either endpoint. `discontinuous` counts samples
without continuous history/timing. `same-time` and `backwards` compare the sampled
game-scene timeline, not head poses, pixels, or compositor submission FPS. Persistent
`same-time` with fresh XR layers can explain smooth head tracking but juddering steering.
The latest `sample-boundary` and `weight` expose exhausted scene history; `display-boundary`
and `prediction-lead` describe the independent runtime head-prediction horizon.
Latest draw counts distinguish identity `matched` from transform `prepared`; `rejected`
means a paired draw failed the transform guards. Unmatched draws can also snap, and
these counts do not measure GPU time. `max-rejected` is the highest rejection count
observed during the window. `scene-step` gives minimum/maximum forward scene-time
advancement per sample in milliseconds; steady 90 Hz scene motion should advance about
11.11 ms each time, even when every submission is fresh. Enable the same renderer log with
`debug.wiicompiled.fpslog=1` on Android, or `AURORA_VR_MOTION_LOG=1` in the environment
before launching a host that does not use OpenXR.
`camera-separated=true` confirms the frame used the published scene camera and the separate
camera/object interpolation path. If false during a single-player race, include that in the
capture so a missing/invalid camera publication can be distinguished from object rejection.
For steering judder, capture a single-player race with **VR frame interpolation = Auto**
(the Graphics interpolation setting is separate). Keep the headset refresh, render scale,
camera mode, and opponent count fixed. Compare a stationary head while alternating steering
left/right with a stationary kart while turning the head. On PC, keep SSW disabled for this
capture so generated compositor frames do not mask the application's scene cadence.
- **A one-second summary.** Timings are `median/worst` in milliseconds; for `end-margin`, worst is
the minimum.
@@ -1204,11 +1273,38 @@ ownership and retained-layer submission without a headset.
For a Windows GPU check, configure Aurora with its tests enabled and
`AURORA_GPU_SMOKE_TESTS=ON`, then build/run `stereo_frame_worker_smoke`. This feeds the actual
renderer a 60 Hz GX stream and an independent 90 Hz stereo provider. The development check
renderer a 60 Hz GX stream and an independent 90 Hz stereo provider, with a default 40 ms
head-prediction lead to exercise the multi-frame horizon seen in VDXR. The development check
produced 359 new stereo submissions in 4 seconds (89.7 FPS). This verifies submission cadence,
not full-race performance or visual quality on a headset. Pass a draw count, for example
`stereo_frame_worker_smoke 2000`, to stress uniform preparation and renderer/producer overlap;
`stereo_frame_worker_smoke 2000 0` checks native stereo with interpolation Off.
The fourth and fifth arguments select headset Hz and prediction lead in milliseconds:
`stereo_frame_worker_smoke 1 1 0 90 65` exercises a 90 Hz headset predicting 65 ms ahead.
Add a sixth argument of `1` to rotate the published camera around geometry 100000 units away;
`stereo_frame_worker_smoke 1000 1 1 90 65 1` combines that with indexed-palette stress. Check
`camera-separated=true`, transform rejection counts and scene cadence together.
Add a seventh argument of `1` for CPU-authored particle quads, for example
`stereo_frame_worker_smoke 1000 1 0 90 65 1 1`. Their matrices stay at identity
while their vertex centers move. VR interpolation records each four-vertex,
direct-F32 quad separately, pairs it across frames by centre and shape, and
shifts the current shape to the sampled center. Current texture, colour and
shape changes still occur at the game's rate; this is position interpolation,
not a particle simulation or a blend between texture animation frames. The
smoke's thousand overlapping quads exceed the pairing bound, so they measure
decoder and replay cost, not pairing. The GX tests cover pairing:
`VrSpeedLineEmitterKeepsEachStreakOnItsOwnPath` replays a speed-line emitter
for 90 frames and prints how many quads moved, were held or were misattributed.
Rigid meshes with a stable basis also reject a large reverse jump relative to
their measured, camera-independent velocity. This holds the new phase when an
animation resets, instead of interpolating backward through the loop. Coconut
Mall's escalator uses a repeating 20-unit phase. Normal direction changes remain
interpolated. `[vr-motion]` reports `vertex-motion` (usable particle pairs in
the latest seal), `vertex-held` (particle quads drawn where the game put them
because no unambiguous partner exists yet) and cumulative `wrap-cuts`; check
those during the affected effects, then confirm the visual result in the headset.
The `[vr-motion]` scene-step range measures actual playback cadence separately from submission FPS.
Use `stereo_frame_worker_smoke 1000 1 1` to exercise ten-matrix palettes and their
larger uniform history, or `stereo_frame_worker_smoke 1000 2 1` to switch interpolation
On/Off during recording. The test compositor discards obsolete ticks and uses
+6 -1
View File
@@ -197,7 +197,8 @@ typedef struct {
AuroraStereoFrameMode mode;
uint64_t contentTag;
// Predicted display time converted to std::chrono::steady_clock nanoseconds.
// Zero disables temporal interpolation for this packet.
// Used for diagnostics. Scene playback has its own clock; the eye poses remain
// predicted for this time even when the runtime looks several game frames ahead.
uint64_t displayTimeNanos;
// Optional; inactive when zero-initialised.
AuroraCockpit cockpit;
@@ -334,6 +335,10 @@ void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]);
// The sealed frame then owns that scale: each eye's head/IPD translation is
// rescaled from the packet's AuroraCockpit::unitsPerMeter to it.
void aurora_set_stereo_scene_anchor_scaled(const float anchorFromScene[12], float unitsPerMeter);
// Recorded world-to-view camera, copied on the GX thread beside the scene anchor.
// Null disables camera-separated interpolation for this frame.
void aurora_set_stereo_scene_view(const float viewFromWorld[12]);
// GX producer thread, after the scene anchor and before sealing that frame.
void aurora_set_stereo_cockpit_item(const AuroraCockpitItem* item);
// Copies a user-supplied Race/Common.szs archive. May be called on the guest
+8
View File
@@ -63,6 +63,12 @@ typedef struct {
uint64_t framesReplayUnsafe;
uint64_t slotReductions;
uint64_t lateSealDrops;
// Matched identity does not guarantee usable interpolation transforms.
uint32_t preparedDraws; // latest seal: draws with usable endpoint pairs
uint32_t rejectedDraws; // latest seal: paired draws rejected by transform guards
uint32_t vertexMotionDraws; // latest seal: CPU-authored quads with usable motion pairs
uint32_t vertexMotionHeld; // latest seal: such quads drawn where recorded (no unambiguous partner yet)
uint64_t animationWrapCuts; // cumulative rigid animation resets held at their new phase
} AuroraFrameInterpolationDiagnostics;
void aurora_get_frame_interpolation_diagnostics(AuroraFrameInterpolationDiagnostics* diagnostics);
@@ -76,6 +82,8 @@ uint32_t aurora_get_frame_interpolation_fps();
// each headset deadline, leaving guest simulation and VI timing at 60 Hz.
void aurora_set_stereo_frame_interpolation(bool enabled);
bool aurora_get_stereo_frame_interpolation();
// Logs scene sampling and transform acceptance separately from headset submission FPS.
void aurora_set_stereo_motion_logging(bool enabled);
// Newly encountered GX pipelines compile on the bounded worker queue. Draws whose pipeline is not
// ready are skipped rather than stalling submission, and pick it up once compilation finishes.
+112 -8
View File
@@ -11,6 +11,7 @@
#include "stereo.hpp"
#include "stereo_mirror.hpp"
#include "stereo_interpolation.hpp"
#include "scene_camera.hpp"
#include "stereo_overlay.hpp"
#include "webgpu/fdm.hpp"
#include "webgpu/gpu.hpp"
@@ -79,6 +80,7 @@ std::atomic<AuroraFrameLogCallback> g_frameLogCallback{nullptr};
// deadlines derived from it, so the presenter cannot drift. Zero means present when ready.
std::atomic<uint64_t> g_presentScheduleBaseNanos{0};
std::atomic<uint64_t> g_presentScheduleIntervalNanos{0};
std::atomic_bool g_stereoMotionLogging{false};
namespace {
Module Log("aurora");
@@ -119,6 +121,8 @@ struct StereoSceneAnchor {
// own scale applies (aurora_set_stereo_scene_anchor_scaled).
float unitsPerMeter = 0.f;
AuroraCockpitItem cockpitItem{};
std::array<float, 12> viewFromWorld{};
bool viewValid = 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
@@ -1940,15 +1944,78 @@ struct RetainedStereoContext {
StereoSceneAnchor anchor;
StereoSceneAnchor previousAnchor;
bool continuous = false;
stereo::SceneCameraMotion cameraMotion;
} g_retainedStereo;
stereo::ScenePlaybackClock g_stereoPlaybackClock;
void log_stereo_motion(const AuroraStereoFrame& input, uint64_t sampleTime, float weight) {
// Environment switch also works for hosts using Aurora without OpenXR.
static const bool forced = [] {
const char* value = std::getenv("AURORA_VR_MOTION_LOG");
bool enabled = value != nullptr && std::strcmp(value, "1") == 0;
#if defined(__ANDROID__)
enabled = enabled || android_debug::property_int("debug.wiicompiled.fpslog", 0) == 1;
#endif
return enabled;
}();
static stereo::MotionSamples samples;
static uint32_t maxRejected = 0;
static auto start = std::chrono::steady_clock::now();
if (!forced && !g_stereoMotionLogging.load(std::memory_order_relaxed)) {
samples = {};
maxRejected = 0;
start = std::chrono::steady_clock::now();
return;
}
const auto& retained = g_retainedStereo;
AuroraFrameInterpolationDiagnostics draws{};
gx::get_frame_interpolation_diagnostics(draws);
maxRejected = std::max(maxRejected, draws.rejectedDraws);
if (samples.samples == 0 && samples.lastSceneTime == 0)
start = std::chrono::steady_clock::now();
samples.record(sampleTime, retained.boundary, retained.interval, retained.continuous, weight);
const auto now = std::chrono::steady_clock::now();
const double seconds = std::chrono::duration<double>(now - start).count();
if (seconds < 1.0)
return;
const auto deltaMs = [](uint64_t a, uint64_t b) {
return a >= b ? static_cast<double>(a - b) / 1e6 : -static_cast<double>(b - a) / 1e6;
};
const uint64_t nowNs = std::chrono::duration_cast<std::chrono::nanoseconds>(now.time_since_epoch()).count();
Log.info("[vr-motion] {:.2f}s samples={} blended={} previous={} current={} discontinuous={} "
"same-time={} backwards={} scene-step={:.2f}/{:.2f}ms max-rejected={} | latest "
"display-boundary={:.2f}ms prediction-lead={:.2f}ms sample-boundary={:.2f}ms weight={:.3f} | latest draws "
"candidates={} matched={} prepared={} rejected={} replay-safe={} camera-separated={} "
"vertex-motion={} vertex-held={} wrap-cuts={}",
seconds, samples.samples, samples.blended, samples.atPrevious, samples.atCurrent,
samples.discontinuous, samples.repeated, samples.backwards,
samples.minStep == UINT64_MAX ? 0.0 : samples.minStep / 1e6, samples.maxStep / 1e6, maxRejected,
deltaMs(input.displayTimeNanos, retained.boundary), deltaMs(input.displayTimeNanos, nowNs),
deltaMs(sampleTime, retained.boundary), weight,
draws.candidates, draws.matches, draws.preparedDraws, draws.rejectedDraws, draws.replaySafe,
retained.cameraMotion.active, draws.vertexMotionDraws, draws.vertexMotionHeld, draws.animationWrapCuts);
samples.clear_window();
maxRejected = 0;
start = now;
}
gfx::StereoReplayFrame interpolated_stereo_frame(const AuroraStereoFrame& input, float& weight) {
const auto& retained = g_retainedStereo;
const uint64_t now = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
const uint64_t sampleTime = g_stereoPlaybackClock.sample_time(now);
weight = retained.continuous
? stereo::interpolation_weight(input.displayTimeNanos, retained.boundary, retained.interval)
? stereo::interpolation_weight(sampleTime, retained.boundary, retained.interval)
: 1.0f;
log_stereo_motion(input, sampleTime, weight);
auto anchor = retained.anchor;
if (weight < 1.0f && anchor.active && retained.previousAnchor.active) {
if (weight < 1.0f && retained.cameraMotion.active) {
Mat3x4<float> result;
if (retained.cameraMotion.sample(weight, result)) {
std::memcpy(anchor.anchorFromScene.data(), &result, sizeof(result));
anchor.active = true;
}
} else if (weight < 1.0f && anchor.active && retained.previousAnchor.active) {
Mat3x4<float> previous, current, result;
std::memcpy(&previous, retained.previousAnchor.anchorFromScene.data(), sizeof(previous));
std::memcpy(&current, anchor.anchorFromScene.data(), sizeof(current));
@@ -2004,6 +2071,27 @@ void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, u
// pre-first-frame UINT32_MAX value to logical frame zero.
ctx.logicalFrame = gfx::current_frame() + 1;
gfx::set_stereo_local_player_count(sceneAnchor.localPlayerCount);
ctx.scheduleBaseNanos = g_presentScheduleBaseNanos.load(std::memory_order_acquire);
ctx.scheduleIntervalNanos = g_presentScheduleIntervalNanos.load(std::memory_order_acquire);
bool continuous = g_retainedStereo.contentTag == contentTag &&
g_retainedStereo.interval != 0 && ctx.scheduleIntervalNanos != 0 &&
ctx.scheduleBaseNanos > g_retainedStereo.boundary &&
ctx.scheduleBaseNanos - g_retainedStereo.boundary <= ctx.scheduleIntervalNanos * 3 / 2 &&
g_retainedStereo.anchor.active == sceneAnchor.active &&
g_retainedStereo.anchor.localPlayerCount == sceneAnchor.localPlayerCount;
stereo::SceneCameraMotion cameraMotion;
if (continuous && gx::stereo_frame_interpolation_active() && sceneAnchor.localPlayerCount == 1 &&
sceneAnchor.viewValid && g_retainedStereo.anchor.viewValid) {
Mat3x4<float> previousView, currentView, previousAnchor, currentAnchor;
std::memcpy(static_cast<void*>(&previousView), g_retainedStereo.anchor.viewFromWorld.data(), sizeof(previousView));
std::memcpy(static_cast<void*>(&currentView), sceneAnchor.viewFromWorld.data(), sizeof(currentView));
std::memcpy(static_cast<void*>(&previousAnchor), g_retainedStereo.anchor.anchorFromScene.data(), sizeof(previousAnchor));
std::memcpy(static_cast<void*>(&currentAnchor), sceneAnchor.anchorFromScene.data(), sizeof(currentAnchor));
// A real camera cut uses current endpoints for the whole scene.
continuous = cameraMotion.prepare(previousView, currentView, previousAnchor, currentAnchor);
}
gx::set_frame_interpolation_view_rebase(cameraMotion.active ? &cameraMotion.currentFromPrevious : nullptr,
cameraMotion.active ? &cameraMotion.previousFromCurrent : nullptr);
if (const auto stereoInput = request_stereo_frame(ctx.logicalFrame, contentTag)) {
ctx.stereoInput = stereoInput;
ctx.stereoFrameToken = stereoInput->frameToken;
@@ -2061,15 +2149,14 @@ void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, u
// encode phase reads only worker-private state.
gfx::seal_frame(sealedFrame);
ctx.retainStereo = gx::stereo_frame_interpolation_active() && gfx::has_late_stereo_replay(sealedFrame);
const bool continuous = ctx.retainStereo && g_retainedStereo.contentTag == contentTag &&
g_retainedStereo.interval != 0 && ctx.scheduleIntervalNanos != 0 &&
ctx.scheduleBaseNanos > g_retainedStereo.boundary &&
ctx.scheduleBaseNanos - g_retainedStereo.boundary <= ctx.scheduleIntervalNanos * 3 / 2 &&
g_retainedStereo.anchor.active == sceneAnchor.active;
continuous = continuous && ctx.retainStereo;
const auto previousAnchor = g_retainedStereo.anchor;
g_retainedStereo = {contentTag, ctx.scheduleBaseNanos, ctx.scheduleIntervalNanos,
ctx.logicalFrame, sceneAnchor, previousAnchor,
continuous};
continuous, cameraMotion};
const uint64_t now = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
g_stereoPlaybackClock.begin_scene(ctx.scheduleBaseNanos, now, continuous);
gfx::expire_bind_group_cache();
}
@@ -2770,6 +2857,19 @@ void aurora_set_stereo_scene_anchor_scaled(const float anchorFromScene[12], floa
}
}
void aurora_set_stereo_scene_view(const float viewFromWorld[12]) {
auto& pending = aurora::g_pendingSceneAnchor;
pending.viewValid = false;
if (viewFromWorld == nullptr) return;
for (size_t i = 0; i < 12; ++i) {
uint32_t bits;
std::memcpy(&bits, viewFromWorld + i, sizeof(bits));
if ((bits & 0x7f800000u) == 0x7f800000u) return;
}
std::memcpy(pending.viewFromWorld.data(), viewFromWorld, sizeof(pending.viewFromWorld));
pending.viewValid = true;
}
void aurora_set_stereo_cockpit_item(const AuroraCockpitItem* item) {
aurora::set_stereo_cockpit_item(item);
}
@@ -2848,6 +2948,10 @@ void aurora_get_frame_interpolation_diagnostics(AuroraFrameInterpolationDiagnost
}
}
void aurora_set_stereo_motion_logging(bool enabled) {
aurora::g_stereoMotionLogging.store(enabled, std::memory_order_relaxed);
}
void aurora_get_present_timing(AuroraPresentTiming* timing) {
if (timing != nullptr) {
*timing = aurora::snapshot_present_timing();
+11 -2
View File
@@ -2188,10 +2188,19 @@ bool prepare_late_stereo_replay(SealedFrame& frame, wgpu::CommandEncoder& cmd, c
Mat3x4<float> before, current, result;
std::memcpy(&before, previous + at, sizeof(before));
std::memcpy(&current, uniform.data() + at, sizeof(current));
const bool vertexMotion = layout.vertexMotion.enabled && offset == layout.positionOffset &&
!layout.indexedMatrices;
if (vertexMotion) {
before = gx::offset_transform_origin(before, layout.vertexMotion.center);
current = gx::offset_transform_origin(current, layout.vertexMotion.center);
}
const bool valid = layout.indexedMatrices ? gx::interpolate_indexed_transform(before, current, weight, result)
: gx::interpolate_transform(before, current, weight, result);
if (valid)
: gx::interpolate_draw_transform(before, current, weight, result);
if (valid) {
if (vertexMotion)
result = gx::offset_transform_origin(result, layout.vertexMotion.center, -1.f);
std::memcpy(uniform.data() + at, &result, sizeof(result));
}
}
};
interpolateMatrices(layout.positionOffset, layout.positionMatrixCount, layout.positionMatrixMask);
+41 -3
View File
@@ -2379,6 +2379,17 @@ bool submit_raw_draw(GXPrimitive prim, GXVtxFmt fmt, const uint8_t* vertices, ui
return true;
}
// Runs for every draw, VR interpolation or not: the shape tests come first so that
// ordinary draws never reach the atomic flag.
static bool particle_quad_motion(GXPrimitive prim, GXVtxFmt fmt, uint16_t count) noexcept {
if (prim != GX_QUADS || count != 4 || g_gxState.vtxDesc[GX_VA_POS] != GX_DIRECT) {
return false;
}
const auto& attr = g_gxState.vtxFmts[fmt].attrs[GX_VA_POS];
return g_gxState.vtxDesc[GX_VA_PNMTXIDX] == GX_NONE && attr.cnt == GX_POS_XYZ && attr.type == GX_F32 &&
g_gxState.projType == GX_PERSPECTIVE && stereo_frame_interpolation_active();
}
static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndian) {
ZoneScoped;
GXVtxFmt fmt = static_cast<GXVtxFmt>(cmd & CP_VAT_MASK);
@@ -2418,13 +2429,15 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
NativeWheelArray* const nativeWheel = resolve_native_wheel(fmt, vertices, vtxCount, vtxSize);
// Try to merge with previous draw call.
if (!g_gxState.stateDirty && !(aurora::stereo_frame_provider_active() && g_gxState.projType == GX_ORTHOGRAPHIC))
if (!g_gxState.stateDirty && !particle_quad_motion(prim, fmt, vtxCount) &&
!(aurora::stereo_frame_provider_active() && g_gxState.projType == GX_ORTHOGRAPHIC))
LIKELY {
auto* lastDraw = gfx::get_last_draw_command<DrawData>();
// Only if the previous draw call was a single instance draw (no lines/points handling), and only into a draw
// that resolved the same animated array: the merged whole renders through that draw's binding. Anything the
// decision cache cannot vouch for (a command it was not recorded against) stays unmerged.
if (lastDraw != nullptr && prim != GX_LINES && prim != GX_LINESTRIP && prim != GX_POINTS &&
!lastDraw->uniformReplayLayout.vertexMotion.enabled &&
lastDraw->instanceCount == 1 &&
(nativeWheelArrays.empty() ||
(nativeWheelLastDrawCommand == lastDraw && nativeWheelLastDecision == nativeWheel)))
@@ -2527,11 +2540,34 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
// Draw-identity hashing only feeds frame interpolation, and only for perspective draws: build_uniform reads the
// identity exclusively past its `!perspective || frame_interpolation_fps() == 0` early-out.
FrameInterpolationDrawIdentity drawIdentity{};
DrawVertexMotion vertexMotion{};
DrawVertexShape vertexShape{};
if (interpolationIdentityActive && particle_quad_motion(prim, fmt, vtxCount)) {
const uint32_t offset = matrix_index_prefix_size(fmt);
if (offset + 3 * sizeof(float) <= vtxStride) {
vertexMotion.enabled = true;
std::array<std::array<float, 3>, 4> corners{};
for (uint32_t vertex = 0; vertex < 4; ++vertex) {
for (uint32_t component = 0; component < 3; ++component) {
const auto* at = vertices + vertex * vtxStride + offset + component * sizeof(float);
// Bitwise finite test also works in fast-math product builds.
vertexMotion.enabled &= (read_u32(at, true) & 0x7f800000u) != 0x7f800000u;
corners[vertex][component] = read_f32(at, true);
vertexMotion.center[component] += corners[vertex][component] * 0.25f;
}
}
for (uint32_t component = 0; component < 3; ++component) {
vertexShape.edge0[component] = corners[1][component] - corners[0][component];
vertexShape.edge1[component] = corners[3][component] - corners[0][component];
}
}
}
if (interpolationIdentityActive)
UNLIKELY {
const HashType drawShape = static_cast<HashType>(vtxCount) | (static_cast<HashType>(underlying(prim)) << 16) |
(static_cast<HashType>(underlying(fmt)) << 24);
const HashType pipelineDrawSignature = xxh3_hash(pipelineState.configHash, drawShape);
const HashType pipelineDrawSignature =
xxh3_hash(pipelineState.configHash, drawShape | (HashType(vertexMotion.enabled) << 32));
const HashType textureSignature = xxh3_hash(bindGroups.textureBindGroup);
const HashType materialAndTopology =
xxh3_hash(matrixTopologySignature, xxh3_hash(bindGroups.textureBindGroup, pipelineDrawSignature));
@@ -2540,10 +2576,12 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
.pipeline = pipelineDrawSignature,
.texture = textureSignature,
.matrixTopology = matrixTopologySignature,
.geometry = geometrySignature,
};
}
const bool perspective = g_gxState.projType == GX_PERSPECTIVE;
const auto uniformRanges = build_uniform(info, vertRange.offset, ranges, drawIdentity, perspective, usedPnMtxMask);
const auto uniformRanges = build_uniform(info, vertRange.offset, ranges, drawIdentity, perspective, usedPnMtxMask,
vertexMotion, vertexShape);
const auto& replayLayout = uniformRanges.replayLayout;
const bool stereo = aurora::stereo_frame_provider_active();
const bool screen = !replayLayout.perspective && !replayLayout.nativeEfbEffect;
+546 -72
View File
@@ -2,6 +2,7 @@
#include "../internal.hpp"
#include "aurora/gfx.h"
#include "../gfx/stereo_replay.hpp"
// Guest matrices really do carry NaN/Inf, and the isfinite guards here keep them
// out of the MatchEdge sort. Needs -fno-finite-math-only (see runtime/CMakeLists.txt).
@@ -44,6 +45,11 @@ std::atomic_uint64_t s_diagFramesLowMatch{0};
std::atomic_uint64_t s_diagFramesReplayUnsafe{0};
std::atomic_uint64_t s_diagSlotReductions{0};
std::atomic_uint64_t s_diagLateSealDrops{0};
std::atomic_uint32_t s_diagPreparedDraws{0};
std::atomic_uint32_t s_diagRejectedDraws{0};
std::atomic_uint32_t s_diagVertexMotionDraws{0};
std::atomic_uint32_t s_diagVertexMotionHeld{0};
std::atomic_uint64_t s_diagAnimationWrapCuts{0};
// Persistent worker pool for the per-sample interpolation tasks. libc++ has no
// parallel execution policies, so without it the seal loop runs serially. Leaked.
class InterpolationWorkerPool {
@@ -147,6 +153,19 @@ struct FrameTransformSnapshot {
Mat3x4<float> position{};
Mat3x4<float> normal{};
uint16_t usedMatrixMask = 1;
// Direct particle vertices can already be in camera space with identity XF.
// Their retained vertex buffer belongs to the current frame, so only the
// sampled camera should move them; an old matrix cannot animate those vertices.
bool viewSpaceVertices = false;
DrawVertexMotion vertexMotion{};
// Vertex-motion quads only: centre and edges in the recording camera's space.
// The seal rebases `position` as if the quad were world-fixed, but emitters
// that follow the kart keep their particles near their old camera-space place.
std::array<float, 3> quadCenter{};
std::array<std::array<float, 3>, 2> quadEdges{};
// Left unmatched in a group whose particles follow the camera: hold it there,
// not at a world position the sampled camera would sweep past.
bool holdInCamera = false;
struct IndexedMatrices {
std::array<Mat3x4<float>, MaxPnMtx> position{};
std::array<Mat3x4<float>, MaxPnMtx> normal{};
@@ -189,10 +208,14 @@ struct PendingUniformInterpolation {
std::vector<FrameTransformEntry> s_previousFrameTransforms;
std::vector<FrameTransformEntry> s_currentFrameTransforms;
Mat3x4<float> s_currentFromPreviousView{}, s_previousFromCurrentView{};
bool s_rebaseView = false;
std::unordered_map<HashType, std::vector<size_t>> s_previousTransformIndices;
std::unordered_map<HashType, std::vector<size_t>> s_currentTransformIndices;
std::unordered_map<HashType, std::vector<size_t>> s_previousStableTransformIndices;
std::unordered_map<HashType, std::vector<size_t>> s_currentStableTransformIndices;
std::unordered_map<HashType, std::vector<size_t>> s_previousGeometryTransformIndices;
std::unordered_map<HashType, std::vector<size_t>> s_currentGeometryTransformIndices;
// Free list for the indexed-matrix snapshots; per-draw heap allocation was the
// hottest cost in this path. Unused slots keep stale data, consumers mask first.
@@ -285,6 +308,19 @@ constexpr float kMinimumScale = 1.0e-5f;
constexpr float kMaximumTranslationPerFrame = 1500.0f;
constexpr float kMinimumQuaternionDot = 0.70710678f;
constexpr size_t kNoPreparedPair = std::numeric_limits<size_t>::max();
// Vertex-motion quad pairing (matchQuadGroup). Costs are squared distances.
// A pair must undercut the runner-up among other particles by this factor.
constexpr float kQuadAmbiguity = 1.5f;
// A particle's edges change by at most 30% of its size per frame.
constexpr float kQuadShapeChange = 0.3f * 0.3f;
// A particle with a path lands within a quarter of its last step of the
// prediction, or within a tenth of its short edge when it barely moves.
constexpr float kQuadPathTolerance = 0.25f * 0.25f;
constexpr float kQuadPathFloor = 0.1f * 0.1f;
// Centres this close belong to one particle: a cross draws two quads per streak.
constexpr float kQuadSamePlace = 1.0e-4f;
// All-pairs bound per group. A denser swarm stays where the game drew it.
constexpr size_t kMaximumQuadPairs = 4096;
HashType combine_identity(HashType first, HashType second) noexcept {
return xxh3_hash(second, first);
@@ -295,6 +331,10 @@ HashType stable_identity(const FrameInterpolationDrawIdentity& identity) noexcep
identity.matrixTopology);
}
HashType geometry_identity(const FrameInterpolationDrawIdentity& identity) noexcept {
return combine_identity(combine_identity(identity.pipeline, identity.geometry), identity.matrixTopology);
}
float dot3(const std::array<float, 3>& a, const std::array<float, 3>& b) noexcept {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
@@ -338,50 +378,53 @@ Quaternion quaternion_from_rotation(const std::array<std::array<float, 3>, 3>& m
return q;
}
// Splits the 3x3 part into a rotation R and an upper-triangular stretch U with M = R*U
// (Gram-Schmidt on the columns): scale on U's diagonal, shear above it, a mirror as a
// negative last diagonal. V*T*R*S puts non-uniform model scale on the columns, so a plain
// R*S comes back with a diagonal U. A sheared matrix keeps its tilt in U instead of
// losing it to the nearest rotation. `skew` receives the largest |cosine| between the
// columns: zero for rotation times scale.
bool decompose_affine(const Mat3x4<float>& matrix, std::array<std::array<float, 3>, 3>& rotation,
std::array<float, 3>& scale, std::array<float, 3>& translation,
Quaternion& quaternion) noexcept {
std::array<float, 6>& stretch, std::array<float, 3>& translation,
Quaternion& quaternion, float& skew) noexcept {
const std::array<Vec4<float>, 3> rows{matrix.m0, matrix.m1, matrix.m2};
// V*T*R*S puts non-uniform model scale on the columns, so scale must be read off the
// columns; row extraction misreads R*S as shear whenever the rotation tilts an axis.
for (size_t row = 0; row < 3; ++row) {
translation[row] = rows[row].w();
if (!std::isfinite(translation[row])) {
return false;
}
}
for (size_t column = 0; column < 3; ++column) {
const float x = rows[0][column];
const float y = rows[1][column];
const float z = rows[2][column];
scale[column] = std::sqrt(x * x + y * y + z * z);
if (!std::isfinite(scale[column]) || scale[column] < kMinimumScale) {
return false;
}
rotation[0][column] = x / scale[column];
rotation[1][column] = y / scale[column];
rotation[2][column] = z / scale[column];
}
const auto rotationColumn = [&rotation](size_t column) noexcept {
return std::array<float, 3>{rotation[0][column], rotation[1][column], rotation[2][column]};
const auto column = [&rows](size_t index) noexcept {
return std::array<float, 3>{rows[0][index], rows[1][index], rows[2][index]};
};
if (std::abs(dot3(rotationColumn(0), rotationColumn(1))) > 0.05f ||
std::abs(dot3(rotationColumn(0), rotationColumn(2))) > 0.05f ||
std::abs(dot3(rotationColumn(1), rotationColumn(2))) > 0.05f) {
const auto c0 = column(0), c1 = column(1), c2 = column(2);
const float u00 = std::sqrt(dot3(c0, c0));
if (!std::isfinite(u00) || u00 < kMinimumScale) {
return false;
}
const float determinant = dot3(rotation[0], cross3(rotation[1], rotation[2]));
if (!std::isfinite(determinant) || std::abs(std::abs(determinant) - 1.0f) > 0.1f) {
const std::array<float, 3> r0{c0[0] / u00, c0[1] / u00, c0[2] / u00};
const float u01 = dot3(r0, c1);
const std::array<float, 3> c1Rest{c1[0] - u01 * r0[0], c1[1] - u01 * r0[1], c1[2] - u01 * r0[2]};
const float u11 = std::sqrt(dot3(c1Rest, c1Rest));
if (!std::isfinite(u11) || u11 < kMinimumScale) {
return false;
}
if (determinant < 0.0f) {
scale[0] = -scale[0];
for (size_t row = 0; row < 3; ++row) {
rotation[row][0] = -rotation[row][0];
}
const std::array<float, 3> r1{c1Rest[0] / u11, c1Rest[1] / u11, c1Rest[2] / u11};
const auto r2 = cross3(r0, r1);
const float u02 = dot3(r0, c2);
const float u12 = dot3(r1, c2);
const float u22 = dot3(r2, c2);
if (!std::isfinite(u02) || !std::isfinite(u12) || !std::isfinite(u22) || std::abs(u22) < kMinimumScale) {
return false;
}
const float length1 = std::sqrt(dot3(c1, c1));
const float length2 = std::sqrt(dot3(c2, c2));
skew = std::max({std::abs(u01) / length1, std::abs(u02) / length2,
std::abs(dot3(c1, c2)) / (length1 * length2)});
for (size_t row = 0; row < 3; ++row) {
rotation[row] = {r0[row], r1[row], r2[row]};
}
stretch = {u00, u01, u02, u11, u12, u22};
quaternion = quaternion_from_rotation(rotation);
return std::isfinite(quaternion.x) && std::isfinite(quaternion.y) && std::isfinite(quaternion.z) &&
@@ -408,12 +451,15 @@ std::array<std::array<float, 3>, 3> rotation_from_quaternion(const Quaternion& q
struct PreparedAffinePair {
Mat3x4<float> previous{};
Mat3x4<float> current{};
std::array<float, 3> previousScale{};
std::array<float, 3> currentScale{};
// Upper-triangular stretch from decompose_affine: u00 u01 u02 u11 u12 u22.
std::array<float, 6> previousStretch{};
std::array<float, 6> currentStretch{};
std::array<float, 3> previousTranslation{};
std::array<float, 3> currentTranslation{};
Quaternion previousQuaternion{};
Quaternion currentQuaternion{};
float rotationAngle = 0.0f;
float inverseRotationSin = 0.0f;
bool linear = false;
bool identical = false;
bool valid = false;
@@ -426,6 +472,9 @@ struct PreparedTransformInterpolation {
// its matrices from a sibling, that is the sibling's partner.
size_t previousProjectionEntry = kNoPreparedPair;
bool indexedValid = false;
bool desktopIndexedValid = false;
// Unpaired quad held in camera space: its own transform is both endpoints.
bool cameraHold = false;
PreparedTransformInterpolation() {
indexedPairOffsets.fill(kNoPreparedPair);
@@ -433,7 +482,7 @@ struct PreparedTransformInterpolation {
};
PreparedAffinePair prepare_affine_pair(const Mat3x4<float>& previous,
const Mat3x4<float>& current) noexcept {
const Mat3x4<float>& current, bool rotatingDraw = false) noexcept {
PreparedAffinePair pair{.previous = previous, .current = current};
if (std::memcmp(&previous, &current, sizeof(current)) == 0) {
pair.identical = true;
@@ -443,10 +492,17 @@ PreparedAffinePair prepare_affine_pair(const Mat3x4<float>& previous,
std::array<std::array<float, 3>, 3> previousRotation{};
std::array<std::array<float, 3>, 3> currentRotation{};
if (!decompose_affine(previous, previousRotation, pair.previousScale,
pair.previousTranslation, pair.previousQuaternion) ||
!decompose_affine(current, currentRotation, pair.currentScale,
pair.currentTranslation, pair.currentQuaternion)) {
float previousSkew = 0.0f, currentSkew = 0.0f;
if (!decompose_affine(previous, previousRotation, pair.previousStretch,
pair.previousTranslation, pair.previousQuaternion, previousSkew) ||
!decompose_affine(current, currentRotation, pair.currentStretch,
pair.currentTranslation, pair.currentQuaternion, currentSkew)) {
return pair;
}
// Camera and seat anchors are rigid; a sheared one is garbage. Draws may shear:
// MKW's Lakitu sways by tilting his body's Y axis, and dropping that tilt from his
// rigid goggles (or holding them at the game frame) sank them into his skinned head.
if (!rotatingDraw && std::max(previousSkew, currentSkew) > 0.05f) {
return pair;
}
@@ -471,9 +527,15 @@ PreparedAffinePair prepare_affine_pair(const Mat3x4<float>& previous,
pair.currentQuaternion.w = -pair.currentQuaternion.w;
quaternionDot = -quaternionDot;
}
if (!std::isfinite(quaternionDot) || quaternionDot < kMinimumQuaternionDot) {
if (!std::isfinite(quaternionDot) || (!rotatingDraw && quaternionDot < kMinimumQuaternionDot)) {
return pair;
}
if (quaternionDot < kMinimumQuaternionDot) {
// A fast wheel is not a camera cut. Slerp its shortest arc so arbitrary VR
// sample weights keep angular speed constant without shrinking the wheel.
pair.rotationAngle = std::acos(std::clamp(quaternionDot, 0.0f, 1.0f));
pair.inverseRotationSin = 1.0f / std::sin(pair.rotationAngle);
}
pair.valid = true;
return pair;
}
@@ -539,10 +601,14 @@ bool evaluate_affine_pair(const PreparedAffinePair& pair, float weight,
return true;
}
// Consecutive 60 Hz transforms stay in one hemisphere and within 90 degrees, so
// normalized lerp is stable and skips three transcendentals per matrix.
const float previousWeight = 1.0f - weight;
const float currentWeight = weight;
// Most transforms use the cheaper normalized lerp. Fast rigid spins need
// constant angular speed across arbitrary display samples, so use slerp there.
const float previousWeight = pair.rotationAngle > 0.0f
? std::sin((1.0f - weight) * pair.rotationAngle) * pair.inverseRotationSin
: 1.0f - weight;
const float currentWeight = pair.rotationAngle > 0.0f
? std::sin(weight * pair.rotationAngle) * pair.inverseRotationSin
: weight;
Quaternion interpolated{
pair.previousQuaternion.x * previousWeight + pair.currentQuaternion.x * currentWeight,
pair.previousQuaternion.y * previousWeight + pair.currentQuaternion.y * currentWeight,
@@ -563,28 +629,23 @@ bool evaluate_affine_pair(const PreparedAffinePair& pair, float weight,
interpolated.w /= quaternionLength;
const auto interpolatedRotation = rotation_from_quaternion(interpolated);
std::array<float, 3> interpolatedScale{};
std::array<float, 6> u{};
for (size_t i = 0; i < u.size(); ++i) {
u[i] = pair.previousStretch[i] + (pair.currentStretch[i] - pair.previousStretch[i]) * weight;
}
std::array<float, 3> interpolatedTranslation{};
for (size_t i = 0; i < 3; ++i) {
interpolatedScale[i] = pair.previousScale[i] +
(pair.currentScale[i] - pair.previousScale[i]) * weight;
interpolatedTranslation[i] =
pair.previousTranslation[i] +
(pair.currentTranslation[i] - pair.previousTranslation[i]) * weight;
}
// Column scale mirrors decompose_affine: the reconstruction is R*S, with
// each scale component applied down its column.
output = {
{interpolatedRotation[0][0] * interpolatedScale[0],
interpolatedRotation[0][1] * interpolatedScale[1],
interpolatedRotation[0][2] * interpolatedScale[2], interpolatedTranslation[0]},
{interpolatedRotation[1][0] * interpolatedScale[0],
interpolatedRotation[1][1] * interpolatedScale[1],
interpolatedRotation[1][2] * interpolatedScale[2], interpolatedTranslation[1]},
{interpolatedRotation[2][0] * interpolatedScale[0],
interpolatedRotation[2][1] * interpolatedScale[1],
interpolatedRotation[2][2] * interpolatedScale[2], interpolatedTranslation[2]},
};
// Mirrors decompose_affine: the reconstruction is R*U, U upper-triangular.
const std::array<Vec4<float>*, 3> outputRows{&output.m0, &output.m1, &output.m2};
for (size_t row = 0; row < 3; ++row) {
const auto& r = interpolatedRotation[row];
*outputRows[row] = {r[0] * u[0], r[0] * u[1] + r[1] * u[3], r[0] * u[2] + r[1] * u[4] + r[2] * u[5],
interpolatedTranslation[row]};
}
return true;
}
@@ -619,6 +680,11 @@ bool interpolate_transform_midpoint(const Mat3x4<float>& previous, const Mat3x4<
return interpolate_transform(previous, current, 0.5f, output);
}
bool interpolate_draw_transform(const Mat3x4<float>& previous, const Mat3x4<float>& current,
float weight, Mat3x4<float>& output) noexcept {
return evaluate_affine_pair(prepare_affine_pair(previous, current, true), std::clamp(weight, 0.0f, 1.0f), output);
}
bool interpolate_indexed_transform(const Mat3x4<float>& previous,
const Mat3x4<float>& current, float weight,
Mat3x4<float>& output) noexcept {
@@ -677,6 +743,23 @@ Mat3x4<float> extrapolate_transform(const Mat3x4<float>& previous,
}
return predicted;
}
std::array<float, 3> translation_of(const Mat3x4<float>& matrix) noexcept {
return {matrix.m0.w(), matrix.m1.w(), matrix.m2.w()};
}
std::array<float, 3> rotate_vector(const Mat3x4<float>& matrix, const std::array<float, 3>& vector) noexcept {
const Vec4<float>* rows[] = {&matrix.m0, &matrix.m1, &matrix.m2};
std::array<float, 3> result{};
for (size_t row = 0; row < 3; ++row)
result[row] = (*rows[row])[0] * vector[0] + (*rows[row])[1] * vector[1] + (*rows[row])[2] * vector[2];
return result;
}
float distance_squared(const std::array<float, 3>& a, const std::array<float, 3>& b) noexcept {
const std::array<float, 3> delta{a[0] - b[0], a[1] - b[1], a[2] - b[2]};
return dot3(delta, delta);
}
} // namespace
float snapshot_match_distance_squared(const FrameTransformEntry& previousEntry,
@@ -789,7 +872,17 @@ void report_producer_paced(bool paced) noexcept {
s_pacingWindowMisses.store(0, std::memory_order_release);
}
void set_frame_interpolation_view_rebase(const Mat3x4<float>* currentFromPrevious,
const Mat3x4<float>* previousFromCurrent) noexcept {
s_rebaseView = currentFromPrevious && previousFromCurrent && stereo_frame_interpolation_active();
if (s_rebaseView) {
s_currentFromPreviousView = *currentFromPrevious;
s_previousFromCurrentView = *previousFromCurrent;
}
}
void begin_frame_interpolation() noexcept {
s_rebaseView = false;
const uint32_t targetFps = frame_interpolation_fps();
static bool previousStereo = false;
const bool stereo = stereo_frame_interpolation_active();
@@ -811,13 +904,17 @@ void begin_frame_interpolation() noexcept {
// pay thousands of small allocations every frame.
clear_index_map_keep_nodes(s_previousTransformIndices, s_previousFrameTransforms.size());
clear_index_map_keep_nodes(s_previousStableTransformIndices, s_previousFrameTransforms.size());
clear_index_map_keep_nodes(s_previousGeometryTransformIndices, s_previousFrameTransforms.size());
for (size_t i = 0; i < s_previousFrameTransforms.size(); ++i) {
const auto& identity = s_previousFrameTransforms[i].identity;
s_previousTransformIndices[identity.combined].push_back(i);
s_previousStableTransformIndices[stable_identity(identity)].push_back(i);
if (identity.geometry != 0)
s_previousGeometryTransformIndices[geometry_identity(identity)].push_back(i);
}
clear_index_map_keep_nodes(s_currentTransformIndices, s_previousFrameTransforms.size());
clear_index_map_keep_nodes(s_currentStableTransformIndices, s_previousFrameTransforms.size());
clear_index_map_keep_nodes(s_currentGeometryTransformIndices, s_previousFrameTransforms.size());
s_pendingUniformInterpolations.clear();
s_perspectiveCandidates = 0;
s_perspectiveMatchable = 0;
@@ -827,6 +924,34 @@ void begin_frame_interpolation() noexcept {
}
void finalize_frame_interpolation() noexcept {
s_diagPreparedDraws.store(0, std::memory_order_relaxed);
s_diagRejectedDraws.store(0, std::memory_order_relaxed);
s_diagVertexMotionDraws.store(0, std::memory_order_relaxed);
s_diagVertexMotionHeld.store(0, std::memory_order_relaxed);
if (s_rebaseView) {
// The old history retires at the end of this seal. Rebase it once, before
// instance matching and motion gates, rather than treating camera rotation
// around a distant object as an object teleport.
for (auto& entry : s_previousFrameTransforms) {
auto& transform = entry.transform;
if (transform.viewSpaceVertices) continue;
if (transform.indexedMatrices) {
for (size_t slot = 0; slot < MaxPnMtx; ++slot) {
if ((transform.usedMatrixMask & (1u << slot)) == 0) continue;
auto& matrices = *transform.indexedMatrices;
matrices.position[slot] = gfx::stereo_replay::compose_affine(s_currentFromPreviousView, matrices.position[slot]);
matrices.normal[slot] = gfx::stereo_replay::compose_normal(s_currentFromPreviousView, matrices.normal[slot]);
matrices.slotHash[slot] = xxh3_hash_s(&matrices.position[slot], sizeof(Mat3x4<float>),
xxh3_hash_s(&matrices.normal[slot], sizeof(Mat3x4<float>)));
}
} else {
transform.position = gfx::stereo_replay::compose_affine(s_currentFromPreviousView, transform.position);
transform.normal = gfx::stereo_replay::compose_normal(s_currentFromPreviousView, transform.normal);
if (entry.hasPrediction)
entry.predictedPosition = gfx::stereo_replay::compose_affine(s_currentFromPreviousView, entry.predictedPosition);
}
}
}
// A frame reported late seals without inserted slots, so the encode phase renders
// the native frame only. Its transforms still seed the next frame's matching.
const bool late = s_dropInterpolationAtSeal.exchange(false, std::memory_order_acq_rel);
@@ -893,6 +1018,210 @@ void finalize_frame_interpolation() noexcept {
static_cast<uint64_t>(cz);
};
// Vertex-motion quads come from particle emitters, which draw many look-alike
// quads: a speed line is two crossed quads, two new ones start on the same ring
// every frame, and each moves further per frame than the gap to its neighbours.
// Nearest-centre pairing swaps them and every swap sweeps a quad across the view,
// so these groups keep only pairs that are unambiguous in centre and shape.
struct QuadPrevious {
std::array<float, 3> world{}, camera{}, predicted{};
std::array<std::array<float, 3>, 2> worldEdges{}, cameraEdges{};
float size2 = 0.f, step2 = 0.f;
bool tracked = false;
};
struct QuadCurrent {
std::array<float, 3> center{};
std::array<std::array<float, 3>, 2> edges{};
float size2 = 0.f, shortEdge2 = 0.f;
};
struct QuadCandidate {
float cost = 0.f;
uint32_t previous = 0, current = 0;
};
struct QuadBest {
float cost = std::numeric_limits<float>::infinity();
float runnerUp = std::numeric_limits<float>::infinity();
uint32_t index = UINT32_MAX;
};
static std::vector<QuadPrevious> quadPrevious;
static std::vector<QuadCurrent> quadCurrent;
static std::vector<QuadCandidate> quadCandidates;
static std::vector<QuadBest> quadBestForCurrent, quadBestForPrevious;
static std::array<std::vector<uint32_t>, 2> quadPairs;
// A first pair that only one reading supports seeds the particle's path but is
// drawn held; it moves once the next frame lands on the prediction.
static std::vector<uint8_t> quadSeedOnly;
quadSeedOnly.assign(s_currentFrameTransforms.size(), 0);
const auto samePlace = [](const std::array<float, 3>& a, const std::array<float, 3>& b, float size2) noexcept {
return distance_squared(a, b) <= kQuadSamePlace * (size2 + 1.f);
};
const auto buildQuadCandidates = [&](bool followsCamera) {
quadCandidates.clear();
for (uint32_t current = 0; current < quadCurrent.size(); ++current) {
const auto& quad = quadCurrent[current];
for (uint32_t previous = 0; previous < quadPrevious.size(); ++previous) {
const auto& before = quadPrevious[previous];
// `<=` also drops a NaN delta.
if (!(distance_squared(before.world, quad.center) <=
kMaximumTranslationPerFrame * kMaximumTranslationPerFrame)) {
continue;
}
const auto shapeDistance = [&](const std::array<std::array<float, 3>, 2>& edges) noexcept {
return distance_squared(edges[0], quad.edges[0]) + distance_squared(edges[1], quad.edges[1]);
};
float centerCost, shapeCost;
if (before.tracked) {
centerCost = distance_squared(before.predicted, quad.center);
if (!(centerCost <= std::max(kQuadPathTolerance * before.step2, kQuadPathFloor * quad.shortEdge2))) {
continue;
}
shapeCost = std::min(shapeDistance(before.worldEdges), shapeDistance(before.cameraEdges));
} else {
centerCost = distance_squared(followsCamera ? before.camera : before.world, quad.center);
shapeCost = shapeDistance(followsCamera ? before.cameraEdges : before.worldEdges);
}
const float cost = centerCost + shapeCost;
if (!(shapeCost <= kQuadShapeChange * quad.size2) || !std::isfinite(cost)) {
continue;
}
quadCandidates.push_back({cost, previous, current});
}
}
};
// Keeps a pair only when each side prefers the other and beats its runner-up
// among other particles; the two quads of one cross are not each other's rivals.
const auto selectQuadPairs = [&](std::vector<uint32_t>& pairs) {
quadBestForCurrent.assign(quadCurrent.size(), {});
quadBestForPrevious.assign(quadPrevious.size(), {});
for (const auto& candidate : quadCandidates) {
auto& forCurrent = quadBestForCurrent[candidate.current];
if (candidate.cost < forCurrent.cost) {
forCurrent.cost = candidate.cost;
forCurrent.index = candidate.previous;
}
auto& forPrevious = quadBestForPrevious[candidate.previous];
if (candidate.cost < forPrevious.cost) {
forPrevious.cost = candidate.cost;
forPrevious.index = candidate.current;
}
}
for (const auto& candidate : quadCandidates) {
auto& forCurrent = quadBestForCurrent[candidate.current];
const auto& rival = quadPrevious[candidate.previous];
if (!samePlace(rival.world, quadPrevious[forCurrent.index].world, rival.size2)) {
forCurrent.runnerUp = std::min(forCurrent.runnerUp, candidate.cost);
}
auto& forPrevious = quadBestForPrevious[candidate.previous];
const auto& other = quadCurrent[candidate.current];
if (!samePlace(other.center, quadCurrent[forPrevious.index].center, other.size2)) {
forPrevious.runnerUp = std::min(forPrevious.runnerUp, candidate.cost);
}
}
pairs.assign(quadCurrent.size(), UINT32_MAX);
for (uint32_t current = 0; current < quadCurrent.size(); ++current) {
const auto& forCurrent = quadBestForCurrent[current];
if (forCurrent.index == UINT32_MAX) {
continue;
}
const auto& forPrevious = quadBestForPrevious[forCurrent.index];
if (samePlace(quadCurrent[current].center, quadCurrent[forPrevious.index].center,
quadCurrent[current].size2) &&
forCurrent.cost * kQuadAmbiguity < forCurrent.runnerUp &&
forPrevious.cost * kQuadAmbiguity < forPrevious.runnerUp) {
pairs[current] = forCurrent.index;
}
}
};
const auto matchQuadGroup = [&] {
const size_t previousCount = groupPreviousIndices.size();
const size_t currentCount = groupCurrentIndices.size();
if (previousCount * currentCount > kMaximumQuadPairs) {
return;
}
quadPrevious.resize(previousCount);
for (size_t index = 0; index < previousCount; ++index) {
const auto& entry = s_previousFrameTransforms[groupPreviousIndices[index]];
auto& quad = quadPrevious[index];
quad.world = translation_of(entry.transform.position);
quad.camera = entry.transform.quadCenter;
quad.cameraEdges = entry.transform.quadEdges;
for (size_t edge = 0; edge < 2; ++edge) {
quad.worldEdges[edge] =
s_rebaseView ? rotate_vector(s_currentFromPreviousView, quad.cameraEdges[edge]) : quad.cameraEdges[edge];
}
quad.size2 = dot3(quad.cameraEdges[0], quad.cameraEdges[0]) + dot3(quad.cameraEdges[1], quad.cameraEdges[1]);
quad.tracked = entry.hasPrediction;
if (quad.tracked) {
quad.predicted = translation_of(entry.predictedPosition);
quad.step2 = distance_squared(quad.predicted, quad.world);
}
}
quadCurrent.resize(currentCount);
for (size_t index = 0; index < currentCount; ++index) {
const auto& transform = s_currentFrameTransforms[groupCurrentIndices[index]].transform;
auto& quad = quadCurrent[index];
quad.center = translation_of(transform.position);
quad.edges = transform.quadEdges;
const float edge0 = dot3(quad.edges[0], quad.edges[0]);
const float edge1 = dot3(quad.edges[1], quad.edges[1]);
quad.size2 = edge0 + edge1;
quad.shortEdge2 = std::min(edge0, edge1);
}
// A particle without a path is read two ways: fixed in the world (smoke left
// behind) or carried with the camera (speed lines follow the kart). Without a
// camera rebase the two readings coincide.
const size_t readings = s_rebaseView ? 2 : 1;
for (size_t reading = 0; reading < readings; ++reading) {
buildQuadCandidates(reading == 1);
selectQuadPairs(quadPairs[reading]);
}
// One emitter's particles move alike. Particles with a path show which reading
// fits: carried ones step less in camera space than in the world.
bool followsCamera = false;
if (readings == 2) {
double worldSteps = 0.0, cameraSteps = 0.0;
uint32_t tracked = 0, worldFirstSteps = 0, cameraFirstSteps = 0;
for (size_t current = 0; current < currentCount; ++current) {
if (const uint32_t previous = quadPairs[0][current]; previous != UINT32_MAX) {
const auto& before = quadPrevious[previous];
if (before.tracked) {
worldSteps += distance_squared(before.world, quadCurrent[current].center);
cameraSteps += distance_squared(before.camera, quadCurrent[current].center);
++tracked;
} else {
++worldFirstSteps;
}
}
if (const uint32_t previous = quadPairs[1][current];
previous != UINT32_MAX && !quadPrevious[previous].tracked) {
++cameraFirstSteps;
}
}
followsCamera = tracked != 0 ? cameraSteps < worldSteps : cameraFirstSteps > worldFirstSteps;
}
const auto& pairs = quadPairs[followsCamera ? 1 : 0];
const auto& otherPairs = quadPairs[followsCamera ? 0 : 1];
for (size_t current = 0; current < currentCount; ++current) {
const size_t currentIndex = groupCurrentIndices[current];
auto& transform = s_currentFrameTransforms[currentIndex].transform;
const uint32_t previous = pairs[current];
// A first step shows only when both readings choose it. A newborn that spawns
// where the last one did fools one reading, rarely both.
const bool seedOnly = previous != UINT32_MAX && !quadPrevious[previous].tracked &&
!(readings == 2 && otherPairs[current] == previous);
transform.holdInCamera = followsCamera && (previous == UINT32_MAX || seedOnly);
if (previous == UINT32_MAX) {
continue;
}
const size_t previousIndex = groupPreviousIndices[previous];
currentToPrevious[currentIndex] = previousIndex;
currentMatched[currentIndex] = 1;
previousMatched[previousIndex] = 1;
quadSeedOnly[currentIndex] = seedOnly;
}
};
const auto matchGroups =
[&](const auto& currentGroups, const auto& previousGroups,
bool allowOrderedFallback) {
@@ -919,6 +1248,12 @@ void finalize_frame_interpolation() noexcept {
if (groupCurrentIndices.empty() || groupPreviousIndices.empty()) {
continue;
}
// Draw identity includes the vertex-motion flag, so a group is all quads or none.
// One quad on each side is no proof either: a particle died and another spawned.
if (s_currentFrameTransforms[groupCurrentIndices.front()].transform.vertexMotion.enabled) {
matchQuadGroup();
continue;
}
// A unique draw has no identity ambiguity. Keep the conservative
// interpolation fallback for malformed/non-finite matrices.
@@ -1078,6 +1413,9 @@ void finalize_frame_interpolation() noexcept {
}
};
matchGroups(s_currentTransformIndices, s_previousTransformIndices, false);
// Prefer unchanged meshes across a texture flip before the material-only
// fallback for deforming geometry. Both passes keep palette topology strict.
matchGroups(s_currentGeometryTransformIndices, s_previousGeometryTransformIndices, false);
matchGroups(s_currentStableTransformIndices, s_previousStableTransformIndices, true);
s_perspectiveMatches = static_cast<uint32_t>(std::count_if(
@@ -1097,7 +1435,7 @@ void finalize_frame_interpolation() noexcept {
s_diagMatches.store(s_perspectiveMatches, std::memory_order_relaxed);
s_diagEligible.store(eligible, std::memory_order_relaxed);
s_diagReplaySafe.store(replaySafe, std::memory_order_relaxed);
if (eligible) {
if (eligible || stereo_frame_interpolation_active()) {
s_diagFramesSealed.fetch_add(1, std::memory_order_relaxed);
if (s_perspectiveMatchable != 0 &&
s_perspectiveMatches * 100 < s_perspectiveMatchable * kLowMatchPercent) {
@@ -1113,9 +1451,11 @@ void finalize_frame_interpolation() noexcept {
// identity encoded by PNMTXIDX and are prepared as one coherent draw below.
constexpr float kMaximumPredictionSeedDeltaSquared =
kMaximumTranslationPerFrame * kMaximumTranslationPerFrame;
uint32_t quadsHeld = 0;
for (size_t currentIndex = 0; currentIndex < currentToPrevious.size(); ++currentIndex) {
const size_t previousIndex = currentToPrevious[currentIndex];
if (previousIndex == SIZE_MAX) {
quadsHeld += s_currentFrameTransforms[currentIndex].transform.vertexMotion.enabled;
continue;
}
auto& currentEntry = s_currentFrameTransforms[currentIndex];
@@ -1123,6 +1463,41 @@ void finalize_frame_interpolation() noexcept {
if (currentEntry.transform.indexedMatrices || previousEntry.transform.indexedMatrices) {
continue;
}
// A looping rigid animation can wrap by far less than the teleport limit
// (Coconut Mall resets its escalator phase every 20 local units). Blending
// that jump produces a brief reverse sweep. Only cut a large, nearly
// opposite jump after a measured velocity, with the same mesh and basis.
// Camera motion has already been removed above. Particle births are not
// cyclic rigid animations and must not use this test.
if (s_rebaseView && previousEntry.hasPrediction &&
!currentEntry.transform.vertexMotion.enabled && currentEntry.identity.geometry != 0 &&
currentEntry.identity.geometry == previousEntry.identity.geometry) {
const auto& before = previousEntry.transform.position;
const auto& now = currentEntry.transform.position;
const auto& predicted = previousEntry.predictedPosition;
const Vec4<float>* oldRows[] = {&before.m0, &before.m1, &before.m2};
const Vec4<float>* newRows[] = {&now.m0, &now.m1, &now.m2};
const Vec4<float>* predictedRows[] = {&predicted.m0, &predicted.m1, &predicted.m2};
float speed2 = 0.f, jump2 = 0.f, dot = 0.f, basisDelta = 0.f, basisSize = 0.f;
for (size_t row = 0; row < 3; ++row) {
const float velocity = (*predictedRows[row])[3] - (*oldRows[row])[3];
const float delta = (*newRows[row])[3] - (*oldRows[row])[3];
speed2 += velocity * velocity;
jump2 += delta * delta;
dot += velocity * delta;
for (size_t col = 0; col < 3; ++col) {
const float difference = (*newRows[row])[col] - (*oldRows[row])[col];
basisDelta += difference * difference;
basisSize += (*newRows[row])[col] * (*newRows[row])[col];
}
}
if (speed2 > 0.0001f && jump2 > std::max(1.f, 16.f * speed2) && dot < 0.f &&
dot * dot > 0.9f * speed2 * jump2 && basisDelta < 0.0001f * basisSize) {
currentToPrevious[currentIndex] = SIZE_MAX;
s_diagAnimationWrapCuts.fetch_add(1, std::memory_order_relaxed);
continue; // Do not seed the next frame with the reset's apparent velocity.
}
}
// Seed the next frame's matching with a constant-velocity reference, but never from
// a pair the interpolator would reject as a teleport (`<=` so NaN fails too).
if (translation_delta_squared(previousEntry.transform.position,
@@ -1132,7 +1507,13 @@ void finalize_frame_interpolation() noexcept {
currentEntry.transform.position);
currentEntry.hasPrediction = true;
}
if (quadSeedOnly[currentIndex]) {
// Seeded above, drawn held: it moves once the next frame lands on the path.
currentToPrevious[currentIndex] = SIZE_MAX;
++quadsHeld;
}
}
s_diagVertexMotionHeld.store(quadsHeld, std::memory_order_relaxed);
if (eligible || stereo_frame_interpolation_active()) {
// Prepare each matched pair once: every sample of a draw shares the same
@@ -1140,18 +1521,38 @@ void finalize_frame_interpolation() noexcept {
std::vector<PreparedTransformInterpolation> preparedTransforms(s_currentFrameTransforms.size());
std::vector<uint8_t> preparedTransformState(s_currentFrameTransforms.size(), 0);
std::vector<PreparedAffinePair> preparedPairs;
std::vector<PreparedAffinePair> desktopPairs;
const bool separateDesktopPairs = s_rebaseView && eligible;
preparedPairs.reserve(s_pendingUniformInterpolations.size() * 2);
const auto appendPreparedPair = [&](const Mat3x4<float>& previousPosition,
const Mat3x4<float>& currentPosition,
const Mat3x4<float>& previousNormal,
const Mat3x4<float>& currentNormal,
bool indexed) {
bool indexed, bool rebased = true, bool vertexMotion = false) {
const size_t pairOffset = preparedPairs.size();
const auto retainCurrentBasis = [&](const Mat3x4<float>& position) {
if (!vertexMotion) return position;
auto result = currentPosition;
result.m0[3] = position.m0[3];
result.m1[3] = position.m1[3];
result.m2[3] = position.m2[3];
return result;
};
if (separateDesktopPairs) {
const auto originalPosition = rebased ? gfx::stereo_replay::compose_affine(s_previousFromCurrentView, previousPosition)
: previousPosition;
const auto originalNormal = rebased ? gfx::stereo_replay::compose_normal(s_previousFromCurrentView, previousNormal)
: previousNormal;
desktopPairs.push_back(indexed ? prepare_indexed_pair(originalPosition, currentPosition)
: prepare_affine_pair(retainCurrentBasis(originalPosition), currentPosition, true));
desktopPairs.push_back(indexed ? prepare_indexed_pair(originalNormal, currentNormal)
: prepare_affine_pair(vertexMotion ? currentNormal : originalNormal, currentNormal, true));
}
preparedPairs.push_back(indexed ? prepare_indexed_pair(previousPosition, currentPosition)
: prepare_affine_pair(previousPosition, currentPosition));
: prepare_affine_pair(retainCurrentBasis(previousPosition), currentPosition, true));
preparedPairs.push_back(indexed ? prepare_indexed_pair(previousNormal, currentNormal)
: prepare_affine_pair(previousNormal, currentNormal));
: prepare_affine_pair(vertexMotion ? currentNormal : previousNormal, currentNormal, true));
return pairOffset;
};
@@ -1276,6 +1677,7 @@ void finalize_frame_interpolation() noexcept {
// A palette is one deformation unit: interpolating only the resolved slots cracks
// the mesh, so any unresolved slot duplicates the whole current draw.
bool allSlotsValid = true;
bool desktopSlotsValid = true;
for (size_t slot = 0; slot < MaxPnMtx; ++slot) {
if ((current.usedMatrixMask & (1u << slot)) == 0) {
continue;
@@ -1283,6 +1685,7 @@ void finalize_frame_interpolation() noexcept {
const auto& resolved = resolvedSlots[static_cast<size_t>(palette) * MaxPnMtx + slot];
if (resolved.position == nullptr) {
allSlotsValid = false;
desktopSlotsValid = false;
break;
}
const size_t pairOffset =
@@ -1291,22 +1694,58 @@ void finalize_frame_interpolation() noexcept {
prepared.indexedPairOffsets[slot] = pairOffset;
if (!preparedPairs[pairOffset].valid || !preparedPairs[pairOffset + 1].valid) {
allSlotsValid = false;
break;
}
const auto& desktop = separateDesktopPairs ? desktopPairs : preparedPairs;
desktopSlotsValid &= desktop[pairOffset].valid && desktop[pairOffset + 1].valid;
}
prepared.indexedValid = allSlotsValid;
prepared.desktopIndexedValid = desktopSlotsValid;
} else {
const size_t previousTransformIndex = currentToPrevious[task.currentTransformIndex];
if (previousTransformIndex >= s_previousFrameTransforms.size()) {
if (current.holdInCamera && s_rebaseView) {
// As if it sat at the same camera-space place last frame: the sampled
// camera then carries it, like the emitter it follows.
prepared.cameraHold = true;
prepared.nonIndexedPairOffset = appendPreparedPair(
gfx::stereo_replay::compose_affine(s_currentFromPreviousView, current.position), current.position,
current.normal, current.normal, false, true, true);
}
continue;
}
const auto& previous = s_previousFrameTransforms[previousTransformIndex].transform;
prepared.previousProjectionEntry = previousTransformIndex;
prepared.nonIndexedPairOffset = appendPreparedPair(
previous.position, current.position, previous.normal, current.normal, false);
previous.position, current.position, previous.normal, current.normal, false, !previous.viewSpaceVertices,
current.vertexMotion.enabled);
}
}
uint32_t preparedDraws = 0;
uint32_t rejectedDraws = 0;
uint32_t vertexMotionDraws = 0;
for (size_t i = 0; i < preparedTransforms.size(); ++i) {
if (preparedTransformState[i] == 0)
continue;
const auto& prepared = preparedTransforms[i];
if (prepared.previousProjectionEntry == kNoPreparedPair)
continue;
const bool valid = s_currentFrameTransforms[i].transform.indexedMatrices
? prepared.indexedValid
: prepared.nonIndexedPairOffset != kNoPreparedPair &&
preparedPairs[prepared.nonIndexedPairOffset].valid &&
preparedPairs[prepared.nonIndexedPairOffset + 1].valid;
if (valid)
++preparedDraws;
else
++rejectedDraws;
if (valid && s_currentFrameTransforms[i].transform.vertexMotion.enabled)
++vertexMotionDraws;
}
s_diagPreparedDraws.store(preparedDraws, std::memory_order_relaxed);
s_diagRejectedDraws.store(rejectedDraws, std::memory_order_relaxed);
s_diagVertexMotionDraws.store(vertexMotionDraws, std::memory_order_relaxed);
const auto interpolatePendingUniform = [&](const auto& task) {
if (task.currentTransformIndex >= s_currentFrameTransforms.size()) {
return;
@@ -1315,20 +1754,24 @@ void finalize_frame_interpolation() noexcept {
const auto& current = s_currentFrameTransforms[task.currentTransformIndex].transform;
std::memcpy(task.uniformData, task.sourceUniformData, task.uniformSize);
const auto& prepared = preparedTransforms[task.currentTransformIndex];
if (task.indexedMatrices && !prepared.indexedValid) {
const bool desktopSample = task.numerator != 0;
const auto& pairs = desktopSample && separateDesktopPairs ? desktopPairs : preparedPairs;
if (task.indexedMatrices && !(desktopSample ? prepared.desktopIndexedValid : prepared.indexedValid)) {
return;
}
// The projection comes from whichever previous entry supplied the transforms, which
// for a borrowed palette is a sibling's partner.
// for a borrowed palette is a sibling's partner. A camera hold has no partner.
const size_t previousTransformIndex = prepared.previousProjectionEntry;
if (previousTransformIndex >= s_previousFrameTransforms.size()) {
if (previousTransformIndex >= s_previousFrameTransforms.size() && !prepared.cameraHold) {
return;
}
const auto& previous = s_previousFrameTransforms[previousTransformIndex].transform;
const auto& previousProjection = prepared.cameraHold
? current.projection
: s_previousFrameTransforms[previousTransformIndex].transform.projection;
const float weight =
static_cast<float>(task.numerator) / static_cast<float>(task.denominator);
const auto interpolatedProjection =
interpolate_projection(previous.projection, current.projection, weight);
interpolate_projection(previousProjection, current.projection, weight);
std::memcpy(task.uniformData + task.projectionOffset, &interpolatedProjection,
sizeof(interpolatedProjection));
@@ -1338,8 +1781,10 @@ void finalize_frame_interpolation() noexcept {
}
Mat3x4<float> interpolatedPosition{};
Mat3x4<float> interpolatedNormal{};
evaluate_affine_pair(preparedPairs[pairOffset], weight, interpolatedPosition);
evaluate_affine_pair(preparedPairs[pairOffset + 1], weight, interpolatedNormal);
evaluate_affine_pair(pairs[pairOffset], weight, interpolatedPosition);
evaluate_affine_pair(pairs[pairOffset + 1], weight, interpolatedNormal);
if (current.vertexMotion.enabled && !task.indexedMatrices)
interpolatedPosition = offset_transform_origin(interpolatedPosition, current.vertexMotion.center, -1.f);
std::memcpy(task.uniformData + task.positionOffset + currentIndex * sizeof(Mat3x4<float>),
&interpolatedPosition, sizeof(interpolatedPosition));
std::memcpy(task.uniformData + task.normalOffset + currentIndex * sizeof(Mat3x4<float>),
@@ -1393,6 +1838,11 @@ void get_frame_interpolation_diagnostics(AuroraFrameInterpolationDiagnostics& di
diagnostics.framesReplayUnsafe = s_diagFramesReplayUnsafe.load(std::memory_order_relaxed);
diagnostics.slotReductions = s_diagSlotReductions.load(std::memory_order_relaxed);
diagnostics.lateSealDrops = s_diagLateSealDrops.load(std::memory_order_relaxed);
diagnostics.preparedDraws = s_diagPreparedDraws.load(std::memory_order_relaxed);
diagnostics.rejectedDraws = s_diagRejectedDraws.load(std::memory_order_relaxed);
diagnostics.vertexMotionDraws = s_diagVertexMotionDraws.load(std::memory_order_relaxed);
diagnostics.vertexMotionHeld = s_diagVertexMotionHeld.load(std::memory_order_relaxed);
diagnostics.animationWrapCuts = s_diagAnimationWrapCuts.load(std::memory_order_relaxed);
}
bool has_interpolated_frame() noexcept {
@@ -1463,6 +1913,7 @@ std::array<gfx::Range, MaxInterpolatedFrames> record_interpolation_draw(const Fr
FrameTransformSnapshot snapshot{
.projection = projection,
.usedMatrixMask = usedPnMtxMask,
.vertexMotion = uniformLayout.vertexMotion,
};
if (uniformLayout.indexedMatrices) {
snapshot.indexedMatrices = acquire_indexed_matrices();
@@ -1482,6 +1933,23 @@ std::array<gfx::Range, MaxInterpolatedFrames> record_interpolation_draw(const Fr
const size_t currentMatrix = std::min<size_t>(g_gxState.currentPnMtx, MaxPnMtx - 1);
snapshot.position = g_gxState.pnMtx[currentMatrix].pos;
snapshot.normal = g_gxState.pnMtx[currentMatrix].nrm;
if (uniformLayout.vertexMotion.enabled) {
snapshot.position = offset_transform_origin(snapshot.position, uniformLayout.vertexMotion.center);
const Vec4<float>* rows[] = {&snapshot.position.m0, &snapshot.position.m1, &snapshot.position.m2};
const auto& shape = uniformLayout.vertexShape;
for (size_t row = 0; row < 3; ++row) {
const auto& basis = *rows[row];
snapshot.quadCenter[row] = basis[3];
snapshot.quadEdges[0][row] = basis[0] * shape.edge0[0] + basis[1] * shape.edge0[1] + basis[2] * shape.edge0[2];
snapshot.quadEdges[1][row] = basis[0] * shape.edge1[0] + basis[1] * shape.edge1[1] + basis[2] * shape.edge1[2];
}
} else if (g_gxState.vtxDesc[GX_VA_POS] == GX_DIRECT) {
const Vec4<float>* rows[] = {&snapshot.position.m0, &snapshot.position.m1, &snapshot.position.m2};
snapshot.viewSpaceVertices = true;
for (size_t row = 0; row < 3; ++row)
for (size_t col = 0; col < 4; ++col)
snapshot.viewSpaceVertices &= (*rows[row])[col] == (row == col ? 1.f : 0.f);
}
}
const size_t currentTransformIndex = s_currentFrameTransforms.size();
@@ -1492,14 +1960,20 @@ std::array<gfx::Range, MaxInterpolatedFrames> record_interpolation_draw(const Fr
s_currentTransformIndices[identity.combined].push_back(currentTransformIndex);
const HashType stableIdentity = stable_identity(identity);
s_currentStableTransformIndices[stableIdentity].push_back(currentTransformIndex);
if (identity.geometry != 0)
s_currentGeometryTransformIndices[geometry_identity(identity)].push_back(currentTransformIndex);
std::array<gfx::Range, MaxInterpolatedFrames> interpolatedRanges{};
++s_perspectiveCandidates;
const auto exactPrevious = s_previousTransformIndices.find(identity.combined);
const auto stablePrevious = s_previousStableTransformIndices.find(stableIdentity);
const auto geometryPrevious = identity.geometry != 0
? s_previousGeometryTransformIndices.find(geometry_identity(identity))
: s_previousGeometryTransformIndices.end();
const bool hasPreviousPartner =
(exactPrevious != s_previousTransformIndices.end() && !exactPrevious->second.empty()) ||
(stablePrevious != s_previousStableTransformIndices.end() && !stablePrevious->second.empty());
(stablePrevious != s_previousStableTransformIndices.end() && !stablePrevious->second.empty()) ||
(geometryPrevious != s_previousGeometryTransformIndices.end() && !geometryPrevious->second.empty());
if (hasPreviousPartner) {
++s_perspectiveMatchable;
}
+40 -2
View File
@@ -11,8 +11,8 @@
namespace aurora::gx {
constexpr uint32_t MaxInterpolatedFrames = 3;
// Identifies a draw across frames. `combined` includes the geometry signature and is exact, while
// `pipeline`/`texture` is the material-only fallback for meshes whose vertex data changes.
// Identifies a draw across frames. Match exact `combined` first, then `pipeline`/`geometry`
// across texture animation, then `pipeline`/`texture` for meshes whose vertex data changes.
struct FrameInterpolationDrawIdentity {
HashType combined = 0;
HashType pipeline = 0;
@@ -20,8 +20,35 @@ struct FrameInterpolationDrawIdentity {
// Hash of the per-vertex PNMTXIDX stream. Matrix values animate, but this topology decides which
// absolute palette slot each vertex reads, so it must match across a pair.
HashType matrixTopology = 0;
// Exact geometry without its texture binding. Texture-pattern animations may
// swap images while the same mesh still needs continuous camera/object motion.
// Zero means unavailable; do not use it as a wildcard geometry match.
HashType geometry = 0;
};
// CPU-authored particle quads move inside their vertex stream, often with an
// identity position matrix. Track their center while retaining the current
// shape, UVs and colour; no renderer thread needs access to guest particles.
struct DrawVertexMotion {
std::array<float, 3> center{};
bool enabled = false;
};
// Matching-only shape of such a quad: the two edges leaving its first corner.
// Emitters draw many look-alike quads, and a speed line moves further per
// frame than the gap to its neighbours; its size and orientation still tell
// them apart. Replay never needs it, so it stays out of UniformReplayLayout.
struct DrawVertexShape {
std::array<float, 3> edge0{}, edge1{};
};
inline Mat3x4<float> offset_transform_origin(Mat3x4<float> matrix,
const std::array<float, 3>& center, float sign = 1.f) noexcept {
for (auto* row : {&matrix.m0, &matrix.m1, &matrix.m2})
(*row)[3] += sign * ((*row)[0] * center[0] + (*row)[1] * center[1] + (*row)[2] * center[2]);
return matrix;
}
// Where the transforms live inside a draw's staged uniform block. Offsets are
// relative to the start of the mapped range.
struct InterpolatedUniformLayout {
@@ -33,6 +60,8 @@ struct InterpolatedUniformLayout {
// Slot the live matrix occupies; a compacted position region holds it at 0.
size_t currentMatrix = 0;
bool indexedMatrices = false;
DrawVertexMotion vertexMotion{};
DrawVertexShape vertexShape{};
};
namespace detail {
@@ -61,6 +90,10 @@ void report_producer_paced(bool paced) noexcept;
void begin_frame_interpolation() noexcept;
void finalize_frame_interpolation() noexcept;
// Before seal, re-express previous VR endpoints in the current recorded camera.
// Both null disables the operation. begin_frame_interpolation clears it.
void set_frame_interpolation_view_rebase(const Mat3x4<float>* currentFromPrevious,
const Mat3x4<float>* previousFromCurrent) noexcept;
// Fills the observability snapshot behind aurora_get_frame_interpolation_diagnostics.
void get_frame_interpolation_diagnostics(AuroraFrameInterpolationDiagnostics& diagnostics) noexcept;
bool has_interpolated_frame() noexcept;
@@ -87,6 +120,11 @@ bool interpolate_transform(const Mat3x4<float>& previous, const Mat3x4<float>& c
float weight, Mat3x4<float>& output) noexcept;
bool interpolate_transform_midpoint(const Mat3x4<float>& previous, const Mat3x4<float>& current,
Mat3x4<float>& output) noexcept;
// Matched rigid draws can spin more than 90 degrees per guest frame (kart tires), and
// a draw matrix may shear (Lakitu's sway tilts his whole body). Camera/seat anchors
// keep the conservative rotation and rigidity guards above.
bool interpolate_draw_transform(const Mat3x4<float>& previous, const Mat3x4<float>& current,
float weight, Mat3x4<float>& output) noexcept;
// Matrix palettes are already-composed skinning transforms, so interpolating their coefficients
// keeps shared boundaries intact. Ordinary one-matrix draws keep the rigid TRS path above.
bool interpolate_indexed_transform(const Mat3x4<float>& previous,
+5 -1
View File
@@ -582,7 +582,8 @@ static Mat4x4<float> effective_projection() noexcept {
UniformRanges build_uniform(const ShaderInfo& info, u32 vtxStart, const BindGroupRanges& ranges,
const FrameInterpolationDrawIdentity& drawIdentity, bool perspective,
uint16_t usedPnMtxMask) noexcept {
uint16_t usedPnMtxMask, DrawVertexMotion vertexMotion,
const DrawVertexShape& vertexShape) noexcept {
ZoneScoped;
auto [buf, range] = gfx::map_uniform(info.uniformSize);
@@ -793,6 +794,7 @@ UniformRanges build_uniform(const ShaderInfo& info, u32 vtxStart, const BindGrou
.normalMatrixCount = layout.nrmCount,
.perspective = perspective,
.indexedMatrices = info.indexAttr.test(GX_VA_PNMTXIDX),
.vertexMotion = vertexMotion,
.nativeEfbEffect = nativeEfbEffect,
.compositeDepthCopy = nativeEfbEffect ? compositeDepthCopy : nullptr,
};
@@ -818,6 +820,8 @@ UniformRanges build_uniform(const ShaderInfo& info, u32 vtxStart, const BindGrou
// A compacted position region holds the current matrix at slot 0.
.currentMatrix = layout.absolutePosRegion ? std::min<size_t>(g_gxState.currentPnMtx, MaxPnMtx - 1) : 0,
.indexedMatrices = info.indexAttr.test(GX_VA_PNMTXIDX),
.vertexMotion = vertexMotion,
.vertexShape = vertexShape,
},
&previousUniform);
g_gxState.stateDirty = false;
+3 -1
View File
@@ -14,6 +14,7 @@ struct UniformReplayLayout {
uint8_t normalMatrixCount = 0;
bool perspective = false;
bool indexedMatrices = false;
DrawVertexMotion vertexMotion{};
// A 2D draw compositing the framebuffer back over itself: bloom, blur and the
// rest of the native post-processing chain. It belongs to the rendered image,
// not to the game's 2D layer, so it must stay where the game aimed it.
@@ -38,6 +39,7 @@ ShaderInfo build_shader_info(const ShaderConfig& config) noexcept;
Light prepare_shader_light(Light light) noexcept;
UniformRanges build_uniform(const ShaderInfo& info, uint32_t vtxStart, const BindGroupRanges& ranges,
const FrameInterpolationDrawIdentity& drawIdentity, bool perspective,
uint16_t usedPnMtxMask = 1) noexcept;
uint16_t usedPnMtxMask = 1, DrawVertexMotion vertexMotion = {},
const DrawVertexShape& vertexShape = {}) noexcept;
u8 color_channel(GXChannelID id) noexcept;
}; // namespace aurora::gx
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include "gx/frame_interpolation.hpp"
#include "gfx/stereo_replay.hpp"
#include <cstdint>
#include <cstring>
namespace aurora::stereo {
inline bool inverse_rigid_view(const Mat3x4<float>& view, Mat3x4<float>& inverse) noexcept {
const Vec4<float>* rows[] = {&view.m0, &view.m1, &view.m2};
for (unsigned i = 0; i < 3; ++i) {
for (unsigned j = 0; j < 4; ++j) {
// Hosts may build Aurora with finite-math-only.
uint32_t bits;
std::memcpy(&bits, reinterpret_cast<const uint8_t*>(rows[i]) + j * sizeof(float), sizeof(bits));
if ((bits & 0x7f800000u) == 0x7f800000u) return false;
}
for (unsigned j = 0; j < 3; ++j) {
float dot = 0;
for (unsigned k = 0; k < 3; ++k) dot += (*rows[i])[k] * (*rows[j])[k];
if (std::abs(dot - (i == j ? 1.f : 0.f)) > 0.002f) return false;
}
}
const float determinant = view.m0[0] * (view.m1[1] * view.m2[2] - view.m1[2] * view.m2[1]) -
view.m0[1] * (view.m1[0] * view.m2[2] - view.m1[2] * view.m2[0]) +
view.m0[2] * (view.m1[0] * view.m2[1] - view.m1[1] * view.m2[0]);
if (determinant < 0.99f) return false;
Vec4<float>* out[] = {&inverse.m0, &inverse.m1, &inverse.m2};
for (unsigned i = 0; i < 3; ++i) {
(*out[i])[3] = 0;
for (unsigned j = 0; j < 3; ++j) {
(*out[i])[j] = (*rows[j])[i];
(*out[i])[3] -= (*rows[j])[i] * (*rows[j])[3];
}
}
return true;
}
// Keep both object endpoints in the current recorded camera's coordinates.
// Sample the actual camera pose separately, so held vertices, unmatched draws
// and rejected object animations still follow smooth game-camera movement.
struct SceneCameraMotion {
Mat3x4<float> currentFromPrevious{};
Mat3x4<float> previousFromCurrent{};
Mat3x4<float> worldFromCurrentScene{};
Mat3x4<float> previousPose{}, currentPose{};
bool active = false;
bool prepare(const Mat3x4<float>& previousView, const Mat3x4<float>& currentView,
const Mat3x4<float>& previousAnchor, const Mat3x4<float>& currentAnchor) noexcept {
active = false;
Mat3x4<float> previousCameraPose{}, sample{};
if (!inverse_rigid_view(previousView, previousCameraPose) ||
!inverse_rigid_view(currentView, worldFromCurrentScene) ||
!gx::interpolate_transform(previousCameraPose, worldFromCurrentScene, 0.5f, sample) ||
!inverse_rigid_view(gfx::stereo_replay::compose_affine(previousAnchor, previousView), previousPose) ||
!inverse_rigid_view(gfx::stereo_replay::compose_affine(currentAnchor, currentView), currentPose) ||
!gx::interpolate_transform(previousPose, currentPose, 0.5f, sample)) return false;
currentFromPrevious = gfx::stereo_replay::compose_affine(currentView, previousCameraPose);
previousFromCurrent = gfx::stereo_replay::compose_affine(previousView, worldFromCurrentScene);
active = true;
return true;
}
bool sample(float weight, Mat3x4<float>& anchorFromCurrentScene) const noexcept {
Mat3x4<float> pose{}, view{};
if (!active || !gx::interpolate_transform(previousPose, currentPose, weight, pose) ||
!inverse_rigid_view(pose, view)) return false;
anchorFromCurrentScene = gfx::stereo_replay::compose_affine(view, worldFromCurrentScene);
return true;
}
};
} // namespace aurora::stereo
+89 -7
View File
@@ -4,14 +4,96 @@
#include <cstdint>
namespace aurora::stereo {
// The current scene belongs to the VI presentation boundary. Delaying scene
// motion by one guest interval lets every display sample fall between known
// endpoints; head tracking is applied afterwards at its predicted display time.
inline float interpolation_weight(uint64_t displayTime, uint64_t boundary, uint64_t interval) noexcept {
if (displayTime == 0 || boundary == 0 || interval == 0)
// Scene playback uses the producer's cadence on a local monotonic clock. The
// compositor may predict head poses several frames ahead; sampling the two known
// scene endpoints at that future time would clamp every image to the newest one.
// Anchor when history starts. If production later arrives earlier on that grid
// (e.g. after shader warm-up), advance the origin enough to reach the new pair.
// Never move playback backwards to follow a late seal or the prediction horizon.
class ScenePlaybackClock {
public:
void begin_scene(uint64_t boundary, uint64_t now, bool continuous) noexcept {
if (!continuous || originBoundary_ == 0 || now < originTime_ || boundary <= lastBoundary_ ||
sample_time(now) < boundary) {
originBoundary_ = boundary;
originTime_ = now;
}
lastBoundary_ = boundary;
}
uint64_t sample_time(uint64_t now) const noexcept {
if (originBoundary_ == 0 || now < originTime_)
return 0;
return originBoundary_ + (now - originTime_);
}
private:
uint64_t originBoundary_ = 0;
uint64_t originTime_ = 0;
uint64_t lastBoundary_ = 0;
};
// The current scene belongs to boundary, and playback blends from the preceding
// scene for one guest interval. Head tracking is applied afterwards, undelayed.
inline float interpolation_weight(uint64_t sampleTime, uint64_t boundary, uint64_t interval) noexcept {
if (sampleTime == 0 || boundary == 0 || interval == 0)
return 1.0f;
if (displayTime <= boundary)
if (sampleTime <= boundary)
return 0.0f;
return static_cast<float>(std::min(static_cast<double>(displayTime - boundary) / static_cast<double>(interval), 1.0));
return static_cast<float>(std::min(static_cast<double>(sampleTime - boundary) / static_cast<double>(interval), 1.0));
}
// Worker-owned window. A fresh eye pair can still repeat the same game-scene
// time, or move it backwards when an overdue scene is replaced. Neither is
// visible in the compositor's submission FPS. Discontinuities have no comparable
// scene time, so they break the sequence rather than counting as backwards motion.
struct MotionSamples {
uint32_t samples = 0;
uint32_t blended = 0;
uint32_t atPrevious = 0;
uint32_t atCurrent = 0;
uint32_t discontinuous = 0;
uint32_t repeated = 0;
uint32_t backwards = 0;
uint64_t lastSceneTime = 0;
uint64_t minStep = UINT64_MAX;
uint64_t maxStep = 0;
void record(uint64_t displayTime, uint64_t boundary, uint64_t interval,
bool continuous, float weight) noexcept {
++samples;
if (!continuous || displayTime == 0 || boundary < interval || interval == 0) {
++discontinuous;
lastSceneTime = 0;
return;
}
if (weight <= 0.0f)
++atPrevious;
else if (weight >= 1.0f)
++atCurrent;
else
++blended;
const uint64_t sceneTime = boundary - interval +
static_cast<uint64_t>(static_cast<double>(interval) * weight);
if (lastSceneTime != 0) {
if (sceneTime >= lastSceneTime) {
const auto step = sceneTime - lastSceneTime;
minStep = std::min(minStep, step);
maxStep = std::max(maxStep, step);
}
// Float interpolation weights can differ by a few nanoseconds at a boundary.
if (sceneTime + 1000 < lastSceneTime)
++backwards;
else if (sceneTime <= lastSceneTime + 1000)
++repeated;
}
lastSceneTime = sceneTime;
}
void clear_window() noexcept {
const auto last = lastSceneTime;
*this = {};
lastSceneTime = last;
}
};
} // namespace aurora::stereo
+723
View File
@@ -7,6 +7,7 @@
#include "gfx/texture.hpp"
#include "gx/shader_info.hpp"
#include "gx/pipeline.hpp"
#include "scene_camera.hpp"
#include "__gx.h"
#include <algorithm>
@@ -242,6 +243,631 @@ TEST_F(GXFifoTest, VrKeepsMatchedEndpointsWithDesktopInterpolationOff) {
EXPECT_EQ(build(40, 200, true).previous.size, 0u); // Readback split invalidates replay.
}
TEST_F(GXFifoTest, VrDiagnosticsExposeDistantCameraTurnRejectionDespiteMatchedIdentity) {
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
gxState().currentPnMtx = 0;
const auto build = [&](float yaw) {
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
const float c = std::cos(yaw), s = std::sin(yaw);
gxState().pnMtx[0].nrm = {{c, 0, s, 0}, {0, 1, 0, 0}, {-s, 0, c, 0}};
for (unsigned i = 0; i < 2; ++i) {
const float distance = i == 0 ? 1000.0f : 100000.0f;
// Two stationary objects, seen from one camera rotating by two degrees.
gxState().pnMtx[0].pos = {{c, 0, s, -s * distance}, {0, 1, 0, 0}, {-s, 0, c, -c * distance}};
aurora::gx::build_uniform(info, 0, {}, {100u + i, 100u + i, 7}, true);
}
aurora::gx::finalize_frame_interpolation();
};
build(0);
build(2.0f * 3.14159265f / 180.0f);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.candidates, 2u);
EXPECT_EQ(diagnostics.matches, 2u);
EXPECT_EQ(diagnostics.preparedDraws, 1u);
EXPECT_EQ(diagnostics.rejectedDraws, 1u);
EXPECT_EQ(diagnostics.framesSealed, 2u); // VR-only frames must count too.
}
TEST_F(GXFifoTest, VrRetainsFastSpinningWheelEndpoints) {
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
gxState().currentPnMtx = 0;
const auto build = [&](float angle, float x) {
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
const float c = std::cos(angle), s = std::sin(angle);
gxState().pnMtx[0].pos = {{c, -s, 0, x}, {s, c, 0, 0}, {0, 0, 1, -50}};
gxState().pnMtx[0].nrm = {{c, -s, 0, 0}, {s, c, 0, 0}, {0, 0, 1, 0}};
const auto result = aurora::gx::build_uniform(info, 0, {}, {100, 42, 7}, true);
aurora::gx::finalize_frame_interpolation();
return result;
};
EXPECT_EQ(build(0, 10).previous.size, 0u);
const auto uniforms = build(2.0f * 3.14159265f / 3.0f, 30);
ASSERT_NE(uniforms.previous.size, 0u);
const auto& bytes = aurora::gfx::testing::uniform_allocation(uniforms.previous.offset);
aurora::Mat3x4<float> previousPosition{}, previousNormal{};
std::memcpy(static_cast<void*>(&previousPosition), bytes.data() + uniforms.replayLayout.positionOffset,
sizeof(previousPosition));
std::memcpy(static_cast<void*>(&previousNormal), bytes.data() + uniforms.replayLayout.normalOffset,
sizeof(previousNormal));
// Rejecting a >90-degree wheel spin used to copy the current position here too,
// leaving the entire wheel at 60 Hz even as the kart body moved smoothly.
EXPECT_FLOAT_EQ(previousPosition.m0.w(), 10);
EXPECT_FLOAT_EQ(previousPosition.m0.x(), 1);
EXPECT_FLOAT_EQ(previousNormal.m0.x(), 1);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.matches, 1u);
EXPECT_EQ(diagnostics.preparedDraws, 1u);
EXPECT_EQ(diagnostics.rejectedDraws, 0u);
}
TEST_F(GXFifoTest, VrTextureAnimationRetainsSpatialHistoryWithStrictMeshIdentity) {
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
gxState().currentPnMtx = 0;
gxState().pnMtx[0].pos = {{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, -50}};
gxState().pnMtx[0].nrm = {{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
const auto begin = [&] {
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
};
const auto draw = [&](float x, aurora::HashType texture, aurora::HashType geometry = 123,
aurora::HashType topology = 0, aurora::HashType pipeline = 42) {
gxState().pnMtx[0].pos.m0[3] = x;
const aurora::gx::FrameInterpolationDrawIdentity identity{
.combined = texture + 1000, .pipeline = pipeline, .texture = texture,
.matrixTopology = topology, .geometry = geometry};
return aurora::gx::build_uniform(info, 0, {}, identity, true);
};
const auto previousX = [&](const auto& uniforms) {
const auto& bytes = aurora::gfx::testing::uniform_allocation(uniforms.previous.offset);
float x;
std::memcpy(&x, bytes.data() + uniforms.replayLayout.positionOffset + 3 * sizeof(float), sizeof(x));
return x;
};
begin();
draw(10, 1);
draw(100, 2);
draw(200, 3);
aurora::gx::finalize_frame_interpolation();
begin();
// The transparent sort reverses two animated instances. An unchanged exact
// match must also keep priority over the texture-independent mesh fallback.
const auto right = draw(110, 4);
const auto left = draw(20, 4);
const auto unchanged = draw(210, 3);
aurora::gx::finalize_frame_interpolation();
ASSERT_NE(right.previous.size, 0u);
ASSERT_NE(left.previous.size, 0u);
ASSERT_NE(unchanged.previous.size, 0u);
EXPECT_FLOAT_EQ(previousX(right), 100);
EXPECT_FLOAT_EQ(previousX(left), 10);
EXPECT_FLOAT_EQ(previousX(unchanged), 200);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.preparedDraws, 3u);
EXPECT_EQ(diagnostics.rejectedDraws, 0u);
begin();
EXPECT_EQ(draw(120, 5, 456).previous.size, 0u); // Different mesh.
EXPECT_EQ(draw(120, 6, 0).previous.size, 0u); // Missing mesh identity.
EXPECT_EQ(draw(120, 7, 123, 9).previous.size, 0u); // Different palette topology.
EXPECT_EQ(draw(120, 8, 123, 0, 43).previous.size, 0u); // Different pipeline.
aurora::gx::finalize_frame_interpolation();
}
TEST(FrameInterpolationContract, FastRigidSpinsKeepAngularSpeedAndDiscontinuityGuards) {
const aurora::Mat3x4<float> previous{{1, 0, 0, 10}, {0, 1, 0, 0}, {0, 0, 1, 0}};
// Include rotations whose quaternion representation needs hemisphere correction.
for (float degrees : {120.0f, 170.0f, -120.0f, 240.0f}) {
const float angle = degrees * 3.14159265f / 180.0f;
const float c = std::cos(angle), s = std::sin(angle);
const aurora::Mat3x4<float> current{{c, -s, 0, 30}, {s, c, 0, 0}, {0, 0, 1, 0}};
aurora::Mat3x4<float> output{};
EXPECT_FALSE(aurora::gx::interpolate_transform(previous, current, 0.5f, output)); // Anchor cut guard.
for (float weight : {0.0f, 1.0f / 3, 2.0f / 3, 1.0f}) {
ASSERT_TRUE(aurora::gx::interpolate_draw_transform(previous, current, weight, output));
const float expectedAngle = (degrees > 180 ? degrees - 360 : degrees) * 3.14159265f / 180.0f * weight;
EXPECT_NEAR(output.m0.x(), std::cos(expectedAngle), 1e-5f);
EXPECT_NEAR(output.m1.x(), std::sin(expectedAngle), 1e-5f);
EXPECT_NEAR(output.m0.w(), 10 + 20 * weight, 1e-5f);
EXPECT_NEAR(output.m0.x() * output.m0.x() + output.m1.x() * output.m1.x(), 1, 1e-5f);
}
}
aurora::Mat3x4<float> invalid = previous, output{};
invalid.m0[3] = 2010;
EXPECT_FALSE(aurora::gx::interpolate_draw_transform(previous, invalid, 0.5f, output));
EXPECT_FLOAT_EQ(output.m0.w(), 2010);
invalid = previous;
invalid.m0[0] = 0; // Singular axis.
EXPECT_FALSE(aurora::gx::interpolate_draw_transform(previous, invalid, 0.5f, output));
invalid = previous;
invalid.m0[3] = std::numeric_limits<float>::quiet_NaN();
EXPECT_FALSE(aurora::gx::interpolate_draw_transform(previous, invalid, 0.5f, output));
}
TEST(FrameInterpolationContract, ShearedRigidDrawsMoveWithTheSkinnedMeshOnTheirBone) {
// Lakitu::Movement::UpdateScale sways MKW's Lakitu by tilting his Y axis: the model
// matrix gets a Y column of (A cos p, 1, A sin p). His goggles are rigid on the face
// bone and his head is skinned to that same bone. The rigid path rejected the shear,
// holding the goggles at the game frame while the head moved on, so they sank in.
const auto swaying = [](float amplitude, float phase, float lift) {
const float c = std::cos(0.698f), s = std::sin(0.698f); // the face bone's roll
const float x = amplitude * std::cos(phase), z = amplitude * std::sin(phase);
// [[1, x, 0], [0, 1, 0], [0, z, 1]] * Rz, then placed in front of the camera.
return aurora::Mat3x4<float>{{c + x * s, -s + x * c, 0, 10}, {s, c, 0, 50 + lift}, {z * s, z * c, 1, -300}};
};
const auto previous = swaying(0.25f, 0.3f, 0), current = swaying(0.3f, 0.5f, 3);
const auto apply = [](const aurora::Mat3x4<float>& matrix, const std::array<float, 3>& point) {
const aurora::Vec4<float>* rows[] = {&matrix.m0, &matrix.m1, &matrix.m2};
std::array<float, 3> result{};
for (size_t row = 0; row < 3; ++row)
result[row] = (*rows[row])[0] * point[0] + (*rows[row])[1] * point[1] + (*rows[row])[2] * point[2] +
(*rows[row])[3];
return result;
};
const std::array<float, 3> goggleCorner{30, -20, 10};
for (float weight : {0.0f, 0.25f, 0.5f, 0.75f, 1.0f}) {
aurora::Mat3x4<float> head{}, goggles{};
ASSERT_TRUE(aurora::gx::interpolate_indexed_transform(previous, current, weight, head));
ASSERT_TRUE(aurora::gx::interpolate_draw_transform(previous, current, weight, goggles));
const auto onHead = apply(head, goggleCorner), onGoggles = apply(goggles, goggleCorner);
for (size_t axis = 0; axis < 3; ++axis) EXPECT_NEAR(onGoggles[axis], onHead[axis], 0.05f) << weight;
}
// Camera and seat anchors are rigid; a sheared one still counts as a cut.
aurora::Mat3x4<float> anchor{};
EXPECT_FALSE(aurora::gx::interpolate_transform(previous, current, 0.5f, anchor));
}
TEST_F(GXFifoTest, VrCameraRebaseKeepsFarInstancesAndHeldParticlesContinuous) {
using aurora::Mat3x4;
using aurora::gfx::stereo_replay::compose_affine;
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(120); // Desktop and VR enabled together.
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
gxState().currentPnMtx = 0;
const Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
const float angle = 0.08f, c = std::cos(angle), s = std::sin(angle);
const Mat3x4<float> currentView{{c, 0, s, 0}, {0, 1, 0, 0}, {-s, 0, c, 0}};
aurora::stereo::SceneCameraMotion motion;
ASSERT_TRUE(motion.prepare(identity, currentView, identity, identity));
const auto draw = [&](const Mat3x4<float>& view, float x, bool particle = false) {
gxState().vtxDesc[GX_VA_POS] = particle ? GX_DIRECT : GX_INDEX16;
gxState().pnMtx[0].pos = particle ? identity : compose_affine(view, {{1, 0, 0, x}, {0, 1, 0, 0}, {0, 0, 1, -100000}});
if (particle) gxState().pnMtx[0].pos.m0[1] = -0.0f;
gxState().pnMtx[0].nrm = particle ? identity : view;
return aurora::gx::build_uniform(info, 0, {}, particle ? aurora::gx::FrameInterpolationDrawIdentity{200, 20, 1}
: aurora::gx::FrameInterpolationDrawIdentity{100, 10, 1}, true);
};
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
draw(identity, -2000);
draw(identity, 2000);
draw(identity, 0, true);
aurora::gx::finalize_frame_interpolation();
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
// All far instances move over the old 1500-unit gate just from camera yaw.
// Submission order also changes, and the right instance moves 30 world units.
const auto right = draw(currentView, 2030);
const auto left = draw(currentView, -2000);
const auto particle = draw(currentView, 0, true);
aurora::gx::set_frame_interpolation_view_rebase(&motion.currentFromPrevious, &motion.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
const auto read = [&](const auto& uniform, aurora::gfx::Range range) {
Mat3x4<float> matrix;
const auto& bytes = aurora::gfx::testing::uniform_allocation(range.offset);
std::memcpy(static_cast<void*>(&matrix), bytes.data() + uniform.replayLayout.positionOffset, sizeof(matrix));
return matrix;
};
ASSERT_NE(right.previous.size, 0u);
ASSERT_NE(left.previous.size, 0u);
ASSERT_NE(particle.previous.size, 0u);
const auto previousRight = read(right, right.previous);
const auto expectedRight = compose_affine(currentView, {{1, 0, 0, 2000}, {0, 1, 0, 0}, {0, 0, 1, -100000}});
EXPECT_NEAR(previousRight.m0.w(), expectedRight.m0.w(), 0.01f);
EXPECT_NEAR(previousRight.m2.w(), expectedRight.m2.w(), 0.01f);
EXPECT_NEAR(read(left, left.previous).m0.w(), read(left, left.current).m0.w(), 0.01f);
EXPECT_FLOAT_EQ(read(particle, particle.previous).m0.x(), 1);
EXPECT_FLOAT_EQ(read(particle, particle.previous).m0.w(), 0); // No camera applied twice to baked vertices.
// Desktop retains its original guarded camera-space behavior.
ASSERT_NE(right.interpolated[0].size, 0u);
EXPECT_NEAR(read(right, right.interpolated[0]).m0.w(), read(right, right.current).m0.w(), 0.01f);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.matches, 3u);
EXPECT_EQ(diagnostics.preparedDraws, 3u);
EXPECT_EQ(diagnostics.rejectedDraws, 0u);
}
TEST_F(GXFifoTest, VrParticleCentersFollowMotionAcrossSortChangesAndCameraTurns) {
using aurora::Mat3x4;
using aurora::gx::offset_transform_origin;
using aurora::gfx::stereo_replay::compose_affine;
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(120);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
const Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
const float angle = 0.08f, c = std::cos(angle), s = std::sin(angle);
const Mat3x4<float> view{{c, 0, s, 0}, {0, 1, 0, 0}, {-s, 0, c, 0}};
aurora::stereo::SceneCameraMotion motion;
ASSERT_TRUE(motion.prepare(identity, view, identity, identity));
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = identity;
const auto center = [&](const Mat3x4<float>& camera, float x) {
const auto matrix = offset_transform_origin(camera, {x, 0, -5000});
return std::array<float, 3>{matrix.m0.w(), matrix.m1.w(), matrix.m2.w()};
};
const auto record = [&](const Mat3x4<float>& camera, float x, uint64_t geometry) {
return aurora::gx::build_uniform(info, 0, {}, {geometry, 7, 9, 0, geometry}, true, 1,
{center(camera, x), true});
};
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
record(identity, -600, 101);
record(identity, 600, 102);
aurora::gx::finalize_frame_interpolation();
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
// Vertex bytes change and transparent submission order reverses. Both draw
// matrices are identity, so matrix-only matching cannot identify the centers.
const auto right = record(view, 630, 201);
const auto left = record(view, -570, 202);
const auto newborn = record(view, 10000, 203);
aurora::gx::set_frame_interpolation_view_rebase(&motion.currentFromPrevious, &motion.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
const auto readCenter = [&](const auto& uniform, aurora::gfx::Range range) {
Mat3x4<float> matrix;
const auto& bytes = aurora::gfx::testing::uniform_allocation(range.offset);
std::memcpy(&matrix, bytes.data() + uniform.replayLayout.positionOffset, sizeof(matrix));
EXPECT_FLOAT_EQ(matrix.m0.x(), 1); // Keep the current billboard's orientation.
EXPECT_FLOAT_EQ(matrix.m2.x(), 0);
return offset_transform_origin(matrix, uniform.replayLayout.vertexMotion.center);
};
for (const auto& [uniform, x] : {std::pair{right, 600.f}, std::pair{left, -600.f}}) {
ASSERT_NE(uniform.previous.size, 0u);
const auto previous = readCenter(uniform, uniform.previous);
EXPECT_NEAR(previous.m0.w(), center(view, x)[0], 0.002f);
EXPECT_NEAR(previous.m2.w(), center(view, x)[2], 0.002f);
const auto current = readCenter(uniform, uniform.current);
Mat3x4<float> half;
ASSERT_TRUE(aurora::gx::interpolate_draw_transform(previous, current, 0.5f, half));
EXPECT_NEAR(half.m0.w(), center(view, x + 15)[0], 0.002f);
// Simultaneous desktop interpolation uses its original camera endpoint.
const auto desktop = readCenter(uniform, uniform.interpolated[0]);
EXPECT_NEAR(desktop.m0.w(), (center(identity, x)[0] + center(view, x + 30)[0]) * 0.5f, 0.002f);
}
EXPECT_NEAR(readCenter(newborn, newborn.previous).m0.w(), center(view, 10000)[0], 0.002f);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.vertexMotionDraws, 2u);
}
namespace {
using Vec3f = std::array<float, 3>;
// One CPU-built particle quad the way nw4r::ef submits it: camera-space corners under
// an identity position matrix, all quads of an emitter sharing pipeline and texture.
aurora::gx::UniformRanges record_particle_quad(const aurora::gx::ShaderInfo& info, uint64_t geometry,
const Vec3f& center, const Vec3f& edge0, const Vec3f& edge1) {
return aurora::gx::build_uniform(info, 0, {}, {geometry, 7, 9, 0, geometry}, true, 1, {center, true},
{edge0, edge1});
}
// The centre a staged uniform draws the quad at.
Vec3f drawn_center(const aurora::gx::UniformRanges& uniform, aurora::gfx::Range range) {
aurora::Mat3x4<float> matrix;
const auto& bytes = aurora::gfx::testing::uniform_allocation(range.offset);
std::memcpy(static_cast<void*>(&matrix), bytes.data() + uniform.replayLayout.positionOffset, sizeof(matrix));
matrix = aurora::gx::offset_transform_origin(matrix, uniform.replayLayout.vertexMotion.center);
return {matrix.m0.w(), matrix.m1.w(), matrix.m2.w()};
}
Vec3f transform_point(const aurora::Mat3x4<float>& matrix, const Vec3f& point) {
return {matrix.m0[0] * point[0] + matrix.m0[1] * point[1] + matrix.m0[2] * point[2] + matrix.m0[3],
matrix.m1[0] * point[0] + matrix.m1[1] * point[1] + matrix.m1[2] * point[2] + matrix.m1[3],
matrix.m2[0] * point[0] + matrix.m2[1] * point[1] + matrix.m2[2] * point[2] + matrix.m2[3]};
}
bool same_point(const Vec3f& a, const Vec3f& b, float tolerance = 0.05f) {
return std::abs(a[0] - b[0]) <= tolerance && std::abs(a[1] - b[1]) <= tolerance &&
std::abs(a[2] - b[2]) <= tolerance;
}
struct ParticleInterpolationReset {
~ParticleInterpolationReset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
};
} // namespace
TEST_F(GXFifoTest, VrParticleQuadsPairByShapeWhereNearestCentresSwap) {
ParticleInterpolationReset reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
const aurora::Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().currentPnMtx = 0;
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = identity;
// The camera flies 100 units forward and carries two streaks, each moving 60 along its
// own length. The one lying across the view ends nearer the other's old centre, so
// pairing centres alone swaps them and both sweep sideways.
const aurora::Mat3x4<float> forward{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 100}};
aurora::stereo::SceneCameraMotion motion;
ASSERT_TRUE(motion.prepare(identity, forward, identity, identity));
const Vec3f across{300, 0, 0}, upright{0, 300, 0}, thinX{12, 0, 0}, thinY{0, 12, 0};
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
record_particle_quad(info, 1, {-20, 0, -500}, across, thinY);
record_particle_quad(info, 2, {20, 0, -500}, upright, thinX);
aurora::gx::finalize_frame_interpolation();
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
const auto lying = record_particle_quad(info, 3, {40, 0, -500}, across, thinY);
const auto standing = record_particle_quad(info, 4, {20, 60, -500}, upright, thinX);
aurora::gx::set_frame_interpolation_view_rebase(&motion.currentFromPrevious, &motion.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
ASSERT_NE(lying.previous.size, 0u);
ASSERT_NE(standing.previous.size, 0u);
// Each starts from its own old place, as the new camera position sees it.
EXPECT_TRUE(same_point(drawn_center(lying, lying.previous), {-20, 0, -400}));
EXPECT_TRUE(same_point(drawn_center(standing, standing.previous), {20, 0, -400}));
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.vertexMotionDraws, 2u);
EXPECT_EQ(diagnostics.vertexMotionHeld, 0u);
}
TEST_F(GXFifoTest, VrParticleQuadBornElsewhereDoesNotSweepFromOneThatDied) {
ParticleInterpolationReset reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
const aurora::Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().currentPnMtx = 0;
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = identity;
const aurora::Mat3x4<float> forward{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 100}};
aurora::stereo::SceneCameraMotion motion;
ASSERT_TRUE(motion.prepare(identity, forward, identity, identity));
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
record_particle_quad(info, 1, {-100, 0, -500}, {300, 0, 0}, {0, 12, 0});
aurora::gx::finalize_frame_interpolation();
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
// That streak died; another one starts elsewhere. One quad on each side of the frame
// is no evidence that they are the same particle.
const auto newborn = record_particle_quad(info, 2, {100, 0, -500}, {0, 300, 0}, {12, 0, 0});
aurora::gx::set_frame_interpolation_view_rebase(&motion.currentFromPrevious, &motion.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
ASSERT_NE(newborn.previous.size, 0u);
EXPECT_TRUE(same_point(drawn_center(newborn, newborn.previous), {100, 0, -500}));
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.vertexMotionDraws, 0u);
EXPECT_EQ(diagnostics.vertexMotionHeld, 1u);
}
TEST_F(GXFifoTest, VrSpeedLineEmitterKeepsEachStreakOnItsOwnPath) {
// A deterministic stand-in for the boost speed lines (rk_koukasen in RKRace.breff):
// 12x300 streaks, each drawn as two crossed quads, two born per frame on a 90-unit
// ring, six-frame life, about 50 units per frame outwards and back, all carried by a
// camera that flies 100 units per frame while turning. Pairing nearest centres swapped
// about half of them and swept every newborn in from a streak that had just died.
ParticleInterpolationReset reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
const aurora::Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().currentPnMtx = 0;
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = identity;
struct Streak {
uint32_t id, birth;
Vec3f origin, velocity;
};
struct Quad {
uint32_t streak, side;
Vec3f center, edge0, edge1;
};
uint32_t seed = 0x5eed1234u;
const auto random = [&seed] {
seed = seed * 1664525u + 1013904223u;
return static_cast<float>(seed >> 8) * (1.0f / 16777216.0f);
};
const auto quads_of = [](const Streak& streak, uint32_t frame) {
const float age = static_cast<float>(frame - streak.birth);
const auto& v = streak.velocity;
const float speed = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
const Vec3f axis{v[0] / speed, v[1] / speed, v[2] / speed};
// Two sides across the axis: the crossed planes of one streak.
const float across = std::sqrt(axis[0] * axis[0] + axis[2] * axis[2]);
const Vec3f side0{axis[2] / across, 0, -axis[0] / across};
const Vec3f side1{axis[1] * side0[2] - axis[2] * side0[1], axis[2] * side0[0] - axis[0] * side0[2],
axis[0] * side0[1] - axis[1] * side0[0]};
Vec3f center{};
for (size_t i = 0; i < 3; ++i) center[i] = streak.origin[i] + v[i] * age - axis[i] * 150;
std::array<Quad, 2> quads{};
for (uint32_t side = 0; side < 2; ++side) {
const auto& s = side == 0 ? side0 : side1;
quads[side] = {streak.id, side, center, {axis[0] * 300, axis[1] * 300, axis[2] * 300},
{s[0] * 12, s[1] * 12, s[2] * 12}};
}
return quads;
};
std::vector<Streak> streaks;
std::vector<Quad> previousQuads;
aurora::Mat3x4<float> previousView = identity;
Vec3f cameraPosition{};
float yaw = 0;
uint32_t nextId = 0, survivors = 0, moved = 0, held = 0, wrong = 0, newborns = 0, newbornsMoved = 0;
for (uint32_t frame = 0; frame < 90; ++frame) {
yaw += 0.01f;
const float c = std::cos(yaw), s = std::sin(yaw);
cameraPosition = {cameraPosition[0] - s * 100, 0, cameraPosition[2] - c * 100};
// Camera looks down -Z, rotated by yaw about +Y; the view is its inverse.
const aurora::Mat3x4<float> view{
{c, 0, -s, -(c * cameraPosition[0] - s * cameraPosition[2])},
{0, 1, 0, 0},
{s, 0, c, -(s * cameraPosition[0] + c * cameraPosition[2])}};
aurora::stereo::SceneCameraMotion motion;
const bool rebase = frame != 0 && motion.prepare(previousView, view, identity, identity);
ASSERT_TRUE(frame == 0 || rebase);
std::erase_if(streaks, [frame](const Streak& streak) { return frame - streak.birth >= 6; });
for (int born = 0; born < 2; ++born) {
const float angle = random() * 6.2831853f;
const float speed = 1.0f + (random() - 0.5f) * 0.46f;
streaks.push_back({nextId++, frame, {90 * std::cos(angle), 90 * std::sin(angle), -300},
{40 * speed * std::cos(angle), 40 * speed * std::sin(angle) + 10 * speed, 30 * speed}});
}
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
std::vector<Quad> quads;
std::vector<aurora::gx::UniformRanges> uniforms;
for (const auto& streak : streaks) {
for (const auto& quad : quads_of(streak, frame)) {
quads.push_back(quad);
uniforms.push_back(record_particle_quad(info, 1000 + quads.size() + frame * 100, quad.center,
quad.edge0, quad.edge1));
}
}
if (rebase)
aurora::gx::set_frame_interpolation_view_rebase(&motion.currentFromPrevious, &motion.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
if (rebase) {
for (size_t index = 0; index < quads.size(); ++index) {
const auto& quad = quads[index];
if (uniforms[index].previous.size == 0) continue;
const auto start = drawn_center(uniforms[index], uniforms[index].previous);
const auto before = std::find_if(previousQuads.begin(), previousQuads.end(), [&](const Quad& old) {
return old.streak == quad.streak && old.side == quad.side;
});
const bool heldHere = same_point(start, quad.center) ||
same_point(start, transform_point(motion.currentFromPrevious, quad.center));
if (before == previousQuads.end()) {
++newborns;
newbornsMoved += !heldHere;
continue;
}
++survivors;
if (heldHere) {
++held;
} else if (same_point(start, transform_point(motion.currentFromPrevious, before->center))) {
++moved;
} else {
++wrong;
}
}
}
previousQuads = std::move(quads);
previousView = view;
}
ASSERT_GT(survivors, 800u);
// Most streak quads keep moving along their own path...
EXPECT_GT(moved, survivors / 2);
// ...and almost none borrows another streak's (it was about half, plus every newborn).
EXPECT_LE(wrong * 50, survivors);
EXPECT_LE(newbornsMoved * 20, newborns);
std::printf("speed lines: %u survivor quads: %u moved, %u held, %u wrong; %u of %u newborn quads moved\n",
survivors, moved, held, wrong, newbornsMoved, newborns);
}
TEST_F(GXFifoTest, VrCyclicRigidMotionDoesNotBlendBackwardsAcrossReset) {
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
const aurora::Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().vtxDesc[GX_VA_POS] = GX_INDEX16;
auto previousView = identity;
const auto record = [&](float phase, float yaw) {
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
const aurora::Mat3x4<float> view{{std::cos(yaw), 0, std::sin(yaw), 0}, {0, 1, 0, 0},
{-std::sin(yaw), 0, std::cos(yaw), 0}};
aurora::stereo::SceneCameraMotion camera;
EXPECT_TRUE(camera.prepare(previousView, view, identity, identity));
gxState().pnMtx[0].pos = aurora::gx::offset_transform_origin(view, {phase, 0, -5000});
gxState().pnMtx[0].nrm = view;
const auto result = aurora::gx::build_uniform(info, 0, {}, {11, 1, 2, 0, 11}, true);
aurora::gx::set_frame_interpolation_view_rebase(&camera.currentFromPrevious, &camera.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
previousView = view;
return result;
};
record(16, 0);
record(17, 0.02f);
record(18, 0.04f);
record(19, 0.06f);
AuroraFrameInterpolationDiagnostics before{}, after{};
aurora::gx::get_frame_interpolation_diagnostics(before);
const auto resetFrame = record(0, 0.08f);
aurora::gx::get_frame_interpolation_diagnostics(after);
EXPECT_EQ(after.animationWrapCuts, before.animationWrapCuts + 1);
const auto& current = aurora::gfx::testing::uniform_allocation(resetFrame.current.offset);
const auto& previous = aurora::gfx::testing::uniform_allocation(resetFrame.previous.offset);
EXPECT_EQ(current, previous); // Hold the new phase instead of reverse sweeping.
record(1, 0.10f);
record(0, 0.12f); // A normal same-speed direction change must still interpolate.
aurora::gx::get_frame_interpolation_diagnostics(after);
EXPECT_EQ(after.animationWrapCuts, before.animationWrapCuts + 1);
EXPECT_EQ(after.preparedDraws, 1u);
}
TEST(FrameInterpolationContract, RequiresStablePerspectiveDrawSequence) {
const auto resetInterpolation = [] {
aurora::gx::set_frame_interpolation_fps(0);
@@ -2269,6 +2895,103 @@ TEST_F(GXFifoTest, MergedDrawOffsetsCachedTopologyWithoutJoiningPrimitives) {
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{3, 4, 5}));
}
TEST_F(GXFifoTest, VrDirectParticleQuadsRetainSeparateCentersInFifoAndRawDraws) {
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::use_real_vertex_format_helpers(true);
aurora::gfx::testing::use_draw_command_tracking(true);
gxState().projType = GX_PERSPECTIVE;
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().vtxFmts[GX_VTXFMT0].attrs[GX_VA_POS] = {GX_POS_XYZ, GX_F32, 0};
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = {{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().stateDirty = true;
const auto makeVertices = [](float x) {
std::vector<uint8_t> vertices;
for (const auto& point : {std::array{x - 5, -5.f, -200.f}, std::array{x + 5, -5.f, -200.f},
std::array{x + 5, 5.f, -200.f}, std::array{x - 5, 5.f, -200.f}}) {
for (float component : point) {
uint32_t bits;
std::memcpy(&bits, &component, sizeof(bits));
for (int shift : {24, 16, 8, 0}) vertices.push_back(static_cast<uint8_t>(bits >> shift));
}
}
return vertices;
};
std::vector<uint8_t> commands;
for (float x : {20.f, 100.f}) {
commands.insert(commands.end(), {static_cast<uint8_t>(GX_QUADS), 0, 4});
const auto vertices = makeVertices(x);
commands.insert(commands.end(), vertices.begin(), vertices.end());
}
decode_fifo(commands);
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u);
auto* draw = aurora::gfx::get_last_draw_command<aurora::gx::DrawData>();
ASSERT_NE(draw, nullptr);
EXPECT_TRUE(draw->uniformReplayLayout.vertexMotion.enabled);
EXPECT_EQ(draw->uniformReplayLayout.vertexMotion.center, (std::array{100.f, 0.f, -200.f}));
const auto raw = makeVertices(300);
ASSERT_TRUE(aurora::gx::fifo::submit_raw_draw(GX_QUADS, GX_VTXFMT0, raw.data(), 4, raw.size()));
draw = aurora::gfx::get_last_draw_command<aurora::gx::DrawData>();
ASSERT_NE(draw, nullptr);
EXPECT_TRUE(draw->uniformReplayLayout.vertexMotion.enabled);
EXPECT_EQ(draw->uniformReplayLayout.vertexMotion.center, (std::array{300.f, 0.f, -200.f}));
// Turning VR interpolation off restores ordinary batching.
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
gxState().stateDirty = true;
decode_fifo(commands);
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 1u);
}
TEST_F(GXFifoTest, InterpolationOffRecordsNothingForParticlesOrMatching) {
// The Quest ships with VR interpolation off. Those frames must not pay for any
// of it: no draw is recorded, no particle quad is tracked, nothing is matched.
struct Reset {
~Reset() { aurora::gx::begin_frame_interpolation(); }
} reset;
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::use_real_vertex_format_helpers(true);
aurora::gfx::testing::use_draw_command_tracking(true);
gxState().projType = GX_PERSPECTIVE;
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().vtxFmts[GX_VTXFMT0].attrs[GX_VA_POS] = {GX_POS_XYZ, GX_F32, 0};
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = {{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().stateDirty = true;
std::vector<uint8_t> commands;
for (float x : {20.f, 100.f, 180.f}) {
commands.insert(commands.end(), {static_cast<uint8_t>(GX_QUADS), 0, 4});
for (const auto& point : {std::array{x - 5, -5.f, -200.f}, std::array{x + 5, -5.f, -200.f},
std::array{x + 5, 5.f, -200.f}, std::array{x - 5, 5.f, -200.f}}) {
for (float component : point) {
uint32_t bits;
std::memcpy(&bits, &component, sizeof(bits));
for (int shift : {24, 16, 8, 0}) commands.push_back(static_cast<uint8_t>(bits >> shift));
}
}
}
decode_fifo(commands);
aurora::gx::finalize_frame_interpolation();
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 2u); // Ordinary batching.
auto* draw = aurora::gfx::get_last_draw_command<aurora::gx::DrawData>();
ASSERT_NE(draw, nullptr);
EXPECT_FALSE(draw->uniformReplayLayout.vertexMotion.enabled);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.candidates, 0u);
EXPECT_EQ(diagnostics.matches, 0u);
EXPECT_EQ(diagnostics.preparedDraws, 0u);
EXPECT_EQ(diagnostics.vertexMotionDraws, 0u);
EXPECT_EQ(diagnostics.vertexMotionHeld, 0u);
EXPECT_FALSE(aurora::gx::has_interpolated_frame());
}
TEST_F(GXFifoTest, OrthographicQuadRecordsScreenRectForVrFurniture) {
// MKW draws its split-screen partition with the partition_line layout: a
// one-pixel picture pane sampling a pattern texture in a full-display
+49 -12
View File
@@ -1,4 +1,4 @@
// Optional GPU smoke test: a 60 Hz GX producer with an independent 90 Hz
// Optional GPU smoke test: a 60 Hz GX producer with an independent headset-rate
// compositor. Exercises the real frame worker, eye replay and submission sink.
#include <aurora/aurora.h>
#include <aurora/gfx.h>
@@ -9,9 +9,11 @@
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <thread>
@@ -56,6 +58,7 @@ static bool stop = false;
static uint64_t completed = 0;
static std::atomic_uint32_t submitted{0};
static uint64_t wakeLateness = 0, submitTime = 0, skippedTicks = 0, compositorFrames = 0;
static uint64_t headsetHz = 90, predictionLeadNanos = 40'000'000;
static bool Provide(uint32_t, AuroraStereoFrame* output, void*) {
std::lock_guard lock(packetMutex);
@@ -73,7 +76,7 @@ static void Submitted(const aurora::stereo::SinkFrame& frame, void*) noexcept {
packetCv.notify_all();
}
static void Log(AuroraLogLevel level, const char* module, const char* message, unsigned int length) {
if (level >= LOG_WARNING)
if (level >= LOG_WARNING || std::strstr(message, "[vr-motion]") != nullptr)
std::fprintf(stderr, "%s: %.*s\n", module, static_cast<int>(length), message);
}
@@ -81,8 +84,12 @@ int main(int argc, char** argv) {
// Extra distinct draws expose CPU uniform/replay costs that a single triangle
// cannot exercise. Keep the eye targets small to isolate that regression.
const unsigned drawCount = argc > 1 ? std::max(1, std::atoi(argv[1])) : 1;
const bool movingCamera = argc > 6 && std::atoi(argv[6]) != 0;
const bool particles = argc > 7 && std::atoi(argv[7]) != 0;
const int mode = argc < 3 ? 1 : std::clamp(std::atoi(argv[2]), 0, 2);
const bool indexed = argc > 3 && std::atoi(argv[3]) != 0;
const bool indexed = !particles && argc > 3 && std::atoi(argv[3]) != 0;
headsetHz = argc > 4 ? std::clamp(std::atoi(argv[4]), 60, 120) : 90;
predictionLeadNanos = argc > 5 ? std::clamp(std::atoi(argv[5]), 0, 100) * 1'000'000ull : 40'000'000;
std::filesystem::create_directories("stereo-smoke-cache");
AuroraConfig config{};
config.appName = "Aurora VR interpolation smoke";
@@ -95,11 +102,12 @@ int main(int argc, char** argv) {
config.windowPosX = -30000;
config.windowPosY = -30000;
config.logCallback = Log;
config.logLevel = LOG_WARNING;
config.logLevel = LOG_INFO;
config.xrInterop = true;
aurora_initialize(argc, argv, &config);
aurora_set_frame_interpolation_fps(0);
aurora_set_stereo_frame_interpolation(mode != 0);
aurora_set_stereo_motion_logging(true);
aurora_set_stereo_frame_provider(Provide, nullptr);
aurora::stereo::set_sink(Encode, Submitted, nullptr);
@@ -107,7 +115,7 @@ int main(int argc, char** argv) {
const auto start = Clock::now();
uint64_t slot = 1;
for (uint64_t token = 1;; ++token) {
const auto deadline = start + std::chrono::nanoseconds(slot * 1'000'000'000 / 90);
const auto deadline = start + std::chrono::nanoseconds(slot * 1'000'000'000 / headsetHz);
WaitUntil(deadline);
const auto woke = Clock::now();
wakeLateness +=
@@ -119,11 +127,11 @@ int main(int argc, char** argv) {
packet = {};
packet.frameToken = token;
packet.contentTag = 42;
// Wake to render the next display tick, as xrWaitFrame does, rather
// than announcing an image whose display deadline has already passed.
// A runtime can predict multiple frames ahead. This must not exhaust
// scene history while the producer and compositor maintain their rates.
packet.displayTimeNanos =
std::chrono::duration_cast<std::chrono::nanoseconds>(deadline.time_since_epoch()).count() +
1'000'000'000 / 90;
predictionLeadNanos;
for (auto& eye : packet.eyes) {
eye.width = 160;
eye.height = 120;
@@ -146,7 +154,7 @@ int main(int argc, char** argv) {
const auto elapsed = std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now() - start).count();
submitTime += std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now() - woke).count();
++compositorFrames;
const auto nextSlot = std::max(slot + 1, static_cast<uint64_t>(elapsed) * 90 / 1'000'000'000);
const auto nextSlot = std::max(slot + 1, static_cast<uint64_t>(elapsed) * headsetHz / 1'000'000'000);
skippedTicks += nextSlot - slot - 1;
slot = nextSlot;
}
@@ -178,6 +186,19 @@ int main(int argc, char** argv) {
std::chrono::duration_cast<std::chrono::nanoseconds>(boundary.time_since_epoch()).count(), 16'666'667);
Mtx44 projection{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, -1, -1}, {0, 0, -1, 0}};
Mtx transform{{1, 0, 0, static_cast<float>(frame % 60) * 0.01f}, {0, 1, 0, 0}, {0, 0, 1, -3}};
const float yaw = movingCamera ? std::sin(static_cast<float>(frame) * 0.08f) * 0.5f : 0;
Mtx sceneView{{std::cos(yaw), 0, std::sin(yaw), 0}, {0, 1, 0, 0}, {-std::sin(yaw), 0, std::cos(yaw), 0}};
if (movingCamera) {
transform[2][3] = -100000;
aurora_set_stereo_scene_view(&sceneView[0][0]);
}
const auto applyView = [&](Mtx viewed) {
for (unsigned row = 0; row < 3; ++row)
for (unsigned col = 0; col < 4; ++col)
viewed[row][col] = (col == 3 ? sceneView[row][3] : 0.f) +
sceneView[row][0] * transform[0][col] + sceneView[row][1] * transform[1][col] +
sceneView[row][2] * transform[2][col];
};
GXSetProjection(projection, GX_PERSPECTIVE);
GXSetCurrentMtx(GX_PNMTX0);
GXSetViewport(0, 0, 160, 120, 0, 1);
@@ -195,12 +216,28 @@ int main(int argc, char** argv) {
// Keep palette triangles small to limit fill cost during uniform stress.
const float extent = indexed ? 0.02f : 1.0f;
if (indexed) {
Mtx viewed;
applyView(viewed);
for (unsigned matrix = 0; matrix < 10; ++matrix)
GXLoadPosMtxImm(transform, matrix * 3);
GXLoadPosMtxImm(viewed, matrix * 3);
}
for (unsigned draw = 0; draw < drawCount; ++draw) {
transform[1][3] = static_cast<float>(draw % 20) * 0.01f;
GXLoadPosMtxImm(transform, GX_PNMTX0);
Mtx viewed;
applyView(viewed);
if (particles) {
// CPU-authored billboard centers, identity XF, current vertex shapes.
// Exercise the actual FIFO decoder, seal and late eye-uniform path.
Mtx identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
if (draw == 0) GXLoadPosMtxImm(identity, GX_PNMTX0);
GXBegin(GX_QUADS, GX_VTXFMT0, 4);
for (const auto& corner : {std::pair{-0.01f, -0.01f}, std::pair{0.01f, -0.01f},
std::pair{0.01f, 0.01f}, std::pair{-0.01f, 0.01f}})
GXPosition3f32(viewed[0][3] + corner.first, viewed[1][3] + corner.second, viewed[2][3]);
GXEnd();
continue;
}
GXLoadPosMtxImm(viewed, GX_PNMTX0);
GXBegin(GX_TRIANGLES, GX_VTXFMT0, indexed ? 30 : 3);
for (unsigned matrix = 0; matrix < (indexed ? 10u : 1u); ++matrix) {
if (indexed)
@@ -248,6 +285,6 @@ int main(int argc, char** argv) {
measuredSubmissions, elapsed, fps);
aurora_shutdown();
// More headset submissions must not come at the expense of simulation speed.
const double target = mode == 2 ? 75 : mode == 1 ? 90 : 60;
const double target = mode == 2 ? (headsetHz + 60) / 2.0 : mode == 1 ? headsetHz : 60;
return 240 / elapsed > 55 && fps > target - 5 && fps < target + 5 ? 0 : 1;
}
@@ -1,6 +1,60 @@
#include "stereo_interpolation.hpp"
#include "scene_camera.hpp"
#include <gtest/gtest.h>
#include <cmath>
#include <limits>
TEST(StereoInterpolation, SeparatesCameraFromHeldGeometryAndFirstPersonAnchor) {
using aurora::Mat3x4;
using aurora::gfx::stereo_replay::compose_affine;
const Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
const float angle = 0.08f;
const float c = std::cos(angle), s = std::sin(angle);
const Mat3x4<float> currentView{{c, 0, s, 0}, {0, 1, 0, 0}, {-s, 0, c, 0}};
const Mat3x4<float> object{{1, 0, 0, 1000}, {0, 1, 0, 0}, {0, 0, 1, -100000}};
const auto currentObject = compose_affine(currentView, object);
aurora::stereo::SceneCameraMotion motion;
for (bool firstPerson : {false, true}) {
// A moving seat is distinct from the game's chase camera. Both must be
// sampled once, and only once, even for geometry with no usable history.
auto previousAnchor = identity, currentAnchor = identity;
if (firstPerson) {
previousAnchor.m1[3] = 200;
currentAnchor.m1[3] = 230;
}
ASSERT_TRUE(motion.prepare(identity, currentView, previousAnchor, currentAnchor));
for (float weight : {0.f, 1.f / 3, 2.f / 3, 1.f}) {
Mat3x4<float> sampledAnchor{}, expectedPose{}, expectedView{};
ASSERT_TRUE(motion.sample(weight, sampledAnchor));
ASSERT_TRUE(aurora::gx::interpolate_transform(motion.previousPose, motion.currentPose, weight, expectedPose));
ASSERT_TRUE(aurora::stereo::inverse_rigid_view(expectedPose, expectedView));
const auto expected = compose_affine(expectedView, object);
// Identical math covers held particle vertices baked into current view
// space and an unmatched/rejected billboard using its current matrix.
const auto actual = compose_affine(sampledAnchor, currentObject);
EXPECT_NEAR(actual.m0.w(), expected.m0.w(), 0.02f);
EXPECT_NEAR(actual.m1.w(), expected.m1.w(), 0.02f);
EXPECT_NEAR(actual.m2.w(), expected.m2.w(), 0.02f);
}
}
}
TEST(StereoInterpolation, CameraCutsAndMalformedViewsDisableCameraSeparation) {
const aurora::Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
aurora::stereo::SceneCameraMotion motion;
ASSERT_TRUE(motion.prepare(identity, identity, identity, identity));
auto cut = identity;
cut.m0[3] = 2000;
EXPECT_FALSE(motion.prepare(identity, cut, identity, identity));
EXPECT_FALSE(motion.active);
cut = identity;
cut.m0[0] = 2;
EXPECT_FALSE(motion.prepare(identity, cut, identity, identity));
cut.m0[0] = std::numeric_limits<float>::quiet_NaN();
EXPECT_FALSE(motion.prepare(identity, cut, identity, identity));
cut = {{-1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, -1, 0}};
EXPECT_FALSE(motion.prepare(identity, cut, identity, identity));
}
TEST(StereoInterpolation, ContinuousMotionAcross60HzScenesAtHeadsetRates) {
constexpr uint64_t interval = 16'666'667;
@@ -30,3 +84,100 @@ TEST(StereoInterpolation, MissingTimingAndStallsDoNotExtrapolate) {
EXPECT_FLOAT_EQ(interpolation_weight(105, 100, 10), 0.5);
EXPECT_FLOAT_EQ(interpolation_weight(500, 100, 10), 1);
}
TEST(StereoInterpolation, PlaybackCadenceDoesNotFollowFutureHeadPrediction) {
constexpr uint64_t start = 1'000'000'000, interval = 16'666'667;
for (uint64_t hz : {72u, 90u, 120u}) {
// The producer's desktop presentation boundary can be ahead of, or behind,
// its actual seal. Neither offset belongs in headset scene playback.
for (int64_t scheduleOffset : {-5'000'000, 0, 7'000'000}) {
const uint64_t origin = start + scheduleOffset;
aurora::stereo::ScenePlaybackClock clock;
clock.begin_scene(origin, start, false);
uint64_t sealedScene = 0;
double previousPosition = 0;
uint32_t oldClampedSamples = 0;
for (uint64_t sample = 1; sample <= hz; ++sample) {
const uint64_t now = start + sample * 1'000'000'000 / hz;
const uint64_t scene = (now - start) / interval;
const uint64_t boundary = origin + scene * interval;
if (scene != sealedScene) {
// Seal jitter must not re-phase the entire playback clock.
clock.begin_scene(boundary, start + scene * interval + (scene % 3) * 100'000, true);
sealedScene = scene;
}
// The captured Virtual Desktop session predicted 37-65 ms ahead.
const uint64_t displayTime = now + (37 + sample % 29) * 1'000'000;
oldClampedSamples += aurora::stereo::interpolation_weight(displayTime, boundary, interval) == 1.0f;
const float weight = aurora::stereo::interpolation_weight(clock.sample_time(now), boundary, interval);
const double position = static_cast<double>(scene) + weight;
if (sample > 1)
EXPECT_NEAR(position - previousPosition, 1'000'000'000.0 / hz / interval, 1e-5);
previousPosition = position;
}
EXPECT_EQ(oldClampedSamples, hz); // Regression reproduces the old all-current result.
}
}
}
TEST(StereoInterpolation, PlaybackReanchorsOnCutsButNeverExtrapolatesAStalledScene) {
aurora::stereo::ScenePlaybackClock clock;
EXPECT_EQ(clock.sample_time(100), 0u);
clock.begin_scene(1000, 100, false);
EXPECT_EQ(clock.sample_time(105), 1005u);
clock.begin_scene(1010, 113, true);
EXPECT_EQ(clock.sample_time(115), 1015u); // Seal latency is not a new clock origin.
EXPECT_FLOAT_EQ(aurora::stereo::interpolation_weight(clock.sample_time(500), 1010, 10), 1);
clock.begin_scene(2000, 500, false); // Stall recovery / scene change.
EXPECT_EQ(clock.sample_time(505), 2005u);
clock.begin_scene(100, 510, true); // Reset producer schedule.
EXPECT_EQ(clock.sample_time(515), 105u);
clock.begin_scene(110, 20, true); // Reset host clock.
EXPECT_EQ(clock.sample_time(25), 115u);
EXPECT_EQ(clock.sample_time(19), 0u);
}
TEST(StereoInterpolation, EarlierProductionAfterWarmupDoesNotClampToPreviousEndpoints) {
aurora::stereo::ScenePlaybackClock clock;
clock.begin_scene(1000, 100, false);
clock.begin_scene(1010, 106, true); // Producer sheds four time units of warm-up latency.
EXPECT_EQ(clock.sample_time(106), 1010u);
EXPECT_EQ(clock.sample_time(111), 1015u);
clock.begin_scene(1020, 118, true); // A subsequent late seal must not move the clock back.
EXPECT_EQ(clock.sample_time(118), 1022u);
EXPECT_FLOAT_EQ(aurora::stereo::interpolation_weight(clock.sample_time(118), 1020, 10), 0.2f);
}
TEST(StereoInterpolation, MotionDiagnosticsDistinguishCadenceFromSubmissionCount) {
aurora::stereo::MotionSamples samples;
constexpr uint64_t boundary = 1'000'000'000, interval = 16'666'667;
const auto record = [&](uint64_t display, uint64_t sceneBoundary, bool continuous = true) {
samples.record(display, sceneBoundary, interval, continuous,
aurora::stereo::interpolation_weight(display, sceneBoundary, interval));
};
record(boundary, boundary);
record(boundary + interval / 2, boundary);
record(boundary + interval, boundary);
// A future display deadline outruns the retained scene; another submission
// cannot advance its motion, even though it can apply a fresh head pose.
record(boundary + 2 * interval, boundary);
record(boundary + 2 * interval, boundary + interval);
EXPECT_EQ(samples.samples, 5u);
EXPECT_EQ(samples.blended, 1u);
EXPECT_EQ(samples.atPrevious, 1u);
EXPECT_EQ(samples.atCurrent, 3u);
EXPECT_EQ(samples.repeated, 1u);
EXPECT_EQ(samples.backwards, 0u);
EXPECT_EQ(samples.minStep, 0u);
EXPECT_EQ(samples.maxStep, interval);
samples.clear_window();
record(boundary + 2 * interval, boundary + interval);
EXPECT_EQ(samples.samples, 1u);
EXPECT_EQ(samples.repeated, 1u); // Preserve cadence across reporting windows.
record(boundary + interval / 2, boundary);
EXPECT_EQ(samples.backwards, 1u);
record(boundary, boundary, false);
record(boundary, boundary);
EXPECT_EQ(samples.discontinuous, 1u);
EXPECT_EQ(samples.backwards, 1u); // A camera cut starts a new sequence.
}
+4
View File
@@ -573,6 +573,10 @@ void MkwVRFirstPersonRecenter() noexcept;
// anchor has been missing long enough to give up holding the last one.
FirstPersonAnchor MkwVRFirstPersonGetAnchor() noexcept;
// Checked guest-thread read of the camera that authored this frame's GX draws.
// Also available with first person off; render workers receive only its copy.
bool MkwVRReadSceneView(Mtx34& view) noexcept;
// Guest-frame inventory snapshot, sampled at the race draw boundary. A
// generation change invalidates any item retained by a prior race.
HeldItem MkwVRFirstPersonGetHeldItem() noexcept;
+10
View File
@@ -37,6 +37,7 @@
#endif
#include <aurora/aurora.h>
#include <aurora/gfx.h>
// Forward declaration for OSWakeupThread - used to wake threads on VI retrace queue
extern "C" void OSWakeupThread_HLE_801aaaa4(CpuContext* ctx);
@@ -538,6 +539,8 @@ void PaceToRetraceBoundary(Clock::time_point deadline) {
struct SceneAnchorPublication {
std::array<float, 12> anchor{};
bool valid = false;
std::array<float, 12> view{};
bool viewValid = false;
// The cockpit seat's exact world scale for this frame, or zero.
float unitsPerMeter = 0.0f;
};
@@ -550,6 +553,8 @@ SceneAnchorPublication PublishVrSceneAnchor() {
mkw::vr::MkwVRFirstPersonCommit();
const mkw::vr::FirstPersonAnchor anchor = mkw::vr::MkwVRFirstPersonGetAnchor();
SceneAnchorPublication publication;
// Only VR frame interpolation separates the camera, so nothing else pays for the read.
publication.viewValid = aurora_get_stereo_frame_interpolation() && mkw::vr::MkwVRReadSceneView(publication.view);
publication.valid = anchor.valid;
if (anchor.valid) {
std::copy(anchor.anchor_from_scene.begin(), anchor.anchor_from_scene.end(), publication.anchor.begin());
@@ -592,6 +597,8 @@ struct GxPresentRecord {
uint64_t scheduleIntervalNanos = 0;
std::array<float, 12> anchor{};
bool anchorValid = false;
std::array<float, 12> view{};
bool viewValid = false;
float anchorUnitsPerMeter = 0.0f;
AuroraCockpitItem cockpitItem{};
bool reportPaced = false;
@@ -613,6 +620,7 @@ void GxPresent_gx(GxPresentRecord record) {
}
aurora_set_stereo_cockpit_item(&record.cockpitItem);
aurora_set_stereo_local_player_count(record.localPlayerCount);
aurora_set_stereo_scene_view(record.viewValid ? record.view.data() : nullptr);
aurora_end_frame_ex(record.contentTag, record.imguiFrame);
g_auroraFrameActive.store(false, std::memory_order_release);
g_auroraFrameHadWork.store(false, std::memory_order_release);
@@ -695,6 +703,8 @@ void VI_HLE_PresentFrame(bool presentedXfb, bool paceToRetrace) {
const SceneAnchorPublication anchor = PublishVrSceneAnchor();
record.anchor = anchor.anchor;
record.anchorValid = anchor.valid;
record.view = anchor.view;
record.viewValid = anchor.viewValid;
record.anchorUnitsPerMeter = anchor.unitsPerMeter;
if (anchor.valid) {
const auto item = mkw::vr::MkwVRFirstPersonGetHeldItem();
+4
View File
@@ -1422,6 +1422,10 @@ void MkwVRFirstPersonRecenter() noexcept {
g_recenter_requested.store(true, std::memory_order_release);
}
bool MkwVRReadSceneView(Mtx34& view) noexcept {
return ReadSceneViewMatrix(view);
}
void MkwVRFirstPersonUpdate(uint64_t guest_frame_index, uint32_t race_camera_address) noexcept {
std::lock_guard lock(g_mutex);
g_state.held_item = {};
+1
View File
@@ -893,6 +893,7 @@ private:
}
const MkwVRPolicySnapshot policy = MkwVRPolicyGetSnapshot();
aurora_set_stereo_motion_logging(diagnostics::Enabled());
// Diagnostics lift the cap: a presentation flickering between the
// race and the virtual screen is exactly what a report needs to show.
if ((!presentation_logged || policy.presentation != logged_presentation) &&