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
+12
View File
@@ -29,6 +29,18 @@ bool TimingHooksActive();
// timing hooks to suspend the 60fps unlock so cutscenes play at native cadence.
bool IsCinematicActive();
// Called by the GPU command processor whenever a draw uses the world/effects
// compositor pixel shader (guest ucode 17e5e4ac3e713245). The compositor runs
// once per frame whenever the 3D world is being rendered and never in the 2D
// front-end (menus, hangar), so its freshness is the "gameplay world active"
// signal for the dynamic FPS pacing.
void NotifyWorldCompositorDraw();
// True while a world-compositor draw happened within the last decay window
// (i.e. the 3D world is being rendered - gameplay or in-engine cutscene, not
// the 2D front-end). Same signal the dynamic FPS pacing uses.
bool WorldRenderActiveRecently();
} // namespace ac6
bool ac6FlipIntervalHook();
@@ -102,6 +102,13 @@ class GraphicsSystem : public system::IGraphicsSystem {
return last_vblank_interrupt_guest_tick_.load(std::memory_order_acquire);
}
// Per-game override of the guest vblank rate, in Hz. 0 = no override (use
// the vsync/guest_vblank_sync_to_refresh cvar logic). Lets game-specific
// timing hooks pace frame-locked content (menus, cinematics) at the native
// rate while gameplay free-runs. Process-wide, not per-instance.
static void SetGuestVblankHzOverride(double hz);
static double GetGuestVblankHzOverride();
bool Save(::rex::stream::ByteStream* stream);
bool Restore(::rex::stream::ByteStream* stream);
@@ -35,6 +35,7 @@
#include "../../../../../src/ac6_backend_fixes/ac6_backend_hooks.h"
#include "../../../../../src/ac6_native_graphics.h"
#include "../../../../../src/render_hooks.h"
REXCVAR_DEFINE_BOOL(d3d12_bindless, true, "GPU/D3D12", "Use bindless resources where available")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
@@ -2660,6 +2661,13 @@ bool D3D12CommandProcessor::IssueDraw(xenos::PrimitiveType primitive_type, uint3
bool memexport_used_pixel = pixel_shader && (pixel_shader->memexport_eM_written() != 0);
bool memexport_used = memexport_used_vertex || memexport_used_pixel;
// AC6: the world/effects compositor draws once per frame whenever the 3D
// world renders (never in the 2D front-end) - stamp it as the "gameplay
// world active" signal for the dynamic FPS pacing.
if (pixel_shader && pixel_shader->ucode_data_hash() == UINT64_C(0x17e5e4ac3e713245)) {
ac6::NotifyWorldCompositorDraw();
}
if (!BeginSubmission(true)) {
return false;
}
+25 -1
View File
@@ -200,7 +200,8 @@ X_STATUS GraphicsSystem::SetupGuestGpu(runtime::FunctionDispatcher* function_dis
vsync_worker_running_ = true;
vsync_worker_thread_ = system::object_ref<system::XHostThread>(
new system::XHostThread(kernel_state_, 128 * 1024, 0, [this, vsync_interval_ticks,
no_vsync_interval_ticks]() {
no_vsync_interval_ticks,
guest_tick_frequency]() {
uint64_t last_frame_time = chrono::Clock::QueryGuestTickCount();
while (vsync_worker_running_) {
uint64_t current_time = chrono::Clock::QueryGuestTickCount();
@@ -208,6 +209,17 @@ X_STATUS GraphicsSystem::SetupGuestGpu(runtime::FunctionDispatcher* function_dis
? vsync_interval_ticks
: (REXCVAR_GET(vsync) ? vsync_interval_ticks
: no_vsync_interval_ticks);
double vblank_hz_override = GetGuestVblankHzOverride();
if (vblank_hz_override > 0.0) {
interval_ticks = std::max(
uint64_t(1), uint64_t(double(guest_tick_frequency) / vblank_hz_override));
}
// Re-anchor when far behind so a shrinking interval (an override
// switching from a paced rate to a much faster one) or a long stall
// does not burst a backlog of MarkVblank calls in one wake.
if (current_time - last_frame_time >= interval_ticks * 4) {
last_frame_time = current_time - interval_ticks;
}
while (current_time - last_frame_time >= interval_ticks) {
MarkVblank();
last_frame_time += interval_ticks;
@@ -399,6 +411,18 @@ void GraphicsSystem::DispatchInterruptCallback(uint32_t source, uint32_t cpu) {
rex::countof(args));
}
namespace {
std::atomic<double> g_guest_vblank_hz_override{0.0};
} // namespace
void GraphicsSystem::SetGuestVblankHzOverride(double hz) {
g_guest_vblank_hz_override.store(hz, std::memory_order_relaxed);
}
double GraphicsSystem::GetGuestVblankHzOverride() {
return g_guest_vblank_hz_override.load(std::memory_order_relaxed);
}
void GraphicsSystem::MarkVblank() {
// TODO: Enable profiling once ported
// SCOPE_profile_cpu_f("gpu");