From 88f07bc9e3285fcc18a5ed2a30d5486bfea398b3 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 27 Aug 2026 12:46:45 -0700 Subject: [PATCH] runtime: keep temporal blending on the render thread --- RELEASE_NOTES.md | 11 + runtime/CMakeLists.txt | 13 + runtime/include/frame_interpolation.h | 40 ++ runtime/include/gpu_gl_renderer.h | 8 +- runtime/include/mod_plugins.h | 8 +- runtime/runtime.cmake | 1 + runtime/src/frame_interpolation.c | 84 ++++ runtime/src/gpu_gl_renderer.c | 455 +++++++----------- runtime/src/main.cpp | 18 +- ...t_frame_interpolation_context_ownership.py | 27 ++ .../test_frame_interpolation_display_rate.py | 6 +- .../tests/test_frame_interpolation_schedule.c | 75 +++ 12 files changed, 446 insertions(+), 300 deletions(-) create mode 100644 runtime/include/frame_interpolation.h create mode 100644 runtime/src/frame_interpolation.c create mode 100644 runtime/tests/test_frame_interpolation_context_ownership.py create mode 100644 runtime/tests/test_frame_interpolation_schedule.c diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 942b1ae9..19cb7a5f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,14 @@ +# Unreleased + +- Replaces OpenGL temporal frame blending's shared second context and + presentation thread with a deadline-driven path on the renderer's original + thread and context. All OpenGL swaps now have one owner, no application mutex + spans pacing or driver work, and the stock path is unchanged when disabled. +- Documents the feature as temporal blending rather than motion-vector frame + generation and adds cadence plus context-ownership regression tests. + +--- + # PSXRecomp OpenBIOS integration OpenBIOS is now a first-class, redistributable runtime backend. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ca4fae3b..56997453 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -357,6 +357,16 @@ if(BUILD_TESTING) target_include_directories(frame_pacing_test PRIVATE include) add_test(NAME frame_pacing_test COMMAND frame_pacing_test) + add_executable(frame_interpolation_schedule_test + tests/test_frame_interpolation_schedule.c + src/frame_interpolation.c) + target_include_directories(frame_interpolation_schedule_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(frame_interpolation_schedule_test PRIVATE m) + endif() + add_test(NAME frame_interpolation_schedule_test + COMMAND frame_interpolation_schedule_test) + add_executable(gpu_primitive_reject_test tests/test_gpu_primitive_reject.c) target_include_directories(gpu_primitive_reject_test PRIVATE include) add_test(NAME gpu_primitive_reject_test COMMAND gpu_primitive_reject_test) @@ -464,6 +474,9 @@ if(BUILD_TESTING) find_package(Python3 COMPONENTS Interpreter) if(Python3_Interpreter_FOUND) + add_test(NAME frame_interpolation_context_ownership_test + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_interpolation_context_ownership.py) add_test(NAME mod_bezel_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_mod_bezel.py) diff --git a/runtime/include/frame_interpolation.h b/runtime/include/frame_interpolation.h new file mode 100644 index 00000000..a60e453d --- /dev/null +++ b/runtime/include/frame_interpolation.h @@ -0,0 +1,40 @@ +#ifndef PSX_FRAME_INTERPOLATION_H +#define PSX_FRAME_INTERPOLATION_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct FrameInterpolationSchedule { + double source_deadline; + double next_present_deadline; + double frame_start; + double frame_end; + double target_period; +} FrameInterpolationSchedule; + +/* Plan one stock guest-frame interval. Presentation deadlines remain anchored + * across calls so guest work consumes part of the interval instead of slowing + * the simulation. Returns zero when the rates or host clock are invalid. */ +int frame_interpolation_schedule_begin(FrameInterpolationSchedule *schedule, + uint64_t now, uint64_t frequency, + double source_hz, double target_hz); + +/* Return each presentation deadline in the current source interval. Alpha is + * the temporal blend position from the previous completed frame to the current + * one. Deadlines missed by more than one output period are coalesced. */ +int frame_interpolation_schedule_next(FrameInterpolationSchedule *schedule, + uint64_t now, uint64_t *deadline, + float *alpha); + +uint64_t frame_interpolation_schedule_end( + const FrameInterpolationSchedule *schedule); +void frame_interpolation_schedule_reset(FrameInterpolationSchedule *schedule); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index e04ea5f9..9f64b4ab 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -23,11 +23,13 @@ int gl_renderer_init_context(struct SDL_Window *win); * Safe before or after context creation; applies live when a context exists. */ void gl_renderer_set_swap_interval(int interval); -/* Presentation-only frame interpolation. High-refresh sub-presents blend the - * two most recent stable display images; guest simulation timing is unchanged. */ +/* Presentation-only temporal blending. High-refresh sub-presents blend the two + * most recent stable display images on the owning render thread/context; this + * does not generate motion vectors or true intermediate object positions. */ void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, - int blend_mode); + double source_hz, int blend_mode); void gl_renderer_set_interpolation_suspended(int suspended); +int gl_renderer_interpolation_owns_cadence(void); void gl_renderer_interpolation_diag(int *enabled, int *suspended, int *history_frames, double *host_hz, double *target_hz, diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index bb66bcb7..a1e4645e 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -136,13 +136,15 @@ int psx_mod_set_native_vblank_rate(uint32_t frames_per_second); /* * Enable presentation-only frame interpolation while leaving guest VBlank, * game logic, timers, and audio at their stock cadence. The OpenGL presenter - * blends between completed guest frames at the requested output rate. + * temporally blends completed guest frames at the requested output rate on its + * owning render thread/context. It does not derive motion vectors or generate + * true intermediate object positions. * A value of zero follows the measured host-display refresh rate. */ int psx_mod_set_frame_interpolation(uint32_t frames_per_second); /* - * Choose how the OpenGL presenter combines completed frames. Linear is the - * legacy full-frame crossfade. Motion-adaptive retains interpolation for + * Choose how the OpenGL presenter combines completed frames. Linear is a + * full-frame crossfade. Motion-adaptive retains temporal blending for * small temporal changes but switches large changes cleanly to reduce the * double-image trails produced by moving objects. */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 4d3c0a4d..ef60dc37 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -262,6 +262,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/timers.c ${PSXRECOMP_ROOT}/runtime/src/interrupts.c ${PSXRECOMP_ROOT}/runtime/src/frame_pacing.c + ${PSXRECOMP_ROOT}/runtime/src/frame_interpolation.c ${PSXRECOMP_ROOT}/runtime/src/host_time.c ${PSXRECOMP_ROOT}/runtime/src/psx_fiber.c ${PSXRECOMP_ROOT}/runtime/src/sio.c diff --git a/runtime/src/frame_interpolation.c b/runtime/src/frame_interpolation.c new file mode 100644 index 00000000..8cbeb7ab --- /dev/null +++ b/runtime/src/frame_interpolation.c @@ -0,0 +1,84 @@ +#include "frame_interpolation.h" + +#include +#include + +void frame_interpolation_schedule_reset(FrameInterpolationSchedule *schedule) { + if (schedule) memset(schedule, 0, sizeof(*schedule)); +} + +int frame_interpolation_schedule_begin(FrameInterpolationSchedule *schedule, + uint64_t now, uint64_t frequency, + double source_hz, double target_hz) { + double source_period; + double target_period; + double now_d = (double)now; + + if (!schedule || frequency == 0 || !isfinite(source_hz) || + !isfinite(target_hz) || source_hz < 1.0 || target_hz < source_hz || + source_hz > 1000.0 || target_hz > 1000.0) { + if (schedule) frame_interpolation_schedule_reset(schedule); + return 0; + } + + source_period = (double)frequency / source_hz; + target_period = (double)frequency / target_hz; + if (source_period < 1.0 || target_period < 1.0) { + frame_interpolation_schedule_reset(schedule); + return 0; + } + + /* Re-anchor after startup, clock discontinuity, or a sustained overrun. + * Normal guest work lands after the prior deadline but before the next one; + * retaining the old anchor makes that work consume the pacing budget. */ + if (schedule->source_deadline <= 0.0 || + now_d + source_period < schedule->source_deadline || + now_d > schedule->source_deadline + source_period * 4.0) { + schedule->frame_start = now_d; + schedule->source_deadline = now_d + source_period; + schedule->next_present_deadline = now_d + target_period; + } else { + schedule->frame_start = schedule->source_deadline; + schedule->source_deadline += source_period; + } + + schedule->frame_end = schedule->source_deadline; + schedule->target_period = target_period; + + /* Coalesce stale output deadlines. Keep the newest missed deadline so an + * over-budget guest frame can update the window once, then resume cadence. */ + while (schedule->next_present_deadline + target_period <= now_d) + schedule->next_present_deadline += target_period; + return 1; +} + +int frame_interpolation_schedule_next(FrameInterpolationSchedule *schedule, + uint64_t now, uint64_t *deadline, + float *alpha) { + double d; + double span; + double a; + + if (!schedule || schedule->target_period <= 0.0 || + schedule->next_present_deadline > schedule->frame_end + 0.5) + return 0; + + d = schedule->next_present_deadline; + schedule->next_present_deadline += schedule->target_period; + if (d < (double)now) d = (double)now; + if (d > schedule->frame_end) d = schedule->frame_end; + + span = schedule->frame_end - schedule->frame_start; + a = span > 0.0 ? (d - schedule->frame_start) / span : 1.0; + if (a < 0.0) a = 0.0; + if (a > 1.0) a = 1.0; + if (deadline) *deadline = (uint64_t)(d + 0.5); + if (alpha) *alpha = (float)a; + return 1; +} + +uint64_t frame_interpolation_schedule_end( + const FrameInterpolationSchedule *schedule) { + if (!schedule || schedule->frame_end <= 0.0) return 0; + return (uint64_t)(schedule->frame_end + 0.5); +} diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index b9081d69..9d9100c4 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -65,8 +65,9 @@ #include "gpu.h" #include "gpu_render.h" -#include "gpu_sw_renderer.h" -#include "gpu_gl_renderer.h" +#include "gpu_sw_renderer.h" +#include "gpu_gl_renderer.h" +#include "frame_interpolation.h" #include "host_osd.h" #include "psx_savestate_menu.h" #include "host_time.h" @@ -117,8 +118,6 @@ #define PSXGL_CONSTANT_ALPHA 0x8003 #define PSXGL_UNPACK_ROW_LENGTH 0x0CF2 #define PSXGL_SRC1_ALPHA 0x8589 -#define PSXGL_SYNC_GPU_COMMANDS_COMPLETE 0x9117 -#define PSXGL_TIMEOUT_IGNORED 0xFFFFFFFFFFFFFFFFull #ifndef APIENTRY #define APIENTRY @@ -160,9 +159,6 @@ typedef void (APIENTRY *PFN_glBufferData)(GLenum, ptrdiff_t, const void *, GLe typedef void (APIENTRY *PFN_glVertexAttribPointer)(GLuint, GLint, GLenum, GLboolean, GLsizei, const void *); typedef void (APIENTRY *PFN_glEnableVertexAttribArray)(GLuint); typedef void (APIENTRY *PFN_glBindFragDataLocationIndexed)(GLuint, GLuint, GLuint, const char *); -typedef GLsync (APIENTRY *PFN_glFenceSync)(GLenum, GLbitfield); -typedef void (APIENTRY *PFN_glWaitSync)(GLsync, GLbitfield, GLuint64); -typedef void (APIENTRY *PFN_glDeleteSync)(GLsync); typedef void (APIENTRY *PFN_glGenFramebuffers)(GLsizei, GLuint *); typedef void (APIENTRY *PFN_glDeleteFramebuffers)(GLsizei, const GLuint *); typedef void (APIENTRY *PFN_glBindFramebuffer)(GLenum, GLuint); @@ -226,9 +222,6 @@ static PFN_glBufferData p_glBufferData; static PFN_glVertexAttribPointer p_glVertexAttribPointer; static PFN_glEnableVertexAttribArray p_glEnableVertexAttribArray; static PFN_glBindFragDataLocationIndexed p_glBindFragDataLocationIndexed; -static PFN_glFenceSync p_glFenceSync; -static PFN_glWaitSync p_glWaitSync; -static PFN_glDeleteSync p_glDeleteSync; static PFN_glGenFramebuffers p_glGenFramebuffers; static PFN_glDeleteFramebuffers p_glDeleteFramebuffers; static PFN_glBindFramebuffer p_glBindFramebuffer; @@ -281,9 +274,6 @@ static int load_modern_gl(void) { LOAD(p_glVertexAttribPointer, "glVertexAttribPointer"); LOAD(p_glEnableVertexAttribArray, "glEnableVertexAttribArray"); LOAD(p_glBindFragDataLocationIndexed, "glBindFragDataLocationIndexed"); - LOAD(p_glFenceSync, "glFenceSync"); - LOAD(p_glWaitSync, "glWaitSync"); - LOAD(p_glDeleteSync, "glDeleteSync"); LOAD(p_glGenFramebuffers, "glGenFramebuffers"); LOAD(p_glBindFramebuffer, "glBindFramebuffer"); LOAD(p_glDeleteFramebuffers, "glDeleteFramebuffers"); LOAD(p_glFramebufferTexture2D, "glFramebufferTexture2D"); @@ -327,9 +317,7 @@ static void gl_swap_with_osd(void); static GLint s_present_uTex = -1, s_present_uUvRect = -1; static GLint s_present_uTexSize = -1, s_present_uSharpScale = -1; static GLint s_present_uSharp = -1; -static GLuint s_interp_prog = 0, s_interp_tex[3]; -static GLsync s_interp_fence[3]; -static GLsync s_interp_draw_fence = NULL; +static GLuint s_interp_prog = 0, s_interp_tex[3]; static GLint s_interp_uPrev = -1, s_interp_uCurr = -1; static GLint s_interp_uAlpha = -1, s_interp_uUvRect = -1; static GLint s_interp_uBlendMode = -1; @@ -339,21 +327,18 @@ static int s_interp_blend_mode = 0; static int s_interp_prev = 0, s_interp_cur = 0; static int s_interp_w = 0, s_interp_h = 0, s_interp_linear = 0; static int s_interp_force_4_3 = 0, s_interp_source_path = -1; -static uint64_t s_interp_start = 0, s_interp_duration = 1; -static uint64_t s_interp_last_capture = 0, s_interp_swaps = 0; -static uint64_t s_interp_captures = 0; -static int s_interp_diag = 0; -static double s_interp_host_hz = 0.0; -static double s_interp_target_hz = 0.0; -static SDL_GLContext s_interp_ctx = NULL; -static SDL_Thread *s_interp_thread = NULL; -static SDL_mutex *s_interp_mutex = NULL; -static SDL_atomic_t s_interp_thread_run; -static GLuint s_interp_thread_vao = 0; -static void interp_reset_history(void); -static int interp_thread_main(void *opaque); -static int interp_present(void); -static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh); +static uint64_t s_interp_swaps = 0; +static uint64_t s_interp_captures = 0; +static int s_interp_diag = 0; +static double s_interp_host_hz = 0.0; +static double s_interp_target_hz = 0.0; +static double s_interp_source_hz = 0.0; +static FrameInterpolationSchedule s_interp_schedule; +static void interp_reset_history(void); +static int interp_present(float alpha); +static void interp_present_source_interval(void); +static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh); +static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh); static int s_raster_ok = 0; /* full GPU pipeline available */ @@ -2905,35 +2890,11 @@ void gl_renderer_set_swap_interval(int interval) { } } -void gl_renderer_shutdown(void) { - if (s_interp_thread) { - SDL_AtomicSet(&s_interp_thread_run, 0); - SDL_WaitThread(s_interp_thread, NULL); - s_interp_thread = NULL; - } - if (s_interp_ctx) { - SDL_GL_DeleteContext(s_interp_ctx); - s_interp_ctx = NULL; - } - if (s_ctx) { - SDL_GL_MakeCurrent(s_win, s_ctx); - if (s_interp_draw_fence) { - p_glDeleteSync(s_interp_draw_fence); - s_interp_draw_fence = NULL; - } - for (int i = 0; i < 3; i++) { - if (s_interp_fence[i]) { - p_glDeleteSync(s_interp_fence[i]); - s_interp_fence[i] = NULL; - } - } - ensure_cpu(); - SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; - } - if (s_interp_mutex) { - SDL_DestroyMutex(s_interp_mutex); - s_interp_mutex = NULL; - } +void gl_renderer_shutdown(void) { + if (s_ctx) { + ensure_cpu(); + SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; + } free(s_conv); s_conv = NULL; s_raster_ok = 0; /* New context regenerates s_present_tex empty; a stale size makes @@ -3726,107 +3687,71 @@ int gl_renderer_perf_aggregate(int wide_filter, double out[18]) { void gl_renderer_set_ws_ablate(int mode) { s_ws_ablate = (mode >= 0 && mode <= 3) ? mode : 0; } int gl_renderer_get_ws_ablate(void) { return s_ws_ablate; } -static void interp_reset_history_unlocked(void) { - s_interp_valid = 0; - s_interp_w = s_interp_h = 0; - s_interp_start = s_interp_last_capture = 0; - s_interp_duration = 1; - s_interp_source_path = -1; -} - -static void interp_reset_history(void) { - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - interp_reset_history_unlocked(); - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); -} - -void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, - int blend_mode) { - double effective_hz = target_hz < 0.0 - ? -1.0 - : (target_hz >= 60.0 ? target_hz : host_hz); - int active = (enabled && - (effective_hz < 0.0 || effective_hz >= 50.0)) ? 1 : 0; - const char *diag = getenv("PSX_GL_INTERP_DIAG"); - s_interp_diag = diag && diag[0] && diag[0] != '0'; - if (active && !s_interp_ctx && s_ctx) { - if (!s_interp_mutex) s_interp_mutex = SDL_CreateMutex(); - SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1); - s_interp_ctx = SDL_GL_CreateContext(s_win); - SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); - SDL_GL_MakeCurrent(s_win, s_ctx); - /* The wall-clock pacer and interpolation scheduler own cadence. Keeping - * driver vsync on the main context adds a 6-11 ms block whenever an - * FMV temporarily suspends interpolation and the main context presents. */ - SDL_GL_SetSwapInterval(0); - if (s_interp_ctx && s_interp_mutex) { - SDL_AtomicSet(&s_interp_thread_run, 1); - s_interp_thread = SDL_CreateThread(interp_thread_main, - "psx-gl-interp", NULL); - } - if (!s_interp_thread) { - SDL_AtomicSet(&s_interp_thread_run, 0); - if (s_interp_ctx) SDL_GL_DeleteContext(s_interp_ctx); - s_interp_ctx = NULL; - active = 0; - } - } - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - if (active != s_interp_enabled) interp_reset_history_unlocked(); - s_interp_enabled = active; - s_interp_host_hz = host_hz; - s_interp_target_hz = active ? effective_hz : 0.0; - s_interp_blend_mode = blend_mode == 1 ? 1 : 0; - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); - if (active && effective_hz < 0.0) - fprintf(stdout, "psxrecomp: GL frame interpolation enabled: uncapped " - "target on %.1f Hz display (%s blend)\n", host_hz, - s_interp_blend_mode ? "motion-adaptive" : "linear"); - else if (active) - fprintf(stdout, "psxrecomp: GL frame interpolation enabled: %.1f FPS " - "target on %.1f Hz display (%s blend)\n", effective_hz, host_hz, - s_interp_blend_mode ? "motion-adaptive" : "linear"); - else - fprintf(stdout, "psxrecomp: GL frame interpolation disabled (host %.1f Hz)\n", host_hz); -} - -void gl_renderer_set_interpolation_suspended(int suspended) { - suspended = suspended ? 1 : 0; - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - if (suspended != s_interp_suspended) interp_reset_history_unlocked(); - s_interp_suspended = suspended; - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); -} +static void interp_reset_history_unlocked(void) { + s_interp_valid = 0; + s_interp_w = s_interp_h = 0; + s_interp_source_path = -1; + frame_interpolation_schedule_reset(&s_interp_schedule); +} + +static void interp_reset_history(void) { + interp_reset_history_unlocked(); +} + +void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, + double source_hz, int blend_mode) { + double effective_hz = target_hz > 0.0 ? target_hz : host_hz; + if (effective_hz < source_hz) effective_hz = source_hz; + int active = (enabled && source_hz >= 1.0 && source_hz <= 1000.0 && + effective_hz >= source_hz && effective_hz <= 1000.0) ? 1 : 0; + const char *diag = getenv("PSX_GL_INTERP_DIAG"); + s_interp_diag = diag && diag[0] && diag[0] != '0'; + if (active != s_interp_enabled || source_hz != s_interp_source_hz || + effective_hz != s_interp_target_hz) + interp_reset_history_unlocked(); + s_interp_enabled = active; + s_interp_host_hz = host_hz; + s_interp_target_hz = active ? effective_hz : 0.0; + s_interp_source_hz = active ? source_hz : 0.0; + s_interp_blend_mode = blend_mode == 1 ? 1 : 0; + if (active) + fprintf(stdout, "psxrecomp: GL temporal frame blending enabled: %.1f " + "presents/s from %.3f guest frames/s on the render thread " + "(%s blend; no motion vectors)\n", + effective_hz, source_hz, + s_interp_blend_mode ? "change-adaptive" : "linear"); + else + fprintf(stdout, "psxrecomp: GL temporal frame blending disabled " + "(host %.1f Hz)\n", host_hz); +} + +void gl_renderer_set_interpolation_suspended(int suspended) { + suspended = suspended ? 1 : 0; + if (suspended != s_interp_suspended) interp_reset_history_unlocked(); + s_interp_suspended = suspended; +} + +int gl_renderer_interpolation_owns_cadence(void) { + return s_ctx && s_interp_enabled && !s_interp_suspended; +} void gl_renderer_interpolation_diag(int *enabled, int *suspended, int *history_frames, double *host_hz, double *target_hz, uint64_t *swaps) { - if (s_interp_mutex) SDL_LockMutex(s_interp_mutex); - if (enabled) *enabled = s_interp_enabled; + if (enabled) *enabled = s_interp_enabled; if (suspended) *suspended = s_interp_suspended; if (history_frames) *history_frames = s_interp_valid; if (host_hz) *host_hz = s_interp_host_hz; - if (target_hz) *target_hz = s_interp_target_hz; - if (swaps) *swaps = s_interp_swaps; - if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); -} + if (target_hz) *target_hz = s_interp_target_hz; + if (swaps) *swaps = s_interp_swaps; +} -/* Copy a stable display image out of the mutable VRAM/wide render target. - * Returns true once both previous and current images are available. */ +/* Copy a stable display image out of the mutable VRAM/wide render target. + * Returns true when temporal blending owns this source-frame interval. */ static int interp_capture(GLuint fbo, int x, int y, int w, int h, int linear, int force_4_3, int source_path) { - if (!s_interp_enabled || s_interp_suspended || !fbo || w <= 0 || h <= 0) return 0; - SDL_LockMutex(s_interp_mutex); - /* The presentation context may still have a draw queued which samples one - * of the shared history textures. Order this context's next allocation or - * copy after that draw before recycling a texture. glWaitSync keeps the - * dependency on the GPU; unlike glFinish it does not stall the guest CPU. */ - if (s_interp_draw_fence) { - p_glWaitSync(s_interp_draw_fence, 0, PSXGL_TIMEOUT_IGNORED); - p_glDeleteSync(s_interp_draw_fence); - s_interp_draw_fence = NULL; - } + if (!s_interp_enabled || s_interp_suspended || !fbo || w <= 0 || h <= 0) return 0; int pw = w * s_scale, ph = h * s_scale; if (pw != s_interp_w || ph != s_interp_h || source_path != s_interp_source_path || force_4_3 != s_interp_force_4_3) { @@ -3843,30 +3768,11 @@ static int interp_capture(GLuint fbo, int x, int y, int w, int h, int dst = 0; if (s_interp_valid == 1) dst = s_interp_cur == 0 ? 1 : 0; else if (s_interp_valid >= 2) dst = s_interp_prev; - if (s_interp_fence[dst]) { - p_glDeleteSync(s_interp_fence[dst]); - s_interp_fence[dst] = NULL; - } - - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); glBindTexture(GL_TEXTURE_2D, s_interp_tex[dst]); glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, x * s_scale, y * s_scale, pw, ph); - s_interp_fence[dst] = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); - glFlush(); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); - - uint64_t now = SDL_GetPerformanceCounter(); - uint64_t freq = SDL_GetPerformanceFrequency(); - if (s_interp_valid > 0 && s_interp_last_capture && now > s_interp_last_capture) { - uint64_t d = now - s_interp_last_capture; - uint64_t lo = freq / 240u, hi = freq / 10u; - if (d < lo) d = lo; - if (d > hi) d = hi; - s_interp_duration = d; - } - s_interp_last_capture = now; - s_interp_start = now; + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); if (s_interp_valid == 0) { s_interp_cur = dst; s_interp_valid = 1; @@ -3877,19 +3783,13 @@ static int interp_capture(GLuint fbo, int x, int y, int w, int h, } s_interp_linear = linear; s_interp_force_4_3 = force_4_3; - s_interp_source_path = source_path; - s_interp_captures++; - int ready = s_interp_valid >= 2; - SDL_UnlockMutex(s_interp_mutex); - return ready; -} - -static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { - int prev = s_interp_prev, curr = s_interp_cur; - if (s_interp_fence[prev]) - p_glWaitSync(s_interp_fence[prev], 0, PSXGL_TIMEOUT_IGNORED); - if (s_interp_fence[curr]) - p_glWaitSync(s_interp_fence[curr], 0, PSXGL_TIMEOUT_IGNORED); + s_interp_source_path = source_path; + s_interp_captures++; + return 1; +} + +static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { + int prev = s_interp_prev, curr = s_interp_cur; p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); glViewport(lx, ly, lw, lh); p_glActiveTexture(PSXGL_TEXTURE0); @@ -3906,25 +3806,16 @@ static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { p_glUniform1f(s_interp_uAlpha, alpha); p_glUniform1i(s_interp_uBlendMode, s_interp_blend_mode); p_glUniform4f(s_interp_uUvRect, 0.f, 0.f, 1.f, 1.f); - p_glBindVertexArray(s_interp_thread_vao); + p_glBindVertexArray(s_present_vao); glDrawArrays(GL_TRIANGLES, 0, 3); p_glBindVertexArray(0); p_glUseProgram(0); p_glActiveTexture(PSXGL_TEXTURE0); } -static int interp_present(void) { - if (!s_ctx || !s_interp_enabled || s_interp_suspended || s_interp_valid < 2) return 0; - uint64_t now = SDL_GetPerformanceCounter(); - if (now <= s_interp_start || !s_interp_duration) return 0; - double a = (double)(now - s_interp_start) / (double)s_interp_duration; - /* Keep swapping at the host cadence after the blend completes. Holding - * alpha at one is visually identical to leaving the current image on the - * front buffer, but avoids an irregular 2/3-swap pattern on 120/144/165 Hz - * displays while the next 59.94 Hz guest frame is being produced. */ - if (a > 1.0) a = 1.0; - if (a < 0.0) a = 0.0; - +static int interp_present(float alpha) { + if (!s_ctx || !s_interp_enabled || s_interp_suspended || s_interp_valid < 1) + return 0; int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); int lx, ly, lw, lh; if (s_interp_force_4_3) @@ -3933,78 +3824,72 @@ static int interp_present(void) { letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); glDisable(GL_SCISSOR_TEST); glViewport(0, 0, ww, wh); - if (lx != 0 || ly != 0 || lw != ww || lh != wh) { - glClearColor(0.f, 0.f, 0.f, 1.f); - glClear(GL_COLOR_BUFFER_BIT); - } - interp_draw_quad((float)a, lx, ly, lw, lh); - if (s_interp_draw_fence) p_glDeleteSync(s_interp_draw_fence); - s_interp_draw_fence = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); - glFlush(); + if (lx != 0 || ly != 0 || lw != ww || lh != wh) { + glClearColor(0.f, 0.f, 0.f, 1.f); + glClear(GL_COLOR_BUFFER_BIT); + } + present_bezel(ww, wh, lx, ly, lw, lh); + interp_draw_quad(alpha, lx, ly, lw, lh); pres_record(GL_PRES_INTERP, 0, 0, s_interp_w, s_interp_h, lx, ly, lw, lh); - gl_swap_with_osd(); + gl_swap_with_osd(); s_interp_swaps++; return 1; -} - -static int interp_thread_main(void *opaque) { - (void)opaque; - if (SDL_GL_MakeCurrent(s_win, s_interp_ctx) != 0) return -1; - SDL_GL_SetSwapInterval(0); /* host-period scheduler owns cadence */ - p_glGenVertexArrays(1, &s_interp_thread_vao); - uint64_t freq = SDL_GetPerformanceFrequency(); - uint64_t deadline = SDL_GetPerformanceCounter(); - uint64_t diag_start = deadline, diag_swaps = 0, diag_captures = 0; - - while (SDL_AtomicGet(&s_interp_thread_run)) { - SDL_LockMutex(s_interp_mutex); - double hz = s_interp_target_hz; - SDL_UnlockMutex(s_interp_mutex); - int uncapped = hz < 0.0; - uint64_t now = SDL_GetPerformanceCounter(); - if (!uncapped) { - if (hz < 50.0) hz = 60.0; - uint64_t period = (uint64_t)((double)freq / hz); - if (!period) period = 1; - deadline += period; - if (now > deadline + period * 4u) deadline = now + period; - for (;;) { - now = SDL_GetPerformanceCounter(); - if (now >= deadline) break; - uint64_t remain = deadline - now; - uint32_t ms = (uint32_t)((remain * 1000u) / - (freq ? freq : 1u)); - if (ms > 1) psx_host_sleep_ms(ms - 1); - } - while (SDL_GetPerformanceCounter() < deadline) {} - now = SDL_GetPerformanceCounter(); - } else { - deadline = now; - } - - SDL_LockMutex(s_interp_mutex); - int presented = 0; - if (SDL_AtomicGet(&s_interp_thread_run) && s_interp_enabled) - presented = interp_present(); - if (s_interp_diag && now - diag_start >= freq * 5u) { - double seconds = (double)(now - diag_start) / (double)freq; - fprintf(stdout, "psxrecomp: GL interpolation cadence: " - "%.2f captures/s, %.2f presents/s\n", - (double)(s_interp_captures - diag_captures) / seconds, - (double)(s_interp_swaps - diag_swaps) / seconds); - fflush(stdout); - diag_start = now; - diag_captures = s_interp_captures; - diag_swaps = s_interp_swaps; - } - SDL_UnlockMutex(s_interp_mutex); - if (uncapped && !presented) psx_host_sleep_ms(1); - } - p_glBindVertexArray(0); - SDL_GL_MakeCurrent(s_win, NULL); - return 0; -} +} + +static void interp_wait_until(uint64_t deadline, uint64_t frequency) { + uint64_t now; + if (!deadline || !frequency) return; + for (;;) { + now = SDL_GetPerformanceCounter(); + if (now >= deadline) return; + uint64_t remain = deadline - now; + uint32_t ms = (uint32_t)((remain * 1000u) / frequency); + if (ms > 1) psx_host_sleep_ms(ms - 1); + } +} + +static void interp_present_source_interval(void) { + static uint64_t diag_start, diag_swaps, diag_captures; + uint64_t frequency = SDL_GetPerformanceFrequency(); + uint64_t now = SDL_GetPerformanceCounter(); + uint64_t deadline; + float alpha; + + if (!frame_interpolation_schedule_begin( + &s_interp_schedule, now, frequency, + s_interp_source_hz, s_interp_target_hz)) + return; + + while (frame_interpolation_schedule_next( + &s_interp_schedule, SDL_GetPerformanceCounter(), + &deadline, &alpha)) { + interp_wait_until(deadline, frequency); + latency_ring_mark(LAT_SWAP_BEGIN); + (void)interp_present(alpha); + latency_ring_mark(LAT_SWAP_END); + } + interp_wait_until(frame_interpolation_schedule_end(&s_interp_schedule), + frequency); + + now = SDL_GetPerformanceCounter(); + if (!diag_start) { + diag_start = now; + diag_swaps = s_interp_swaps; + diag_captures = s_interp_captures; + } else if (s_interp_diag && frequency && + now - diag_start >= frequency * 5u) { + double seconds = (double)(now - diag_start) / (double)frequency; + fprintf(stdout, "psxrecomp: GL temporal-blend cadence: " + "%.2f captures/s, %.2f presents/s (single context)\n", + (double)(s_interp_captures - diag_captures) / seconds, + (double)(s_interp_swaps - diag_swaps) / seconds); + fflush(stdout); + diag_start = now; + diag_captures = s_interp_captures; + diag_swaps = s_interp_swaps; + } +} /* Draw one host OSD ARGB image into the default framebuffer at (vx,vy) * in top-left window coordinates (y down). Bitmap is ow×oh; viewport is @@ -4052,11 +3937,7 @@ static void gl_draw_osd_image(const uint32_t *px, int ow, int oh, * cancel for already-oriented captures and made toasts upside-down. */ p_glUniform4f(s_present_uUvRect, 0.f, 0.f, 1.f, 1.f); { - GLuint vao = s_present_vao; - if (s_interp_ctx && SDL_GL_GetCurrentContext() == s_interp_ctx && - s_interp_thread_vao) - vao = s_interp_thread_vao; - p_glBindVertexArray(vao); + p_glBindVertexArray(s_present_vao); glDrawArrays(GL_TRIANGLES, 0, 3); p_glBindVertexArray(0); } @@ -4252,12 +4133,13 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - if (s_force_present_remaining <= 0 && - s_last_present_path == GL_PRES_VRAM && - s_last_dx == disp_x && s_last_dy == disp_y && - s_last_dw == w && s_last_dh == h && - !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1) && - !host_osd_needs_present()) { + if (s_force_present_remaining <= 0 && + s_last_present_path == GL_PRES_VRAM && + s_last_dx == disp_x && s_last_dy == disp_y && + s_last_dw == w && s_last_dh == h && + !present_dirty_test(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1) && + !host_osd_needs_present() && + !gl_renderer_interpolation_owns_cadence()) { s_probe_skip++; gl_perf_present_enter(); gl_perf_present_exit(0); @@ -4282,10 +4164,11 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, } int interp_pair = interp_capture(s_hr_fbo, disp_x, disp_y, w, h, linear, force_4_3, GL_PRES_VRAM); - if (interp_pair) { - /* Interp owns Swap — still snapshot the band so resim hold-last has - * a frozen frame if an episode opens before the next main-thread Swap. */ - hold_capture_native_fbo(s_hr_fbo, disp_x, disp_y, w, h, force_4_3, linear); + if (interp_pair) { + /* Temporal blending owns this stock frame interval. Capture hold-last + * before pacing; every blend and Swap remains on this context/thread. */ + hold_capture_native_fbo(s_hr_fbo, disp_x, disp_y, w, h, force_4_3, linear); + interp_present_source_interval(); gl_perf_present_exit(0); present_dirty_rect(disp_x, disp_y, disp_x + w - 1, disp_y + h - 1, 0); present_force_consumed(); @@ -4360,12 +4243,13 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - if (s_force_present_remaining <= 0 && - s_last_present_path == GL_PRES_WIDE && + if (s_force_present_remaining <= 0 && + s_last_present_path == GL_PRES_WIDE && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == g_wide_w && s_last_dh == disp_h && !present_dirty_test(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1) && - !host_osd_needs_present()) { + !host_osd_needs_present() && + !gl_renderer_interpolation_owns_cadence()) { s_probe_skip++; gl_perf_present_enter(); gl_perf_present_exit(1); @@ -4385,8 +4269,9 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) } int interp_pair = interp_capture(fbo, 0, disp_y, g_wide_w, disp_h, linear, 0, GL_PRES_WIDE); - if (interp_pair) { - hold_capture_native_fbo(fbo, 0, disp_y, g_wide_w, disp_h, 0, linear); + if (interp_pair) { + hold_capture_native_fbo(fbo, 0, disp_y, g_wide_w, disp_h, 0, linear); + interp_present_source_interval(); gl_perf_present_exit(1); present_dirty_rect(0, disp_y, VRAM_W - 1, disp_y + disp_h - 1, 0); present_force_consumed(); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 9406c8e7..8f401795 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1368,13 +1368,13 @@ extern "C" int psx_mod_set_frame_interpolation( if (frames_per_second) { std::fprintf(stdout, - "psxrecomp: mod selected presentation-only interpolation at " - "%u FPS; guest timing remains stock\n", + "psxrecomp: mod selected presentation-only temporal blending at " + "%u presents/s; guest timing remains stock (no motion vectors)\n", (unsigned)frames_per_second); } else { std::fprintf(stdout, - "psxrecomp: mod selected display-refresh presentation-only " - "interpolation; guest timing remains stock\n"); + "psxrecomp: mod selected display-refresh presentation-only temporal " + "blending; guest timing remains stock (no motion vectors)\n"); } return 1; } @@ -2634,6 +2634,11 @@ static int present_effective_swap_interval(void) { } static int present_should_wall_pace(void) { + /* The single-context temporal-blend path subdivides this same stock frame + * interval. Do not run the outer pacer as well or guest timing doubles. */ + if (g_frame_interpolation && g_gl_active && + gl_renderer_interpolation_owns_cadence()) + return 0; return g_frame_period_ms > 0.0 && !present_vsync_owns_cadence(); } @@ -13048,7 +13053,10 @@ session_reboot: g_video_scale = gr_scale(); gl_renderer_set_interpolation(g_frame_interpolation, g_host_refresh_hz, (double)g_frame_interpolation_fps, - /*blend_mode*/ 0); + g_frame_period_ms > 0.0 + ? 1000.0 / g_frame_period_ms + : 59.94, + g_frame_interpolation_blend); } /* Vulkan backend: create the instance/device/swapchain on the * SDL_WINDOW_VULKAN window. On failure, fall back to software (vkb_init diff --git a/runtime/tests/test_frame_interpolation_context_ownership.py b/runtime/tests/test_frame_interpolation_context_ownership.py new file mode 100644 index 00000000..ca649af6 --- /dev/null +++ b/runtime/tests/test_frame_interpolation_context_ownership.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Pin temporal blending to the renderer's original thread and GL context.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +RENDERER = (ROOT / "runtime" / "src" / "gpu_gl_renderer.c").read_text( + encoding="utf-8" +) + +assert RENDERER.count("SDL_GL_CreateContext(") == 1, ( + "temporal blending must not create a second GL context" +) +assert "SDL_CreateThread(" not in RENDERER, ( + "temporal blending must not create a presentation thread" +) +assert "SDL_LockMutex(" not in RENDERER, ( + "the GL renderer must not hold an application mutex across driver work" +) +assert RENDERER.count("SDL_GL_SwapWindow(") == 1, ( + "all OpenGL presentation must converge on one owned swap call" +) +assert "gl_renderer_interpolation_owns_cadence" in RENDERER +assert "no motion vectors" in RENDERER + +print("single-context temporal blending ownership guard passed") diff --git a/runtime/tests/test_frame_interpolation_display_rate.py b/runtime/tests/test_frame_interpolation_display_rate.py index 59e42b7b..f2700f3e 100644 --- a/runtime/tests/test_frame_interpolation_display_rate.py +++ b/runtime/tests/test_frame_interpolation_display_rate.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Guard the mod API's zero sentinel: follow the display, never busy-loop.""" +"""Guard the mod API's zero sentinel: follow the measured display rate.""" from pathlib import Path @@ -17,9 +17,7 @@ assert EXPECTED_ASSIGNMENT in MAIN, ( "psx_mod_set_frame_interpolation(0) must preserve the 0 sentinel so " "gl_renderer_set_interpolation resolves it to the measured host refresh" ) -assert "frames_per_second ? (int)frames_per_second : -1" not in MAIN, ( - "the -1 sentinel selects the uncapped interpolation busy loop" -) +assert "frames_per_second ? (int)frames_per_second : -1" not in MAIN assert "zero follows the measured host-display refresh rate" in HEADER, ( "the public plugin contract must document the display-rate sentinel" ) diff --git a/runtime/tests/test_frame_interpolation_schedule.c b/runtime/tests/test_frame_interpolation_schedule.c new file mode 100644 index 00000000..a748e6a2 --- /dev/null +++ b/runtime/tests/test_frame_interpolation_schedule.c @@ -0,0 +1,75 @@ +#include +#include +#include + +#include "frame_interpolation.h" + +static int failures; +#define CHECK(condition, message) do { \ + if (!(condition)) { fprintf(stderr, "FAIL: %s\n", message); failures++; } \ +} while (0) + +static int run_interval(FrameInterpolationSchedule *schedule, uint64_t now, + double source_hz, double target_hz, + float *first_alpha, float *last_alpha) { + uint64_t deadline; + float alpha; + int count = 0; + + CHECK(frame_interpolation_schedule_begin( + schedule, now, 1000000u, source_hz, target_hz), + "valid rates should produce an interval"); + while (frame_interpolation_schedule_next( + schedule, now, &deadline, &alpha)) { + CHECK(deadline >= now, "presentation deadline must not be in the past"); + CHECK(deadline <= frame_interpolation_schedule_end(schedule), + "presentation deadline must stay inside the source interval"); + if (count == 0 && first_alpha) *first_alpha = alpha; + if (last_alpha) *last_alpha = alpha; + count++; + } + return count; +} + +int main(void) { + FrameInterpolationSchedule schedule = {0}; + float first = 0.0f, last = 0.0f; + uint64_t end; + + CHECK(run_interval(&schedule, 1000000u, 60.0, 120.0, + &first, &last) == 2, + "120 Hz should schedule two presents per 60 Hz source interval"); + CHECK(fabsf(first - 0.5f) < 0.001f && fabsf(last - 1.0f) < 0.001f, + "120 Hz blend phases should be one-half and complete"); + + frame_interpolation_schedule_reset(&schedule); + end = 2000000u; + int total = 0; + for (int i = 0; i < 5; i++) { + total += run_interval(&schedule, end, 60.0, 144.0, NULL, NULL); + end = frame_interpolation_schedule_end(&schedule) + 1000u; + } + CHECK(total == 12, + "144 Hz should retain fractional cadence across five source frames"); + + frame_interpolation_schedule_reset(&schedule); + CHECK(run_interval(&schedule, 3000000u, 60.0, 165.0, NULL, NULL) >= 2, + "165 Hz should schedule multiple presents"); + end = frame_interpolation_schedule_end(&schedule); + CHECK(frame_interpolation_schedule_begin( + &schedule, end + 500u, 1000000u, 60.0, 165.0), + "normal guest work should keep the existing cadence anchor"); + CHECK(frame_interpolation_schedule_end(&schedule) == end + 16667u || + frame_interpolation_schedule_end(&schedule) == end + 16666u, + "guest work must consume, not extend, the next source interval"); + + CHECK(!frame_interpolation_schedule_begin( + &schedule, 0u, 0u, 60.0, 120.0), + "zero host frequency should be rejected"); + CHECK(!frame_interpolation_schedule_begin( + &schedule, 0u, 1000000u, 60.0, 59.0), + "output below source cadence should be rejected"); + + printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); + return failures ? 1 : 0; +}