mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-09-10 17:16:47 -04:00
1933 lines
74 KiB
C++
1933 lines
74 KiB
C++
#include <aurora/aurora.h>
|
|
|
|
#ifdef AURORA_ENABLE_GX
|
|
#include "gfx/common.hpp"
|
|
#include "gfx/efb_ram_copy.hpp"
|
|
#include "gx/fifo.hpp"
|
|
#include "gx/shader_info.hpp"
|
|
#include "imgui.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 "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 <cmath>
|
|
#include <condition_variable>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#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};
|
|
// 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;
|
|
|
|
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) 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;
|
|
// 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;
|
|
|
|
bool frame_worker_requested() noexcept {
|
|
#ifdef AURORA_ENABLE_GX
|
|
static const bool enabled = [] {
|
|
#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) noexcept;
|
|
#endif
|
|
|
|
void frame_worker_main() noexcept {
|
|
{
|
|
std::lock_guard lock(g_frameWorker.mutex);
|
|
g_frameWorker.threadId = std::this_thread::get_id();
|
|
}
|
|
|
|
#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 (;;) {
|
|
{
|
|
std::unique_lock lock(g_frameWorker.mutex);
|
|
g_frameWorker.cv.wait(lock, [] { return g_frameWorker.stop || g_frameWorker.jobPending; });
|
|
if (g_frameWorker.stop) {
|
|
break;
|
|
}
|
|
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 (!run_frame_worker_cycle(sealedFrame)) {
|
|
break;
|
|
}
|
|
#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.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;
|
|
|
|
void wait_for_frame_worker_private(FrameWorkerPhase phase) noexcept {
|
|
constexpr auto kWaitServiceInterval = std::chrono::milliseconds(1);
|
|
while (!wait_for_frame_worker_private_for(phase, kWaitServiceInterval)) {
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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};
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
bool present_presentation_job(const PresentationJob& job) {
|
|
ZoneScoped;
|
|
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(),
|
|
};
|
|
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);
|
|
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);
|
|
}
|
|
|
|
// `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) {
|
|
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(),
|
|
};
|
|
const auto pass = encoder.BeginRenderPass(&renderPassDescriptor);
|
|
pass.SetPipeline(webgpu::g_CopyPipeline);
|
|
pass.SetBindGroup(0, presentBindGroup, 0, nullptr);
|
|
pass.SetViewport(viewport.left, viewport.top, viewport.width, viewport.height,
|
|
viewport.znear, viewport.zfar);
|
|
pass.Draw(3);
|
|
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(),
|
|
};
|
|
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);
|
|
imgui::render(pass);
|
|
pass.End();
|
|
}
|
|
}
|
|
#endif
|
|
|
|
void shutdown() noexcept {
|
|
stop_frame_worker();
|
|
#ifdef AURORA_ENABLE_GX
|
|
stop_presenter();
|
|
g_presentationImagePools = {};
|
|
imgui::shutdown();
|
|
gfx::shutdown();
|
|
webgpu::shutdown();
|
|
#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) {
|
|
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 (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 {
|
|
wgpu::CommandEncoder encoder; // slot 0's encoder; already holds the staging copies
|
|
webgpu::PresentSource presentSource{};
|
|
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;
|
|
};
|
|
|
|
// 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) {
|
|
ZoneScopedN("Seal 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();
|
|
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();
|
|
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.
|
|
imgui::render_frame_data();
|
|
// 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);
|
|
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);
|
|
|
|
// 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 auto buffer = target.Finish(&cmdBufDescriptor);
|
|
std::lock_guard submitLock(g_queueSubmitMutex);
|
|
g_queue.Submit(1, &buffer);
|
|
};
|
|
|
|
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);
|
|
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.
|
|
gfx::render(sealedFrame, encoder, -1, true);
|
|
// 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);
|
|
presentationJobs.push_back({
|
|
.image = std::move(image),
|
|
.logicalFrame = ctx.logicalFrame,
|
|
.presentAt = slotPresentDeadline(interpolatedFrame),
|
|
.interpolated = true,
|
|
.duplicated = true,
|
|
});
|
|
submitEncodedSlot(encoder);
|
|
encoder = g_device.CreateCommandEncoder(&encoderDescriptor);
|
|
}
|
|
}
|
|
auto finalImage =
|
|
acquire_presentation_image(ctx.interpolatedFrameCount, ctx.snapshotWidth, ctx.snapshotHeight);
|
|
encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true);
|
|
auto pendingFrameCapture = encode_frame_capture(encoder, ctx.presentSource);
|
|
presentationJobs.push_back({
|
|
.image = std::move(finalImage),
|
|
.logicalFrame = ctx.logicalFrame,
|
|
.presentAt = slotPresentDeadline(ctx.interpolatedFrameCount),
|
|
.interpolated = false,
|
|
});
|
|
submitEncodedSlot(encoder);
|
|
|
|
// 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) {
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
FrameMarkNamed("Aurora frame");
|
|
}
|
|
|
|
// One complete frame-worker cycle. The scene encode only leaves the renderer mutex when
|
|
// interpolation actually inserts slots; otherwise both phases publish together.
|
|
bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame) noexcept {
|
|
ZoneScopedN("Frame worker cycle");
|
|
webgpu::fail_if_device_lost();
|
|
SealedFrameContext ctx;
|
|
std::vector<PresentationJob> presentationJobs;
|
|
bool overlapEncode = false;
|
|
{
|
|
std::lock_guard gpuLock(g_rendererGpuMutex);
|
|
seal_frame_locked(sealedFrame, ctx);
|
|
overlapEncode = ctx.interpolationActive;
|
|
if (!overlapEncode) {
|
|
presentationJobs = encode_sealed_frame(sealedFrame, ctx);
|
|
}
|
|
}
|
|
if (!overlapEncode) {
|
|
publish_presentations(std::move(presentationJobs), ctx.interpolationActive);
|
|
}
|
|
|
|
{
|
|
std::unique_lock lock(g_frameWorker.mutex);
|
|
g_frameWorker.cv.wait(lock, [] { return g_frameWorker.stop || g_frameWorker.prepareAllowed; });
|
|
if (g_frameWorker.stop) {
|
|
return false;
|
|
}
|
|
g_frameWorker.prepareAllowed = false;
|
|
}
|
|
|
|
// 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, overlapEncode ? ImGuiFramePolicy::Deferred : ImGuiFramePolicy::Immediate,
|
|
&imguiNewFrameOwed);
|
|
|
|
{
|
|
std::lock_guard lock(g_frameWorker.mutex);
|
|
g_frameWorker.framePrepared = prepared;
|
|
g_frameWorker.sealed.store(true, std::memory_order_release);
|
|
if (!overlapEncode) {
|
|
g_frameWorker.ready.store(true, std::memory_order_release);
|
|
}
|
|
}
|
|
g_frameWorker.cv.notify_all();
|
|
|
|
if (overlapEncode) {
|
|
// Mutex-free: the producer drains and records the next frame in parallel, taking the renderer
|
|
// mutex per drain, and this phase never takes it.
|
|
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());
|
|
}
|
|
{
|
|
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) noexcept {
|
|
ZoneScoped;
|
|
#ifdef AURORA_ENABLE_GX
|
|
webgpu::fail_if_device_lost();
|
|
if (pumpEvents) {
|
|
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);
|
|
presentationJobs = encode_sealed_frame(sealedFrame, ctx);
|
|
}
|
|
publish_presentations(std::move(presentationJobs), ctx.interpolationActive);
|
|
record_frame_telemetry();
|
|
#else
|
|
(void)pumpEvents;
|
|
(void)drainFifo;
|
|
#endif
|
|
}
|
|
|
|
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.
|
|
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) {
|
|
// 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() noexcept {
|
|
#ifdef AURORA_ENABLE_GX
|
|
webgpu::fail_if_device_lost();
|
|
#endif
|
|
if (!frame_worker_requested()) {
|
|
end_frame_impl(true, true);
|
|
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.
|
|
wait_for_frame_worker_private(FrameWorkerPhase::Done);
|
|
|
|
// Seal all current GX work on the CPU while the renderer is known ready.
|
|
// Later FIFO writes belong exclusively to the next frame.
|
|
{
|
|
std::lock_guard gpuLock(g_rendererGpuMutex);
|
|
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.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);
|
|
}
|
|
std::recursive_mutex& renderer_gpu_mutex() noexcept { return g_rendererGpuMutex; }
|
|
} // 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() { return aurora::begin_frame(); }
|
|
void aurora_end_frame() { aurora::end_frame(); }
|
|
void aurora_set_frame_worker_wait_callback(AuroraFrameWorkerWaitCallback callback) {
|
|
aurora::g_frameWorkerWaitCallback.store(callback, std::memory_order_release);
|
|
}
|
|
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_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_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(); }
|