Smooth motion: plan the frames the clock shows; vrr in settings.toml; atomics

- settings.toml parses [video] vsync = "vrr" and a launcher save writes it
  back as "vrr" (was dropped / saved as "on"); every mode round-trips in
  video_enhancement_settings_test.
- fg_slots counts the display intervals the clock (fg_clock_phase) fills,
  with the same 0.985 phase limit. fg_plan_hz and the dynamic resolution
  share use it: 59.94 Hz content on a 60 Hz panel (2.002 intervals) plans
  and budgets one in-between frame, as shown, not two.
- The real share read on the emulation thread uses atomics: s_fg_flip_vb,
  s_fg_swap_ema and a published copy of the generated-frame cost estimate.
- fg_hud_lerp accepts a match at the 8th 2D triangle of the window.
- docs/FRAME_GENERATION.md: the plan's slots and the vrr mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-10-07 22:34:36 -07:00
parent 6f96857b85
commit 8948e9584c
8 changed files with 141 additions and 26 deletions
+13 -2
View File
@@ -77,8 +77,10 @@ has measured time to spare.
that lead to it: up to `n/(n+1)` of a game frame later than without
generation (R4 at 30 Hz on 120 Hz: 25 ms at n = 3, 17 ms at n = 1).
Extrapolation (no delay, guessed motion) is not offered.
- **Plan.** `n = fg_plan(...)`: slots = round(flip interval × refresh)
(R4 on 120 Hz: 4), limited to what fits in 85 % of the interval after the
- **Plan.** `n = fg_plan(...)`: slots = `fg_slots`, the display intervals
the clock actually fills (in-between frames at phase k / (flip × refresh)
below 0.985, plus the real one: R4 on 120 Hz: 4; 59.94 Hz content on a
60 Hz panel, 2.002 intervals: 2, one in-between frame), limited to what fits in 85 % of the interval after the
real frames' render-thread CPU time and one swap, at the measured cost of a
generated frame (CPU, GPU via `GL_TIME_ELAPSED`, plus a swap). On macOS a
real frame's `TIME_ELAPSED` span reads close to the whole interval (it
@@ -96,6 +98,15 @@ has measured time to spare.
- **Sync points** show the waiting real frame and restart the lists at the
next flip.
- **Variable refresh.** `[video] vsync = "vrr"` (game.toml, or the
player's settings.toml, which a launcher save writes back as `"vrr"`):
for G-Sync / FreeSync / ProMotion panels. Each frame is presented when it
is ready (swap interval 0); Smooth motion targets the panel's maximum
refresh at the current size and never exceeds it. The other modes are
`"on"` (default), `"immediate"` (`"off"`) and `"adaptive"`.
`PSX_HOST_REFRESH_HZ` / `PSX_HOST_REFRESH_MAX_HZ` simulate a display in
headless tests.
## Limits
- Only GTE-projected triangles move; sprites, lines and fills are the
+1 -1
View File
@@ -1 +1 @@
9fb6c5d611491719d009c97f5703ae028cf91bd91f1499db0011c7f56f952cfa
c377987c91eadb52bd563bf8acf162bf5af5249cb7337d2404cdbd6171d6f9f5
+3 -1
View File
@@ -2893,6 +2893,7 @@ UserSettings load_user_settings(const fs::path& path) {
if (m == "on" || m == "vsync") { s.vsync = 1; s.has_vsync = true; }
else if (m == "off" || m == "immediate"){ s.vsync = 0; s.has_vsync = true; }
else if (m == "adaptive") { s.vsync = -1; s.has_vsync = true; }
else if (m == "vrr") { s.vsync = 2; s.has_vsync = true; }
});
if (v.contains("frame_interpolation")) try_get([&]{
s.frame_interpolation = toml::find<bool>(v, "frame_interpolation");
@@ -3252,7 +3253,8 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) {
if (s.has_frame_generation)
f << "frame_generation = " << (s.frame_generation ? "true" : "false") << "\n";
if (s.has_vsync)
f << "vsync = \"" << (s.vsync == 0 ? "immediate" : s.vsync < 0 ? "adaptive" : "on") << "\"\n";
f << "vsync = \"" << (s.vsync == 0 ? "immediate" : s.vsync < 0 ? "adaptive"
: s.vsync == 2 ? "vrr" : "on") << "\"\n";
if (s.has_frame_interpolation)
f << "frame_interpolation = " << (s.frame_interpolation ? "true" : "false") << "\n";
if (s.has_frame_interpolation_fps)
@@ -354,6 +354,25 @@ static void test_pipeline_user_settings() {
check(back.has_frame_generation && back.frame_generation, "frame_generation round-trips");
fs::remove(p);
/* [video] vsync: every mode parses from settings.toml and a launcher
* save writes back the same mode (vrr = 2 included). */
const struct { const char *text; int value; } vs[] = {
{ "on", 1 }, { "immediate", 0 }, { "adaptive", -1 }, { "vrr", 2 } };
for (const auto &m : vs) {
p = write_temp("psxrecomp_vsync_read.toml",
std::string("[video]\nvsync = \"") + m.text + "\"\n");
auto vr = PSXRecompV4::load_user_settings(p);
check(vr.has_vsync && vr.vsync == m.value, "settings vsync parses every mode");
fs::remove(p);
PSXRecompV4::UserSettings vo;
vo.has_vsync = true; vo.vsync = m.value;
p = fs::temp_directory_path() / "psxrecomp_vsync_rt.toml";
check(PSXRecompV4::save_user_settings(p, vo), "save_user_settings writes vsync");
auto vb = PSXRecompV4::load_user_settings(p);
check(vb.has_vsync && vb.vsync == m.value, "settings vsync round-trips every mode");
fs::remove(p);
}
PSXRecompV4::UserSettings none;
p = fs::temp_directory_path() / "psxrecomp_pipe_none.toml";
check(PSXRecompV4::save_user_settings(p, none), "save_user_settings writes defaults");
+5 -1
View File
@@ -148,7 +148,11 @@ double fg_clock_phase(uint64_t since_real_ns, uint64_t step_ns, uint64_t flip_ns
/* HUD motion between two frames' 2D triangles (frame_gen.c). */
void fg_hud_lerp(const FgPrimList *L, const FgPrimList *O, double u, float *x, float *y,
float max_px);
/* The refresh fg_plan sees: rounded up to whole slots per game frame. */
/* fg_slots: display intervals a game frame `flip_s` long fills on a
* `refresh_hz` display as the clock (fg_clock_phase) schedules it: the
* in-between frames it shows plus the real one. fg_plan_hz: the refresh
* fg_plan sees, so its slots are exactly fg_slots. */
int fg_slots(double flip_s, double refresh_hz);
double fg_plan_hz(double flip_s, double refresh_hz);
/* The next present time on the global grid after presenting at `now`. */
uint64_t fg_next_due(uint64_t due, uint64_t now, uint64_t step_ns);
+25 -8
View File
@@ -661,12 +661,25 @@ double fg_step_s(double flip_s, double refresh_hz, int n) {
return flip_s / (double)(n + 1);
}
/* The last phase fg_clock_phase still generates; at or past it the next
* real frame is due. Shared with fg_slots so the plan (and the dynamic
* resolution budget) counts exactly the frames the clock shows. */
#define FG_PHASE_LAST 0.985
int fg_slots(double flip_s, double refresh_hz) {
if (flip_s <= 0.0 || refresh_hz <= 0.0) return 0;
/* The clock generates at phase k / (flip_s * refresh_hz), k = 1, 2, ...
* while below FG_PHASE_LAST: 59.94 Hz content on 60 Hz (2.002
* intervals) shows one, at 100 Hz (3.34) three, on 120 Hz (4.004) three. */
const double step = 1.0 / (flip_s * refresh_hz);
int k = 1;
while (k < 64 && (double)k * step < FG_PHASE_LAST) k++;
return k; /* the shown in-between frames + the real one */
}
double fg_plan_hz(double flip_s, double refresh_hz) {
if (flip_s <= 0.0 || refresh_hz <= 0.0) return refresh_hz;
/* Room for every display interval a game frame can hold (3.34 at
* 100 Hz -> 4 slots); the clock drops the one that does not fit. */
double slots = ceil(flip_s * refresh_hz - 1e-6);
return slots / flip_s;
return (double)fg_slots(flip_s, refresh_hz) / flip_s;
}
uint64_t fg_next_due(uint64_t due, uint64_t now, uint64_t step_ns) {
@@ -684,7 +697,7 @@ double fg_clock_phase(uint64_t since_real_ns, uint64_t step_ns, uint64_t flip_ns
* from now, so the last in-between frame of a game frame lands close
* to 1 (a quarter-interval margin here dropped it at every refresh). */
(void)step_ns;
if (t >= 0.985) return 0.0;
if (t >= FG_PHASE_LAST) return 0.0;
return t;
}
@@ -803,6 +816,7 @@ int fg_ceiling_get(FgCeiling *c, double now) {
* little (at most max_px per corner: a tachometer needle, a sliding panel)
* are placed at fraction u of the way from L to O. Anything that changed
* what it draws (digits) or jumped stays as L drew it. */
#define FG_HUD_WINDOW 8u
void fg_hud_lerp(const FgPrimList *L, const FgPrimList *O, double u, float *x, float *y,
float max_px) {
if (!L || !O || u <= 0.0 || u >= 1.0) return;
@@ -810,14 +824,17 @@ void fg_hud_lerp(const FgPrimList *L, const FgPrimList *O, double u, float *x, f
for (uint32_t j = 0; j < L->n; j++) {
const FgPrim *a = &L->v[j];
if (a->vid[0] || a->vid[1] || a->vid[2]) continue;
/* The match is searched among the next FG_HUD_WINDOW 2D triangles of
* O (a match on the last of them counts). */
uint32_t m = o, seen = 0;
for (; m < O->n && seen < 8u; m++) {
int found = 0;
for (; m < O->n && seen < FG_HUD_WINDOW; m++) {
const FgPrim *b = &O->v[m];
if (b->vid[0] || b->vid[1] || b->vid[2]) continue;
seen++;
if (b->key == a->key && b->view == a->view) break;
if (b->key == a->key && b->view == a->view) { found = 1; break; }
}
if (m >= O->n || seen >= 8u) continue;
if (!found) continue;
const FgPrim *b = &O->v[m];
o = m + 1;
int ok = 1, moved = 0;
+26 -11
View File
@@ -10158,7 +10158,10 @@ static int s_fg_have_last = 0, s_fg_last_dx = 0, s_fg_last_dy = 0;
* when drawing moves to another buffer (games flip at a VBlank after they
* start the next frame, so a flip is not a list boundary). */
static int s_fg_buf[4][4], s_fg_nbuf = 0, s_fg_cur_buf = -1;
static int s_fg_vblanks = 0, s_fg_flip_vb = 1; /* presents per flip */
static int s_fg_vblanks = 0;
/* Presents per flip. Written by the render thread, read by the emulation
* thread (gl_renderer_frame_gen_real_share): atomic. */
static _Atomic int s_fg_flip_vb = 1;
/* The camera fit of the pair a schedule draws from and each newer vertex's
* placement (fg_cam_fit), positions at the current phase, and the pending
* vertex sources of the next triangle (RTH_FG_SRC: ids, integer x/y,
@@ -10241,10 +10244,14 @@ static int fg_recently_generated(void) {
/* Render thread: every swap's wall time. A swap waits for the compositor
* (and on a busy WindowServer for its round trip); each generated frame adds
* one, so the plan counts it. */
static double s_fg_swap_ema = 0.0;
/* Written by the render thread, read by the emulation thread
* (gl_renderer_frame_gen_real_share): atomic, as is the published copy of
* the generated-frame cost estimate (s_fg_cost.ema, render thread only). */
static _Atomic double s_fg_swap_ema = 0.0;
static _Atomic double s_fg_cost_ema_pub = 0.0;
static void fg_note_swap(uint64_t ns) {
const double c = (double)ns * 1e-9;
s_fg_swap_ema = s_fg_swap_ema > 0.0 ? s_fg_swap_ema * 0.9 + c * 0.1 : c;
const double c = (double)ns * 1e-9, e = atomic_load(&s_fg_swap_ema);
atomic_store(&s_fg_swap_ema, e > 0.0 ? e * 0.9 + c * 0.1 : c);
}
/* A real VBlank frame's render-thread cost, for the plan: its CPU time. Its
@@ -11017,6 +11024,7 @@ static void fg_gen_cost_poll(void) {
p_glGetQueryObjectui64v(s_fg_q[i], GL_QUERY_RESULT, &ns);
double c = (double)(ns > s_fg_q_cpu[i] ? ns : s_fg_q_cpu[i]) * 1e-9;
fg_cost_add(fg_cost(), c, s_fg_fit_s);
atomic_store(&s_fg_cost_ema_pub, fg_cost()->ema);
s_fg_gen_gpu_ms = (double)ns * 1e-6;
s_fg_gen_cpu_ms = (double)s_fg_q_cpu[i] * 1e-6;
s_fg_qt++;
@@ -11220,8 +11228,10 @@ static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stal
}
if (s_fg_pending) fg_flush();
s_fg_flips++;
s_fg_flip_vb = s_fg_have_last ? s_fg_vblanks : 1;
if (s_fg_flip_vb > 4) s_fg_flip_vb = 4;
{
const int vb = s_fg_have_last ? s_fg_vblanks : 1;
atomic_store(&s_fg_flip_vb, vb > 4 ? 4 : vb);
}
s_fg_vblanks = 0;
s_fg_have_last = 1; s_fg_last_dx = disp[0]; s_fg_last_dy = disp[1];
/* A game that flips right after drawing: the capturing list is this
@@ -11269,7 +11279,7 @@ static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stal
FG_BRK_INIT();
const double ghz = atomic_load(&s_fg_guest_hz), rhz = atomic_load(&s_fg_refresh_hz);
const double flip_s = (double)s_fg_flip_vb / (ghz > 1.0 ? ghz : 59.94);
const int slots = (int)floor(flip_s * rhz + 0.5);
const int slots = fg_slots(flip_s, rhz); /* as the clock shows them */
s_fg_last_slots = slots;
/* What one generated frame may cost (its swap included) to fit. */
const double real_s = s_fg_real_ema * (double)s_fg_flip_vb + s_fg_swap_ema;
@@ -11293,6 +11303,7 @@ static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stal
if (s_fg_force) n = slots > 1 ? slots - 1 : 1;
else if (fg_breaker_open(&s_fg_brk, now) && !stale && !held) {
const double est = fg_cost_estimate(fg_cost(), now, s_fg_fit_s);
atomic_store(&s_fg_cost_ema_pub, fg_cost()->ema);
n = fg_plan(flip_s, fg_plan_hz(flip_s, rhz), real_s, est > 0.0 ? est + s_fg_swap_ema : 0.0, 0.85,
fg_ceiling_get(fg_ceil(), now));
}
@@ -11413,10 +11424,14 @@ void gl_renderer_fg_source(const uint32_t id[3], const int32_t pc[9], const int3
double gl_renderer_frame_gen_real_share(void) {
if (!s_fg_on || !rth_record_mode()) return 1.0;
const double ghz = atomic_load(&s_fg_guest_hz), rhz = atomic_load(&s_fg_refresh_hz);
const double flip_s = (double)s_fg_flip_vb / (ghz > 1.0 ? ghz : 59.94);
if (flip_s <= 0.0 || rhz <= 0.0 || s_fg_flip_vb < 2) return 1.0;
const int slots = (int)ceil(flip_s * rhz - 1e-6);
const double g = s_fg_cost.ema > 0.0 ? s_fg_cost.ema + s_fg_swap_ema : 0.0;
const int vb = atomic_load(&s_fg_flip_vb);
const double flip_s = (double)vb / (ghz > 1.0 ? ghz : 59.94);
if (flip_s <= 0.0 || rhz <= 0.0 || vb < 2) return 1.0;
/* The frames the clock actually shows (fg_slots): 59.94 Hz content on a
* 60 Hz panel is 2.002 intervals, one in-between frame, not two. */
const int slots = fg_slots(flip_s, rhz);
const double ema = atomic_load(&s_fg_cost_ema_pub);
const double g = ema > 0.0 ? ema + atomic_load(&s_fg_swap_ema) : 0.0;
if (slots < 2 || g <= 0.0) return 1.0;
double share = (0.85 * flip_s - (double)(slots - 1) * g) / flip_s;
if (share < 0.35) share = 0.35;
+49 -2
View File
@@ -325,8 +325,14 @@ static void test_any_rate(void) {
const int n = fg_plan(flip_s, fg_plan_hz(flip_s, hz), 0.001, 0.0005, 0.85, 64);
uint64_t due = 0, last = 0, min_gap = UINT64_MAX;
unsigned presents = 0, frames = 300, gens = 0;
int phase_ok = 1;
int phase_ok = 1, shown_ok = 1;
unsigned max_shown = 0;
/* A whole number of intervals a game frame (59.94 Hz content on
* 60 / 120 / 240 / 360 Hz): every game frame shows the same count. */
const double x = flip_s * hz;
const int whole = fabs(x - floor(x + 0.5)) < 0.01;
for (unsigned f = 0; f < frames; f++) {
const unsigned gens_before = gens;
const uint64_t t0 = (uint64_t)f * flip_ns, next = t0 + flip_ns;
if (due < t0 || due > t0 + step) due = t0;
double prev_t = 0.0;
@@ -349,6 +355,10 @@ static void test_any_rate(void) {
due = fg_next_due(due, now, step);
break;
}
/* Every planned in-between frame is shown (the plan, and the
* dynamic resolution budget built on it, count no dropped one). */
if (f > 0 && gens - gens_before > max_shown) max_shown = gens - gens_before;
if (f > 0 && whole && gens - gens_before != (unsigned)n) shown_ok = 0;
}
const double secs = frames * flip_s, rate = presents / secs;
char what[96];
@@ -356,10 +366,29 @@ static void test_any_rate(void) {
check(phase_ok && rate > hz * 0.95 && rate < hz * 1.03, what);
snprintf(what, sizeof what, "%.0f Hz: no two presents closer than one interval", hz);
check(min_gap + 1000u >= step, what);
(void)gens;
/* The plan (and the dynamic resolution budget built on it) is the
* most frames the clock shows in a game frame: none planned is
* dropped every frame, none shown is unbudgeted. */
snprintf(what, sizeof what, "%.0f Hz: the clock shows up to the %d planned frames", hz, n);
check(max_shown == (unsigned)n && shown_ok, what);
}
}
/* 59.94 Hz content (two VBlanks a game frame) on 60 / 120 Hz panels: 2.002
* and 4.004 intervals. The clock shows 1 / 3 in-between frames; the plan
* and the dynamic resolution share count exactly those (fg_slots). */
static void test_slots_5994(void) {
const double flip_s = 2.0 / 59.94;
check(fg_slots(flip_s, 60.0) == 2, "59.94 on 60 Hz: 2 slots (1 in-between frame)");
check(fg_slots(flip_s, 120.0) == 4, "59.94 on 120 Hz: 4 slots");
check(fg_slots(flip_s, 100.0) == 4, "59.94 on 100 Hz: 4 slots (3.34 intervals)");
check(fg_slots(2.0 / 60.0, 60.0) == 2, "60 on 60 Hz: 2 slots");
check(fg_plan(flip_s, fg_plan_hz(flip_s, 60.0), 0.001, 0.0005, 0.85, 64) == 1,
"59.94 on 60 Hz: one in-between frame planned");
check(fg_plan(flip_s, fg_plan_hz(flip_s, 120.0), 0.001, 0.0005, 0.85, 64) == 3,
"59.94 on 120 Hz: three planned");
}
static void test_hud_lerp(void) {
FgPrimList L = {0}, O = {0};
FgPrim p; memset(&p, 0, sizeof p);
@@ -375,10 +404,28 @@ static void test_hud_lerp(void) {
check(x[2] == 20.0f && x[0] == 10.0f, "HUD needle halfway between the frames");
check(x[5] == 15.0f, "a changed digit stays as drawn");
fg_prims_free(&L); fg_prims_free(&O);
/* The match window is 8 2D triangles of the other frame, the 8th
* included: a needle found there still moves, one at the 9th does not. */
for (int at = 8; at <= 9; at++) {
FgPrimList A = {0}, B = {0};
memset(&p, 0, sizeof p);
p.key = 7; p.view = 1;
p.x[0] = 10; p.y[0] = 10; p.x[1] = 20; p.y[1] = 10; p.x[2] = 15; p.y[2] = 30;
fg_prims_add(&A, &p);
for (int i = 1; i < at; i++) { FgPrim o = p; o.key = 100 + i; fg_prims_add(&B, &o); }
FgPrim m = p; m.x[2] = 25; fg_prims_add(&B, &m);
float ax[3] = { 10, 20, 15 }, ay[3] = { 10, 10, 30 };
fg_hud_lerp(&A, &B, 0.5, ax, ay, 24.0f);
check(at == 8 ? ax[2] == 20.0f : ax[2] == 15.0f,
at == 8 ? "a match at the 8th 2D triangle moves" : "a match at the 9th stays");
fg_prims_free(&A); fg_prims_free(&B);
}
}
int main(void) {
test_hud_lerp();
test_slots_5994();
test_any_rate();
test_ceiling();
test_cost();