From e4f1f985428101eb6c8f83fce834e281860b1fd2 Mon Sep 17 00:00:00 2001 From: Yves Date: Fri, 23 Oct 2026 03:25:18 +0800 Subject: [PATCH] Capture both HUD mattes with one checkpoint and readback Co-authored-by: Yves --- .../upstream/vr-paired-hud-capture.md | 17 ++++++ runtime/include/gpu_gl_renderer.h | 2 +- runtime/include/mod_plugins.h | 22 ++++--- runtime/src/gpu_gl_renderer.c | 22 ++++--- runtime/src/render_pass.c | 55 ++++++++++++------ runtime/tests/test_render_pass_abort.c | 4 +- runtime/tests/test_render_pass_sandbox.c | 57 ++++++++++++++----- 7 files changed, 128 insertions(+), 51 deletions(-) create mode 100644 docs/internal/upstream/vr-paired-hud-capture.md diff --git a/docs/internal/upstream/vr-paired-hud-capture.md b/docs/internal/upstream/vr-paired-hud-capture.md new file mode 100644 index 00000000..143dc40a --- /dev/null +++ b/docs/internal/upstream/vr-paired-hud-capture.md @@ -0,0 +1,17 @@ +# Capture both HUD mattes with one checkpoint and readback + +Source: [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp). +- [a876bae3](https://github.com/FractalEngineer/psxrecomp/commit/a876bae3385a8b972d8a5cf083ad5e30cf05c4bd) +- [d2554481](https://github.com/FractalEngineer/psxrecomp/commit/d2554481fac39409cebef7602e59bd155e000fe6) + +Original author: Yves ; retained as commit author +and Co-authored-by on this reconstructed port. + +Adapted: this feature and its tests. Excluded: other VR features, game policy, +assets and chronological investigation notes. +Base: `4419d234ab66c839da94dba597794ac47a09eb00` (current upstream master or the preceding feature). + +Validation: Windows/GCC 13.2 Release: sandbox and abort tests pass (2/2), including both matte buffers and capture-only checkpoint reuse. + +Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace). +Requires a framework pin bump and rebuild; no game/BIOS regeneration. diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index cca2306b..9b287802 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -136,7 +136,7 @@ uint32_t gl_renderer_stereo_unavailable(void); int gl_renderer_stereo_begin(int x, int y, int w, int h, int reuse_backup); int gl_renderer_stereo_end(uint32_t eye, int keep); void gl_renderer_stereo_stage_reset(void); -int gl_renderer_stereo_stage_readback(uint32_t eye,uint32_t width,uint32_t height,uint8_t *rgba); +int gl_renderer_stereo_stage_readback_pair(uint32_t width,uint32_t height,uint8_t *rgba); int gl_renderer_stereo_publish(uint64_t pair_id, uint64_t guest_cycle, const int32_t eye_view[2][3]); void gl_renderer_stereo_reset(void); diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 07f87920..9ddd2c3c 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -651,14 +651,20 @@ uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks, * partially updated plugin state) across guest code, or tolerate the abort. */ int psx_mod_render_pass(struct CPUState* cpu, const PSXModRenderPass* pass, PSXModRenderPassFn fn, void* user); -/* Isolated draw/readback using the stereo-capable rollback transaction. - * Never publishes an eye pair or changes presentation. Returns native-sized - * top-to-bottom RGBA8 pixels (alpha remains the PSX mask bit, not coverage). - * Call outside another pass. rgba_bytes must cover rect->w*rect->h*4. */ -int psx_mod_render_capture_rgba(struct CPUState *cpu,const PSXModRenderPass *rect, - PSXModRenderPassFn fn,void *user, - uint8_t *rgba,uint32_t rgba_bytes); -#define PSX_MOD_RENDER_CAPTURE_RGBA 1 +/* Isolated dual-matte draw/readback using the stereo-capable rollback + * transaction. Draws two mattes of the same slice, the first into eye 0 of the + * stage surface and the second into eye 1, so both planes of a frame cost one + * readback instead of two. Never publishes an eye pair or changes presentation. + * Returns native-sized top-to-bottom RGBA8 pixels (alpha remains the PSX mask + * bit, not coverage). Call outside another pass. rgba_bytes must cover + * 2*rect->w*rect->h*4; the first matte arrives in the buffer's first half and + * the second in its second half. */ +int psx_mod_render_capture_rgba_pair(struct CPUState *cpu, + const PSXModRenderPass *rect, + PSXModRenderPassFn fn,void *user_first, + void *user_second,uint8_t *rgba, + uint32_t rgba_bytes); +#define PSX_MOD_RENDER_CAPTURE_RGBA_PAIR 1 /* * Why passes cannot run right now, the host-time budget aside (a plan that * returns 0 while this says READY was shed for time). A plugin that relies on diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index ddecb0f6..56db186d 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -8807,27 +8807,33 @@ void gl_renderer_stereo_stage_reset(void) { GL_RT_SYNC("stereo_stage_reset"); s_stereo_pair[1 - s_stereo_current].mask = 0; } -int gl_renderer_stereo_stage_readback(uint32_t eye,uint32_t w,uint32_t h,uint8_t *rgba) { - GL_RT_SYNC("capture_readback"); - if(eye>=2 || !rgba || !w || !h || w>1024 || h>512)return 0; +/* Both staged eye images in one read. The blit target is double height, eye 0 + * into its lower half and eye 1 into its upper half, so one glReadPixels + * returns the two height-sized planes contiguously, eye 0 first. */ +int gl_renderer_stereo_stage_readback_pair(uint32_t w,uint32_t h,uint8_t *rgba) { + GL_RT_SYNC("capture_readback_pair"); + if(!rgba || !w || !h || w>1024 || h>512)return 0; StereoPair *pair=&s_stereo_pair[1-s_stereo_current]; - if(!(pair->mask&(1u<mask&3u)!=3u)return 0; GLint read,draw,texture,pack,pbo,row,skipr,skipp; GLboolean scissor=glIsEnabled(GL_SCISSOR_TEST),srgb=glIsEnabled(0x8DB9); glGetIntegerv(0x8CAA,&read);glGetIntegerv(0x8CA6,&draw);glGetIntegerv(GL_TEXTURE_BINDING_2D,&texture); glGetIntegerv(GL_PACK_ALIGNMENT,&pack);glGetIntegerv(0x88ED,&pbo);glGetIntegerv(GL_PACK_ROW_LENGTH,&row); glGetIntegerv(GL_PACK_SKIP_ROWS,&skipr);glGetIntegerv(GL_PACK_SKIP_PIXELS,&skipp); int ok=pass_make_color_fbo(&s_capture_read_tex,NULL,&s_capture_read_fbo,&s_capture_read_w,&s_capture_read_h, - w,h,GL_RGBA8,GL_RGBA,GL_UNSIGNED_BYTE); + w,h*2u,GL_RGBA8,GL_RGBA,GL_UNSIGNED_BYTE); if(ok) { glDisable(GL_SCISSOR_TEST);glDisable(0x8DB9); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,pair->fbo[eye]); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER,s_capture_read_fbo); - p_glBlitFramebuffer(0,0,pair->tw[eye],pair->th[eye],0,0,w,h,GL_COLOR_BUFFER_BIT,GL_NEAREST); + for(uint32_t eye=0;eye<2u;eye++) { + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,pair->fbo[eye]); + p_glBlitFramebuffer(0,0,pair->tw[eye],pair->th[eye],0,(GLint)(eye*h),w,(GLint)((eye+1u)*h), + GL_COLOR_BUFFER_BIT,GL_NEAREST); + } p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,s_capture_read_fbo); p_glBindBuffer(0x88EB,0);glPixelStorei(GL_PACK_ALIGNMENT,1);glPixelStorei(GL_PACK_ROW_LENGTH,0); glPixelStorei(GL_PACK_SKIP_ROWS,0);glPixelStorei(GL_PACK_SKIP_PIXELS,0); - glReadPixels(0,0,w,h,GL_RGBA,GL_UNSIGNED_BYTE,rgba); + glReadPixels(0,0,w,h*2u,GL_RGBA,GL_UNSIGNED_BYTE,rgba); } p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,read);p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER,draw); glBindTexture(GL_TEXTURE_2D,texture);glPixelStorei(GL_PACK_ALIGNMENT,pack); diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 73862abc..0c1d44ed 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -308,6 +308,12 @@ static int s_shed_geom_valid; /* A HUD-only capture transaction reuses eye 0's slot but is not an eye draw; * frame tracing must not attribute its cost to the left eye. */ static int s_capture_only; +/* The two transactions of one HUD capture call are a pair of their own: the + * second matte draws from exactly the state the restore that ended the first + * one put back, so it may reuse the first one's checkpoint the way a pair's + * right eye reuses the left's. Scoped to the capture call, so the reuse never + * reaches the pair that renders later in the same frame. */ +static int s_capture_pair; static int pass_refuse(const char *reason, uint32_t status) { /* A refusal always carries a reason: last_failure.reason == NULL means @@ -900,10 +906,10 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass s_open_generation = 0; s_leaks_before = gl_renderer_pass_leaks(); - /* A stereo eye whose machine state still equals the pair's captured - * baseline skips re-capturing it. */ - int reuse_baseline = eye >= 0 && !s_capture_only && - s_baseline_ready && baseline_reuse_enabled(); + /* A stereo eye, or the second matte of a capture, whose machine state still + * equals the baseline captured for it skips re-capturing that state. */ + int reuse_baseline = eye >= 0 && s_baseline_ready && baseline_reuse_enabled() && + (!s_capture_only || s_capture_pair); if (!checkpoint_save(cpu, reuse_baseline)) { gl_renderer_pass_begin_diag(&s_attempt.gl); s_stats.checkpoint_refused++; @@ -1039,10 +1045,12 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass } } /* The restore just put the live machine back to the captured baseline, so - * the other eye of this pair may reuse it. Anything else - a non-stereo - * transaction, a HUD-only capture, a failed eye or an unverified restore - - * leaves nothing safe to reuse. */ - s_baseline_ready = (eye >= 0 && !s_capture_only && ok && verify_ok) ? 1 : 0; + * the other eye of this pair, or the other matte of this capture, may reuse + * it. Anything else - a non-stereo transaction, a capture that is not part + * of a matte pair, a failed eye or an unverified restore - leaves nothing + * safe to reuse. */ + s_baseline_ready = (eye >= 0 && ok && verify_ok && + (!s_capture_only || s_capture_pair)) ? 1 : 0; t1 = gl_renderer_perf_ticks(); { @@ -1272,24 +1280,37 @@ void psx_mod_stereo_set_budget_ms(double ms) { s_shed_xr_budget_ms = (ms > 0.0 && ms < 1000.0) ? ms : 0.0; } -int psx_mod_render_capture_rgba(CPUState *cpu,const PSXModRenderPass *rect, - PSXModRenderPassFn fn,void *user, - uint8_t *rgba,uint32_t bytes) { - if(s_pair_busy || !cpu || !rect || !fn || !rgba || rect->struct_sizestruct_sizew || !rect->h || rect->x+rect->w>1024 || rect->y+rect->h>512 || - (uint64_t)rect->w*rect->h*4>bytes || transaction_status(1)!=PSX_MOD_RENDER_PASS_READY)return 0; + (uint64_t)rect->w*rect->h*8>bytes || transaction_status(1)!=PSX_MOD_RENDER_PASS_READY)return 0; if(g_overlay_flush_pending_cycles)g_overlay_flush_pending_cycles(); uint64_t trace_t0=gl_renderer_perf_ticks(); s_pair_busy=1;s_plan_serial++; gl_renderer_stereo_stage_reset(); PSXModRenderPass capture=*rect;capture.alpha_q16=0; - s_capture_only=1; /* this eye-0 transaction is a HUD capture, not an eye draw */ - int ok=render_transaction(cpu,&capture,fn,user,0); - s_capture_only=0; + /* Start uncaptured: the ready flag carries the pair's baseline from the + * previous frame, which describes a machine the guest has since changed. */ + s_capture_only=1;s_capture_pair=1;s_baseline_ready=0; + uint64_t cycle=psx_cycle_count,stores=g_guest_store_count; + int ok=render_transaction(cpu,&capture,fn,user_first,0); + /* The second matte must draw from the state the first one's restore put + * back. If the clock or the store count moved, that baseline no longer + * describes it, so capture the machine again instead of reusing it. */ + if(psx_cycle_count!=cycle || g_guest_store_count!=stores)s_baseline_ready=0; + if(ok)ok=render_transaction(cpu,&capture,fn,user_second,1); + s_capture_only=0;s_capture_pair=0;s_baseline_ready=0; uint64_t trace_t1=gl_renderer_perf_ticks(); psx_frame_trace_add(PSX_TRACE_HUD_DRAW, trace_ticks_to_us(trace_t1-trace_t0)); - if(ok)ok=gl_renderer_stereo_stage_readback(0,rect->w,rect->h,rgba); + if(ok)ok=gl_renderer_stereo_stage_readback_pair(rect->w,rect->h,rgba); psx_frame_trace_add(PSX_TRACE_HUD_READBACK, trace_ticks_to_us(gl_renderer_perf_ticks()-trace_t1)); gl_renderer_stereo_stage_reset();s_pair_busy=0; diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index d623e8c3..f161d86f 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -215,8 +215,8 @@ int gl_renderer_stereo_end(uint32_t eye, int keep) { return 1; } void gl_renderer_stereo_stage_reset(void) { s_stereo_mask = 0; } -int gl_renderer_stereo_stage_readback(uint32_t eye,uint32_t w,uint32_t h,uint8_t *rgba) { - (void)eye;(void)w;(void)h;(void)rgba;return 0; +int gl_renderer_stereo_stage_readback_pair(uint32_t w,uint32_t h,uint8_t *rgba) { + (void)w;(void)h;(void)rgba;return 0; } void gl_renderer_stereo_reset(void) { s_stereo_mask = 0; s_stereo_published = 0; } int gl_renderer_stereo_set_presentation(uint32_t mode) { return mode <= 1; } diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index f80d7c6f..d54332d4 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -285,9 +285,9 @@ int gl_renderer_stereo_end(uint32_t eye, int keep) { return 1; } void gl_renderer_stereo_stage_reset(void) { s_stereo_mask = 0; } -int gl_renderer_stereo_stage_readback(uint32_t eye,uint32_t w,uint32_t h,uint8_t *rgba) { - if(!(s_stereo_mask & (1u<gpr[8]=0x1234;s_ram[0x456]=0x42;psx_advance_cycles(5000); return 1; } static void test_isolated_capture(void) { CPUState cpu={0};PSXModRenderPass rect={sizeof rect,0,0,0,2,2}; - uint8_t pixels[16]={0}; + int first=1,second=2; + uint8_t pixels[32]={0}; render_pass_reset_session();s_gl_status=0;s_begin_ok=1;s_netplay=0; cpu.gpr[8]=123;s_ram[0x456]=0x77; uint64_t cycle=psx_cycle_count,published=s_stereo_published; - CHECK(psx_mod_render_capture_rgba(&cpu,&rect,isolated_draw,NULL,pixels,sizeof pixels), - "isolated capture succeeds without publishing a stereo pair"); + s_dual_draws=0; + CHECK(psx_mod_render_capture_rgba_pair(&cpu,&rect,isolated_draw,&first,&second, + pixels,sizeof pixels), + "isolated dual capture succeeds without publishing a stereo pair"); + CHECK(s_dual_draws==2 && s_dual_users[0]==&first && s_dual_users[1]==&second, + "one transaction per matte, each with its own user"); CHECK(cpu.gpr[8]==123 && s_ram[0x456]==0x77 && psx_cycle_count==cycle, "isolated capture restores CPU, RAM and the guest clock"); - CHECK(s_stereo_published==published && s_stereo_mask==0 && pixels[0]==0x5A, - "isolated pixels copied and temporary staging discarded"); - CHECK(!psx_mod_render_capture_rgba(&cpu,&rect,isolated_draw,NULL,pixels,sizeof pixels-1), + CHECK(s_stereo_published==published && s_stereo_mask==0 && + pixels[0]==0x5A && pixels[31]==0x5A, + "both isolated planes copied and temporary staging discarded"); + /* The two mattes draw from one machine state, so the second one reuses the + * checkpoint the first one captured, the way a pair's right eye does. */ + RenderStereoStats stats; + render_stereo_get_stats(&stats); + CHECK(stats.checkpoint_saves==1 && stats.checkpoint_reuses==1, + "the two mattes of one capture share one checkpoint"); + s_dual_draws=0; + CHECK(!psx_mod_render_capture_rgba_pair(&cpu,&rect,isolated_draw,&first,&second, + pixels,sizeof pixels-1), "undersized pixel buffer refused"); + CHECK(s_dual_draws==0 && !psx_mod_render_capture_rgba_pair(&cpu,&rect,isolated_draw, + &first,NULL,pixels,sizeof pixels), + "a missing second matte is refused before any draw"); + /* The capture's shared checkpoint is scoped to the capture: the pair that + * renders later in the same frame must still capture its first eye. */ + PSXModStereoFrame f={sizeof f,2,0,240,320,240}; + render_pass_reset_session();s_gl_status=0;s_begin_ok=1;s_eye_decline=-1;s_eye_calls=0; + CHECK(psx_mod_render_stereo(&cpu,&f,pair_draw,NULL)==1, + "a pair still renders after a capture"); + render_stereo_get_stats(&stats); + CHECK(stats.checkpoint_saves==1 && stats.checkpoint_reuses==1, + "the pair after a capture captures its own baseline"); } int main(void) { test_store_policy();