runtime: keep temporal blending on the render thread

This commit is contained in:
Matthew Stanley
2026-08-27 12:46:45 -07:00
parent dc0baf0fd8
commit 88f07bc9e3
12 changed files with 446 additions and 300 deletions
+11
View File
@@ -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.
+13
View File
@@ -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)
+40
View File
@@ -0,0 +1,40 @@
#ifndef PSX_FRAME_INTERPOLATION_H
#define PSX_FRAME_INTERPOLATION_H
#include <stdint.h>
#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
+5 -3
View File
@@ -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,
+5 -3
View File
@@ -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.
*/
+1
View File
@@ -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
+84
View File
@@ -0,0 +1,84 @@
#include "frame_interpolation.h"
#include <math.h>
#include <string.h>
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);
}
+170 -285
View File
@@ -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();
+13 -5
View File
@@ -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
@@ -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")
@@ -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"
)
@@ -0,0 +1,75 @@
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#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;
}