diff --git a/OPENXR.md b/OPENXR.md index 61a6045..981b422 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -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 diff --git a/aurora-main/include/aurora/aurora.h b/aurora-main/include/aurora/aurora.h index 1c75b71..cb5ee48 100644 --- a/aurora-main/include/aurora/aurora.h +++ b/aurora-main/include/aurora/aurora.h @@ -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 diff --git a/aurora-main/include/aurora/gfx.h b/aurora-main/include/aurora/gfx.h index a35f596..2729428 100644 --- a/aurora-main/include/aurora/gfx.h +++ b/aurora-main/include/aurora/gfx.h @@ -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. diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index 7f8b00f..9a6cdba 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -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 g_frameLogCallback{nullptr}; // deadlines derived from it, so the presenter cannot drift. Zero means present when ready. std::atomic g_presentScheduleBaseNanos{0}; std::atomic 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 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(now - start).count(); + if (seconds < 1.0) + return; + const auto deltaMs = [](uint64_t a, uint64_t b) { + return a >= b ? static_cast(a - b) / 1e6 : -static_cast(b - a) / 1e6; + }; + const uint64_t nowNs = std::chrono::duration_cast(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::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 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 previous, current, result; std::memcpy(&previous, retained.previousAnchor.anchorFromScene.data(), sizeof(previous)); std::memcpy(¤t, 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 previousView, currentView, previousAnchor, currentAnchor; + std::memcpy(static_cast(&previousView), g_retainedStereo.anchor.viewFromWorld.data(), sizeof(previousView)); + std::memcpy(static_cast(¤tView), sceneAnchor.viewFromWorld.data(), sizeof(currentView)); + std::memcpy(static_cast(&previousAnchor), g_retainedStereo.anchor.anchorFromScene.data(), sizeof(previousAnchor)); + std::memcpy(static_cast(¤tAnchor), 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::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(); diff --git a/aurora-main/lib/gfx/common.cpp b/aurora-main/lib/gfx/common.cpp index 17da6a7..9e00361 100644 --- a/aurora-main/lib/gfx/common.cpp +++ b/aurora-main/lib/gfx/common.cpp @@ -2188,10 +2188,19 @@ bool prepare_late_stereo_replay(SealedFrame& frame, wgpu::CommandEncoder& cmd, c Mat3x4 before, current, result; std::memcpy(&before, previous + at, sizeof(before)); std::memcpy(¤t, 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); diff --git a/aurora-main/lib/gx/command_processor.cpp b/aurora-main/lib/gx/command_processor.cpp index 8c71bc8..5b52139 100644 --- a/aurora-main/lib/gx/command_processor.cpp +++ b/aurora-main/lib/gx/command_processor.cpp @@ -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(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(); // 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, 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(vtxCount) | (static_cast(underlying(prim)) << 16) | (static_cast(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; diff --git a/aurora-main/lib/gx/frame_interpolation.cpp b/aurora-main/lib/gx/frame_interpolation.cpp index 3eef429..a7e785a 100644 --- a/aurora-main/lib/gx/frame_interpolation.cpp +++ b/aurora-main/lib/gx/frame_interpolation.cpp @@ -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 position{}; Mat3x4 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 quadCenter{}; + std::array, 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, MaxPnMtx> position{}; std::array, MaxPnMtx> normal{}; @@ -189,10 +208,14 @@ struct PendingUniformInterpolation { std::vector s_previousFrameTransforms; std::vector s_currentFrameTransforms; +Mat3x4 s_currentFromPreviousView{}, s_previousFromCurrentView{}; +bool s_rebaseView = false; std::unordered_map> s_previousTransformIndices; std::unordered_map> s_currentTransformIndices; std::unordered_map> s_previousStableTransformIndices; std::unordered_map> s_currentStableTransformIndices; +std::unordered_map> s_previousGeometryTransformIndices; +std::unordered_map> 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::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& a, const std::array& 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, 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& matrix, std::array, 3>& rotation, - std::array& scale, std::array& translation, - Quaternion& quaternion) noexcept { + std::array& stretch, std::array& translation, + Quaternion& quaternion, float& skew) noexcept { const std::array, 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{rotation[0][column], rotation[1][column], rotation[2][column]}; + const auto column = [&rows](size_t index) noexcept { + return std::array{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 r0{c0[0] / u00, c0[1] / u00, c0[2] / u00}; + const float u01 = dot3(r0, c1); + const std::array 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 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, 3> rotation_from_quaternion(const Quaternion& q struct PreparedAffinePair { Mat3x4 previous{}; Mat3x4 current{}; - std::array previousScale{}; - std::array currentScale{}; + // Upper-triangular stretch from decompose_affine: u00 u01 u02 u11 u12 u22. + std::array previousStretch{}; + std::array currentStretch{}; std::array previousTranslation{}; std::array 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& previous, - const Mat3x4& current) noexcept { + const Mat3x4& current, bool rotatingDraw = false) noexcept { PreparedAffinePair pair{.previous = previous, .current = current}; if (std::memcmp(&previous, ¤t, sizeof(current)) == 0) { pair.identical = true; @@ -443,10 +492,17 @@ PreparedAffinePair prepare_affine_pair(const Mat3x4& previous, std::array, 3> previousRotation{}; std::array, 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& 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 interpolatedScale{}; + std::array u{}; + for (size_t i = 0; i < u.size(); ++i) { + u[i] = pair.previousStretch[i] + (pair.currentStretch[i] - pair.previousStretch[i]) * weight; + } std::array 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*, 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& previous, const Mat3x4< return interpolate_transform(previous, current, 0.5f, output); } +bool interpolate_draw_transform(const Mat3x4& previous, const Mat3x4& current, + float weight, Mat3x4& 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& previous, const Mat3x4& current, float weight, Mat3x4& output) noexcept { @@ -677,6 +743,23 @@ Mat3x4 extrapolate_transform(const Mat3x4& previous, } return predicted; } + +std::array translation_of(const Mat3x4& matrix) noexcept { + return {matrix.m0.w(), matrix.m1.w(), matrix.m2.w()}; +} + +std::array rotate_vector(const Mat3x4& matrix, const std::array& vector) noexcept { + const Vec4* rows[] = {&matrix.m0, &matrix.m1, &matrix.m2}; + std::array 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& a, const std::array& b) noexcept { + const std::array 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* currentFromPrevious, + const Mat3x4* 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), + xxh3_hash_s(&matrices.normal[slot], sizeof(Mat3x4))); + } + } 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(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 world{}, camera{}, predicted{}; + std::array, 2> worldEdges{}, cameraEdges{}; + float size2 = 0.f, step2 = 0.f; + bool tracked = false; + }; + struct QuadCurrent { + std::array center{}; + std::array, 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::infinity(); + float runnerUp = std::numeric_limits::infinity(); + uint32_t index = UINT32_MAX; + }; + static std::vector quadPrevious; + static std::vector quadCurrent; + static std::vector quadCandidates; + static std::vector quadBestForCurrent, quadBestForPrevious; + static std::array, 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 quadSeedOnly; + quadSeedOnly.assign(s_currentFrameTransforms.size(), 0); + + const auto samePlace = [](const std::array& a, const std::array& 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, 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& 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(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* oldRows[] = {&before.m0, &before.m1, &before.m2}; + const Vec4* newRows[] = {&now.m0, &now.m1, &now.m2}; + const Vec4* 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 preparedTransforms(s_currentFrameTransforms.size()); std::vector preparedTransformState(s_currentFrameTransforms.size(), 0); std::vector preparedPairs; + std::vector desktopPairs; + const bool separateDesktopPairs = s_rebaseView && eligible; preparedPairs.reserve(s_pendingUniformInterpolations.size() * 2); const auto appendPreparedPair = [&](const Mat3x4& previousPosition, const Mat3x4& currentPosition, const Mat3x4& previousNormal, const Mat3x4& currentNormal, - bool indexed) { + bool indexed, bool rebased = true, bool vertexMotion = false) { const size_t pairOffset = preparedPairs.size(); + const auto retainCurrentBasis = [&](const Mat3x4& 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(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(task.numerator) / static_cast(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 interpolatedPosition{}; Mat3x4 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), &interpolatedPosition, sizeof(interpolatedPosition)); std::memcpy(task.uniformData + task.normalOffset + currentIndex * sizeof(Mat3x4), @@ -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 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 record_interpolation_draw(const Fr const size_t currentMatrix = std::min(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* 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* 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 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 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; } diff --git a/aurora-main/lib/gx/frame_interpolation.hpp b/aurora-main/lib/gx/frame_interpolation.hpp index 02b100f..cc3c79d 100644 --- a/aurora-main/lib/gx/frame_interpolation.hpp +++ b/aurora-main/lib/gx/frame_interpolation.hpp @@ -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 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 edge0{}, edge1{}; +}; + +inline Mat3x4 offset_transform_origin(Mat3x4 matrix, + const std::array& 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* currentFromPrevious, + const Mat3x4* 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& previous, const Mat3x4& c float weight, Mat3x4& output) noexcept; bool interpolate_transform_midpoint(const Mat3x4& previous, const Mat3x4& current, Mat3x4& 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& previous, const Mat3x4& current, + float weight, Mat3x4& 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& previous, diff --git a/aurora-main/lib/gx/shader_info.cpp b/aurora-main/lib/gx/shader_info.cpp index ecc5c4b..8642ee9 100644 --- a/aurora-main/lib/gx/shader_info.cpp +++ b/aurora-main/lib/gx/shader_info.cpp @@ -582,7 +582,8 @@ static Mat4x4 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(g_gxState.currentPnMtx, MaxPnMtx - 1) : 0, .indexedMatrices = info.indexAttr.test(GX_VA_PNMTXIDX), + .vertexMotion = vertexMotion, + .vertexShape = vertexShape, }, &previousUniform); g_gxState.stateDirty = false; diff --git a/aurora-main/lib/gx/shader_info.hpp b/aurora-main/lib/gx/shader_info.hpp index 1d035a5..04604e7 100644 --- a/aurora-main/lib/gx/shader_info.hpp +++ b/aurora-main/lib/gx/shader_info.hpp @@ -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 diff --git a/aurora-main/lib/scene_camera.hpp b/aurora-main/lib/scene_camera.hpp new file mode 100644 index 0000000..c4271cc --- /dev/null +++ b/aurora-main/lib/scene_camera.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include "gx/frame_interpolation.hpp" +#include "gfx/stereo_replay.hpp" +#include +#include + +namespace aurora::stereo { + +inline bool inverse_rigid_view(const Mat3x4& view, Mat3x4& inverse) noexcept { + const Vec4* 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(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* 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 currentFromPrevious{}; + Mat3x4 previousFromCurrent{}; + Mat3x4 worldFromCurrentScene{}; + Mat3x4 previousPose{}, currentPose{}; + bool active = false; + + bool prepare(const Mat3x4& previousView, const Mat3x4& currentView, + const Mat3x4& previousAnchor, const Mat3x4& currentAnchor) noexcept { + active = false; + Mat3x4 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& anchorFromCurrentScene) const noexcept { + Mat3x4 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 diff --git a/aurora-main/lib/stereo_interpolation.hpp b/aurora-main/lib/stereo_interpolation.hpp index 8a8e90a..b566b66 100644 --- a/aurora-main/lib/stereo_interpolation.hpp +++ b/aurora-main/lib/stereo_interpolation.hpp @@ -4,14 +4,96 @@ #include 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(std::min(static_cast(displayTime - boundary) / static_cast(interval), 1.0)); + return static_cast(std::min(static_cast(sampleTime - boundary) / static_cast(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(static_cast(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 diff --git a/aurora-main/tests/gx_fifo_test.cpp b/aurora-main/tests/gx_fifo_test.cpp index e7fd397..8eb5a08 100644 --- a/aurora-main/tests/gx_fifo_test.cpp +++ b/aurora-main/tests/gx_fifo_test.cpp @@ -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 @@ -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 previousPosition{}, previousNormal{}; + std::memcpy(static_cast(&previousPosition), bytes.data() + uniforms.replayLayout.positionOffset, + sizeof(previousPosition)); + std::memcpy(static_cast(&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 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 current{{c, -s, 0, 30}, {s, c, 0, 0}, {0, 0, 1, 0}}; + aurora::Mat3x4 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 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::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{{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& matrix, const std::array& point) { + const aurora::Vec4* rows[] = {&matrix.m0, &matrix.m1, &matrix.m2}; + std::array 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 goggleCorner{30, -20, 10}; + for (float weight : {0.0f, 0.25f, 0.5f, 0.75f, 1.0f}) { + aurora::Mat3x4 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 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 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 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& 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 matrix; + const auto& bytes = aurora::gfx::testing::uniform_allocation(range.offset); + std::memcpy(static_cast(&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 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 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& camera, float x) { + const auto matrix = offset_transform_origin(camera, {x, 0, -5000}); + return std::array{matrix.m0.w(), matrix.m1.w(), matrix.m2.w()}; + }; + const auto record = [&](const Mat3x4& 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 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 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; + +// 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 matrix; + const auto& bytes = aurora::gfx::testing::uniform_allocation(range.offset); + std::memcpy(static_cast(&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& 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 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 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 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 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 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(seed >> 8) * (1.0f / 16777216.0f); + }; + const auto quads_of = [](const Streak& streak, uint32_t frame) { + const float age = static_cast(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 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 streaks; + std::vector previousQuads; + aurora::Mat3x4 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 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 quads; + std::vector 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 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 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{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 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(bits >> shift)); + } + } + return vertices; + }; + std::vector commands; + for (float x : {20.f, 100.f}) { + commands.insert(commands.end(), {static_cast(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(); + 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(); + 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 commands; + for (float x : {20.f, 100.f, 180.f}) { + commands.insert(commands.end(), {static_cast(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(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(); + 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 diff --git a/aurora-main/tests/stereo_frame_worker_smoke.cpp b/aurora-main/tests/stereo_frame_worker_smoke.cpp index a2d5faf..d39bfd0 100644 --- a/aurora-main/tests/stereo_frame_worker_smoke.cpp +++ b/aurora-main/tests/stereo_frame_worker_smoke.cpp @@ -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 #include @@ -9,9 +9,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -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(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(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(Clock::now() - start).count(); submitTime += std::chrono::duration_cast(Clock::now() - woke).count(); ++compositorFrames; - const auto nextSlot = std::max(slot + 1, static_cast(elapsed) * 90 / 1'000'000'000); + const auto nextSlot = std::max(slot + 1, static_cast(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(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(frame % 60) * 0.01f}, {0, 1, 0, 0}, {0, 0, 1, -3}}; + const float yaw = movingCamera ? std::sin(static_cast(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(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; } diff --git a/aurora-main/tests/stereo_interpolation_test.cpp b/aurora-main/tests/stereo_interpolation_test.cpp index 9127342..960185e 100644 --- a/aurora-main/tests/stereo_interpolation_test.cpp +++ b/aurora-main/tests/stereo_interpolation_test.cpp @@ -1,6 +1,60 @@ #include "stereo_interpolation.hpp" +#include "scene_camera.hpp" #include #include +#include + +TEST(StereoInterpolation, SeparatesCameraFromHeldGeometryAndFirstPersonAnchor) { + using aurora::Mat3x4; + using aurora::gfx::stereo_replay::compose_affine; + const Mat3x4 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 currentView{{c, 0, s, 0}, {0, 1, 0, 0}, {-s, 0, c, 0}}; + const Mat3x4 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 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 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::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(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. +} diff --git a/runtime/include/vr/mkw_vr_first_person.h b/runtime/include/vr/mkw_vr_first_person.h index a3976a2..aff2e77 100644 --- a/runtime/include/vr/mkw_vr_first_person.h +++ b/runtime/include/vr/mkw_vr_first_person.h @@ -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; diff --git a/runtime/src/hle/vi.cpp b/runtime/src/hle/vi.cpp index 38d975d..ff91445 100644 --- a/runtime/src/hle/vi.cpp +++ b/runtime/src/hle/vi.cpp @@ -37,6 +37,7 @@ #endif #include +#include // 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 anchor{}; bool valid = false; + std::array 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 anchor{}; bool anchorValid = false; + std::array 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(); diff --git a/runtime/src/vr/mkw_vr_first_person.cpp b/runtime/src/vr/mkw_vr_first_person.cpp index 001abfe..6bf8215 100644 --- a/runtime/src/vr/mkw_vr_first_person.cpp +++ b/runtime/src/vr/mkw_vr_first_person.cpp @@ -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 = {}; diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp index e4e5730..86b30e3 100644 --- a/runtime/src/vr/openxr_integration.cpp +++ b/runtime/src/vr/openxr_integration.cpp @@ -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) &&