mirror of
https://github.com/sal063/AC6_recomp
synced 2026-08-22 23:21:35 -04:00
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:
@@ -91,11 +91,6 @@
|
||||
|
||||
#include "../render_hooks.h"
|
||||
|
||||
REXCVAR_DEFINE_BOOL(ac6_fps_physics_fix, true, "AC6",
|
||||
"Make flight-model accel/decel framerate-independent when the FPS unlock "
|
||||
"is active (rescales the game's fixed per-frame force steps by "
|
||||
"frame time / 33.3ms)");
|
||||
|
||||
namespace {
|
||||
|
||||
// Longitudinal force/speed-command accumulator in the flight-model object
|
||||
@@ -112,12 +107,13 @@ constexpr double kNativeFrameMs = 1000.0 / 30.0;
|
||||
// wrappers are the sole callers), so plain statics are safe throughout.
|
||||
|
||||
// Per-frame-step scale for this frame: 1.0 at the native 30fps cadence,
|
||||
// shrinking as the frame rate rises (never above 1.0 - the game's own delta
|
||||
// clamp already holds sim speed at/below 30fps). Gated on the SAME signal as
|
||||
// the frame-delta hooks (ac6::TimingHooksActive) so the force step and the
|
||||
// kinematics delta always revert to vanilla together (cutscene clamp, menus,
|
||||
// unlock off). Returns exactly 1.0 for a pass-through. Inputs change at most
|
||||
// once per guest frame, so the result is cached per frame.
|
||||
// shrinking as the frame rate rises. Below 30fps it keeps growing (up to the
|
||||
// ac6_min_sim_fps floor via ac6::ClampedMinSimFps) so the per-frame dynamics
|
||||
// track the raised delta clamp instead of freezing at the 30fps step. Gated on
|
||||
// the SAME signal as the frame-delta hooks (ac6::TimingHooksActive) so the
|
||||
// dynamics and the kinematics delta always revert to vanilla together
|
||||
// (cutscene clamp, menus, unlock off). Returns exactly 1.0 for a pass-through.
|
||||
// Inputs change at most once per guest frame, so the result is cached.
|
||||
double StepRatio() {
|
||||
static uint64_t s_cached_frame = ~uint64_t(0);
|
||||
static double s_cached_ratio = 1.0;
|
||||
@@ -127,15 +123,18 @@ double StepRatio() {
|
||||
}
|
||||
s_cached_frame = stats.frame_count;
|
||||
s_cached_ratio = 1.0;
|
||||
if (!REXCVAR_GET(ac6_fps_physics_fix) || !ac6::TimingHooksActive()) {
|
||||
// Framerate-independence is part of the unlock; gate on the unlock alone
|
||||
// (was the separate ac6_fps_physics_fix cvar, now folded in).
|
||||
if (!ac6::TimingHooksActive()) {
|
||||
return s_cached_ratio;
|
||||
}
|
||||
if (stats.frame_time_ms <= 0.0) {
|
||||
return s_cached_ratio; // no frame measured yet
|
||||
}
|
||||
const double ratio_cap = 30.0 / ac6::ClampedMinSimFps();
|
||||
double ratio = stats.frame_time_ms / kNativeFrameMs;
|
||||
if (ratio > 1.0) {
|
||||
ratio = 1.0; // never blend past the native 30fps step
|
||||
if (ratio > ratio_cap) {
|
||||
ratio = ratio_cap;
|
||||
} else if (ratio < 0.02) {
|
||||
ratio = 0.02; // sanity floor against a bogus frame-time sample
|
||||
}
|
||||
|
||||
+3
-2
@@ -7,7 +7,6 @@
|
||||
#include <rex/logging.h>
|
||||
|
||||
REXCVAR_DECLARE(bool, ac6_render_capture);
|
||||
REXCVAR_DECLARE(bool, ac6_timing_hooks_enabled);
|
||||
REXCVAR_DECLARE(bool, ac6_unlock_fps);
|
||||
REXCVAR_DECLARE(bool, ac6_native_graphics_enabled);
|
||||
REXCVAR_DECLARE(bool, ac6_force_safe_draw_resolution_scale);
|
||||
@@ -218,7 +217,9 @@ std::unique_ptr<rex::ui::WindowedApp> Ac6recompAppCreate(rex::ui::WindowedAppCon
|
||||
if (!rex::cvar::HasNonDefaultValue("log_level")) {
|
||||
REXCVAR_SET(log_level, "debug");
|
||||
}
|
||||
REXCVAR_SET(ac6_unlock_fps, false);
|
||||
// Smooth 60fps unlock is on by default now (zero-config for players); the
|
||||
// toml can still set ac6_unlock_fps=false for stock locked behaviour.
|
||||
REXCVAR_SET(ac6_unlock_fps, true);
|
||||
ApplyAc6DefaultSettings();
|
||||
ApplyAc6HybridStartupSafetyOverrides();
|
||||
ApplyAc6FixDefaults();
|
||||
|
||||
+173
-24
@@ -6,14 +6,25 @@
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
#include <native/thread.h>
|
||||
#include <rex/cvar.h>
|
||||
#include <rex/graphics/graphics_system.h>
|
||||
#include <rex/logging.h>
|
||||
#include <rex/system/kernel_state.h>
|
||||
|
||||
REXCVAR_DEFINE_BOOL(ac6_unlock_fps, false, "AC6", "Unlock frame rate to 60fps");
|
||||
REXCVAR_DEFINE_BOOL(ac6_timing_hooks_enabled, true, "AC6",
|
||||
"Enable AC6 timing hooks that alter the game's presentation cadence");
|
||||
REXCVAR_DEFINE_BOOL(ac6_unlock_fps, true, "AC6",
|
||||
"Master switch for the smooth 60fps unlock (modern present pacing + "
|
||||
"dt-snap + physics dt-correction). On by default; false = stock behaviour.");
|
||||
REXCVAR_DEFINE_BOOL(ac6_cutscene_clamp, true, "AC6",
|
||||
"Suspend the 60fps unlock during in-engine cutscenes so they "
|
||||
"play at native ~30fps instead of double speed");
|
||||
@@ -23,14 +34,90 @@ REXCVAR_DEFINE_BOOL(ac6_dynamic_vblank, true, "AC6",
|
||||
"free-runs at the configured rate. Gameplay is detected via the "
|
||||
"world-compositor draw heartbeat; cutscenes via the cinematic "
|
||||
"hooks.");
|
||||
REXCVAR_DEFINE_BOOL(ac6_delta_precision, true, "AC6",
|
||||
"With the FPS unlock active, carry the fractional remainder of the "
|
||||
"game's integer frame delta across frames so its floor(x)+1 "
|
||||
"truncation guard stops inflating game speed at high framerates "
|
||||
"(+2% at 60fps, +10% at 300fps)");
|
||||
REXCVAR_DEFINE_DOUBLE(ac6_fps_target, 60.0, "AC6",
|
||||
"The rate the simulation + presentation run at under the FPS unlock. "
|
||||
">0 = that exact rate (clamped to 30..ac6_max_sim_fps). "
|
||||
"0 = AUTO: the largest rate <= ac6_max_sim_fps that evenly divides your "
|
||||
"monitor's refresh, so frames land on refresh boundaries instead of "
|
||||
"beating against them (240Hz->60, 144Hz->48, 120Hz->60, 60Hz->60). "
|
||||
"Pick a target that divides your refresh; auto does this for you. "
|
||||
"Mirrors PA's native PC engine, which has no separate cap and simply "
|
||||
"runs the (dt-correct) sim at the display rate.");
|
||||
REXCVAR_DEFINE_DOUBLE(ac6_max_sim_fps, 60.0, "AC6",
|
||||
"Ceiling on the simulation/pacing rate. AC6's physics is only validated "
|
||||
"to 60fps; above it, fixed-step assumptions surface (untested regime). "
|
||||
"Both ac6_fps_target and the auto (refresh-matched) target are clamped to "
|
||||
"this. Raise only once the >60 regime has been validated.");
|
||||
REXCVAR_DEFINE_BOOL(ac6_dt_snap, true, "AC6",
|
||||
"With the FPS unlock active, snap the per-frame simulation delta to the "
|
||||
"EXACT pacing target when the real frame time is within ~1ms of it, so "
|
||||
"the fixed-step integrator sees a constant step. Removes the residual "
|
||||
"per-frame delta jitter that a fixed-step sim turns into visible shake; "
|
||||
"genuine slowdowns fall through to the precision path.");
|
||||
REXCVAR_DEFINE_DOUBLE(ac6_min_sim_fps, 20.0, "AC6",
|
||||
"Lowest framerate the simulation runs at true speed before the "
|
||||
"game's frame-delta clamp forces slow motion. The stock game floors "
|
||||
"at 30 (below 30fps it plays in slow motion); this lowers the floor "
|
||||
"(default 20) so a sub-30fps dip runs at correct speed - just "
|
||||
"choppier - instead of slowing down and rubber-banding on recovery. "
|
||||
"Set 30 for stock behavior. Active with the FPS unlock; drives the "
|
||||
"frame-delta clamp and the physics step cap together so dynamics "
|
||||
"stay consistent with the kinematics.");
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
// Current monitor refresh rate in Hz, 0 if unknown. Cached on first use (a
|
||||
// mid-session refresh change is rare enough to need a restart).
|
||||
static double HostRefreshHz() {
|
||||
static double cached = -1.0;
|
||||
if (cached >= 0.0) {
|
||||
return cached;
|
||||
}
|
||||
cached = 0.0;
|
||||
#if defined(_WIN32)
|
||||
DEVMODEW dm;
|
||||
ZeroMemory(&dm, sizeof(dm));
|
||||
dm.dmSize = sizeof(dm);
|
||||
if (EnumDisplaySettingsW(nullptr, ENUM_CURRENT_SETTINGS, &dm) && dm.dmDisplayFrequency > 1) {
|
||||
cached = double(dm.dmDisplayFrequency);
|
||||
}
|
||||
#endif
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Single source of truth for the sim/presentation rate under the unlock, so the
|
||||
// pacing limiter and the dt-snap always agree. See ac6_fps_target / ac6_max_sim_fps.
|
||||
static double ResolvePacingTargetFps() {
|
||||
// Absolute floor (10) lets sub-30 rates be targeted for testing; below
|
||||
// ac6_min_sim_fps the sim goes slow-mo (expected), but the rate is honored.
|
||||
constexpr double kMinTargetFps = 10.0;
|
||||
double ceil_fps = REXCVAR_GET(ac6_max_sim_fps);
|
||||
if (ceil_fps < kMinTargetFps) ceil_fps = kMinTargetFps;
|
||||
if (ceil_fps > 240.0) ceil_fps = 240.0;
|
||||
|
||||
double target = REXCVAR_GET(ac6_fps_target);
|
||||
if (target > 0.0) {
|
||||
if (target < kMinTargetFps) target = kMinTargetFps;
|
||||
if (target > ceil_fps) target = ceil_fps;
|
||||
return target;
|
||||
}
|
||||
|
||||
// Auto: largest integer in [kMinTargetFps, ceil] that evenly divides the
|
||||
// refresh rate, so frames align to refresh boundaries instead of beating.
|
||||
const double refresh = HostRefreshHz();
|
||||
if (refresh < kMinTargetFps) {
|
||||
return ceil_fps; // unknown refresh: just run at the ceiling
|
||||
}
|
||||
const int hz = int(refresh + 0.5);
|
||||
for (int f = int(ceil_fps + 0.5); f >= int(kMinTargetFps); --f) {
|
||||
if (hz % f == 0) {
|
||||
return double(f);
|
||||
}
|
||||
}
|
||||
// No clean divisor >= 30 (e.g. 50/75Hz): fall back to min(refresh, ceiling).
|
||||
return refresh < ceil_fps ? refresh : ceil_fps;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::atomic<double> g_frame_time_ms{0.0};
|
||||
@@ -77,7 +164,7 @@ bool AreTimingHooksActive() {
|
||||
// to keep menus/hangar at native cadence, and it fails closed (a game
|
||||
// build/render path whose compositor shader hashes differently would never
|
||||
// stamp it). ac6_dynamic_vblank=false restores the plain always-on unlock.
|
||||
return REXCVAR_GET(ac6_timing_hooks_enabled) && REXCVAR_GET(ac6_unlock_fps) &&
|
||||
return REXCVAR_GET(ac6_unlock_fps) &&
|
||||
!ac6::IsCinematicActive() &&
|
||||
(!REXCVAR_GET(ac6_dynamic_vblank) || IsWorldRenderActive());
|
||||
}
|
||||
@@ -94,6 +181,16 @@ bool TimingHooksActive() {
|
||||
return AreTimingHooksActive();
|
||||
}
|
||||
|
||||
double ClampedMinSimFps() {
|
||||
double min_fps = REXCVAR_GET(ac6_min_sim_fps);
|
||||
if (min_fps < 10.0) {
|
||||
min_fps = 10.0;
|
||||
} else if (min_fps > 30.0) {
|
||||
min_fps = 30.0; // can't floor above 30fps - that would slow 30fps content
|
||||
}
|
||||
return min_fps;
|
||||
}
|
||||
|
||||
bool WorldRenderActiveRecently() {
|
||||
return IsWorldRenderActive();
|
||||
}
|
||||
@@ -147,29 +244,60 @@ void ac6DeltaPrecisionHook(PPCRegister& r8, PPCRegister& r10, PPCRegister& r29,
|
||||
s_remainder = 0.0;
|
||||
return;
|
||||
}
|
||||
if (!REXCVAR_GET(ac6_delta_precision)) {
|
||||
const bool precision = true; // always on now (frame-delta correctness)
|
||||
const double min_fps = ac6::ClampedMinSimFps();
|
||||
const bool raise_floor = min_fps < 30.0;
|
||||
if (!precision && !raise_floor) {
|
||||
s_remainder = 0.0;
|
||||
return;
|
||||
}
|
||||
const double max_delta = 100.0; // the game's stock 30fps delta clamp
|
||||
// The delta clamp becomes 3000/min_fps instead of the stock 100 (30fps->100,
|
||||
// 20fps->150): the sim keeps true speed down to min_fps before slow motion.
|
||||
const double max_delta = 3000.0 / min_fps;
|
||||
|
||||
// Exact delta this frame in the game's own scale (elapsed * 3000), using the
|
||||
// same ticks-per-frame divisor (r10 = freq / 30) the game divided by.
|
||||
// same ticks-per-frame divisor (r10 = freq / 30) the game divided by. Clamp
|
||||
// to the floor: time slower than min_fps is dropped, not banked.
|
||||
double exact = double(r30.u32) * 100.0 / double(r10.u32);
|
||||
if (exact > max_delta) {
|
||||
exact = max_delta;
|
||||
}
|
||||
|
||||
// Carry the remainder (no +1) so the summed integer deltas track real time.
|
||||
const double base = s_remainder + exact;
|
||||
// dt-snap: at a steady framerate near the pacing target, force the delta to the
|
||||
// EXACT target value so the fixed-step integrator sees a constant step. This is
|
||||
// what removes the residual per-frame jitter (the aircraft shake) that survives
|
||||
// smooth pacing - a fixed-step sim amplifies even sub-millisecond dt variance.
|
||||
// Only engages within a tight window of the target; genuine slowdowns/speedups
|
||||
// fall through to the precision path below. Mirrors PA's native PC engine, which
|
||||
// feeds a constant dt at a steady rate. The target matches the pacing limiter
|
||||
// exactly (shared ResolvePacingTargetFps), so sim and presentation stay locked.
|
||||
if (REXCVAR_GET(ac6_dt_snap)) {
|
||||
const double target_fps = ResolvePacingTargetFps();
|
||||
const double target_delta = 3000.0 / target_fps; // game units, 100 = 30fps
|
||||
const double tol_units = 3.0; // ~1ms window (100 delta-units = 33.3ms, so 1ms = 3)
|
||||
if (tol_units > 0.0 && target_delta <= max_delta &&
|
||||
std::fabs(exact - target_delta) < tol_units) {
|
||||
double snapped = std::floor(target_delta + 0.5);
|
||||
if (snapped < 1.0) {
|
||||
snapped = 1.0;
|
||||
}
|
||||
s_remainder = 0.0; // locked to target; no fractional carry
|
||||
r8.u64 = uint64_t(snapped);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// precision on: carry the remainder (no +1). precision off: keep the game's
|
||||
// floor(exact)+1 integer. floor(exact + 1) == floor(exact) + 1.
|
||||
const double base = precision ? (s_remainder + exact) : (exact + 1.0);
|
||||
double delta = std::floor(base);
|
||||
if (delta < 1.0) {
|
||||
delta = 1.0; // the game guarantees progress every frame; the overshoot is
|
||||
// repaid through a negative remainder next frame
|
||||
delta = 1.0; // the game guarantees progress every frame; on the precision
|
||||
// path the overshoot is repaid through a negative remainder
|
||||
} else if (delta > max_delta) {
|
||||
delta = max_delta;
|
||||
}
|
||||
s_remainder = base - delta;
|
||||
s_remainder = precision ? (base - delta) : 0.0;
|
||||
r8.u64 = uint64_t(delta);
|
||||
}
|
||||
|
||||
@@ -180,19 +308,35 @@ void ac6PresentTimingHook(PPCRegister& /*r31*/) {
|
||||
// frame-locked content (menus, hangar, cutscenes) at the native 60Hz. Only
|
||||
// engages when the FPS unlock is on, so default configurations keep the plain
|
||||
// cvar-driven vblank behavior.
|
||||
const bool unlock = REXCVAR_GET(ac6_timing_hooks_enabled) && REXCVAR_GET(ac6_unlock_fps);
|
||||
const bool unlock = REXCVAR_GET(ac6_unlock_fps);
|
||||
const bool dynamic_pacing = REXCVAR_GET(ac6_dynamic_vblank) && unlock;
|
||||
// Single source of truth for "the unlock is remapping the cadence right now" -
|
||||
// the same signal that gates the interval/delta hooks and the physics rescale.
|
||||
const bool free_running = dynamic_pacing && AreTimingHooksActive();
|
||||
|
||||
// Guest-vblank Hz override for this frame. 0 = no override (free-run at the
|
||||
// vsync/tearing rate); dynamic pacing forces frame-locked content to 60Hz.
|
||||
// Frame pacing for this frame. Precedence:
|
||||
// 1. frame-locked content (menus, cutscenes) -> fixed 60Hz vblank override;
|
||||
// 2. gameplay with ac6_vblank_auto -> modern present pacing: the guest
|
||||
// vblank free-runs (gates nothing) and the game's swap thread instead
|
||||
// blocks on real GPU delivery plus an absolute-deadline limiter at
|
||||
// ac6_fps_target. The game runs at min(target, real GPU throughput)
|
||||
// with uniform frame times - no vblank grid, so a sub-target GPU can
|
||||
// never alias into the 60/30 sub-harmonic staircase;
|
||||
// 3. otherwise the plain cvar-driven vblank (previous behavior).
|
||||
double override_hz = 0.0;
|
||||
double pacing_target_hz = 0.0;
|
||||
if (dynamic_pacing && !free_running) {
|
||||
override_hz = 60.0;
|
||||
} else if (free_running) {
|
||||
// Force the guest vblank to free-run (non-gating) during gameplay so
|
||||
// PaceGuestPresent is the SOLE gate, regardless of the vsync /
|
||||
// guest_vblank_sync_to_refresh cvars (whose defaults would otherwise re-add
|
||||
// a 60Hz gate). This makes the smooth unlock work with zero configuration.
|
||||
override_hz = 1000.0;
|
||||
pacing_target_hz = ResolvePacingTargetFps();
|
||||
}
|
||||
rex::graphics::GraphicsSystem::SetGuestVblankHzOverride(override_hz);
|
||||
rex::graphics::GraphicsSystem::SetGuestPresentPacing(pacing_target_hz);
|
||||
|
||||
const auto now = Clock::now();
|
||||
if (g_frame_start.time_since_epoch().count() != 0) {
|
||||
@@ -205,18 +349,23 @@ void ac6PresentTimingHook(PPCRegister& /*r31*/) {
|
||||
}
|
||||
g_frame_start = now;
|
||||
|
||||
// Log the first handful of pacing transitions.
|
||||
// Log the first handful of pacing transitions (smooth pacing keyed negative
|
||||
// so mode switches log distinctly from plain overrides).
|
||||
const double log_key = pacing_target_hz > 0.0 ? -pacing_target_hz : override_hz;
|
||||
static double last_log_key = -0.5;
|
||||
static uint32_t transition_logs = 0;
|
||||
if (unlock && override_hz != last_log_key && transition_logs < 32) {
|
||||
if (unlock && log_key != last_log_key && transition_logs < 32) {
|
||||
++transition_logs;
|
||||
if (override_hz == 0.0) {
|
||||
if (pacing_target_hz > 0.0) {
|
||||
REXLOG_INFO("[AC6-VBLANK] pacing -> modern (present-paced, target {:.0f}fps)",
|
||||
pacing_target_hz);
|
||||
} else if (override_hz == 0.0) {
|
||||
REXLOG_INFO("[AC6-VBLANK] pacing -> free-run (uncapped)");
|
||||
} else {
|
||||
REXLOG_INFO("[AC6-VBLANK] pacing -> {:.0f}Hz guest vblank", override_hz);
|
||||
}
|
||||
}
|
||||
last_log_key = override_hz;
|
||||
last_log_key = log_key;
|
||||
}
|
||||
|
||||
void ac6CinematicTickHook(PPCRegister& /*r3*/) {
|
||||
|
||||
+6
-1
@@ -6,7 +6,6 @@
|
||||
#include <rex/ppc/types.h>
|
||||
|
||||
REXCVAR_DECLARE(bool, ac6_unlock_fps);
|
||||
REXCVAR_DECLARE(bool, ac6_timing_hooks_enabled);
|
||||
REXCVAR_DECLARE(bool, ac6_cutscene_clamp);
|
||||
|
||||
namespace ac6 {
|
||||
@@ -24,6 +23,12 @@ FrameStats GetFrameStats();
|
||||
// off this so it stays in lockstep with the frame-delta hooks.
|
||||
bool TimingHooksActive();
|
||||
|
||||
// ac6_min_sim_fps clamped to its supported range [10, 30]. Single source for
|
||||
// the low-fps floor shared by the frame-delta clamp (ac6DeltaPrecisionHook)
|
||||
// and the physics step cap (StepRatio) - the two must saturate at the same
|
||||
// framerate or the dynamics desync from the kinematics below 30fps.
|
||||
double ClampedMinSimFps();
|
||||
|
||||
// True while an in-engine cutscene (NU::FW::IngameCinematics, driven by
|
||||
// CAce6DemoManager::Exec) has ticked within the last decay window. Used by the
|
||||
// timing hooks to suspend the 60fps unlock so cutscenes play at native cadence.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user