Files
simplysanatoriandClaude Opus 5.5 dac9971ecc Eye pose checks: frustum word, GPU uniform probe, cheap stamp; mock bookkeeping before the wait
The design already snapshots each set's packet once (ProvideStereoFrame copies it under
published_mutex_; every eye path uses that copy). This makes the remaining ways one eye
could differ measurable on the headset too:
- the stamp's fourth pose word: the frustum tangents each eye's fill used; xrmock compares
  them with the layer's submitted fov (and the other eye's);
- a GPU uniform probe (KARTFRAME_FRAME_LOG=1 or KARTFRAME_UNIFORM_PROBE=1): after both eyes,
  each eye's first perspective range is read back from g_uniformBuffer (now CopySrc) and
  compared with the bytes its fill wrote, counted per 10 s;
- the XRP fields ride on the XRS line (one log call per set).
The test stamp is 64x15 pixels, one pixel a bit (was 4x4 cells), and xrmock does its
read-back bookkeeping before xrWaitFrame's sleep instead of after the wake-up it measures
from: with the larger stamp that work alone moved 'ends later than 1 ms' by 2 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:35:43 -04:00

3887 lines
171 KiB
C++

#include <aurora/aurora.h>
#ifdef AURORA_ENABLE_GX
#include "gfx/common.hpp"
#include "gfx/efb_ram_copy.hpp"
#include "gfx/foveation.hpp"
#include "gfx/hitch_stats.hpp"
#include "gfx/stereo_replay.hpp"
#include "gx/fifo.hpp"
#include "gx/shader_info.hpp"
#include "imgui.hpp"
#include "stereo.hpp"
#include "stereo_mirror.hpp"
#include "stereo_interpolation.hpp"
#include "stereo_overlay.hpp"
#include "webgpu/fdm.hpp"
#include "webgpu/gpu.hpp"
#include <webgpu/webgpu_cpp.h>
#endif
#include "input.hpp"
#include "internal.hpp"
#include "window.hpp"
#include <SDL3/SDL_filesystem.h>
#include <SDL3/SDL_thread.h>
#include <magic_enum.hpp>
#include "android_debug.hpp"
#ifdef AURORA_ENABLE_GX
#include "gfx/pipeline_cache.hpp"
#endif
#if defined(__ANDROID__)
#include <pthread.h>
#include <unistd.h>
#endif
#include "system_info.hpp"
#include "tracy/Tracy.hpp"
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#endif
#include <atomic>
#include <algorithm>
#include <array>
#include <chrono>
#include <ctime>
#include <cmath>
#include <condition_variable>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <fstream>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <vector>
#ifdef AURORA_ENABLE_GX
namespace aurora::gx {
// Producer pacing feedback for the adaptive slot count, defined in lib/gx/shader_info.cpp;
// declared here so the C entry point at the bottom of this file can forward to it.
void report_producer_paced(bool paced) noexcept;
} // namespace aurora::gx
#endif
namespace aurora {
AuroraConfig g_config;
uint32_t g_sdlCustomEventsStart;
char g_gameName[4];
std::atomic<AuroraFrameWorkerWaitCallback> g_frameWorkerWaitCallback{nullptr};
// aurora_set_host_event_pump(): the host pumps SDL itself, from the window's thread.
std::atomic_bool g_hostEventPump{false};
std::atomic<AuroraFrameLogCallback> g_frameLogCallback{nullptr};
// Presentation schedule for the frame being sealed, set by the producer. Jobs carry absolute
// deadlines derived from it, so the presenter cannot drift. Zero means present when ready.
std::atomic<uint64_t> g_presentScheduleBaseNanos{0};
std::atomic<uint64_t> g_presentScheduleIntervalNanos{0};
namespace {
Module Log("aurora");
std::atomic<uint32_t> g_captureFrame{UINT32_MAX};
std::string g_captureOutputPath;
struct StereoProviderRegistration {
AuroraStereoFrameProvider callback = nullptr;
void* userdata = nullptr;
};
#ifdef AURORA_ENABLE_GX
struct StereoSinkRegistration {
stereo::SinkCallback callback = nullptr;
stereo::SubmitCallback submitted = nullptr;
void* userdata = nullptr;
stereo::DirectTargetsCallback direct = nullptr;
void* directUserdata = nullptr;
};
#endif
std::mutex g_stereoRegistrationMutex;
StereoProviderRegistration g_stereoProvider;
std::atomic_bool g_stereoProviderActive{false};
#ifdef AURORA_ENABLE_GX
StereoSinkRegistration g_stereoSink;
#endif
// First-person camera relocation for one sealed frame: a row-major affine 3x4
// from the game's recorded view space into the space to render from. `active`
// false means the identity transform, i.e. render from the recorded camera.
struct StereoSceneAnchor {
std::array<float, 12> anchorFromScene{
1.f, 0.f, 0.f, 0.f, //
0.f, 1.f, 0.f, 0.f, //
0.f, 0.f, 1.f, 0.f,
};
bool active = false;
uint32_t localPlayerCount = 1;
// World units per metre the anchor was built with, or zero when the packet's
// own scale applies (aurora_set_stereo_scene_anchor_scaled).
float unitsPerMeter = 0.f;
};
// Producer thread only, between aurora_set_stereo_scene_anchor() and the seal
// that consumes it. Cleared at every seal so a producer that stops publishing
// falls back to the recorded camera instead of freezing on a stale anchor.
StereoSceneAnchor g_pendingSceneAnchor;
uint32_t g_pendingStereoLocalPlayerCount = 1;
using PresentClock = std::chrono::steady_clock;
struct PresentTimingSample {
PresentClock::time_point presentedAt{};
std::chrono::nanoseconds interval{};
// Slot carried a copy of the native image instead of replayed interpolation, so the present
// counts toward the rate but shows no new motion.
bool duplicated = false;
};
std::mutex g_presentTimingMutex;
std::array<PresentTimingSample, 512> g_presentTimingSamples{};
size_t g_presentTimingWriteIndex = 0;
size_t g_presentTimingSampleCount = 0;
std::optional<PresentClock::time_point> g_lastSuccessfulPresent;
uint64_t g_totalPresentCount = 0;
void wait_until_precise(PresentClock::time_point deadline) noexcept {
// Busy-spin tail covering the waitable timer's wakeup jitter. 150 us was not enough in
// practice; 400 us absorbs the observed overshoot, about 1.6 ms of one core at 240 Hz.
constexpr auto kSpinWindow = std::chrono::microseconds(400);
auto now = PresentClock::now();
if (now >= deadline) {
return;
}
const auto timerDeadline = deadline - now > kSpinWindow ? deadline - kSpinWindow : deadline;
#if defined(_WIN32)
#ifndef CREATE_WAITABLE_TIMER_HIGH_RESOLUTION
#define CREATE_WAITABLE_TIMER_HIGH_RESOLUTION 0x00000002
#endif
struct HighResolutionTimer {
HANDLE handle = ::CreateWaitableTimerExW(nullptr, nullptr, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION,
TIMER_MODIFY_STATE | SYNCHRONIZE);
~HighResolutionTimer() {
if (handle != nullptr) {
::CloseHandle(handle);
}
}
};
static thread_local HighResolutionTimer timer;
if (timer.handle != nullptr && timerDeadline > now) {
const auto remaining100ns =
std::chrono::duration_cast<std::chrono::duration<int64_t, std::ratio<1, 10000000>>>(timerDeadline - now);
LARGE_INTEGER due{};
due.QuadPart = -std::max<int64_t>(remaining100ns.count(), 1);
if (::SetWaitableTimerEx(timer.handle, &due, 0, nullptr, nullptr, nullptr, 0) != FALSE) {
::WaitForSingleObject(timer.handle, INFINITE);
}
} else
#endif
{
std::this_thread::sleep_until(timerDeadline);
}
while (PresentClock::now() < deadline) {
#if defined(_WIN32)
YieldProcessor();
#else
std::this_thread::yield();
#endif
}
}
void record_successful_present(bool, uint32_t, std::chrono::nanoseconds, std::chrono::nanoseconds,
std::chrono::nanoseconds, std::chrono::nanoseconds, std::chrono::nanoseconds,
std::chrono::nanoseconds, bool duplicated, uint32_t, std::chrono::nanoseconds) noexcept {
const auto now = PresentClock::now();
std::chrono::nanoseconds interval{};
{
std::lock_guard lock(g_presentTimingMutex);
if (g_lastSuccessfulPresent) {
interval = std::chrono::duration_cast<std::chrono::nanoseconds>(now - *g_lastSuccessfulPresent);
}
g_lastSuccessfulPresent = now;
g_presentTimingSamples[g_presentTimingWriteIndex] = {
.presentedAt = now,
.interval = interval,
.duplicated = duplicated,
};
g_presentTimingWriteIndex = (g_presentTimingWriteIndex + 1) % g_presentTimingSamples.size();
g_presentTimingSampleCount = std::min(g_presentTimingSampleCount + 1, g_presentTimingSamples.size());
++g_totalPresentCount;
}
}
AuroraPresentTiming snapshot_present_timing() noexcept {
constexpr auto kWindow = std::chrono::seconds(1);
const auto cutoff = PresentClock::now() - kWindow;
std::vector<double> milliseconds;
uint64_t totalPresentCount = 0;
size_t newMotionSamples = 0;
{
std::lock_guard lock(g_presentTimingMutex);
totalPresentCount = g_totalPresentCount;
milliseconds.reserve(g_presentTimingSampleCount);
for (size_t i = 0; i < g_presentTimingSampleCount; ++i) {
const auto& sample = g_presentTimingSamples[i];
if (sample.presentedAt >= cutoff && sample.interval.count() > 0) {
milliseconds.push_back(std::chrono::duration<double, std::milli>(sample.interval).count());
if (!sample.duplicated) {
++newMotionSamples;
}
}
}
}
AuroraPresentTiming result{
.totalPresentCount = totalPresentCount,
.sampleCount = static_cast<uint32_t>(milliseconds.size()),
};
if (milliseconds.empty()) {
return result;
}
double totalMilliseconds = 0.0;
for (const double value : milliseconds) {
totalMilliseconds += value;
}
result.averageFrameTimeMs = totalMilliseconds / static_cast<double>(milliseconds.size());
result.framesPerSecond = result.averageFrameTimeMs > 0.0 ? 1000.0 / result.averageFrameTimeMs : 0.0;
// Duplicated presentation slots keep the presented cadence but carry no new
// motion; scale them out so this reads as the rate the eye actually sees.
result.effectiveFramesPerSecond =
result.framesPerSecond * (static_cast<double>(newMotionSamples) / static_cast<double>(milliseconds.size()));
for (const double value : milliseconds) {
const double difference = value - result.averageFrameTimeMs;
result.jitterMs += difference * difference;
}
result.jitterMs = std::sqrt(result.jitterMs / static_cast<double>(milliseconds.size()));
std::sort(milliseconds.begin(), milliseconds.end());
result.p95FrameTimeMs =
milliseconds[static_cast<size_t>(std::floor(static_cast<double>(milliseconds.size() - 1) * 0.95))];
return result;
}
// ImGui draw data lives in the shared context, so starting the next ImGui frame destroys draw
// lists the sealed slots still replay. That is why ImGui callers wait for DONE, not SEALED.
enum class ImGuiFramePolicy {
// Start ImGui's next frame as part of renderer preparation, as the
// synchronous path always has.
Immediate,
// Leave it unstarted; the caller owes an imgui::new_frame() once it has
// finished replaying this frame's draw data.
Deferred,
};
bool begin_frame_impl(bool pumpEvents, ImGuiFramePolicy imguiPolicy = ImGuiFramePolicy::Immediate,
bool* imguiNewFrameOwed = nullptr) noexcept;
bool begin_frame_render_state_impl(ImGuiFramePolicy imguiPolicy, bool* imguiNewFrameOwed) noexcept;
void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag, const StereoSceneAnchor& sceneAnchor,
imgui::HostFramePtr hostImGuiFrame) noexcept;
// The two publication points of a frame-worker cycle, cleared together under `mutex`. Sealed:
// producer-shared renderer state is free again. Done: slots encoded, presented, ImGui restarted.
enum class FrameWorkerPhase {
Sealed,
Done,
};
struct FrameWorkerState {
std::mutex mutex;
std::condition_variable cv;
std::thread thread;
std::thread::id threadId{};
bool started = false;
bool stop = false;
bool jobPending = false;
bool stereoPending = false;
// Written with jobPending and copied by the worker under this mutex. They
// belong to that exact queued frame, not to the producer's next frame.
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
StereoSceneAnchor sceneAnchor{};
imgui::HostFramePtr hostImGuiFrame;
// Readiness is polled thousands of times per frame, so these flags double as a publication
// barrier. `sealed` is released before `ready`, and both are cleared under `mutex`.
std::atomic_bool sealed{true};
std::atomic_bool ready{true};
bool framePrepared = false;
bool prepareAllowed = false;
};
FrameWorkerState g_frameWorker;
// The worker's Linux thread id, published for the host's scheduling hints (Android).
std::atomic<uint32_t> g_frameWorkerNativeThreadId{0};
bool frame_worker_requested() noexcept {
#ifdef AURORA_ENABLE_GX
static const bool enabled = [] {
#if defined(__APPLE__)
// ImGui's SDL backend may raise an SDL window from ImGui::NewFrame(). On
// macOS that reaches AppKit, whose window operations are main-thread-only;
// doing it on the frame worker terminates the process with EXC_BREAKPOINT.
// Keep all SDL/ImGui work on the calling thread until the worker no longer
// owns frame preparation on Apple platforms.
return false;
#endif
#if defined(_WIN32)
// RenderDoc's D3D12 layer is injected before Aurora starts and needs device and command
// ownership on one thread, so keep frame submission synchronous there.
if (::GetModuleHandleW(L"renderdoc.dll") != nullptr) {
return false;
}
#endif
return true;
}();
#if defined(_WIN32)
static const bool renderDocLoaded = ::GetModuleHandleW(L"renderdoc.dll") != nullptr;
if (renderDocLoaded) {
static const bool logged = [] {
Log.info("Disabled asynchronous frame submission worker for RenderDoc capture");
return true;
}();
(void)logged;
}
#endif
return enabled;
#else
return false;
#endif
}
#ifdef AURORA_ENABLE_GX
// Returns false when a stop request was observed mid-cycle.
bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag, imgui::HostFramePtr hostImGuiFrame,
const StereoSceneAnchor& sceneAnchor) noexcept;
void run_retained_stereo_frame(gfx::SealedFrame& sealedFrame) noexcept;
#endif
void frame_worker_main() noexcept {
{
std::lock_guard lock(g_frameWorker.mutex);
g_frameWorker.threadId = std::this_thread::get_id();
}
#if defined(__linux__)
// Published for the host's scheduling (Android hints; Linux: the raised priority of the threads a
// headset frame depends on).
g_frameWorkerNativeThreadId.store(static_cast<uint32_t>(gettid()), std::memory_order_release);
#endif
#if defined(__linux__)
// A thread inherits its creator's name, and the producer that starts this
// worker may itself be a named thread; profiles should tell the two apart.
pthread_setname_np(pthread_self(), "aurora worker");
#endif
#ifdef AURORA_ENABLE_GX
// Owned by the worker for its whole lifetime so the sealed pass vector and
// its pooled command lists keep their capacity across frames.
gfx::SealedFrame sealedFrame;
#endif
for (;;) {
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
StereoSceneAnchor sceneAnchor{};
imgui::HostFramePtr hostImGuiFrame;
bool stereoOnly = false;
{
std::unique_lock lock(g_frameWorker.mutex);
g_frameWorker.cv.wait(
lock, [] { return g_frameWorker.stop || g_frameWorker.jobPending || g_frameWorker.stereoPending; });
if (g_frameWorker.stop) {
break;
}
stereoOnly = !g_frameWorker.jobPending;
g_frameWorker.stereoPending = false;
if (stereoOnly)
g_frameWorker.ready.store(false, std::memory_order_release);
contentTag = g_frameWorker.contentTag;
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
sceneAnchor = g_frameWorker.sceneAnchor;
g_frameWorker.sceneAnchor = {};
hostImGuiFrame = std::move(g_frameWorker.hostImGuiFrame);
g_frameWorker.jobPending = false;
}
// The CPU already decoded the sealed frame at its GX boundary; the worker only owns
// encode/submit/present, so it never touches the producer's next FIFO buffer.
#ifdef AURORA_ENABLE_GX
if (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, std::move(hostImGuiFrame), sceneAnchor)) {
break;
}
#else
(void)contentTag;
(void)sceneAnchor;
#endif
}
// A stop request can unblock either wait above mid-cycle, so release both phases before
// stop_frame_worker() joins.
{
std::lock_guard lock(g_frameWorker.mutex);
g_frameWorker.sealed.store(true, std::memory_order_release);
g_frameWorker.ready.store(true, std::memory_order_release);
}
g_frameWorker.cv.notify_all();
}
void ensure_frame_worker_started() noexcept {
if (!frame_worker_requested()) {
return;
}
std::lock_guard lock(g_frameWorker.mutex);
if (g_frameWorker.started) {
return;
}
g_frameWorker.stop = false;
g_frameWorker.jobPending = false;
g_frameWorker.stereoPending = false;
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
g_frameWorker.sceneAnchor = {};
g_frameWorker.sealed.store(true, std::memory_order_release);
g_frameWorker.ready.store(true, std::memory_order_release);
g_frameWorker.prepareAllowed = false;
g_frameWorker.started = true;
g_frameWorker.thread = std::thread(frame_worker_main);
Log.info("Enabled bounded asynchronous frame submission worker");
}
bool frame_worker_phase_reached(FrameWorkerPhase phase) noexcept {
return phase == FrameWorkerPhase::Sealed ? g_frameWorker.sealed.load(std::memory_order_acquire)
: g_frameWorker.ready.load(std::memory_order_acquire);
}
bool wait_for_frame_worker_private_for(FrameWorkerPhase phase, std::chrono::microseconds timeout) noexcept;
// Where a frame's wall-clock time goes between the producer and the worker, summed in nanoseconds
// and reported with the frame-rate log: the producer's waits for each worker phase, and the
// worker cycle's four stretches: sealing, the wait for the producer's prepare permit, preparing
// the next frame (which includes waiting for a free staging buffer), and the overlapped encode.
std::atomic<uint64_t> g_producerWaitDoneNs{0};
std::atomic<uint64_t> g_producerWaitSealedNs{0};
std::atomic<uint64_t> g_workerSealNs{0};
std::atomic<uint64_t> g_workerEncodeNs{0};
std::atomic<uint64_t> g_workerPermitWaitNs{0};
std::atomic<uint64_t> g_workerPrepareNs{0};
void wait_for_frame_worker_private(FrameWorkerPhase phase) noexcept {
constexpr auto kWaitServiceInterval = std::chrono::milliseconds(1);
if (frame_worker_phase_reached(phase)) {
return;
}
const auto started = std::chrono::steady_clock::now();
while (!wait_for_frame_worker_private_for(phase, kWaitServiceInterval)) {}
const auto waited = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - started).count());
(phase == FrameWorkerPhase::Sealed ? g_producerWaitSealedNs : g_producerWaitDoneNs)
.fetch_add(waited, std::memory_order_relaxed);
}
bool wait_for_frame_worker_private_for(FrameWorkerPhase phase, std::chrono::microseconds timeout) noexcept {
// Each phase's state is published before its flag is set, which keeps the common
// display-list path out of the worker control mutex.
if (frame_worker_phase_reached(phase)) {
return true;
}
std::unique_lock lock(g_frameWorker.mutex);
if (!g_frameWorker.started || g_frameWorker.threadId == std::this_thread::get_id()) {
return true;
}
if (timeout <= std::chrono::microseconds::zero()) {
return frame_worker_phase_reached(phase);
}
const bool reached = g_frameWorker.cv.wait_for(lock, timeout, [phase] { return frame_worker_phase_reached(phase); });
if (reached) {
return true;
}
lock.unlock();
// Both phases service the guest's alarm/retrace pump identically; the
// producer must keep its own timing alive however long it waits.
if (const auto callback = g_frameWorkerWaitCallback.load(std::memory_order_acquire)) {
callback();
}
return false;
}
void stop_frame_worker() noexcept {
{
std::lock_guard lock(g_frameWorker.mutex);
if (!g_frameWorker.started) {
return;
}
g_frameWorker.stop = true;
g_frameWorker.prepareAllowed = true;
}
g_frameWorker.cv.notify_all();
if (g_frameWorker.thread.joinable()) {
g_frameWorker.thread.join();
}
std::lock_guard lock(g_frameWorker.mutex);
g_frameWorker.started = false;
g_frameWorker.threadId = {};
g_frameWorker.framePrepared = false;
g_frameWorker.jobPending = false;
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
g_frameWorker.sceneAnchor = {};
}
uint32_t align_to(uint32_t value, uint32_t alignment) noexcept { return (value + alignment - 1) & ~(alignment - 1); }
void append_u16(std::ofstream& out, uint16_t value) {
const char bytes[] = {static_cast<char>(value), static_cast<char>(value >> 8)};
out.write(bytes, sizeof(bytes));
}
void append_u32(std::ofstream& out, uint32_t value) {
const char bytes[] = {static_cast<char>(value), static_cast<char>(value >> 8), static_cast<char>(value >> 16),
static_cast<char>(value >> 24)};
out.write(bytes, sizeof(bytes));
}
bool write_bmp(const char* path, const uint8_t* pixels, uint32_t width, uint32_t height, uint32_t bytesPerRow,
bool bgra) {
std::ofstream out(path, std::ios::binary | std::ios::trunc);
if (!out)
return false;
constexpr uint32_t pixelOffset = 14 + 40;
const uint32_t imageSize = width * height * 4;
out.write("BM", 2);
append_u32(out, pixelOffset + imageSize);
append_u16(out, 0);
append_u16(out, 0);
append_u32(out, pixelOffset);
append_u32(out, 40);
append_u32(out, width);
append_u32(out, height);
append_u16(out, 1);
append_u16(out, 32);
append_u32(out, 0);
append_u32(out, imageSize);
append_u32(out, 2835);
append_u32(out, 2835);
append_u32(out, 0);
append_u32(out, 0);
std::vector<uint8_t> row(width * 4);
for (uint32_t y = height; y-- > 0;) {
const auto* source = pixels + static_cast<size_t>(y) * bytesPerRow;
if (bgra) {
out.write(reinterpret_cast<const char*>(source), row.size());
} else {
for (uint32_t x = 0; x < width; ++x) {
row[x * 4 + 0] = source[x * 4 + 2];
row[x * 4 + 1] = source[x * 4 + 1];
row[x * 4 + 2] = source[x * 4 + 0];
row[x * 4 + 3] = source[x * 4 + 3];
}
out.write(reinterpret_cast<const char*>(row.data()), row.size());
}
}
return out.good();
}
#ifdef AURORA_ENABLE_GX
// GPU
using webgpu::g_device;
using webgpu::g_instance;
using webgpu::g_queue;
using webgpu::g_surface;
std::recursive_mutex g_rendererGpuMutex;
std::mutex g_queueSubmitMutex;
// Surface ownership, held apart from the renderer mutex: a presentation slot touches the surface,
// its image and the queue, nothing recorded. Lock order is surface then renderer.
std::mutex g_surfaceMutex;
std::atomic<bool> g_surfaceReconfigurePending{false};
std::atomic<bool> g_surfaceRecreatePending{false};
struct StereoEyeTarget {
webgpu::TextureWithSampler color;
webgpu::TextureWithSampler resolvedColor;
webgpu::TextureWithSampler depth;
uint32_t requestedWidth = 0;
uint32_t requestedHeight = 0;
uint32_t samples = 0;
wgpu::TextureFormat colorFormat = wgpu::TextureFormat::Undefined;
wgpu::TextureFormat depthFormat = wgpu::TextureFormat::Undefined;
// Built on demand for the desktop mirror only, and dropped with the rest of
// the target when ensure_stereo_eye_target replaces the textures.
wgpu::BindGroup copyBindGroup;
// Correct a GPU/runtime image inversion on the completed race eye, without
// changing view-anchored menus or the desktop mirror.
webgpu::TextureWithSampler orientedColor;
wgpu::BindGroup orientedCopyBindGroup;
// Foveated rendering: a second view of `color` for the immersive eye passes,
// which the patched Dawn binds to this eye's fragment density map
// (webgpu/fdm.hpp), and what that map was built for.
wgpu::TextureView foveatedView;
uint64_t densityMap = 0;
std::array<int32_t, 7> densityKey{};
const webgpu::TextureWithSampler& output() const noexcept { return resolvedColor.texture ? resolvedColor : color; }
};
std::array<StereoEyeTarget, AURORA_STEREO_EYE_COUNT> g_stereoEyeTargets;
stereo::MirrorState g_stereoMirrorState;
// The map's binding holds the foveated view, and with it the eye texture, until it is released.
void release_eye_density_map(StereoEyeTarget& target) noexcept {
if (target.densityMap != 0) {
webgpu::fdm::release_map(target.densityMap);
target.densityMap = 0;
}
target.densityKey = {};
}
// The eye targets outlive a frame, so the mirror samples them through a bind
// group cached beside them rather than one built per presentation slot.
wgpu::BindGroup stereo_eye_copy_bind_group(uint32_t eyeIndex) {
auto& target = g_stereoEyeTargets[eyeIndex];
if (!target.copyBindGroup && target.output().texture) {
target.copyBindGroup = webgpu::create_copy_bind_group(target.output());
}
return target.copyBindGroup;
}
void ensure_stereo_eye_target(uint32_t eyeIndex, uint32_t width, uint32_t height) {
auto& target = g_stereoEyeTargets[eyeIndex];
const uint32_t samples = webgpu::g_graphicsConfig.msaaSamples;
if (target.color.texture && target.requestedWidth == width && target.requestedHeight == height &&
target.samples == samples && target.colorFormat == webgpu::g_graphicsConfig.surfaceConfiguration.format &&
target.depthFormat == wgpu::TextureFormat::Depth24PlusStencil8) {
return;
}
release_eye_density_map(target);
target = {};
target.color = webgpu::create_render_texture(width, height, samples > 1);
if (samples > 1) {
target.resolvedColor = webgpu::create_render_texture(target.color.size.width, target.color.size.height, false);
}
// The cockpit uses one stencil bit to survive later depth-disabled HUD draws.
// Keep this attachment eye-only; native EFB depth sampling is unchanged.
const wgpu::TextureDescriptor depthDescriptor{
.label = eyeIndex == 0 ? "Stereo left eye depth" : "Stereo right eye depth",
.usage = wgpu::TextureUsage::RenderAttachment,
.dimension = wgpu::TextureDimension::e2D,
.size = target.color.size,
.format = wgpu::TextureFormat::Depth24PlusStencil8,
.mipLevelCount = 1,
.sampleCount = samples,
};
target.depth.texture = g_device.CreateTexture(&depthDescriptor);
target.depth.view = target.depth.texture.CreateView();
target.depth.size = target.color.size;
target.depth.format = wgpu::TextureFormat::Depth24PlusStencil8;
target.requestedWidth = width;
target.requestedHeight = height;
target.samples = samples;
target.colorFormat = target.color.format;
target.depthFormat = target.depth.format;
}
// The view an immersive eye's passes render through while foveated, or none. The eye's fragment
// density map is rebuilt whenever its size, field of view or level changes (a map is immutable), and
// is used once its upload has completed.
wgpu::TextureView foveated_eye_view(uint32_t eyeIndex, const AuroraStereoEye& input) {
auto& target = g_stereoEyeTargets[eyeIndex];
const auto level = static_cast<gfx::foveation::Level>(gfx::get_stereo_foveation());
if (level == gfx::foveation::Level::Off || target.samples > 1 || !webgpu::fdm::available()) {
return {};
}
const auto fov = gfx::foveation::fov_from_projection(input.projection);
// Hundredths of a tangent: finer than a map texel, coarse enough to ignore pose noise.
const auto hundredths = [](float value) { return static_cast<int32_t>(std::lround(value * 100.0f)); };
const std::array<int32_t, 7> key{static_cast<int32_t>(target.color.size.width),
static_cast<int32_t>(target.color.size.height),
static_cast<int32_t>(level),
hundredths(fov.tanLeft),
hundredths(fov.tanRight),
hundredths(fov.tanDown),
hundredths(fov.tanUp)};
if (key != target.densityKey) {
release_eye_density_map(target);
target.densityKey = key;
if (!target.foveatedView) {
const wgpu::TextureViewDescriptor descriptor{
.label = eyeIndex == 0 ? "Foveated left eye" : "Foveated right eye",
.usage = wgpu::TextureUsage::RenderAttachment,
};
target.foveatedView = target.color.texture.CreateView(&descriptor);
}
gfx::foveation::Map map;
gfx::foveation::build(target.color.size.width, target.color.size.height, webgpu::fdm::texel_size(), fov, level,
map);
target.densityMap = webgpu::fdm::create_map(map.width, map.height, map.rg8.data());
if (target.densityMap != 0 && !webgpu::fdm::bind(target.foveatedView, target.densityMap)) {
webgpu::fdm::release_map(target.densityMap);
target.densityMap = 0;
}
static constexpr std::array<const char*, gfx::foveation::kLevelCount> kLevelNames{"off", "low", "medium", "high"};
if (target.densityMap != 0) {
Log.info("{} eye foveation {}: {}x{} density map, {} pixels per texel", eyeIndex == 0 ? "Left" : "Right",
kLevelNames[static_cast<uint32_t>(level)], map.width, map.height, webgpu::fdm::texel_size());
} else {
Log.warn("{} eye foveation {}: the {}x{} density map could not be created", eyeIndex == 0 ? "Left" : "Right",
kLevelNames[static_cast<uint32_t>(level)], map.width, map.height);
}
}
return target.densityMap != 0 && webgpu::fdm::map_ready(target.densityMap) ? target.foveatedView
: wgpu::TextureView{};
}
std::optional<AuroraStereoFrame> request_stereo_frame(uint32_t logicalFrame, uint64_t contentTag) noexcept {
StereoProviderRegistration registration;
{
std::lock_guard lock(g_stereoRegistrationMutex);
registration = g_stereoProvider;
}
if (registration.callback == nullptr) {
return std::nullopt;
}
AuroraStereoFrame frame{};
bool provided = false;
try {
provided = registration.callback(logicalFrame, &frame, registration.userdata);
} catch (...) {
Log.error("Stereo frame provider threw an exception; rendering frame {} in mono", logicalFrame);
return std::nullopt;
}
if (!provided) {
return std::nullopt;
}
if (frame.mode != AURORA_STEREO_FRAME_IMMERSIVE_REPLAY && frame.mode != AURORA_STEREO_FRAME_VIRTUAL_SCREEN) {
Log.warn("Stereo frame {} has invalid mode {}; rendering in mono", logicalFrame, static_cast<uint32_t>(frame.mode));
return std::nullopt;
}
// A virtual-screen packet only copies the completed mono image and remains
// a safe fallback across a transition. Immersive replay changes the GX
// transforms, so it requires the exact tag latched for this sealed content.
// The provider's packet owns an acquired OpenXR image. Returning no packet
// after consuming it leaves that image pending until the 10-second timeout,
// blacking out the headset and delaying the next presentation mode. Finish
// the request with a mono copy instead; the next packet uses the new policy.
if (frame.mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY &&
(contentTag == AURORA_STEREO_CONTENT_TAG_UNKNOWN || frame.contentTag != contentTag)) {
static uint32_t mismatchedFrames = 0;
++mismatchedFrames;
if ((mismatchedFrames & (mismatchedFrames - 1)) == 0) {
Log.warn("Stereo content changed during submission; using mono copy: frame={}, count={}, packet-tag={}, sealed-tag={}",
logicalFrame, mismatchedFrames, frame.contentTag, contentTag);
}
frame.mode = AURORA_STEREO_FRAME_VIRTUAL_SCREEN;
}
const auto finite = [](const float* values, size_t count) {
// aurora_core is built with -ffast-math, so std::isfinite may be folded to
// true. Inspecting the IEEE-754 exponent keeps the provider boundary safe
// under the target's real release flags.
return std::all_of(values, values + count, [](const float& value) {
uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
return (bits & 0x7f800000u) != 0x7f800000u;
});
};
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
const auto& input = frame.eyes[eye];
const bool transformsValid = frame.mode == AURORA_STEREO_FRAME_VIRTUAL_SCREEN ||
(finite(input.projection, 16) && finite(input.viewFromCenter, 12));
if (input.width == 0 || input.height == 0 || !transformsValid) {
Log.warn("Stereo frame {} has invalid eye {} dimensions or transforms; rendering in mono", logicalFrame, eye);
return std::nullopt;
}
}
// The cockpit overlay is optional: a bad one is dropped, never the frame.
if (frame.cockpit.active) {
const auto& cockpit = frame.cockpit;
bool valid = finite(&cockpit.wheelAngle, 1) && finite(&cockpit.handlebarRadius, 1) &&
finite(&cockpit.unitsPerMeter, 1) && cockpit.unitsPerMeter > 0.f &&
finite(cockpit.seatFromHandlebar, 12);
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
valid = valid && finite(cockpit.eyeFromSeat[eye], 12);
}
for (const auto& hand : cockpit.hands) {
valid = valid && finite(&hand.squeeze, 1) && finite(hand.seatFromGrip, 12);
}
if (!valid) {
static bool cockpitRejectionLogged = false;
if (!cockpitRejectionLogged) {
cockpitRejectionLogged = true;
Log.warn("Stereo frame {} carries a non-finite VR cockpit; drawing it without the cockpit", logicalFrame);
}
frame.cockpit = {};
}
}
return frame;
}
gfx::StereoReplayFrame make_stereo_replay_frame(const AuroraStereoFrame& input, const StereoSceneAnchor& sceneAnchor) {
Mat3x4<float> anchorFromScene;
std::memcpy(&anchorFromScene, sceneAnchor.anchorFromScene.data(), sizeof(anchorFromScene));
gfx::StereoReplayFrame replay{};
replay.cockpit = input.cockpit;
replay.window = input.mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY && input.window;
replay.displayTimeNanos = input.displayTimeNanos;
replay.frameToken = input.frameToken;
// The sealed guest frame owns its scale. The packet may have been sampled
// just before a change of scale (a character swap, a lightning strike), so
// only its head/IPD translation is rescaled to the frame's.
const float frameUnits = sceneAnchor.active && sceneAnchor.unitsPerMeter > 0.f ? sceneAnchor.unitsPerMeter
: input.cockpit.unitsPerMeter;
const float unitRatio = input.cockpit.unitsPerMeter > 0.f && frameUnits > 0.f
? frameUnits / input.cockpit.unitsPerMeter
: 1.f;
replay.cockpit.unitsPerMeter = frameUnits;
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
ensure_stereo_eye_target(eye, input.eyes[eye].width, input.eyes[eye].height);
const auto& owned = g_stereoEyeTargets[eye];
const auto& output = owned.output();
auto& view = replay.eyes[eye];
view.target = {
.colorView = owned.color.view,
.resolveView = owned.resolvedColor.view,
.depthView = owned.depth.view,
.copySourceTexture = output.texture,
.copySourceView = output.view,
.copySourceDepthView = owned.depth.view,
.size = owned.color.size,
.msaaSamples = webgpu::g_graphicsConfig.msaaSamples,
.depthFormat = owned.depth.format,
};
// Not the immersive window's eyes: the host may aim them through the window, whose field of
// view then changes with every head movement and would rebuild the density map each frame.
if (input.mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY && !input.window) {
view.target.foveatedColorView = foveated_eye_view(eye, input.eyes[eye]);
}
std::memcpy(&view.projection, input.eyes[eye].projection, sizeof(view.projection));
std::memcpy(&view.viewFromCenter, input.eyes[eye].viewFromCenter, sizeof(view.viewFromCenter));
view.handHud = input.handHud;
if (input.handHud) std::memcpy(&view.handHudViewFromPanel, input.handHudViewFromPanel[eye], sizeof(view.handHudViewFromPanel));
if (unitRatio != 1.f) {
view.viewFromCenter.m0[3] *= unitRatio;
view.viewFromCenter.m1[3] *= unitRatio;
view.viewFromCenter.m2[3] *= unitRatio;
}
// World draws already carry the recorded camera, so they need the anchor
// folded in; the virtual screen is authored in the anchored camera's space
// and keeps viewFromCenter.
view.viewFromScene = sceneAnchor.active
? gfx::stereo_replay::compose_affine(view.viewFromCenter, anchorFromScene)
: view.viewFromCenter;
// A mirror-mode draw takes the same route from the mirrored eye delta, so the
// reflection its projection carries is taken in the anchored camera's space.
const auto mirroredFromCenter = gfx::stereo_replay::mirror_view_delta_x(view.viewFromCenter);
view.viewFromSceneMirrored = sceneAnchor.active
? gfx::stereo_replay::compose_affine(mirroredFromCenter, anchorFromScene)
: mirroredFromCenter;
}
return replay;
}
// The eyes of the set being encoded, when they render straight into the headset's swapchain images
// (stereo::DirectTargetsCallback); empty otherwise. Set by use_direct_eye_targets right before the eyes
// are encoded, read by the panel composite and the sink, cleared by run_stereo_sink.
struct DirectEye {
wgpu::Texture texture;
wgpu::TextureView view;
};
std::array<DirectEye, AURORA_STEREO_EYE_COUNT> g_directEyes;
// The content tag of the last game frame a set replayed immersive (encode_sealed_frame's native cut).
std::atomic<uint64_t> g_lastImmersiveTag{AURORA_STEREO_CONTENT_TAG_UNKNOWN};
const wgpu::TextureView& eye_output_view(uint32_t eye) noexcept {
return g_directEyes[eye].texture ? g_directEyes[eye].view : g_stereoEyeTargets[eye].output().view;
}
// Whether a race's eyes render straight into the swapchain images (no copy) or into Aurora's own
// targets, copied into them. Which is cheaper depends on the runtime's images, not on us: in the mock,
// images in ordinary device memory took the eyes directly 0.9 ms a set cheaper than the 1.3 to 1.9 ms
// copy; images in host-visible write-combined memory made the eye passes 1.5 ms dearer than the copy.
// So the race's refresh sets measure both ways with GPU timestamps (first eye pass to the end of the
// sink, blocks of 16 sets alternating, after the first 600) and keep the cheaper for the process.
// KARTFRAME_DIRECT_EYES=1 / 0 forces one way. Frame worker thread, except the readback callback.
class DirectEyesCalibration {
public:
// The way for this set: 1 straight into the images, 0 copied. Marks the set measured or not.
int choose() noexcept {
std::lock_guard lock(mutex_);
measuring_ = false;
if (forced_ >= 0) return forced_;
if (decided_ >= 0) return decided_;
if (!webgpu::g_timestampQueriesSupported) return decide_locked(true), decided_;
const uint32_t n = sets_++;
if (n < kSkip) return 0;
if (n >= kSkip + kTrialSets || (samples_[0].size() >= kWanted && samples_[1].size() >= kWanted)) {
decide_locked(false);
return decided_;
}
measuring_ = true;
way_ = static_cast<int>(((n - kSkip) / kBlock) & 1u) ^ 1;
return way_;
}
void not_applied() noexcept { measuring_ = false; }
void begin(wgpu::CommandEncoder& encoder) noexcept { mark(encoder, true); }
void end(wgpu::CommandEncoder& encoder) noexcept { mark(encoder, false); }
void after_submit() noexcept {
if (pending_ < 0) return;
auto& slot = slots_[static_cast<size_t>(pending_)];
const int way = slot.way;
const size_t index = static_cast<size_t>(pending_);
pending_ = -1;
slot.readback.MapAsync(wgpu::MapMode::Read, 0, 2 * sizeof(uint64_t), wgpu::CallbackMode::AllowSpontaneous,
[this, index, way](wgpu::MapAsyncStatus status, wgpu::StringView) {
auto& slot = slots_[index];
std::lock_guard lock(mutex_);
if (status == wgpu::MapAsyncStatus::Success) {
const auto* t = static_cast<const uint64_t*>(
slot.readback.GetConstMappedRange(0, 2 * sizeof(uint64_t)));
if (t != nullptr && t[1] > t[0] && decided_ < 0) {
samples_[way].push_back(static_cast<float>(t[1] - t[0]) / 1e6f);
}
slot.mapped = true;
}
slot.busy = status == wgpu::MapAsyncStatus::Success;
if (!slot.busy) slot.mapped = false;
});
}
int decided() noexcept {
std::lock_guard lock(mutex_);
return forced_ >= 0 ? forced_ : decided_;
}
DirectEyesCalibration() {
const char* env = std::getenv("KARTFRAME_DIRECT_EYES");
forced_ = env != nullptr && (env[0] == '0' || env[0] == '1') ? env[0] - '0' : -1;
}
private:
static constexpr uint32_t kSkip = 600, kBlock = 16, kTrialSets = 2400, kWanted = 200;
struct Slot {
wgpu::QuerySet queries;
wgpu::Buffer resolve, readback;
int way = 0;
bool busy = false, mapped = false;
};
void decide_locked(bool noTimer) noexcept {
const auto median = [](std::vector<float>& v) {
if (v.empty()) return 0.f;
std::nth_element(v.begin(), v.begin() + static_cast<std::ptrdiff_t>(v.size() / 2), v.end());
return v[v.size() / 2];
};
const size_t n0 = samples_[0].size(), n1 = samples_[1].size();
const float copy = median(samples_[0]), direct = median(samples_[1]);
decided_ = !noTimer && n0 >= 30 && n1 >= 30 && direct < copy ? 1 : 0;
Log.info("VR eyes: eye passes and output {:.2f} ms a set rendered straight into the swapchain images ({} sets), "
"{:.2f} ms rendered apart and copied ({} sets){}: {} for this session",
direct, n1, copy, n0, noTimer ? " (no GPU timestamps)" : "",
decided_ ? "straight into the images" : "copied");
samples_[0].clear();
samples_[1].clear();
}
void mark(wgpu::CommandEncoder& encoder, bool begin) noexcept {
if (!measuring_) return;
if (begin) {
pending_ = -1;
for (size_t i = 0; i < slots_.size(); ++i) {
std::lock_guard lock(mutex_);
auto& slot = slots_[i];
if (slot.busy && !slot.mapped) continue;
if (slot.mapped) {
slot.readback.Unmap();
slot.mapped = false;
}
slot.busy = true;
pending_ = static_cast<int>(i);
break;
}
if (pending_ < 0) {
measuring_ = false;
return;
}
auto& slot = slots_[static_cast<size_t>(pending_)];
slot.way = way_;
if (!slot.queries) {
const wgpu::QuerySetDescriptor qd{.type = wgpu::QueryType::Timestamp, .count = 2};
slot.queries = g_device.CreateQuerySet(&qd);
const wgpu::BufferDescriptor rd{.usage = wgpu::BufferUsage::QueryResolve | wgpu::BufferUsage::CopySrc,
.size = 2 * sizeof(uint64_t)};
slot.resolve = g_device.CreateBuffer(&rd);
const wgpu::BufferDescriptor bd{.usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst,
.size = 2 * sizeof(uint64_t)};
slot.readback = g_device.CreateBuffer(&bd);
}
if (!marker_) {
const wgpu::TextureDescriptor td{.usage = wgpu::TextureUsage::RenderAttachment,
.size = {1, 1, 1},
.format = wgpu::TextureFormat::RGBA8Unorm};
marker_ = g_device.CreateTexture(&td).CreateView();
}
}
if (pending_ < 0) return;
auto& slot = slots_[static_cast<size_t>(pending_)];
const wgpu::PassTimestampWrites writes{
.querySet = slot.queries,
.beginningOfPassWriteIndex = begin ? 0u : wgpu::kQuerySetIndexUndefined,
.endOfPassWriteIndex = begin ? wgpu::kQuerySetIndexUndefined : 1u,
};
const wgpu::RenderPassColorAttachment ca{.view = marker_, .loadOp = wgpu::LoadOp::Clear,
.storeOp = wgpu::StoreOp::Discard};
const wgpu::RenderPassDescriptor rd{.label = "VR eye output timing", .colorAttachmentCount = 1,
.colorAttachments = &ca, .timestampWrites = &writes};
encoder.BeginRenderPass(&rd).End();
if (!begin) {
encoder.ResolveQuerySet(slot.queries, 0, 2, slot.resolve, 0);
encoder.CopyBufferToBuffer(slot.resolve, 0, slot.readback, 0, 2 * sizeof(uint64_t));
}
}
std::mutex mutex_;
int forced_ = -1, decided_ = -1, way_ = 0, pending_ = -1;
bool measuring_ = false;
uint32_t sets_ = 0;
std::array<std::vector<float>, 2> samples_;
std::array<Slot, 6> slots_;
wgpu::TextureView marker_;
};
DirectEyesCalibration g_directEyesCalibration;
// An immersive set's eyes go straight into the acquired swapchain images when the sink takes them (the
// backend allows it per set: it measures which way is cheaper on the runtime's images) and
// nothing else needs Aurora's own eye targets this set: no MSAA resolve, no orientation correction, no
// foveation view, no desktop mirror of the eyes, both eyes the size of their swapchain image.
bool use_direct_eye_targets(gfx::StereoReplayFrame& replay, const AuroraStereoFrame& input) noexcept {
g_directEyes = {};
if (input.mode != AURORA_STEREO_FRAME_IMMERSIVE_REPLAY ||
webgpu::g_graphicsConfig.msaaSamples > 1 || webgpu::g_RaceOutputNeedsCopy) {
return false;
}
const auto mirror = gfx::get_stereo_mirror_view();
if (mirror != AURORA_STEREO_MIRROR_NORMAL && mirror != AURORA_STEREO_MIRROR_NONE) {
return false;
}
stereo::DirectTargetsCallback callback;
void* userdata;
{
std::lock_guard lock(g_stereoRegistrationMutex);
callback = g_stereoSink.direct;
userdata = g_stereoSink.directUserdata;
}
const auto& left = g_stereoEyeTargets[0];
if (callback == nullptr || replay.eyes[0].target.foveatedColorView || replay.eyes[1].target.foveatedColorView ||
g_stereoEyeTargets[1].color.size.width != left.color.size.width ||
g_stereoEyeTargets[1].color.size.height != left.color.size.height) {
return false;
}
std::array<wgpu::Texture, AURORA_STEREO_EYE_COUNT> textures;
if (!callback(input.frameToken, left.color.format, left.color.size.width, left.color.size.height, textures,
userdata)) {
return false;
}
// A view per swapchain image, kept while the image is (the runtime cycles through a few).
static std::vector<std::pair<WGPUTexture, wgpu::TextureView>> views;
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
auto it = std::find_if(views.begin(), views.end(), [&](const auto& v) { return v.first == textures[eye].Get(); });
if (it == views.end()) {
if (views.size() >= 16) views.erase(views.begin());
views.emplace_back(textures[eye].Get(), textures[eye].CreateView());
it = views.end() - 1;
}
g_directEyes[eye] = {textures[eye], it->second};
auto& target = replay.eyes[eye].target;
target.colorView = it->second;
target.resolveView = {};
target.copySourceTexture = textures[eye];
target.copySourceView = it->second;
}
return true;
}
#include "vr_ui.hpp"
// The Frame's menus (AuroraStereoFrame::window on a virtual-screen packet with ui.anchored): eye target
// 0 is the screen's image as usual, eye target 1 (both eyes side by side) the room around it, with the
// controller models and the pointer, for the backend's environment projection layer.
bool menu_environment_requested(const AuroraStereoFrame& frame) noexcept {
return frame.mode == AURORA_STEREO_FRAME_VIRTUAL_SCREEN && frame.ui.anchored && frame.window;
}
void encode_menu_environment(wgpu::CommandEncoder& encoder, const webgpu::PresentSource& mono,
const AuroraStereoFrame& frame) {
gfx::gpu_timing_mark(encoder, gfx::GpuTimingCategory::MenuEnv);
struct EndMark {
wgpu::CommandEncoder& encoder;
~EndMark() { gfx::gpu_timing_mark(encoder, std::nullopt); }
} endMark{encoder};
const auto& target = g_stereoEyeTargets[1].output();
const uint32_t eyeWidth = target.size.width / AURORA_STEREO_EYE_COUNT;
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
const vrui::Environment env{.view = target.view, .x = eye * eyeWidth, .width = eyeWidth,
.height = target.size.height, .clear = eye == 0};
vrui::render(encoder, mono, frame, AuroraVRUiGuide{}, eye, &env);
}
}
void encode_virtual_screen_eye(wgpu::CommandEncoder& encoder, const webgpu::PresentSource& source, uint32_t eyeIndex) {
const auto& output = g_stereoEyeTargets[eyeIndex].output();
const std::array attachments{
wgpu::RenderPassColorAttachment{
.view = output.view,
.loadOp = wgpu::LoadOp::Clear,
.storeOp = wgpu::StoreOp::Store,
.clearValue = {.r = 0.0, .g = 0.0, .b = 0.0, .a = 1.0},
},
};
const wgpu::RenderPassDescriptor descriptor{
.label = eyeIndex == 0 ? "Virtual screen left eye" : "Virtual screen right eye",
.colorAttachmentCount = attachments.size(),
.colorAttachments = attachments.data(),
.timestampWrites = gfx::gpu_timing_pass(gfx::GpuTimingCategory::VirtualScreen),
};
{
const auto pass = encoder.BeginRenderPass(&descriptor);
if (source.bindGroup && source.size.width != 0 && source.size.height != 0) {
const auto viewport = webgpu::calculate_present_viewport(output.size.width, output.size.height, source.size.width,
source.size.height);
pass.SetPipeline(webgpu::g_CopyPipeline);
pass.SetBindGroup(0, source.bindGroup, 0, nullptr);
pass.SetViewport(viewport.left, viewport.top, viewport.width, viewport.height, viewport.znear, viewport.zfar);
pass.Draw(3);
}
pass.End();
}
}
struct PendingStereoSink {
stereo::SinkFrame frame;
stereo::SubmitCallback submitted = nullptr;
void* userdata = nullptr;
};
// Lane L: the scene state each eye of this set used (gfx::EyeSceneRecord), into the sink frame (the
// frame stamp) and, with KARTFRAME_FRAME_LOG=1, one line per set:
// XRS t=<display time, steady ms> s=<set> f=<previous>/<current game frame> w=<weight> late=<0 seal fill, 1
// sample-time fill> L=<interpolated>/<current>/<recorded>/<outside> R=<same> (perspective draws: filled from the
// pair, filled current-only, drawn with the recorded uniform, drawn with a range outside this set's fill),
// then lane P's XRP fields (in take_eye_scene).
// Lane P: an eye's orientation (x, y, z, w) from the view transform written into its uniforms: the view
// rotation is the inverse of the eye's orientation (lean aside), so this is the conjugate of its quaternion.
std::array<float, 4> eye_orientation(const Mat3x4<float>& view) noexcept {
const float m00 = view.m0[0], m01 = view.m0[1], m02 = view.m0[2];
const float m10 = view.m1[0], m11 = view.m1[1], m12 = view.m1[2];
const float m20 = view.m2[0], m21 = view.m2[1], m22 = view.m2[2];
float x, y, z, w;
const float trace = m00 + m11 + m22;
if (trace > 0.f) {
const float s = std::sqrt(trace + 1.f) * 2.f;
w = 0.25f * s, x = (m21 - m12) / s, y = (m02 - m20) / s, z = (m10 - m01) / s;
} else if (m00 > m11 && m00 > m22) {
const float s = std::sqrt(1.f + m00 - m11 - m22) * 2.f;
w = (m21 - m12) / s, x = 0.25f * s, y = (m01 + m10) / s, z = (m02 + m20) / s;
} else if (m11 > m22) {
const float s = std::sqrt(1.f + m11 - m00 - m22) * 2.f;
w = (m02 - m20) / s, x = (m01 + m10) / s, y = 0.25f * s, z = (m12 + m21) / s;
} else {
const float s = std::sqrt(1.f + m22 - m00 - m11) * 2.f;
w = (m10 - m01) / s, x = (m02 + m20) / s, y = (m12 + m21) / s, z = 0.25f * s;
}
const float sign = w < 0.f ? -1.f : 1.f; // canonical: w >= 0
return {-x * sign, -y * sign, -z * sign, w * sign};
}
// Lane P: the GPU uniform probe. Every refresh set of one game frame rewrites the same g_uniformBuffer region
// (the sealed frame's eye ranges) at its start; the eye draws read it. If a set's draws could ever see the
// next set's bytes (its copy overlapping them on the GPU), the right eye, drawn last, would be rendered with
// the next set's head pose. After both eyes of a set, each eye's first filled range is copied back and
// compared with the bytes the fill wrote. On with KARTFRAME_FRAME_LOG=1 or KARTFRAME_UNIFORM_PROBE=1; the
// counts go into the "VR eye poses" line. Frame worker, except the map callback.
class UniformProbe {
public:
static bool enabled() noexcept {
static const bool on = [] {
const char* probe = std::getenv("KARTFRAME_UNIFORM_PROBE");
if (probe != nullptr && *probe != '\0') return probe[0] == '1';
const char* log = std::getenv("KARTFRAME_FRAME_LOG");
return log != nullptr && log[0] == '1';
}();
return on;
}
// Records the copies for this set (after its eyes, in its encoder). The slot is mapped on the next call,
// when this set's command buffer has been submitted.
void record(wgpu::CommandEncoder& encoder, const gfx::EyeSceneRecord& scene) noexcept {
request_maps();
if (!scene.probed[0] || !scene.probed[1] || !gfx::g_uniformBuffer) return;
Slot* free = nullptr;
{
std::lock_guard lock(mutex_);
for (auto& slot : slots_) {
if (slot.state == State::Mapped) {
slot.readback.Unmap();
slot.state = State::Free;
}
if (slot.state == State::Free && free == nullptr) free = &slot;
}
if (free == nullptr) {
++skipped_;
return;
}
free->state = State::Recorded;
}
if (!free->readback) {
const wgpu::BufferDescriptor descriptor{.label = "Eye uniform probe",
.usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst,
.size = 2 * kBytes};
free->readback = g_device.CreateBuffer(&descriptor);
}
for (uint32_t eye = 0; eye < 2; ++eye) {
free->size[eye] = scene.probeSize[eye];
free->expected[eye] = scene.probeBytes[eye];
encoder.CopyBufferToBuffer(gfx::g_uniformBuffer, scene.probeOffset[eye], free->readback, eye * kBytes,
scene.probeSize[eye]);
}
}
// Read back so far: sets, sets whose left / right eye range differed from what its fill wrote; resets.
std::array<uint32_t, 4> take() noexcept {
std::lock_guard lock(mutex_);
const std::array<uint32_t, 4> out{checked_, differs_[0], differs_[1], skipped_};
checked_ = differs_[0] = differs_[1] = skipped_ = 0;
return out;
}
private:
static constexpr uint64_t kBytes = 64;
enum class State { Free, Recorded, Mapping, Mapped };
struct Slot {
wgpu::Buffer readback;
State state = State::Free;
std::array<uint32_t, 2> size{};
std::array<std::array<uint8_t, 64>, 2> expected{};
};
void request_maps() noexcept {
for (size_t i = 0; i < slots_.size(); ++i) {
{
std::lock_guard lock(mutex_);
if (slots_[i].state != State::Recorded) continue;
slots_[i].state = State::Mapping;
}
slots_[i].readback.MapAsync(
wgpu::MapMode::Read, 0, 2 * kBytes, wgpu::CallbackMode::AllowSpontaneous,
[this, i](wgpu::MapAsyncStatus status, wgpu::StringView) {
auto& slot = slots_[i];
std::lock_guard lock(mutex_);
if (status != wgpu::MapAsyncStatus::Success) {
slot.state = State::Free;
return;
}
const auto* bytes = static_cast<const uint8_t*>(slot.readback.GetConstMappedRange(0, 2 * kBytes));
if (bytes != nullptr) {
++checked_;
for (uint32_t eye = 0; eye < 2; ++eye) {
if (std::memcmp(bytes + eye * kBytes, slot.expected[eye].data(), slot.size[eye]) != 0) ++differs_[eye];
}
}
slot.state = State::Mapped;
});
}
}
std::mutex mutex_;
std::array<Slot, 8> slots_{};
uint32_t checked_ = 0, skipped_ = 0;
std::array<uint32_t, 2> differs_{};
};
UniformProbe g_uniformProbe;
void take_eye_scene(stereo::SinkFrame& frame, wgpu::CommandEncoder& encoder) noexcept {
static uint32_t sets = 0;
auto& record = gfx::eye_scene_record();
if (frame.mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY && record.valid) {
++sets;
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT && eye < 2; ++eye) {
frame.scene[eye] = {.valid = true, .late = record.late, .anchored = record.anchored,
.continuous = record.continuous, .weight = record.weight,
.previousFrame = record.previousFrame, .currentFrame = record.currentFrame, .set = sets,
.interpolated = record.interpolated[eye], .current = record.current[eye],
.recorded = record.recorded[eye], .outside = record.outside[eye]};
if (record.posed[eye]) {
frame.scene[eye].poseTime = record.poseTime[eye];
frame.scene[eye].poseToken = record.poseToken[eye];
frame.scene[eye].poseOrientation = eye_orientation(record.pose[eye]);
float projection[16];
std::memcpy(projection, &record.projection[eye], sizeof(projection));
const auto fov = gfx::foveation::fov_from_projection(projection);
frame.scene[eye].poseTangents = {fov.tanLeft, fov.tanRight, fov.tanDown, fov.tanUp};
frame.scene[eye].poseMixed = record.poseMixed[eye];
}
}
{
// Lane P: every set's eyes must be filled from the set's own packet, one transform per eye. Counted
// always (a headset log shows it without the test stamp): sets checked, sets whose eyes used
// different packets, sets with an eye filled from a packet other than the set's, sets with an eye
// whose draws were filled with more than one transform.
static uint32_t checked = 0, split = 0, foreign = 0, mixed = 0;
static auto since = std::chrono::steady_clock::now();
const auto& l = record;
if (UniformProbe::enabled()) g_uniformProbe.record(encoder, record);
if (l.posed[0] && l.posed[1]) {
++checked;
split += l.poseTime[0] != l.poseTime[1] || l.poseToken[0] != l.poseToken[1] ? 1 : 0;
foreign += l.poseTime[0] != frame.displayTimeNanos || l.poseTime[1] != frame.displayTimeNanos ||
l.poseToken[0] != frame.frameToken || l.poseToken[1] != frame.frameToken
? 1
: 0;
mixed += l.poseMixed[0] != 0 || l.poseMixed[1] != 0 ? 1 : 0;
}
if (const auto now = std::chrono::steady_clock::now(); now - since >= std::chrono::seconds(10) && checked != 0) {
Log.info("VR eye poses (10 s): {} sets, eyes from different packets {}, an eye not from the set's packet {}, "
"an eye filled with more than one transform {}",
checked, split, foreign, mixed);
if (UniformProbe::enabled()) {
const auto probe = g_uniformProbe.take();
Log.info("VR eye uniform probe (10 s): {} sets read back after their eyes, left eye's range not what its "
"fill wrote {}, right eye's {} ({} sets not probed: no free slot)",
probe[0], probe[1], probe[2], probe[3]);
}
checked = split = foreign = mixed = 0;
since = now;
}
}
static const bool logSets = [] {
const char* env = std::getenv("KARTFRAME_FRAME_LOG");
return env != nullptr && env[0] == '1';
}();
if (logSets) {
const auto& l = frame.scene[0];
const auto& r = frame.scene[1];
// Lane P, on the same line (one log call per set): the packet and head pose each eye's uniform fill used.
// XRP tL/tR=<packet display time per eye, ms> kL/kR=<packet token> yL/yR=<eye yaw, degrees> mL/mR=<draws
// filled with another packet or transform> same=<1: both eyes from the set's own packet>.
const auto yaw = [](const stereo::EyeScene& e) {
const auto& q = e.poseOrientation;
return std::atan2(2.f * (q[3] * q[1] + q[0] * q[2]), 1.f - 2.f * (q[1] * q[1] + q[0] * q[0])) * 57.29578f;
};
Log.info("XRS t={:.3f} s={} f={}/{} w={:.4f} late={} L={}/{}/{}/{} R={}/{}/{}/{} XRP tL={:.3f} tR={:.3f} kL={} kR={} "
"yL={:.4f} yR={:.4f} mL={} mR={} same={}",
static_cast<double>(frame.displayTimeNanos) / 1e6, sets, l.previousFrame, l.currentFrame, l.weight,
l.late ? 1 : 0, l.interpolated, l.current, l.recorded, l.outside, r.interpolated, r.current, r.recorded,
r.outside, static_cast<double>(l.poseTime) / 1e6, static_cast<double>(r.poseTime) / 1e6, l.poseToken,
r.poseToken, yaw(l), yaw(r), l.poseMixed, r.poseMixed,
l.poseTime == r.poseTime && l.poseToken == r.poseToken && l.poseTime == frame.displayTimeNanos ? 1 : 0);
}
}
record = {};
}
std::optional<PendingStereoSink> run_stereo_sink(wgpu::CommandEncoder& encoder, uint64_t frameToken,
uint32_t logicalFrame, AuroraStereoFrameMode mode,
bool retained = false, uint64_t displayTimeNanos = 0) noexcept {
StereoSinkRegistration registration;
{
std::lock_guard lock(g_stereoRegistrationMutex);
registration = g_stereoSink;
}
if (registration.callback == nullptr) {
return std::nullopt;
}
stereo::SinkFrame frame{
.frameToken = frameToken,
.logicalFrame = logicalFrame,
.mode = mode,
.retained = retained,
.displayTimeNanos = displayTimeNanos,
};
take_eye_scene(frame, encoder);
const int orientation = mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY ? webgpu::g_RaceOutputCopyMode : 0;
const bool correction = mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY && webgpu::g_RaceOutputNeedsCopy;
const auto directEyes = std::exchange(g_directEyes, {});
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
const auto& output = g_stereoEyeTargets[eye].output();
const webgpu::TextureWithSampler* image = &output;
if (directEyes[eye].texture && mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY) {
frame.eyes[eye] = {
.texture = &directEyes[eye].texture,
.view = &directEyes[eye].view,
.size = output.size,
.format = output.format,
};
continue;
}
if (correction) {
auto& target = g_stereoEyeTargets[eye];
if (!target.orientedColor.texture || target.orientedColor.size.width != output.size.width ||
target.orientedColor.size.height != output.size.height || target.orientedColor.format != output.format) {
target.orientedColor = webgpu::create_render_texture(output.size.width, output.size.height, false);
target.orientedCopyBindGroup = {};
}
if (!target.orientedCopyBindGroup) target.orientedCopyBindGroup = webgpu::create_copy_bind_group(output);
const wgpu::RenderPassColorAttachment attachment{
.view = target.orientedColor.view,
.loadOp = wgpu::LoadOp::Clear,
.storeOp = wgpu::StoreOp::Store,
};
const wgpu::RenderPassDescriptor passDescriptor{
.label = "VR output orientation correction",
.colorAttachmentCount = 1,
.colorAttachments = &attachment,
};
auto pass = encoder.BeginRenderPass(&passDescriptor);
pass.SetPipeline(webgpu::g_OrientedCopyPipelines[orientation]);
pass.SetBindGroup(0, target.orientedCopyBindGroup);
pass.Draw(3);
pass.End();
image = &target.orientedColor;
}
frame.eyes[eye] = {
.texture = &image->texture,
.view = &image->view,
.size = image->size,
.format = image->format,
};
}
if (!registration.callback(encoder, frame, registration.userdata)) {
return std::nullopt;
}
return PendingStereoSink{
.frame = frame,
.submitted = registration.submitted,
.userdata = registration.userdata,
};
}
void request_surface_reconfigure() noexcept { g_surfaceReconfigurePending.store(true, std::memory_order_release); }
void request_surface_recreate() noexcept {
g_surfaceRecreatePending.store(true, std::memory_order_release);
g_surfaceReconfigurePending.store(true, std::memory_order_release);
}
struct PendingFrameCapture {
wgpu::Buffer buffer;
uint32_t width = 0;
uint32_t height = 0;
uint32_t bytesPerRow = 0;
uint64_t bufferSize = 0;
bool bgra = false;
std::string path;
};
std::optional<PendingFrameCapture> encode_frame_capture(const wgpu::CommandEncoder& encoder,
const webgpu::PresentSource& source) {
const uint32_t requestedFrame = g_captureFrame.load(std::memory_order_acquire);
const uint32_t currentFrame = gfx::current_frame();
if (requestedFrame == UINT32_MAX || currentFrame < requestedFrame)
return std::nullopt;
g_captureFrame.store(UINT32_MAX, std::memory_order_release);
if (currentFrame != requestedFrame) {
Log.error("Missed requested frame capture {} (current frame {})", requestedFrame, currentFrame);
return std::nullopt;
}
if (!source.texture || source.size.width == 0 || source.size.height == 0) {
Log.error("Frame {} capture has no present-source texture", currentFrame);
return std::nullopt;
}
const bool bgra =
source.format == wgpu::TextureFormat::BGRA8Unorm || source.format == wgpu::TextureFormat::BGRA8UnormSrgb;
const bool rgba =
source.format == wgpu::TextureFormat::RGBA8Unorm || source.format == wgpu::TextureFormat::RGBA8UnormSrgb;
if (!bgra && !rgba) {
Log.error("Frame {} capture does not support texture format {}", currentFrame,
magic_enum::enum_name(source.format));
return std::nullopt;
}
const uint32_t bytesPerRow = align_to(source.size.width * 4, 256);
const uint64_t bufferSize = static_cast<uint64_t>(bytesPerRow) * source.size.height;
const wgpu::BufferDescriptor descriptor{
.label = "Visual validation frame capture",
.usage = wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::MapRead,
.size = bufferSize,
};
auto buffer = g_device.CreateBuffer(&descriptor);
const wgpu::TexelCopyTextureInfo sourceInfo{
.texture = source.texture,
.mipLevel = 0,
.origin = {0, 0, 0},
.aspect = wgpu::TextureAspect::All,
};
const wgpu::TexelCopyBufferInfo destinationInfo{
.layout = {.offset = 0, .bytesPerRow = bytesPerRow, .rowsPerImage = source.size.height},
.buffer = buffer,
};
encoder.CopyTextureToBuffer(&sourceInfo, &destinationInfo, &source.size);
return PendingFrameCapture{
.buffer = std::move(buffer),
.width = source.size.width,
.height = source.size.height,
.bytesPerRow = bytesPerRow,
.bufferSize = bufferSize,
.bgra = bgra,
.path = g_captureOutputPath,
};
}
void complete_frame_capture(PendingFrameCapture& capture) {
wgpu::MapAsyncStatus mapStatus = wgpu::MapAsyncStatus::CallbackCancelled;
wgpu::StringView mapMessage{};
const auto future =
capture.buffer.MapAsync(wgpu::MapMode::Read, 0, capture.bufferSize, wgpu::CallbackMode::WaitAnyOnly,
[&mapStatus, &mapMessage](wgpu::MapAsyncStatus status, wgpu::StringView message) {
mapStatus = status;
mapMessage = message;
});
const auto waitStatus = g_instance.WaitAny(future, 5000000000);
if (waitStatus != wgpu::WaitStatus::Success || mapStatus != wgpu::MapAsyncStatus::Success) {
Log.error("Frame capture readback failed wait={} map={} message={}", magic_enum::enum_name(waitStatus),
magic_enum::enum_name(mapStatus), mapMessage);
return;
}
const auto* pixels = static_cast<const uint8_t*>(capture.buffer.GetConstMappedRange(0, capture.bufferSize));
if (write_bmp(capture.path.c_str(), pixels, capture.width, capture.height, capture.bytesPerRow, capture.bgra)) {
Log.info("Captured rendered frame to '{}' ({}x{})", capture.path, capture.width, capture.height);
} else {
Log.error("Failed to write rendered frame capture to '{}'", capture.path);
}
capture.buffer.Unmap();
}
#endif
// AuroraBackend is an anonymous C typedef enum, which magic_enum cannot
// reflect under this toolchain -- name it by hand for diagnostics.
constexpr const char* backend_name(AuroraBackend backend) noexcept {
switch (backend) {
case BACKEND_AUTO:
return "Auto";
case BACKEND_D3D11:
return "D3D11";
case BACKEND_D3D12:
return "D3D12";
case BACKEND_METAL:
return "Metal";
case BACKEND_VULKAN:
return "Vulkan";
case BACKEND_OPENGL:
return "OpenGL";
case BACKEND_OPENGLES:
return "OpenGLES";
case BACKEND_WEBGPU:
return "WebGPU";
case BACKEND_NULL:
return "Null";
}
return "Unknown";
}
#ifdef AURORA_ENABLE_GX
constexpr std::array PreferredBackendOrder{
#ifdef ENABLE_BACKEND_WEBGPU
BACKEND_WEBGPU,
#endif
#ifdef DAWN_ENABLE_BACKEND_D3D12
BACKEND_D3D12,
#endif
#if defined(_WIN32) && defined(DAWN_ENABLE_BACKEND_D3D11)
BACKEND_D3D11,
#endif
#ifdef DAWN_ENABLE_BACKEND_METAL
BACKEND_METAL,
#endif
#ifdef DAWN_ENABLE_BACKEND_VULKAN
BACKEND_VULKAN,
#endif
#if !defined(_WIN32) && defined(DAWN_ENABLE_BACKEND_D3D11)
BACKEND_D3D11,
#endif
// #ifdef DAWN_ENABLE_BACKEND_DESKTOP_GL
// BACKEND_OPENGL,
// #endif
// #ifdef DAWN_ENABLE_BACKEND_OPENGLES
// BACKEND_OPENGLES,
// #endif
#ifdef DAWN_ENABLE_BACKEND_NULL
BACKEND_NULL,
#endif
};
#else
constexpr std::array<AuroraBackend, 0> PreferredBackendOrder{};
#endif
bool g_initialFrame = false;
AuroraInfo initialize(int argc, char* argv[], const AuroraConfig& config) noexcept {
g_config = config;
Log.info("Aurora initializing");
log_system_information();
if (g_config.appName == nullptr) {
g_config.appName = "Aurora";
} else {
g_config.appName = strdup(g_config.appName);
}
if (g_config.userPath == nullptr) {
g_config.userPath = SDL_GetPrefPath(nullptr, g_config.appName);
} else {
g_config.userPath = strdup(g_config.userPath);
}
if (g_config.cachePath == nullptr) {
g_config.cachePath = SDL_GetPrefPath(nullptr, g_config.appName);
} else {
g_config.cachePath = strdup(g_config.cachePath);
}
if (g_config.resourcesPath == nullptr) {
g_config.resourcesPath = SDL_GetBasePath();
} else {
g_config.resourcesPath = strdup(g_config.resourcesPath);
}
if (g_config.pipelineCachePath == nullptr) {
g_config.pipelineCachePath = g_config.cachePath;
} else {
g_config.pipelineCachePath = strdup(g_config.pipelineCachePath);
}
if (g_config.msaa == 0) {
g_config.msaa = 1;
}
if (g_config.maxTextureAnisotropy == 0) {
g_config.maxTextureAnisotropy = 16;
}
ASSERT(window::initialize(), "Error initializing window");
g_sdlCustomEventsStart = SDL_RegisterEvents(2);
ASSERT(g_sdlCustomEventsStart, "Failed to allocate user events: {}", SDL_GetError());
ASSERT(window::initialize_event_watch(), "Error initializing SDL event watch");
#ifdef AURORA_ENABLE_GX
/* Attempt to create a window using the calling application's desired backend */
const AuroraBackend requestedBackend = config.desiredBackend;
AuroraBackend selectedBackend = requestedBackend;
bool windowCreated = false;
if (selectedBackend != BACKEND_AUTO) {
Log.info("Requested graphics backend: {}", backend_name(selectedBackend));
if (window::create_window(selectedBackend)) {
if (webgpu::initialize(selectedBackend)) {
windowCreated = true;
} else {
window::destroy_window();
}
} else {
Log.error("Failed to create a window for backend {}: {}", backend_name(selectedBackend), SDL_GetError());
}
if (!windowCreated) {
/* An explicitly requested backend that cannot be brought up falls back to the BACKEND_AUTO
* search instead of aborting, and the substitution is always reported. */
Log.error(
"Requested graphics backend {} is unavailable on this system; "
"falling back to automatic selection",
backend_name(requestedBackend));
}
}
if (!windowCreated) {
for (const auto backendType : PreferredBackendOrder) {
selectedBackend = backendType;
if (!window::create_window(selectedBackend)) {
continue;
}
if (webgpu::initialize(selectedBackend)) {
windowCreated = true;
break;
} else {
window::destroy_window();
}
}
}
ASSERT(windowCreated, "Error creating window: {}", SDL_GetError());
if (requestedBackend != BACKEND_AUTO && selectedBackend != requestedBackend) {
Log.error(
"Graphics backend fallback in effect: video.graphics_api requested {}, "
"running on {}",
backend_name(requestedBackend), backend_name(selectedBackend));
}
// Initialize SDL_Renderer for ImGui when we can't use a Dawn backend
if (webgpu::g_backendType == wgpu::BackendType::Null) {
ASSERT(window::create_renderer(), "Failed to initialize SDL renderer: {}", SDL_GetError());
}
#else
AuroraBackend selectedBackend = BACKEND_NULL;
ASSERT(window::create_window(BACKEND_NULL), "Error creating window: {}", SDL_GetError());
ASSERT(window::create_renderer(), "Failed to initialize SDL renderer: {}", SDL_GetError());
#endif
window::show_window();
#ifdef AURORA_ENABLE_GX
gfx::initialize();
imgui::create_context();
#endif
const auto size = window::get_window_size();
Log.info("Using framebuffer size {}x{} scale {}", size.fb_width, size.fb_height, size.scale);
#ifdef AURORA_ENABLE_GX
if (g_config.imGuiInitCallback != nullptr) {
g_config.imGuiInitCallback(&size);
}
imgui::initialize();
#endif
g_initialFrame = true;
g_config.desiredBackend = selectedBackend;
return {
.backend = selectedBackend,
.userPath = g_config.userPath,
.cachePath = g_config.cachePath,
.window = window::get_sdl_window(),
.windowSize = size,
};
}
#ifdef AURORA_ENABLE_GX
struct AcquiredSurfaceTexture {
wgpu::Texture texture;
wgpu::TextureView view;
};
std::optional<AcquiredSurfaceTexture> acquire_surface_texture() noexcept {
if (!window::is_presentable()) {
request_surface_reconfigure();
return std::nullopt;
}
if (!g_surface) {
request_surface_reconfigure();
return std::nullopt;
}
if (webgpu::headless()) {
auto texture = webgpu::headless_surface_texture();
return AcquiredSurfaceTexture{.texture = texture, .view = texture.CreateView()};
}
wgpu::SurfaceTexture surfaceTexture;
g_surface.GetCurrentTexture(&surfaceTexture);
switch (surfaceTexture.status) {
case wgpu::SurfaceGetCurrentTextureStatus::SuccessOptimal:
return AcquiredSurfaceTexture{
.texture = surfaceTexture.texture,
.view = surfaceTexture.texture.CreateView(),
};
case wgpu::SurfaceGetCurrentTextureStatus::SuccessSuboptimal:
Log.info("Surface texture is suboptimal, deferring swapchain reconfiguration");
request_surface_reconfigure();
return AcquiredSurfaceTexture{
.texture = surfaceTexture.texture,
.view = surfaceTexture.texture.CreateView(),
};
case wgpu::SurfaceGetCurrentTextureStatus::Timeout:
Log.warn("Surface texture acquisition timed out");
return std::nullopt;
case wgpu::SurfaceGetCurrentTextureStatus::Outdated:
Log.info("Surface texture is {}, reconfiguring swapchain", magic_enum::enum_name(surfaceTexture.status));
request_surface_reconfigure();
return std::nullopt;
case wgpu::SurfaceGetCurrentTextureStatus::Lost:
Log.warn("Surface texture is {}, requesting surface recreation", magic_enum::enum_name(surfaceTexture.status));
request_surface_recreate();
return std::nullopt;
case wgpu::SurfaceGetCurrentTextureStatus::Error:
Log.warn("Surface texture is {}, deferring surface recovery", magic_enum::enum_name(surfaceTexture.status));
request_surface_reconfigure();
return std::nullopt;
default:
Log.error("Failed to get surface texture: {}", magic_enum::enum_name(surfaceTexture.status));
return std::nullopt;
}
}
struct PresentationImage {
webgpu::TextureWithSampler texture;
wgpu::BindGroup bindGroup;
};
std::shared_ptr<PresentationImage> g_retainedMenuImage;
AuroraVRUiGuide g_retainedMenuGuide{};
struct PresentationJob {
std::shared_ptr<PresentationImage> image;
uint32_t logicalFrame = 0;
// Absolute deadline on PresentClock stamped by the producer's schedule.
// Default (epoch) means "present as soon as ready" (no software pacing).
PresentClock::time_point presentAt{};
bool interpolated = false;
// Slot carries a copy of the native image rather than a replayed interpolation. It counts as a
// present but not toward effectiveFramesPerSecond.
bool duplicated = false;
// Whole display periods the group slid forward at encode time (late group).
uint32_t slidPeriods = 0;
};
std::array<std::vector<std::shared_ptr<PresentationImage>>, gx::MaxInterpolatedFrames + 1> g_presentationImagePools;
std::shared_ptr<PresentationImage> acquire_presentation_image(size_t slot, uint32_t width, uint32_t height) {
auto& pool = g_presentationImagePools.at(slot);
// A use count of one means only the pool holds the image, so no job can be reading it. Idle
// images from an older surface size are dropped here instead of leaking for the run.
for (auto it = pool.begin(); it != pool.end();) {
const auto& image = *it;
if (image->texture.size.width == width && image->texture.size.height == height) {
if (image.use_count() == 1) {
return image;
}
++it;
continue;
}
if (image.use_count() == 1) {
it = pool.erase(it);
} else {
++it;
}
}
auto image = std::make_shared<PresentationImage>();
image->texture = webgpu::create_render_texture(width, height, false);
image->bindGroup = webgpu::create_copy_bind_group(image->texture);
pool.emplace_back(image);
return image;
}
// A standalone headset never shows the app's Android surface while OpenXR drives the display, so presenting to it
// (and copying the mirror image the desktop would show) is pure GPU cost there. Presentation snapshots are still
// encoded: in menus the virtual-screen eyes are built from them. Desktop keeps its window mirror.
bool headset_owns_display() noexcept {
#if defined(__ANDROID__)
return stereo_frame_provider_active();
#else
return false;
#endif
}
bool present_presentation_job(const PresentationJob& job) {
ZoneScoped;
if (headset_owns_display()) {
return false;
}
const auto submissionStarted = PresentClock::now();
// Keep the threshold far above compositor and scheduling jitter. The timings below separate a
// real surface stall from a bad deadline, and only the former needs a rebuild.
constexpr auto kSurfaceStallThreshold = std::chrono::milliseconds(250);
std::chrono::nanoseconds surfaceLockDuration{};
std::chrono::nanoseconds acquireDuration{};
std::chrono::nanoseconds encodeDuration{};
std::chrono::nanoseconds finishDuration{};
std::chrono::nanoseconds submitDuration{};
std::chrono::nanoseconds scheduleWaitDuration{};
std::chrono::nanoseconds presentDuration{};
bool presented = false;
if (g_surfaceReconfigurePending.load(std::memory_order_acquire) || window::native_resize_pending() ||
!window::is_presentable()) {
return false;
}
std::chrono::nanoseconds lateBy{};
if (job.presentAt != PresentClock::time_point{}) {
// How expired the deadline already is at dequeue. Positive values mean the
// slot cannot be paced and fires immediately, which is a burst symptom.
lateBy = std::chrono::duration_cast<std::chrono::nanoseconds>(PresentClock::now() - job.presentAt);
}
{
window::SurfaceLock surfaceLock;
// Acquire, encode, submit and present are one unit against a configured swapchain, so the
// surface lock covers all of them. The renderer mutex is deliberately not taken.
const auto surfaceLockStarted = PresentClock::now();
std::unique_lock surfaceOwnership(g_surfaceMutex);
surfaceLockDuration =
std::chrono::duration_cast<std::chrono::nanoseconds>(PresentClock::now() - surfaceLockStarted);
// Surface contention is waiting, not encoding, so keep it out of the encode timing where a
// reconfigure would look like GPU command recording.
const auto workStarted = PresentClock::now();
bool surfaceSizeChanged = window::native_resize_pending();
if (!surfaceSizeChanged && !g_surfaceReconfigurePending.load(std::memory_order_acquire) &&
window::is_presentable() && g_surface) {
// native_window_size_matches compares the OS client size with the configured swapchain, which is
// what native_fb_* reports. One window query instead of SDL's per-call ones.
surfaceSizeChanged = !window::native_window_size_matches(webgpu::g_graphicsConfig.surfaceConfiguration.width,
webgpu::g_graphicsConfig.surfaceConfiguration.height);
}
if (!surfaceSizeChanged && window::is_presentable()) {
const auto acquireStarted = PresentClock::now();
auto acquired = acquire_surface_texture();
acquireDuration = std::chrono::duration_cast<std::chrono::nanoseconds>(PresentClock::now() - acquireStarted);
if (acquired) {
const wgpu::CommandEncoderDescriptor encoderDescriptor{
.label = "Presentation encoder",
};
const auto encoder = g_device.CreateCommandEncoder(&encoderDescriptor);
const std::array attachments{
wgpu::RenderPassColorAttachment{
.view = acquired->view,
.loadOp = wgpu::LoadOp::Clear,
.storeOp = wgpu::StoreOp::Store,
},
};
const wgpu::RenderPassDescriptor renderPassDescriptor{
.label = "Presentation copy pass",
.colorAttachmentCount = attachments.size(),
.colorAttachments = attachments.data(),
.timestampWrites = gfx::gpu_timing_pass(gfx::GpuTimingCategory::Present),
};
const auto pass = encoder.BeginRenderPass(&renderPassDescriptor);
pass.SetPipeline(webgpu::g_CopyPipeline);
pass.SetBindGroup(0, job.image->bindGroup, 0, nullptr);
pass.SetViewport(0.f, 0.f, static_cast<float>(webgpu::g_graphicsConfig.surfaceConfiguration.width),
static_cast<float>(webgpu::g_graphicsConfig.surfaceConfiguration.height), 0.f, 1.f);
pass.Draw(3);
pass.End();
const auto encodeFinished = PresentClock::now();
encodeDuration =
std::chrono::duration_cast<std::chrono::nanoseconds>(encodeFinished - workStarted - acquireDuration);
const wgpu::CommandBufferDescriptor cmdBufDescriptor{
.label = "Presentation command buffer",
};
const auto finishStarted = PresentClock::now();
const auto buffer = encoder.Finish(&cmdBufDescriptor);
finishDuration = std::chrono::duration_cast<std::chrono::nanoseconds>(PresentClock::now() - finishStarted);
const auto submitStarted = PresentClock::now();
{
std::lock_guard submitLock(g_queueSubmitMutex);
g_queue.Submit(1, &buffer);
}
submitDuration = std::chrono::duration_cast<std::chrono::nanoseconds>(PresentClock::now() - submitStarted);
// Pace the Present() call itself, not the whole unit, so acquire/encode/submit variance stays out
// of the cadence. Holding the image across the wait is safe while the surface lock is held.
if (job.presentAt != PresentClock::time_point{}) {
const auto scheduleWaitStarted = PresentClock::now();
wait_until_precise(job.presentAt);
scheduleWaitDuration =
std::chrono::duration_cast<std::chrono::nanoseconds>(PresentClock::now() - scheduleWaitStarted);
}
// A native resize can arrive after acquisition, so drop the obsolete image and let the render
// worker reconfigure at its ordered frame boundary.
if (!g_surfaceReconfigurePending.load(std::memory_order_acquire) && !window::native_resize_pending() &&
window::is_presentable() &&
window::native_window_size_matches(webgpu::g_graphicsConfig.surfaceConfiguration.width,
webgpu::g_graphicsConfig.surfaceConfiguration.height)) {
const auto presentStarted = PresentClock::now();
wgpu::Status presentStatus;
{
std::lock_guard submitLock(g_queueSubmitMutex);
if (webgpu::headless()) {
static uint64_t headlessPresents = 0;
if (++headlessPresents % 300 == 0) {
Log.info("headless: {} frames presented to the offscreen target", headlessPresents);
}
presentStatus = wgpu::Status::Success;
} else {
presentStatus = g_surface.Present();
}
}
presentDuration = std::chrono::duration_cast<std::chrono::nanoseconds>(PresentClock::now() - presentStarted);
if (presentStatus == wgpu::Status::Success) {
presented = true;
record_successful_present(
job.interpolated, job.logicalFrame, acquireDuration, encodeDuration, finishDuration, submitDuration,
presentDuration,
std::chrono::duration_cast<std::chrono::nanoseconds>(PresentClock::now() - submissionStarted),
job.duplicated, job.slidPeriods, lateBy);
} else {
Log.warn("Surface present failed: {}", static_cast<int>(presentStatus));
request_surface_reconfigure();
}
}
acquired.reset();
}
}
}
const auto totalDuration =
std::chrono::duration_cast<std::chrono::nanoseconds>(PresentClock::now() - submissionStarted);
constexpr int kStallRebuildThreshold = 3;
constexpr auto kStallRebuildCooldown = std::chrono::seconds(5);
static int s_consecutiveStalledPresents = 0;
static PresentClock::time_point s_lastStallRebuild{};
if (totalDuration >= kSurfaceStallThreshold) {
const auto surfaceWorkDuration =
acquireDuration + encodeDuration + finishDuration + submitDuration + presentDuration;
const bool surfaceStalled = surfaceWorkDuration >= kSurfaceStallThreshold;
bool rebuildRequested = false;
if (surfaceStalled) {
++s_consecutiveStalledPresents;
const auto now = PresentClock::now();
if (s_consecutiveStalledPresents >= kStallRebuildThreshold &&
(s_lastStallRebuild == PresentClock::time_point{} || now - s_lastStallRebuild >= kStallRebuildCooldown)) {
s_lastStallRebuild = now;
s_consecutiveStalledPresents = 0;
rebuildRequested = true;
}
} else {
s_consecutiveStalledPresents = 0;
}
Log.warn(
"Presentation job took {:.1f} ms (surface lock {:.1f}, acquire {:.1f}, encode {:.1f}, "
"finish {:.1f}, submit {:.1f}, schedule wait {:.1f}, present {:.1f}){}",
std::chrono::duration<double, std::milli>(totalDuration).count(),
std::chrono::duration<double, std::milli>(surfaceLockDuration).count(),
std::chrono::duration<double, std::milli>(acquireDuration).count(),
std::chrono::duration<double, std::milli>(encodeDuration).count(),
std::chrono::duration<double, std::milli>(finishDuration).count(),
std::chrono::duration<double, std::milli>(submitDuration).count(),
std::chrono::duration<double, std::milli>(scheduleWaitDuration).count(),
std::chrono::duration<double, std::milli>(presentDuration).count(),
rebuildRequested ? "; rebuilding the surface" : "");
if (rebuildRequested) {
request_surface_reconfigure();
}
} else {
s_consecutiveStalledPresents = 0;
}
return presented;
}
struct PresenterState {
std::mutex mutex;
std::condition_variable cv;
std::thread thread;
std::deque<PresentationJob> jobs;
bool started = false;
bool stop = false;
bool presenting = false;
};
PresenterState g_presenter;
std::atomic<bool> g_presenterStarted{false};
void presenter_main() noexcept {
if (!SDL_SetCurrentThreadPriority(SDL_THREAD_PRIORITY_HIGH)) {
Log.warn("Could not raise the asynchronous presenter thread priority: {}", SDL_GetError());
}
for (;;) {
PresentationJob job;
{
std::unique_lock lock(g_presenter.mutex);
g_presenter.cv.wait(lock, [] { return g_presenter.stop || !g_presenter.jobs.empty(); });
if (g_presenter.stop && g_presenter.jobs.empty()) {
break;
}
job = std::move(g_presenter.jobs.front());
g_presenter.jobs.pop_front();
g_presenter.presenting = true;
}
g_presenter.cv.notify_all();
present_presentation_job(job);
{
std::lock_guard lock(g_presenter.mutex);
g_presenter.presenting = false;
}
g_presenter.cv.notify_all();
}
}
void ensure_presenter_started() {
std::lock_guard lock(g_presenter.mutex);
if (g_presenter.started) {
return;
}
g_presenter.stop = false;
g_presenter.presenting = false;
g_presenter.thread = std::thread(presenter_main);
g_presenter.started = true;
g_presenterStarted.store(true, std::memory_order_release);
Log.info("Enabled bounded asynchronous presentation worker");
}
void wait_for_presenter_idle() noexcept {
if (!g_presenterStarted.load(std::memory_order_acquire)) {
return;
}
std::unique_lock lock(g_presenter.mutex);
g_presenter.cv.wait(lock, [] { return g_presenter.jobs.empty() && !g_presenter.presenting; });
}
void enqueue_presentations(std::vector<PresentationJob>&& jobs) {
ensure_presenter_started();
// Scale the bound with the group being handed over: a 240 Hz frame enqueues four jobs at once,
// and a bound sized for two groups blocked the frame worker on a draining one.
const size_t maximumQueuedJobs =
(std::max)(static_cast<size_t>(2 * (gx::MaxInterpolatedFrames + 1)), 3 * jobs.size());
std::unique_lock lock(g_presenter.mutex);
if (g_presenter.stop) {
return;
}
if (g_presenter.jobs.size() + jobs.size() > maximumQueuedJobs) {
// Presentation is a real-time stream, not a lossless queue: waiting for room couples the guest
// and audio clocks to a blocked Present(). Keep the newest group and drop obsolete images.
const size_t dropped = g_presenter.jobs.size();
g_presenter.jobs.clear();
if (dropped != 0) {
// A stuck Present can keep replacement mode active for a while. Aggregate
// the warning instead of turning a driver stall into a log flood.
static size_t droppedSinceWarning = 0;
static PresentClock::time_point lastWarning{};
droppedSinceWarning += dropped;
const auto now = PresentClock::now();
if (lastWarning == PresentClock::time_point{} || now - lastWarning >= std::chrono::seconds(1)) {
Log.warn("Presenter fell behind; dropped {} stale presentation jobs", droppedSinceWarning);
droppedSinceWarning = 0;
lastWarning = now;
}
}
}
for (auto& job : jobs) {
g_presenter.jobs.emplace_back(std::move(job));
}
lock.unlock();
g_presenter.cv.notify_all();
}
void stop_presenter() noexcept {
if (!g_presenterStarted.load(std::memory_order_acquire)) {
return;
}
{
std::lock_guard lock(g_presenter.mutex);
g_presenter.stop = true;
}
g_presenter.cv.notify_all();
if (g_presenter.thread.joinable()) {
g_presenter.thread.join();
}
{
std::lock_guard lock(g_presenter.mutex);
g_presenter.jobs.clear();
g_presenter.started = false;
g_presenter.presenting = false;
}
g_presenterStarted.store(false, std::memory_order_release);
}
// What a presentation slot draws under the image. Mono is the ordinary desktop
// view; the rest mirror the headset and are only ever chosen while a stereo
// provider is feeding one. ImGui is drawn over all of them alike, so the
// settings menu stays reachable even under Black.
using stereo::MirrorPlan;
// Places one eye inside `bounds`, keeping the eye's own aspect ratio rather
// than the game's presented one: an eye is already the shape the headset asked
// for, so letterboxing it to the game's aspect would crop the compositor's view.
void draw_mirror_eye(const wgpu::RenderPassEncoder& pass, uint32_t eyeIndex, const webgpu::Viewport& bounds) {
const auto bindGroup = stereo_eye_copy_bind_group(eyeIndex);
const auto& output = g_stereoEyeTargets[eyeIndex].output();
if (!bindGroup || output.size.width == 0 || output.size.height == 0 || bounds.width <= 0.f ||
bounds.height <= 0.f) {
return;
}
const auto fitted = webgpu::calculate_present_viewport(static_cast<uint32_t>(bounds.width),
static_cast<uint32_t>(bounds.height), output.size.width,
output.size.height);
// A window too small to hold a half still rounds down to nothing here.
if (fitted.width <= 0.f || fitted.height <= 0.f) {
return;
}
pass.SetBindGroup(0, bindGroup, 0, nullptr);
pass.SetViewport(bounds.left + fitted.left, bounds.top + fitted.top, fitted.width, fitted.height, fitted.znear,
fitted.zfar);
pass.Draw(3);
}
// Read by aurora_get_stereo_screen_aspects from the XR input thread.
std::atomic<float> g_stereoPictureAspect{0.f};
std::atomic<float> g_stereoSnapshotAspect{0.f};
// Records the geometry a headset frame's 2D content was laid out with: the picture aspect exactly as
// encode_presentation_snapshot fits it into the snapshot (and, for immersive replay, as
// stereo_hud_screen sizes the HUD screen), and the snapshot's own aspect.
void publish_stereo_screen_aspects(const webgpu::PresentSource& presentSource, const wgpu::Extent3D& snapshotSize,
bool immersiveReplay) noexcept {
float picture = 0.f;
if (!window::get_present_aspect_ratio(picture) || !(picture > 0.f)) {
if (immersiveReplay) {
picture = 16.f / 9.f;
} else if (presentSource.size.width != 0 && presentSource.size.height != 0) {
picture = static_cast<float>(presentSource.size.width) / static_cast<float>(presentSource.size.height);
}
}
const float snapshot = snapshotSize.width != 0 && snapshotSize.height != 0
? static_cast<float>(snapshotSize.width) / static_cast<float>(snapshotSize.height)
: 0.f;
g_stereoPictureAspect.store(picture, std::memory_order_relaxed);
g_stereoSnapshotAspect.store(snapshot, std::memory_order_relaxed);
}
// `presentSource` is latched in the seal prologue: by the time this encodes, the producer's next
// gfx::begin_frame() may already have cleared the display-copy override.
void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, const webgpu::PresentSource& presentSource,
const PresentationImage& image, bool includeImGui,
MirrorPlan plan = MirrorPlan::Mono, const ImDrawData* hostImGuiData = nullptr) {
ZoneScoped;
auto viewport = webgpu::calculate_present_viewport(image.texture.size.width, image.texture.size.height,
presentSource.size.width, presentSource.size.height);
float presentAspect = 0.f;
if (window::get_present_aspect_ratio(presentAspect)) {
viewport = webgpu::calculate_present_viewport_for_aspect(image.texture.size.width, image.texture.size.height,
presentAspect);
}
wgpu::BindGroup presentBindGroup = presentSource.bindGroup;
{
const std::array attachments{
wgpu::RenderPassColorAttachment{
.view = image.texture.view,
.loadOp = wgpu::LoadOp::Clear,
.storeOp = wgpu::StoreOp::Store,
},
};
const wgpu::RenderPassDescriptor renderPassDescriptor{
.label = "Interpolation snapshot pass",
.colorAttachmentCount = attachments.size(),
.colorAttachments = attachments.data(),
.timestampWrites = gfx::gpu_timing_pass(gfx::GpuTimingCategory::Snapshot),
};
const auto pass = encoder.BeginRenderPass(&renderPassDescriptor);
const auto imageWidth = static_cast<float>(image.texture.size.width);
const auto imageHeight = static_cast<float>(image.texture.size.height);
// Black needs nothing but the clear the attachment already performed.
if (plan != MirrorPlan::Black) {
pass.SetPipeline(webgpu::g_CopyPipeline);
}
switch (plan) {
case MirrorPlan::Mono:
pass.SetBindGroup(0, presentBindGroup, 0, nullptr);
pass.SetViewport(viewport.left, viewport.top, viewport.width, viewport.height, viewport.znear, viewport.zfar);
pass.Draw(3);
break;
case MirrorPlan::LeftEye:
case MirrorPlan::RightEye:
draw_mirror_eye(pass, plan == MirrorPlan::LeftEye ? 0u : 1u,
{.left = 0.f, .top = 0.f, .width = imageWidth, .height = imageHeight, .znear = 0.f, .zfar = 1.f});
break;
case MirrorPlan::BothEyes: {
// Side by side in the window's two halves, in the order the compositor
// receives them, so the pair reads the way the headset is wearing it.
const float halfWidth = imageWidth * 0.5f;
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
draw_mirror_eye(pass, eye,
{.left = static_cast<float>(eye) * halfWidth,
.top = 0.f,
.width = halfWidth,
.height = imageHeight,
.znear = 0.f,
.zfar = 1.f});
}
break;
}
case MirrorPlan::Black:
break;
}
pass.End();
}
if (includeImGui) {
const std::array attachments{
wgpu::RenderPassColorAttachment{
.view = image.texture.view,
.loadOp = wgpu::LoadOp::Load,
.storeOp = wgpu::StoreOp::Store,
},
};
const wgpu::RenderPassDescriptor renderPassDescriptor{
.label = "Snapshot ImGui pass",
.colorAttachmentCount = attachments.size(),
.colorAttachments = attachments.data(),
.timestampWrites = gfx::gpu_timing_pass(gfx::GpuTimingCategory::Snapshot),
};
const auto pass = encoder.BeginRenderPass(&renderPassDescriptor);
pass.SetViewport(0.f, 0.f, static_cast<float>(image.texture.size.width),
static_cast<float>(image.texture.size.height), 0.f, 1.f);
if (hostImGuiData != nullptr) {
imgui::render(pass, hostImGuiData);
} else {
imgui::render(pass);
}
pass.End();
}
}
#endif
void shutdown() noexcept {
stop_frame_worker();
#ifdef AURORA_ENABLE_GX
stop_presenter();
for (auto& target : g_stereoEyeTargets) {
release_eye_density_map(target);
}
g_stereoEyeTargets = {};
g_stereoMirrorState.Reset();
g_retainedMenuImage.reset();
g_retainedMenuGuide = {};
g_presentationImagePools = {};
stereo_overlay::shutdown();
{
std::lock_guard lock(vrui::mutex);
vrui::pipeline = nullptr;
vrui::panoramaBinding = nullptr;
vrui::panoramaView = nullptr;
vrui::panorama = nullptr;
vrui::controllerTextures.clear();
vrui::activeQuality = -1;
}
imgui::shutdown();
gfx::shutdown();
webgpu::shutdown();
#endif
{
std::lock_guard lock(g_stereoRegistrationMutex);
g_stereoProvider = {};
g_stereoProviderActive.store(false, std::memory_order_release);
#ifdef AURORA_ENABLE_GX
g_stereoSink = {};
#endif
}
input::shutdown();
window::shutdown();
}
const AuroraEvent* update() noexcept {
ZoneScoped;
if (g_initialFrame) {
g_initialFrame = false;
input::initialize();
}
return window::poll_events();
}
bool begin_frame_impl(bool pumpEvents, ImGuiFramePolicy imguiPolicy, bool* imguiNewFrameOwed) noexcept {
ZoneScoped;
#ifdef AURORA_ENABLE_GX
webgpu::fail_if_device_lost();
if (pumpEvents && !g_hostEventPump.load(std::memory_order_acquire)) {
window::pump_events();
}
const bool surfaceReconfigurePending = g_surfaceReconfigurePending.load(std::memory_order_acquire);
const bool surfaceMutationRequired =
surfaceReconfigurePending || !window::is_presentable() || !g_surface || window::native_resize_pending() ||
!window::native_window_size_matches(webgpu::g_graphicsConfig.surfaceConfiguration.width,
webgpu::g_graphicsConfig.surfaceConfiguration.height);
if (surfaceMutationRequired) {
wait_for_presenter_idle();
window::SurfaceLock surfaceLock;
// Reconfiguring invalidates any image the presenter holds, so take the surface exclusively; a
// drained queue does not stop a later job. Order is surface before renderer everywhere.
std::lock_guard surfaceOwnership(g_surfaceMutex);
std::lock_guard gpuLock(g_rendererGpuMutex);
// Surface configuration belongs to the render thread; the SDL thread must not reconfigure Dawn
// while this worker is acquiring or presenting.
if (!window::is_presentable()) {
webgpu::release_surface();
return false;
}
if (window::is_paused()) {
return false;
}
const bool consumeSurfaceReconfigure = g_surfaceReconfigurePending.exchange(false, std::memory_order_acq_rel);
const bool consumeSurfaceRecreate = g_surfaceRecreatePending.exchange(false, std::memory_order_acq_rel);
if (!g_surface || consumeSurfaceReconfigure) {
// Reconfigure in place unless the surface was actually lost. See
// g_surfaceRecreatePending for why destroying a live surface here is fatal
// while a capture overlay is attached.
webgpu::refresh_surface(consumeSurfaceRecreate);
if (!g_surface) {
return false;
}
}
if (window::native_resize_pending() ||
!window::native_window_size_matches(webgpu::g_graphicsConfig.surfaceConfiguration.width,
webgpu::g_graphicsConfig.surfaceConfiguration.height)) {
window::sync_frame_buffer_size();
}
} else if (window::is_paused()) {
return false;
}
return begin_frame_render_state_impl(imguiPolicy, imguiNewFrameOwed);
#else
(void)imguiPolicy;
(void)imguiNewFrameOwed;
return true;
#endif
}
bool begin_frame_render_state_impl(ImGuiFramePolicy imguiPolicy, bool* imguiNewFrameOwed) noexcept {
#ifdef AURORA_ENABLE_GX
std::lock_guard gpuLock(g_rendererGpuMutex);
// Note the debt before gfx::begin_frame() can fail: the synchronous path always started the
// ImGui frame here, and the runtime's retry loop depends on that pairing.
if (imgui::host_frames_active()) {
// The host starts its own ImGui frames (imgui::host_frame_begin).
} else if (imguiPolicy == ImGuiFramePolicy::Immediate) {
imgui::new_frame(window::get_window_size());
} else if (imguiNewFrameOwed != nullptr) {
*imguiNewFrameOwed = true;
}
if (!gfx::begin_frame()) {
return false;
}
#else
(void)imguiPolicy;
(void)imguiNewFrameOwed;
#endif
return true;
}
#ifdef AURORA_ENABLE_GX
// Everything the mutex-free encode phase needs, latched while the renderer GPU mutex is held.
// None of it may be re-read from a global later; the producer has already begun the next frame.
struct SealedFrameContext {
AuroraVRUiGuide uiGuide{};
wgpu::CommandEncoder encoder; // slot 0's encoder; already holds the staging copies
webgpu::PresentSource presentSource{};
std::optional<gfx::StereoReplayFrame> stereoReplay;
uint64_t stereoFrameToken = 0;
AuroraStereoFrameMode stereoFrameMode = AURORA_STEREO_FRAME_IMMERSIVE_REPLAY;
bool immersiveStereoPrepared = false;
uint64_t scheduleBaseNanos = 0;
uint64_t scheduleIntervalNanos = 0;
uint32_t interpolatedFrameCount = 0;
uint32_t snapshotWidth = 1;
uint32_t snapshotHeight = 1;
uint32_t logicalFrame = 0;
bool interpolationActive = false;
bool replayInterpolatedFrames = false;
std::optional<AuroraStereoFrame> stereoInput;
bool retainStereo = false;
imgui::StereoOverlay stereoOverlay;
imgui::HostFramePtr imguiFrame;
};
const ImDrawData* host_imgui_data(const SealedFrameContext& ctx) noexcept {
return ctx.imguiFrame ? imgui::host_frame_draw_data(*ctx.imguiFrame) : nullptr;
}
// 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;
uint32_t previousLogicalFrame = 0; // the game frame before logicalFrame (lane L's scene stamp)
} g_retainedStereo;
gfx::StereoReplayFrame interpolated_stereo_frame(const AuroraStereoFrame& input, float& weight) {
const auto& retained = g_retainedStereo;
// KARTFRAME_SCENE_CLOCK=0: the fixed rule (display time against the frame's own interval), which
// gives weight 1 for nearly every set on a headset (see stereo::SceneClock).
static const bool sceneClock = [] {
const char* env = std::getenv("KARTFRAME_SCENE_CLOCK");
return env == nullptr || env[0] != '0';
}();
static stereo::SceneClock clock;
weight = !retained.continuous ? 1.0f
: sceneClock ? clock.weight(input.displayTimeNanos, retained.boundary, retained.interval)
: stereo::interpolation_weight(input.displayTimeNanos, retained.boundary, retained.interval);
auto replay = make_stereo_replay_frame(input, retained.anchor);
if (retained.continuous && retained.anchor.active && retained.previousAnchor.active) {
replay.interpolateInAnchor = true;
std::memcpy(&replay.previousAnchor, retained.previousAnchor.anchorFromScene.data(), sizeof(replay.previousAnchor));
std::memcpy(&replay.currentAnchor, retained.anchor.anchorFromScene.data(), sizeof(replay.currentAnchor));
}
return replay;
}
// The game frames of the transform pair the last fill used (lane L's scene stamp).
void note_scene_frames() noexcept {
auto& scene = gfx::eye_scene_record();
scene.continuous = g_retainedStereo.continuous;
scene.currentFrame = g_retainedStereo.logicalFrame;
scene.previousFrame = g_retainedStereo.continuous ? g_retainedStereo.previousLogicalFrame : g_retainedStereo.logicalFrame;
}
void run_retained_stereo_frame(gfx::SealedFrame& sealedFrame) noexcept {
std::lock_guard gpuLock(g_rendererGpuMutex);
if (!gx::stereo_frame_interpolation_active() ||
(!g_retainedMenuImage && !gfx::has_late_stereo_replay(sealedFrame))) return;
const auto input = request_stereo_frame(g_retainedStereo.logicalFrame, g_retainedStereo.contentTag);
if (!input) return;
if (input->mode == AURORA_STEREO_FRAME_VIRTUAL_SCREEN && g_retainedMenuImage) {
gfx::gpu_timing_begin_frame(1);
auto encoder=g_device.CreateCommandEncoder();
const auto& image=*g_retainedMenuImage;
const webgpu::PresentSource mono{.bindGroup=image.bindGroup,.texture=image.texture.texture,
.size=image.texture.size,.format=image.texture.format};
for(uint32_t eye=0;eye<AURORA_STEREO_EYE_COUNT;++eye) {
ensure_stereo_eye_target(eye,input->eyes[eye].width,input->eyes[eye].height);
if(menu_environment_requested(*input)) {
if(eye==0) {
encode_virtual_screen_eye(encoder,mono,0);
const auto& output=g_stereoEyeTargets[0].output();
stereo_overlay::composite_flat(encoder,output.view,output.size,0);
} else {
encode_menu_environment(encoder,mono,*input);
vrui::screen_marks(encoder,mono,*input);
}
} else if(input->ui.anchored) {
auto frame=*input;webgpu::PresentSource panel{};
const bool guide=g_retainedMenuGuide.active && stereo_overlay::prepared_source(panel);
if(guide) frame.ui.width*=.75f;
vrui::render(encoder,guide?panel:mono,frame,guide?g_retainedMenuGuide:AuroraVRUiGuide{},eye);
if(!guide) {
Mat4x4<float> projection;Mat3x4<float> view;
std::memcpy(&projection,frame.eyes[eye].projection,sizeof(projection));
std::memcpy(&view,frame.ui.eyeFromPanel[eye],sizeof(view));
stereo_overlay::composite_immersive(encoder,g_stereoEyeTargets[eye].output().view,projection,view,eye);
}
} else {
encode_virtual_screen_eye(encoder,mono,eye);
const auto& output=g_stereoEyeTargets[eye].output();
stereo_overlay::composite_flat(encoder,output.view,output.size,eye);
}
}
gfx::gpu_timing_mark(encoder,gfx::GpuTimingCategory::Sink);
const auto sink=run_stereo_sink(encoder,input->frameToken,g_retainedStereo.logicalFrame,input->mode,true,input->displayTimeNanos);
gfx::gpu_timing_mark(encoder,std::nullopt);
gfx::gpu_timing_end_frame(encoder);
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);
gfx::gpu_timing_after_submit();
return;
}
if(input->mode != AURORA_STEREO_FRAME_IMMERSIVE_REPLAY || !gfx::has_late_stereo_replay(sealedFrame)) return;
g_lastImmersiveTag.store(g_retainedStereo.contentTag, std::memory_order_relaxed);
float weight;
auto replay = interpolated_stereo_frame(*input, weight);
{
// How the eye sets sample the game: weight 0 shows the previous game frame, 1 the current one,
// between interpolates. Display times too far ahead of the game's frame boundary give 1 every
// time (each game frame shown twice at 120 Hz).
static std::array<uint32_t, 4> bins{};
static auto binsStart = std::chrono::steady_clock::now();
++bins[!g_retainedStereo.continuous ? 3 : weight <= 0.f ? 0 : weight >= 1.f ? 2 : 1];
if (const auto now = std::chrono::steady_clock::now(); now - binsStart >= std::chrono::seconds(10)) {
Log.info("VR interpolation weights (10 s): 0: {}, between: {}, 1: {}, no previous frame: {}", bins[0], bins[1],
bins[2], bins[3]);
bins = {};
binsStart = now;
}
}
gfx::gpu_timing_begin_frame(1);
auto encoder = g_device.CreateCommandEncoder();
if (!gfx::prepare_late_stereo_replay(sealedFrame, encoder, replay, weight)) {
gfx::gpu_timing_end_frame(encoder); // closes the slot; nothing was recorded on the GPU
return;
}
note_scene_frames();
if (g_directEyesCalibration.choose() == 1 && !use_direct_eye_targets(replay, *input)) {
g_directEyesCalibration.not_applied(); // a trial set that could not go straight in: not a sample
}
g_directEyesCalibration.begin(encoder);
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
gfx::render_stereo_eye(sealedFrame, encoder, replay, eye, false);
// The panel texture from the last sealed frame; its draw data is not ours to touch here.
stereo_overlay::composite_immersive(encoder, eye_output_view(eye), replay.eyes[eye].projection,
replay.eyes[eye].viewFromCenter, eye);
}
gfx::gpu_timing_mark(encoder, gfx::GpuTimingCategory::Sink);
const auto sink = run_stereo_sink(encoder, input->frameToken, g_retainedStereo.logicalFrame, input->mode, true,
input->displayTimeNanos);
gfx::gpu_timing_mark(encoder, std::nullopt);
g_directEyesCalibration.end(encoder);
gfx::gpu_timing_end_frame(encoder);
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);
}
g_directEyesCalibration.after_submit();
gfx::gpu_timing_after_submit();
}
// Phase 1: everything that touches producer-shared renderer state. Needs g_rendererGpuMutex and
// a FIFO already drained into the recorded pass list.
void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, uint64_t contentTag,
const StereoSceneAnchor& sceneAnchor, imgui::HostFramePtr hostImGuiFrame) {
ZoneScopedN("Seal frame");
// Every pass this cycle encodes, from the seal's probe blits to the final eye, is timed under
// one frame; encode_sealed_frame resolves it on its last submission.
gfx::gpu_timing_begin_frame();
const auto encoderDescriptor = wgpu::CommandEncoderDescriptor{
.label = "Redraw encoder",
};
ctx.encoder = g_device.CreateCommandEncoder(&encoderDescriptor);
// Probe-sized CPU-consumed copies read back asynchronously. Their downscale blits push uniforms,
// so prepare them while the producer's staging buffers are still mapped.
gfx::efb_ram::seal_async_downloads();
// current_frame() advances inside gfx::end_frame; unsigned wrap maps the
// pre-first-frame UINT32_MAX value to logical frame zero.
ctx.logicalFrame = gfx::current_frame() + 1;
gfx::set_stereo_local_player_count(sceneAnchor.localPlayerCount);
// UI content advances with the game producer, even when the retained-frame
// presenter already consumed this interval's XR request.
{ std::lock_guard lock(vrui::mutex); ctx.uiGuide=vrui::guide; }
if (const auto stereoInput = request_stereo_frame(ctx.logicalFrame, contentTag)) {
ctx.stereoInput = stereoInput;
ctx.stereoFrameToken = stereoInput->frameToken;
ctx.stereoFrameMode = stereoInput->mode;
ctx.stereoReplay = make_stereo_replay_frame(*stereoInput, sceneAnchor);
}
if (ctx.stereoReplay && ctx.stereoFrameMode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY) {
// Keep the accepted frame alive even if the additional eye-uniform copies
// do not fit. The encode phase will duplicate the completed mono image to
// both eyes, allowing the sink to release/end the already-acquired XR
// frame instead of orphaning it indefinitely.
ctx.immersiveStereoPrepared = gfx::end_frame(ctx.encoder, *ctx.stereoReplay);
static bool immersiveReplaySuccessLogged = false;
static bool immersiveReplayFallbackLogged = false;
if (ctx.immersiveStereoPrepared && !immersiveReplaySuccessLogged) {
immersiveReplaySuccessLogged = true;
Log.info("Immersive stereo GX replay prepared successfully for both eyes");
} else if (!ctx.immersiveStereoPrepared && !immersiveReplayFallbackLogged) {
immersiveReplayFallbackLogged = true;
Log.warn("Immersive stereo GX replay preparation failed; duplicating the mono image into the projection layer");
}
} else {
gfx::end_frame(ctx.encoder);
}
gfx::g_stats.presentedFrameCount = 0;
gfx::g_stats.interpolatedFrameCount = 0;
// Latched before the producer's next gfx::begin_frame() calls
// gx::begin_frame_interpolation(), which resets both of these.
ctx.interpolatedFrameCount = gx::interpolated_frame_count();
ctx.interpolationActive = ctx.interpolatedFrameCount != 0;
ctx.replayInterpolatedFrames = ctx.interpolationActive && gx::frame_interpolation_replay_safe() &&
(!stereo_frame_provider_active() || sceneAnchor.active);
ctx.scheduleBaseNanos = g_presentScheduleBaseNanos.load(std::memory_order_acquire);
ctx.scheduleIntervalNanos = g_presentScheduleIntervalNanos.load(std::memory_order_acquire);
const auto windowSize = window::get_window_size();
ctx.snapshotWidth = (std::max)(windowSize.native_fb_width, 1u);
ctx.snapshotHeight = (std::max)(windowSize.native_fb_height, 1u);
ctx.logicalFrame = gfx::current_frame();
// Latched before webgpu::clear_present_source_override() in the producer's
// next gfx::begin_frame().
ctx.presentSource = webgpu::current_present_source();
// ImGui draw lists are built once per frame and replayed by each slot's ImGui pass, which is why
// the next ImGui frame cannot start until the encode phase is done.
if (hostImGuiFrame) {
// The host closed its own ImGui frame and handed over a copy of the draw data.
ctx.imguiFrame = std::move(hostImGuiFrame);
} else {
imgui::render_frame_data();
}
// The headset panel's draw data follows the same rule on the host's side.
ctx.stereoOverlay = imgui::latch_stereo_overlay();
// Drop the sealed frame's lazy RAM-readback requests while the producer is still excluded; it
// starts registering the next frame's as soon as SEALED is published.
gfx::efb_ram::cancel();
// Detach the recorded passes. From here the producer's list is empty and the
// encode phase reads only worker-private state.
gfx::seal_frame(sealedFrame);
ctx.retainStereo = gx::stereo_frame_interpolation_active() && gfx::has_late_stereo_replay(sealedFrame);
const bool continuous = ctx.retainStereo && g_retainedStereo.contentTag == contentTag &&
g_retainedStereo.interval != 0 && ctx.scheduleIntervalNanos != 0 &&
ctx.scheduleBaseNanos > g_retainedStereo.boundary &&
ctx.scheduleBaseNanos - g_retainedStereo.boundary <= ctx.scheduleIntervalNanos * 3 / 2 &&
g_retainedStereo.anchor.active == sceneAnchor.active;
const auto previousAnchor = g_retainedStereo.anchor;
const auto previousLogicalFrame = g_retainedStereo.logicalFrame;
g_retainedStereo = {contentTag, ctx.scheduleBaseNanos, ctx.scheduleIntervalNanos,
ctx.logicalFrame, sceneAnchor, previousAnchor,
continuous, previousLogicalFrame};
gfx::expire_bind_group_cache();
}
// Phase 2: encode every presentation slot. Reads only `ctx` and the sealed passes, so it runs
// without the renderer GPU mutex while the producer records the next frame.
std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx) {
ZoneScopedN("Encode sealed frame");
auto encoder = std::move(ctx.encoder);
const auto encoderDescriptor = wgpu::CommandEncoderDescriptor{
.label = "Redraw encoder",
};
// Absolute slot deadlines: slot k of jobCount presents at base + k * interval / jobCount. With
// interpolation off, pace to the boundary that just passed plus 6.5 ms; late frames free-run.
constexpr uint64_t kNativePresentOffsetNanos = 6'500'000;
const uint32_t presentationJobCount = ctx.interpolatedFrameCount + 1;
const auto slotPresentDeadline = [&](uint32_t slot) -> PresentClock::time_point {
if (ctx.scheduleBaseNanos == 0 || ctx.scheduleIntervalNanos == 0) {
return {};
}
if (!ctx.interpolationActive) {
return PresentClock::time_point{
std::chrono::nanoseconds{ctx.scheduleBaseNanos - ctx.scheduleIntervalNanos + kNativePresentOffsetNanos}};
}
const uint64_t offsetNanos = (ctx.scheduleIntervalNanos * static_cast<uint64_t>(slot)) / presentationJobCount;
return PresentClock::time_point{std::chrono::nanoseconds{ctx.scheduleBaseNanos + offsetNanos}};
};
std::vector<PresentationJob> presentationJobs;
presentationJobs.reserve(presentationJobCount);
std::optional<PendingStereoSink> pendingStereoSink;
const bool stereoOutput = ctx.stereoReplay.has_value();
const bool immersiveReplay = stereoOutput && ctx.immersiveStereoPrepared;
// One choice for the whole group: a slot showing the mono view next to slots
// mirroring an eye would strobe between two different images.
// Nothing presents the snapshot on a headset, so it only needs the clear (see headset_owns_display).
const bool headsetOnly = headset_owns_display();
// An immersive race frame: its eyes are encoded here, or (most frames at 120 Hz) by the refresh sets
// that replay it later, recognised by its content tag, which a refresh set replayed immersive last.
const bool immersiveFrame =
immersiveReplay || (ctx.retainStereo && !ctx.stereoInput &&
g_retainedStereo.contentTag != AURORA_STEREO_CONTENT_TAG_UNKNOWN &&
g_retainedStereo.contentTag == g_lastImmersiveTag.load(std::memory_order_relaxed));
// The native render of such a frame stops after its last copy the eyes sample (below), as on a
// standalone headset. On Linux the desktop window then shows black during races (nobody sees it
// while the headset is on); KARTFRAME_MONO_FULL=1 keeps the whole native render and the window's
// mirror of it. Lane I: ~1 ms of GPU a game frame in the race.
static const bool monoFull = [] {
const char* env = std::getenv("KARTFRAME_MONO_FULL");
return env != nullptr && env[0] == '1';
}();
const bool cutNative = immersiveFrame && (headsetOnly || !monoFull) && stereo_frame_provider_active() &&
!gfx::efb_ram::has_pending() &&
g_captureFrame.load(std::memory_order_acquire) == UINT32_MAX;
const MirrorPlan mirrorPlan =
headsetOnly || cutNative ? MirrorPlan::Black
: g_stereoMirrorState.Resolve(gfx::get_stereo_mirror_view(),
stereo_frame_provider_active(), stereoOutput,
immersiveReplay);
// Each slot is submitted as soon as it is encoded, so the GPU starts slot 0 while slot 1 is still
// recording. Queue order preserves the ordering the single batched buffer gave.
const wgpu::CommandBufferDescriptor cmdBufDescriptor{
.label = "Presentation slot command buffer",
};
const auto submitEncodedSlot = [&](wgpu::CommandEncoder& target,
const PendingStereoSink* stereoSubmission = nullptr) {
const auto buffer = target.Finish(&cmdBufDescriptor);
{
std::lock_guard submitLock(g_queueSubmitMutex);
g_queue.Submit(1, &buffer);
// The native backend may enqueue follow-up work on Dawn's underlying
// graphics queue. Keep it adjacent to this submission so presenter or
// EFB work cannot interleave between the WebGPU copy and that handoff.
if (stereoSubmission != nullptr && stereoSubmission->submitted != nullptr) {
stereoSubmission->submitted(stereoSubmission->frame, stereoSubmission->userdata);
}
}
};
// Ahead of every other ImGui pass of this frame: the ImGui backend keeps one projection uniform,
// and the headset panel's canvas is not the desktop's size, so its pass has to reach the queue
// before a desktop pass rewrites that uniform. The eyes below sample the texture it fills.
if (const auto panel = stereo_overlay::prepare(stereo_frame_provider_active() ? ctx.stereoOverlay.drawData : nullptr,
ctx.stereoOverlay.widthFraction)) {
std::lock_guard submitLock(g_queueSubmitMutex);
g_queue.Submit(1, &panel);
}
if (ctx.replayInterpolatedFrames) {
for (uint32_t interpolatedFrame = 0; interpolatedFrame < ctx.interpolatedFrameCount; ++interpolatedFrame) {
gfx::render(sealedFrame, encoder, static_cast<int32_t>(interpolatedFrame), false);
auto image = acquire_presentation_image(interpolatedFrame, ctx.snapshotWidth, ctx.snapshotHeight);
encode_presentation_snapshot(encoder, ctx.presentSource, *image, true, mirrorPlan, host_imgui_data(ctx));
presentationJobs.push_back({
.image = std::move(image),
.logicalFrame = ctx.logicalFrame,
.presentAt = slotPresentDeadline(interpolatedFrame),
.interpolated = true,
});
submitEncodedSlot(encoder);
encoder = g_device.CreateCommandEncoder(&encoderDescriptor);
}
}
// 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.
//
// On a headset an immersive frame's native render is never presented: the eyes replay the draws
// themselves and only sample the EFB copies it resolves. So it stops after the last pass that
// produces one of those copies (never the display copy), which on a Quest 3 was 4 to 6 ms of a
// 12 ms GPU frame spent on a 1280x720 image nobody saw. A pending CPU readback or a frame
// capture still gets the whole image.
int32_t nativeRenderLastPass = INT32_MAX;
if (cutNative) {
nativeRenderLastPass = gfx::last_pass_feeding_replay(sealedFrame);
}
gfx::render(sealedFrame, encoder, -1, !immersiveReplay && !ctx.retainStereo, nativeRenderLastPass);
// 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.
gfx::efb_ram::encode_async_downloads(encoder);
if (!ctx.replayInterpolatedFrames) {
for (uint32_t interpolatedFrame = 0; interpolatedFrame < ctx.interpolatedFrameCount; ++interpolatedFrame) {
auto image = acquire_presentation_image(interpolatedFrame, ctx.snapshotWidth, ctx.snapshotHeight);
encode_presentation_snapshot(encoder, ctx.presentSource, *image, true, mirrorPlan, host_imgui_data(ctx));
presentationJobs.push_back({
.image = std::move(image),
.logicalFrame = ctx.logicalFrame,
.presentAt = slotPresentDeadline(interpolatedFrame),
.interpolated = true,
.duplicated = true,
});
submitEncodedSlot(encoder);
encoder = g_device.CreateCommandEncoder(&encoderDescriptor);
}
}
// Keep both eye replays and the sink copy in the final submission. The
// duplicate-slot path above may submit and rotate the encoder several
// times, so encoding stereo before it would pair the post-submit callback
// with the wrong command buffer. Within this last encoder the eyes come
// first, so a mirroring final slot samples this frame's eyes rather than the
// previous frame's; the interpolated slots above necessarily mirror the
// previous frame, having been encoded before this replay.
if (immersiveReplay) {
g_lastImmersiveTag.store(g_retainedStereo.contentTag, std::memory_order_relaxed);
if (ctx.retainStereo && ctx.stereoInput) {
float weight;
ctx.stereoReplay = interpolated_stereo_frame(*ctx.stereoInput, weight);
if (gfx::prepare_late_stereo_replay(sealedFrame, encoder, *ctx.stereoReplay, weight)) note_scene_frames();
} else if (auto& scene = gfx::eye_scene_record(); scene.valid && !scene.late) {
scene.currentFrame = scene.previousFrame = ctx.logicalFrame; // filled at the seal: current transforms
}
if (ctx.stereoInput && g_directEyesCalibration.decided() == 1) {
use_direct_eye_targets(*ctx.stereoReplay, *ctx.stereoInput);
}
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
gfx::render_stereo_eye(sealedFrame, encoder, *ctx.stereoReplay, eye,
!ctx.retainStereo && eye + 1 == AURORA_STEREO_EYE_COUNT);
stereo_overlay::composite_immersive(encoder, eye_output_view(eye),
ctx.stereoReplay->eyes[eye].projection,
ctx.stereoReplay->eyes[eye].viewFromCenter, eye);
}
}
auto finalImage = acquire_presentation_image(ctx.interpolatedFrameCount, ctx.snapshotWidth, ctx.snapshotHeight);
// A virtual-screen frame builds its eyes out of the completed mono snapshot,
// so that snapshot must hold the mono image whatever the desktop ends up
// showing. Black re-clears it below, once the eyes have taken their copy.
const bool virtualScreenNeedsMono = stereoOutput && !immersiveReplay;
encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true,
virtualScreenNeedsMono ? MirrorPlan::Mono : mirrorPlan, host_imgui_data(ctx));
if (stereoOutput) {
publish_stereo_screen_aspects(ctx.presentSource, finalImage->texture.size, immersiveReplay);
}
// Keep a completed snapshot across race/menu transitions too. Consuming a
// virtual-screen packet while the previous frame was immersive must still
// submit it; dropping it strands the acquired headset image.
// The headset background must not alias the desktop presentation image:
// mirror/black output can overwrite that image after the eyes sampled it.
// Refresh on EVERY game frame while XR is active, including frames without
// an XR request: run_retained_stereo_frame can consume that request first.
// Gating this on stereoOutput leaves the presenter replaying a stale (often
// startup-black) image indefinitely, until recentering changes the timing.
if (stereo_frame_provider_active() && cutNative) {
// The game image is incomplete: a virtual-screen set before the next full frame shows nothing
// new (the retained layer repeats) rather than a half-drawn picture.
g_retainedMenuImage.reset();
} else if (stereo_frame_provider_active()) {
auto background=acquire_presentation_image(0,ctx.snapshotWidth,ctx.snapshotHeight);
// Always sample the mono game source. Desktop ImGui is drawn separately
// into the VR panel; including it only on frames with an XR request would
// make the background alternate between game and desktop settings.
encode_presentation_snapshot(encoder,ctx.presentSource,*background,false,MirrorPlan::Mono);
g_retainedMenuImage=std::move(background);
static bool independentMenuRefreshLogged = false;
if (!stereoOutput && !independentMenuRefreshLogged) {
Log.info("VR menu source refreshed without an XR request; retained presentation follows live game frames");
independentMenuRefreshLogged = true;
}
}
g_retainedMenuGuide=ctx.uiGuide;
if (virtualScreenNeedsMono) {
// The completed game image is the background. VR settings and tutorials
// have their own panel texture and are composited below.
const auto& background=g_retainedMenuImage ? *g_retainedMenuImage : *finalImage;
const webgpu::PresentSource completedMono{
.bindGroup = background.bindGroup,
.texture = background.texture.texture,
.size = background.texture.size,
.format = background.texture.format,
};
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
const auto& output = g_stereoEyeTargets[eye].output();
if (ctx.stereoInput && menu_environment_requested(*ctx.stereoInput)) {
if (eye == 0) {
encode_virtual_screen_eye(encoder, completedMono, 0);
stereo_overlay::composite_flat(encoder, output.view, output.size, 0);
} else {
encode_menu_environment(encoder, completedMono, *ctx.stereoInput);
vrui::screen_marks(encoder, completedMono, *ctx.stereoInput);
}
} else if (ctx.stereoInput && ctx.stereoInput->ui.anchored) {
auto frame=*ctx.stereoInput;
webgpu::PresentSource panelSource{};
const bool guide=ctx.uiGuide.active && stereo_overlay::prepared_source(panelSource);
if (guide) frame.ui.width *= .75f;
vrui::render(encoder,guide ? panelSource : completedMono,frame,guide ? ctx.uiGuide : AuroraVRUiGuide{},eye);
if (!guide) {
Mat4x4<float> projection; Mat3x4<float> view;
std::memcpy(&projection,frame.eyes[eye].projection,sizeof(projection));
std::memcpy(&view,frame.ui.eyeFromPanel[eye],sizeof(view));
stereo_overlay::composite_immersive(encoder,output.view,projection,view,eye);
}
} else {
encode_virtual_screen_eye(encoder, completedMono, eye);
stereo_overlay::composite_flat(encoder, output.view, output.size, eye);
}
// An immersive packet that could not be replayed still goes out as a windowed layer.
if (ctx.stereoReplay->window) {
gfx::mask_stereo_eye_output(sealedFrame, encoder, *ctx.stereoReplay, eye, output.view, output.size);
}
}
if (mirrorPlan == MirrorPlan::Black && !headsetOnly) {
encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true, MirrorPlan::Black,
host_imgui_data(ctx));
}
}
if (stereoOutput) {
gfx::gpu_timing_mark(encoder, gfx::GpuTimingCategory::Sink);
pendingStereoSink = run_stereo_sink(encoder, ctx.stereoFrameToken, ctx.logicalFrame, ctx.stereoFrameMode, false,
ctx.stereoInput ? ctx.stereoInput->displayTimeNanos : 0);
gfx::gpu_timing_mark(encoder, std::nullopt);
}
auto pendingFrameCapture = encode_frame_capture(encoder, ctx.presentSource);
presentationJobs.push_back({
.image = std::move(finalImage),
.logicalFrame = ctx.logicalFrame,
.presentAt = slotPresentDeadline(ctx.interpolatedFrameCount),
.interpolated = false,
});
gfx::gpu_timing_end_frame(encoder);
submitEncodedSlot(encoder, pendingStereoSink ? &*pendingStereoSink : nullptr);
gfx::gpu_timing_after_submit();
// A group that finished encoding past its anchor slides forward by whole display periods, never
// per slot. The cursor keeps two groups off one anchor, which bursts then holds for a period.
static PresentClock::time_point s_lastGroupAnchor{};
if (ctx.interpolationActive && ctx.scheduleIntervalNanos != 0 && !presentationJobs.empty() &&
presentationJobs.front().presentAt != PresentClock::time_point{}) {
const std::chrono::nanoseconds interval{static_cast<int64_t>(ctx.scheduleIntervalNanos)};
auto anchor = presentationJobs.front().presentAt;
const auto now = PresentClock::now();
if (now > anchor) {
const auto behind = std::chrono::duration_cast<std::chrono::nanoseconds>(now - anchor);
const uint64_t periods = static_cast<uint64_t>(behind.count()) / ctx.scheduleIntervalNanos + 1u;
anchor += std::chrono::nanoseconds{static_cast<int64_t>(periods * ctx.scheduleIntervalNanos)};
}
if (s_lastGroupAnchor != PresentClock::time_point{} && anchor <= s_lastGroupAnchor) {
anchor = s_lastGroupAnchor + interval;
}
const auto shift =
std::chrono::duration_cast<std::chrono::nanoseconds>(anchor - presentationJobs.front().presentAt);
if (shift.count() > 0) {
const uint32_t slidPeriods = static_cast<uint32_t>(
(static_cast<uint64_t>(shift.count()) + ctx.scheduleIntervalNanos - 1u) / ctx.scheduleIntervalNanos);
for (auto& job : presentationJobs) {
job.presentAt += shift;
job.slidPeriods = slidPeriods;
}
}
s_lastGroupAnchor = anchor;
} else {
// No schedule (interpolation off, boot/black presents): the grid is gone,
// so the cursor must not constrain the next scheduled group.
s_lastGroupAnchor = {};
}
if (pendingFrameCapture.has_value()) {
complete_frame_capture(*pendingFrameCapture);
}
gfx::after_submit();
gfx::g_stats.presentedFrameCount = static_cast<uint32_t>(presentationJobs.size());
gfx::g_stats.interpolatedFrameCount = ctx.interpolatedFrameCount;
return presentationJobs;
}
// Phase 3: hand the encoded group to whoever owns presentation.
void publish_presentations(std::vector<PresentationJob>&& presentationJobs, bool interpolationActive) {
#if defined(__APPLE__)
(void)interpolationActive;
// Presenting reaches SDL/AppKit, whose window operations must stay on the
// main thread. Interpolation normally starts the presenter worker, so keep
// its jobs synchronous on Apple platforms.
for (const auto& job : presentationJobs) {
present_presentation_job(job);
}
#else
// Keep presentation on the presenter whenever the async frame worker runs, even with
// interpolation off, so every mode shares one surface/resize path. RenderDoc keeps the sync path.
if (frame_worker_requested() || interpolationActive || g_presenterStarted.load(std::memory_order_acquire)) {
enqueue_presentations(std::move(presentationJobs));
} else {
for (const auto& job : presentationJobs) {
present_presentation_job(job);
}
}
#endif
}
void record_frame_telemetry() {
TracyPlotConfig("aurora: lastVertSize", tracy::PlotFormatType::Memory, false, true, 0);
TracyPlotConfig("aurora: lastUniformSize", tracy::PlotFormatType::Memory, false, true, 0);
TracyPlotConfig("aurora: lastIndexSize", tracy::PlotFormatType::Memory, false, true, 0);
TracyPlotConfig("aurora: lastStorageSize", tracy::PlotFormatType::Memory, false, true, 0);
TracyPlotConfig("aurora: lastTextureUploadSize", tracy::PlotFormatType::Memory, false, true, 0);
TracyPlot("aurora: queuedPipelines", static_cast<int64_t>(gfx::g_stats.queuedPipelines));
TracyPlot("aurora: createdPipelines", static_cast<int64_t>(gfx::g_stats.createdPipelines));
TracyPlot("aurora: drawCallCount", static_cast<int64_t>(gfx::g_stats.drawCallCount));
TracyPlot("aurora: mergedDrawCallCount", static_cast<int64_t>(gfx::g_stats.mergedDrawCallCount));
TracyPlot("aurora: lastVertSize", static_cast<int64_t>(gfx::g_stats.lastVertSize));
TracyPlot("aurora: lastUniformSize", static_cast<int64_t>(gfx::g_stats.lastUniformSize));
TracyPlot("aurora: lastIndexSize", static_cast<int64_t>(gfx::g_stats.lastIndexSize));
TracyPlot("aurora: lastStorageSize", static_cast<int64_t>(gfx::g_stats.lastStorageSize));
TracyPlot("aurora: lastTextureUploadSize", static_cast<int64_t>(gfx::g_stats.lastTextureUploadSize));
TracyPlot("aurora: frameIndex", static_cast<int64_t>(gfx::current_frame()));
#if defined(TRACY_ENABLE) && defined(_WIN32)
const auto fileTimeValue = [](const FILETIME& value) noexcept {
ULARGE_INTEGER result{};
result.LowPart = value.dwLowDateTime;
result.HighPart = value.dwHighDateTime;
return result.QuadPart;
};
FILETIME creation{}, exit{}, processKernel{}, processUser{}, threadKernel{}, threadUser{};
const bool processTimesAvailable =
GetProcessTimes(GetCurrentProcess(), &creation, &exit, &processKernel, &processUser) != FALSE;
const bool threadTimesAvailable =
GetThreadTimes(GetCurrentThread(), &creation, &exit, &threadKernel, &threadUser) != FALSE;
const uint64_t processCpu100ns =
processTimesAvailable ? fileTimeValue(processKernel) + fileTimeValue(processUser) : 0;
const uint64_t threadCpu100ns = threadTimesAvailable ? fileTimeValue(threadKernel) + fileTimeValue(threadUser) : 0;
static uint64_t previousProcessCpu100ns = processCpu100ns;
static uint64_t previousThreadCpu100ns = threadCpu100ns;
TracyPlot("aurora: processCpuUsPerFrame", static_cast<int64_t>((processCpu100ns - previousProcessCpu100ns) / 10));
TracyPlot("aurora: mainThreadCpuUsPerFrame", static_cast<int64_t>((threadCpu100ns - previousThreadCpu100ns) / 10));
previousProcessCpu100ns = processCpu100ns;
previousThreadCpu100ns = threadCpu100ns;
#endif
#if defined(__ANDROID__) || defined(__linux__)
{
// `adb shell setprop debug.wiicompiled.fpslog 1` before launch logs the game's rendered frame rate every five
// seconds. The headset compositor's own log (logcat tag VrApi) repeats frames, so it cannot show this.
// Linux: KARTFRAME_FPSLOG=1 (frame rate and the producer/worker split), 2 (also per-pass GPU timestamps).
static const bool fpsLog = [] {
#if defined(__ANDROID__)
const bool on = android_debug::property_int("debug.wiicompiled.fpslog", 0) == 1;
const bool gpuTiming = on;
#else
const char* env = std::getenv("KARTFRAME_FPSLOG");
const bool on = env != nullptr && (env[0] == '1' || env[0] == '2');
const bool gpuTiming = env != nullptr && env[0] == '2';
#endif
// The same switch turns on the per-pass GPU timestamps reported below the frame-rate line.
gfx::gpu_timing_set_enabled(gpuTiming);
return on;
}();
if (fpsLog) {
static auto windowStart = std::chrono::steady_clock::now();
static uint32_t windowFrames = 0;
// Draw calls are what a recorded frame costs three times over (mono and both eyes), so they belong beside the
// GPU timings: an overlay that stops draws merging shows up here long before it shows up as a frame rate.
static uint64_t windowDraws = 0;
static uint64_t windowMerged = 0;
++windowFrames;
windowDraws += gfx::g_stats.drawCallCount;
windowMerged += gfx::g_stats.mergedDrawCallCount;
const auto now = std::chrono::steady_clock::now();
const std::chrono::duration<double> elapsed = now - windowStart;
if (elapsed.count() >= 5.0) {
// Per-frame averages of where the wall-clock time went (see the counters' definition).
const auto msPerFrame = [&](std::atomic<uint64_t>& counter) {
return static_cast<double>(counter.exchange(0, std::memory_order_relaxed)) / 1e6 / std::max(windowFrames, 1u);
};
const double waitDone = msPerFrame(g_producerWaitDoneNs);
const double waitSealed = msPerFrame(g_producerWaitSealedNs);
const double seal = msPerFrame(g_workerSealNs);
const double permitWait = msPerFrame(g_workerPermitWaitNs);
const double prepare = msPerFrame(g_workerPrepareNs);
const double encode = msPerFrame(g_workerEncodeNs);
Log.info("Game frame rate {:.1f} FPS ({} frames in {:.2f} s); per frame the producer waited {:.2f} ms for "
"DONE and {:.2f} ms for SEALED; the worker spent {:.2f} ms sealing, {:.2f} ms waiting for the "
"prepare permit, {:.2f} ms preparing the next frame and {:.2f} ms encoding; {:.0f} draw calls a "
"frame ({:.0f} primitives merged away)",
windowFrames / elapsed.count(), windowFrames, elapsed.count(), waitDone, waitSealed, seal,
permitWait, prepare, encode,
static_cast<double>(windowDraws) / std::max(windowFrames, 1u),
static_cast<double>(windowMerged) / std::max(windowFrames, 1u));
windowDraws = 0;
windowMerged = 0;
if (const std::string gpuTiming = gfx::gpu_timing_report(); !gpuTiming.empty()) {
Log.info("{}", gpuTiming);
}
if (const auto frameLog = g_frameLogCallback.load(std::memory_order_acquire)) {
char extra[512];
extra[0] = '\0';
frameLog(extra, sizeof(extra), elapsed.count(), windowFrames);
if (extra[0] != '\0') {
Log.info("{}", extra);
}
}
windowStart = now;
windowFrames = 0;
}
}
}
#endif
FrameMarkNamed("Aurora frame");
}
// One complete frame-worker cycle. Desktop and headset interpolation both
// release the producer after sealing, before encoding their extra scene views.
bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag, imgui::HostFramePtr hostImGuiFrame,
const StereoSceneAnchor& sceneAnchor) noexcept {
ZoneScopedN("Frame worker cycle");
webgpu::fail_if_device_lost();
SealedFrameContext ctx;
const auto elapsedNs = [](std::chrono::steady_clock::time_point since) {
return static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - since).count());
};
auto stretchStarted = std::chrono::steady_clock::now();
{
std::lock_guard gpuLock(g_rendererGpuMutex);
seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor, std::move(hostImGuiFrame));
}
g_workerSealNs.fetch_add(elapsedNs(stretchStarted), std::memory_order_relaxed);
stretchStarted = std::chrono::steady_clock::now();
// VR interpolation: the producer permits the next frame's preparation only when it begins that
// frame, a game frame's logic later (about 10 ms of every 16.7 on the Frame). The worker used to
// sit out that wait, and an eye set asked for meanwhile (every refresh at 120 Hz) waited with it.
// So with the stereo provider and interpolation active the sealed frame is encoded first (its
// eyes, if a packet was taken at the seal, reach the headset earlier too) and the wait serves
// the retained replays, exactly as the idle worker would: the frame is sealed, its uploads are
// submitted, and nothing of the next frame exists yet. The producer's first drain still waits
// for SEALED, which follows the permit as before; only an encode longer than the producer's
// frame logic would delay it.
const bool encodeBeforePermit = stereo_frame_provider_active() && gx::stereo_frame_interpolation_active();
if (encodeBeforePermit) {
std::vector<PresentationJob> presentationJobs = encode_sealed_frame(sealedFrame, ctx);
publish_presentations(std::move(presentationJobs), ctx.interpolationActive);
g_workerEncodeNs.fetch_add(elapsedNs(stretchStarted), std::memory_order_relaxed);
stretchStarted = std::chrono::steady_clock::now();
}
{
std::unique_lock lock(g_frameWorker.mutex);
for (;;) {
g_frameWorker.cv.wait(lock, [&] {
return g_frameWorker.stop || g_frameWorker.prepareAllowed ||
(encodeBeforePermit && g_frameWorker.stereoPending);
});
if (g_frameWorker.stop) {
return false;
}
if (g_frameWorker.prepareAllowed) {
break;
}
g_frameWorker.stereoPending = false;
lock.unlock();
run_retained_stereo_frame(sealedFrame);
lock.lock();
}
g_frameWorker.prepareAllowed = false;
}
g_workerPermitWaitNs.fetch_add(elapsedNs(stretchStarted), std::memory_order_relaxed);
stretchStarted = std::chrono::steady_clock::now();
// Preparing the next frame belongs to the SEALED phase: without a fresh pass 0 and mapped
// staging buffers the producer's drain has nowhere to put its commands.
bool imguiNewFrameOwed = false;
const bool prepared = begin_frame_impl(false, ImGuiFramePolicy::Deferred, &imguiNewFrameOwed);
g_workerPrepareNs.fetch_add(elapsedNs(stretchStarted), std::memory_order_relaxed);
stretchStarted = std::chrono::steady_clock::now();
// SEALED before the encode, always. The encode reads only `ctx` and the sealed passes, so it
// runs while the producer records the next frame, which takes the renderer mutex per drain.
// It used to be published after the encode unless interpolation was on, and the producer's
// first drain of each frame then waited for the whole encode and submit: 3 to 4.5 ms of every
// frame on a Quest 3, the difference between a twelve-kart race start at 50 and at 60 fps.
{
std::lock_guard lock(g_frameWorker.mutex);
g_frameWorker.framePrepared = prepared;
g_frameWorker.sealed.store(true, std::memory_order_release);
}
g_frameWorker.cv.notify_all();
if (!encodeBeforePermit) {
std::vector<PresentationJob> presentationJobs = encode_sealed_frame(sealedFrame, ctx);
publish_presentations(std::move(presentationJobs), ctx.interpolationActive);
}
if (imguiNewFrameOwed) {
// Safe only now: every slot has replayed this frame's ImGui draw lists.
std::lock_guard gpuLock(g_rendererGpuMutex);
imgui::new_frame(window::get_window_size());
}
g_workerEncodeNs.fetch_add(elapsedNs(stretchStarted), std::memory_order_relaxed);
{
std::lock_guard lock(g_frameWorker.mutex);
g_frameWorker.ready.store(true, std::memory_order_release);
}
g_frameWorker.cv.notify_all();
record_frame_telemetry();
return true;
}
#endif
// Synchronous frame submission: seal, encode and present inline on the calling thread. Used when
// the frame worker is disabled (RenderDoc captures) and on the boot path.
void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag,
const StereoSceneAnchor& sceneAnchor, imgui::HostFramePtr hostImGuiFrame) noexcept {
ZoneScoped;
#ifdef AURORA_ENABLE_GX
webgpu::fail_if_device_lost();
if (pumpEvents && !g_hostEventPump.load(std::memory_order_acquire)) {
window::pump_events();
}
gfx::SealedFrame sealedFrame;
SealedFrameContext ctx;
std::vector<PresentationJob> presentationJobs;
{
std::lock_guard gpuLock(g_rendererGpuMutex);
if (drainFifo) {
gx::fifo::drain();
}
seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor, std::move(hostImGuiFrame));
presentationJobs = encode_sealed_frame(sealedFrame, ctx);
}
publish_presentations(std::move(presentationJobs), ctx.interpolationActive);
record_frame_telemetry();
#else
(void)pumpEvents;
(void)drainFifo;
(void)contentTag;
(void)sceneAnchor;
#endif
}
// One "hitch:" line per game frame slower than kHitchFrameNs (end_frame to end_frame), with what the
// process did meanwhile (gfx/hitch_stats.hpp; frame/perf/hitches.py reads it). mono is CLOCK_MONOTONIC
// seconds (steady_clock on Linux), so external samplers can be lined up with it.
struct HitchTracker {
static constexpr uint64_t kHitchFrameNs = 20'000'000;
uint64_t lastEndNs = 0;
uint64_t lastCpuNs = 0;
uint64_t lastRunDelayNs = 0;
uint64_t workerWaitNs = 0;
uint64_t beginNs = 0; // inside aurora_begin_frame (worker preparation, surface)
uint32_t beginJoins = 0; // begin_frame joined the worker's preparation (waitForSurfacePreparation)
uint64_t pipelineWait = 0, pipelineSync = 0, pipelineSyncCount = 0, texConvert = 0, texConvertBytes = 0,
texUploadBytes = 0;
uint32_t createdPipelines = 0;
uint32_t lines = 0;
uint64_t lineWindowStart = 0;
void on_end_frame(uint64_t now) noexcept {
using namespace gfx::hitch;
const uint64_t pw = g_pipelineWaitNs.load(std::memory_order_relaxed);
const uint64_t ps = g_pipelineSyncNs.load(std::memory_order_relaxed);
const uint64_t pc = g_pipelineSyncCount.load(std::memory_order_relaxed);
const uint64_t tc = g_texConvertNs.load(std::memory_order_relaxed);
const uint64_t tb = g_texConvertBytes.load(std::memory_order_relaxed);
const uint64_t tu = g_texUploadBytes.load(std::memory_order_relaxed);
const uint32_t cp = gfx::g_stats.createdPipelines;
// The game thread's own CPU time: frame_ms well above cpu_ms means it was blocked or not scheduled.
uint64_t cpu = 0;
#if defined(__linux__)
if (timespec ts{}; clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts) == 0) {
cpu = static_cast<uint64_t>(ts.tv_sec) * 1'000'000'000ull + static_cast<uint64_t>(ts.tv_nsec);
}
#endif
// Time the game thread was runnable but waiting for a CPU (second field of schedstat).
uint64_t runDelay = 0;
#if defined(__linux__)
if (std::FILE* f = std::fopen("/proc/thread-self/schedstat", "r")) {
unsigned long long onCpu = 0, waited = 0;
if (std::fscanf(f, "%llu %llu", &onCpu, &waited) == 2) {
runDelay = waited;
}
std::fclose(f);
}
#endif
if (lastEndNs != 0 && now - lastEndNs > kHitchFrameNs) {
if (now - lineWindowStart > 1'000'000'000) {
lineWindowStart = now;
lines = 0;
}
if (++lines <= 30) {
Log.info("hitch: mono={:.3f} frame_ms={:.1f} game_cpu_ms={:.1f} runq_ms={:.1f} begin_ms={:.1f} worker_wait_ms={:.1f} "
"pipelines_new={} pipeline_wait_ms={:.1f} pipeline_inline={} pipeline_inline_ms={:.1f} tex_convert_ms={:.1f} "
"tex_convert_kb={} tex_upload_kb={} begin_joins={}",
now / 1e9, (now - lastEndNs) / 1e6, (cpu - lastCpuNs) / 1e6, (runDelay - lastRunDelayNs) / 1e6,
beginNs / 1e6, workerWaitNs / 1e6, cp - createdPipelines, (pw - pipelineWait) / 1e6,
pc - pipelineSyncCount, (ps - pipelineSync) / 1e6, (tc - texConvert) / 1e6, (tb - texConvertBytes) / 1024,
(tu - texUploadBytes) / 1024, beginJoins);
}
}
lastEndNs = now;
lastCpuNs = cpu;
lastRunDelayNs = runDelay;
beginNs = 0;
beginJoins = 0;
workerWaitNs = 0;
pipelineWait = pw;
pipelineSync = ps;
pipelineSyncCount = pc;
texConvert = tc;
texConvertBytes = tb;
texUploadBytes = tu;
createdPipelines = cp;
}
};
HitchTracker g_hitchTracker; // game thread only
bool begin_frame() noexcept {
#ifdef AURORA_ENABLE_GX
// The asynchronous fast path below can return a logically prepared frame
// without entering begin_frame_impl(), so loss must be checked before it.
webgpu::fail_if_device_lost();
#endif
if (!frame_worker_requested()) {
return begin_frame_impl(true);
}
ensure_frame_worker_started();
// SDL needs event pumping on the window-owning producer thread, and the worker passes
// pumpEvents=false, so keep it here even when the fast path returns early.
if (!g_hostEventPump.load(std::memory_order_acquire)) {
window::pump_events();
}
bool waitForSurfacePreparation = false;
#ifdef AURORA_ENABLE_GX
// A surface mutation can legitimately fail preparation, and optimistic success would let GX/ImGui
// record into a frame that was never begun, so join this path and return its real result.
waitForSurfacePreparation = !window::is_presentable() || !g_surface || window::native_resize_pending() ||
window::is_paused() ||
!window::native_window_size_matches(webgpu::g_graphicsConfig.surfaceConfiguration.width,
webgpu::g_graphicsConfig.surfaceConfiguration.height);
#endif
bool workerPreparationPending = false;
{
std::lock_guard lock(g_frameWorker.mutex);
// The runtime begins right after an asynchronous end, so treat the worker's pending begin as an
// active frame unless a resize needs the real preparation result.
if (!g_frameWorker.ready.load(std::memory_order_acquire)) {
g_frameWorker.prepareAllowed = true;
g_frameWorker.cv.notify_one();
if (!waitForSurfacePreparation) {
return true;
}
workerPreparationPending = true;
} else if (g_frameWorker.framePrepared) {
return true;
}
}
if (workerPreparationPending) {
++g_hitchTracker.beginJoins;
static bool logged = false;
if (!logged) {
logged = true;
Log.info("begin_frame joins the frame worker's preparation: presentable {}, surface {}, resize pending {}, "
"paused {}, size matches {}",
window::is_presentable(), static_cast<bool>(g_surface), window::native_resize_pending(), window::is_paused(),
window::native_window_size_matches(webgpu::g_graphicsConfig.surfaceConfiguration.width,
webgpu::g_graphicsConfig.surfaceConfiguration.height));
}
// DONE, not SEALED: this only runs while the window is changing and the caller is about to act on
// the surface, so keep the resize path fully serialized.
wait_for_frame_worker_private(FrameWorkerPhase::Done);
std::lock_guard lock(g_frameWorker.mutex);
return g_frameWorker.framePrepared;
}
{
std::lock_guard lock(g_frameWorker.mutex);
if (g_frameWorker.framePrepared) {
return true;
}
}
const bool prepared = begin_frame_impl(false);
{
std::lock_guard lock(g_frameWorker.mutex);
g_frameWorker.framePrepared = prepared;
}
return prepared;
}
void end_frame(uint64_t contentTag, imgui::HostFramePtr hostImGuiFrame) noexcept {
#ifdef AURORA_ENABLE_GX
webgpu::fail_if_device_lost();
#endif
g_hitchTracker.on_end_frame(gfx::hitch::now_ns());
// Claim the anchor published for this frame. Clearing it here is what makes a
// producer that stops publishing fall back to the recorded camera.
StereoSceneAnchor sceneAnchor = g_pendingSceneAnchor;
sceneAnchor.localPlayerCount = g_pendingStereoLocalPlayerCount;
g_pendingStereoLocalPlayerCount = 1;
g_pendingSceneAnchor = {};
if (!frame_worker_requested()) {
end_frame_impl(true, true, contentTag, sceneAnchor, std::move(hostImGuiFrame));
return;
}
ensure_frame_worker_started();
// DONE: this seals another frame, which means reusing the worker's encoder
// state and its SealedFrame. The previous cycle must be completely finished.
const uint64_t workerWaitStart = gfx::hitch::now_ns();
wait_for_frame_worker_private(FrameWorkerPhase::Done);
g_hitchTracker.workerWaitNs += gfx::hitch::now_ns() - workerWaitStart;
// Seal all current GX work on the CPU while the renderer is known ready.
// Later FIFO writes belong exclusively to the next frame.
{
const uint64_t lockStart = gfx::hitch::now_ns();
std::lock_guard gpuLock(g_rendererGpuMutex);
g_hitchTracker.workerWaitNs += gfx::hitch::now_ns() - lockStart;
gx::fifo::drain();
}
{
std::lock_guard lock(g_frameWorker.mutex);
g_frameWorker.framePrepared = false;
g_frameWorker.sealed.store(false, std::memory_order_release);
g_frameWorker.ready.store(false, std::memory_order_release);
g_frameWorker.contentTag = contentTag;
g_frameWorker.sceneAnchor = sceneAnchor;
g_frameWorker.hostImGuiFrame = std::move(hostImGuiFrame);
g_frameWorker.jobPending = true;
g_frameWorker.prepareAllowed = false;
}
g_frameWorker.cv.notify_one();
}
} // namespace
void wait_for_frame_worker() noexcept { wait_for_frame_worker_private(FrameWorkerPhase::Done); }
std::chrono::nanoseconds wait_for_frame_worker_sealed() noexcept {
if (g_frameWorker.sealed.load(std::memory_order_acquire)) {
return std::chrono::nanoseconds::zero();
}
const auto started = std::chrono::steady_clock::now();
wait_for_frame_worker_private(FrameWorkerPhase::Sealed);
return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - started);
}
bool wait_for_frame_worker_for(std::chrono::microseconds timeout) noexcept {
return wait_for_frame_worker_private_for(FrameWorkerPhase::Done, timeout);
}
void quiesce_frame_worker() noexcept {
{
std::lock_guard lock(g_frameWorker.mutex);
if (!g_frameWorker.started || g_frameWorker.threadId == std::this_thread::get_id() ||
g_frameWorker.ready.load(std::memory_order_acquire)) {
return;
}
// This may race the worker just after it passed the prepare wait. In that
// case the permit is harmless and is cleared after DONE before another job
// can be queued by this producer thread.
g_frameWorker.prepareAllowed = true;
}
g_frameWorker.cv.notify_all();
wait_for_frame_worker_private(FrameWorkerPhase::Done);
{
std::lock_guard lock(g_frameWorker.mutex);
g_frameWorker.prepareAllowed = false;
}
}
std::recursive_mutex& renderer_gpu_mutex() noexcept { return g_rendererGpuMutex; }
bool stereo_frame_provider_active() noexcept { return g_stereoProviderActive.load(std::memory_order_acquire); }
void set_stereo_frame_provider(AuroraStereoFrameProvider provider, void* userdata) noexcept {
std::lock_guard lock(g_stereoRegistrationMutex);
#ifdef AURORA_ENABLE_GX
// Registration changes require an idle frame worker, so its cached mirror
// state can be reset here without racing texture use.
g_stereoMirrorState.Reset();
#endif
g_stereoProvider = {
.callback = provider,
.userdata = userdata,
};
g_stereoProviderActive.store(provider != nullptr, std::memory_order_release);
}
void set_stereo_scene_anchor(const float anchorFromScene[12]) noexcept {
if (anchorFromScene == nullptr) {
g_pendingSceneAnchor = {};
return;
}
// aurora_core is built with -ffast-math, so std::isfinite may be folded to
// true. Inspect the IEEE-754 exponent, matching request_stereo_frame().
for (size_t i = 0; i < 12; ++i) {
uint32_t bits = 0;
std::memcpy(&bits, &anchorFromScene[i], sizeof(bits));
if ((bits & 0x7f800000u) == 0x7f800000u) {
static bool rejectionLogged = false;
if (!rejectionLogged) {
rejectionLogged = true;
Log.warn("Rejected a non-finite stereo scene anchor; keeping the recorded camera");
}
g_pendingSceneAnchor = {};
return;
}
}
StereoSceneAnchor anchor{};
std::memcpy(anchor.anchorFromScene.data(), anchorFromScene, sizeof(anchor.anchorFromScene));
anchor.active = true;
g_pendingSceneAnchor = anchor;
}
#ifdef AURORA_ENABLE_GX
namespace stereo {
void set_sink(SinkCallback callback, SubmitCallback submitted, void* userdata) noexcept {
std::lock_guard lock(g_stereoRegistrationMutex);
g_stereoSink.callback = callback;
g_stereoSink.submitted = submitted;
g_stereoSink.userdata = userdata;
}
void set_direct_targets(DirectTargetsCallback callback, void* userdata) noexcept {
std::lock_guard lock(g_stereoRegistrationMutex);
g_stereoSink.direct = callback;
g_stereoSink.directUserdata = userdata;
}
} // namespace stereo
#endif
} // namespace aurora
// C API bindings
AuroraInfo aurora_initialize(int argc, char* argv[], const AuroraConfig* config) {
return aurora::initialize(argc, argv, *config);
}
void aurora_shutdown() { aurora::shutdown(); }
const AuroraEvent* aurora_update() { return aurora::update(); }
bool aurora_begin_frame() {
const uint64_t start = aurora::gfx::hitch::now_ns();
const bool ok = aurora::begin_frame();
aurora::g_hitchTracker.beginNs += aurora::gfx::hitch::now_ns() - start;
return ok;
}
void aurora_end_frame() { aurora::end_frame(AURORA_STEREO_CONTENT_TAG_UNKNOWN, {}); }
void aurora_end_frame_tagged(uint64_t contentTag) { aurora::end_frame(contentTag, {}); }
void aurora_end_frame_ex(uint64_t contentTag, void* imguiFrame) {
aurora::imgui::HostFramePtr frame;
if (imguiFrame != nullptr) {
auto* holder = static_cast<aurora::imgui::HostFramePtr*>(imguiFrame);
frame = std::move(*holder);
delete holder;
}
aurora::end_frame(contentTag, std::move(frame));
}
void aurora_set_host_event_pump(bool hostPumps) {
aurora::g_hostEventPump.store(hostPumps, std::memory_order_release);
}
void aurora_set_frame_log_callback(AuroraFrameLogCallback callback) {
aurora::g_frameLogCallback.store(callback, std::memory_order_release);
}
extern "C" void aurora_imgui_host_frame_begin(void) {
#ifdef AURORA_ENABLE_GX
// ImGui's WebGPU backend creates its device objects lazily from new_frame.
std::lock_guard gpuLock(aurora::g_rendererGpuMutex);
#endif
aurora::imgui::host_frame_begin(aurora::window::get_window_size());
}
extern "C" void* aurora_imgui_host_frame_end(void) { return new aurora::imgui::HostFramePtr(aurora::imgui::host_frame_end()); }
extern "C" void aurora_imgui_host_frame_release(void* imguiFrame) {
delete static_cast<aurora::imgui::HostFramePtr*>(imguiFrame);
}
void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]) {
aurora::set_stereo_scene_anchor(anchorFromScene);
}
void aurora_set_stereo_scene_anchor_scaled(const float anchorFromScene[12], float unitsPerMeter) {
aurora::set_stereo_scene_anchor(anchorFromScene);
uint32_t bits = 0;
std::memcpy(&bits, &unitsPerMeter, sizeof(bits));
if (aurora::g_pendingSceneAnchor.active && (bits & 0x7f800000u) != 0x7f800000u && unitsPerMeter > 0.f) {
aurora::g_pendingSceneAnchor.unitsPerMeter = unitsPerMeter;
}
}
void aurora_set_stereo_local_player_count(uint32_t count) {
aurora::g_pendingStereoLocalPlayerCount = count >= 1 && count <= 4 ? count : 1;
}
void aurora_set_frame_worker_wait_callback(AuroraFrameWorkerWaitCallback callback) {
aurora::g_frameWorkerWaitCallback.store(callback, std::memory_order_release);
}
void aurora_set_stereo_frame_provider(AuroraStereoFrameProvider provider, void* 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(); }
bool aurora_wait_for_frame_worker_for(uint32_t timeoutMicros) {
return aurora::wait_for_frame_worker_for(std::chrono::microseconds(timeoutMicros));
}
void aurora_quiesce_frame_worker() { aurora::quiesce_frame_worker(); }
void aurora_store_pipeline_caches() {
#ifdef AURORA_ENABLE_GX
aurora::gfx::store_pipeline_caches();
#endif
}
void aurora_request_pipeline_cache_store() {
#ifdef AURORA_ENABLE_GX
aurora::gfx::request_pipeline_cache_store();
#endif
}
void aurora_set_pipeline_cache_idle_store(bool allowed) {
#ifdef AURORA_ENABLE_GX
aurora::gfx::set_pipeline_cache_idle_store(allowed);
#else
(void)allowed;
#endif
}
uint32_t aurora_get_frame_worker_native_thread_id(void) {
return aurora::g_frameWorkerNativeThreadId.load(std::memory_order_acquire);
}
void aurora_set_present_schedule(uint64_t baseNanos, uint64_t intervalNanos) {
aurora::g_presentScheduleBaseNanos.store(baseNanos, std::memory_order_release);
aurora::g_presentScheduleIntervalNanos.store(intervalNanos, std::memory_order_release);
}
void aurora_report_producer_paced(bool paced) {
#ifdef AURORA_ENABLE_GX
aurora::gx::report_producer_paced(paced);
#else
(void)paced;
#endif
}
void aurora_get_frame_interpolation_diagnostics(AuroraFrameInterpolationDiagnostics* diagnostics) {
if (diagnostics != nullptr) {
aurora::gx::get_frame_interpolation_diagnostics(*diagnostics);
}
}
void aurora_get_present_timing(AuroraPresentTiming* timing) {
if (timing != nullptr) {
*timing = aurora::snapshot_present_timing();
}
}
void aurora_set_frame_interpolation_fps(uint32_t targetFps) {
#ifdef AURORA_ENABLE_GX
aurora::gx::set_frame_interpolation_fps(targetFps);
#else
(void)targetFps;
#endif
}
uint32_t aurora_get_frame_interpolation_fps() {
#ifdef AURORA_ENABLE_GX
return aurora::gx::frame_interpolation_fps();
#else
return 0;
#endif
}
void aurora_request_frame_capture(uint32_t frame, const char* outputPath) {
aurora::g_captureOutputPath = outputPath != nullptr ? outputPath : "frame_capture.bmp";
aurora::g_captureFrame.store(frame, std::memory_order_release);
}
bool aurora_flush_efb_copies_to_ram() {
#ifdef AURORA_ENABLE_GX
if (!aurora::gfx::efb_ram::has_pending()) {
return true;
}
if (!aurora::gfx::efb_ram::prepare_downloads()) {
return false;
}
// This finalizes the frame still being recorded, on the producer thread, so join the whole cycle
// first: the encode phase owns the previous passes, EFB targets and image pool.
aurora::wait_for_frame_worker();
// The renderer is about to submit a prefix of the active frame. Its resumed
// suffix cannot safely be replayed against the same mutable EFB resources.
aurora::gx::mark_frame_interpolation_replay_unsafe();
aurora::gx::fifo::drain();
const wgpu::CommandEncoderDescriptor encoderDescriptor{
.label = "GX CPU-visible EFB copy encoder",
};
auto encoder = aurora::webgpu::g_device.CreateCommandEncoder(&encoderDescriptor);
aurora::gfx::end_batch(encoder);
aurora::gfx::render(encoder);
aurora::gfx::efb_ram::encode_downloads(encoder);
const wgpu::CommandBufferDescriptor commandDescriptor{
.label = "GX CPU-visible EFB copy command buffer",
};
const auto commandBuffer = encoder.Finish(&commandDescriptor);
{
std::lock_guard submitLock(aurora::g_queueSubmitMutex);
aurora::webgpu::g_queue.Submit(1, &commandBuffer);
}
const bool copied = aurora::gfx::efb_ram::complete_downloads();
aurora::gfx::after_submit();
const bool resumed = aurora::gfx::resume_frame();
return copied && resumed;
#else
return true;
#endif
}
bool aurora_flush_efb_copy_to_ram(void* dest) {
#ifdef AURORA_ENABLE_GX
if (dest == nullptr || !aurora::gfx::efb_ram::has_pending(dest) || !aurora::gfx::efb_ram::prepare_downloads(dest)) {
return false;
}
// See aurora_flush_efb_copies_to_ram: this encodes the in-progress frame on
// the producer thread, so the worker's overlapped encode has to be finished.
aurora::wait_for_frame_worker();
// Preserve the requested output cadence by duplicating the completed native
// image instead of replaying this split frame.
aurora::gx::mark_frame_interpolation_replay_unsafe();
aurora::gx::fifo::drain();
const wgpu::CommandEncoderDescriptor encoderDescriptor{
.label = "GX demanded EFB copy encoder",
};
auto encoder = aurora::webgpu::g_device.CreateCommandEncoder(&encoderDescriptor);
aurora::gfx::end_batch(encoder);
aurora::gfx::render(encoder);
aurora::gfx::efb_ram::encode_downloads(encoder, dest);
const wgpu::CommandBufferDescriptor commandDescriptor{
.label = "GX demanded EFB copy command buffer",
};
const auto commandBuffer = encoder.Finish(&commandDescriptor);
{
std::lock_guard submitLock(aurora::g_queueSubmitMutex);
aurora::webgpu::g_queue.Submit(1, &commandBuffer);
}
const bool copied = aurora::gfx::efb_ram::complete_downloads();
aurora::gfx::after_submit();
const bool resumed = aurora::gfx::resume_frame();
return copied && resumed;
#else
(void)dest;
return true;
#endif
}
AuroraBackend aurora_get_backend() { return aurora::g_config.desiredBackend; }
const AuroraBackend* aurora_get_available_backends(size_t* count) {
if (count != nullptr) {
*count = aurora::PreferredBackendOrder.size();
}
return aurora::PreferredBackendOrder.data();
}
void aurora_set_log_level(AuroraLogLevel level) { aurora::g_config.logLevel = level; }
void aurora_set_pause_on_focus_lost(bool value) { aurora::g_config.pauseOnFocusLost = value; }
void aurora_set_disable_copy_filter(bool disabled) { aurora::g_config.disableCopyFilter = disabled; }
bool aurora_get_disable_copy_filter() { return aurora::g_config.disableCopyFilter; }
void aurora_set_stereo_stop_at_display_copy(bool enabled) { aurora::gfx::set_stereo_stop_at_display_copy(enabled); }
bool aurora_get_stereo_stop_at_display_copy() { return aurora::gfx::get_stereo_stop_at_display_copy(); }
void aurora_set_stereo_skip_copy_clears(bool enabled) { aurora::gfx::set_stereo_skip_copy_clears(enabled); }
bool aurora_get_stereo_skip_copy_clears() { return aurora::gfx::get_stereo_skip_copy_clears(); }
void aurora_set_stereo_single_pass_eyes(bool enabled) { aurora::gfx::set_stereo_single_pass_eyes(enabled); }
bool aurora_get_stereo_single_pass_eyes() { return aurora::gfx::get_stereo_single_pass_eyes(); }
void aurora_set_stereo_foveation(uint32_t level) {
aurora::gfx::set_stereo_foveation(std::min(level, aurora::gfx::foveation::kLevelCount - 1));
}
uint32_t aurora_get_stereo_foveation() { return aurora::gfx::get_stereo_foveation(); }
bool aurora_stereo_foveation_available() { return aurora::webgpu::fdm::available(); }
void aurora_set_stereo_hud_screen(bool enabled, float width, float distance) {
aurora::gfx::set_stereo_hud_screen(enabled, width, distance);
}
void aurora_set_stereo_hud_world_depth(float distance) {
distance = std::isfinite(distance) && distance > 0.f ? distance : 0.f;
if (aurora::gfx::get_stereo_hud_world_depth() == distance) return;
aurora::gx::fifo::drain();
aurora::gfx::set_stereo_hud_world_depth(distance);
aurora::gx::g_gxState.stateDirty = true;
}
void aurora_set_stereo_hud_world_anchor(float x, float y) {
x = std::isfinite(x) ? x : 0.f;
y = std::isfinite(y) ? y : 0.f;
const auto old = aurora::gfx::get_stereo_hud_world_anchor();
if (old.first == x && old.second == y) return;
aurora::gx::fifo::drain();
aurora::gfx::set_stereo_hud_world_anchor(x, y);
aurora::gx::g_gxState.stateDirty = true;
}
bool aurora_get_stereo_hud_screen_enabled() { return aurora::gfx::get_stereo_hud_screen_enabled(); }
bool aurora_get_stereo_screen_aspects(float* pictureAspect, float* snapshotAspect) {
const float picture = aurora::g_stereoPictureAspect.load(std::memory_order_relaxed);
const float snapshot = aurora::g_stereoSnapshotAspect.load(std::memory_order_relaxed);
if (pictureAspect == nullptr || snapshotAspect == nullptr || !(picture > 0.f) || !(snapshot > 0.f)) {
return false;
}
*pictureAspect = picture;
*snapshotAspect = snapshot;
return true;
}
void aurora_set_stereo_mirror_view(AuroraStereoMirrorView view) { aurora::gfx::set_stereo_mirror_view(view); }
AuroraStereoMirrorView aurora_get_stereo_mirror_view() { return aurora::gfx::get_stereo_mirror_view(); }
void aurora_set_background_input(bool value) {
aurora::g_config.allowJoystickBackgroundEvents = value;
aurora::window::set_background_input(value);
}
void aurora_set_display_mode(AuroraDisplayMode mode) { aurora::window::set_display_mode(mode); }
AuroraDisplayMode aurora_get_display_mode() { return aurora::window::get_display_mode(); }
void aurora_set_vr_controller_model(uint32_t hand,const AuroraVRControllerVertex* triangles,uint32_t count) {
if(hand>=2 || count>300000 || count%3!=0) return;
std::lock_guard lock(aurora::vrui::mutex);
if(triangles&&count) aurora::vrui::models[hand].assign(triangles,triangles+count);
else aurora::vrui::models[hand].clear();
}
float aurora_get_vr_panel_aspect(void) {
return aurora::vrui::panelAspect.load(std::memory_order_relaxed);
}
void aurora_set_vr_controller_texture(uint32_t material,uint32_t width,uint32_t height,const uint8_t* rgba) {
if(!material || !rgba || !width || !height || width>8192 || height>8192) return;
std::lock_guard lock(aurora::vrui::mutex);
auto& texture=aurora::vrui::controllerTextures[material];
if(texture.binding || !texture.pixels.empty()) return;
texture.width=width;texture.height=height;
texture.pixels.assign(rgba,rgba+size_t(width)*height*4);
}
void aurora_set_vr_controller_anchors(uint32_t hand,const float* positions) {
if(hand>=2 || !positions) return;
std::lock_guard lock(aurora::vrui::mutex);
for(int row=0;row<5;++row) for(int axis=0;axis<3;++axis)
aurora::vrui::anchors[hand][row][axis]=positions[row*3+axis];
}
void aurora_set_vr_menu_shader_quality(int quality) {
aurora::vrui::requestedQuality.store(std::clamp(quality,0,3),std::memory_order_relaxed);
}
void aurora_set_vr_ui_guide(const AuroraVRUiGuide* guide) {
std::lock_guard lock(aurora::vrui::mutex);
aurora::vrui::guide=guide?*guide:AuroraVRUiGuide{};
}
void aurora_set_vr_hand_workshop(const AuroraVRControllerVertex* triangles,uint32_t count,
uint32_t width,uint32_t height,const uint8_t* rgba,const bool* captured) {
if(count>120000 || count%3 || (rgba && (!width || !height || width>1024 || height>1024)))return;
std::lock_guard lock(aurora::vrui::mutex);
aurora::vrui::workshopActive=captured!=nullptr;
if(!captured) {
aurora::vrui::workshopModel.clear();
aurora::vrui::controllerTextures.erase(aurora::vrui::workshopMaterial);
return;
}
std::copy_n(captured,2,aurora::vrui::workshopCaptured.begin());
if(triangles && count)aurora::vrui::workshopModel.assign(triangles,triangles+count);
if(rgba) {
auto& texture=aurora::vrui::controllerTextures[aurora::vrui::workshopMaterial];
texture={};texture.width=width;texture.height=height;
texture.pixels.assign(rgba,rgba+size_t(width)*height*4);
}
}