From 85a3d8d6d0fe119a55f63e626a3fe6b47a015014 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:59:00 -0700 Subject: [PATCH] Present thread: swap off the render thread ([video] present_thread) Opt-in under the render thread. Composed frames go to a ring of offscreen slots at the window's size (binds of framebuffer 0 on the composing context land on the current slot); a present thread with a second, shared context on the same window waits on the slot's fence, copies it 1:1 and swaps. The compositor's wait moves off rendering; the composing side waits only while every slot is queued or on screen. Falls back to direct swaps when the shared context or sync objects are unavailable. Co-Authored-By: Claude Opus 5.5 --- recompiler/src/config_loader.cpp | 3 + recompiler/src/config_loader.h | 8 + .../tests/video_enhancement_settings_test.cpp | 16 ++ runtime/CMakeLists.txt | 7 + runtime/include/gpu_gl_renderer.h | 8 + runtime/include/present_thread.h | 80 ++++++ runtime/runtime.cmake | 1 + runtime/src/gpu_gl_renderer.c | 263 +++++++++++++++++- runtime/src/main.cpp | 74 ++--- runtime/src/present_thread.c | 238 ++++++++++++++++ runtime/tests/run_gl_frame_gen.py | 50 +++- runtime/tests/run_gl_readback_region.py | 2 + runtime/tests/run_gl_render_thread.py | 2 +- runtime/tests/run_gl_scale_invariance.py | 2 + runtime/tests/run_gl_texture_filter.py | 2 + runtime/tests/test_gl_frame_gen.c | 46 ++- runtime/tests/test_present_thread.c | 128 +++++++++ 17 files changed, 891 insertions(+), 39 deletions(-) create mode 100644 runtime/include/present_thread.h create mode 100644 runtime/src/present_thread.c create mode 100644 runtime/tests/test_present_thread.c diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 6ef03792..f089e553 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -802,6 +802,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (video.contains("render_thread")) { rt.video_render_thread = toml::find(video, "render_thread"); } + if (video.contains("present_thread")) { + rt.video_present_thread = toml::find(video, "present_thread"); + } if (video.contains("frame_generation")) { rt.video_frame_generation = toml::find(video, "frame_generation"); } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index ebaa58a8..60895332 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -583,6 +583,14 @@ struct RuntimeConfig { // PSX_FRAME_GEN=0/1 overrides. bool video_frame_generation = false; + // present_thread: with render_thread, composed frames go to offscreen + // slots and a present thread (second, shared GL context on the window) + // does the copy and the swap, so the window compositor's wait does not + // block rendering (docs/RENDER_THREAD.md "Present thread"). Same pixels. + // Off by default; PSX_PRESENT_THREAD=0/1 overrides, + // PSX_PRESENT_THREAD_SLOTS=2..4 (default 3). + bool video_present_thread = false; + // low_latency_input: re-sample the pad after the wall-clock pacer (just // before present) so the next CPU frame reads near-fresh input instead of // input ~one frame stale. Default on. vsync: present/swap mode — diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index a775acfd..39cb4264 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -335,6 +335,21 @@ static void test_render_thread() { fs::remove(q); } +/* [video] present_thread (docs/RENDER_THREAD.md): opt-in, off by default. */ +static void test_present_thread() { + fs::path p = write_game_toml("psxrecomp_pt_default.toml", ""); + auto gc = PSXRecompV4::load_game_config(p); + check(!gc.runtime.video_present_thread, "present_thread defaults OFF"); + fs::remove(p); + fs::path q = write_game_toml("psxrecomp_pt_on.toml", + "[video]\n" + "render_thread = true\n" + "present_thread = true\n"); + auto gq = PSXRecompV4::load_game_config(q); + check(gq.runtime.video_present_thread, "[video] present_thread = true is honoured"); + fs::remove(q); +} + /* [video] frame_generation (docs/FRAME_GENERATION.md): opt-in, off by default. */ static void test_frame_generation() { fs::path p = write_game_toml("psxrecomp_fg_default.toml", ""); @@ -416,6 +431,7 @@ int main() { test_texture_window_batching(); test_render_thread(); test_frame_generation(); + test_present_thread(); test_pgxp_title_keys(); if (failures) { diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 3d62ac39..ddea695a 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1152,6 +1152,13 @@ if(BUILD_TESTING) find_package(Threads REQUIRED) target_link_libraries(render_thread_test PRIVATE Threads::Threads) add_test(NAME render_thread_test COMMAND render_thread_test) + # Present thread core ([video] present_thread): submission order is + # presentation order, slots are never composed while queued or on + # screen, done tokens reach the next composer, stop drains. + add_executable(present_thread_test tests/test_present_thread.c src/present_thread.c) + target_include_directories(present_thread_test PRIVATE include) + target_link_libraries(present_thread_test PRIVATE Threads::Threads) + add_test(NAME present_thread_test COMMAND present_thread_test) endif() # Same family of bug as frame_pacing: the starvation watchdog's staleness diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 6efa688f..94ae9c57 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -503,6 +503,14 @@ int gl_renderer_render_thread_active(void); void gl_renderer_render_thread_frame_boundary(void); void gl_renderer_render_thread_sync(const char *reason); int gl_renderer_render_thread_json(char *out, size_t cap); +/* [video] present_thread (docs/RENDER_THREAD.md "Present thread"): set before + * gl_renderer_render_thread_start; with the render thread, composed frames go + * to `slots` (2..4) offscreen slots and a present thread with a shared context + * does the copy + swap. Falls back to direct swaps when it cannot start. + * json: the "present_thread" member for the render_thread debug command. */ +void gl_renderer_set_present_thread(int on, int slots); +int gl_renderer_present_thread_active(void); +int gl_renderer_present_thread_json(char *out, size_t cap); int gl_renderer_fbo_peek_deferred(int x, int y, int w, int h, uint16_t *out); void gl_renderer_ring_capture(uint32_t frame, int wide, int base_x, int disp_y, int disp_h, int cx, int cy, int cw, int ch); diff --git a/runtime/include/present_thread.h b/runtime/include/present_thread.h new file mode 100644 index 00000000..9a47d8f0 --- /dev/null +++ b/runtime/include/present_thread.h @@ -0,0 +1,80 @@ +/* present_thread.h — opt-in present thread under the render thread + * ([video] present_thread, docs/RENDER_THREAD.md "Present thread"). + * + * The thread that composes frames (the render thread, or the emulation thread + * while it holds the context at a sync point) draws each presented frame into + * an offscreen slot instead of the window, then hands the slot over; the + * present thread owns a second graphics context on the same window and does + * the final copy and the swap. The swap's wait on the window compositor then + * lands on the present thread, not on rendering. + * + * This core is backend-neutral: a ring of `slots` numbered slots, each FREE, + * COMPOSING (the producer's current slot, exactly one), QUEUED or PRESENTING. + * Queued slots are presented in submission order. The producer blocks in + * pt_submit() only while every other slot is queued or presenting (the + * display is the bottleneck; that wait is the swap's wait, moved). + * + * Fences are opaque: `ready` travels with a submitted slot to present(); + * present() returns a `done` token (e.g. a fence after its copy) that the + * producer receives when it next takes that slot, before drawing into it. + * + * Producer calls (pt_current / pt_submit) may come from different threads + * over time, but never concurrently (the context hand-off orders them). */ +#ifndef PSX_PRESENT_THREAD_H +#define PSX_PRESENT_THREAD_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define PT_MAX_SLOTS 4 + +/* Present thread: make the present context current (current != 0, return 0 on + * failure: pt_start then fails) or release it (current == 0). */ +typedef int (*PtCtxFn)(void *user, int current); +/* Present thread: show `slot` (wait for `ready`, copy, swap). Returns the + * slot's done token (may be NULL). */ +typedef void *(*PtPresentFn)(void *user, int slot, void *ready); +/* Present thread, after the last present at stop or for a token nobody will + * collect: dispose of a done token. Optional. */ +typedef void (*PtDisposeFn)(void *user, void *token); + +typedef struct PtConfig { + int slots; /* 2..PT_MAX_SLOTS */ + PtCtxFn ctx; + PtPresentFn present; + PtDisposeFn dispose; /* optional */ + void *user; +} PtConfig; + +/* Start the thread; returns once its ctx(1) has run. 1 on success (the + * producer's current slot is 0), 0 on failure (nothing running). */ +int pt_start(const PtConfig *cfg); +/* Present everything queued, release the context on the present thread, + * join. Done tokens still held are passed to dispose on the present thread + * before it releases its context. Safe when not running. */ +void pt_stop(void); +int pt_running(void); + +/* Producer: the slot being composed. */ +int pt_current(void); +/* Producer: queue the current slot with `ready`, then take the next free slot + * (blocking while none is). *next_done receives that slot's done token (NULL + * if none); *wait_ns the time spent blocked. Returns the new current slot, or + * -1 when not running. */ +int pt_submit(void *ready, void **next_done, uint64_t *wait_ns); +/* Producer: wait until nothing is queued or presenting. */ +void pt_drain(void); + +typedef struct PtStats { + uint64_t submits, presents, waits, wait_ns, present_ns, present_max_ns; + int slots, queued_high; +} PtStats; +void pt_get_stats(PtStats *out); + +#ifdef __cplusplus +} +#endif +#endif /* PSX_PRESENT_THREAD_H */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index ac549c55..1bb494ef 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -370,6 +370,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/gpu_vram_dirty.c ${PSXRECOMP_ROOT}/runtime/src/gpu_render.c ${PSXRECOMP_ROOT}/runtime/src/render_thread.c + ${PSXRECOMP_ROOT}/runtime/src/present_thread.c ${PSXRECOMP_ROOT}/runtime/src/frame_gen.c ${PSXRECOMP_ROOT}/runtime/src/gpu_gl_renderer.c ${PSXRECOMP_ROOT}/runtime/src/gpu_hd_textures.cpp diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index cbebc723..c9433da4 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -95,6 +95,7 @@ #include "psx_rewind.h" #include "psx_openxr.h" #include "openxr_color.h" +#include "present_thread.h" #include "psx_sdl.h" #if defined(PSX_SDL3) @@ -293,6 +294,25 @@ static PFN_glBindRenderbuffer p_glBindRenderbuffer; static PFN_glRenderbufferStorage p_glRenderbufferStorage; static PFN_glFramebufferRenderbuffer p_glFramebufferRenderbuffer; +/* Present thread (docs/RENDER_THREAD.md "Present thread"): while it runs, + * the default framebuffer of the composing context is an offscreen slot. + * Every bind of framebuffer 0 on that context lands on the current slot; the + * present thread's own context binds the real one (p_glBindFramebuffer_raw). */ +typedef void *(APIENTRY *PFN_glFenceSync)(GLenum, GLbitfield); +typedef void (APIENTRY *PFN_glWaitSync)(void *, GLbitfield, uint64_t); +typedef void (APIENTRY *PFN_glDeleteSync)(void *); +static PFN_glFenceSync p_glFenceSync; +static PFN_glWaitSync p_glWaitSync; +static PFN_glDeleteSync p_glDeleteSync; +static PFN_glBindFramebuffer p_glBindFramebuffer_raw; +static int s_pt_on = 0; /* redirect active (present thread running) */ +static volatile int s_pt_interval_gen = 0; /* bumped per swap-interval change */ +static GLuint pt_target_fbo(void); +static void APIENTRY gl_bind_fb_redirect(GLenum target, GLuint fb) { + if (fb == 0 && s_pt_on) fb = pt_target_fbo(); + p_glBindFramebuffer_raw(target, fb); +} + static int load_modern_gl(void) { int ok = 1; #define LOAD(p, n) do { p = (void *)SDL_GL_GetProcAddress(n); if (!p) ok = 0; } while (0) @@ -340,6 +360,14 @@ static int load_modern_gl(void) { /* Optional: only the debug presented-image ring maps pack buffers. */ p_glMapBuffer = (void *)SDL_GL_GetProcAddress("glMapBuffer"); p_glUnmapBuffer = (void *)SDL_GL_GetProcAddress("glUnmapBuffer"); + /* Optional: the present thread needs sync objects (GL 3.2). */ + p_glFenceSync = (void *)SDL_GL_GetProcAddress("glFenceSync"); + p_glWaitSync = (void *)SDL_GL_GetProcAddress("glWaitSync"); + p_glDeleteSync = (void *)SDL_GL_GetProcAddress("glDeleteSync"); + if (p_glBindFramebuffer && p_glBindFramebuffer != gl_bind_fb_redirect) { + p_glBindFramebuffer_raw = p_glBindFramebuffer; + p_glBindFramebuffer = gl_bind_fb_redirect; + } #undef LOAD return ok; } @@ -1296,7 +1324,7 @@ static void pres_record(int path, int dx, int dy, int w, int h, * we pass in are already bottom-origin GL window coords). */ uint8_t px[3] = { 0, 0, 0 }; if (probe_pixels && lw > 0 && lh > 0) { - glReadBuffer(GL_BACK); + glReadBuffer(s_pt_on ? PSXGL_COLOR_ATTACHMENT0 : GL_BACK); glPixelStorei(GL_PACK_ALIGNMENT, 1); glReadPixels(lx + lw / 2, ly + lh / 2, 1, 1, GL_RGB, GL_UNSIGNED_BYTE, px); glPixelStorei(GL_PACK_ALIGNMENT, 4); @@ -5432,6 +5460,7 @@ int gl_renderer_init_context(SDL_Window *win) { void gl_renderer_set_swap_interval(int interval) { GL_RT_SYNC("set_swap_interval"); s_swap_interval = interval; + s_pt_interval_gen++; /* the present thread's context applies it */ if (s_ctx) { if (SDL_GL_SetSwapInterval(interval) != 0 && interval < 0) { SDL_GL_SetSwapInterval(1); @@ -5442,6 +5471,7 @@ void gl_renderer_set_swap_interval(int interval) { int gl_renderer_get_swap_interval(void) { GL_RT_SYNC("get_swap_interval"); if(!s_ctx)return -2; + if(s_pt_on)return s_swap_interval; #if defined(PSX_SDL3) int interval=0; return SDL_GL_GetSwapInterval(&interval)?interval:-2; @@ -7756,7 +7786,7 @@ static int openxr_copy_native(uint32_t eye,uint32_t texture,int w,int h) { int ok=p_glCheckFramebufferStatus(PSXGL_DRAW_FRAMEBUFFER)==PSXGL_FRAMEBUFFER_COMPLETE; if(ok) { int *r=s_native_surface_rect; - glDisable(GL_SCISSOR_TEST);p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,0);glReadBuffer(GL_BACK); + glDisable(GL_SCISSOR_TEST);p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,0);glReadBuffer(s_pt_on?PSXGL_COLOR_ATTACHMENT0:GL_BACK); glDisable(0x8DB9); /* Default framebuffer is already upright: unlike native VRAM/eye * textures, its bottom GL row is the displayed bottom row. */ @@ -8190,6 +8220,227 @@ static int ov_needs_present(void) { } static uint64_t s_swaps_total = 0; /* every swap, real or generated (diagnostic) */ + +/* ---- Present thread ([video] present_thread) ------------------------------- + * Opt-in under the render thread. Each composed frame (everything that used + * to land in the window's back buffer: the display quad, OSD, captures) goes + * to an offscreen slot of the window's size; the swap becomes "fence, queue + * the slot, take the next free one". The present thread owns a second context + * on the same window, sharing objects with s_ctx, and does the 1:1 copy and + * the real swap, so the compositor's wait no longer blocks rendering. The + * composing side waits only while every slot is queued or on screen. */ +#define PT_SYNC_GPU_COMMANDS_COMPLETE 0x9117 +#define PT_TIMEOUT_IGNORED 0xFFFFFFFFFFFFFFFFull +static int s_pt_want = 0, s_pt_slots = 3; +static GLenum s_pt_format = GL_RGBA8; /* the window's: no alpha -> GL_RGB8 */ +static SDL_GLContext s_pt_ctx = NULL; +static GLuint s_pts_tex[PT_MAX_SLOTS], s_pts_rb[PT_MAX_SLOTS], s_pts_fbo[PT_MAX_SLOTS]; +static int s_pts_w[PT_MAX_SLOTS], s_pts_h[PT_MAX_SLOTS]; +static unsigned s_pts_gen[PT_MAX_SLOTS]; +/* present thread's own objects (FBOs are per-context) */ +static GLuint s_ptp_fbo[PT_MAX_SLOTS]; +static unsigned s_ptp_gen[PT_MAX_SLOTS]; +static int s_ptp_interval_gen = -1; + +void gl_renderer_set_present_thread(int on, int slots) { + s_pt_want = on ? 1 : 0; + s_pt_slots = slots < 2 ? 2 : slots > PT_MAX_SLOTS ? PT_MAX_SLOTS : slots; +} +int gl_renderer_present_thread_active(void) { return s_pt_on; } + +/* Composing context: the current slot, (re)allocated at the window's size. */ +static GLuint pt_target_fbo(void) { + const int i = pt_current(); + if (i < 0) return 0; + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + if (ww < 1) ww = 1; + if (wh < 1) wh = 1; + if (s_pts_fbo[i] && s_pts_w[i] == ww && s_pts_h[i] == wh) return s_pts_fbo[i]; + GLint tex_prev = 0, rb_prev = 0; + glGetIntegerv(GL_TEXTURE_BINDING_2D, &tex_prev); + glGetIntegerv(0x8CA7 /* GL_RENDERBUFFER_BINDING */, &rb_prev); + if (s_pts_fbo[i]) { + p_glDeleteFramebuffers(1, &s_pts_fbo[i]); + p_glDeleteRenderbuffers(1, &s_pts_rb[i]); + glDeleteTextures(1, &s_pts_tex[i]); + s_pts_fbo[i] = s_pts_rb[i] = s_pts_tex[i] = 0; + } + s_pts_tex[i] = make_tex(s_pt_format, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE); + p_glGenRenderbuffers(1, &s_pts_rb[i]); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_pts_rb[i]); + p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, ww, wh); + p_glGenFramebuffers(1, &s_pts_fbo[i]); + p_glBindFramebuffer_raw(PSXGL_FRAMEBUFFER, s_pts_fbo[i]); + p_glFramebufferTexture2D(PSXGL_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, s_pts_tex[i], 0); + p_glFramebufferRenderbuffer(PSXGL_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, + PSXGL_RENDERBUFFER, s_pts_rb[i]); + if (p_glCheckFramebufferStatus(PSXGL_FRAMEBUFFER) != PSXGL_FRAMEBUFFER_COMPLETE) + fprintf(stdout, "psxrecomp: present thread: slot FBO incomplete\n"); + glBindTexture(GL_TEXTURE_2D, (GLuint)tex_prev); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, (GLuint)rb_prev); + s_pts_w[i] = ww; s_pts_h[i] = wh; + s_pts_gen[i]++; + return s_pts_fbo[i]; +} + +static int pt_cb_ctx(void *user, int current) { + (void)user; + if (current) { + if (SDL_GL_MakeCurrent(s_win, s_pt_ctx) != 0) return 0; + s_ptp_interval_gen = -1; + return 1; + } + for (int i = 0; i < PT_MAX_SLOTS; i++) { + if (s_ptp_fbo[i]) p_glDeleteFramebuffers(1, &s_ptp_fbo[i]); + s_ptp_fbo[i] = 0; + s_ptp_gen[i] = 0; + } + glFinish(); + SDL_GL_MakeCurrent(s_win, NULL); + return 1; +} + +static void *pt_cb_present(void *user, int slot, void *ready) { + (void)user; + if (s_ptp_interval_gen != s_pt_interval_gen) { + s_ptp_interval_gen = s_pt_interval_gen; + if (SDL_GL_SetSwapInterval(s_swap_interval) != 0 && s_swap_interval < 0) + SDL_GL_SetSwapInterval(1); + } + if (ready) { p_glWaitSync(ready, 0, PT_TIMEOUT_IGNORED); p_glDeleteSync(ready); } + if (!s_ptp_fbo[slot] || s_ptp_gen[slot] != s_pts_gen[slot]) { + if (!s_ptp_fbo[slot]) p_glGenFramebuffers(1, &s_ptp_fbo[slot]); + p_glBindFramebuffer_raw(PSXGL_READ_FRAMEBUFFER, s_ptp_fbo[slot]); + p_glFramebufferTexture2D(PSXGL_READ_FRAMEBUFFER, PSXGL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, + s_pts_tex[slot], 0); + s_ptp_gen[slot] = s_pts_gen[slot]; + } + const int sw = s_pts_w[slot], sh = s_pts_h[slot]; + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + p_glBindFramebuffer_raw(PSXGL_READ_FRAMEBUFFER, s_ptp_fbo[slot]); + p_glBindFramebuffer_raw(PSXGL_DRAW_FRAMEBUFFER, 0); + glDisable(GL_SCISSOR_TEST); + if (ww == sw && wh == sh) { + p_glBlitFramebuffer(0, 0, sw, sh, 0, 0, sw, sh, GL_COLOR_BUFFER_BIT, GL_NEAREST); + } else { + /* The window changed after this frame was composed: fit it. */ + glViewport(0, 0, ww, wh); + glClearColor(0.f, 0.f, 0.f, 1.f); + glClear(GL_COLOR_BUFFER_BIT); + p_glBlitFramebuffer(0, 0, sw, sh, 0, 0, ww, wh, GL_COLOR_BUFFER_BIT, GL_LINEAR); + } +#ifdef GL_PRESENT_THREAD_TEST_HOOK + GL_PRESENT_THREAD_TEST_HOOK(slot); +#endif + void *done = p_glFenceSync(PT_SYNC_GPU_COMMANDS_COMPLETE, 0); + SDL_GL_SwapWindow(s_win); + return done; +} + +static void pt_cb_dispose(void *user, void *token) { + (void)user; + if (token) p_glDeleteSync(token); +} + +/* Composing context current; 1 when the present thread runs. */ +static int pt_begin(void) { + if (!s_pt_want || s_pt_on || !s_win || !s_ctx) return 0; + if (!p_glFenceSync || !p_glWaitSync || !p_glDeleteSync) { + fprintf(stdout, "psxrecomp: present thread: no sync objects; swaps stay on the render thread\n"); + return 0; + } + /* The slots stand in for the window's back buffer: same channels, so + * destination alpha and readbacks behave as they did there. */ + { + GLint a = 8; + typedef void (APIENTRY *PFN_gfap)(GLenum, GLenum, GLenum, GLint *); + PFN_gfap gfap = (PFN_gfap)SDL_GL_GetProcAddress("glGetFramebufferAttachmentParameteriv"); + p_glBindFramebuffer_raw(PSXGL_FRAMEBUFFER, 0); + while (glGetError() != GL_NO_ERROR) {} + if (gfap) gfap(PSXGL_FRAMEBUFFER, GL_BACK_LEFT, 0x8215 /* ALPHA_SIZE */, &a); + if (glGetError() != GL_NO_ERROR) a = 8; + s_pt_format = a > 0 ? GL_RGBA8 : GL_RGB8; + } + SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1); + s_pt_ctx = SDL_GL_CreateContext(s_win); + SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); + if (!s_pt_ctx) { + fprintf(stdout, "psxrecomp: present thread: shared context failed (%s); swaps stay on " + "the render thread\n", SDL_GetError()); + SDL_GL_MakeCurrent(s_win, s_ctx); + return 0; + } + SDL_GL_MakeCurrent(s_win, NULL); /* creation made it current here */ + SDL_GL_MakeCurrent(s_win, s_ctx); + memset(s_pts_fbo, 0, sizeof s_pts_fbo); + memset(s_ptp_fbo, 0, sizeof s_ptp_fbo); + PtConfig c = { s_pt_slots, pt_cb_ctx, pt_cb_present, pt_cb_dispose, NULL }; + if (!pt_start(&c)) { + fprintf(stdout, "psxrecomp: present thread: start failed; swaps stay on the render thread\n"); + SDL_GL_DeleteContext(s_pt_ctx); + s_pt_ctx = NULL; + SDL_GL_MakeCurrent(s_win, s_ctx); + return 0; + } + s_pt_on = 1; + fprintf(stdout, "psxrecomp: present thread on (%d slots)\n", s_pt_slots); + return 1; +} + +/* Composing context current, render thread stopped. */ +static void pt_end(void) { + if (!s_pt_on) return; + glFlush(); + pt_stop(); + s_pt_on = 0; + for (int i = 0; i < PT_MAX_SLOTS; i++) { + if (s_pts_fbo[i]) { + p_glDeleteFramebuffers(1, &s_pts_fbo[i]); + p_glDeleteRenderbuffers(1, &s_pts_rb[i]); + glDeleteTextures(1, &s_pts_tex[i]); + } + s_pts_fbo[i] = s_pts_rb[i] = s_pts_tex[i] = 0; + s_pts_w[i] = s_pts_h[i] = 0; + } + SDL_GL_DeleteContext(s_pt_ctx); + s_pt_ctx = NULL; + SDL_GL_MakeCurrent(s_win, s_ctx); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); +} + +/* The swap: direct, or queued to the present thread. */ +static void gl_present_swap(void) { + if (!s_pt_on) { SDL_GL_SwapWindow(s_win); return; } + (void)pt_target_fbo(); /* a frame that never bound 0 still has its slot */ + void *ready = p_glFenceSync(PT_SYNC_GPU_COMMANDS_COMPLETE, 0); + glFlush(); + void *done = NULL; + (void)pt_submit(ready, &done, NULL); + if (done) { p_glWaitSync(done, 0, PT_TIMEOUT_IGNORED); p_glDeleteSync(done); } + /* Whatever is bound as the window now means the new slot. */ + GLint d = 0, r = 0; + glGetIntegerv(0x8CA6 /* DRAW_FRAMEBUFFER_BINDING */, &d); + glGetIntegerv(0x8CAA /* READ_FRAMEBUFFER_BINDING */, &r); + for (int i = 0; i < PT_MAX_SLOTS; i++) { + if (s_pts_fbo[i] && (GLuint)d == s_pts_fbo[i]) p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); + if (s_pts_fbo[i] && (GLuint)r == s_pts_fbo[i]) p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + } +} + +int gl_renderer_present_thread_json(char *out, size_t cap) { + PtStats st; + pt_get_stats(&st); + return snprintf(out, cap, + "\"present_thread\":{\"active\":%d,\"slots\":%d,\"submits\":%llu,\"presents\":%llu," + "\"waits\":%llu,\"wait_ms\":%.3f,\"swap_ms_avg\":%.3f,\"swap_ms_max\":%.3f," + "\"queued_high\":%d}", + s_pt_on, st.slots, (unsigned long long)st.submits, (unsigned long long)st.presents, + (unsigned long long)st.waits, st.wait_ns / 1e6, + st.presents ? (double)st.present_ns / (double)st.presents / 1e6 : 0.0, + st.present_max_ns / 1e6, st.queued_high); +} static void gl_swap_now(int generated); static int fg_keep_real(void); /* frame generation */ static void fg_note_swap(uint64_t ns); @@ -8288,7 +8539,7 @@ static void gl_swap_now(int generated) { s_swaps_total++; uint64_t t0 = s_dyn_on ? SDL_GetPerformanceCounter() : 0; const uint64_t rthf_t0 = rth_replaying() ? host_now_ns_rthf() : 0; - SDL_GL_SwapWindow(s_win); + gl_present_swap(); if (rthf_t0) { const uint64_t sw = host_now_ns_rthf() - rthf_t0; s_rthf_swap_ns += sw; @@ -11091,7 +11342,9 @@ int gl_renderer_render_thread_start(int max_frames) { cfg.user = NULL; cfg.tick = fg_tick; fg_invalidate(); + (void)pt_begin(); /* [video] present_thread; falls back to direct swaps */ if (!rt_start(&cfg)) { + pt_end(); s_rth_on = 0; s_vram = s_rth_vram_pub; sw_renderer_rebind_vram(s_rth_vram_pub); @@ -11106,6 +11359,7 @@ int gl_renderer_render_thread_start(int max_frames) { void gl_renderer_render_thread_stop(void) { if (!s_rth_on) return; rt_stop(); /* drains; the context is current here again */ + pt_end(); s_rth_on = 0; s_vram = s_rth_vram_pub; sw_renderer_rebind_vram(s_rth_vram_pub); @@ -11162,7 +11416,8 @@ int gl_renderer_render_thread_json(char *out, size_t cap) { k += snprintf(out + k, cap - (size_t)k, "%s{\"frame\":%llu,\"wait_us\":%.1f,\"reason\":\"%s\"}", i ? "," : "", (unsigned long long)ev[i].frame, ev[i].wait_ns / 1e3, ev[i].reason); - if (k > 0 && (size_t)k < cap) k += snprintf(out + k, cap - (size_t)k, "]"); + if (k > 0 && (size_t)k < cap) k += snprintf(out + k, cap - (size_t)k, "],"); + if (k > 0 && (size_t)k < cap) k += gl_renderer_present_thread_json(out + k, cap - (size_t)k); return k; } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 6ec628cb..8781026b 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1459,6 +1459,9 @@ static int g_render_thread = 0; static int g_render_thread_frames = 2; /* [video] frame_generation (docs/FRAME_GENERATION.md), with the render thread. */ static int g_frame_generation = 0; +/* [video] present_thread (docs/RENDER_THREAD.md), with the render thread. */ +static int g_present_thread = 0; +static int g_present_thread_slots = 3; static int s_render_thread_tried = 0; static int g_video_vsync = 1; static int g_frame_interpolation = 0; @@ -9212,6 +9215,40 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { return ep; } +/* Render thread: start once, then close every frame (after its present, + * before pacing, so the render thread draws while the guest waits). */ +static void render_thread_vblank(void) { +#ifndef PSX_SDL_NO_RENDER + if (!g_render_thread) return; + if (!s_render_thread_tried) { + s_render_thread_tried = 1; + gl_renderer_set_present_thread(g_present_thread, g_present_thread_slots); + if (g_gl_active && gr_backend() == GR_BACKEND_OPENGL && + gl_renderer_render_thread_start(g_render_thread_frames)) { + std::fprintf(stdout, "psxrecomp: render thread on (OpenGL, %d frame(s) in flight)\n", + g_render_thread_frames); + if (g_frame_generation) { + gl_renderer_set_frame_generation(1); + std::fprintf(stdout, "psxrecomp: frame generation on (render thread, " + "from surplus only)\n"); + } + } else { + std::fprintf(stdout, "psxrecomp: render thread requested but not started " + "(needs the OpenGL backend without netplay, frame " + "interpolation or a 24-bit display)\n"); + if (g_frame_generation) + std::fprintf(stdout, "psxrecomp: frame generation needs the render thread; off\n"); + } + std::fflush(stdout); + } + if (g_frame_generation) + gl_renderer_frame_gen_configure(g_host_refresh_hz, + g_guest_frame_period_ms > 0.0 + ? 1000.0 / g_guest_frame_period_ms : 0.0); + gl_renderer_render_thread_frame_boundary(); +#endif +} + /* ---- Dynamic internal resolution: the host side --------------------------- * [video] dynamic_resolution (docs/ENHANCEMENTS.md, IR3). The controller * (dynamic_resolution.c) decides; this feeds it one sample per guest VBlank, @@ -9577,38 +9614,6 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { c.down_block_until > now_s ? c.down_block_until - now_s : 0.0, blocked); } -/* Render thread: start once, then close every frame (after its present, - * before pacing, so the render thread draws while the guest waits). */ -static void render_thread_vblank(void) { -#ifndef PSX_SDL_NO_RENDER - if (!g_render_thread) return; - if (!s_render_thread_tried) { - s_render_thread_tried = 1; - if (g_gl_active && gr_backend() == GR_BACKEND_OPENGL && - gl_renderer_render_thread_start(g_render_thread_frames)) { - std::fprintf(stdout, "psxrecomp: render thread on (OpenGL, %d frame(s) in flight)\n", - g_render_thread_frames); - if (g_frame_generation) { - gl_renderer_set_frame_generation(1); - std::fprintf(stdout, "psxrecomp: frame generation on (render thread, " - "from surplus only)\n"); - } - } else { - std::fprintf(stdout, "psxrecomp: render thread requested but not started " - "(needs the OpenGL backend without netplay, frame " - "interpolation or a 24-bit display)\n"); - if (g_frame_generation) - std::fprintf(stdout, "psxrecomp: frame generation needs the render thread; off\n"); - } - std::fflush(stdout); - } - if (g_frame_generation) - gl_renderer_frame_gen_configure(g_host_refresh_hz, - g_guest_frame_period_ms > 0.0 - ? 1000.0 / g_guest_frame_period_ms : 0.0); - gl_renderer_render_thread_frame_boundary(); -#endif -} static void sdl_vblank_present(void) { sync_guest_cadence_to_video_standard(); @@ -15717,6 +15722,7 @@ int main(int argc, char** argv) { gc.runtime.video_texture_window_batching ? 1 : 0); g_render_thread = gc.runtime.video_render_thread ? 1 : 0; g_frame_generation = gc.runtime.video_frame_generation ? 1 : 0; + g_present_thread = gc.runtime.video_present_thread ? 1 : 0; g_video_vsync = gc.runtime.video_vsync; g_frame_interpolation = gc.runtime.video_frame_interpolation ? 1 : 0; g_frame_interpolation_fps = gc.runtime.video_frame_interpolation_fps; @@ -18023,6 +18029,10 @@ session_reboot: * flight (default 2). */ if (const char* e = std::getenv("PSX_RENDER_THREAD")) g_render_thread = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_PRESENT_THREAD")) + g_present_thread = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_PRESENT_THREAD_SLOTS")) + g_present_thread_slots = std::atoi(e) > 0 ? std::atoi(e) : 3; if (const char* e = std::getenv("PSX_FRAME_GEN")) g_frame_generation = (*e && *e != '0') ? 1 : 0; if (const char* e = std::getenv("PSX_RENDER_THREAD_FRAMES")) diff --git a/runtime/src/present_thread.c b/runtime/src/present_thread.c new file mode 100644 index 00000000..3a6a6552 --- /dev/null +++ b/runtime/src/present_thread.c @@ -0,0 +1,238 @@ +/* present_thread.c — present thread core. See present_thread.h and + * docs/RENDER_THREAD.md ("Present thread"). + * + * Presents run at display rate (tens to a few hundred per second), so one + * mutex guards all slot state; nothing is held while present() runs. */ + +#include "present_thread.h" + +#include + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +typedef SRWLOCK pt_mutex; +typedef CONDITION_VARIABLE pt_cond; +static void mtx_init(pt_mutex *m) { InitializeSRWLock(m); } +static void mtx_lock(pt_mutex *m) { AcquireSRWLockExclusive(m); } +static void mtx_unlock(pt_mutex *m) { ReleaseSRWLockExclusive(m); } +static void cnd_init(pt_cond *c) { InitializeConditionVariable(c); } +static void cnd_wait(pt_cond *c, pt_mutex *m) { SleepConditionVariableSRW(c, m, INFINITE, 0); } +static void cnd_bcast(pt_cond *c) { WakeAllConditionVariable(c); } +static uint64_t now_ns(void) { + LARGE_INTEGER f, t; + QueryPerformanceFrequency(&f); + QueryPerformanceCounter(&t); + return (uint64_t)((double)t.QuadPart * 1e9 / (double)f.QuadPart); +} +typedef HANDLE pt_thread; +#else +# include +# if defined(__APPLE__) +# include +# endif +# include +typedef pthread_mutex_t pt_mutex; +typedef pthread_cond_t pt_cond; +static void mtx_init(pt_mutex *m) { pthread_mutex_init(m, NULL); } +static void mtx_lock(pt_mutex *m) { pthread_mutex_lock(m); } +static void mtx_unlock(pt_mutex *m) { pthread_mutex_unlock(m); } +static void cnd_init(pt_cond *c) { pthread_cond_init(c, NULL); } +static void cnd_wait(pt_cond *c, pt_mutex *m) { pthread_cond_wait(c, m); } +static void cnd_bcast(pt_cond *c) { pthread_cond_broadcast(c); } +static uint64_t now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +} +typedef pthread_t pt_thread; +#endif + +enum { S_FREE = 0, S_COMPOSING, S_QUEUED, S_PRESENTING }; + +static struct { + PtConfig cfg; + pt_mutex mtx; + pt_cond cv; /* any state change; both sides wait on it */ + pt_thread thread; + int running, stop, init_done, init_ok; + int state[PT_MAX_SLOTS]; + void *ready[PT_MAX_SLOTS]; + void *done[PT_MAX_SLOTS]; + int queue[PT_MAX_SLOTS]; + int q_head, q_len; + int current; + PtStats st; +} P; +static int s_pt_inited = 0; + +static void *present_loop(void) { + int ok = P.cfg.ctx(P.cfg.user, 1); + mtx_lock(&P.mtx); + P.init_done = 1; + P.init_ok = ok; + cnd_bcast(&P.cv); + if (!ok) { mtx_unlock(&P.mtx); return NULL; } + for (;;) { + while (!P.q_len && !P.stop) cnd_wait(&P.cv, &P.mtx); + if (!P.q_len) break; /* stop with nothing queued */ + const int slot = P.queue[P.q_head]; + P.q_head = (P.q_head + 1) % PT_MAX_SLOTS; + P.q_len--; + P.state[slot] = S_PRESENTING; + void *ready = P.ready[slot]; + P.ready[slot] = NULL; + mtx_unlock(&P.mtx); + const uint64_t t0 = now_ns(); + void *done = P.cfg.present(P.cfg.user, slot, ready); + const uint64_t dt = now_ns() - t0; + mtx_lock(&P.mtx); + P.st.presents++; + P.st.present_ns += dt; + if (dt > P.st.present_max_ns) P.st.present_max_ns = dt; + P.done[slot] = done; + P.state[slot] = S_FREE; + cnd_bcast(&P.cv); + } + /* Tokens nobody will collect any more. */ + for (int i = 0; i < P.cfg.slots; i++) { + if (P.done[i] && P.cfg.dispose) P.cfg.dispose(P.cfg.user, P.done[i]); + P.done[i] = NULL; + } + mtx_unlock(&P.mtx); + P.cfg.ctx(P.cfg.user, 0); + return NULL; +} + +#ifdef _WIN32 +static DWORD WINAPI present_main(LPVOID a) { (void)a; present_loop(); return 0; } +#else +static void *present_main(void *a) { (void)a; return present_loop(); } +#endif + +int pt_start(const PtConfig *cfg) { + if (P.running || !cfg || !cfg->ctx || !cfg->present) return 0; + if (cfg->slots < 2 || cfg->slots > PT_MAX_SLOTS) return 0; + if (!s_pt_inited) { mtx_init(&P.mtx); cnd_init(&P.cv); s_pt_inited = 1; } + mtx_lock(&P.mtx); + P.cfg = *cfg; + P.stop = P.init_done = P.init_ok = 0; + P.q_head = P.q_len = 0; + for (int i = 0; i < PT_MAX_SLOTS; i++) { + P.state[i] = S_FREE; + P.ready[i] = P.done[i] = NULL; + } + P.current = 0; + P.state[0] = S_COMPOSING; + memset(&P.st, 0, sizeof P.st); + P.st.slots = cfg->slots; + mtx_unlock(&P.mtx); + int ok; +#ifdef _WIN32 + P.thread = CreateThread(NULL, 0, present_main, NULL, 0, NULL); + ok = P.thread != NULL; + if (ok) SetThreadPriority(P.thread, THREAD_PRIORITY_ABOVE_NORMAL); +#else + pthread_attr_t attr; + pthread_attr_init(&attr); +# if defined(__APPLE__) + pthread_attr_set_qos_class_np(&attr, QOS_CLASS_USER_INTERACTIVE, 0); +# endif + ok = pthread_create(&P.thread, &attr, present_main, NULL) == 0; + pthread_attr_destroy(&attr); +#endif + if (!ok) return 0; + mtx_lock(&P.mtx); + while (!P.init_done) cnd_wait(&P.cv, &P.mtx); + ok = P.init_ok; + mtx_unlock(&P.mtx); + if (!ok) { +#ifdef _WIN32 + WaitForSingleObject(P.thread, INFINITE); + CloseHandle(P.thread); +#else + pthread_join(P.thread, NULL); +#endif + return 0; + } + P.running = 1; + return 1; +} + +void pt_stop(void) { + if (!P.running) return; + mtx_lock(&P.mtx); + P.stop = 1; + cnd_bcast(&P.cv); + mtx_unlock(&P.mtx); +#ifdef _WIN32 + WaitForSingleObject(P.thread, INFINITE); + CloseHandle(P.thread); +#else + pthread_join(P.thread, NULL); +#endif + P.running = 0; +} + +int pt_running(void) { return P.running; } +int pt_current(void) { return P.running ? P.current : -1; } + +int pt_submit(void *ready, void **next_done, uint64_t *wait_ns) { + if (next_done) *next_done = NULL; + if (wait_ns) *wait_ns = 0; + if (!P.running) return -1; + mtx_lock(&P.mtx); + const int cur = P.current; + P.ready[cur] = ready; + P.state[cur] = S_QUEUED; + P.queue[(P.q_head + P.q_len) % PT_MAX_SLOTS] = cur; + P.q_len++; + P.st.submits++; + if (P.q_len > P.st.queued_high) P.st.queued_high = P.q_len; + cnd_bcast(&P.cv); + int next = -1; + uint64_t t0 = 0; + for (;;) { + /* Prefer the slot after the current one: the oldest free. */ + for (int k = 1; k <= P.cfg.slots; k++) { + const int s = (cur + k) % P.cfg.slots; + if (P.state[s] == S_FREE) { next = s; break; } + } + if (next >= 0) break; + if (!t0) { t0 = now_ns(); P.st.waits++; } + cnd_wait(&P.cv, &P.mtx); + } + if (t0) { + const uint64_t w = now_ns() - t0; + P.st.wait_ns += w; + if (wait_ns) *wait_ns = w; + } + P.state[next] = S_COMPOSING; + P.current = next; + if (next_done) *next_done = P.done[next]; + else if (P.done[next] && P.cfg.dispose) P.cfg.dispose(P.cfg.user, P.done[next]); + P.done[next] = NULL; + mtx_unlock(&P.mtx); + return next; +} + +void pt_drain(void) { + if (!P.running) return; + mtx_lock(&P.mtx); + for (;;) { + int busy = P.q_len > 0; + for (int i = 0; i < P.cfg.slots && !busy; i++) + busy = P.state[i] == S_PRESENTING; + if (!busy) break; + cnd_wait(&P.cv, &P.mtx); + } + mtx_unlock(&P.mtx); +} + +void pt_get_stats(PtStats *out) { + if (!out) return; + if (!s_pt_inited) { memset(out, 0, sizeof *out); return; } + mtx_lock(&P.mtx); + *out = P.st; + mtx_unlock(&P.mtx); +} diff --git a/runtime/tests/run_gl_frame_gen.py b/runtime/tests/run_gl_frame_gen.py index 128fba32..0a2d696d 100644 --- a/runtime/tests/run_gl_frame_gen.py +++ b/runtime/tests/run_gl_frame_gen.py @@ -77,7 +77,7 @@ def main(): # facade (gpu_render.c) and render thread (render_thread.c). sources = [("probe", fixture), ("sw", src / "gpu_sw_renderer.c"), ("fi", src / "frame_interpolation.c"), ("rp", src / "render_pass_plan.c"), - ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), + ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), ("pth", src / "present_thread.c"), ("facade", src / "gpu_render.c"), ("fg", src / "frame_gen.c")] sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"] # MinGW's PE linker reports undefined references from sections it later @@ -139,6 +139,54 @@ def main(): print(f"FAIL scale {s} {path} {timing}: generated frames off={runs[0]['generated']} " f"on={runs[1]['generated']}") ok = False + # Present thread ([video] present_thread): the fixture checks the + # window shows every composed image bit for bit, in order. The real + # images match the direct-swap run: bit for bit, except that + # rasterizing into a texture instead of the window's drawable may + # round a filtered present pixel differently (seen on Apple's + # GL-on-Metal: 1 LSB, a pixel or two per frame); allowed up to 1 LSB + # on at most 0.05 % of the pixels, and reported. + for fg in (0, 1): + dirs = {} + for mode in ("direct", "pt"): + sub = dest / f"pt-{s}-{path}-{timing}-{fg}-{mode}" + sub.mkdir(exist_ok=True) + cmd = [str(c) for c in (probe, s, fg, path, args.frames, timing)] + if mode == "pt": + cmd.append("pt") + env = dict(os.environ, FG_DUMP="1") + r = subprocess.run(cmd, cwd=sub, capture_output=True, text=True, + encoding="utf-8", errors="replace", env=env) + p = parse(r.stdout) + if r.returncode or p["failures"] != 0: + print(f"FAIL scale {s} {path} {timing} fg={fg} {mode}: exit={r.returncode}", + r.stdout.strip().splitlines()[-4:], r.stderr.strip()[-800:]) + ok = False + dirs[mode] = sub + files = sorted(f.name for f in dirs["direct"].glob("real*.rgba")) + other = sorted(f.name for f in dirs["pt"].glob("real*.rgba")) + if files != other or not files: + print(f"FAIL scale {s} {path} {timing} fg={fg}: present thread real frames " + f"{len(other)} vs {len(files)}") + ok = False + continue + exact, worst, worst_px = 0, 0, 0.0 + for name in files: + a = (dirs["direct"] / name).read_bytes() + b = (dirs["pt"] / name).read_bytes() + if a == b: + exact += 1 + continue + d = [abs(x - y) for x, y in zip(a, b) if x != y] + px = len({i // 4 for i, (x, y) in enumerate(zip(a, b)) if x != y}) + worst = max(worst, max(d) if d else 0) + worst_px = max(worst_px, px / (len(a) / 4)) + print(f"scale {s} {path} {timing} frame_generation={fg} present_thread=1: " + f"{exact}/{len(files)} real frames bit-identical, max diff {worst} LSB " + f"on {worst_px * 100:.3f} % of pixels") + if worst > 1 or worst_px > 0.0005: + print(f"FAIL scale {s} {path} {timing} fg={fg}: present thread changed real frames") + ok = False print("PASS" if ok else "FAIL") return 0 if ok else 1 diff --git a/runtime/tests/run_gl_readback_region.py b/runtime/tests/run_gl_readback_region.py index 88f8ec05..fbc0f768 100644 --- a/runtime/tests/run_gl_readback_region.py +++ b/runtime/tests/run_gl_readback_region.py @@ -73,6 +73,8 @@ def main(): ("xr", framework / "runtime/src/psx_openxr.c")] if (framework / "runtime/src/render_thread.c").exists(): sources.append(("rth", framework / "runtime/src/render_thread.c")) + if (framework / "runtime/src/present_thread.c").exists(): + sources.append(("pth", framework / "runtime/src/present_thread.c")) if (framework / "runtime/src/frame_gen.c").exists(): sources.append(("fg", framework / "runtime/src/frame_gen.c")) for name, source in sources: diff --git a/runtime/tests/run_gl_render_thread.py b/runtime/tests/run_gl_render_thread.py index b0bf1b2d..d1dafbb1 100644 --- a/runtime/tests/run_gl_render_thread.py +++ b/runtime/tests/run_gl_render_thread.py @@ -84,7 +84,7 @@ def main(): # facade (gpu_render.c) and render thread (render_thread.c). sources = [("probe", fixture), ("sw", src / "gpu_sw_renderer.c"), ("fi", src / "frame_interpolation.c"), ("rp", src / "render_pass_plan.c"), - ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), + ("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), ("pth", src / "present_thread.c"), ("facade", src / "gpu_render.c"), ("fg", src / "frame_gen.c")] sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"] # MinGW's PE linker reports undefined references from sections it later diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py index 1a13368d..b1d09cab 100644 --- a/runtime/tests/run_gl_scale_invariance.py +++ b/runtime/tests/run_gl_scale_invariance.py @@ -136,6 +136,8 @@ def main(): sources.append(("xr", framework / "runtime/src/psx_openxr.c")) if (framework / "runtime/src/render_thread.c").exists(): sources.append(("rth", framework / "runtime/src/render_thread.c")) + if (framework / "runtime/src/present_thread.c").exists(): + sources.append(("pth", framework / "runtime/src/present_thread.c")) if (framework / "runtime/src/frame_gen.c").exists(): sources.append(("fg", framework / "runtime/src/frame_gen.c")) # Unused renderer functions reference the rest of the runtime; the linker diff --git a/runtime/tests/run_gl_texture_filter.py b/runtime/tests/run_gl_texture_filter.py index 2a35c0e8..9fb58a34 100644 --- a/runtime/tests/run_gl_texture_filter.py +++ b/runtime/tests/run_gl_texture_filter.py @@ -41,6 +41,8 @@ def main(): sources.append(('xr',fw/'runtime/src/psx_openxr.c')) if (fw/'runtime/src/render_thread.c').exists(): sources.append(('rth',fw/'runtime/src/render_thread.c')) + if (fw/'runtime/src/present_thread.c').exists(): + sources.append(('pth',fw/'runtime/src/present_thread.c')) if (fw/'runtime/src/frame_gen.c').exists(): sources.append(('fg',fw/'runtime/src/frame_gen.c')) objects=[] diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c index d15184be..efc8d5a4 100644 --- a/runtime/tests/test_gl_frame_gen.c +++ b/runtime/tests/test_gl_frame_gen.c @@ -18,6 +18,11 @@ * Original source-owned scene; no retail payload. */ static void fixture_present(int generated); #define GL_PRESENT_TEST_HOOK(gen) fixture_present(gen) +/* argv[6] == "pt": the present thread is on; every image it puts in the + * window (read back after its copy, before its swap) must be the image the + * composing side presented, in the same order. */ +static void fixture_window(int slot); +#define GL_PRESENT_THREAD_TEST_HOOK(slot) fixture_window(slot) #include "gpu_gl_renderer.c" #include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" @@ -101,7 +106,37 @@ static uint64_t read_back(uint32_t *dst) { glReadPixels(0, 0, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE, dst); return fnv(dst, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); } +#define MAX_SHOWN 4096 +static int pt_mode; +static uint64_t composed_d[MAX_SHOWN], window_d[MAX_SHOWN]; +static int n_composed, n_window; +static void fixture_window(int slot) { + (void)slot; + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + uint32_t *px = (uint32_t *)malloc((size_t)ww * wh * 4); + p_glBindFramebuffer_raw(PSXGL_READ_FRAMEBUFFER, 0); + glReadBuffer(GL_BACK); + glPixelStorei(GL_PACK_ALIGNMENT, 4); + glReadPixels(0, 0, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE, px); + if (n_window < MAX_SHOWN) + window_d[n_window] = fnv(px, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + n_window++; + free(px); +} static void fixture_present(int generated) { + if (pt_mode) { + int ww = 0, wh = 0; + SDL_GL_GetDrawableSize(s_win, &ww, &wh); + uint32_t *px = (uint32_t *)malloc((size_t)ww * wh * 4); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + glPixelStorei(GL_PACK_ALIGNMENT, 4); + glReadPixels(0, 0, ww, wh, GL_RGBA, GL_UNSIGNED_BYTE, px); + if (n_composed < MAX_SHOWN) + composed_d[n_composed] = fnv(px, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + n_composed++; + free(px); + } if (generated) { n_gen++; return; } int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); @@ -183,6 +218,7 @@ int main(int argc, char **argv) { /* "late": the game flips to a frame one VBlank after it starts drawing * the next one (as R4 does), so a flip is not a list boundary. */ int late = argc > 5 && !strcmp(argv[5], "late"); + pt_mode = argc > 6 && !strcmp(argv[6], "pt"); if (SDL_Init(SDL_INIT_VIDEO) != 0) { fprintf(stderr, "SKIP no video (%s)\n", SDL_GetError()); return 77; @@ -214,7 +250,9 @@ int main(int argc, char **argv) { if (!gl_renderer_init_context(win)) { fprintf(stderr, "FAIL context\n"); return 2; } textures(); if (wide_mode) gr_wide_configure(426, 53); + if (pt_mode) gl_renderer_set_present_thread(1, 3); check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + if (pt_mode) check(gl_renderer_present_thread_active(), "present thread started"); if (fg) { setenv("PSX_FRAME_GEN_FORCE", "1", 1); gl_renderer_set_frame_generation(1); @@ -280,8 +318,14 @@ int main(int argc, char **argv) { check(glGetError() == GL_NO_ERROR, "GL error"); printf("real=%016llx\n", (unsigned long long)real_seq); printf("presents=%llu generated=%llu\n", (unsigned long long)n_real, (unsigned long long)n_gen); + gl_renderer_shutdown(); /* stops the render and present threads: all shown */ + if (pt_mode) { + int same = n_composed == n_window && n_composed <= MAX_SHOWN && n_composed > 0; + for (int i = 0; same && i < n_composed; i++) same = composed_d[i] == window_d[i]; + printf("pt composed=%d shown=%d\n", n_composed, n_window); + check(same, "the window shows every composed image, in order"); + } printf("checks=%d failures=%d\n", checks, failures); - gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); return failures ? 1 : 0; diff --git a/runtime/tests/test_present_thread.c b/runtime/tests/test_present_thread.c new file mode 100644 index 00000000..c1a8aa19 --- /dev/null +++ b/runtime/tests/test_present_thread.c @@ -0,0 +1,128 @@ +/* present_thread_test — the present thread core (present_thread.c) against a + * fake presenter: submission order is presentation order, a slot is never + * composed while queued or presenting, each done token reaches the producer + * that next takes its slot, a slow display blocks the producer (and a fast one + * still keeps every invariant), stop presents everything queued and disposes leftover tokens, a + * failed context start reports failure. */ +#include "present_thread.h" + +#include +#include +#include +#include +#include +#include + +static int checks, failures; +static void check(int ok, const char *label) { + checks++; + if (!ok) { fprintf(stderr, "FAIL %s\n", label); failures++; } +} +static void sleep_us(long us) { + struct timespec ts = { us / 1000000, (us % 1000000) * 1000 }; + nanosleep(&ts, NULL); +} + +#define N_FRAMES 600 +static _Atomic int slot_busy[PT_MAX_SLOTS]; /* 1 while queued or presenting */ +static uintptr_t presented[N_FRAMES + 8]; +static _Atomic int n_presented; +static int ctx_ok = 1, ctx_on, ctx_off; +static long present_us = 0; +static _Atomic int disposed; +static uintptr_t last_done[PT_MAX_SLOTS]; +static _Atomic int order_bad; + +static int fake_ctx(void *u, int cur) { (void)u; if (cur) { ctx_on++; return ctx_ok; } ctx_off++; return 1; } +static void *fake_present(void *u, int slot, void *ready) { + (void)u; + if (!atomic_load(&slot_busy[slot])) atomic_store(&order_bad, 1); + int i = atomic_fetch_add(&n_presented, 1); + if (i < N_FRAMES + 8) presented[i] = (uintptr_t)ready; + if (present_us) sleep_us(present_us + (rand() % 3) * present_us / 2); + atomic_store(&slot_busy[slot], 0); + /* done token: slot in the low byte, frame above */ + return (void *)(((uintptr_t)ready << 8) | (uintptr_t)slot | 0x80u); +} +static void fake_dispose(void *u, void *t) { (void)u; (void)t; atomic_fetch_add(&disposed, 1); } + +static PtConfig cfg(int slots) { + PtConfig c = { slots, fake_ctx, fake_present, fake_dispose, NULL }; + return c; +} +static void reset(void) { + for (int i = 0; i < PT_MAX_SLOTS; i++) { atomic_store(&slot_busy[i], 0); last_done[i] = 0; } + atomic_store(&n_presented, 0); + atomic_store(&disposed, 0); + atomic_store(&order_bad, 0); + ctx_on = ctx_off = 0; +} + +static void run(int slots, long us, int expect_waits) { + reset(); + present_us = us; + PtConfig c = cfg(slots); + check(pt_start(&c), "start"); + check(pt_current() == 0, "first slot is 0"); + int bad_reuse = 0, bad_token = 0; + for (int f = 1; f <= N_FRAMES; f++) { + const int cur = pt_current(); + if (atomic_load(&slot_busy[cur])) bad_reuse++; + /* producer "composes" for a bit */ + if (f % 7 == 0) sleep_us(200); + atomic_store(&slot_busy[cur], 1); + void *done = NULL; + uint64_t w = 0; + const int next = pt_submit((void *)(uintptr_t)f, &done, &w); + if (next < 0 || next >= slots || next == cur || atomic_load(&slot_busy[next])) bad_reuse++; + if ((uintptr_t)done != last_done[next]) bad_token++; + /* this slot's next token is unknown until presented again */ + last_done[next] = 0; + /* remember what present will hand back for cur */ + last_done[cur] = (((uintptr_t)f) << 8) | (uintptr_t)cur | 0x80u; + } + pt_drain(); + check(atomic_load(&n_presented) == N_FRAMES, "every submitted slot presented"); + int ordered = 1; + for (int i = 0; i < N_FRAMES; i++) if (presented[i] != (uintptr_t)(i + 1)) ordered = 0; + check(ordered, "presented in submission order"); + check(!bad_reuse, "a slot is never composed while queued or presenting"); + check(!bad_token, "done token reaches the producer taking the slot"); + check(!atomic_load(&order_bad), "present sees only queued slots"); + PtStats st; + pt_get_stats(&st); + check(st.submits == N_FRAMES && st.presents == N_FRAMES, "stats count"); + check(st.queued_high <= slots, "bounded queue"); + if (expect_waits > 0) check(st.waits > 0 && st.wait_ns > 0, "slow display blocks the producer"); + /* stop with frames queued: they are presented, leftover tokens disposed */ + present_us = 2000; + for (int f = 0; f < slots - 1; f++) { + void *d = NULL; + atomic_store(&slot_busy[pt_current()], 1); + (void)pt_submit((void *)(uintptr_t)(N_FRAMES + 1 + f), &d, NULL); + } + pt_stop(); + check(atomic_load(&n_presented) == N_FRAMES + slots - 1, "stop presents the queue"); + check(atomic_load(&disposed) > 0, "stop disposes uncollected tokens"); + check(ctx_on == 1 && ctx_off == 1, "context taken and released once"); + check(!pt_running() && pt_submit(NULL, NULL, NULL) == -1, "stopped"); +} + +int main(void) { + srand(7); + run(3, 0, 0); + run(3, 1500, 1); + run(2, 800, 1); + run(4, 300, -1); /* may or may not wait: invariants only */ + /* context failure */ + reset(); + ctx_ok = 0; + PtConfig c = cfg(3); + check(!pt_start(&c), "start fails when the context cannot be made current"); + check(!pt_running(), "not running after failed start"); + ctx_ok = 1; + PtConfig bad = cfg(1); + check(!pt_start(&bad), "one slot refused"); + printf("present_thread_test: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +}