mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 04:04:18 +02:00
Added support for VR Frame Interpolation
This commit is contained in:
1 parent
c8eaa52727
commit
6467c6390c
24 files changed
+1030
-132
No files matched your search
@@ -29,7 +29,7 @@ Standalone launches remain opt-in. `Config.toml` is created with the following d
|
|||||||
enabled = false
|
enabled = false
|
||||||
required = false
|
required = false
|
||||||
mirror_view = "normal"
|
mirror_view = "normal"
|
||||||
eager_frame_heartbeat = false
|
frame_interpolation_fps = 0
|
||||||
render_scale = 1.0
|
render_scale = 1.0
|
||||||
world_units_per_meter = 500.0
|
world_units_per_meter = 500.0
|
||||||
hud_distance_meters = 2.0
|
hud_distance_meters = 2.0
|
||||||
@@ -66,12 +66,27 @@ image is chosen, so the setting can always be changed back.
|
|||||||
Eye mirror modes retain the last eye image when a desktop frame has no new XR packet, so they
|
Eye mirror modes retain the last eye image when a desktop frame has no new XR packet, so they
|
||||||
do not alternate with the normal camera. `"none"` also stays black between XR packets.
|
do not alternate with the normal camera. `"none"` also stays black between XR packets.
|
||||||
|
|
||||||
`eager_frame_heartbeat` is live in **F10 > VR > Eager Frame Heartbeat** and defaults to `false`.
|
**F10 > VR > VR frame interpolation (experimental)** offers **Off, Auto, 72, 90, 120** and
|
||||||
With it off, the XR thread waits for new game frames and repeats the last valid image during
|
applies immediately. `frame_interpolation_fps` stores `0` for Off (the default), `1` for Auto,
|
||||||
stalls, with a 50 ms keep-alive interval. With it on, the thread repeats at headset display
|
or the selected rate. The earlier `frame_interpolation = true` checkbox migrates to Auto.
|
||||||
deadlines while waiting for new rendering, matching the eager behavior. Compare both settings
|
Auto renders at the headset's display deadlines; the numbered choices cap the rate of new
|
||||||
during the same race to check smoothness with your OpenXR runtime. Both retain the protection
|
stereo frames. They do not change the headset's physical refresh setting. For VDXR with Virtual
|
||||||
against black frames during pauses and window dragging.
|
Desktop set to 90 Hz, select Auto or 90. The menu shows both the detected headset rate and the
|
||||||
|
rate of newly rendered VR frames, excluding repeated images. Menus and other virtual-screen
|
||||||
|
scenes continue at the game's rate; assess interpolation during an immersive race.
|
||||||
|
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
|
||||||
`render_scale` scales the per-eye size recommended by the OpenXR runtime.
|
`render_scale` scales the per-eye size recommended by the OpenXR runtime.
|
||||||
`world_units_per_meter` controls the scale of headset translation in the game world.
|
`world_units_per_meter` controls the scale of headset translation in the game world.
|
||||||
@@ -158,8 +173,16 @@ short-lived immutable stereo packet. Each sealed GX frame and immersive packet c
|
|||||||
policy-generation tag; a mismatch is rendered in mono and the acquired XR frame is canceled, so an
|
policy-generation tag; a mismatch is rendered in mono and the acquired XR frame is canceled, so an
|
||||||
asynchronous menu/race transition cannot replay race transforms over unsafe content.
|
asynchronous menu/race transition cannot replay race transforms over unsafe content.
|
||||||
|
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
The D3D12 pacing thread retains the last completed projection or virtual-screen layer and
|
The D3D12 pacing thread retains the last completed projection or virtual-screen layer and
|
||||||
resubmits it during stalls (or missed display deadlines with eager heartbeat enabled), including while moving the desktop window,
|
resubmits it during stalls, including while moving the desktop window,
|
||||||
pausing, or minimizing. The scene freezes until rendering resumes; the compositor can still
|
pausing, or minimizing. The scene freezes until rendering resumes; the compositor can still
|
||||||
reproject the retained image for head movement. Repeated layers keep their original render poses
|
reproject the retained image for head movement. Repeated layers keep their original render poses
|
||||||
and field of view, paired with the new compositor display time. Two pairs of eye swapchains keep
|
and field of view, paired with the new compositor display time. Two pairs of eye swapchains keep
|
||||||
@@ -215,6 +238,31 @@ The Vulkan path intentionally does not create an unrelated Vulkan device or use
|
|||||||
a workaround. It accepts a future explicit Dawn native context, including external queue locking,
|
a workaround. It accepts a future explicit Dawn native context, including external queue locking,
|
||||||
so it can be enabled once Aurora exposes those handles safely.
|
so it can be enabled once Aurora exposes those handles safely.
|
||||||
|
|
||||||
|
### Interpolation validation
|
||||||
|
|
||||||
|
The GX tests cover retained transform endpoints with desktop interpolation off and continuous
|
||||||
|
sampling at 72/90/120 Hz. `mkw_frame_interpolation_pacing_tests` covers fixed-rate scheduling,
|
||||||
|
live changes, stalls and configuration migration; `mkw_openxr_replay_tests` exercises swapchain
|
||||||
|
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
|
||||||
|
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 test checks that the producer stays above 55 FPS as well as checking headset submissions;
|
||||||
|
replaying an old scene more often must not hide a slowed simulation. Validate actual races in VDXR at 90 Hz with
|
||||||
|
Auto/90 selected, including race entry/exit, first person, recentering and pauses.
|
||||||
|
|
||||||
|
Stereo uniform calculations use cached CPU memory, followed by a single write into the upload
|
||||||
|
buffer. Reading or modifying matrices directly in D3D12 upload memory can be extremely slow,
|
||||||
|
especially with many character draws; see Microsoft's [Map guidance](https://learn.microsoft.com/en-us/windows/win32/api/d3d12/nf-d3d12-id3d12resource-map).
|
||||||
|
Retained interpolation reserves eye ranges at seal time and fills them once at the headset sample
|
||||||
|
time. VR interpolation also releases the producer after sealing so eye encoding can overlap the
|
||||||
|
next game frame, as it does with desktop interpolation.
|
||||||
|
|
||||||
## Current limitations
|
## Current limitations
|
||||||
|
|
||||||
- Only the project's supported PAL `RMCP01` translation has race instrumentation addresses.
|
- Only the project's supported PAL `RMCP01` translation has race instrumentation addresses.
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ typedef struct {
|
|||||||
// Appended to preserve the frameToken/eyes prefix used by older providers.
|
// Appended to preserve the frameToken/eyes prefix used by older providers.
|
||||||
AuroraStereoFrameMode mode;
|
AuroraStereoFrameMode mode;
|
||||||
uint64_t contentTag;
|
uint64_t contentTag;
|
||||||
|
// Predicted display time converted to std::chrono::steady_clock nanoseconds.
|
||||||
|
// Zero disables temporal interpolation for this packet.
|
||||||
|
uint64_t displayTimeNanos;
|
||||||
} AuroraStereoFrame;
|
} AuroraStereoFrame;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,6 +139,10 @@ typedef struct {
|
|||||||
*/
|
*/
|
||||||
typedef bool (*AuroraStereoFrameProvider)(uint32_t logicalFrame, AuroraStereoFrame* frame, void* userdata);
|
typedef bool (*AuroraStereoFrameProvider)(uint32_t logicalFrame, AuroraStereoFrame* frame, void* userdata);
|
||||||
|
|
||||||
|
// Wake retained stereo replay after publishing a packet. The provider remains
|
||||||
|
// non-blocking, and all OpenXR calls stay on the application's pacing thread.
|
||||||
|
void aurora_notify_stereo_frame();
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
const char* appName;
|
const char* appName;
|
||||||
const char* userPath;
|
const char* userPath;
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ void aurora_get_frame_interpolation_diagnostics(AuroraFrameInterpolationDiagnost
|
|||||||
void aurora_set_frame_interpolation_fps(uint32_t targetFps);
|
void aurora_set_frame_interpolation_fps(uint32_t targetFps);
|
||||||
uint32_t aurora_get_frame_interpolation_fps();
|
uint32_t aurora_get_frame_interpolation_fps();
|
||||||
|
|
||||||
|
// Independent from desktop interpolation: replay captured race transforms at
|
||||||
|
// 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();
|
||||||
|
|
||||||
// Newly encountered GX pipelines compile on the bounded worker queue. Draws whose pipeline is not
|
// 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.
|
// ready are skipped rather than stalling submission, and pick it up once compilation finishes.
|
||||||
void aurora_set_skip_unready_pipelines(bool enabled);
|
void aurora_set_skip_unready_pipelines(bool enabled);
|
||||||
|
|||||||
+124
-6
@@ -9,6 +9,7 @@
|
|||||||
#include "imgui.hpp"
|
#include "imgui.hpp"
|
||||||
#include "stereo.hpp"
|
#include "stereo.hpp"
|
||||||
#include "stereo_mirror.hpp"
|
#include "stereo_mirror.hpp"
|
||||||
|
#include "stereo_interpolation.hpp"
|
||||||
#include "webgpu/gpu.hpp"
|
#include "webgpu/gpu.hpp"
|
||||||
#include <webgpu/webgpu_cpp.h>
|
#include <webgpu/webgpu_cpp.h>
|
||||||
#endif
|
#endif
|
||||||
@@ -272,6 +273,7 @@ struct FrameWorkerState {
|
|||||||
bool started = false;
|
bool started = false;
|
||||||
bool stop = false;
|
bool stop = false;
|
||||||
bool jobPending = false;
|
bool jobPending = false;
|
||||||
|
bool stereoPending = false;
|
||||||
// Written with jobPending and copied by the worker under this mutex. They
|
// Written with jobPending and copied by the worker under this mutex. They
|
||||||
// belong to that exact queued frame, not to the producer's next frame.
|
// belong to that exact queued frame, not to the producer's next frame.
|
||||||
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
||||||
@@ -326,6 +328,7 @@ bool frame_worker_requested() noexcept {
|
|||||||
// Returns false when a stop request was observed mid-cycle.
|
// Returns false when a stop request was observed mid-cycle.
|
||||||
bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag,
|
bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag,
|
||||||
const StereoSceneAnchor& sceneAnchor) noexcept;
|
const StereoSceneAnchor& sceneAnchor) noexcept;
|
||||||
|
void run_retained_stereo_frame(gfx::SealedFrame& sealedFrame) noexcept;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
void frame_worker_main() noexcept {
|
void frame_worker_main() noexcept {
|
||||||
@@ -343,12 +346,18 @@ void frame_worker_main() noexcept {
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
||||||
StereoSceneAnchor sceneAnchor{};
|
StereoSceneAnchor sceneAnchor{};
|
||||||
|
bool stereoOnly = false;
|
||||||
{
|
{
|
||||||
std::unique_lock lock(g_frameWorker.mutex);
|
std::unique_lock lock(g_frameWorker.mutex);
|
||||||
g_frameWorker.cv.wait(lock, [] { return g_frameWorker.stop || g_frameWorker.jobPending; });
|
g_frameWorker.cv.wait(
|
||||||
|
lock, [] { return g_frameWorker.stop || g_frameWorker.jobPending || g_frameWorker.stereoPending; });
|
||||||
if (g_frameWorker.stop) {
|
if (g_frameWorker.stop) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
stereoOnly = !g_frameWorker.jobPending;
|
||||||
|
g_frameWorker.stereoPending = false;
|
||||||
|
if (stereoOnly)
|
||||||
|
g_frameWorker.ready.store(false, std::memory_order_release);
|
||||||
contentTag = g_frameWorker.contentTag;
|
contentTag = g_frameWorker.contentTag;
|
||||||
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
||||||
sceneAnchor = g_frameWorker.sceneAnchor;
|
sceneAnchor = g_frameWorker.sceneAnchor;
|
||||||
@@ -359,6 +368,19 @@ void frame_worker_main() noexcept {
|
|||||||
// The CPU already decoded the sealed frame at its GX boundary; the worker only owns
|
// The CPU already decoded the sealed frame at its GX boundary; the worker only owns
|
||||||
// encode/submit/present, so it never touches the producer's next FIFO buffer.
|
// encode/submit/present, so it never touches the producer's next FIFO buffer.
|
||||||
#ifdef AURORA_ENABLE_GX
|
#ifdef AURORA_ENABLE_GX
|
||||||
|
if (stereoOnly) {
|
||||||
|
run_retained_stereo_frame(sealedFrame);
|
||||||
|
{
|
||||||
|
std::lock_guard lock(g_frameWorker.mutex);
|
||||||
|
// The producer can queue its next seal after observing the preceding
|
||||||
|
// DONE but before this idle replay claims the worker. Do not publish
|
||||||
|
// that newer job as done before it has actually run.
|
||||||
|
if (!g_frameWorker.jobPending)
|
||||||
|
g_frameWorker.ready.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
g_frameWorker.cv.notify_all();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!run_frame_worker_cycle(sealedFrame, contentTag, sceneAnchor)) {
|
if (!run_frame_worker_cycle(sealedFrame, contentTag, sceneAnchor)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -388,6 +410,7 @@ void ensure_frame_worker_started() noexcept {
|
|||||||
}
|
}
|
||||||
g_frameWorker.stop = false;
|
g_frameWorker.stop = false;
|
||||||
g_frameWorker.jobPending = false;
|
g_frameWorker.jobPending = false;
|
||||||
|
g_frameWorker.stereoPending = false;
|
||||||
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
||||||
g_frameWorker.sceneAnchor = {};
|
g_frameWorker.sceneAnchor = {};
|
||||||
g_frameWorker.sealed.store(true, std::memory_order_release);
|
g_frameWorker.sealed.store(true, std::memory_order_release);
|
||||||
@@ -1662,8 +1685,62 @@ struct SealedFrameContext {
|
|||||||
uint32_t logicalFrame = 0;
|
uint32_t logicalFrame = 0;
|
||||||
bool interpolationActive = false;
|
bool interpolationActive = false;
|
||||||
bool replayInterpolatedFrames = false;
|
bool replayInterpolatedFrames = false;
|
||||||
|
std::optional<AuroraStereoFrame> stereoInput;
|
||||||
|
bool retainStereo = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Worker-owned scene state. A separate buffer generation check protects against
|
||||||
|
// synchronous EFB submissions overwriting the retained frame's GPU data.
|
||||||
|
struct RetainedStereoContext {
|
||||||
|
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
||||||
|
uint64_t boundary = 0;
|
||||||
|
uint64_t interval = 0;
|
||||||
|
uint32_t logicalFrame = 0;
|
||||||
|
StereoSceneAnchor anchor;
|
||||||
|
StereoSceneAnchor previousAnchor;
|
||||||
|
bool continuous = false;
|
||||||
|
} g_retainedStereo;
|
||||||
|
|
||||||
|
gfx::StereoReplayFrame interpolated_stereo_frame(const AuroraStereoFrame& input, float& weight) {
|
||||||
|
const auto& retained = g_retainedStereo;
|
||||||
|
weight = retained.continuous
|
||||||
|
? stereo::interpolation_weight(input.displayTimeNanos, retained.boundary, retained.interval)
|
||||||
|
: 1.0f;
|
||||||
|
auto anchor = retained.anchor;
|
||||||
|
if (weight < 1.0f && anchor.active && retained.previousAnchor.active) {
|
||||||
|
Mat3x4<float> previous, current, result;
|
||||||
|
std::memcpy(&previous, retained.previousAnchor.anchorFromScene.data(), sizeof(previous));
|
||||||
|
std::memcpy(¤t, anchor.anchorFromScene.data(), sizeof(current));
|
||||||
|
if (gx::interpolate_transform(previous, current, weight, result)) {
|
||||||
|
std::memcpy(anchor.anchorFromScene.data(), &result, sizeof(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return make_stereo_replay_frame(input, anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
void run_retained_stereo_frame(gfx::SealedFrame& sealedFrame) noexcept {
|
||||||
|
std::lock_guard gpuLock(g_rendererGpuMutex);
|
||||||
|
if (!gx::stereo_frame_interpolation_active() || !gfx::has_late_stereo_replay(sealedFrame))
|
||||||
|
return;
|
||||||
|
const auto input = request_stereo_frame(g_retainedStereo.logicalFrame, g_retainedStereo.contentTag);
|
||||||
|
if (!input || input->mode != AURORA_STEREO_FRAME_IMMERSIVE_REPLAY)
|
||||||
|
return;
|
||||||
|
float weight;
|
||||||
|
auto replay = interpolated_stereo_frame(*input, weight);
|
||||||
|
auto encoder = g_device.CreateCommandEncoder();
|
||||||
|
if (!gfx::prepare_late_stereo_replay(sealedFrame, encoder, replay, weight))
|
||||||
|
return;
|
||||||
|
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
||||||
|
gfx::render_stereo_eye(sealedFrame, encoder, replay, eye, false);
|
||||||
|
}
|
||||||
|
const auto sink = run_stereo_sink(encoder, input->frameToken, g_retainedStereo.logicalFrame, input->mode);
|
||||||
|
const auto buffer = encoder.Finish();
|
||||||
|
std::lock_guard submitLock(g_queueSubmitMutex);
|
||||||
|
g_queue.Submit(1, &buffer);
|
||||||
|
if (sink && sink->submitted)
|
||||||
|
sink->submitted(sink->frame, sink->userdata);
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 1: everything that touches producer-shared renderer state. Needs g_rendererGpuMutex and
|
// Phase 1: everything that touches producer-shared renderer state. Needs g_rendererGpuMutex and
|
||||||
// a FIFO already drained into the recorded pass list.
|
// a FIFO already drained into the recorded pass list.
|
||||||
void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, uint64_t contentTag,
|
void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, uint64_t contentTag,
|
||||||
@@ -1680,6 +1757,7 @@ void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, u
|
|||||||
// pre-first-frame UINT32_MAX value to logical frame zero.
|
// pre-first-frame UINT32_MAX value to logical frame zero.
|
||||||
ctx.logicalFrame = gfx::current_frame() + 1;
|
ctx.logicalFrame = gfx::current_frame() + 1;
|
||||||
if (const auto stereoInput = request_stereo_frame(ctx.logicalFrame, contentTag)) {
|
if (const auto stereoInput = request_stereo_frame(ctx.logicalFrame, contentTag)) {
|
||||||
|
ctx.stereoInput = stereoInput;
|
||||||
ctx.stereoFrameToken = stereoInput->frameToken;
|
ctx.stereoFrameToken = stereoInput->frameToken;
|
||||||
ctx.stereoFrameMode = stereoInput->mode;
|
ctx.stereoFrameMode = stereoInput->mode;
|
||||||
ctx.stereoReplay = make_stereo_replay_frame(*stereoInput, sceneAnchor);
|
ctx.stereoReplay = make_stereo_replay_frame(*stereoInput, sceneAnchor);
|
||||||
@@ -1727,6 +1805,16 @@ void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, u
|
|||||||
// Detach the recorded passes. From here the producer's list is empty and the
|
// Detach the recorded passes. From here the producer's list is empty and the
|
||||||
// encode phase reads only worker-private state.
|
// encode phase reads only worker-private state.
|
||||||
gfx::seal_frame(sealedFrame);
|
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;
|
||||||
|
const auto previousAnchor = g_retainedStereo.anchor;
|
||||||
|
g_retainedStereo = {contentTag, ctx.scheduleBaseNanos, ctx.scheduleIntervalNanos,
|
||||||
|
ctx.logicalFrame, sceneAnchor, previousAnchor,
|
||||||
|
continuous};
|
||||||
gfx::expire_bind_group_cache();
|
gfx::expire_bind_group_cache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1801,7 +1889,7 @@ std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame,
|
|||||||
|
|
||||||
// A demanded CPU-visible EFB readback submits a prefix of the frame, so replaying the resumed
|
// A demanded CPU-visible EFB readback submits a prefix of the frame, so replaying the resumed
|
||||||
// stream would mutate an already-rendered EFB. Render once, then duplicate into the slots.
|
// stream would mutate an already-rendered EFB. Render once, then duplicate into the slots.
|
||||||
gfx::render(sealedFrame, encoder, -1, !immersiveReplay);
|
gfx::render(sealedFrame, encoder, -1, !immersiveReplay && !ctx.retainStereo);
|
||||||
// The copy targets now hold this frame's resolves, so queue their readbacks on the same encoder;
|
// The copy targets now hold this frame's resolves, so queue their readbacks on the same encoder;
|
||||||
// completion is harvested in gfx::after_submit, never waited on here.
|
// completion is harvested in gfx::after_submit, never waited on here.
|
||||||
gfx::efb_ram::encode_async_downloads(encoder);
|
gfx::efb_ram::encode_async_downloads(encoder);
|
||||||
@@ -1828,8 +1916,14 @@ std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame,
|
|||||||
// previous frame's; the interpolated slots above necessarily mirror the
|
// previous frame's; the interpolated slots above necessarily mirror the
|
||||||
// previous frame, having been encoded before this replay.
|
// previous frame, having been encoded before this replay.
|
||||||
if (immersiveReplay) {
|
if (immersiveReplay) {
|
||||||
|
if (ctx.retainStereo && ctx.stereoInput) {
|
||||||
|
float weight;
|
||||||
|
ctx.stereoReplay = interpolated_stereo_frame(*ctx.stereoInput, weight);
|
||||||
|
gfx::prepare_late_stereo_replay(sealedFrame, encoder, *ctx.stereoReplay, weight);
|
||||||
|
}
|
||||||
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
||||||
gfx::render_stereo_eye(sealedFrame, encoder, *ctx.stereoReplay, eye, eye + 1 == AURORA_STEREO_EYE_COUNT);
|
gfx::render_stereo_eye(sealedFrame, encoder, *ctx.stereoReplay, eye,
|
||||||
|
!ctx.retainStereo && eye + 1 == AURORA_STEREO_EYE_COUNT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1978,8 +2072,8 @@ void record_frame_telemetry() {
|
|||||||
FrameMarkNamed("Aurora frame");
|
FrameMarkNamed("Aurora frame");
|
||||||
}
|
}
|
||||||
|
|
||||||
// One complete frame-worker cycle. The scene encode only leaves the renderer mutex when
|
// One complete frame-worker cycle. Desktop and headset interpolation both
|
||||||
// interpolation actually inserts slots; otherwise both phases publish together.
|
// release the producer after sealing, before encoding their extra scene views.
|
||||||
bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag,
|
bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag,
|
||||||
const StereoSceneAnchor& sceneAnchor) noexcept {
|
const StereoSceneAnchor& sceneAnchor) noexcept {
|
||||||
ZoneScopedN("Frame worker cycle");
|
ZoneScopedN("Frame worker cycle");
|
||||||
@@ -1990,7 +2084,7 @@ bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag,
|
|||||||
{
|
{
|
||||||
std::lock_guard gpuLock(g_rendererGpuMutex);
|
std::lock_guard gpuLock(g_rendererGpuMutex);
|
||||||
seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor);
|
seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor);
|
||||||
overlapEncode = ctx.interpolationActive;
|
overlapEncode = ctx.interpolationActive || ctx.retainStereo;
|
||||||
if (!overlapEncode) {
|
if (!overlapEncode) {
|
||||||
presentationJobs = encode_sealed_frame(sealedFrame, ctx);
|
presentationJobs = encode_sealed_frame(sealedFrame, ctx);
|
||||||
}
|
}
|
||||||
@@ -2284,6 +2378,30 @@ void aurora_set_frame_worker_wait_callback(AuroraFrameWorkerWaitCallback callbac
|
|||||||
void aurora_set_stereo_frame_provider(AuroraStereoFrameProvider provider, void* userdata) {
|
void aurora_set_stereo_frame_provider(AuroraStereoFrameProvider provider, void* userdata) {
|
||||||
aurora::set_stereo_frame_provider(provider, userdata);
|
aurora::set_stereo_frame_provider(provider, userdata);
|
||||||
}
|
}
|
||||||
|
void aurora_notify_stereo_frame() {
|
||||||
|
#ifdef AURORA_ENABLE_GX
|
||||||
|
std::lock_guard lock(aurora::g_frameWorker.mutex);
|
||||||
|
if (aurora::g_frameWorker.started && aurora::gx::stereo_frame_interpolation_active()) {
|
||||||
|
aurora::g_frameWorker.stereoPending = true;
|
||||||
|
aurora::g_frameWorker.cv.notify_one();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
void aurora_set_stereo_frame_interpolation(bool enabled) {
|
||||||
|
#ifdef AURORA_ENABLE_GX
|
||||||
|
std::lock_guard lock(aurora::g_frameWorker.mutex);
|
||||||
|
aurora::gx::detail::g_stereoFrameInterpolation.store(enabled, std::memory_order_release);
|
||||||
|
if (!enabled)
|
||||||
|
aurora::g_frameWorker.stereoPending = false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
bool aurora_get_stereo_frame_interpolation() {
|
||||||
|
#ifdef AURORA_ENABLE_GX
|
||||||
|
return aurora::gx::stereo_frame_interpolation_active();
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
void aurora_wait_for_frame_worker() { aurora::wait_for_frame_worker(); }
|
void aurora_wait_for_frame_worker() { aurora::wait_for_frame_worker(); }
|
||||||
bool aurora_wait_for_frame_worker_for(uint32_t timeoutMicros) {
|
bool aurora_wait_for_frame_worker_for(uint32_t timeoutMicros) {
|
||||||
return aurora::wait_for_frame_worker_for(std::chrono::microseconds(timeoutMicros));
|
return aurora::wait_for_frame_worker_for(std::chrono::microseconds(timeoutMicros));
|
||||||
|
|||||||
+225
-69
@@ -296,8 +296,29 @@ static void recycle_render_passes(std::vector<RenderPass>& passes) noexcept {
|
|||||||
passes.clear();
|
passes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct LateStereoUniform {
|
||||||
|
gx::UniformReplayLayout layout;
|
||||||
|
Viewport viewport;
|
||||||
|
Range current;
|
||||||
|
Range previous;
|
||||||
|
std::array<Range, AURORA_STEREO_EYE_COUNT> eyes;
|
||||||
|
};
|
||||||
|
struct LateStereoData {
|
||||||
|
std::vector<LateStereoUniform> uniforms;
|
||||||
|
std::vector<uint8_t> sources;
|
||||||
|
std::vector<uint8_t> uploadBytes;
|
||||||
|
ClipRect displayRegion{};
|
||||||
|
stereo_replay::HudScreen hudScreen{};
|
||||||
|
uint64_t generation = 0;
|
||||||
|
uint32_t uploadOffset = 0;
|
||||||
|
uint32_t uploadSize = 0;
|
||||||
|
};
|
||||||
|
// Advanced for every upload, including synchronous mid-frame EFB readbacks.
|
||||||
|
static std::atomic_uint64_t g_replayBufferGeneration{0};
|
||||||
|
static LateStereoData g_pendingLateStereo;
|
||||||
struct SealedFrameData {
|
struct SealedFrameData {
|
||||||
std::vector<RenderPass> passes;
|
std::vector<RenderPass> passes;
|
||||||
|
LateStereoData stereo;
|
||||||
};
|
};
|
||||||
|
|
||||||
SealedFrame::SealedFrame() : m_data(std::make_unique<SealedFrameData>()) {}
|
SealedFrame::SealedFrame() : m_data(std::make_unique<SealedFrameData>()) {}
|
||||||
@@ -1274,7 +1295,79 @@ static stereo_replay::HudScreen stereo_hud_screen() noexcept {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame) noexcept {
|
// Shared by the normal seal and headset-deadline replay. Head transforms are
|
||||||
|
// composed after scene interpolation, so free look never inherits its delay.
|
||||||
|
static void write_stereo_uniform(std::span<uint8_t> uniform, const gx::UniformReplayLayout& layout,
|
||||||
|
const StereoReplayEye& eye, const Mat4x4<float>& gameProjection,
|
||||||
|
const Viewport& drawViewport, ClipRect displayRegion,
|
||||||
|
const stereo_replay::HudScreen& hudScreen) noexcept {
|
||||||
|
if (layout.perspective) {
|
||||||
|
const auto projection = stereo_replay::compose_projection(eye.projection, gameProjection);
|
||||||
|
std::memcpy(uniform.data() + layout.projectionOffset, &projection, sizeof(projection));
|
||||||
|
|
||||||
|
for (uint32_t matrix = 0; matrix < layout.positionMatrixCount; ++matrix) {
|
||||||
|
if ((layout.positionMatrixMask & (1u << matrix)) == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const size_t offset = layout.positionOffset + matrix * sizeof(Mat3x4<float>);
|
||||||
|
Mat3x4<float> source;
|
||||||
|
std::memcpy(&source, uniform.data() + offset, sizeof(source));
|
||||||
|
const auto transformed = stereo_replay::compose_affine(eye.viewFromScene, source);
|
||||||
|
std::memcpy(uniform.data() + offset, &transformed, sizeof(transformed));
|
||||||
|
}
|
||||||
|
for (uint32_t matrix = 0; matrix < layout.normalMatrixCount; ++matrix) {
|
||||||
|
const size_t offset = layout.normalOffset + matrix * sizeof(Mat3x4<float>);
|
||||||
|
Mat3x4<float> source;
|
||||||
|
std::memcpy(&source, uniform.data() + offset, sizeof(source));
|
||||||
|
const auto transformed = stereo_replay::compose_normal(eye.viewFromScene, source);
|
||||||
|
std::memcpy(uniform.data() + offset, &transformed, sizeof(transformed));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 2D content reaches the eye entirely through its projection: the
|
||||||
|
// draw's own position matrices lay the element out in screen space.
|
||||||
|
// The screen rectangle is built in the VR-neutral view space, so this
|
||||||
|
// path uses viewFromCenter, not viewFromScene: folding the anchor in
|
||||||
|
// would leave the screen behind at the camera the anchor replaced.
|
||||||
|
// First lift viewport-local NDC into displayed-frame NDC; replay will
|
||||||
|
// use a full-eye viewport so sub-pane elements are not transformed by
|
||||||
|
// the recorded viewport a second time.
|
||||||
|
const auto ndcRemap = stereo_replay::make_hud_ndc_remap(
|
||||||
|
drawViewport.left, drawViewport.top, drawViewport.width, drawViewport.height,
|
||||||
|
static_cast<float>(displayRegion.x), static_cast<float>(displayRegion.y),
|
||||||
|
static_cast<float>(displayRegion.width), static_cast<float>(displayRegion.height));
|
||||||
|
const auto projection = stereo_replay::compose_hud_screen_projection(eye.projection, eye.viewFromCenter, hudScreen,
|
||||||
|
gameProjection, ndcRemap);
|
||||||
|
std::memcpy(uniform.data() + layout.projectionOffset, &projection, sizeof(projection));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayRegion.width > 0 && displayRegion.height > 0) {
|
||||||
|
float renderSize[2];
|
||||||
|
float logicalSize[2];
|
||||||
|
std::memcpy(renderSize, uniform.data() + 8, sizeof(renderSize));
|
||||||
|
std::memcpy(logicalSize, uniform.data() + 16, sizeof(logicalSize));
|
||||||
|
if (layout.perspective) {
|
||||||
|
renderSize[0] *= static_cast<float>(eye.target.size.width) / static_cast<float>(displayRegion.width);
|
||||||
|
renderSize[1] *= static_cast<float>(eye.target.size.height) / static_cast<float>(displayRegion.height);
|
||||||
|
} else {
|
||||||
|
// Point/line expansion and GX's pixel-center correction now operate
|
||||||
|
// in the full eye viewport. Recover the complete logical frame size
|
||||||
|
// from this draw's logical-to-render scale.
|
||||||
|
if (renderSize[0] != 0.0f) {
|
||||||
|
logicalSize[0] *= static_cast<float>(displayRegion.width) / renderSize[0];
|
||||||
|
}
|
||||||
|
if (renderSize[1] != 0.0f) {
|
||||||
|
logicalSize[1] *= static_cast<float>(displayRegion.height) / renderSize[1];
|
||||||
|
}
|
||||||
|
renderSize[0] = static_cast<float>(eye.target.size.width);
|
||||||
|
renderSize[1] = static_cast<float>(eye.target.size.height);
|
||||||
|
std::memcpy(uniform.data() + 16, logicalSize, sizeof(logicalSize));
|
||||||
|
}
|
||||||
|
std::memcpy(uniform.data() + 8, renderSize, sizeof(renderSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
|
||||||
|
LateStereoData* history = nullptr) noexcept {
|
||||||
const StereoDisplaySource displaySource = stereo_display_source(g_renderPasses);
|
const StereoDisplaySource displaySource = stereo_display_source(g_renderPasses);
|
||||||
const ClipRect displayRegion = displaySource.region;
|
const ClipRect displayRegion = displaySource.region;
|
||||||
// This is the producer-side preparation path; eye replay can query the pure
|
// This is the producer-side preparation path; eye replay can query the pure
|
||||||
@@ -1345,6 +1438,14 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (history != nullptr) {
|
||||||
|
history->uniforms.reserve(replayPerspectiveDrawCount + replayHudScreenDrawCount);
|
||||||
|
history->sources.reserve(requiredBytes);
|
||||||
|
history->displayRegion = displayRegion;
|
||||||
|
history->hudScreen = hudScreen;
|
||||||
|
history->uploadOffset =
|
||||||
|
static_cast<uint32_t>(AURORA_ALIGN(g_uniforms.size(), g_cachedLimits.minUniformBufferOffsetAlignment));
|
||||||
|
}
|
||||||
Viewport drawViewport{
|
Viewport drawViewport{
|
||||||
.left = static_cast<float>(displayRegion.x),
|
.left = static_cast<float>(displayRegion.x),
|
||||||
.top = static_cast<float>(displayRegion.y),
|
.top = static_cast<float>(displayRegion.y),
|
||||||
@@ -1353,6 +1454,10 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame)
|
|||||||
.znear = 0.0f,
|
.znear = 0.0f,
|
||||||
.zfar = 1.0f,
|
.zfar = 1.0f,
|
||||||
};
|
};
|
||||||
|
// D3D12 upload heaps can be write-combined. Read each source once, and do
|
||||||
|
// all read/modify/write operations in cached CPU memory before uploading.
|
||||||
|
std::array<uint8_t, gx::MaxUniformSize> sourceUniform;
|
||||||
|
std::array<uint8_t, gx::MaxUniformSize> eyeUniform;
|
||||||
for (auto& pass : g_renderPasses) {
|
for (auto& pass : g_renderPasses) {
|
||||||
if (!pass.efbTarget) {
|
if (!pass.efbTarget) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1368,9 +1473,9 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame)
|
|||||||
}
|
}
|
||||||
auto& draw = command.data.draw.gx;
|
auto& draw = command.data.draw.gx;
|
||||||
const auto& layout = draw.uniformReplayLayout;
|
const auto& layout = draw.uniformReplayLayout;
|
||||||
|
std::memcpy(sourceUniform.data(), g_uniforms.data() + draw.uniformRange.offset, draw.uniformRange.size);
|
||||||
Mat4x4<float> gameProjection;
|
Mat4x4<float> gameProjection;
|
||||||
std::memcpy(&gameProjection, g_uniforms.data() + draw.uniformRange.offset + layout.projectionOffset,
|
std::memcpy(&gameProjection, sourceUniform.data() + layout.projectionOffset, sizeof(gameProjection));
|
||||||
sizeof(gameProjection));
|
|
||||||
// Only a genuinely affine projection carries its NDC position in its clip
|
// Only a genuinely affine projection carries its NDC position in its clip
|
||||||
// position, which is what the virtual screen reprojection consumes. GX
|
// position, which is what the virtual screen reprojection consumes. GX
|
||||||
// tracks the projection type separately from the matrix, so a 2D draw
|
// tracks the projection type separately from the matrix, so a 2D draw
|
||||||
@@ -1379,77 +1484,41 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame)
|
|||||||
if (!layout.perspective && !stereo_replay::is_orthographic_projection(gameProjection)) {
|
if (!layout.perspective && !stereo_replay::is_orthographic_projection(gameProjection)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
LateStereoUniform* saved = nullptr;
|
||||||
|
if (history != nullptr) {
|
||||||
|
saved = &history->uniforms.emplace_back();
|
||||||
|
saved->layout = layout;
|
||||||
|
saved->viewport = drawViewport;
|
||||||
|
const auto save = [&](const uint8_t* source, uint32_t size) -> Range {
|
||||||
|
if (size == 0)
|
||||||
|
return {};
|
||||||
|
const Range copy{static_cast<uint32_t>(history->sources.size()), size};
|
||||||
|
history->sources.insert(history->sources.end(), source, source + size);
|
||||||
|
return copy;
|
||||||
|
};
|
||||||
|
saved->current = save(sourceUniform.data(), draw.uniformRange.size);
|
||||||
|
saved->previous = save(g_uniforms.data() + draw.previousUniformRange.offset, draw.previousUniformRange.size);
|
||||||
|
}
|
||||||
for (uint32_t eyeIndex = 0; eyeIndex < AURORA_STEREO_EYE_COUNT; ++eyeIndex) {
|
for (uint32_t eyeIndex = 0; eyeIndex < AURORA_STEREO_EYE_COUNT; ++eyeIndex) {
|
||||||
auto [uniform, range] = copy_uniform(draw.uniformRange);
|
auto [uniform, range] = map_uniform(draw.uniformRange.size);
|
||||||
draw.stereoUniformRanges[eyeIndex] = range;
|
draw.stereoUniformRanges[eyeIndex] = range;
|
||||||
|
if (saved != nullptr) {
|
||||||
|
// The late replay uploads these ranges at the actual display time.
|
||||||
|
// Preparing another eye pair here would immediately be overwritten.
|
||||||
|
saved->eyes[eyeIndex] = range;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const auto& eye = stereoFrame.eyes[eyeIndex];
|
const auto& eye = stereoFrame.eyes[eyeIndex];
|
||||||
|
std::memcpy(eyeUniform.data(), sourceUniform.data(), range.size);
|
||||||
if (layout.perspective) {
|
write_stereo_uniform({eyeUniform.data(), range.size}, layout, eye, gameProjection, drawViewport, displayRegion,
|
||||||
const auto projection = stereo_replay::compose_projection(eye.projection, gameProjection);
|
hudScreen);
|
||||||
std::memcpy(uniform.data() + layout.projectionOffset, &projection, sizeof(projection));
|
std::memcpy(uniform.data(), eyeUniform.data(), range.size);
|
||||||
|
|
||||||
for (uint32_t matrix = 0; matrix < layout.positionMatrixCount; ++matrix) {
|
|
||||||
if ((layout.positionMatrixMask & (1u << matrix)) == 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const size_t offset = layout.positionOffset + matrix * sizeof(Mat3x4<float>);
|
|
||||||
Mat3x4<float> source;
|
|
||||||
std::memcpy(&source, uniform.data() + offset, sizeof(source));
|
|
||||||
const auto transformed = stereo_replay::compose_affine(eye.viewFromScene, source);
|
|
||||||
std::memcpy(uniform.data() + offset, &transformed, sizeof(transformed));
|
|
||||||
}
|
|
||||||
for (uint32_t matrix = 0; matrix < layout.normalMatrixCount; ++matrix) {
|
|
||||||
const size_t offset = layout.normalOffset + matrix * sizeof(Mat3x4<float>);
|
|
||||||
Mat3x4<float> source;
|
|
||||||
std::memcpy(&source, uniform.data() + offset, sizeof(source));
|
|
||||||
const auto transformed = stereo_replay::compose_normal(eye.viewFromScene, source);
|
|
||||||
std::memcpy(uniform.data() + offset, &transformed, sizeof(transformed));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 2D content reaches the eye entirely through its projection: the
|
|
||||||
// draw's own position matrices lay the element out in screen space.
|
|
||||||
// The screen rectangle is built in the VR-neutral view space, so this
|
|
||||||
// path uses viewFromCenter, not viewFromScene: folding the anchor in
|
|
||||||
// would leave the screen behind at the camera the anchor replaced.
|
|
||||||
// First lift viewport-local NDC into displayed-frame NDC; replay will
|
|
||||||
// use a full-eye viewport so sub-pane elements are not transformed by
|
|
||||||
// the recorded viewport a second time.
|
|
||||||
const auto ndcRemap = stereo_replay::make_hud_ndc_remap(
|
|
||||||
drawViewport.left, drawViewport.top, drawViewport.width, drawViewport.height,
|
|
||||||
static_cast<float>(displayRegion.x), static_cast<float>(displayRegion.y),
|
|
||||||
static_cast<float>(displayRegion.width), static_cast<float>(displayRegion.height));
|
|
||||||
const auto projection = stereo_replay::compose_hud_screen_projection(
|
|
||||||
eye.projection, eye.viewFromCenter, hudScreen, gameProjection, ndcRemap);
|
|
||||||
std::memcpy(uniform.data() + layout.projectionOffset, &projection, sizeof(projection));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (displayRegion.width > 0 && displayRegion.height > 0) {
|
|
||||||
float renderSize[2];
|
|
||||||
float logicalSize[2];
|
|
||||||
std::memcpy(renderSize, uniform.data() + 8, sizeof(renderSize));
|
|
||||||
std::memcpy(logicalSize, uniform.data() + 16, sizeof(logicalSize));
|
|
||||||
if (layout.perspective) {
|
|
||||||
renderSize[0] *= static_cast<float>(eye.target.size.width) / static_cast<float>(displayRegion.width);
|
|
||||||
renderSize[1] *= static_cast<float>(eye.target.size.height) / static_cast<float>(displayRegion.height);
|
|
||||||
} else {
|
|
||||||
// Point/line expansion and GX's pixel-center correction now operate
|
|
||||||
// in the full eye viewport. Recover the complete logical frame size
|
|
||||||
// from this draw's logical-to-render scale.
|
|
||||||
if (renderSize[0] != 0.0f) {
|
|
||||||
logicalSize[0] *= static_cast<float>(displayRegion.width) / renderSize[0];
|
|
||||||
}
|
|
||||||
if (renderSize[1] != 0.0f) {
|
|
||||||
logicalSize[1] *= static_cast<float>(displayRegion.height) / renderSize[1];
|
|
||||||
}
|
|
||||||
renderSize[0] = static_cast<float>(eye.target.size.width);
|
|
||||||
renderSize[1] = static_cast<float>(eye.target.size.height);
|
|
||||||
std::memcpy(uniform.data() + 16, logicalSize, sizeof(logicalSize));
|
|
||||||
}
|
|
||||||
std::memcpy(uniform.data() + 8, renderSize, sizeof(renderSize));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (history != nullptr) {
|
||||||
|
history->uploadSize = static_cast<uint32_t>(g_uniforms.size()) - history->uploadOffset;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1464,7 +1533,19 @@ static bool end_batch_impl(const wgpu::CommandEncoder& cmd, bool advanceFrame,
|
|||||||
// interpolation tasks pointing into it would dangle. Tie the clear to the rotation itself.
|
// interpolation tasks pointing into it would dangle. Tie the clear to the rotation itself.
|
||||||
gx::drop_pending_frame_interpolation_uniforms();
|
gx::drop_pending_frame_interpolation_uniforms();
|
||||||
}
|
}
|
||||||
const bool stereoPrepared = stereoFrame == nullptr || prepare_stereo_replay_uniforms(*stereoFrame);
|
g_pendingLateStereo = {};
|
||||||
|
++g_replayBufferGeneration;
|
||||||
|
const bool captureStereo =
|
||||||
|
advanceFrame && gx::stereo_frame_interpolation_active() && gx::frame_interpolation_replay_safe();
|
||||||
|
const StereoReplayFrame placeholder{};
|
||||||
|
const bool stereoPrepared = (stereoFrame == nullptr && !captureStereo) ||
|
||||||
|
prepare_stereo_replay_uniforms(stereoFrame != nullptr ? *stereoFrame : placeholder,
|
||||||
|
captureStereo ? &g_pendingLateStereo : nullptr);
|
||||||
|
if (captureStereo && stereoPrepared) {
|
||||||
|
g_pendingLateStereo.generation = g_replayBufferGeneration.load(std::memory_order_acquire);
|
||||||
|
} else {
|
||||||
|
g_pendingLateStereo = {};
|
||||||
|
}
|
||||||
g_uniforms.append_zeroes(gx::MaxUniformSize); // Pad the end of the buffer
|
g_uniforms.append_zeroes(gx::MaxUniformSize); // Pad the end of the buffer
|
||||||
uint64_t bufferOffset = 0;
|
uint64_t bufferOffset = 0;
|
||||||
const auto writeBuffer = [&](ByteBuffer& buf, wgpu::Buffer& out, uint64_t size, std::string_view label) {
|
const auto writeBuffer = [&](ByteBuffer& buf, wgpu::Buffer& out, uint64_t size, std::string_view label) {
|
||||||
@@ -1735,6 +1816,7 @@ void seal_frame(SealedFrame& out) noexcept {
|
|||||||
// The encode that could still have been holding these has completed: the
|
// The encode that could still have been holding these has completed: the
|
||||||
// producer joins the worker's DONE phase before it seals another frame.
|
// producer joins the worker's DONE phase before it seals another frame.
|
||||||
g_retiredBindGroups.clear();
|
g_retiredBindGroups.clear();
|
||||||
|
out.data().stereo = std::move(g_pendingLateStereo);
|
||||||
auto& passes = out.data().passes;
|
auto& passes = out.data().passes;
|
||||||
// The previous cycle already recycled these, so this normally just hands the empty vector, its
|
// The previous cycle already recycled these, so this normally just hands the empty vector, its
|
||||||
// capacity included, back to the producer.
|
// capacity included, back to the producer.
|
||||||
@@ -1752,6 +1834,80 @@ void render(SealedFrame& frame, wgpu::CommandEncoder& cmd, int32_t interpolatedF
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool has_late_stereo_replay(const SealedFrame& frame) noexcept {
|
||||||
|
const auto& data = frame.data().stereo;
|
||||||
|
return data.generation != 0 && data.generation == g_replayBufferGeneration.load(std::memory_order_acquire) &&
|
||||||
|
!data.uniforms.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool prepare_late_stereo_replay(SealedFrame& frame, wgpu::CommandEncoder& cmd, const StereoReplayFrame& stereoFrame,
|
||||||
|
float weight) {
|
||||||
|
if (!has_late_stereo_replay(frame))
|
||||||
|
return false;
|
||||||
|
auto& data = frame.data().stereo;
|
||||||
|
data.uploadBytes.resize(data.uploadSize);
|
||||||
|
auto* bytes = data.uploadBytes.data();
|
||||||
|
weight = std::clamp(weight, 0.0f, 1.0f);
|
||||||
|
for (const auto& saved : data.uniforms) {
|
||||||
|
// Interpolate once; both eyes share exactly the same scene sample.
|
||||||
|
std::span<uint8_t> uniform{bytes + saved.eyes[0].offset - data.uploadOffset, saved.current.size};
|
||||||
|
std::memcpy(uniform.data(), data.sources.data() + saved.current.offset, uniform.size());
|
||||||
|
const auto& layout = saved.layout;
|
||||||
|
if (layout.perspective && saved.previous.size == saved.current.size && weight < 1.0f) {
|
||||||
|
const auto* previous = data.sources.data() + saved.previous.offset;
|
||||||
|
const auto interpolateMatrices = [&](uint32_t offset, uint32_t count, uint32_t mask) {
|
||||||
|
for (uint32_t matrix = 0; matrix < count; ++matrix) {
|
||||||
|
if ((mask & (1u << matrix)) == 0)
|
||||||
|
continue;
|
||||||
|
const size_t at = offset + matrix * sizeof(Mat3x4<float>);
|
||||||
|
Mat3x4<float> before, current, result;
|
||||||
|
std::memcpy(&before, previous + at, sizeof(before));
|
||||||
|
std::memcpy(¤t, uniform.data() + at, sizeof(current));
|
||||||
|
const bool valid = layout.indexedMatrices ? gx::interpolate_indexed_transform(before, current, weight, result)
|
||||||
|
: gx::interpolate_transform(before, current, weight, result);
|
||||||
|
if (valid)
|
||||||
|
std::memcpy(uniform.data() + at, &result, sizeof(result));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
interpolateMatrices(layout.positionOffset, layout.positionMatrixCount, layout.positionMatrixMask);
|
||||||
|
interpolateMatrices(layout.normalOffset, layout.normalMatrixCount, layout.positionMatrixMask);
|
||||||
|
// Interpolate the game depth mapping before applying the HMD frustum.
|
||||||
|
for (size_t component = 0; component < 16; ++component) {
|
||||||
|
const size_t at = layout.projectionOffset + component * sizeof(float);
|
||||||
|
float before, current;
|
||||||
|
std::memcpy(&before, previous + at, sizeof(float));
|
||||||
|
std::memcpy(¤t, uniform.data() + at, sizeof(float));
|
||||||
|
const float value = before + (current - before) * weight;
|
||||||
|
std::memcpy(uniform.data() + at, &value, sizeof(float));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mat4x4<float> projection;
|
||||||
|
std::memcpy(&projection, uniform.data() + layout.projectionOffset, sizeof(projection));
|
||||||
|
for (uint32_t eye = 1; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
||||||
|
std::memcpy(bytes + saved.eyes[eye].offset - data.uploadOffset, uniform.data(), uniform.size());
|
||||||
|
}
|
||||||
|
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
||||||
|
write_stereo_uniform({bytes + saved.eyes[eye].offset - data.uploadOffset, saved.current.size}, layout,
|
||||||
|
stereoFrame.eyes[eye], projection, saved.viewport, data.displayRegion, data.hudScreen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Never interpolate in mapped upload memory: write-combined pages make CPU
|
||||||
|
// reads expensive even for values that were just written there.
|
||||||
|
const wgpu::BufferDescriptor descriptor{
|
||||||
|
.label = "Headset interpolation uniforms",
|
||||||
|
.usage = wgpu::BufferUsage::CopySrc,
|
||||||
|
.size = data.uploadSize,
|
||||||
|
.mappedAtCreation = true,
|
||||||
|
};
|
||||||
|
auto upload = g_device.CreateBuffer(&descriptor);
|
||||||
|
std::memcpy(upload.GetMappedRange(), bytes, data.uploadSize);
|
||||||
|
upload.Unmap();
|
||||||
|
// Command-buffer ordering keeps these writes after the preceding eye pair,
|
||||||
|
// without mutating any producer staging memory or desktop uniforms.
|
||||||
|
cmd.CopyBufferToBuffer(upload, 0, g_uniformBuffer, data.uploadOffset, data.uploadSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, const StereoReplayFrame& stereoFrame,
|
void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, const StereoReplayFrame& stereoFrame,
|
||||||
uint32_t eye, bool finalize) {
|
uint32_t eye, bool finalize) {
|
||||||
CHECK(eye < AURORA_STEREO_EYE_COUNT, "invalid stereo eye {}", eye);
|
CHECK(eye < AURORA_STEREO_EYE_COUNT, "invalid stereo eye {}", eye);
|
||||||
|
|||||||
@@ -343,6 +343,12 @@ private:
|
|||||||
// called with the renderer GPU mutex held; see SealedFrame.
|
// called with the renderer GPU mutex held; see SealedFrame.
|
||||||
void seal_frame(SealedFrame& out) noexcept;
|
void seal_frame(SealedFrame& out) noexcept;
|
||||||
|
|
||||||
|
// Retained replay owns CPU transform endpoints and reserved eye-uniform ranges.
|
||||||
|
// Call with the renderer mutex held: a mid-frame EFB submission invalidates it.
|
||||||
|
bool has_late_stereo_replay(const SealedFrame& frame) noexcept;
|
||||||
|
bool prepare_late_stereo_replay(SealedFrame& frame, wgpu::CommandEncoder& cmd, const StereoReplayFrame& stereoFrame,
|
||||||
|
float weight);
|
||||||
|
|
||||||
// Encode a sealed frame. Never touches the producer-visible recording state,
|
// Encode a sealed frame. Never touches the producer-visible recording state,
|
||||||
// so this may run concurrently with the producer's FIFO drains.
|
// so this may run concurrently with the producer's FIFO drains.
|
||||||
void render(SealedFrame& frame, wgpu::CommandEncoder& cmd, int32_t interpolatedFrame = -1, bool finalize = true);
|
void render(SealedFrame& frame, wgpu::CommandEncoder& cmd, int32_t interpolatedFrame = -1, bool finalize = true);
|
||||||
|
|||||||
@@ -2313,6 +2313,7 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
|
|||||||
.uniformRange = uniformRanges.current,
|
.uniformRange = uniformRanges.current,
|
||||||
.interpolatedUniformRanges = uniformRanges.interpolated,
|
.interpolatedUniformRanges = uniformRanges.interpolated,
|
||||||
.stereoUniformRanges = {},
|
.stereoUniformRanges = {},
|
||||||
|
.previousUniformRange = uniformRanges.previous,
|
||||||
.uniformReplayLayout = uniformRanges.replayLayout,
|
.uniformReplayLayout = uniformRanges.replayLayout,
|
||||||
.vtxCount = vtxCount,
|
.vtxCount = vtxCount,
|
||||||
.indexCount = numIndices,
|
.indexCount = numIndices,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
namespace aurora::gx {
|
namespace aurora::gx {
|
||||||
namespace detail {
|
namespace detail {
|
||||||
std::atomic_uint32_t g_frameInterpolationFps{0};
|
std::atomic_uint32_t g_frameInterpolationFps{0};
|
||||||
|
std::atomic_bool g_stereoFrameInterpolation{false};
|
||||||
} // namespace detail
|
} // namespace detail
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -790,9 +791,12 @@ void report_producer_paced(bool paced) noexcept {
|
|||||||
|
|
||||||
void begin_frame_interpolation() noexcept {
|
void begin_frame_interpolation() noexcept {
|
||||||
const uint32_t targetFps = frame_interpolation_fps();
|
const uint32_t targetFps = frame_interpolation_fps();
|
||||||
if (targetFps != s_previousInterpolationFps) {
|
static bool previousStereo = false;
|
||||||
|
const bool stereo = stereo_frame_interpolation_active();
|
||||||
|
if (targetFps != s_previousInterpolationFps || stereo != previousStereo) {
|
||||||
recycle_transform_entries(s_previousFrameTransforms);
|
recycle_transform_entries(s_previousFrameTransforms);
|
||||||
s_previousInterpolationFps = targetFps;
|
s_previousInterpolationFps = targetFps;
|
||||||
|
previousStereo = stereo;
|
||||||
}
|
}
|
||||||
// Latch the slot count for the frame starting here; see s_activeInterpolationSamples
|
// Latch the slot count for the frame starting here; see s_activeInterpolationSamples
|
||||||
// for why it cannot move again until the seal.
|
// for why it cannot move again until the seal.
|
||||||
@@ -825,8 +829,11 @@ void begin_frame_interpolation() noexcept {
|
|||||||
void finalize_frame_interpolation() noexcept {
|
void finalize_frame_interpolation() noexcept {
|
||||||
// A frame reported late seals without inserted slots, so the encode phase renders
|
// 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.
|
// the native frame only. Its transforms still seed the next frame's matching.
|
||||||
if (s_dropInterpolationAtSeal.exchange(false, std::memory_order_acq_rel)) {
|
const bool late = s_dropInterpolationAtSeal.exchange(false, std::memory_order_acq_rel);
|
||||||
|
if (late) {
|
||||||
s_diagLateSealDrops.fetch_add(1, std::memory_order_relaxed);
|
s_diagLateSealDrops.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
if (late && !stereo_frame_interpolation_active()) {
|
||||||
s_hasInterpolatedFrame.store(false, std::memory_order_release);
|
s_hasInterpolatedFrame.store(false, std::memory_order_release);
|
||||||
s_pendingUniformInterpolations.clear();
|
s_pendingUniformInterpolations.clear();
|
||||||
retire_frame_transforms();
|
retire_frame_transforms();
|
||||||
@@ -1078,7 +1085,7 @@ void finalize_frame_interpolation() noexcept {
|
|||||||
[](size_t previousIndex) { return previousIndex != SIZE_MAX; }));
|
[](size_t previousIndex) { return previousIndex != SIZE_MAX; }));
|
||||||
// Interpolation never pauses on match quality: an unmatched draw just renders its
|
// Interpolation never pauses on match quality: an unmatched draw just renders its
|
||||||
// end-frame state, while a ratio gate flapped the whole output cadence instead.
|
// end-frame state, while a ratio gate flapped the whole output cadence instead.
|
||||||
const bool eligible = frame_interpolation_fps() != 0;
|
const bool eligible = frame_interpolation_fps() != 0 && !late;
|
||||||
|
|
||||||
// Overlay observability: the live match ratio, and how often the scene sits in
|
// Overlay observability: the live match ratio, and how often the scene sits in
|
||||||
// low-match territory where inserted slots mostly duplicate draws.
|
// low-match territory where inserted slots mostly duplicate draws.
|
||||||
@@ -1127,7 +1134,7 @@ void finalize_frame_interpolation() noexcept {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eligible) {
|
if (eligible || stereo_frame_interpolation_active()) {
|
||||||
// Prepare each matched pair once: every sample of a draw shares the same
|
// Prepare each matched pair once: every sample of a draw shares the same
|
||||||
// previous/current matrices. A flat vector keeps the sample tasks parallel.
|
// previous/current matrices. A flat vector keeps the sample tasks parallel.
|
||||||
std::vector<PreparedTransformInterpolation> preparedTransforms(s_currentFrameTransforms.size());
|
std::vector<PreparedTransformInterpolation> preparedTransforms(s_currentFrameTransforms.size());
|
||||||
@@ -1445,9 +1452,14 @@ void extend_interpolation_draw(uint16_t usedPnMtxMask) noexcept {
|
|||||||
snapshot.usedMatrixMask |= addedSlots;
|
snapshot.usedMatrixMask |= addedSlots;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::array<gfx::Range, MaxInterpolatedFrames> record_interpolation_draw(
|
std::array<gfx::Range, MaxInterpolatedFrames> record_interpolation_draw(const FrameInterpolationDrawIdentity& identity,
|
||||||
const FrameInterpolationDrawIdentity& identity, const Mat4x4<float>& projection,
|
const Mat4x4<float>& projection,
|
||||||
uint16_t usedPnMtxMask, const InterpolatedUniformLayout& uniformLayout) noexcept {
|
uint16_t usedPnMtxMask,
|
||||||
|
const InterpolatedUniformLayout& uniformLayout,
|
||||||
|
gfx::Range* previousUniform) noexcept {
|
||||||
|
if (previousUniform != nullptr) {
|
||||||
|
*previousUniform = {};
|
||||||
|
}
|
||||||
FrameTransformSnapshot snapshot{
|
FrameTransformSnapshot snapshot{
|
||||||
.projection = projection,
|
.projection = projection,
|
||||||
.usedMatrixMask = usedPnMtxMask,
|
.usedMatrixMask = usedPnMtxMask,
|
||||||
@@ -1517,6 +1529,26 @@ std::array<gfx::Range, MaxInterpolatedFrames> record_interpolation_draw(
|
|||||||
});
|
});
|
||||||
interpolatedRanges[sample] = interpolatedRange;
|
interpolatedRanges[sample] = interpolatedRange;
|
||||||
}
|
}
|
||||||
|
// Keep the matched previous endpoint, in the current palette's layout, for
|
||||||
|
// arbitrary headset display times. It shares desktop matching and cut guards.
|
||||||
|
if (previousUniform != nullptr && stereo_frame_interpolation_active()) {
|
||||||
|
auto [buffer, range] = gfx::map_uniform(uniformLayout.uniformSize);
|
||||||
|
std::memcpy(buffer.data(), uniformLayout.sourceUniformData, uniformLayout.uniformSize);
|
||||||
|
s_pendingUniformInterpolations.push_back({
|
||||||
|
.currentTransformIndex = currentTransformIndex,
|
||||||
|
.sourceUniformData = uniformLayout.sourceUniformData,
|
||||||
|
.uniformData = buffer.data(),
|
||||||
|
.uniformSize = uniformLayout.uniformSize,
|
||||||
|
.projectionOffset = uniformLayout.projectionOffset,
|
||||||
|
.positionOffset = uniformLayout.positionOffset,
|
||||||
|
.normalOffset = uniformLayout.normalOffset,
|
||||||
|
.currentMatrix = uniformLayout.currentMatrix,
|
||||||
|
.numerator = 0,
|
||||||
|
.denominator = 1,
|
||||||
|
.indexedMatrices = uniformLayout.indexedMatrices,
|
||||||
|
});
|
||||||
|
*previousUniform = range;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return interpolatedRanges;
|
return interpolatedRanges;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,13 +39,19 @@ namespace detail {
|
|||||||
// Defined in frame_interpolation.cpp, exposed so the early-outs below stay inline: the shipped
|
// Defined in frame_interpolation.cpp, exposed so the early-outs below stay inline: the shipped
|
||||||
// build compiles shards without LTO, so a cross-TU call would land on every draw in the frame.
|
// build compiles shards without LTO, so a cross-TU call would land on every draw in the frame.
|
||||||
extern std::atomic_uint32_t g_frameInterpolationFps;
|
extern std::atomic_uint32_t g_frameInterpolationFps;
|
||||||
|
extern std::atomic_bool g_stereoFrameInterpolation;
|
||||||
} // namespace detail
|
} // namespace detail
|
||||||
|
|
||||||
// 0 when interpolation is disabled; otherwise the configured target (120/180/240).
|
// 0 when interpolation is disabled; otherwise the configured target (120/180/240).
|
||||||
inline uint32_t frame_interpolation_fps() noexcept {
|
inline uint32_t frame_interpolation_fps() noexcept {
|
||||||
return detail::g_frameInterpolationFps.load(std::memory_order_acquire);
|
return detail::g_frameInterpolationFps.load(std::memory_order_acquire);
|
||||||
}
|
}
|
||||||
inline bool frame_interpolation_active() noexcept { return frame_interpolation_fps() != 0; }
|
inline bool stereo_frame_interpolation_active() noexcept {
|
||||||
|
return detail::g_stereoFrameInterpolation.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
inline bool frame_interpolation_active() noexcept {
|
||||||
|
return frame_interpolation_fps() != 0 || stereo_frame_interpolation_active();
|
||||||
|
}
|
||||||
|
|
||||||
void set_frame_interpolation_fps(uint32_t targetFps) noexcept;
|
void set_frame_interpolation_fps(uint32_t targetFps) noexcept;
|
||||||
|
|
||||||
@@ -67,9 +73,11 @@ bool frame_interpolation_replay_safe() noexcept;
|
|||||||
|
|
||||||
// Records one perspective draw and maps its intermediate uniform copies, returning the mapped
|
// Records one perspective draw and maps its intermediate uniform copies, returning the mapped
|
||||||
// range per slot (empty when the draw has no counterpart). Called by build_uniform.
|
// range per slot (empty when the draw has no counterpart). Called by build_uniform.
|
||||||
std::array<gfx::Range, MaxInterpolatedFrames> record_interpolation_draw(
|
std::array<gfx::Range, MaxInterpolatedFrames> record_interpolation_draw(const FrameInterpolationDrawIdentity& identity,
|
||||||
const FrameInterpolationDrawIdentity& identity, const Mat4x4<float>& projection,
|
const Mat4x4<float>& projection,
|
||||||
uint16_t usedPnMtxMask, const InterpolatedUniformLayout& uniformLayout) noexcept;
|
uint16_t usedPnMtxMask,
|
||||||
|
const InterpolatedUniformLayout& uniformLayout,
|
||||||
|
gfx::Range* previousUniform = nullptr) noexcept;
|
||||||
|
|
||||||
// Folds a merged draw back into the snapshot of the draw it joined. aurora renders merged
|
// Folds a merged draw back into the snapshot of the draw it joined. aurora renders merged
|
||||||
// primitives through the first one's uniform block, so without this the merged-in bones tear.
|
// primitives through the first one's uniform block, so without this the merged-in bones tear.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct DrawData {
|
|||||||
gfx::Range uniformRange;
|
gfx::Range uniformRange;
|
||||||
std::array<gfx::Range, MaxInterpolatedFrames> interpolatedUniformRanges;
|
std::array<gfx::Range, MaxInterpolatedFrames> interpolatedUniformRanges;
|
||||||
std::array<gfx::Range, AURORA_STEREO_EYE_COUNT> stereoUniformRanges;
|
std::array<gfx::Range, AURORA_STEREO_EYE_COUNT> stereoUniformRanges;
|
||||||
|
gfx::Range previousUniformRange;
|
||||||
UniformReplayLayout uniformReplayLayout;
|
UniformReplayLayout uniformReplayLayout;
|
||||||
uint32_t vtxCount;
|
uint32_t vtxCount;
|
||||||
uint32_t indexCount;
|
uint32_t indexCount;
|
||||||
|
|||||||
@@ -767,10 +767,11 @@ UniformRanges build_uniform(const ShaderInfo& info, u32 vtxStart, const BindGrou
|
|||||||
.positionMatrixCount = layout.postexCount,
|
.positionMatrixCount = layout.postexCount,
|
||||||
.normalMatrixCount = layout.nrmCount,
|
.normalMatrixCount = layout.nrmCount,
|
||||||
.perspective = perspective,
|
.perspective = perspective,
|
||||||
|
.indexedMatrices = info.indexAttr.test(GX_VA_PNMTXIDX),
|
||||||
.nativeEfbEffect = !perspective && samplesRecentEfbCopy && (samplesReducedEfbCopy || blends),
|
.nativeEfbEffect = !perspective && samplesRecentEfbCopy && (samplesReducedEfbCopy || blends),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!perspective || frame_interpolation_fps() == 0) {
|
if (!perspective || !frame_interpolation_active()) {
|
||||||
g_gxState.stateDirty = false;
|
g_gxState.stateDirty = false;
|
||||||
return {
|
return {
|
||||||
.current = range,
|
.current = range,
|
||||||
@@ -779,6 +780,7 @@ UniformRanges build_uniform(const ShaderInfo& info, u32 vtxStart, const BindGrou
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gfx::Range previousUniform{};
|
||||||
const auto interpolatedRanges = record_interpolation_draw(
|
const auto interpolatedRanges = record_interpolation_draw(
|
||||||
drawIdentity, effectiveProj, usedPnMtxMask,
|
drawIdentity, effectiveProj, usedPnMtxMask,
|
||||||
InterpolatedUniformLayout{
|
InterpolatedUniformLayout{
|
||||||
@@ -790,11 +792,13 @@ UniformRanges build_uniform(const ShaderInfo& info, u32 vtxStart, const BindGrou
|
|||||||
// A compacted position region holds the current matrix at slot 0.
|
// A compacted position region holds the current matrix at slot 0.
|
||||||
.currentMatrix = layout.absolutePosRegion ? std::min<size_t>(g_gxState.currentPnMtx, MaxPnMtx - 1) : 0,
|
.currentMatrix = layout.absolutePosRegion ? std::min<size_t>(g_gxState.currentPnMtx, MaxPnMtx - 1) : 0,
|
||||||
.indexedMatrices = info.indexAttr.test(GX_VA_PNMTXIDX),
|
.indexedMatrices = info.indexAttr.test(GX_VA_PNMTXIDX),
|
||||||
});
|
},
|
||||||
|
&previousUniform);
|
||||||
g_gxState.stateDirty = false;
|
g_gxState.stateDirty = false;
|
||||||
return {
|
return {
|
||||||
.current = range,
|
.current = range,
|
||||||
.interpolated = interpolatedRanges,
|
.interpolated = interpolatedRanges,
|
||||||
|
.previous = previousUniform,
|
||||||
.replayLayout = replayLayout,
|
.replayLayout = replayLayout,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct UniformReplayLayout {
|
|||||||
uint8_t positionMatrixCount = 0;
|
uint8_t positionMatrixCount = 0;
|
||||||
uint8_t normalMatrixCount = 0;
|
uint8_t normalMatrixCount = 0;
|
||||||
bool perspective = false;
|
bool perspective = false;
|
||||||
|
bool indexedMatrices = false;
|
||||||
// A 2D draw compositing the framebuffer back over itself: bloom, blur and the
|
// 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,
|
// 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.
|
// not to the game's 2D layer, so it must stay where the game aimed it.
|
||||||
@@ -22,6 +23,7 @@ struct UniformReplayLayout {
|
|||||||
struct UniformRanges {
|
struct UniformRanges {
|
||||||
gfx::Range current;
|
gfx::Range current;
|
||||||
std::array<gfx::Range, MaxInterpolatedFrames> interpolated;
|
std::array<gfx::Range, MaxInterpolatedFrames> interpolated;
|
||||||
|
gfx::Range previous;
|
||||||
UniformReplayLayout replayLayout;
|
UniformReplayLayout replayLayout;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace aurora::stereo {
|
||||||
|
// The current scene belongs to the VI presentation boundary. Delaying scene
|
||||||
|
// motion by one guest interval lets every display sample fall between known
|
||||||
|
// endpoints; head tracking is applied afterwards at its predicted display time.
|
||||||
|
inline float interpolation_weight(uint64_t displayTime, uint64_t boundary, uint64_t interval) noexcept {
|
||||||
|
if (displayTime == 0 || boundary == 0 || interval == 0)
|
||||||
|
return 1.0f;
|
||||||
|
if (displayTime <= boundary)
|
||||||
|
return 0.0f;
|
||||||
|
return static_cast<float>(std::min(static_cast<double>(displayTime - boundary) / static_cast<double>(interval), 1.0));
|
||||||
|
}
|
||||||
|
} // namespace aurora::stereo
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
include(FetchContent)
|
include(FetchContent)
|
||||||
include(GoogleTest)
|
include(GoogleTest)
|
||||||
|
|
||||||
|
option(AURORA_GPU_SMOKE_TESTS "Build opt-in tests requiring a desktop GPU" OFF)
|
||||||
|
if (AURORA_GPU_SMOKE_TESTS AND AURORA_ENABLE_GX AND WIN32)
|
||||||
|
add_executable(stereo_frame_worker_smoke stereo_frame_worker_smoke.cpp)
|
||||||
|
target_include_directories(stereo_frame_worker_smoke PRIVATE ../lib)
|
||||||
|
target_link_libraries(stereo_frame_worker_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi
|
||||||
|
dawn::dawncpp_headers)
|
||||||
|
endif ()
|
||||||
|
|
||||||
if (NOT TARGET gtest)
|
if (NOT TARGET gtest)
|
||||||
FetchContent_Declare(googletest
|
FetchContent_Declare(googletest
|
||||||
URL https://github.com/google/googletest/archive/refs/tags/v1.17.0.tar.gz
|
URL https://github.com/google/googletest/archive/refs/tags/v1.17.0.tar.gz
|
||||||
@@ -18,6 +26,7 @@ if (AURORA_ENABLE_GX)
|
|||||||
gx_fifo_test.cpp
|
gx_fifo_test.cpp
|
||||||
gx_test_stubs.cpp
|
gx_test_stubs.cpp
|
||||||
stereo_replay_test.cpp
|
stereo_replay_test.cpp
|
||||||
|
stereo_interpolation_test.cpp
|
||||||
stereo_mirror_test.cpp
|
stereo_mirror_test.cpp
|
||||||
texture_bind_group_cache_key_test.cpp
|
texture_bind_group_cache_key_test.cpp
|
||||||
../lib/gfx/efb_ram_encoder.cpp
|
../lib/gfx/efb_ram_encoder.cpp
|
||||||
|
|||||||
@@ -200,6 +200,48 @@ static u32 read_be32_at(const std::vector<u8>& bytes, size_t offset) {
|
|||||||
(static_cast<u32>(bytes[offset + 2]) << 8) | static_cast<u32>(bytes[offset + 3]);
|
(static_cast<u32>(bytes[offset + 2]) << 8) | static_cast<u32>(bytes[offset + 3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_F(GXFifoTest, VrKeepsMatchedEndpointsWithDesktopInterpolationOff) {
|
||||||
|
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 build = [&](float x, aurora::HashType identity, bool split = false) {
|
||||||
|
aurora::gx::begin_frame_interpolation();
|
||||||
|
aurora::gfx::testing::reset_uniform_allocations();
|
||||||
|
gxState().pnMtx[0].pos.m0[3] = x;
|
||||||
|
if (split)
|
||||||
|
aurora::gx::mark_frame_interpolation_replay_unsafe();
|
||||||
|
const auto result = aurora::gx::build_uniform(info, 0, {}, {identity, identity, 7}, true);
|
||||||
|
aurora::gx::finalize_frame_interpolation();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
EXPECT_EQ(build(10, 100).previous.size, 0u); // Warm-up.
|
||||||
|
auto uniforms = build(20, 100);
|
||||||
|
ASSERT_NE(uniforms.previous.size, 0u);
|
||||||
|
const auto readX = [&](aurora::gfx::Range range) {
|
||||||
|
const auto& bytes = aurora::gfx::testing::uniform_allocation(range.offset);
|
||||||
|
float x;
|
||||||
|
std::memcpy(&x, bytes.data() + uniforms.replayLayout.positionOffset + 3 * sizeof(float), sizeof(x));
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
EXPECT_FLOAT_EQ(readX(uniforms.previous), 10);
|
||||||
|
EXPECT_FLOAT_EQ(readX(uniforms.current), 20);
|
||||||
|
EXPECT_EQ(aurora::gx::interpolated_frame_count(), 0u); // Desktop remains off.
|
||||||
|
EXPECT_TRUE(std::all_of(uniforms.interpolated.begin(), uniforms.interpolated.end(),
|
||||||
|
[](auto range) { return range.size == 0; }));
|
||||||
|
EXPECT_EQ(build(30, 200).previous.size, 0u); // Unmatched draws use current transforms.
|
||||||
|
EXPECT_EQ(build(40, 200, true).previous.size, 0u); // Readback split invalidates replay.
|
||||||
|
}
|
||||||
|
|
||||||
TEST(FrameInterpolationContract, RequiresStablePerspectiveDrawSequence) {
|
TEST(FrameInterpolationContract, RequiresStablePerspectiveDrawSequence) {
|
||||||
const auto resetInterpolation = [] {
|
const auto resetInterpolation = [] {
|
||||||
aurora::gx::set_frame_interpolation_fps(0);
|
aurora::gx::set_frame_interpolation_fps(0);
|
||||||
@@ -473,8 +515,13 @@ TEST(FrameInterpolationContract, IndexedPaletteHistoryKeepsAbsoluteVertexSlots)
|
|||||||
std::array<uint8_t, uniformSize> changedSource{};
|
std::array<uint8_t, uniformSize> changedSource{};
|
||||||
aurora::gx::begin_frame_interpolation();
|
aurora::gx::begin_frame_interpolation();
|
||||||
const auto changedRanges = recordFrame(changedTopology, 91.0f, 9.0f, changedSource);
|
const auto changedRanges = recordFrame(changedTopology, 91.0f, 9.0f, changedSource);
|
||||||
EXPECT_EQ(changedRanges[0].size, 0u);
|
|
||||||
aurora::gx::finalize_frame_interpolation();
|
aurora::gx::finalize_frame_interpolation();
|
||||||
|
// Palette borrowing may reserve a range before matching is resolved. The
|
||||||
|
// safety contract is that a changed topology never reuses the old transforms.
|
||||||
|
if (changedRanges[0].size != 0) {
|
||||||
|
const auto& unchanged = aurora::gfx::testing::uniform_allocation(changedRanges[0].offset);
|
||||||
|
EXPECT_EQ(std::memcmp(unchanged.data(), changedSource.data(), uniformSize), 0);
|
||||||
|
}
|
||||||
|
|
||||||
aurora::gx::set_frame_interpolation_fps(0);
|
aurora::gx::set_frame_interpolation_fps(0);
|
||||||
aurora::gx::begin_frame_interpolation();
|
aurora::gx::begin_frame_interpolation();
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// Optional GPU smoke test: a 60 Hz GX producer with an independent 90 Hz
|
||||||
|
// compositor. Exercises the real frame worker, eye replay and submission sink.
|
||||||
|
#include <aurora/aurora.h>
|
||||||
|
#include <aurora/gfx.h>
|
||||||
|
#include <aurora/main.h>
|
||||||
|
#include <dolphin/gx.h>
|
||||||
|
#include <dolphin/mtx.h>
|
||||||
|
#include "stereo.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
static std::mutex packetMutex;
|
||||||
|
static std::condition_variable packetCv;
|
||||||
|
static AuroraStereoFrame packet{};
|
||||||
|
static bool available = false;
|
||||||
|
static bool stop = false;
|
||||||
|
static uint64_t completed = 0;
|
||||||
|
static std::atomic_uint32_t submitted{0};
|
||||||
|
|
||||||
|
static bool Provide(uint32_t, AuroraStereoFrame* output, void*) {
|
||||||
|
std::lock_guard lock(packetMutex);
|
||||||
|
if (!available)
|
||||||
|
return false;
|
||||||
|
*output = packet;
|
||||||
|
available = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
static bool Encode(wgpu::CommandEncoder&, const aurora::stereo::SinkFrame&, void*) noexcept { return true; }
|
||||||
|
static void Submitted(const aurora::stereo::SinkFrame& frame, void*) noexcept {
|
||||||
|
std::lock_guard lock(packetMutex);
|
||||||
|
completed = frame.frameToken;
|
||||||
|
++submitted;
|
||||||
|
packetCv.notify_all();
|
||||||
|
}
|
||||||
|
static void Log(AuroraLogLevel level, const char* module, const char* message, unsigned int length) {
|
||||||
|
if (level >= LOG_WARNING)
|
||||||
|
std::fprintf(stderr, "%s: %.*s\n", module, static_cast<int>(length), message);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 interpolate = argc < 3 || std::atoi(argv[2]) != 0;
|
||||||
|
std::filesystem::create_directories("stereo-smoke-cache");
|
||||||
|
AuroraConfig config{};
|
||||||
|
config.appName = "Aurora VR interpolation smoke";
|
||||||
|
config.userPath = ".";
|
||||||
|
config.cachePath = "stereo-smoke-cache";
|
||||||
|
config.desiredBackend = BACKEND_D3D12;
|
||||||
|
config.windowWidth = 160;
|
||||||
|
config.windowHeight = 120;
|
||||||
|
config.hasWindowPosition = true;
|
||||||
|
config.windowPosX = -30000;
|
||||||
|
config.windowPosY = -30000;
|
||||||
|
config.logCallback = Log;
|
||||||
|
config.logLevel = LOG_WARNING;
|
||||||
|
config.xrInterop = true;
|
||||||
|
aurora_initialize(argc, argv, &config);
|
||||||
|
aurora_set_frame_interpolation_fps(0);
|
||||||
|
aurora_set_stereo_frame_interpolation(interpolate);
|
||||||
|
aurora_set_stereo_frame_provider(Provide, nullptr);
|
||||||
|
aurora::stereo::set_sink(Encode, Submitted, nullptr);
|
||||||
|
|
||||||
|
std::thread compositor([] {
|
||||||
|
const auto start = Clock::now();
|
||||||
|
for (uint64_t token = 1;; ++token) {
|
||||||
|
const auto deadline = start + std::chrono::nanoseconds(token * 1'000'000'000 / 90);
|
||||||
|
std::this_thread::sleep_until(deadline);
|
||||||
|
{
|
||||||
|
std::lock_guard lock(packetMutex);
|
||||||
|
if (stop)
|
||||||
|
return;
|
||||||
|
packet = {};
|
||||||
|
packet.frameToken = token;
|
||||||
|
packet.contentTag = 42;
|
||||||
|
packet.displayTimeNanos =
|
||||||
|
std::chrono::duration_cast<std::chrono::nanoseconds>(deadline.time_since_epoch()).count();
|
||||||
|
for (auto& eye : packet.eyes) {
|
||||||
|
eye.width = 160;
|
||||||
|
eye.height = 120;
|
||||||
|
eye.projection[0] = eye.projection[5] = 1;
|
||||||
|
eye.projection[10] = -1;
|
||||||
|
eye.projection[11] = -1;
|
||||||
|
eye.projection[14] = -1;
|
||||||
|
eye.viewFromCenter[0] = eye.viewFromCenter[5] = eye.viewFromCenter[10] = 1;
|
||||||
|
}
|
||||||
|
available = true;
|
||||||
|
}
|
||||||
|
aurora_notify_stereo_frame();
|
||||||
|
std::unique_lock lock(packetMutex);
|
||||||
|
packetCv.wait(lock, [&] { return stop || completed == token; });
|
||||||
|
if (stop)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const auto start = Clock::now();
|
||||||
|
for (uint64_t frame = 0; frame < 240; ++frame) {
|
||||||
|
const auto boundary = start + std::chrono::nanoseconds((frame + 1) * 1'000'000'000 / 60);
|
||||||
|
std::this_thread::sleep_until(boundary);
|
||||||
|
aurora_update();
|
||||||
|
if (!aurora_begin_frame())
|
||||||
|
continue;
|
||||||
|
aurora_set_present_schedule(
|
||||||
|
std::chrono::duration_cast<std::chrono::nanoseconds>(boundary.time_since_epoch()).count(), 16'666'667);
|
||||||
|
Mtx44 projection{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, -1, -1}, {0, 0, -1, 0}};
|
||||||
|
Mtx transform{{1, 0, 0, static_cast<float>(frame % 60) * 0.01f}, {0, 1, 0, 0}, {0, 0, 1, -3}};
|
||||||
|
GXSetProjection(projection, GX_PERSPECTIVE);
|
||||||
|
GXSetCurrentMtx(GX_PNMTX0);
|
||||||
|
GXSetViewport(0, 0, 160, 120, 0, 1);
|
||||||
|
GXSetScissor(0, 0, 160, 120);
|
||||||
|
GXClearVtxDesc();
|
||||||
|
GXSetVtxDesc(GX_VA_POS, GX_DIRECT);
|
||||||
|
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_F32, 0);
|
||||||
|
GXSetNumTexGens(0);
|
||||||
|
GXSetNumChans(0);
|
||||||
|
GXSetNumTevStages(1);
|
||||||
|
GXSetTevOrder(GX_TEVSTAGE0, GX_TEXCOORD_NULL, GX_TEXMAP_NULL, GX_COLOR_NULL);
|
||||||
|
GXSetTevOp(GX_TEVSTAGE0, GX_PASSCLR);
|
||||||
|
for (unsigned draw = 0; draw < drawCount; ++draw) {
|
||||||
|
transform[1][3] = static_cast<float>(draw % 20) * 0.01f;
|
||||||
|
GXLoadPosMtxImm(transform, GX_PNMTX0);
|
||||||
|
GXBegin(GX_TRIANGLES, GX_VTXFMT0, 3);
|
||||||
|
GXPosition3f32(-1 + static_cast<float>(draw) * 0.0001f, -1, 0);
|
||||||
|
GXPosition3f32(1, -1, 0);
|
||||||
|
GXPosition3f32(0, 1, 0);
|
||||||
|
GXEnd();
|
||||||
|
}
|
||||||
|
aurora_end_frame_tagged(42);
|
||||||
|
if (drawCount > 1 && (frame + 1) % 60 == 0) {
|
||||||
|
std::printf("Completed %llu producer frames in %.2f s\n", static_cast<unsigned long long>(frame + 1),
|
||||||
|
std::chrono::duration<double>(Clock::now() - start).count());
|
||||||
|
std::fflush(stdout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard lock(packetMutex);
|
||||||
|
stop = true;
|
||||||
|
packetCv.notify_all();
|
||||||
|
}
|
||||||
|
compositor.join();
|
||||||
|
aurora_set_stereo_frame_interpolation(false);
|
||||||
|
aurora_quiesce_frame_worker();
|
||||||
|
aurora_set_stereo_frame_provider(nullptr, nullptr);
|
||||||
|
aurora::stereo::set_sink(nullptr, nullptr);
|
||||||
|
const double elapsed = std::chrono::duration<double>(Clock::now() - start).count();
|
||||||
|
const double fps = submitted.load() / elapsed;
|
||||||
|
std::printf("%u draws: producer %.1f FPS; %u stereo submissions in %.2f s (%.1f FPS)\n", drawCount, 240 / elapsed,
|
||||||
|
submitted.load(), elapsed, fps);
|
||||||
|
aurora_shutdown();
|
||||||
|
// More headset submissions must not come at the expense of simulation speed.
|
||||||
|
return 240 / elapsed > 55 && fps > (interpolate ? 85 : 55) && fps < (interpolate ? 100 : 65) ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#include "stereo_interpolation.hpp"
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
TEST(StereoInterpolation, ContinuousMotionAcross60HzScenesAtHeadsetRates) {
|
||||||
|
constexpr uint64_t interval = 16'666'667;
|
||||||
|
// A camera/object moving one unit per guest frame must advance uniformly,
|
||||||
|
// even at 72/90 Hz where many samples are neither midpoints nor endpoints.
|
||||||
|
for (uint64_t hz : {72u, 90u, 120u}) {
|
||||||
|
double previousPosition = -1;
|
||||||
|
for (uint64_t sample = 1; sample <= hz; ++sample) {
|
||||||
|
const uint64_t displayTime = 1'000'000'000 + sample * 1'000'000'000 / hz;
|
||||||
|
const uint64_t scene = (displayTime - 1'000'000'000) / interval;
|
||||||
|
const uint64_t boundary = 1'000'000'000 + scene * interval;
|
||||||
|
const float weight = aurora::stereo::interpolation_weight(displayTime, boundary, interval);
|
||||||
|
const double position = static_cast<double>(scene) + weight;
|
||||||
|
if (sample > 1)
|
||||||
|
EXPECT_NEAR(position - previousPosition, 1'000'000'000.0 / hz / interval, 1e-5);
|
||||||
|
previousPosition = position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(StereoInterpolation, MissingTimingAndStallsDoNotExtrapolate) {
|
||||||
|
using aurora::stereo::interpolation_weight;
|
||||||
|
EXPECT_FLOAT_EQ(interpolation_weight(0, 100, 10), 1);
|
||||||
|
EXPECT_FLOAT_EQ(interpolation_weight(105, 0, 10), 1);
|
||||||
|
EXPECT_FLOAT_EQ(interpolation_weight(105, 100, 0), 1);
|
||||||
|
EXPECT_FLOAT_EQ(interpolation_weight(95, 100, 10), 0);
|
||||||
|
EXPECT_FLOAT_EQ(interpolation_weight(105, 100, 10), 0.5);
|
||||||
|
EXPECT_FLOAT_EQ(interpolation_weight(500, 100, 10), 1);
|
||||||
|
}
|
||||||
@@ -333,6 +333,11 @@ set_target_properties(mkw_platform PROPERTIES UNITY_BUILD OFF)
|
|||||||
# Keep these independent from Aurora's BUILD_TESTING option: they validate the
|
# Keep these independent from Aurora's BUILD_TESTING option: they validate the
|
||||||
# project's host-platform contracts, not Aurora's third-party test suite.
|
# project's host-platform contracts, not Aurora's third-party test suite.
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
add_executable(mkw_frame_interpolation_pacing_tests tests/frame_interpolation_pacing_tests.cpp)
|
||||||
|
target_include_directories(mkw_frame_interpolation_pacing_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||||
|
target_link_libraries(mkw_frame_interpolation_pacing_tests PRIVATE mkw::toml11)
|
||||||
|
target_compile_features(mkw_frame_interpolation_pacing_tests PRIVATE cxx_std_20)
|
||||||
|
add_test(NAME mkw_frame_interpolation_pacing_tests COMMAND mkw_frame_interpolation_pacing_tests)
|
||||||
add_executable(mkw_platform_paths_tests "${CMAKE_CURRENT_LIST_DIR}/tests/platform_paths_tests.cpp")
|
add_executable(mkw_platform_paths_tests "${CMAKE_CURRENT_LIST_DIR}/tests/platform_paths_tests.cpp")
|
||||||
target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
|
target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
|
||||||
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
|
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <toml.hpp>
|
#include <toml.hpp>
|
||||||
#include "platform/host_platform.h"
|
#include "platform/host_platform.h"
|
||||||
|
#include "vr/frame_interpolation_pacing.h"
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#ifndef WIN32_LEAN_AND_MEAN
|
#ifndef WIN32_LEAN_AND_MEAN
|
||||||
#define WIN32_LEAN_AND_MEAN
|
#define WIN32_LEAN_AND_MEAN
|
||||||
@@ -57,7 +58,7 @@ struct RuntimeUserConfig {
|
|||||||
std::optional<bool> vrStopAtDisplayCopy;
|
std::optional<bool> vrStopAtDisplayCopy;
|
||||||
std::optional<bool> vrSkipCopyClears;
|
std::optional<bool> vrSkipCopyClears;
|
||||||
std::optional<std::string> vrMirrorView;
|
std::optional<std::string> vrMirrorView;
|
||||||
std::optional<bool> vrEagerFrameHeartbeat;
|
std::optional<uint32_t> vrFrameInterpolationFps;
|
||||||
std::optional<bool> vrFirstPerson;
|
std::optional<bool> vrFirstPerson;
|
||||||
std::optional<float> vrFirstPersonUnitsPerMeter;
|
std::optional<float> vrFirstPersonUnitsPerMeter;
|
||||||
std::optional<float> vrFirstPersonHeadUpMeters;
|
std::optional<float> vrFirstPersonHeadUpMeters;
|
||||||
@@ -380,8 +381,8 @@ inline void EnsureConfigFile() {
|
|||||||
"# \"right\" mirror the headset's eyes, and \"none\" blacks the window\n"
|
"# \"right\" mirror the headset's eyes, and \"none\" blacks the window\n"
|
||||||
"# out. Changeable live from the F10 menu.\n"
|
"# out. Changeable live from the F10 menu.\n"
|
||||||
"mirror_view = \"normal\"\n"
|
"mirror_view = \"normal\"\n"
|
||||||
"# Repeat at headset cadence (true), or only during stalls (false). Live.\n"
|
"# VR interpolation: 0 = Off, 1 = Auto, or 72/90/120 FPS. Live.\n"
|
||||||
"eager_frame_heartbeat = false\n"
|
"frame_interpolation_fps = 0\n"
|
||||||
"render_scale = 1.0\n"
|
"render_scale = 1.0\n"
|
||||||
"world_units_per_meter = 500.0\n"
|
"world_units_per_meter = 500.0\n"
|
||||||
"hud_distance_meters = 2.0\n"
|
"hud_distance_meters = 2.0\n"
|
||||||
@@ -642,7 +643,12 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
|||||||
value && IsSupportedVrMirrorView(*value)) {
|
value && IsSupportedVrMirrorView(*value)) {
|
||||||
config.vrMirrorView = *value;
|
config.vrMirrorView = *value;
|
||||||
}
|
}
|
||||||
config.vrEagerFrameHeartbeat = FindConfigValue<bool>(document, "vr", "eager_frame_heartbeat");
|
config.vrFrameInterpolationFps = FindConfigValue<uint32_t>(document, "vr", "frame_interpolation_fps");
|
||||||
|
if (!config.vrFrameInterpolationFps) {
|
||||||
|
// Migrate the initial experimental checkbox to Auto.
|
||||||
|
if (const auto legacy = FindConfigValue<bool>(document, "vr", "frame_interpolation"))
|
||||||
|
config.vrFrameInterpolationFps = *legacy ? 1u : 0u;
|
||||||
|
}
|
||||||
if (auto value = FindConfigInt(document, "vr", "first_person_hidden_model");
|
if (auto value = FindConfigInt(document, "vr", "first_person_hidden_model");
|
||||||
value && *value >= -1 && *value <= 31) {
|
value && *value >= -1 && *value <= 31) {
|
||||||
config.vrFirstPersonHiddenModel = static_cast<int32_t>(*value);
|
config.vrFirstPersonHiddenModel = static_cast<int32_t>(*value);
|
||||||
@@ -944,9 +950,10 @@ inline bool SetVrMirrorView(std::string value) {
|
|||||||
return WriteSetting("vr", "mirror_view", FormatString(value));
|
return WriteSetting("vr", "mirror_view", FormatString(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
inline bool SetVrEagerFrameHeartbeat(bool value) {
|
inline bool SetVrFrameInterpolationFps(uint32_t value) {
|
||||||
Mutable().vrEagerFrameHeartbeat = value;
|
value = mkw::vr::NormalizeFrameInterpolationFps(value);
|
||||||
return WriteSetting("vr", "eager_frame_heartbeat", value ? "true" : "false");
|
Mutable().vrFrameInterpolationFps = value;
|
||||||
|
return WriteSetting("vr", "frame_interpolation_fps", std::to_string(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
inline bool SetVrFirstPersonRotation(std::string value) {
|
inline bool SetVrFirstPersonRotation(std::string value) {
|
||||||
@@ -1281,8 +1288,8 @@ inline std::string VrMirrorView(std::string fallback = kVrMirrorViewDefault) {
|
|||||||
return value && IsSupportedVrMirrorView(*value) ? *value : std::move(fallback);
|
return value && IsSupportedVrMirrorView(*value) ? *value : std::move(fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
inline bool VrEagerFrameHeartbeat() {
|
inline uint32_t VrFrameInterpolationFps() {
|
||||||
return Get().vrEagerFrameHeartbeat.value_or(false);
|
return mkw::vr::NormalizeFrameInterpolationFps(Get().vrFrameInterpolationFps.value_or(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
inline std::string VrFirstPersonRotation(std::string fallback = kVrFirstPersonRotationDefault) {
|
inline std::string VrFirstPersonRotation(std::string fallback = kVrFirstPersonRotationDefault) {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace mkw::vr {
|
||||||
|
inline uint32_t NormalizeFrameInterpolationFps(uint32_t value) noexcept {
|
||||||
|
return value == 1 || value == 72 || value == 90 || value == 120 ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A render-rate ceiling on the compositor's own display-time grid. Auto (1)
|
||||||
|
// renders every tick. Fixed targets cannot increase the physical refresh rate.
|
||||||
|
class FrameInterpolationPacing {
|
||||||
|
public:
|
||||||
|
bool ShouldRender(int64_t display_time, uint32_t target) noexcept {
|
||||||
|
if (target != target_ || display_time <= last_time_) {
|
||||||
|
next_time_ = 0;
|
||||||
|
target_ = target;
|
||||||
|
}
|
||||||
|
last_time_ = display_time;
|
||||||
|
if (target == 0 || target == 1) {
|
||||||
|
next_time_ = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (next_time_ != 0 && display_time + 1'000 < next_time_) return false;
|
||||||
|
const int64_t interval = 1'000'000'000 / target;
|
||||||
|
if (next_time_ == 0 || display_time - next_time_ > interval * 2) {
|
||||||
|
next_time_ = display_time + interval;
|
||||||
|
} else {
|
||||||
|
next_time_ += interval;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void Reset() noexcept { *this = {}; }
|
||||||
|
private:
|
||||||
|
int64_t next_time_ = 0;
|
||||||
|
int64_t last_time_ = 0;
|
||||||
|
uint32_t target_ = 0;
|
||||||
|
};
|
||||||
|
} // namespace mkw::vr
|
||||||
@@ -53,8 +53,14 @@ void OpenXRRequestRecenter() noexcept;
|
|||||||
// once per published frame.
|
// once per published frame.
|
||||||
void OpenXRSetLeanBackDegrees(float degrees) noexcept;
|
void OpenXRSetLeanBackDegrees(float degrees) noexcept;
|
||||||
|
|
||||||
// Live pacing choice. Disabled: submit fresh frames promptly and repeat during
|
// Live scene interpolation at the headset's own display deadlines.
|
||||||
// stalls. Enabled: repeat at each headset display deadline while waiting.
|
// 0 = Off, 1 = Auto, otherwise 72/90/120 as a rendering-rate ceiling.
|
||||||
void OpenXRSetEagerFrameHeartbeat(bool enabled) noexcept;
|
void OpenXRSetFrameInterpolationFps(uint32_t target) noexcept;
|
||||||
|
bool OpenXRFrameInterpolationAvailable() noexcept;
|
||||||
|
struct OpenXRFrameTiming {
|
||||||
|
float headset_hz = 0;
|
||||||
|
float rendered_fps = 0; // Newly rendered pairs; excludes retained-layer repeats.
|
||||||
|
};
|
||||||
|
OpenXRFrameTiming OpenXRGetFrameTiming() noexcept;
|
||||||
|
|
||||||
} // namespace mkw::vr
|
} // namespace mkw::vr
|
||||||
@@ -117,7 +117,13 @@ float g_vrFirstPersonHeadUp = RuntimeConfigFile::VrFirstPersonHeadUpMeters();
|
|||||||
float g_vrFirstPersonHeadForward = RuntimeConfigFile::VrFirstPersonHeadForwardMeters();
|
float g_vrFirstPersonHeadForward = RuntimeConfigFile::VrFirstPersonHeadForwardMeters();
|
||||||
float g_vrFirstPersonHeadRight = RuntimeConfigFile::VrFirstPersonHeadRightMeters();
|
float g_vrFirstPersonHeadRight = RuntimeConfigFile::VrFirstPersonHeadRightMeters();
|
||||||
bool g_vrFirstPersonHideDriver = RuntimeConfigFile::VrFirstPersonHideDriver();
|
bool g_vrFirstPersonHideDriver = RuntimeConfigFile::VrFirstPersonHideDriver();
|
||||||
bool g_vrEagerFrameHeartbeat = RuntimeConfigFile::VrEagerFrameHeartbeat();
|
constexpr std::array<uint32_t, 5> kVrInterpolationFps{0, 1, 72, 90, 120};
|
||||||
|
constexpr std::array<const char*, 5> kVrInterpolationLabels{"Off", "Auto", "72", "90", "120"};
|
||||||
|
int g_vrFrameInterpolationMode = [] {
|
||||||
|
const auto value = RuntimeConfigFile::VrFrameInterpolationFps();
|
||||||
|
return static_cast<int>(std::find(kVrInterpolationFps.begin(), kVrInterpolationFps.end(), value) -
|
||||||
|
kVrInterpolationFps.begin());
|
||||||
|
}();
|
||||||
int g_vrFirstPersonHiddenModel = RuntimeConfigFile::VrFirstPersonHiddenModel();
|
int g_vrFirstPersonHiddenModel = RuntimeConfigFile::VrFirstPersonHiddenModel();
|
||||||
// Config spellings and menu labels for the desktop mirror, index-matched to
|
// Config spellings and menu labels for the desktop mirror, index-matched to
|
||||||
// AuroraStereoMirrorView so the combo selection converts to either directly.
|
// AuroraStereoMirrorView so the combo selection converts to either directly.
|
||||||
@@ -931,15 +937,25 @@ void DrawVrSettings() {
|
|||||||
"same desktop image, so the eye choices only differ from Normal during a race.");
|
"same desktop image, so the eye choices only differ from Normal during a race.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ImGui::Checkbox("Eager Frame Heartbeat", &g_vrEagerFrameHeartbeat)) {
|
if (ImGui::Combo("VR frame interpolation (experimental)", &g_vrFrameInterpolationMode,
|
||||||
mkw::vr::OpenXRSetEagerFrameHeartbeat(g_vrEagerFrameHeartbeat);
|
kVrInterpolationLabels.data(), static_cast<int>(kVrInterpolationLabels.size()))) {
|
||||||
RuntimeConfigFile::SetVrEagerFrameHeartbeat(g_vrEagerFrameHeartbeat);
|
const auto target = kVrInterpolationFps[static_cast<size_t>(g_vrFrameInterpolationMode)];
|
||||||
|
mkw::vr::OpenXRSetFrameInterpolationFps(target);
|
||||||
|
RuntimeConfigFile::SetVrFrameInterpolationFps(target);
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) {
|
if (ImGui::IsItemHovered()) {
|
||||||
ImGui::SetTooltip(
|
ImGui::SetTooltip(
|
||||||
"On: repeat the last frame at headset refresh rate while waiting for the game. "
|
"Auto matches the headset refresh rate. 72, 90 and 120 cap the scene rendering rate; "
|
||||||
"Off (default): follow the game's frame rate, with repeats during stalls. "
|
"set the headset's refresh rate in Virtual Desktop or your VR runtime. "
|
||||||
"Compare both during a race to check headset smoothness. Applies immediately.");
|
"The game stays at 60 Hz. Adds one game frame of scene latency; head tracking stays current. "
|
||||||
|
"Needs GPU headroom and may show interpolation artifacts. Applies immediately.");
|
||||||
|
}
|
||||||
|
const auto xrTiming = mkw::vr::OpenXRGetFrameTiming();
|
||||||
|
if (mkw::vr::OpenXRIsRunning()) {
|
||||||
|
ImGui::TextDisabled("Headset: %.1f Hz | New VR frames: %.1f FPS", xrTiming.headset_hz, xrTiming.rendered_fps);
|
||||||
|
if (g_vrFrameInterpolationMode != 0 && !mkw::vr::OpenXRFrameInterpolationAvailable()) {
|
||||||
|
ImGui::TextWrapped("The OpenXR runtime does not provide the clock conversion needed for interpolation.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Like the mirror above and unlike the enable toggle, these two apply to the
|
// Like the mirror above and unlike the enable toggle, these two apply to the
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "vr/mkw_vr_first_person.h"
|
#include "vr/mkw_vr_first_person.h"
|
||||||
#include "vr/mkw_vr_policy.h"
|
#include "vr/mkw_vr_policy.h"
|
||||||
#include "vr/mkw_vr_instrumentation.h"
|
#include "vr/mkw_vr_instrumentation.h"
|
||||||
|
#include <aurora/gfx.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
@@ -267,11 +268,21 @@ public:
|
|||||||
config.engine_name = "Aurora";
|
config.engine_name = "Aurora";
|
||||||
config.resolution_scale = RuntimeConfigFile::VrRenderScale(1.0f);
|
config.resolution_scale = RuntimeConfigFile::VrRenderScale(1.0f);
|
||||||
config.required_extensions = {"XR_KHR_D3D12_enable"};
|
config.required_extensions = {"XR_KHR_D3D12_enable"};
|
||||||
|
config.optional_extensions = {"XR_KHR_win32_convert_performance_counter_time", "XR_FB_display_refresh_rate"};
|
||||||
if (!runtime_->Initialize(config)) {
|
if (!runtime_->Initialize(config)) {
|
||||||
SetError("OpenXR instance initialization failed: " + runtime_->LastError().message);
|
SetError("OpenXR instance initialization failed: " + runtime_->LastError().message);
|
||||||
ResetPreparedObjects();
|
ResetPreparedObjects();
|
||||||
return OpenXRStartupResult::Unavailable;
|
return OpenXRStartupResult::Unavailable;
|
||||||
}
|
}
|
||||||
|
const auto& extensions = runtime_->EnabledExtensions();
|
||||||
|
if (std::find(extensions.begin(), extensions.end(),
|
||||||
|
"XR_KHR_win32_convert_performance_counter_time") != extensions.end()) {
|
||||||
|
runtime_->LoadFunction("xrConvertTimeToWin32PerformanceCounterKHR", &convert_display_time_);
|
||||||
|
}
|
||||||
|
if (std::find(extensions.begin(), extensions.end(), "XR_FB_display_refresh_rate") != extensions.end()) {
|
||||||
|
runtime_->LoadFunction("xrGetDisplayRefreshRateFB", &get_display_refresh_rate_);
|
||||||
|
}
|
||||||
|
interpolation_available_.store(convert_display_time_ != nullptr, std::memory_order_release);
|
||||||
if (!backend_->QueryGraphicsRequirements(*runtime_)) {
|
if (!backend_->QueryGraphicsRequirements(*runtime_)) {
|
||||||
SetError(backend_->LastError());
|
SetError(backend_->LastError());
|
||||||
ResetPreparedObjects();
|
ResetPreparedObjects();
|
||||||
@@ -304,6 +315,10 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop_.store(false, std::memory_order_release);
|
stop_.store(false, std::memory_order_release);
|
||||||
|
{
|
||||||
|
std::lock_guard lock(interpolation_mutex_);
|
||||||
|
interpolation_stopping_ = false;
|
||||||
|
}
|
||||||
teardown_requested_.store(false, std::memory_order_release);
|
teardown_requested_.store(false, std::memory_order_release);
|
||||||
WithdrawPublishedFrame();
|
WithdrawPublishedFrame();
|
||||||
aurora_set_stereo_frame_provider(&OpenXRIntegration::ProvideStereoFrame, this);
|
aurora_set_stereo_frame_provider(&OpenXRIntegration::ProvideStereoFrame, this);
|
||||||
@@ -325,6 +340,12 @@ public:
|
|||||||
|
|
||||||
void Shutdown() noexcept {
|
void Shutdown() noexcept {
|
||||||
teardown_requested_.store(false, std::memory_order_release);
|
teardown_requested_.store(false, std::memory_order_release);
|
||||||
|
// Stop idle replays before draining; no new worker job may race provider removal.
|
||||||
|
{
|
||||||
|
std::lock_guard lock(interpolation_mutex_);
|
||||||
|
interpolation_stopping_ = true;
|
||||||
|
aurora_set_stereo_frame_interpolation(false);
|
||||||
|
}
|
||||||
if (pacing_thread_.joinable()) {
|
if (pacing_thread_.joinable()) {
|
||||||
// Registration changes are only safe while no sealed frame is in
|
// Registration changes are only safe while no sealed frame is in
|
||||||
// flight. The caller invokes us before Aurora teardown.
|
// flight. The caller invokes us before Aurora teardown.
|
||||||
@@ -354,6 +375,11 @@ public:
|
|||||||
backend_.reset();
|
backend_.reset();
|
||||||
runtime_.reset();
|
runtime_.reset();
|
||||||
prepared_ = false;
|
prepared_ = false;
|
||||||
|
convert_display_time_ = nullptr;
|
||||||
|
get_display_refresh_rate_ = nullptr;
|
||||||
|
headset_hz_.store(0, std::memory_order_relaxed);
|
||||||
|
rendered_fps_.store(0, std::memory_order_relaxed);
|
||||||
|
interpolation_available_.store(false, std::memory_order_release);
|
||||||
ResetTrackingOrigin();
|
ResetTrackingOrigin();
|
||||||
applied_session_run_serial_ = 0;
|
applied_session_run_serial_ = 0;
|
||||||
session_was_active_ = false;
|
session_was_active_ = false;
|
||||||
@@ -365,8 +391,16 @@ public:
|
|||||||
recenter_requested_.store(true, std::memory_order_release);
|
recenter_requested_.store(true, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SetEagerFrameHeartbeat(bool enabled) noexcept {
|
void SetFrameInterpolationFps(uint32_t target) noexcept {
|
||||||
eager_frame_heartbeat_.store(enabled, std::memory_order_relaxed);
|
frame_interpolation_fps_.store(NormalizeFrameInterpolationFps(target), std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenXRFrameTiming FrameTiming() const noexcept {
|
||||||
|
return {headset_hz_.load(std::memory_order_relaxed), rendered_fps_.load(std::memory_order_relaxed)};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FrameInterpolationAvailable() const noexcept {
|
||||||
|
return interpolation_available_.load(std::memory_order_acquire);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SetLeanBackDegrees(float degrees) noexcept {
|
void SetLeanBackDegrees(float degrees) noexcept {
|
||||||
@@ -463,6 +497,9 @@ private:
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!session_active) {
|
if (!session_active) {
|
||||||
|
SetInterpolationActive(false);
|
||||||
|
interpolation_pacing_.Reset();
|
||||||
|
rendered_fps_.store(0, std::memory_order_relaxed);
|
||||||
WaitForStopOrDelay(std::chrono::milliseconds(5));
|
WaitForStopOrDelay(std::chrono::milliseconds(5));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -495,6 +532,11 @@ private:
|
|||||||
presentation.quad_distance_meters = policy.config.hud_distance_meters;
|
presentation.quad_distance_meters = policy.config.hud_distance_meters;
|
||||||
presentation.quad_width_meters = policy.config.hud_width_meters;
|
presentation.quad_width_meters = policy.config.hud_width_meters;
|
||||||
|
|
||||||
|
// Updating this on the owner thread also confines retained replay to
|
||||||
|
// validated race content. The provider checks policy tags again.
|
||||||
|
const uint32_t interpolation_target = frame_interpolation_fps_.load(std::memory_order_relaxed);
|
||||||
|
SetInterpolationActive(immersive && FrameInterpolationAvailable() && interpolation_target != 0);
|
||||||
|
|
||||||
OpenXRD3D12Frame frame{};
|
OpenXRD3D12Frame frame{};
|
||||||
const OpenXRD3D12BeginStatus begin = backend_->BeginFrame(presentation, frame);
|
const OpenXRD3D12BeginStatus begin = backend_->BeginFrame(presentation, frame);
|
||||||
if (begin == OpenXRD3D12BeginStatus::SessionNotRunning) {
|
if (begin == OpenXRD3D12BeginStatus::SessionNotRunning) {
|
||||||
@@ -511,6 +553,7 @@ private:
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateFrameTiming(frame.xr_frame);
|
||||||
// Both of these read this frame's located head pose and must run
|
// Both of these read this frame's located head pose and must run
|
||||||
// before FinishFrame submits a layer built from it.
|
// before FinishFrame submits a layer built from it.
|
||||||
ServiceRecenterRequest();
|
ServiceRecenterRequest();
|
||||||
@@ -524,6 +567,15 @@ private:
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (aurora_get_stereo_frame_interpolation() &&
|
||||||
|
!interpolation_pacing_.ShouldRender(frame.xr_frame.predicted_display_time, interpolation_target)) {
|
||||||
|
if (!backend_->TryCancelPendingFrame(frame) || !backend_->FinishFrame(frame, false)) {
|
||||||
|
SetError(backend_->LastError());
|
||||||
|
fatal = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
std::lock_guard lock(published_mutex_);
|
std::lock_guard lock(published_mutex_);
|
||||||
// First person renders at life-size scale, third person at the
|
// First person renders at life-size scale, third person at the
|
||||||
@@ -534,6 +586,7 @@ private:
|
|||||||
policy.content_tag);
|
policy.content_tag);
|
||||||
published_.store(&published_frame_, std::memory_order_release);
|
published_.store(&published_frame_, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
aurora_notify_stereo_frame();
|
||||||
|
|
||||||
OpenXRD3D12SubmissionStatus submission = OpenXRD3D12SubmissionStatus::Timeout;
|
OpenXRD3D12SubmissionStatus submission = OpenXRD3D12SubmissionStatus::Timeout;
|
||||||
bool canceled_before_encode = false;
|
bool canceled_before_encode = false;
|
||||||
@@ -541,15 +594,9 @@ private:
|
|||||||
std::chrono::steady_clock::now() + std::chrono::milliseconds(50);
|
std::chrono::steady_clock::now() + std::chrono::milliseconds(50);
|
||||||
while (!stop_.load(std::memory_order_acquire) &&
|
while (!stop_.load(std::memory_order_acquire) &&
|
||||||
submission == OpenXRD3D12SubmissionStatus::Timeout) {
|
submission == OpenXRD3D12SubmissionStatus::Timeout) {
|
||||||
// Eager mode fills missed headset slots. With it off, completion
|
// Fresh rendering wakes us immediately. A 50 ms keep-alive
|
||||||
// wakes us immediately at the game's cadence, while a 50 ms
|
// protects stalls without issuing eager repeats during GPU work.
|
||||||
// keep-alive still protects pauses and window drags from black.
|
submission = backend_->WaitForSubmission(frame, 50);
|
||||||
// Read each iteration so the toggle also works during a stall.
|
|
||||||
const auto wait_ms = eager_frame_heartbeat_.load(std::memory_order_relaxed)
|
|
||||||
? static_cast<uint32_t>(std::clamp<XrDuration>(
|
|
||||||
frame.xr_frame.predicted_display_period / 1'000'000 - 4, 0, 12))
|
|
||||||
: 50u;
|
|
||||||
submission = backend_->WaitForSubmission(frame, wait_ms);
|
|
||||||
if (submission == OpenXRD3D12SubmissionStatus::Timeout) {
|
if (submission == OpenXRD3D12SubmissionStatus::Timeout) {
|
||||||
// A pause, minimized window, or guest stall may leave no GX
|
// A pause, minimized window, or guest stall may leave no GX
|
||||||
// frame to consume this packet. Withdraw it, then cancel the
|
// frame to consume this packet. Withdraw it, then cancel the
|
||||||
@@ -592,7 +639,10 @@ private:
|
|||||||
} else if (!submit) {
|
} else if (!submit) {
|
||||||
SetError("Aurora's D3D12 stereo copy failed; continuing on the desktop mirror");
|
SetError("Aurora's D3D12 stereo copy failed; continuing on the desktop mirror");
|
||||||
fatal = true;
|
fatal = true;
|
||||||
} else if (immersive && !immersive_submission_logged) {
|
} else {
|
||||||
|
++timing_submissions_;
|
||||||
|
}
|
||||||
|
if (submit && !fatal && immersive && !immersive_submission_logged) {
|
||||||
immersive_submission_logged = true;
|
immersive_submission_logged = true;
|
||||||
RT_LOG(RT_TAG_RUNTIME)
|
RT_LOG(RT_TAG_RUNTIME)
|
||||||
<< "[mkw-vr] first immersive packet consumed and submitted as "
|
<< "[mkw-vr] first immersive packet consumed and submitted as "
|
||||||
@@ -601,6 +651,7 @@ private:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetInterpolationActive(false);
|
||||||
running_.store(false, std::memory_order_release);
|
running_.store(false, std::memory_order_release);
|
||||||
MkwVRPolicySetSessionActive(false);
|
MkwVRPolicySetSessionActive(false);
|
||||||
if (!stop_.load(std::memory_order_acquire)) {
|
if (!stop_.load(std::memory_order_acquire)) {
|
||||||
@@ -621,6 +672,7 @@ private:
|
|||||||
destination = {};
|
destination = {};
|
||||||
destination.frameToken = source.xr_frame.serial;
|
destination.frameToken = source.xr_frame.serial;
|
||||||
destination.contentTag = content_tag;
|
destination.contentTag = content_tag;
|
||||||
|
destination.displayTimeNanos = DisplayTimeNanos(source.xr_frame.predicted_display_time);
|
||||||
destination.mode = immersive ? AURORA_STEREO_FRAME_IMMERSIVE_REPLAY
|
destination.mode = immersive ? AURORA_STEREO_FRAME_IMMERSIVE_REPLAY
|
||||||
: AURORA_STEREO_FRAME_VIRTUAL_SCREEN;
|
: AURORA_STEREO_FRAME_VIRTUAL_SCREEN;
|
||||||
for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) {
|
for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) {
|
||||||
@@ -701,6 +753,42 @@ private:
|
|||||||
virtual_screen_pose_valid_ = false;
|
virtual_screen_pose_valid_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SetInterpolationActive(bool active) noexcept {
|
||||||
|
std::lock_guard lock(interpolation_mutex_);
|
||||||
|
aurora_set_stereo_frame_interpolation(active && !interpolation_stopping_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateFrameTiming(const OpenXRFrame& frame) noexcept {
|
||||||
|
float hz = 0;
|
||||||
|
if (get_display_refresh_rate_ == nullptr ||
|
||||||
|
XR_FAILED(get_display_refresh_rate_(runtime_->Session(), &hz)) || !(hz > 0)) {
|
||||||
|
if (frame.predicted_display_period > 0)
|
||||||
|
hz = static_cast<float>(1.0e9 / static_cast<double>(frame.predicted_display_period));
|
||||||
|
}
|
||||||
|
headset_hz_.store(hz, std::memory_order_relaxed);
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
const float elapsed = std::chrono::duration<float>(now - timing_start_).count();
|
||||||
|
if (elapsed >= 1.0f) {
|
||||||
|
rendered_fps_.store(static_cast<float>(timing_submissions_) / elapsed, std::memory_order_relaxed);
|
||||||
|
timing_start_ = now;
|
||||||
|
timing_submissions_ = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t DisplayTimeNanos(XrTime display_time) noexcept {
|
||||||
|
if (convert_display_time_ == nullptr) return 0;
|
||||||
|
LARGE_INTEGER display_counter{}, counter{}, frequency{};
|
||||||
|
if (XR_FAILED(convert_display_time_(runtime_->Instance(), display_time, &display_counter)) ||
|
||||||
|
!QueryPerformanceFrequency(&frequency) || frequency.QuadPart <= 0 ||
|
||||||
|
!QueryPerformanceCounter(&counter)) return 0;
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
const auto now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(now.time_since_epoch()).count();
|
||||||
|
const auto delta = static_cast<int64_t>(
|
||||||
|
(static_cast<double>(display_counter.QuadPart) - static_cast<double>(counter.QuadPart)) *
|
||||||
|
1.0e9 / static_cast<double>(frequency.QuadPart));
|
||||||
|
return now_ns + delta > 0 ? static_cast<uint64_t>(now_ns + delta) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
void WaitForStopOrDelay(std::chrono::milliseconds delay) {
|
void WaitForStopOrDelay(std::chrono::milliseconds delay) {
|
||||||
std::unique_lock lock(stop_mutex_);
|
std::unique_lock lock(stop_mutex_);
|
||||||
stop_cv_.wait_for(lock, delay,
|
stop_cv_.wait_for(lock, delay,
|
||||||
@@ -729,7 +817,18 @@ private:
|
|||||||
std::atomic_bool teardown_requested_{false};
|
std::atomic_bool teardown_requested_{false};
|
||||||
std::atomic_bool recenter_requested_{false};
|
std::atomic_bool recenter_requested_{false};
|
||||||
std::atomic<float> lean_back_degrees_{RuntimeConfigFile::VrLeanBackDegrees()};
|
std::atomic<float> lean_back_degrees_{RuntimeConfigFile::VrLeanBackDegrees()};
|
||||||
std::atomic_bool eager_frame_heartbeat_{RuntimeConfigFile::VrEagerFrameHeartbeat()};
|
std::atomic_uint32_t frame_interpolation_fps_{RuntimeConfigFile::VrFrameInterpolationFps()};
|
||||||
|
std::atomic_bool interpolation_available_{false};
|
||||||
|
std::mutex interpolation_mutex_;
|
||||||
|
bool interpolation_stopping_ = true;
|
||||||
|
FrameInterpolationPacing interpolation_pacing_;
|
||||||
|
std::atomic<float> headset_hz_{0};
|
||||||
|
std::atomic<float> rendered_fps_{0};
|
||||||
|
std::chrono::steady_clock::time_point timing_start_ = std::chrono::steady_clock::now();
|
||||||
|
uint32_t timing_submissions_ = 0;
|
||||||
|
PFN_xrGetDisplayRefreshRateFB get_display_refresh_rate_ = nullptr;
|
||||||
|
using ConvertDisplayTime = XrResult (XRAPI_PTR*)(XrInstance, XrTime, LARGE_INTEGER*);
|
||||||
|
ConvertDisplayTime convert_display_time_ = nullptr;
|
||||||
std::atomic<PublishedFrame*> published_{nullptr};
|
std::atomic<PublishedFrame*> published_{nullptr};
|
||||||
PublishedFrame published_frame_{};
|
PublishedFrame published_frame_{};
|
||||||
std::mutex published_mutex_;
|
std::mutex published_mutex_;
|
||||||
@@ -818,11 +917,27 @@ void OpenXRSetLeanBackDegrees(float degrees) noexcept {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
void OpenXRSetEagerFrameHeartbeat(bool enabled) noexcept {
|
void OpenXRSetFrameInterpolationFps(uint32_t target) noexcept {
|
||||||
#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
|
#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
|
||||||
OpenXRIntegration::Get().SetEagerFrameHeartbeat(enabled);
|
OpenXRIntegration::Get().SetFrameInterpolationFps(target);
|
||||||
#else
|
#else
|
||||||
(void)enabled;
|
(void)target;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenXRFrameTiming OpenXRGetFrameTiming() noexcept {
|
||||||
|
#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
|
||||||
|
return OpenXRIntegration::Get().FrameTiming();
|
||||||
|
#else
|
||||||
|
return {};
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OpenXRFrameInterpolationAvailable() noexcept {
|
||||||
|
#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
|
||||||
|
return OpenXRIntegration::Get().FrameInterpolationAvailable();
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#include "vr/frame_interpolation_pacing.h"
|
||||||
|
#include "runtime_config.h"
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
using mkw::vr::FrameInterpolationPacing;
|
||||||
|
|
||||||
|
static void Require(bool condition) {
|
||||||
|
if (!condition) std::abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
for (uint32_t target : {0u, 1u, 72u, 90u, 120u}) {
|
||||||
|
std::istringstream input("[vr]\nframe_interpolation_fps = " + std::to_string(target) + "\n");
|
||||||
|
const auto config = RuntimeConfigFile::ParseConfig(input);
|
||||||
|
Require(config.vrFrameInterpolationFps == target);
|
||||||
|
}
|
||||||
|
std::istringstream legacy("[vr]\nframe_interpolation = true\neager_frame_heartbeat = true\n");
|
||||||
|
Require(RuntimeConfigFile::ParseConfig(legacy).vrFrameInterpolationFps == 1);
|
||||||
|
std::istringstream explicitOff("[vr]\nframe_interpolation_fps = 0\nframe_interpolation = true\n");
|
||||||
|
Require(RuntimeConfigFile::ParseConfig(explicitOff).vrFrameInterpolationFps == 0);
|
||||||
|
std::istringstream missing("[vr]\n");
|
||||||
|
Require(!RuntimeConfigFile::ParseConfig(missing).vrFrameInterpolationFps.has_value());
|
||||||
|
for (uint32_t headset : {72u, 90u, 120u}) {
|
||||||
|
for (uint32_t target : {1u, 72u, 90u, 120u}) {
|
||||||
|
FrameInterpolationPacing pacing;
|
||||||
|
uint32_t rendered = 0;
|
||||||
|
// Ten seconds on the actual display grid, including noninteger ratios.
|
||||||
|
for (uint32_t frame = 0; frame < headset * 10; ++frame) {
|
||||||
|
const auto time = 1'000'000'000ll + static_cast<int64_t>(frame) * 1'000'000'000ll / headset;
|
||||||
|
if (pacing.ShouldRender(time, target)) ++rendered;
|
||||||
|
}
|
||||||
|
const auto expected = std::min(headset, target == 1 ? headset : target) * 10;
|
||||||
|
Require(std::abs(static_cast<int>(rendered) - static_cast<int>(expected)) <= 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FrameInterpolationPacing pacing;
|
||||||
|
Require(pacing.ShouldRender(1'000'000'000, 72));
|
||||||
|
Require(!pacing.ShouldRender(1'008'333'333, 72));
|
||||||
|
Require(pacing.ShouldRender(1'008'333'334, 120)); // Live target change.
|
||||||
|
Require(pacing.ShouldRender(5'000'000'000, 120)); // Stall: no catch-up burst.
|
||||||
|
Require(!pacing.ShouldRender(5'000'000'001, 120));
|
||||||
|
Require(pacing.ShouldRender(1'000'000, 120)); // New session clock.
|
||||||
|
pacing.Reset();
|
||||||
|
Require(pacing.ShouldRender(1'000'001, 120));
|
||||||
|
Require(mkw::vr::NormalizeFrameInterpolationFps(90) == 90);
|
||||||
|
Require(mkw::vr::NormalizeFrameInterpolationFps(1) == 1);
|
||||||
|
Require(mkw::vr::NormalizeFrameInterpolationFps(60) == 0);
|
||||||
|
Require(mkw::vr::NormalizeFrameInterpolationFps(UINT32_MAX) == 0);
|
||||||
|
std::cout << "VR interpolation pacing tests passed\n";
|
||||||
|
}
|
||||||
Reference in new issue
Block a user