Added modern present pacing + dt-snap: smooth fps unlock

Guest swap thread blocks on real GPU delivery (NotifyGuestPresent feeds the delivery counter) + an absolute-deadline limiter at ac6_fps_target, not the vblank grid (no sub-harmonic staircase). dt-snap locks the sim delta to the exact target at steady rate (kills the shake). Guest vblank forced to free-run during gameplay so pacing needs zero config. ac6_unlock_fps is the single master switch, ON by default (folded in timing-hooks/vblank-auto/delta-precision/dt-snap-tolerance AND the flight-model framerate-independence fix).
This commit is contained in:
Dipshet
2026-07-14 20:20:23 +02:00
parent 9603fb84d0
commit 6dc4120790
8 changed files with 328 additions and 41 deletions
@@ -109,6 +109,23 @@ class GraphicsSystem : public system::IGraphicsSystem {
static void SetGuestVblankHzOverride(double hz);
static double GetGuestVblankHzOverride();
// Modern present pacing: while target_hz > 0, PaceGuestPresent (called by
// VdSwap on the guest thread that submits swaps) blocks that thread until
// every previously issued swap has been delivered to the presenter (GPU
// backpressure, frame latency 1), then rate-limits it to target_hz with an
// absolute-deadline limiter. The game then runs at min(target, real GPU
// throughput) with uniform frame times - the modern game loop - instead of
// quantizing to vblank-grid sub-harmonics (60/30/20) when the GPU cannot
// hold the target. Combine with a free-running guest vblank so the vblank
// wait never gates. target_hz 0 = off. Process-wide, not per-instance.
static void SetGuestPresentPacing(double target_hz);
// Called by VdSwap on the swapping guest thread before it submits the next
// swap; blocks per SetGuestPresentPacing. No-op while pacing is off.
static void PaceGuestPresent();
// Called by the command processor whenever a guest frame is actually
// delivered to the presenter (guest output refreshed for a swap).
static void NotifyGuestPresent();
bool Save(::rex::stream::ByteStream* stream);
bool Restore(::rex::stream::ByteStream* stream);
@@ -2582,6 +2582,12 @@ bool D3D12CommandProcessor::IssueSwapInternal(uint32_t frontbuffer_ptr,
return true;
});
if (refreshed) {
// A guest frame was actually delivered to the presenter - feed the
// delivery-paced present pacing (see GraphicsSystem::NotifyGuestPresent).
GraphicsSystem::NotifyGuestPresent();
}
// End the frame even if did not present for any reason (the image refresher
// was not called), to prevent leaking per-frame resources.
REXGPU_DEBUG("IssueSwap: post-RefreshGuestOutput EndSubmission");
+105
View File
@@ -13,10 +13,13 @@
#include <algorithm>
#include <cctype>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <thread>
#include <utility>
#include <rex/cvar.h>
@@ -113,6 +116,23 @@ __declspec(dllexport) uint32_t AmdPowerXpressRequestHighPerformance = 1;
} // extern "C"
#endif // REX_PLATFORM_WIN32
namespace {
// Modern present pacing state (see SetGuestPresentPacing / PaceGuestPresent).
// The counters pair the guest's swap submissions (VdSwap) with the frames the
// command processor actually delivered to the presenter (NotifyGuestPresent).
// PaceGuestPresent blocks the swapping guest thread on that pairing plus an
// absolute-deadline frame limiter - GPU backpressure and a rate ceiling, the
// modern game loop - instead of pacing the guest off the vblank grid.
std::atomic<double> g_present_pacing_target_hz{0.0};
std::atomic<uint64_t> g_guest_present_count{0};
std::atomic<uint64_t> g_guest_swaps_issued{0};
std::mutex g_present_pacing_mutex;
std::condition_variable g_present_pacing_cv;
// Guarded by g_present_pacing_mutex.
std::chrono::steady_clock::time_point g_present_pacing_deadline{};
uint64_t g_present_delivery_at_last_timeout = UINT64_MAX;
} // namespace
GraphicsSystem::GraphicsSystem() : vsync_worker_running_(false) {}
GraphicsSystem::~GraphicsSystem() = default;
@@ -423,6 +443,91 @@ double GraphicsSystem::GetGuestVblankHzOverride() {
return g_guest_vblank_hz_override.load(std::memory_order_relaxed);
}
void GraphicsSystem::SetGuestPresentPacing(double target_hz) {
g_present_pacing_target_hz.store(target_hz, std::memory_order_relaxed);
}
void GraphicsSystem::NotifyGuestPresent() {
g_guest_present_count.fetch_add(1, std::memory_order_relaxed);
// Empty critical section closes the race with PaceGuestPresent's predicate
// check-then-wait, so the notify below cannot fall between them and be lost.
{ std::lock_guard<std::mutex> lock(g_present_pacing_mutex); }
g_present_pacing_cv.notify_all();
}
void GraphicsSystem::PaceGuestPresent() {
const double target_hz = g_present_pacing_target_hz.load(std::memory_order_relaxed);
if (target_hz <= 0.0) {
return;
}
// 1) Backpressure: before letting the game submit the next swap, wait until
// every previously issued swap has been delivered to the presenter (frame
// latency 1). This is what paces the game to the real GPU rate when it
// cannot hold target_hz - uniformly, with no vblank grid to alias against.
const uint64_t issued = g_guest_swaps_issued.load(std::memory_order_relaxed);
{
std::unique_lock<std::mutex> lock(g_present_pacing_mutex);
const auto pred = [&] {
return g_guest_present_count.load(std::memory_order_relaxed) >= issued;
};
const uint64_t delivered_now = g_guest_present_count.load(std::memory_order_relaxed);
// While delivery is known-stalled (swaps are not reaching the presenter -
// device loss, experimental swap paths), skip the wait entirely; the
// limiter below still runs, degrading this to a plain frame-rate cap.
const bool delivery_stalled = g_present_delivery_at_last_timeout != UINT64_MAX &&
delivered_now == g_present_delivery_at_last_timeout;
if (!delivery_stalled && !pred()) {
if (g_present_pacing_cv.wait_for(lock, std::chrono::milliseconds(100), pred)) {
g_present_delivery_at_last_timeout = UINT64_MAX;
} else {
// Timed out: forgive the whole backlog so one undelivered swap cannot
// park every future frame on this timeout.
g_present_delivery_at_last_timeout =
g_guest_present_count.load(std::memory_order_relaxed);
g_guest_swaps_issued.store(g_present_delivery_at_last_timeout,
std::memory_order_relaxed);
}
} else if (!delivery_stalled) {
g_present_delivery_at_last_timeout = UINT64_MAX;
}
}
g_guest_swaps_issued.fetch_add(1, std::memory_order_relaxed);
// 2) Ceiling: absolute-deadline limiter at target_hz. Deadlines advance by
// exactly one interval while the game keeps up (drift-free target rate) and
// re-anchor to now when it does not, so a slow stretch accrues no catch-up
// debt that would burst frames afterwards.
const auto interval = std::chrono::duration_cast<std::chrono::steady_clock::duration>(
std::chrono::duration<double>(1.0 / target_hz));
auto now = std::chrono::steady_clock::now();
std::chrono::steady_clock::time_point wake;
{
std::lock_guard<std::mutex> lock(g_present_pacing_mutex);
if (g_present_pacing_deadline < now) {
g_present_pacing_deadline = now;
}
wake = g_present_pacing_deadline;
g_present_pacing_deadline = wake + interval;
}
// Coarse sleep to ~2ms short of the deadline, then spin the remainder for
// precision (OS sleep granularity is ~1ms and can overshoot).
while (true) {
now = std::chrono::steady_clock::now();
if (now >= wake) {
break;
}
const auto remaining = wake - now;
if (remaining > std::chrono::milliseconds(2)) {
rex::thread::Sleep(
std::chrono::duration_cast<std::chrono::milliseconds>(remaining) -
std::chrono::milliseconds(2));
} else {
std::this_thread::yield();
}
}
}
void GraphicsSystem::MarkVblank() {
// TODO: Enable profiling once ported
// SCOPE_profile_cpu_f("gpu");
@@ -430,6 +430,11 @@ void VdSwap_entry(ppc_pvoid_t buffer_ptr, // ptr into primary ringbuffer
assert(width);
assert(height);
// Modern present pacing (no-op unless enabled): block this guest thread on
// GPU delivery of the previous swap plus the target-rate limiter before the
// game may submit the next one. See GraphicsSystem::PaceGuestPresent.
rex::graphics::GraphicsSystem::PaceGuestPresent();
namespace xenos = rex::graphics::xenos;
xenos::xe_gpu_texture_fetch_t gpu_fetch;