diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 7c724721..d332b189 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -55,6 +55,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `screenshot_wide_hires` | ✓ | | `path`, `base_x` | The displayed band of the native-wide surface at internal resolution (`wide_w×S` by `height×S`). `present_shot` is capped at the window; this checks a widescreen + internal-resolution combination at full size | | `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 | +| `texture_lod` | ✓ | | `mode` (optional: 0 off, 1 mipmap), `aniso` (optional: 1..16) | Read or set `[video] texture_lod` / `anisotropic_filtering` live. Replies `mode`, `aniso` | | `post_aa` | ✓ | | `mode` (optional: 0 off, 1 fxaa, 2 fxaa_hq) | Read or set post-process anti-aliasing live (`[video] antialiasing_mode`). Replies `mode`, `passes` (filtered presents so far) and `gpu_us`: the mean pass time since the last query when the run has `PSX_POST_AA_TIME=N` (N passes per present between `glFinish` fences, a measurement mode), else 0 | | `hd_textures` | ✓ | | optional `replacements`, `dump` (0 or 1), `reload` (1) | HD pack root, switches, backend support, replacement count, matched/ready/applied draw counts, and queued dump count. Optional controls update the configured host pack on the emulation thread; no configured root is an error. Reload retains unchanged upload identities. OpenGL displays replacements; software/Vulkan retain original artwork. Pair with `present_shot` for visual evidence and native VRAM probes for architectural data | | `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) | @@ -480,9 +481,9 @@ between Play and TCP availability. ## Complete command index (generated) -**362 commands registered** — 349 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**363 commands registered** — 350 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -74 of 362 have prose above; **288 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. +75 of 363 have prose above; **288 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. @@ -789,6 +790,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `stereo_dump` | ✓ | | | | `stereo_stats` | ✓ | | | | `synth_recurse` | ✓ | | | +| `texture_lod` | ✓ | | ✓ | | `thread_ctx_ring` | ✓ | | | | `thread_trace` | ✓ | | | | `thread_trace_clear` | ✓ | | | diff --git a/docs/config_schema.md b/docs/config_schema.md index 187dd648..2b9875a4 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -656,6 +656,29 @@ is a renderer redesign, not an option. SMAA needs its area/search lookup textures and three passes; on an image that is already area-resolved from a higher internal resolution it buys little over FXAA, so it is left for later. +### Texture LOD and anisotropic filtering (`texture_lod`, `anisotropic_filtering`) + +```toml +[video] +texture_lod = "off" # off (default) | mipmap +anisotropic_filtering = 1 # 1..16, taps along the footprint (with mipmap) +``` + +PS1 texture pages and CLUTs live in VRAM and are decoded per fetch, so there +is no GL mip chain to sample, and a far road or wall at high internal scale +still point-samples one texel per pixel (shimmer). `mipmap` emulates the mip +level in the textured shader: the screen-space UV footprint is box-averaged +(opaque texels of the same STP class only), and `anisotropic_filtering` > 1 +spreads up to that many taps along the footprint's long axis so grazing +surfaces stay sharp across it. Every tap clamps to the primitive's own UV +bounds (or its texture window), so packed atlases never bleed into each +other, and a live CLUT change is seen at once (nothing is cached). Only +proven 3D world primitives are affected; sprites, HUD and magnified textures +keep their exact texels, and `off` is the historical path. Cost is up to 64 +fetches per minified pixel. OpenGL only. `PSX_TEXTURE_LOD=0|1` and +`PSX_ANISO=N` override them for one run; the `texture_lod` TCP command +switches them live. + The runtime clamps N per backend: - Software and Vulkan stop at 4. diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index a7100871..65f8a826 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -1a049b61e3b599c223bf00aa56dc55ddb9d5b6e2a894b9c34f6abaddf0cec844 +e08d3438b6881c8085e9440dda32664b665404140d4e0a96c2f2c5dff264a922 diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 294e3a11..decbf9d6 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -813,6 +813,19 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.video_pgxp_preserve_projection = toml::find(video, "pgxp_preserve_projection"); } + if (video.contains("texture_lod")) { + const auto mode = toml::find(video, "texture_lod"); + if (mode == "off") rt.video_texture_lod = 0; + else if (mode == "mipmap") rt.video_texture_lod = 1; + else throw std::runtime_error(fmt::format( + "[video] texture_lod must be \"off\" or \"mipmap\": {}", mode)); + } + if (video.contains("anisotropic_filtering")) { + const auto n = toml::find(video, "anisotropic_filtering"); + if (n < 1 || n > 16) throw std::runtime_error(fmt::format( + "[video] anisotropic_filtering must be 1..16: {}", n)); + rt.video_anisotropic_filtering = n; + } if (video.contains("pgxp_depth_buffer")) rt.video_pgxp_depth_buffer = toml::find(video, "pgxp_depth_buffer"); if (video.contains("pgxp_color_correction")) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 86d9810b..b0ea9f31 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -563,6 +563,13 @@ struct RuntimeConfig { // pgxp_depth_buffer) to close T-junction cracks above 1x ("off", // "fine" = 1 output px, "wide" = half a native px). bool video_pgxp_depth_buffer = false; + // texture_lod: "off" (default) or "mipmap" — mip-level emulation for + // minified 3D world textures (CLUT pages have no GL mip chain; the + // footprint is box-averaged inside the primitive's UV bounds). + // anisotropic_filtering: taps along the footprint's major axis (1..16, + // default 1); only with texture_lod. OpenGL only. + int video_texture_lod = 0; + int video_anisotropic_filtering = 1; bool video_pgxp_color_correction = false; int video_pgxp_seam = 0; double video_pgxp_depth_threshold = 4096.0; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index e954602b..6a3d99d2 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -285,6 +285,19 @@ if(BUILD_TESTING) --sdl-library "$" --output "${CMAKE_CURRENT_BINARY_DIR}/gl-hd-texture-results") set_tests_properties(gl_hd_texture_pack_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 180) + # [video] texture_lod / anisotropic_filtering: off and untracked/ + # magnified prims are unchanged; minified world textures average + # inside their UV bounds; render thread and PGXP depth agree. + add_test(NAME gl_texture_lod_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_texture_lod.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-texture-lod-results") + set_tests_properties(gl_texture_lod_test PROPERTIES + LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) endif() add_executable(launcher_device_roundtrip_test tests/test_launcher_device_roundtrip.cpp) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 1d3187d0..f2e11e37 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -282,6 +282,13 @@ void gl_renderer_set_wide_fast(int on); * gr_set_depth_triangle (no extra command per triangle otherwise). Any thread. */ int gl_renderer_pgxp_render_wanted(void); void gl_renderer_set_pgxp_depth(int on); +/* [video] texture_lod / anisotropic_filtering: mip-level emulation (box + * average of the screen footprint, clamped to the primitive's UV bounds) for + * minified world textures, with up to `aniso` taps along the footprint's + * major axis. mode 0 (default) leaves sampling exactly as before. */ +void gl_renderer_set_texture_lod(int mode, int aniso); +int gl_renderer_texture_lod(void); +int gl_renderer_anisotropy(void); int gl_renderer_get_pgxp_depth(void); void gl_renderer_set_pgxp_color_perspective(int on); int gl_renderer_get_pgxp_color_perspective(void); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index dad3298a..5d519401 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8573,6 +8573,19 @@ static void handle_post_aa(int id, const char *json) gl_renderer_post_aa_gpu_us()); } +/* texture_lod: {"cmd":"texture_lod"} reads, {"cmd":"texture_lod","mode":0|1, + * "aniso":1..16} sets [video] texture_lod / anisotropic_filtering live. */ +static void handle_texture_lod(int id, const char *json) +{ + int mode = json_get_int(json, "mode", -1); + int aniso = json_get_int(json, "aniso", -1); + if (mode >= 0 || aniso >= 0) + gl_renderer_set_texture_lod(mode >= 0 ? mode : gl_renderer_texture_lod(), + aniso >= 0 ? aniso : gl_renderer_anisotropy()); + send_fmt("{\"id\":%d,\"ok\":true,\"mode\":%d,\"aniso\":%d}", id, + gl_renderer_texture_lod(), gl_renderer_anisotropy()); +} + static void handle_frame_perf(int id, const char *json) { (void)json; @@ -15665,6 +15678,7 @@ static const CmdEntry s_commands[] = { { "nclip_stats", handle_nclip_stats }, { "frame_perf", handle_frame_perf }, { "post_aa", handle_post_aa }, + { "texture_lod", handle_texture_lod }, { "gl_ws_ablate", handle_gl_ws_ablate }, { "gl_interp", handle_gl_interp }, { "render_pass_stats", handle_render_pass_stats }, diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 5201f60e..c0a2ce7d 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -887,6 +887,9 @@ static int s_mod_r = 128, s_mod_g = 128, s_mod_b = 128, s_mod_raw = 0; static int s_mask_set = 0, s_mask_check = 0; static int s_tw_mask_x = 0, s_tw_mask_y = 0, s_tw_off_x = 0, s_tw_off_y = 0; static int s_tex_filter = 0; +/* [video] texture_lod (0 off, 1 mipmap emulation) and anisotropic_filtering + * (taps along the footprint's major axis, 1..16). */ +static int s_tex_lod = 0, s_tex_aniso = 1; /* Opaque textured draws carry the exact mask bit in FBO alpha. Keeping the * duplicate stencil copy current is deferred until mask checking is requested. */ static int s_stencil_valid = 1; @@ -1885,7 +1888,8 @@ static const char *TEX_FS = "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 int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" - "uniform int u_filter; /* 0 nearest, 1 bilinear, 2 stable */\n" + "uniform int u_filter; /* bits 0-3: 0 nearest, 1 bilinear, 2 stable; bit 4: LOD */\n" + "uniform int u_aniso; /* [video] anisotropic_filtering, 1..16 */\n" "uniform float u_shift;\n" "int vram_at(int x, int y){\n" " ivec2 p = ivec2(x & 1023, y & 511);\n" @@ -1947,13 +1951,46 @@ static const char *TEX_FS = " ivec2 at=clamp(ivec2(floor(t*vec2(size))),ivec2(0),size-ivec2(1));\n" " return texelFetch(u_hd_texture,at,0);\n" "}\n" + /* [video] texture_lod: mip-level emulation for CLUT/15-bit pages, which + * have no GL mip chain (they are decoded per fetch from raw VRAM). The + * screen-space UV footprint gives a major and a minor axis; up to u_aniso + * taps go along the major axis (anisotropic filtering) and each averages + * a k x k texel box the size of the minor footprint (the mip level). + * Every fetch clamps to the primitive's own UV bounds (or its texture + * window), so a packed atlas never bleeds into the neighbour; texels + * that are transparent or of the other STP class carry no weight. */ + "vec3 lod_sample(vec2 uv, vec2 dx, vec2 dy, int stp, vec3 base){\n" + " float lx=length(dx), ly=length(dy);\n" + " vec2 ax = lx >= ly ? dx : dy;\n" + " float major=max(lx,ly), minor=max(min(lx,ly),1.0);\n" + " if (major <= 1.0) return base;\n" + " int n = clamp(int(ceil(major/minor)), 1, u_aniso);\n" + " float box = max(minor, major/float(n));\n" + " int k = clamp(int(ceil(box)), 1, 8);\n" + " while (k > 1 && n*k*k > 64) k--;\n" + " vec2 bx = normalize(dx) * box, by = normalize(dy) * box;\n" + " if (lx < 1e-6) bx = vec2(box, 0.0);\n" + " if (ly < 1e-6) by = vec2(0.0, box);\n" + " vec4 sum = vec4(0.0);\n" + " for (int t=0; t>15)&1) == stp) sum += vec4(col5(raw), 1.0);\n" + " }\n" + " }\n" + " return sum.a > 0.0 ? sum.rgb / sum.a : base;\n" + "}\n" "void main(){\n" " int stp; vec3 rgb;\n" + " int fmode = u_filter & 15;\n" " /* v_persp is 0 for every prim unless [video] perspective_texturing is on\n" " * AND this prim's packet carried full GTE projection provenance, so the\n" " * default is the PS1's affine (noperspective) mapping. */\n" " vec2 uv = (v_persp != 0) ? v_uv_p : v_uv;\n" " vec2 dx=dFdx(uv), dy=dFdy(uv);\n" + " vec2 uv0=uv, dx0=dx, dy0=dy; /* the PS1 sample point (texture_lod) */\n" " if (v_hd_mode != 0) {\n" " vec4 hd=hd_texel(uv);\n" " rgb=hd.rgb;\n" @@ -1969,12 +2006,12 @@ static const char *TEX_FS = " stp=hd.a<=242.0/255.0 ? 1 : 0;\n" " if(all(equal(hd,vec4(0.0))) || (stp==0 && all(equal(hd.rgb,vec3(0.0))))) discard;\n" " }\n" - " } else if (u_filter == 0) {\n" + " } else if (fmode == 0) {\n" " int raw = fetch_texel(int(floor(uv.x)), int(floor(uv.y)));\n" " if (raw == 0) discard;\n" " rgb = col5(raw);\n" " stp = (raw >> 15) & 1;\n" - " } else if (u_filter == 2) {\n" + " } else if (fmode == 2) {\n" " uv+=vec2(u_shift); int raw=stable_texel(ivec2(floor(uv)));\n" " if(raw==0) discard; stp=(raw>>15)&1;\n" " float lx=length(dx), ly=length(dy);\n" @@ -2015,6 +2052,7 @@ static const char *TEX_FS = " + float((c01 >> 15) & 1) * w01 + float((c11 >> 15) & 1) * w11) / opac;\n" " stp = stpf >= 0.5 ? 1 : 0;\n" " }\n" + " if ((u_filter & 16) != 0 && v_hd_mode == 0) rgb = lod_sample(uv0, dx0, dy0, stp, rgb);\n" " if (u_semipass == 1 && stp == 1) discard;\n" " if (u_semipass == 2 && stp == 0) discard;\n" " if (v_raw == 0) rgb = clamp(rgb * (v_cp != 0 ? v_col_p : v_col.rgb) * 2.0, 0.0, 1.0);\n" @@ -4447,7 +4485,11 @@ static void gpu_textured_triangle(const int *xs, const int *ys, int semi, const int *lim) { // Mode 2 needs proven world geometry. Sprites, HUD and untracked packets // retain point sampling; their cutout pixels must stay sharp. - const int filter=s_tex_filter==2 && ((lim && !s_projected_uv_valid) || (!s_pc_valid && !s_pq_valid)) ? 0 : s_tex_filter; + const int untracked=(lim && !s_projected_uv_valid) || (!s_pc_valid && !s_pq_valid); + /* [video] texture_lod rides in the filter batch key (bit 4) for proven + * world geometry only; sprites and HUD keep their exact texels. */ + const int filter=(s_tex_filter==2 && untracked ? 0 : s_tex_filter) | + (s_tex_lod && !untracked ? 16 : 0); int lim_buf[4]; int uv_buf[6]; if (!lim) { @@ -4842,6 +4884,25 @@ static int glb_scale(void) { return s_out_scale; } /* real internal SSAA scal native-wide CPU present path + gr_scale() callers need the true scale — the FBO-direct present is unaffected since it never reads gr_scale()) */ +static GLint s_uAniso = -1; +static void lod_apply_uniforms(void) { + if (!s_tex_prog) return; + GLint prev = 0; glGetIntegerv(GL_CURRENT_PROGRAM, &prev); + if (s_uAniso < 0) s_uAniso = p_glGetUniformLocation(s_tex_prog, "u_aniso"); + p_glUseProgram(s_tex_prog); p_glUniform1i(s_uAniso, s_tex_aniso); + p_glUseProgram((GLuint)prev); +} +void gl_renderer_set_texture_lod(int mode, int aniso) { + GL_RT_SYNC("texture_lod"); + if (aniso < 1) aniso = 1; + if (aniso > 16) aniso = 16; + if (s_ctx) { flush_flat_batch(); flush_tex_batch(); hiw_flush_queue(); } + s_tex_lod = mode ? 1 : 0; + s_tex_aniso = aniso; + if (s_ctx) lod_apply_uniforms(); +} +int gl_renderer_texture_lod(void) { return s_tex_lod; } +int gl_renderer_anisotropy(void) { return s_tex_aniso; } static void glb_set_texture_filter(int b) { s_tex_filter = b >= 0 && b <= 2 ? b : 0; sw_set_texture_filter(b != 0); } static int glb_texture_filter(void) { return s_tex_filter; } @@ -5693,6 +5754,8 @@ static int init_gpu_raster(void) { s_uSemimode = p_glGetUniformLocation(s_tex_prog, "u_semimode"); s_uMaskset = p_glGetUniformLocation(s_tex_prog, "u_maskset"); s_uFilter = p_glGetUniformLocation(s_tex_prog, "u_filter"); + s_uAniso = -1; + lod_apply_uniforms(); s_uLimits = p_glGetUniformLocation(s_tex_prog, "u_limits"); s_uHdTexture = p_glGetUniformLocation(s_tex_prog, "u_hd_texture"); s_uBlitSrc = p_glGetUniformLocation(s_blit_prog, "u_src"); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c94e99de..5c083f8b 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1280,6 +1280,8 @@ static int g_video_pgxp_position_fallback = 1; static int g_video_pgxp_preserve_projection = 0; /* PGXP renderer features (G1.14); PSX_PGXP_DEPTH / _COLOR / _SEAM override. */ static int g_video_pgxp_depth_buffer = 0; +/* [video] texture_lod / anisotropic_filtering; PSX_TEXTURE_LOD / PSX_ANISO. */ +static int g_video_texture_lod = 0, g_video_aniso = 1; static int g_video_pgxp_color_correction = 0; static int g_video_pgxp_seam = 0; static float g_video_pgxp_depth_threshold = 4096.0f; @@ -16069,6 +16071,8 @@ int main(int argc, char** argv) { gc.runtime.video_pgxp_preserve_projection ? 1 : 0; g_video_pgxp_mod_only = gc.runtime.video_pgxp_mod_only ? 1 : 0; g_video_pgxp_depth_buffer = gc.runtime.video_pgxp_depth_buffer ? 1 : 0; + g_video_texture_lod = gc.runtime.video_texture_lod; + g_video_aniso = gc.runtime.video_anisotropic_filtering; g_video_pgxp_color_correction = gc.runtime.video_pgxp_color_correction ? 1 : 0; g_video_pgxp_seam = gc.runtime.video_pgxp_seam; g_video_pgxp_depth_threshold = (float)gc.runtime.video_pgxp_depth_threshold; @@ -18455,6 +18459,9 @@ session_reboot: if (const char* e = std::getenv("PSX_PGXP_COLOR")) g_video_pgxp_color_correction = (*e && *e != '0'); if (const char* e = std::getenv("PSX_PGXP_SEAM")) g_video_pgxp_seam = std::atoi(e); gl_renderer_set_pgxp_depth(g_video_pgxp_depth_buffer); + if (const char* e = std::getenv("PSX_TEXTURE_LOD")) g_video_texture_lod = std::atoi(e); + if (const char* e = std::getenv("PSX_ANISO")) g_video_aniso = std::atoi(e); + gl_renderer_set_texture_lod(g_video_texture_lod, g_video_aniso); gl_renderer_set_pgxp_color_perspective(g_video_pgxp_color_correction); gl_renderer_set_pgxp_seam(g_video_pgxp_seam); if (const char* e = std::getenv("PSX_PGXP_DEPTH_THRESHOLD")) g_video_pgxp_depth_threshold = (float)std::atof(e); diff --git a/runtime/tests/run_gl_render_thread.py b/runtime/tests/run_gl_render_thread.py index d1dafbb1..7bd3c578 100644 --- a/runtime/tests/run_gl_render_thread.py +++ b/runtime/tests/run_gl_render_thread.py @@ -56,6 +56,8 @@ def main(): ap.add_argument("--fixture", type=pathlib.Path) ap.add_argument("--scales", default="1,4") ap.add_argument("--frames", default="80") + ap.add_argument("--single", action="store_true", + help="run the fixture once with no arguments; its exit code is the result") args = ap.parse_args() sdl_includes = [str(pathlib.Path(d).resolve()) for d in args.sdl_include.split(";") if d] args.sdl_library = str(pathlib.Path(args.sdl_library).resolve()) @@ -116,6 +118,13 @@ def main(): print(r.stderr[-3000:]) return 2 + if args.single: + r = run([probe]) + print(r.stdout[-6000:], r.stderr[-3000:]) + if r.returncode == SKIP_EXIT: + print("SKIP:", r.stderr.strip()[-600:]) + return r.returncode + ok = True first = True for s in [int(v) for v in args.scales.split(",") if v]: diff --git a/runtime/tests/test_gl_texture_lod.c b/runtime/tests/test_gl_texture_lod.c new file mode 100644 index 00000000..fb11148d --- /dev/null +++ b/runtime/tests/test_gl_texture_lod.c @@ -0,0 +1,139 @@ +/* [video] texture_lod / anisotropic_filtering on a real, hidden OpenGL + * context, driven through the renderer facade (gr_*). + * + * - off (the default) draws exactly what it always drew; with it on, + * untracked (HUD / sprite) primitives and magnified textures are still + * byte-identical to off; + * - on, a heavily minified one-texel red/blue checker on proven world + * geometry averages towards purple instead of aliasing; + * - the footprint never leaves the primitive's UV bounds: a green border + * packed right next to the sampled texture never bleeds in; + * - anisotropic: a texture minified along u only keeps its v detail with + * 16 taps, where a single isotropic tap box blurs it; + * - the render thread and PGXP depth give the same pixels as the direct + * path, at 4x internal resolution. + * Original source-owned scene; no retail payload. */ +#define main rt_fixture_main +#include "test_gl_render_thread.c" +#undef main + +static uint16_t nat[1024 * 512]; + +/* 15-bit page at (512,0): 128x128 one-texel checker red/blue, in a 2-texel + * green border (outside every UV range drawn). Rows 0..127 of a second page + * at (640,0): horizontal stripes, 2 texels each, red/blue. */ +static void pages(void) { + static uint16_t pg[132 * 132], st[128 * 128]; + for (int y = 0; y < 132; y++) + for (int x = 0; x < 132; x++) { + int in = x >= 2 && x < 130 && y >= 2 && y < 130; + pg[y * 132 + x] = !in ? 0x03e0 : (((x + y) & 1) ? 0x001f : 0x7c00); + } + cpu_upload(510, 0, 132, 132, pg); /* texel (0,0) of page 8 is pg(2,2) */ + for (int y = 0; y < 128; y++) + for (int x = 0; x < 128; x++) st[y * 128 + x] = ((y >> 1) & 1) ? 0x001f : 0x7c00; + cpu_upload(640, 0, 128, 128, st); +} +#define TP_CHECK 0x0108 /* 15-bit page at (512,0) */ +#define TP_STRIPE 0x010A /* 15-bit page at (640,0) */ + +static void tri(int tracked, int x, int y, int w, int h, int u0, int v0, int uw, int vh, uint16_t tp) { + gr_set_precise_triangle(tracked, x << 16, y << 16, (x + w) << 16, y << 16, x << 16, (y + h) << 16); + gr_draw_textured_triangle(x, y, u0, v0, x + w, y, u0 + uw, v0, x, y + h, u0, v0 + vh, 0, 0, tp); + gr_set_precise_triangle(tracked, (x + w) << 16, y << 16, (x + w) << 16, (y + h) << 16, x << 16, (y + h) << 16); + gr_draw_textured_triangle(x + w, y, u0 + uw, v0, x + w, y + h, u0 + uw, v0 + vh, x, y + h, u0, v0 + vh, 0, 0, tp); +} +static void scene(int depth) { + gr_set_draw_area(0, 0, 319, 239); + gr_set_draw_offset(0, 0); + mask_bits(0, 0); + gr_set_semi_transparency(0, 0); + gr_set_color_modulation(128, 128, 128, 1); + gr_fill_rect(0, 0, 320, 240, 0x0000); + if (depth) gl_renderer_set_pgxp_depth(1); + tri(1, 10, 10, 16, 16, 2, 2, 120, 120, TP_CHECK); /* 7.5:1 minified world */ + tri(0, 40, 10, 16, 16, 2, 2, 120, 120, TP_CHECK); /* same, untracked (HUD) */ + tri(1, 70, 10, 64, 64, 2, 2, 32, 32, TP_CHECK); /* magnified world */ + tri(1, 10, 100, 16, 100, 0, 0, 127, 100, TP_STRIPE); /* u minified only */ + gl_renderer_render_thread_frame_boundary(); + if (depth) gl_renderer_set_pgxp_depth(0); + gr_vram_transfer_out(0, 0, 1024, 512, nat); +} +static int channel(uint16_t p, int c) { return (p >> (c * 5)) & 31; } +/* Mean |red - blue| over the minified checker: 31 = aliasing, 0 = averaged. */ +static double chroma(int x0) { + long d = 0; int n = 0; + for (int y = 13; y < 23; y++) + for (int x = x0 + 3; x < x0 + 9; x++) { uint16_t p = nat[y * 1024 + x]; d += labs(channel(p, 0) - channel(p, 2)); n++; } + return (double)d / n; +} +static int any_green(void) { + for (int y = 0; y < 240; y++) + for (int x = 0; x < 320; x++) if (channel(nat[y * 1024 + x], 1) > 2) return 1; + return 0; +} +/* Stripe contrast down column 12 of the u-minified quad. */ +static double stripe_contrast(void) { + long d = 0; int n = 0; + for (int y = 104; y < 190; y++) { uint16_t p = nat[y * 1024 + 12]; d += labs(channel(p, 0) - channel(p, 2)); n++; } + return (double)d / n; +} +static int region_equal(const uint16_t *a, const uint16_t *b, int x, int y, int w, int h) { + for (int r = y; r < y + h; r++) + if (memcmp(a + r * 1024 + x, b + r * 1024 + x, (size_t)w * 2)) return 0; + return 1; +} + +int main(void) { + if (SDL_Init(SDL_INIT_VIDEO) != 0) { fprintf(stderr, "SKIP no video\n"); return 77; } + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); +#if defined(__APPLE__) + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG); +#endif + SDL_Window *win = SDL_CreateWindow("Texture LOD hidden test", 0, 0, 320, 240, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + if (!win) { fprintf(stderr, "SKIP no window\n"); return 77; } + gr_set_backend(GR_BACKEND_OPENGL); + gr_init(vram); + gr_set_scale(1); + gl_renderer_set_swap_interval(0); + if (!gl_renderer_init_context(win)) { fprintf(stderr, "SKIP no GL context\n"); return 77; } + pages(); + static uint16_t off[1024 * 512], on[1024 * 512]; + check(gl_renderer_texture_lod() == 0, "off by default"); + scene(0); memcpy(off, nat, sizeof off); + const double c_off = chroma(10), s_off = stripe_contrast(); + check(!any_green(), "baseline samples no border"); + gl_renderer_set_texture_lod(1, 1); + scene(0); memcpy(on, nat, sizeof on); + const double c_on = chroma(10), s_iso = stripe_contrast(); + check(region_equal(off, on, 40, 10, 16, 16), "untracked (HUD) primitive untouched"); + check(region_equal(off, on, 70, 10, 64, 64), "magnified texture untouched"); + check(c_on < c_off / 3.0, "minified checker averages instead of aliasing"); + check(!any_green(), "footprint stays inside the primitive's UV bounds"); + gl_renderer_set_texture_lod(1, 16); + scene(0); + const double s_aniso = stripe_contrast(); + printf("chroma off=%.2f on=%.2f stripes off=%.2f iso=%.2f aniso16=%.2f\n", + c_off, c_on, s_off, s_iso, s_aniso); + check(s_iso < s_off / 2.0, "isotropic box blurs the minor axis"); + check(s_aniso > s_off * 0.8, "16 anisotropic taps keep the minor-axis detail"); + check(!any_green(), "anisotropic taps stay inside the UV bounds"); + memcpy(on, nat, sizeof on); + scene(1); + check(memcmp(nat, on, sizeof on) == 0, "PGXP depth path samples the same"); + check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + scene(0); + gl_renderer_render_thread_sync("lod"); + check(memcmp(nat, on, sizeof on) == 0, "render thread gives the same pixels"); + gl_renderer_render_thread_stop(); + gl_renderer_set_texture_lod(0, 1); + scene(0); + check(memcmp(nat, off, sizeof off) == 0, "back off restores the exact image"); + check(glGetError() == GL_NO_ERROR, "GL errors"); + printf("checks=%d failures=%d\n", checks, failures); + gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); + return failures ? 1 : 0; +}