diff --git a/docs/RENDER_THREAD.md b/docs/RENDER_THREAD.md index 9a6422f9..5e95b5c1 100644 --- a/docs/RENDER_THREAD.md +++ b/docs/RENDER_THREAD.md @@ -176,9 +176,15 @@ resolution (below), not of this layer. ## Dynamic resolution (`[video] dynamic_resolution`) Opt-in as before (`dynamic_resolution = true`, `dynamic_resolution_min`; -`PSX_DYNRES=0/1`, `PSX_DYNRES_MIN` for one run); no new keys. The surfaces -are allocated at the configured internal resolution (the ceiling, e.g. Match -display) and the level steps between that and the minimum. With the render +`PSX_DYNRES=0/1`, `PSX_DYNRES_MIN` for one run); no new keys. The hr +(VRAM) surface is allocated at the configured internal resolution (the +ceiling, e.g. Match display) and the level steps between that and the +minimum. The native-wide surfaces are allocated at the current level and +reallocated at every step (the presented rows and margins rescaled from the +old surface into the new one): on Apple's GL-on-Metal every render pass into +an attachment loads and stores the whole attachment, so a wide surface held +at the ceiling cost the ceiling's bandwidth at every level (R4 2P VS: about +4 s at 35-40 Hz after the race starts at a 10x ceiling, see the PR). With the render thread off nothing changes: the emulation thread's wall-time controller (`dynres_*` in `dynamic_resolution.c`) decides. With it on, the guest's frame no longer pays for GL, so its wall time says nothing about whether the diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 0da34dd1..f97dbd49 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -323,6 +323,7 @@ typedef struct GlDynresStats { * the reseed, the rects back, the native-wide surfaces. */ double last_ms, last_prep_ms, last_seed_ms, last_rects_ms, last_wide_ms; int last_rects; /* rects kept at full detail */ + uint64_t wide_reallocs; /* native-wide surfaces reallocated at a step */ } GlDynresStats; void gl_renderer_dynres_stats(GlDynresStats *out); /* Host time the renderer spent, as running totals in performance-counter diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index f7be6c58..aba12ac0 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -449,11 +449,11 @@ static int s_hr_scale = 1; /* Dynamic internal resolution ([video] dynamic_resolution; the step block * near the end of this file). 0 = off, and then every surface is allocated at * the scale it renders at, exactly as before. Otherwise the scale the hr - * surface and the native-wide surfaces were allocated at (the ceiling): the - * current level (s_hr_scale == s_out_scale, never above it) renders into - * their lower-left VRAM*S corner. Only allocation and the present's - * normalised UVs read it; everything else keeps taking the two scales above - * at use. */ + * surface was allocated at (the ceiling): the current level (s_hr_scale == + * s_out_scale, never above it) renders into its lower-left VRAM*S corner. + * Only allocation and the present's normalised UVs read it; everything else + * keeps taking the two scales above at use. Native-wide surfaces are + * allocated at the level instead (s_wide_as) and reallocated at each step. */ static int s_alloc_scale = 0; /* The scale a surface rendering at `cur` is (to be) allocated at. */ static inline int alloc_scale_for(int cur) { @@ -827,6 +827,12 @@ static int s_wide_base[WIDE_MAX_SURF]; /* base_x per surface (-1 = free) * /* Native rows [y0, y1) each surface has presented (dynamic resolution: the * rows a scale step rescales; y1 <= y0 = none yet, then all of them). */ static int s_dyn_wide_y0[WIDE_MAX_SURF], s_dyn_wide_y1[WIDE_MAX_SURF]; +/* The scale each surface is allocated at. Dynamic resolution allocates a wide + * surface at the level it renders at (not the ceiling) and reallocates it at + * every step: on Apple's GL-on-Metal every render pass into an attachment + * loads and stores the whole attachment, so a surface allocated at the + * ceiling costs the ceiling's bandwidth at every level. */ +static int s_wide_as[WIDE_MAX_SURF]; static int g_wide_w = 0; /* wide width (native px); 0 = disabled */ static int g_wide_off = 0; /* centering OFFSET (native px) */ static GLuint g_wide_cur = 0; /* active mirror FBO (0 = no mirror) */ @@ -5782,11 +5788,43 @@ static void wide_free_all(void) { if (s_wide_tex[i]) { glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; } if (s_wide_rb[i]) { p_glDeleteRenderbuffers(1, &s_wide_rb[i]); s_wide_rb[i] = 0; } s_wide_base[i] = -1; + s_wide_as[i] = 0; wst_clear(i); } g_wide_cur = 0; } +/* Create a native-wide surface (colour texture, depth-stencil RB, FBO) at + * scale S, cleared to black with a zero stencil. 0 on failure (nothing left + * allocated). */ +static int wide_alloc_surface(int S, GLuint *tex, GLuint *rb, GLuint *fbo) { + int w = g_wide_w * S, h = VRAM_H * S; + s_cw_fbo_creates++; + *tex = make_tex(GL_RGBA8, w, h, GL_RGBA, GL_UNSIGNED_BYTE); + /* Depth-stencil RB, same as the hr FBO: the stencil carries the PSX + * mask-bit mirror for the wide surface, and (the hard lesson) a + * stencil-less FBO turns every stencil-enabled mirror draw into ~0.6ms of + * driver-side work — the 16:9 GL perf collapse. */ + p_glGenRenderbuffers(1, rb); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, *rb); + p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, w, h); + if (!make_fbo(fbo, *tex, *rb)) { + glDeleteTextures(1, tex); *tex = 0; + p_glDeleteRenderbuffers(1, rb); *rb = 0; + *fbo = 0; + return 0; + } + /* Clear to black so unwritten margins are clean (not stale). */ + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, *fbo); + glDisable(GL_SCISSOR_TEST); + glClearColor(0, 0, 0, 0); + glClearStencil(0); + glStencilMask(0xFF); + glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); + return 1; +} + /* Lazily allocate (or find) the wide FBO+tex for base_x. Returns the FBO id, or * 0 on failure / more distinct buffers than WIDE_MAX_SURF. */ static GLuint wide_fbo_for(int base_x) { @@ -5801,8 +5839,8 @@ static GLuint wide_fbo_for(int base_x) { * Refuse that with one log line instead of failing mid-draw: the frame * then presents 4:3 through the CPU path at 1x, without the native-wide * margins. */ - /* Dynamic resolution: allocated at the ceiling (alloc_scale_for), like - * the hr surface, so a step never reallocates it. */ + /* Dynamic resolution: the limit is checked at the ceiling + * (alloc_scale_for), which every level may step up to. */ const int AS = alloc_scale_for(s_out_scale); if (s_gl_max_dim > 0 && ((int64_t)g_wide_w * AS > s_gl_max_dim || @@ -5817,29 +5855,13 @@ static GLuint wide_fbo_for(int base_x) { } for (int i = 0; i < WIDE_MAX_SURF; i++) { if (!s_wide_fbo[i]) { - int w = g_wide_w * AS, h = VRAM_H * AS; - s_cw_fbo_creates++; - s_wide_tex[i] = make_tex(GL_RGBA8, w, h, GL_RGBA, GL_UNSIGNED_BYTE); - /* Depth-stencil RB, same as the hr FBO: the stencil carries the - * PSX mask-bit mirror for the wide surface, and (the hard lesson) - * a stencil-less FBO turns every stencil-enabled mirror draw into - * ~0.6ms of driver-side work — the 16:9 GL perf collapse. */ - p_glGenRenderbuffers(1, &s_wide_rb[i]); - p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_wide_rb[i]); - p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, w, h); - if (!make_fbo(&s_wide_fbo[i], s_wide_tex[i], s_wide_rb[i])) { - glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; - p_glDeleteRenderbuffers(1, &s_wide_rb[i]); s_wide_rb[i] = 0; + /* Allocated at the level it renders at; a dynamic-resolution step + * reallocates it (dyn_apply). The limit above is checked at the + * ceiling, so no step can exceed it. */ + if (!wide_alloc_surface(s_out_scale, &s_wide_tex[i], &s_wide_rb[i], + &s_wide_fbo[i])) return 0; - } - /* Clear to black so unwritten margins are clean (not stale). */ - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_wide_fbo[i]); - glDisable(GL_SCISSOR_TEST); - glClearColor(0, 0, 0, 0); - glClearStencil(0); - glStencilMask(0xFF); - glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); + s_wide_as[i] = s_out_scale; s_wide_base[i] = base_x; s_dyn_wide_y0[i] = s_dyn_wide_y1[i] = 0; /* nothing presented yet */ wst_clear(i); /* colour and stencil cleared together */ @@ -8599,9 +8621,10 @@ static int present_wide_fbo_impl(int disp_x, int disp_y, int disp_h, int linear) } if (!s_ctx || !s_raster_ok || g_wide_w <= 0) return 0; GLuint fbo = 0, tex = 0; + int wide_as = 0; for (int i = 0; i < WIDE_MAX_SURF; i++) if (s_wide_fbo[i] && s_wide_base[i] == disp_x) { - fbo = s_wide_fbo[i]; tex = s_wide_tex[i]; + fbo = s_wide_fbo[i]; tex = s_wide_tex[i]; wide_as = s_wide_as[i]; if (s_alloc_scale) { /* the rows a scale step rescales */ int y0 = disp_y < 0 ? 0 : disp_y; int y1 = disp_y + disp_h > VRAM_H ? VRAM_H : disp_y + disp_h; @@ -8661,8 +8684,10 @@ static int present_wide_fbo_impl(int disp_x, int disp_y, int disp_h, int linear) s_last_dw = g_wide_w; s_last_dh = disp_h; return 1; } - present_target_quad(tex, present_alloc_extent(g_wide_w, s_out_scale), - present_alloc_extent(VRAM_H, s_out_scale), + /* The surface is allocated at its own scale (s_wide_as), the level. */ + const float wext = (wide_as > s_out_scale && s_out_scale >= 1) + ? (float)wide_as / (float)s_out_scale : 1.0f; + present_target_quad(tex, (float)g_wide_w * wext, (float)VRAM_H * wext, 0, disp_y, g_wide_w, disp_h, linear, lx, ly, lw, lh, 1, 1, s_out_scale); pres_record(GL_PRES_WIDE, disp_x, disp_y, g_wide_w, disp_h, lx, ly, lw, lh); gpu_timeline_note(GTL_PRESENT, GTL_PATH_WIDE_FBO, @@ -9044,8 +9069,35 @@ static int dyn_apply(int snew) { if (!s_wide_fbo[i]) continue; int y0 = s_dyn_wide_y0[i], y1 = s_dyn_wide_y1[i]; if (y1 <= y0) { y0 = 0; y1 = VRAM_H; } - dyn_rescale_in_place(s_wide_fbo[i], g_wide_w, (const int (*)[2])cols, nc, - y0, y1, sold, snew); + /* Reallocated at the new level (s_wide_as): the same rows and + * columns rescaled from the old surface into a cleared new one. + * If the allocation fails, a step down rescales in place as + * before and the surface keeps its larger allocation; a step up + * past it drops the surface (recreated at the next target). */ + GLuint ntex = 0, nrb = 0, nfbo = 0; + if (wide_alloc_surface(snew, &ntex, &nrb, &nfbo)) { + for (int c = 0; c < nc; c++) + if (cols[c][1] > cols[c][0]) + dyn_rescale_rect(s_wide_tex[i], cols[c][0] * sold, y0 * sold, sold, + nfbo, cols[c][0], y0, cols[c][1] - cols[c][0], + y1 - y0, snew); + if (g_wide_cur == s_wide_fbo[i]) g_wide_cur = nfbo; + p_glDeleteFramebuffers(1, &s_wide_fbo[i]); + glDeleteTextures(1, &s_wide_tex[i]); + p_glDeleteRenderbuffers(1, &s_wide_rb[i]); + s_wide_fbo[i] = nfbo; s_wide_tex[i] = ntex; s_wide_rb[i] = nrb; + s_wide_as[i] = snew; + s_dyn_stats.wide_reallocs++; + } else if (snew <= s_wide_as[i]) { + dyn_rescale_in_place(s_wide_fbo[i], g_wide_w, (const int (*)[2])cols, nc, + y0, y1, sold, snew); + } else { + if (g_wide_cur == s_wide_fbo[i]) g_wide_cur = 0; + p_glDeleteFramebuffers(1, &s_wide_fbo[i]); s_wide_fbo[i] = 0; + glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; + p_glDeleteRenderbuffers(1, &s_wide_rb[i]); s_wide_rb[i] = 0; + s_wide_base[i] = -1; s_wide_as[i] = 0; + } wst_all(i); /* colour only */ } } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 08adf8fa..13006f74 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9556,7 +9556,7 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { "\"held_windows\":%llu,\"steps\":%llu,\"deferred\":%llu,\"last_from\":%d," "\"last_to\":%d,\"last_ms\":%.3f,\"last_interval_ms\":%.3f,\"last_prep_ms\":%.3f," "\"last_seed_ms\":%.3f,\"last_rects_ms\":%.3f,\"last_wide_ms\":%.3f," - "\"step_cost_ms\":%.3f,\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", + "\"wide_reallocs\":%llu,\"step_cost_ms\":%.3f,\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", g_dynres.active ? 1 : 0, dynres_requested(), st.ceiling, g_dynres.active ? c.floor : 0, st.level, st.level * g_video_ref_lines, c.forced, c.last_load, c.last_late, c.last_vblank_hz, c.f, @@ -9565,7 +9565,7 @@ extern "C" int psx_dynres_status_json(char *out, int cap) { c.windows, c.held_windows, (unsigned long long)st.steps, (unsigned long long)st.deferred, st.last_from, st.last_to, st.last_ms, g_dynres.step_interval_ms, st.last_prep_ms, st.last_seed_ms, st.last_rects_ms, - st.last_wide_ms, c.step_cost_s * 1000.0, + st.last_wide_ms, (unsigned long long)st.wide_reallocs, c.step_cost_s * 1000.0, c.down_block_until > now_s ? c.down_block_until - now_s : 0.0, blocked); } diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index e5611f81..2fa85ede 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -62,8 +62,9 @@ * gl_renderer_pass_begin opens nothing there: a pass backs up and restores * only the authoritative surface, never the window's tiles. * - mode "steps" (dynamic resolution, argv: scale = the ceiling, then a - * comma list of levels, then "fresh" or "chain"): the surfaces stay - * allocated at the ceiling and the scale steps at run time. + * comma list of levels, then "fresh" or "chain"): the hr surface stays + * allocated at the ceiling, the native-wide surface at the current level + * (reallocated at each step), and the scale steps at run time. * "fresh" steps before anything is drawn, then renders the scene: the * runner requires the native digest, the frame at S and the wide surface * to equal the fixed-scale run at the final level (rendering into the @@ -978,15 +979,17 @@ static uint64_t steps_wide_digest(uint32_t **keep) { return d; } -/* The native-wide surface is allocated at the ceiling, whatever the level - * when it was created: a later step up must not outgrow it. */ -static void steps_check_wide_alloc(int ceiling) { +/* The native-wide surface is allocated at the level it renders at (not the + * ceiling, whose size every render pass would load and store on + * GL-on-Metal), and a step reallocates it at the new level. */ +static void steps_check_wide_alloc(int level) { GLint tw = 0, th = 0; glBindTexture(GL_TEXTURE_2D, s_wide_tex[0]); glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &tw); glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &th); glBindTexture(GL_TEXTURE_2D, 0); - check(tw == 426 * ceiling && th == 512 * ceiling, "wide surface allocated at the ceiling"); + check(tw == 426 * level && th == 512 * level && s_wide_as[0] == level, + "wide surface allocated at the level"); } static int steps_main(int ceiling, int window, const char *chain, int fresh) { @@ -1018,7 +1021,7 @@ static int steps_main(int ceiling, int window, const char *chain, int fresh) { uint64_t digest = steps_native_digest(1); uint64_t hires = steps_hires_digest(0, 0, FRAME_W, FRAME_H); steps_wide_part(); - steps_check_wide_alloc(ceiling); /* created below the ceiling */ + steps_check_wide_alloc(s_out_scale); /* created below the ceiling */ uint64_t wide = steps_wide_digest(NULL); check(glGetError() == GL_NO_ERROR, "GL error"); printf("level=%d\n", s_out_scale); @@ -1030,7 +1033,7 @@ static int steps_main(int ceiling, int window, const char *chain, int fresh) { } scene(); steps_wide_part(); - steps_check_wide_alloc(ceiling); + steps_check_wide_alloc(ceiling); /* the level is the ceiling here */ /* A back buffer with its own content, the draw area while stepping. */ glb_set_draw_area(0, STEPS_BACK_Y, FRAME_W - 1, STEPS_BACK_Y + FRAME_H - 1); glb_wide_disable_target(); @@ -1062,6 +1065,7 @@ static int steps_main(int ceiling, int window, const char *chain, int fresh) { char label[128]; snprintf(label, sizeof label, "step %dx -> %dx applied", sold, snew); check(ok && s_out_scale == snew && s_hr_scale == snew, label); + steps_check_wide_alloc(snew); GlDynresStats st; gl_renderer_dynres_stats(&st); uint64_t d1 = steps_native_digest(0);