From 67da069f9cb64bbbf2959386ff2505daa6cbec9b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:10:18 -0700 Subject: [PATCH] GL: texture window per vertex; opt-in texture-window batching The OpenGL textured program read the GP0(E2h) texture window from a uniform, so every window change ended the open textured batch (batch reason 5). Titles that tile far textures with per-primitive windows pay for that in draws: Ridge Racer Type 4's split screen changes the window about 515 times a game frame and drew about 180 batches, against 17 with batching on. On the native-wide path every batch that reaches the margins is drawn again into the wide surface; under heavy load on an M4, 2P with widescreen at Match display ran at 39-43 guest VBlanks/s and 1P at 50, against 50-58 and 57 with batching on. The window now rides in each textured vertex (a_twin, the E2h low 20 bits as one float) like texpage and CLUT already do, and the shader decodes it there; the uniform and the window-replay queue's copy of it are gone. With the default unchanged, batches still split at every window change, so every vertex carries the batch's window and the image is the same as before: the new test's oracle passes on the unmodified renderer too, with the same digests at 1x, 4x, 9x and in the windowed 9x/18x mode. [video] texture_window_batching = true (game.toml only, default off) lets primitives with different windows share a batch. The image is the same: an opaque batch draws its colour in one painter-ordered pass and semi-transparent primitives are still drawn one per batch. While mask checking is on the window stays a batch key, because an opaque batch fixes the stencil for its STP=1 texels after its colour pass. PSX_GL_TEXWIN_BATCH=0|1 overrides it for a run and the TCP command gl_texwin_batch switches it live. Software and Vulkan are unchanged. Tests: gl_scale_invariance_test gains mode "twin" (alternating windows over opaque, semi-transparent, set-mask, mask-check and bilinear prims, into the native-wide margins): off and on give the same native VRAM, frame at S and wide surface, on draws 17 batches instead of 46 and flushes only for the 5 mask-check window changes. Each run also checks an oracle that does not depend on the renderer: raw rects through all four windows into a 4-bit and a 15-bit page must give, pixel for pixel in the native VRAM and at the centre of each pixel at S, the texel the PS1 window rule picks from the VRAM as read back. A decode or packing error both settings share fails it (checked with (v_twin >> 11) and with off_y left out of the packed window). video_enhancement_settings_test checks the key's default and opt-in. Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 5 +- docs/config_schema.md | 24 ++ recompiler/src/config_loader.cpp | 4 + recompiler/src/config_loader.h | 7 + .../tests/video_enhancement_settings_test.cpp | 19 ++ runtime/include/gpu_gl_renderer.h | 8 + runtime/src/debug_server.c | 15 ++ runtime/src/gpu_gl_renderer.c | 74 +++++-- runtime/src/main.cpp | 5 + runtime/tests/run_gl_scale_invariance.py | 29 +++ runtime/tests/test_gl_scale_invariance.c | 208 ++++++++++++++++++ 11 files changed, 373 insertions(+), 25 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 1cbd3e8b..3d6bd0fa 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -429,9 +429,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**322 commands registered** — 309 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**323 commands registered** — 310 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -61 of 322 have prose above; **261 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +61 of 323 have prose above; **262 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -559,6 +559,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `gl_fbo_peek` | ✓ | | | | `gl_interp` | ✓ | | ✓ | | `gl_present_ring` | ✓ | | | +| `gl_texwin_batch` | ✓ | | | | `gl_vram_diff` | ✓ | | | | `gl_wide_fast` | ✓ | | | | `gl_ws_ablate` | ✓ | | | diff --git a/docs/config_schema.md b/docs/config_schema.md index 34eb4d24..40d8dc14 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -564,6 +564,30 @@ Settings surface. A game migrating Skip FMVs into its built-in mod catalog sets it to false. The runtime then hides the Settings row, ignores stale persisted values, and leaves activation to the selected trusted plugin. +### Texture-window batching (`texture_window_batching`, OpenGL) + +```toml +[video] +texture_window_batching = true # game.toml only; default false +``` + +The OpenGL renderer draws consecutive textured primitives in one batch while +their blend, mask and filter state match. By default a GP0(E2h) texture-window +change also ends the batch, although each vertex carries its primitive's +texture window. With this key on, primitives with different windows share a +batch. The image is the same either way; only the number of draws changes. +While mask checking (GP0(E6h) bit 1) is on, a window change still ends the +batch, because an opaque batch updates the mask bits of its own texels only +after its colour pass. + +It is for games that tile textures through per-primitive windows. Ridge Racer +Type 4's split screen changes the window about 515 times a frame, which drew +about 180 batches a frame instead of 17, and every batch that reaches the +native-wide margins is drawn again into the wide surface. The software and +Vulkan renderers ignore the key. +`PSX_GL_TEXWIN_BATCH=0|1` overrides it for one run, and the TCP command +`gl_texwin_batch on=<0|1>` switches it live. + ### Local rewind (`settings.toml`) Rewind is a player setting, not a game one: it lives in the user's diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index ab673327..7396c46d 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -728,6 +728,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (video.contains("low_latency_input")) { rt.video_low_latency_input = toml::find(video, "low_latency_input"); } + if (video.contains("texture_window_batching")) { + rt.video_texture_window_batching = + toml::find(video, "texture_window_batching"); + } if (video.contains("vsync")) { const auto mode = toml::find(video, "vsync"); if (mode == "on" || mode == "vsync") rt.video_vsync = 1; diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 089a4f02..cc6fa654 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -494,6 +494,13 @@ struct RuntimeConfig { // game once visuals are validated. bool video_offer_vulkan = false; + // texture_window_batching: OpenGL only. Let textured primitives with + // different GP0(E2h) texture windows share one draw (the window rides in + // each vertex) instead of ending the batch at every window change. The + // image is identical; games that tile textures with per-primitive windows + // draw in far fewer batches. Off by default; a game opts in. + bool video_texture_window_batching = 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 e9802f7b..5ff41d3d 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -278,6 +278,24 @@ static void test_internal_resolution_settings() { } } +/* [video] texture_window_batching: a game.toml-only OpenGL batching opt-in + * (the image is unchanged; see gpu_gl_renderer.c s_twin_batching). Off unless + * the game asks for it. */ +static void test_texture_window_batching() { + fs::path p = write_game_toml("psxrecomp_twin_default.toml", ""); + auto gc = PSXRecompV4::load_game_config(p); + check(!gc.runtime.video_texture_window_batching, + "texture_window_batching defaults OFF"); + fs::remove(p); + fs::path q = write_game_toml("psxrecomp_twin_on.toml", + "[video]\n" + "texture_window_batching = true\n"); + auto gq = PSXRecompV4::load_game_config(q); + check(gq.runtime.video_texture_window_batching, + "[video] texture_window_batching = true is honoured"); + fs::remove(q); +} + int main() { test_internal_resolution_game_toml(); test_internal_resolution_settings(); @@ -288,6 +306,7 @@ int main() { test_user_settings_read(); test_user_settings_absent_key(); test_user_settings_round_trip(); + test_texture_window_batching(); if (failures) { std::fprintf(stderr, "video_enhancement_settings_test: %d failure(s)\n", diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index ba2f4a7f..3fb4a6f9 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -332,6 +332,14 @@ uint64_t gl_renderer_perf_prim_split(double *out_tex_frac); * isolation, blend-mode, mask, filter, backdrop-gate, texture-window, capacity. */ void gl_renderer_batch_diag(uint64_t out[8]); +/* Texture-window batching ([video] texture_window_batching; default off). + * On: textured prims with different GP0(E2h) texture windows share a batch + * (each vertex carries its window), except while mask checking is on. The + * image is the same either way; only the number of draws changes. Safe to + * call before GL context creation. */ +void gl_renderer_set_texture_window_batching(int on); +int gl_renderer_get_texture_window_batching(void); + #ifdef __cplusplus } #endif diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index ea63b496..6b4f0f4c 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8041,6 +8041,20 @@ static void handle_gl_wide_fast(int id, const char *json) send_fmt("{\"id\":%d,\"ok\":true,\"on\":%d}", id, gl_renderer_get_wide_fast()); } +/* gl_texwin_batch on=<0|1>: [video] texture_window_batching, live. 1 = textured + * prims with different GP0(E2h) texture windows share a batch; 0 = a window + * change ends the batch. The image is the same; for A/B of batch counts + * (frame_perf batch_diag[6] = window flushes) and frame time. */ +extern void gl_renderer_set_texture_window_batching(int on); +extern int gl_renderer_get_texture_window_batching(void); +static void handle_gl_texwin_batch(int id, const char *json) +{ + int on = json_get_int(json, "on", -1); + if (on >= 0) gl_renderer_set_texture_window_batching(on); + send_fmt("{\"id\":%d,\"ok\":true,\"on\":%d}", id, + gl_renderer_get_texture_window_batching()); +} + /* Live GTE widescreen-squash toggle (diagnostic for 8C far-backdrop void): * ws_aspect num= den= calls gte_set_display_aspect at runtime so we can * compare squash ON (e.g. 16/9) vs OFF (1/1) in-place without a relaunch. */ @@ -14117,6 +14131,7 @@ static const CmdEntry s_commands[] = { { "render_pass_dump", handle_render_pass_dump }, { "render_pass_refuse", handle_render_pass_refuse }, { "gl_wide_fast", handle_gl_wide_fast }, + { "gl_texwin_batch", handle_gl_texwin_batch }, { "synth_recurse", handle_synth_recurse }, { "gl_fbo_peek", handle_gl_fbo_peek }, { "gl_vram_diff", handle_gl_vram_diff }, diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 52ff57e5..1a01ce2c 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -528,13 +528,14 @@ static int s_scratch_w = 0, s_scratch_h = 0; static GLuint s_geo_prog = 0, s_geo_vao = 0, s_geo_vbo = 0; static GLuint s_tex_prog = 0, s_tex_vao = 0, s_tex_vbo = 0; /* Textured vertex: pos(2) uv(2) col(4) tpage(2) clut(2) depth(1) raw(1) limits(4) - * semi(1) q(1) + * semi(1) q(1) twin(1) * — per-prim texture state in flat attributes so prims batch (see flush_tex_batch). * q is the perspective weight ([video] perspective_texturing): 0 = affine, the * PS1-faithful default, which makes the vertex shader's w exactly 1.0 and the * fragment shader read the noperspective varying — i.e. bit-identical to the - * pre-feature pipeline. */ -#define TEXV 20 + * pre-feature pipeline. twin is the prim's GP0(E2h) texture window, its low 20 + * bits as a whole float (mask x, mask y, offset x, offset y; 5 bits each). */ +#define TEXV 21 static GLuint s_blit_prog = 0, s_blit_vao = 0, s_blit_vbo = 0; static GLuint s_blit_hi_prog = 0; /* windowed hi surface blit */ static GLint s_uBhSrc = -1, s_uBhPass = -1, s_uBhMaskset = -1, s_uBhSrcDiv = -1; @@ -558,7 +559,7 @@ static float s_pq[3]; static GLint s_uVram = -1, s_uTpage = -1, s_uClut = -1, s_uDepth = -1; static GLint s_uPalette = -1; static GLint s_uRaw = -1, s_uSemipass = -1, s_uSemimode = -1; -static GLint s_uTwin = -1, s_uMaskset = -1, s_uFilter = -1; +static GLint s_uMaskset = -1, s_uFilter = -1; static GLint s_uLimits = -1; /* Native-wide x-projection uniforms (per program). u_xoff = x translation in * native px (0 canonical), u_xhalf = x clip half-extent in native px (512 @@ -1260,9 +1261,10 @@ static const char *GEO_FS = /* Textured program. Per-prim texture state (texpage, clut, depth, raw, uv * limits) is carried in FLAT vertex attributes — constant across a prim's * vertices — instead of uniforms, so consecutive textured prims with the same - * blend/mask/texture-window state batch into one draw (see flush_tex_batch). - * The remaining uniforms (u_twin/u_maskset/u_filter/u_semipass) are the batch - * keys + per-pass state. */ + * blend/mask state batch into one draw (see flush_tex_batch). The texture + * window rides in the vertex too (a_twin), so a GP0(E2h) change need not end + * a batch. The remaining uniforms (u_maskset/u_filter/u_semipass) are the + * batch keys + per-pass state. */ static const char *TEX_VS = "#version 330\n" "layout(location=0) in vec2 a_pos;\n" @@ -1275,6 +1277,7 @@ static const char *TEX_VS = "layout(location=7) in vec4 a_limits;\n" "layout(location=8) in float a_semi;\n" "layout(location=9) in float a_q; /* persp weight; 0 = affine (default) */\n" + "layout(location=10) in float a_twin; /* GP0(E2h) bits 0..19 */\n" "uniform float u_shift;\n" "uniform float u_xoff; /* native-wide x translation (px); 0 canonical */\n" "uniform float u_xhalf; /* x clip half-extent (px); 512 canonical */\n" @@ -1285,11 +1288,13 @@ static const char *TEX_VS = "flat out int v_persp;\n" "flat out ivec2 v_tpage; flat out ivec2 v_clut; flat out int v_depth;\n" "flat out int v_raw; flat out ivec4 v_limits; flat out int v_semi;\n" + "flat out int v_twin;\n" "void main(){ v_uv = a_uv; v_uv_p = a_uv; v_col = a_col;\n" " v_persp = (a_q > 0.0) ? 1 : 0;\n" " v_tpage = ivec2(a_tpage + 0.5); v_clut = ivec2(a_clut + 0.5);\n" " v_depth = int(a_depth + 0.5); v_raw = int(a_raw + 0.5);\n" " v_semi = int(a_semi + 0.5);\n" + " v_twin = int(a_twin + 0.5);\n" " v_limits = ivec4(floor(a_limits + 0.5));\n" " /* u_shift: align GL's center-sample grid with the PS1 integer grid (see\n" " * GEO_VS) so interpolated uv at a fragment equals the PS1 DDA value. */\n" @@ -1317,11 +1322,11 @@ static const char *TEX_FS = "flat in int v_raw; /* 1 = no color modulation */\n" "flat in ivec4 v_limits; /* prim uv sampling bounds (inclusive, post-wrap) */\n" "flat in int v_semi; /* GP0 command has semi-transparency enabled */\n" + "flat in int v_twin; /* texture window, GP0(E2h) bits 0..19 */\n" "uniform usampler2D u_vram;\n" "uniform usampler2D u_palette;\n" "uniform int u_semipass; /* 0=all texels, 1=STP=0 only, 2=STP=1 only */\n" "uniform int u_semimode; /* PS1 blend mode; drives dual-source factors */\n" - "uniform ivec4 u_twin; /* texture window: mask_x, mask_y, off_x, off_y */\n" "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" "uniform int u_filter; /* 1 = bilinear */\n" "uniform float u_shift;\n" @@ -1337,9 +1342,12 @@ static const char *TEX_FS = "}\n" "int fetch_texel(int u, int v){\n" " u &= 255; v &= 255;\n" - " if ((u_twin.x | u_twin.y) != 0) {\n" - " u = (u & ~(u_twin.x * 8)) | ((u_twin.z & u_twin.x) * 8);\n" - " v = (v & ~(u_twin.y * 8)) | ((u_twin.w & u_twin.y) * 8);\n" + " /* texture window: mask_x, mask_y, off_x, off_y (8-px units) */\n" + " ivec4 tw = ivec4(v_twin & 31, (v_twin >> 5) & 31,\n" + " (v_twin >> 10) & 31, (v_twin >> 15) & 31);\n" + " if ((tw.x | tw.y) != 0) {\n" + " u = (u & ~(tw.x * 8)) | ((tw.z & tw.x) * 8);\n" + " v = (v & ~(tw.y * 8)) | ((tw.w & tw.y) * 8);\n" " } else {\n" " u = clamp(u, v_limits.x, v_limits.z);\n" " v = clamp(v, v_limits.y, v_limits.w);\n" @@ -2450,9 +2458,10 @@ static void wide_clear_bd_scale(GLint uScale, GLint uCenter) { /* ---- textured-prim batching -------------------------------------------- * * Consecutive textured prims sharing blend/mask/texwindow/filter coalesce into - * one draw. Per-prim texture state (texpage/clut/depth/raw/uv-limits) rides in - * the vertex (TEXV flat attributes), so only `semi` (blend) and the global - * mask/twin/filter are batch keys. flush_tex_batch() draws the queued verts; it + * one draw. Per-prim texture state (texpage/clut/depth/raw/uv-limits/texture + * window) rides in the vertex (TEXV flat attributes), so only `semi` (blend) + * and the global mask/filter are batch keys, plus the texture window unless + * texture-window batching is on (below). flush_tex_batch() draws the queued verts; it * is called before any op that reads VRAM, writes it outside the batch, or * changes batch state (see callers: flush_cpu_upload, flush_pack_if_sampling, * every non-textured glb_ wrapper, and the present path). Drawing reads only the @@ -2467,6 +2476,25 @@ static GLuint s_tb_bank_tex; static int s_tb_twin[4] = {0, 0, 0, 0}; static uint64_t s_batch_total = 0, s_batch_reason[7]; +/* Texture-window batching ([video] texture_window_batching, OpenGL; off by + * default). Off: a GP0(E2h) texture-window change ends the open textured batch + * (batch reason 5), as before. On: prims with different windows share a batch, + * since each vertex carries its own window. Games that tile far textures with + * per-prim windows (R4's split screen: ~515 E2 changes and ~180 batches a game + * frame, ~17 with this on) then draw in a tenth of the batches, which matters + * most on the native-wide path, where a batch reaching the margins is drawn + * again into the wide surface. + * The result is pixel-identical: an opaque batch draws its colour in one + * painter-ordered pass and semi-transparent prims are still drawn one per + * batch. The one exception is mask checking (GP0(E6h) bit 1): an opaque batch + * fixes the stencil for its STP=1 texels after its colour pass, so a later + * prim in the same batch is not stopped by an earlier one's mask bit. The + * window therefore stays a batch key while the check is on, and this mode + * never makes a batch longer than the default mode would there. */ +static int s_twin_batching = 0; +void gl_renderer_set_texture_window_batching(int on) { s_twin_batching = on ? 1 : 0; } +int gl_renderer_get_texture_window_batching(void) { return s_twin_batching; } + void gl_renderer_batch_diag(uint64_t out[8]) { out[0] = s_batch_total; for (int i = 0; i < 7; i++) out[i + 1] = s_batch_reason[i]; @@ -2599,7 +2627,6 @@ typedef struct { uint8_t check; /* mask-check state at draw time */ uint8_t filter; GLuint tex; /* sampled texture (bank or raw mirror) */ - int twin[4]; int ax0, ay0, ax1, ay1; /* draw area, inclusive */ size_t vfirst; /* float offset into s_hq_v */ int vcount; /* vertices */ @@ -2672,7 +2699,6 @@ static int hiw_enqueue_tex(int nverts, int semi, int mirror, int gate) { c->mask = (uint8_t)s_tb_mask; c->filter = (uint8_t)s_tb_filter; c->tex = s_tb_bank_tex ? s_tb_bank_tex : s_raw_tex; - memcpy(c->twin, s_tb_twin, sizeof c->twin); if (wq) hiw_wide_set(c, gate); return wq; } @@ -2730,7 +2756,6 @@ static void hiw_replay_wide(void) { p_glUniform1f(s_tex_uXscale, c->wscale); p_glUniform1f(s_tex_uXcenter, c->wcenter); glBindTexture(GL_TEXTURE_2D, c->tex); - p_glUniform4i(s_uTwin, c->twin[0], c->twin[1], c->twin[2], c->twin[3]); p_glUniform1i(s_uMaskset, c->mask); p_glUniform1i(s_uFilter, c->filter); p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)((size_t)c->vcount * TEXV * sizeof(float)), @@ -2807,7 +2832,6 @@ static void hiw_flush_queue(void) { cur = HQ_TEX; } glBindTexture(GL_TEXTURE_2D, c->tex); - p_glUniform4i(s_uTwin, c->twin[0], c->twin[1], c->twin[2], c->twin[3]); p_glUniform1i(s_uMaskset, c->mask); p_glUniform1i(s_uFilter, c->filter); p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)((size_t)c->vcount * TEXV * sizeof(float)), @@ -2859,7 +2883,6 @@ static void flush_tex_batch(void) { glBindTexture(GL_TEXTURE_2D, s_tb_bank_tex && !s_tb_bank_live_clut ? s_tb_bank_tex : s_raw_tex); p_glUniform1i(s_uPalette, 1); p_glActiveTexture(PSXGL_TEXTURE0); - p_glUniform4i(s_uTwin, s_tb_twin[0], s_tb_twin[1], s_tb_twin[2], s_tb_twin[3]); p_glUniform1i(s_uMaskset, s_tb_mask); p_glUniform1i(s_uFilter, s_tb_filter); p_glBindVertexArray(s_tex_vao); @@ -3260,7 +3283,8 @@ static void gpu_textured_triangle(const int *xs, const int *ys, /* Append to the textured batch. Flush first if this prim's blend/mask/twin/ * filter differ from the open batch, or the buffer is full. Per-prim texture - * state goes in the vertex; only these keys force a new draw. */ + * state goes in the vertex; only these keys force a new draw (the window + * not at all under texture-window batching, see s_twin_batching). */ { flush_flat_batch(); /* painter order: flat GEO before textured */ int twx = s_tw_mask_x, twy = s_tw_mask_y, tox = s_tw_off_x, toy = s_tw_off_y; @@ -3301,8 +3325,9 @@ static void gpu_textured_triangle(const int *xs, const int *ys, else if (s_mask_set != s_tb_mask) reason = 2; else if (s_tex_filter != s_tb_filter) reason = 3; else if (gate != s_tb_gate) reason = 4; - else if (twx != s_tb_twin[0] || twy != s_tb_twin[1] || - tox != s_tb_twin[2] || toy != s_tb_twin[3]) reason = 5; + else if ((!s_twin_batching || s_mask_check) && + (twx != s_tb_twin[0] || twy != s_tb_twin[1] || + tox != s_tb_twin[2] || toy != s_tb_twin[3])) reason = 5; } if (reason >= 0) { s_batch_reason[reason]++; @@ -3315,6 +3340,8 @@ static void gpu_textured_triangle(const int *xs, const int *ys, s_tb_bank_live_clut = s_selected_bank_live_clut; s_tb_twin[0] = twx; s_tb_twin[1] = twy; s_tb_twin[2] = tox; s_tb_twin[3] = toy; } + /* The prim's window, exact as a float (20 bits). */ + float twin = (float)(twx | (twy << 5) | (tox << 10) | (toy << 15)); float *vp = &s_tb[s_tb_n * TEXV]; for (int i = 0; i < 3; i++, vp += TEXV) { vp[0] = s_pc_valid ? s_pc_x[i] : (float)xs[i]; @@ -3328,6 +3355,7 @@ static void gpu_textured_triangle(const int *xs, const int *ys, vp[16] = (float)lim[2]; vp[17] = (float)lim[3]; vp[18] = semi >= 0 ? (float)(semi + 1) : 0.0f; /* a_semi code */ vp[19] = s_pq_valid ? s_pq[i] : 0.0f; /* a_q; 0 = affine */ + vp[20] = twin; /* a_twin */ } s_tb_n += 3; if (isolate) flush_tex_batch(); /* draw this semi prim alone, in submission order */ @@ -4224,7 +4252,6 @@ static int init_gpu_raster(void) { s_uRaw = p_glGetUniformLocation(s_tex_prog, "u_raw"); s_uSemipass = p_glGetUniformLocation(s_tex_prog, "u_semipass"); s_uSemimode = p_glGetUniformLocation(s_tex_prog, "u_semimode"); - s_uTwin = p_glGetUniformLocation(s_tex_prog, "u_twin"); s_uMaskset = p_glGetUniformLocation(s_tex_prog, "u_maskset"); s_uFilter = p_glGetUniformLocation(s_tex_prog, "u_filter"); s_uLimits = p_glGetUniformLocation(s_tex_prog, "u_limits"); @@ -4317,6 +4344,7 @@ static int init_gpu_raster(void) { p_glVertexAttribPointer(7, 4, GL_FLOAT, GL_FALSE, st, (void*)(14*sizeof(float))); p_glEnableVertexAttribArray(7); /* limits */ p_glVertexAttribPointer(8, 1, GL_FLOAT, GL_FALSE, st, (void*)(18*sizeof(float))); p_glEnableVertexAttribArray(8); /* semi */ p_glVertexAttribPointer(9, 1, GL_FLOAT, GL_FALSE, st, (void*)(19*sizeof(float))); p_glEnableVertexAttribArray(9); /* q */ + p_glVertexAttribPointer(10, 1, GL_FLOAT, GL_FALSE, st, (void*)(20*sizeof(float))); p_glEnableVertexAttribArray(10); /* twin */ } p_glGenVertexArrays(1, &s_blit_vao); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a14372ec..e0adc24b 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -13404,6 +13404,8 @@ int main(int argc, char** argv) { g_video_aspect_num = gc.runtime.video_aspect_num; g_video_aspect_den = gc.runtime.video_aspect_den; g_low_latency_input = gc.runtime.video_low_latency_input ? 1 : 0; + gl_renderer_set_texture_window_batching( + gc.runtime.video_texture_window_batching ? 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; @@ -15553,6 +15555,9 @@ session_reboot: g_video_perspective_texturing = (*e && *e != '0') ? 1 : 0; if (const char* e = std::getenv("PSX_PGXP_CPU_MODE")) g_video_pgxp_cpu_mode = (*e && *e != '0') ? 1 : 0; + /* [video] texture_window_batching A/B (same image, fewer GL draws). */ + if (const char* e = std::getenv("PSX_GL_TEXWIN_BATCH")) + gl_renderer_set_texture_window_batching((*e && *e != '0') ? 1 : 0); gte_geometry_correction_set(g_video_geometry_correction); gpu_texture_correction_set(g_video_perspective_texturing); pgxp_set_cpu_mode(g_video_pgxp_cpu_mode); diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py index 0f1991ad..83521336 100644 --- a/runtime/tests/run_gl_scale_invariance.py +++ b/runtime/tests/run_gl_scale_invariance.py @@ -24,6 +24,12 @@ Mask runs (mode mask) change the GP0(E6) mask-check bit after a line, a flat triangle or an opaque textured rect and before its batch is drawn: the draw must keep the check bit it was submitted under, at 1x, above it and in the window mode. +Texture-window runs (mode twin) draw prims that alternate GP0(E2h) windows +with [video] texture_window_batching off and on: the native VRAM, the frame at +S and the wide surface must be the same both ways, at 1x, above it and in the +window mode, and on must draw in fewer batches. Each of those runs also checks +raw rects drawn through every window against the PS1 window rule computed in C +from the VRAM, so a decode error both ways share still fails. Pass runs (mode passes, where the renderer has the frame-rate stack's render passes) check that the window mode refuses them and the full-VRAM surface offers them. @@ -255,6 +261,29 @@ def main(): r.stderr.strip()[-600:]) if r.returncode or not parsed or parsed[1]: ok = False + # Texture-window batching off vs on: (label, scale, env). + for label, s, extra in (("full", 1, {}), ("full", 4, {}), ("full", 9, {}), + ("window", 9, {"PSX_GL_HIRES_WINDOW": "1"}), + ("window", 18, {"PSX_GL_HIRES_WINDOW": "1"})): + e = dict(env) + e.update(extra) + got = {} + for on in (0, 1): + r = run([dest / "probe", s, "twin", on], env=e) + parsed = parse_run(r.stdout) + m = re.search(r"^twin_flushes=(\d+) batches=(\d+)$", r.stdout, re.M) + print(f"twin {label} {s} batching={on}: exit={r.returncode}", + r.stdout.strip().splitlines()[-5:], r.stderr.strip()[-600:]) + if r.returncode or not parsed or parsed[1] or not m: + ok = False + got[on] = (parsed[2] if parsed else None, parse_hires(r.stdout), + parse_hires(r.stdout, "wide"), int(m[2]) if m else None) + if None in got[0][:3] or got[0][:3] != got[1][:3]: + print(f"FAIL twin {label} {s}x: batching changes the image:", got) + ok = False + if got[0][3] is None or got[1][3] is None or got[1][3] >= got[0][3]: + print(f"FAIL twin {label} {s}x: batching on did not draw fewer batches:", got) + ok = False # Render passes: offered on the full-VRAM surface, refused in the window mode. if not passes: print("passes: skipped (this renderer has no render passes)") diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index 1113b55b..4b94c130 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -44,6 +44,17 @@ * check on (then off) or off (then on) before their batch is drawn: with * the check on the rect's pixels stay, with it off they are overwritten, * in the native VRAM and in the frame at S. + * - mode "twin" (with or without the window), argv[3] 0/1: [video] + * texture_window_batching off/on. Textured prims alternating GP0(E2h) + * texture windows (opaque, semi-transparent, set-mask, mask check, + * bilinear; inside the frame and into native-wide margins). The runner + * checks the native VRAM, the frame at S and the wide surface are the same + * with it off and on; this run checks that on never flushes for a window + * change except under mask check, and off flushes at every one. An oracle + * independent of the renderer then draws raw rects through each window + * into both page depths: every pixel must be the texel the PS1 window rule + * picks from the VRAM as read back, in the native VRAM and at S, so a + * window decode error both settings share still fails. * - mode "passes" (built only where the renderer has render passes, the * frame-rate stack; the runner defines PSX_TEST_RENDER_PASSES): with the * flip-aware frame blend ready, the full-VRAM surface offers render passes @@ -381,6 +392,197 @@ static int mask_main(int scale, int window) { return failures ? 1 : 0; } +/* ---- mode "twin": texture-window batching -------------------------------- + * argv[3] "0"/"1" = [video] texture_window_batching off/on. Textured prims that + * alternate GP0(E2h) windows: opaque polys and rects inside the frame and into + * the native-wide margins, semi-transparent ones, a run under set-mask, a run + * under mask check (which must still split at every window change) and a + * bilinear run. The runner compares the native VRAM, the frame at S and the + * wide surface of the two runs; this run checks the window flushes and the + * window oracle (twin_oracle). */ +#define TWIN_TPAGE4 0x0008 /* 4-bit page at (512, 0), CLUT at (512, 256) */ +#define TWIN_TPAGE15 0x0109 /* 15-bit page at (576, 0) */ +static const uint32_t twin_windows[4] = { + 0, /* no window */ + 0x1Cu, /* u repeats every 32 */ + 0x18u | (0x1Eu << 5) | (0x08u << 10) | (0x02u << 15), + 0x1Fu | (0x1Fu << 5) | (0x05u << 10) | (0x0Au << 15), /* one 8x8 tile */ +}; +static void twin_quad(int i, int x, int y, int w, int h, uint16_t tp) { + glb_set_texture_window(twin_windows[i & 3]); + if (i % 3 == 2) { + glb_draw_textured_rect(x, y, w, h, 3 + i, 5 + 2 * i, 512, 256, tp); + return; + } + int u0 = 2 * i, v0 = i, u1 = u0 + 90 + i, v1 = v0 + 70; + glb_draw_shaded_textured_triangle(x, y, u0, v0, 0x808080, x + w, y + 3, u1, v0, 0x6090b0, + x, y + h, u0, v1, 0xb09060, 512, 256, tp, 0); + glb_draw_shaded_textured_triangle(x + w, y + 3, u1, v0, 0x6090b0, x, y + h, u0, v1, 0xb09060, + x + w - 7, y + h + 2, u1, v1, 0x80a080, 512, 256, tp, 0); +} +/* The window oracle: what the GP0(E2h) window must sample, from the PS1 rule + * and the VRAM as read back, so the off/on comparison cannot hide a decode + * error both modes share. Raw opaque rects (no dither, one texel per native + * pixel), one per window and page, drawn back to back so they share batches + * when the key is on. Every pixel is checked in the native VRAM and, at its + * centre, in the frame at S. Counts the pixels each window drew. */ +#define TWIN_OR_W 70 +#define TWIN_OR_H 20 +static uint16_t twin_texel(const uint16_t *v, uint16_t tp, int u, int t) { + int px = (tp & 15) * 64, py = ((tp >> 4) & 1) * 256; + if (((tp >> 7) & 3) == 2) return v[((py + t) & 511) * 1024 + ((px + u) & 1023)]; + int idx = (v[((py + t) & 511) * 1024 + ((px + (u >> 2)) & 1023)] >> ((u & 3) * 4)) & 15; + return v[256 * 1024 + 512 + idx]; /* CLUT at (512, 256) */ +} +static void twin_oracle(int scale, int drawn[4]) { + /* Texels for the windows that reach past the scene's 64x64 pages. */ + static uint16_t more[64 * 128]; + for (int i = 0; i < 64 * 128; i++) + more[i] = (uint16_t)(((i * 0x1d0bu) ^ (i >> 4)) | ((i & 7) == 5 ? 0x8000u : 0)); + glb_vram_transfer_in(512, 64, 64, 64, more); /* 4-bit rows 64.. */ + glb_vram_transfer_in(576, 64, 64, 64, more + 64 * 64); /* 15-bit rows 64.. */ + glb_vram_transfer_in(640, 0, 64, 128, more); /* 15-bit u 64..127 */ + glb_set_draw_area(0, 0, FRAME_W - 1, FRAME_H - 1); + glb_set_mask_bits(0, 0); + glb_set_semi_transparency(0, 0); + glb_set_texture_filter(0); + glb_set_color_modulation(128, 128, 128, 1); /* raw: the texel as is */ + gl_renderer_sync_cpu(); + check(gl_renderer_fbo_peek(0, 0, 1024, 512, vram), "oracle: VRAM before"); + int fw = FRAME_W * scale, fh = FRAME_H * scale, ow = 0, oh = 0; + uint32_t *before = (uint32_t *)malloc((size_t)fw * fh * 4); + uint32_t *after = (uint32_t *)malloc((size_t)fw * fh * 4); + int nb = before ? gl_renderer_read_display_hires(0, 0, FRAME_W, FRAME_H, before, fw * fh, &ow, &oh) : 0; + check(nb == fw * fh, "oracle: frame at S before"); + for (int i = 0; i < 8; i++) { + glb_set_texture_window(twin_windows[i & 3]); + glb_draw_textured_rect(4 + (i & 3) * 78, 2 + (i >> 2) * 22, TWIN_OR_W, TWIN_OR_H, + 9 * i + 3, 5 * i + 1, 512, 256, i < 4 ? TWIN_TPAGE4 : TWIN_TPAGE15); + } + glb_set_texture_window(0); + glb_set_color_modulation(128, 128, 128, 0); + gl_renderer_sync_cpu(); + check(gl_renderer_fbo_peek(0, 0, 1024, 512, peek), "oracle: native peek"); + int na = after ? gl_renderer_read_display_hires(0, 0, FRAME_W, FRAME_H, after, fw * fh, &ow, &oh) : 0; + check(na == fw * fh, "oracle: frame at S after"); + long bad = 0, bad_hi = 0; + for (int i = 0; i < 8; i++) { + uint32_t w = twin_windows[i & 3]; + int mx = w & 31, my = (w >> 5) & 31, ox = (w >> 10) & 31, oy = (w >> 15) & 31; + int x0 = 4 + (i & 3) * 78, y0 = 2 + (i >> 2) * 22; + for (int dy = 0; dy < TWIN_OR_H; dy++) + for (int dx = 0; dx < TWIN_OR_W; dx++) { + int u = (9 * i + 3 + dx) & 255, t = (5 * i + 1 + dy) & 255; + u = (u & ~(mx * 8)) | ((ox & mx) * 8); + t = (t & ~(my * 8)) | ((oy & my) * 8); + uint16_t texel = twin_texel(vram, i < 4 ? TWIN_TPAGE4 : TWIN_TPAGE15, u, t); + int x = x0 + dx, y = y0 + dy; + uint16_t want = texel ? texel : vram[y * 1024 + x]; + if (texel) drawn[i & 3]++; + if (peek[y * 1024 + x] != want) { + if (!bad) fprintf(stderr, "oracle: window %d page %d px (%d,%d) native %04x want %04x\n", + i & 3, i >> 2, x, y, peek[y * 1024 + x], want); + bad++; + } + if (nb == fw * fh && na == fw * fh) { + size_t c = (size_t)(y * scale + scale / 2) * fw + (size_t)(x * scale + scale / 2); + uint32_t got = after[c] & 0xFFFFFFu; /* BGRA: R bits 16.., B bits 0.. */ + int ok = texel ? (((got >> 19) & 31) == (texel & 31) && + ((got >> 11) & 31) == ((texel >> 5) & 31) && + ((got >> 3) & 31) == ((texel >> 10) & 31)) + : got == (before[c] & 0xFFFFFFu); + bad_hi += !ok; + } + } + } + free(before); + free(after); + printf("twin_oracle drawn=%d,%d,%d,%d native_bad=%ld hires_bad=%ld\n", + drawn[0], drawn[1], drawn[2], drawn[3], bad, bad_hi); + check(bad == 0, "oracle: native VRAM is what the texture window samples"); + check(bad_hi == 0, "oracle: frame at S is what the texture window samples"); + for (int k = 0; k < 4; k++) check(drawn[k] > TWIN_OR_W * TWIN_OR_H / 2, "oracle: every window draws"); +} +static int twin_main(int scale, int window, int on) { + static uint16_t page[64 * 64], page15[64 * 64], clut[16]; + for (int i = 0; i < 64 * 64; i++) page[i] = (uint16_t)((i * 0x2469u) ^ (i >> 2) ^ 0x1111u); + for (int i = 0; i < 64 * 64; i++) + page15[i] = (uint16_t)(((i * 0x0843u) & 0x7fffu) | (((i >> 5) & 3) == 1 ? 0x8000u : 0)); + for (int i = 0; i < 16; i++) clut[i] = (uint16_t)(i ? (0x0421u * (uint16_t)i) | ((i & 3) == 3 ? 0x8000u : 0) : 0); + glb_vram_transfer_in(512, 0, 64, 64, page); + glb_vram_transfer_in(576, 0, 64, 64, page15); + glb_vram_transfer_in(512, 256, 16, 1, clut); + gl_renderer_set_texture_window_batching(on); + check(gl_renderer_get_texture_window_batching() == on, "texture-window batching set"); + glb_set_draw_area(0, 0, 1023, 511); + glb_set_draw_offset(0, 0); + glb_set_mask_bits(0, 0); + glb_set_semi_transparency(0, 0); + glb_set_color_modulation(128, 128, 128, 0); + if (window) check(hiw_ensure(0, FRAME_W) != NULL, "window covers the frame"); + glb_fill_rect(0, 0, FRAME_W, FRAME_H, 0x0c63); + glb_wide_configure(426, 53); + glb_wide_set_target(0); + glb_set_draw_area(0, 0, FRAME_W - 1, FRAME_H - 1); + uint64_t r5 = s_batch_reason[5], total = s_batch_total; + /* A: 24 opaque prims, a new window each (23 changes); 4-bit and 15-bit, + * some reaching the margins. */ + for (int i = 0; i < 24; i++) + twin_quad(i, -40 + (i % 6) * 66, 8 + (i / 6) * 40, 70 + (i & 1) * 20, 44, + (i & 4) ? TWIN_TPAGE15 : TWIN_TPAGE4); + /* B: semi-transparent, every mode (drawn one per batch either way). */ + for (int i = 0; i < 4; i++) { + glb_set_semi_transparency(1, i); + twin_quad(i + 1, 20 + i * 70, 150, 80, 40, TWIN_TPAGE15); + } + glb_set_semi_transparency(0, 0); + /* C1: set-mask, 4 prims (3 changes). */ + glb_set_mask_bits(1, 0); + for (int i = 0; i < 4; i++) twin_quad(i, 30 + i * 60, 180, 50, 30, TWIN_TPAGE4); + /* C2: mask check, 6 overlapping prims (5 changes; split in both modes). */ + glb_set_mask_bits(0, 1); + for (int i = 0; i < 6; i++) twin_quad(i + 2, 10 + i * 40, 170, 90, 50, TWIN_TPAGE15); + glb_set_mask_bits(0, 0); + /* D: bilinear, 4 prims (3 changes). */ + glb_set_texture_filter(1); + for (int i = 0; i < 4; i++) twin_quad(i + 3, -20 + i * 95, 205, 90, 30, TWIN_TPAGE4); + flush_tex_batch(); + glb_set_texture_filter(0); + glb_set_texture_window(0); + uint64_t flushes = s_batch_reason[5] - r5, batches = s_batch_total - total; + printf("twin_flushes=%llu batches=%llu\n", (unsigned long long)flushes, + (unsigned long long)batches); + check(flushes == (on ? 5u : 34u), on ? "batching on: window flushes only under mask check" + : "batching off: every window change flushes"); + int drawn[4] = {0, 0, 0, 0}; + twin_oracle(scale, drawn); + glb_set_draw_area(0, 0, 1023, 511); + gl_renderer_sync_cpu(); + check(gl_renderer_fbo_peek(0, 0, 1024, 512, peek), "native peek"); + uint64_t digest = fnv(peek, sizeof peek, 0xcbf29ce484222325ull); + int fw = FRAME_W * scale, fh = FRAME_H * scale, ow = 0, oh = 0; + uint32_t *img = (uint32_t *)malloc((size_t)fw * fh * 4); + int n = img ? gl_renderer_read_display_hires(0, 0, FRAME_W, FRAME_H, img, fw * fh, &ow, &oh) : 0; + check(n == fw * fh && ow == fw && oh == fh, "hires readback size"); + uint64_t hires = n ? fnv(img, (size_t)fw * fh * 4, 0xcbf29ce484222325ull) : 0; + free(img); + uint64_t wide = 0; + { + int ww = 426 * scale, wh = 512 * scale, gw = 0, gh = 0; + uint32_t *wb = (uint32_t *)malloc((size_t)ww * wh * 4); + int got = wb ? glb_wide_dump_full(wb, ww * wh, &gw, &gh, 0) : 0; + check(got == ww * wh && gw == ww && gh == wh, "wide surface dump size"); + if (got) wide = fnv(wb, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + free(wb); + } + check(glGetError() == GL_NO_ERROR, "GL error"); + printf("digest=%016llx\n", (unsigned long long)digest); + printf("hires=%016llx\n", (unsigned long long)hires); + printf("wide=%016llx\n", (unsigned long long)wide); + printf("checks=%d failures=%d\n", checks, failures); + return failures ? 1 : 0; +} + #if defined(PSX_TEST_RENDER_PASSES) /* Mode "passes": see the header. */ static int passes_main(int scale, int window) { @@ -642,6 +844,12 @@ int main(int argc, char **argv) { return rc; } #endif + if (!strcmp(mode, "twin")) { + if (si.windowed) check(si.hr_scale == 1, "window mode engaged"); + int rc = twin_main(scale, si.windowed, argc > 3 && argv[3][0] == '1'); + gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); + return rc; + } if (!strcmp(mode, "mask")) { if (si.windowed) check(si.hr_scale == 1, "window mode engaged"); int rc = mask_main(scale, si.windowed);