Add dynamic vblank pacing for the FPS unlock (ac6_dynamic_vblank)

With the unlock forcing single-vblank presents, frame-locked content (menus,
hangar, in-engine cutscenes) free-runs far too fast while only gameplay should.
Pace the guest vblank dynamically: free-run only while the 3D world is being
rendered, otherwise force the native 60Hz.

"World is rendering" is detected from the GPU side - the command processor
stamps a heartbeat (NotifyWorldCompositorDraw) whenever a draw uses the
world/effects compositor pixel shader (ucode 17e5e4ac3e713245), which runs
every frame of 3D but never in the 2D front-end. The present timing hook then
sets a guest-vblank Hz override via the new GraphicsSystem hook. Only engages
under the unlock; ac6_dynamic_vblank=false restores the plain always-on unlock.
This commit is contained in:
Dipshet
2026-07-09 22:38:29 +03:00
parent 175480efb7
commit 2b52e5e2c8
5 changed files with 123 additions and 2 deletions
+71 -1
View File
@@ -6,6 +6,8 @@
#include <chrono>
#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");
@@ -14,6 +16,12 @@ REXCVAR_DEFINE_BOOL(ac6_timing_hooks_enabled, true, "AC6",
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");
REXCVAR_DEFINE_BOOL(ac6_dynamic_vblank, true, "AC6",
"With the FPS unlock active, pace frame-locked content (menus, "
"cutscenes, pause) at the native 60Hz guest vblank while gameplay "
"free-runs at the configured rate. Gameplay is detected via the "
"world-compositor draw heartbeat; cutscenes via the cinematic "
"hooks.");
using Clock = std::chrono::steady_clock;
@@ -34,15 +42,38 @@ std::atomic<int64_t> g_last_cinematic_tick_ms{INT64_MIN};
// auto-releases shortly after it ends.
constexpr int64_t kCinematicDecayMs = 100;
// Wall-clock ms of the last draw using the world/effects compositor pixel
// shader (stamped by the GPU command processor via NotifyWorldCompositorDraw).
// The compositor runs every frame the 3D world renders and never in the 2D
// front-end, so its freshness distinguishes free-runnable gameplay from
// frame-locked menus/hangar. (The delta-time hook was tried first as this
// signal, but it lives in the frame layer and ticks in menus too.)
// INT64_MIN = never drawn.
std::atomic<int64_t> g_last_world_draw_ms{INT64_MIN};
constexpr int64_t kWorldDrawDecayMs = 300;
int64_t NowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
Clock::now().time_since_epoch())
.count();
}
bool IsWorldRenderActive() {
const int64_t last = g_last_world_draw_ms.load(std::memory_order_relaxed);
if (last == INT64_MIN) {
return false;
}
return (NowMs() - last) <= kWorldDrawDecayMs;
}
bool AreTimingHooksActive() {
// The world-render gate only applies under dynamic vblank pacing: it exists
// 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) &&
!ac6::IsCinematicActive();
!ac6::IsCinematicActive() &&
(!REXCVAR_GET(ac6_dynamic_vblank) || IsWorldRenderActive());
}
} // namespace
@@ -57,6 +88,10 @@ bool TimingHooksActive() {
return AreTimingHooksActive();
}
bool WorldRenderActiveRecently() {
return IsWorldRenderActive();
}
bool IsCinematicActive() {
if (!REXCVAR_GET(ac6_cutscene_clamp)) {
return false;
@@ -92,6 +127,24 @@ void ac6DeltaDivisorHook(PPCRegister& r29) {
void ac6PresentTimingHook(PPCRegister& /*r31*/) {
// ac6::d3d::OnFrameBoundary(); // MOVED TO GPU THREAD
// Dynamic vblank pacing: free-run only while the 3D world is rendering; pace
// 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 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.
double override_hz = 0.0;
if (dynamic_pacing && !free_running) {
override_hz = 60.0;
}
rex::graphics::GraphicsSystem::SetGuestVblankHzOverride(override_hz);
const auto now = Clock::now();
if (g_frame_start.time_since_epoch().count() != 0) {
const double frame_time_ms =
@@ -102,6 +155,19 @@ void ac6PresentTimingHook(PPCRegister& /*r31*/) {
g_frame_count.fetch_add(1, std::memory_order_relaxed);
}
g_frame_start = now;
// Log the first handful of pacing transitions.
static double last_log_key = -0.5;
static uint32_t transition_logs = 0;
if (unlock && override_hz != last_log_key && transition_logs < 32) {
++transition_logs;
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;
}
void ac6CinematicTickHook(PPCRegister& /*r3*/) {
@@ -121,4 +187,8 @@ FrameStats GetFrameStats() {
g_frame_count.load(std::memory_order_relaxed)};
}
void NotifyWorldCompositorDraw() {
g_last_world_draw_ms.store(NowMs(), std::memory_order_relaxed);
}
} // namespace ac6