From 856cbabffebac0964c5edb6dad2e7bb7ede6c119 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:57:39 -0700 Subject: [PATCH] Render passes: make pass image textures as slots fill pass_slot_cap() limits a generation to what two generations fit in 256 MiB, but pass_gen_textures() allocated all 17 slots of each generation at internal-resolution size as soon as passes ran, whatever the cap: 34 textures, about 167 MB at 4x and 1.5 GB at 12x for at most two used slots. Slot textures are now made on first use, never past the cap, so the budget holds at any internal scale; a size change frees the old set, and context teardown frees every pass texture, backup and journal FBO and forgets their names (a new context no longer reuses dead ones). render_pass_stats reports image_textures and image_bytes. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 5 +- docs/TCP_COMMANDS.md | 2 +- runtime/include/gpu_gl_renderer.h | 2 + runtime/src/debug_server.c | 10 ++- runtime/src/gpu_gl_renderer.c | 86 +++++++++++++++++++++--- runtime/tests/test_render_pass_guards.py | 11 +++ 6 files changed, 100 insertions(+), 16 deletions(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index c65e9c4c..989de283 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -94,8 +94,9 @@ a smoothed per-pass cost. Nothing ever slows the guest down: when the budget runs out, fewer passes are rendered. Pass images are kept at internal resolution (the size the presenter -captures); two generations fit a 256 MiB budget, which limits passes per -frame at very high internal resolutions. +captures). Their textures are made as the slots fill, never more slots than +two generations fit in 256 MiB, which limits passes per frame at very high +internal resolutions; a size change frees the old set. ## Verifying a title diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 7506c8cc..792f2945 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -54,7 +54,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `present_shot` | ✓ | | `path` | PNG of the **composed present surface** — the frame after the backend fits the display buffer to the window, so it carries the presented aspect. ⚠ every other capture resolves the display buffer *before* that fit: on a 508×256 display in a 4:3 window they answer 508×256 while the player sees 640×480. Use this one for anything aspect-shaped (widescreen, letterbox), where a pre-fit buffer would hide the very stage the change touches. Staged and fulfilled on the next present, so the ack means *queued* — poll `present_shot_seq`. Unavailable headless and on the Vulkan backend (its swapchain has no readback hook) | | `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates | | `gl_interp` | ✓ | | — | OpenGL frame-rate presenter ([FRAME_RATE.md](FRAME_RATE.md)): enabled/suspended, host and target Hz, swaps, `source` (`vblank`/`flip`), `flip_period`, `captures` (new source frames) and `duplicates` (VBlanks that re-presented the same frame) | -| `render_pass_stats` | ✓ | | — | Render passes ([RENDER_PASSES.md](RENDER_PASSES.md)): plans, phases wanted/planned (shedding), passes, rollbacks (`nesting_repairs`: watchdog aborts whose skipped frame exits the restore undid), dropped device stores by class, `verify_mismatch` under `PSX_RENDER_PASS_VERIFY=1`, host-time split per pass, presents made from pass images | +| `render_pass_stats` | ✓ | | — | Render passes ([RENDER_PASSES.md](RENDER_PASSES.md)): plans, phases wanted/planned (shedding), passes, rollbacks (`nesting_repairs`: watchdog aborts whose skipped frame exits the restore undid), dropped device stores by class, `verify_mismatch` under `PSX_RENDER_PASS_VERIFY=1`, host-time split per pass, presents made from pass images, pass image textures allocated (`image_textures`, `image_bytes`) | | `render_pass_dump` | ✓ | | `path`, `count` | Write the images (the game's own frame, then each pass in phase order) of the next `count` frames that get passes as `/g__a.png` | | `geom_correction` | | ✓ | — | `[video] geometry_correction` / `perspective_texturing` engagement: enable flag plus free-running `geometry_vertex_hits` and `perspective_triangles` totals. Both enhancements silently fall back to the faithful path on anything they cannot prove is projected geometry, so a zero counter with the flag on means the title never qualifies — sample twice and diff for a rate | | `sio_state` | ✓ | ✓ | — | SIO registers + (native only) pad/memcard protocol + TX/RX history | diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 5b6f39ef..eb787047 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -64,6 +64,8 @@ void gl_renderer_pass_service_presents(void); void gl_renderer_pass_diag(uint64_t out[8]); /* Out-of-rect VRAM writes journaled and rolled back (lifetime count). */ uint64_t gl_renderer_pass_journaled(void); +/* Pass image textures allocated now (both generations) and their bytes. */ +uint32_t gl_renderer_pass_image_textures(uint64_t *bytes); /* Debug: dump the images of the next `generations` shown frames as PNGs. */ void gl_renderer_pass_dump_arm(const char *dir, int generations); uint64_t gl_renderer_perf_ticks(void); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 4d21f020..2aeb9337 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7980,9 +7980,11 @@ static void handle_render_pass_stats(int id, const char *json) { (void)json; RenderPassStats st; - uint64_t gd[8]; + uint64_t gd[8], image_bytes = 0; + uint32_t image_textures; render_pass_get_stats(&st); gl_renderer_pass_diag(gd); + image_textures = gl_renderer_pass_image_textures(&image_bytes); send_fmt("{\"id\":%d,\"ok\":true,\"plans\":%llu,\"planned\":%llu," "\"wanted\":%llu,\"refused\":%llu,\"passes\":%llu," "\"aborted\":%llu,\"discarded\":%llu,\"watchdog\":%llu,\"vram_leaks\":%llu," @@ -7997,7 +7999,8 @@ static void handle_render_pass_stats(int id, const char *json) "\"promotions\":%llu,\"pass_presents\":%llu," "\"blended_presents\":%llu,\"expired\":%llu," "\"unmatched_flips\":%llu,\"early_presents\":%llu," - "\"cost_us\":%llu,\"frame_images\":%llu,\"journaled\":%llu}", + "\"cost_us\":%llu,\"frame_images\":%llu,\"journaled\":%llu," + "\"image_textures\":%u,\"image_bytes\":%llu}", id, (unsigned long long)st.plans, (unsigned long long)st.planned, (unsigned long long)st.wanted, (unsigned long long)st.refused, (unsigned long long)st.passes, (unsigned long long)st.aborted, @@ -8018,7 +8021,8 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned long long)gd[2], (unsigned long long)gd[3], (unsigned long long)gd[4], (unsigned long long)gd[5], (unsigned long long)gd[6], (unsigned long long)gd[7], - (unsigned long long)gl_renderer_pass_journaled()); + (unsigned long long)gl_renderer_pass_journaled(), + (unsigned)image_textures, (unsigned long long)image_bytes); } /* render_pass_dump path= count=: write the images (the game's own diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 32f741d2..d9320b49 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -3212,7 +3212,10 @@ void gl_renderer_set_swap_interval(int interval) { } } +static void pass_resources_release(void); + void gl_renderer_shutdown(void) { + pass_resources_release(); if (s_ctx) { for (unsigned i = 1; i < 65536u; ++i) if (s_bank_tex[i]) glDeleteTextures(1, &s_bank_tex[i]); @@ -4335,7 +4338,11 @@ typedef struct PassGen { double t_start, t_len; /* host ticks, set on promotion */ } PassGen; static PassGen s_pgen[2]; +/* Slot textures are made as slots fill: [0, s_pgen_alloc_n) exist, all at + * s_pgen_alloc_w x h. A generation never fills past pass_slot_cap, so two + * generations stay inside its budget whatever the internal scale. */ static GLuint s_pgen_tex[2][PASS_SLOTS]; +static uint32_t s_pgen_alloc_n[2]; static int s_pgen_alloc_w[2], s_pgen_alloc_h[2]; static int s_pgen_cur = 0; static int s_pgen_promote = 0; @@ -4484,17 +4491,32 @@ static int pass_make_color_fbo(GLuint *tex, GLuint *rb, GLuint *fbo, return 1; } -static int pass_gen_textures(int gi, int w, int h) { - if (s_pgen_alloc_w[gi] == w && s_pgen_alloc_h[gi] == h && s_pgen_tex[gi][0]) - return 1; - for (uint32_t i = 0; i < PASS_SLOTS; i++) { - if (!s_pgen_tex[gi][i]) glGenTextures(1, &s_pgen_tex[gi][i]); - glBindTexture(GL_TEXTURE_2D, s_pgen_tex[gi][i]); +static void pass_gen_release(int gi) { + if (s_ctx && s_pgen_alloc_n[gi]) + glDeleteTextures((GLsizei)s_pgen_alloc_n[gi], s_pgen_tex[gi]); + memset(s_pgen_tex[gi], 0, sizeof s_pgen_tex[gi]); + s_pgen_alloc_n[gi] = 0; + s_pgen_alloc_w[gi] = s_pgen_alloc_h[gi] = 0; +} + +/* Make sure slots [0, need) of generation gi exist at w x h. A size change + * frees the old set first (the slot cap depends on the size only). */ +static int pass_gen_reserve(int gi, uint32_t need, int w, int h) { + if (need > PASS_SLOTS || need > pass_slot_cap(w, h)) return 0; + if (s_pgen_alloc_w[gi] != w || s_pgen_alloc_h[gi] != h) { + pass_gen_release(gi); + s_pgen_alloc_w[gi] = w; + s_pgen_alloc_h[gi] = h; + } + while (s_pgen_alloc_n[gi] < need) { + GLuint *t = &s_pgen_tex[gi][s_pgen_alloc_n[gi]]; + glGenTextures(1, t); + if (!*t) return 0; + glBindTexture(GL_TEXTURE_2D, *t); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); + s_pgen_alloc_n[gi]++; } - s_pgen_alloc_w[gi] = w; - s_pgen_alloc_h[gi] = h; return 1; } @@ -4570,6 +4592,38 @@ static PassJournal s_pj[PASS_JOURNAL_MAX]; static int s_pj_n = 0; static uint64_t s_pj_total = 0; +static void pass_free_color_fbo(GLuint *tex, GLuint *rb, GLuint *fbo, + int *w, int *h) { + if (s_ctx) { + if (*fbo) p_glDeleteFramebuffers(1, fbo); + if (*tex) glDeleteTextures(1, tex); + if (rb && *rb) p_glDeleteRenderbuffers(1, rb); + } + *fbo = 0; *tex = 0; + if (rb) *rb = 0; + *w = *h = 0; +} + +/* Context teardown (gl_renderer_shutdown): free the pass images, backups + * and journal, and forget their names so a new context makes fresh ones. */ +static void pass_resources_release(void) { + pass_gen_release(0); + pass_gen_release(1); + pass_gens_invalidate(); + pass_free_color_fbo(&s_pb_hr_tex, &s_pb_hr_rb, &s_pb_hr_fbo, + &s_pb_hr_w, &s_pb_hr_h); + pass_free_color_fbo(&s_pb_raw_tex, NULL, &s_pb_raw_fbo, + &s_pb_raw_w, &s_pb_raw_h); + pass_free_color_fbo(&s_pb_wide_tex, &s_pb_wide_rb, &s_pb_wide_fbo, + &s_pb_wide_w, &s_pb_wide_h); + for (int i = 0; i < PASS_JOURNAL_MAX; i++) { + PassJournal *e = &s_pj[i]; + pass_free_color_fbo(&e->hr_tex, &e->hr_rb, &e->hr_fbo, &e->hr_w, &e->hr_h); + pass_free_color_fbo(&e->raw_tex, NULL, &e->raw_fbo, &e->raw_w, &e->raw_h); + } + s_pj_n = 0; +} + static int pass_journal_protect(int x, int y, int w, int h) { int S = s_scale; PassJournal *j; @@ -4643,7 +4697,7 @@ int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, g = &s_pgen[gi]; if (open_gen) { if (tw != s_interp_w || th != s_interp_h) return 0; /* not what is presented */ - if (!pass_gen_textures(gi, tw, th)) return 0; + if (!pass_gen_reserve(gi, 1u, tw, th)) return 0; memset(g, 0, sizeof *g); g->x = x; g->y = y; g->w = w; g->h = h; g->tex_w = tw; g->tex_h = th; @@ -4718,7 +4772,8 @@ void gl_renderer_pass_end(uint32_t alpha_q16, int keep) { flush_tex_batch(); flush_cpu_upload(); if (keep && alpha_q16 && g->valid && !g->promoted && g->n < PASS_SLOTS && - g->n < pass_slot_cap(g->tex_w, g->tex_h)) { + g->n < pass_slot_cap(g->tex_w, g->tex_h) && + pass_gen_reserve(gi, g->n + 1u, g->tex_w, g->tex_h)) { /* Passes arrive in ascending phase; keep the list sorted anyway. */ uint32_t slot = g->n, at = g->n; GLuint t; @@ -4831,6 +4886,17 @@ void gl_renderer_pass_diag(uint64_t out[8]) { uint64_t gl_renderer_pass_journaled(void) { return s_pj_total; } +uint32_t gl_renderer_pass_image_textures(uint64_t *bytes) { + uint32_t n = s_pgen_alloc_n[0] + s_pgen_alloc_n[1]; + if (bytes) { + *bytes = 0; + for (int gi = 0; gi < 2; gi++) + *bytes += (uint64_t)s_pgen_alloc_n[gi] * (uint64_t)s_pgen_alloc_w[gi] * + (uint64_t)s_pgen_alloc_h[gi] * 4u; + } + return n; +} + static uint64_t s_idle_ticks_accum_fwd(uint64_t add) { s_idle_ticks_accum += add; return s_idle_ticks_accum; diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index d2cd3309..9a26f9ac 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -80,6 +80,17 @@ assert "nesting_restore(&s_ck.nest);" in b, ( "a watchdog abort longjmps past the exits of the frames it leaves: the " "restore must put the host nesting back (render_pass_abort_test)") +gl = (SRC / "gpu_gl_renderer.c").read_text(encoding="utf-8") +b = body(gl, "static int pass_gen_reserve(int gi, uint32_t need, int w, int h) {") +assert "need > pass_slot_cap(w, h)" in b, ( + "pass image textures must stay inside the slot cap's memory budget") +assert "pass_gen_reserve(gi, g->n + 1u, g->tex_w, g->tex_h)" in gl, ( + "pass image textures are made as slots fill, not all PASS_SLOTS up front") +assert "for (uint32_t i = 0; i < PASS_SLOTS; i++) {\n if (!s_pgen_tex" not in gl, ( + "no eager allocation of every slot texture") +assert "pass_resources_release();" in body(gl, "void gl_renderer_shutdown(void) {"), ( + "pass textures die with the context: forget them at shutdown") + # Default off means no cost for other titles: the headers folded into the # overlay codegen hash (runtime/codegen_hash_sources.cmake) must not carry the # render-pass API, or every title's overlay cache and savestates would be