mirror of
https://github.com/sal063/AC6_recomp
synced 2026-07-29 15:44:12 -04:00
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:
+71
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user