diff --git a/docs/FRAME_GENERATION.md b/docs/FRAME_GENERATION.md index 172eebec..da094643 100644 --- a/docs/FRAME_GENERATION.md +++ b/docs/FRAME_GENERATION.md @@ -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 diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 11bd3664..61178d9e 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -9fb6c5d611491719d009c97f5703ae028cf91bd91f1499db0011c7f56f952cfa +c377987c91eadb52bd563bf8acf162bf5af5249cb7337d2404cdbd6171d6f9f5 diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index defabaa7..ba0903db 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -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(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) diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index 21f7d88e..d09bba7b 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -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"); diff --git a/runtime/include/frame_gen.h b/runtime/include/frame_gen.h index 98c582d8..29b6adf6 100644 --- a/runtime/include/frame_gen.h +++ b/runtime/include/frame_gen.h @@ -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); diff --git a/runtime/src/frame_gen.c b/runtime/src/frame_gen.c index 3148efb3..a512387b 100644 --- a/runtime/src/frame_gen.c +++ b/runtime/src/frame_gen.c @@ -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; diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 215c8abd..3595b36d 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -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; diff --git a/runtime/tests/test_frame_gen.c b/runtime/tests/test_frame_gen.c index 5352eed2..718f4f63 100644 --- a/runtime/tests/test_frame_gen.c +++ b/runtime/tests/test_frame_gen.c @@ -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();