From c8b692eda2f7c11b5b2f8f6d5c56194078b478ae Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 5 Aug 2026 14:02:58 -0700 Subject: [PATCH] feat(video): opt-in sub-pixel vertex precision + perspective-correct textures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the parked projection/perspective work reachable and gets it into the renderers people actually use. Addresses issue #92 (PGXP-style geometry and texture correction). The GTE side cache, SWC2 RAM provenance, GP0 triangle preparation and the software-renderer consumption path already landed in 2ceaf5a ("park projection and perspective correction", from kem0x PR #14), but were unreachable: the generic setters gte_geometry_correction_set() / gpu_texture_correction_set() had no production caller, and the corrected vertices went straight to sw_set_precise_triangle(), bypassing the renderer vtable — while the default renderer is OpenGL. This adds: - [video] geometry_correction and [video] perspective_texturing, in game.toml and settings.toml, both defaulting false. Independent knobs; the launcher seed marks them present so a save round-trips a hand-edited key instead of dropping it. Startup names the active pair, and notes when geometry correction is on below supersampling 2 (where it cannot be seen). - gr_set_precise_triangle() / gr_set_perspective_triangle() on the GpuRenderBackend vtable, so gpu.c hands the correction to whichever backend is live rather than to the software rasterizer directly. NULL entries no-op. - OpenGL and Vulkan consumption. Positions are already float end-to-end, so sub-pixel vertices drop in; the readback dirty rect widens by one pixel to cover them. Perspective UVs use a per-vertex a_q weight and clip coordinates pre-multiplied by w = 1/q, letting the hardware's own perspective divide interpolate a smooth UV varying beside the affine noperspective one. a_q == 0 — the default, and what every prim gets with the feature off — makes w exactly 1.0 and selects the affine varying, so the faithful path is the pre-feature pipeline unchanged. The PS1-visible GTE SXY FIFO stays integer either way: guest-side screen-bounds culling and SXY readback are untouched, and this remains visual-only. Also fixes a pre-existing concurrent-build race found while validating: every runtime target runs embed_spirv.py over the same shaders, and each wrote ".spv" next to the source, so under -j N one process deleted the file another had just written (FileNotFoundError on a shader that compiled fine). The intermediates now go to a per-invocation temp dir. Parked-work provenance retained; see ENHANCEMENTS.md G1. Co-authored-by: Kareem Olim Co-Authored-By: Claude Opus 5 (1M context) --- ENHANCEMENTS.md | 110 ++++++++++++ README.md | 19 +++ .../kem0x-pr14-projection-perspective.md | 16 ++ recompiler/CMakeLists.txt | 16 ++ recompiler/src/config_loader.cpp | 22 +++ recompiler/src/config_loader.h | 27 +++ .../tests/video_enhancement_settings_test.cpp | 159 ++++++++++++++++++ runtime/include/cpu_state.h | 9 + runtime/include/gpu.h | 7 + runtime/include/gpu_render.h | 23 +++ runtime/shaders/geo_tex.frag | 12 +- runtime/shaders/geo_tex.vert | 18 +- runtime/src/gpu.c | 12 +- runtime/src/gpu_gl_renderer.c | 94 +++++++++-- runtime/src/gpu_render.c | 11 ++ runtime/src/gpu_vk_renderer.c | 66 +++++++- runtime/src/main.cpp | 39 +++++ tools/embed_spirv.py | 16 +- 18 files changed, 642 insertions(+), 34 deletions(-) create mode 100644 recompiler/tests/video_enhancement_settings_test.cpp diff --git a/ENHANCEMENTS.md b/ENHANCEMENTS.md index 88b4f0ae..e067a834 100644 --- a/ENHANCEMENTS.md +++ b/ENHANCEMENTS.md @@ -400,3 +400,113 @@ stays, inert + A/B-able). What was learned, so the next attempt starts ahead: - **Groundwork landed on `feat/ws-2d-scene-pillarbox`:** census `tagged` column, `ws_hud_mode` live A/B, and the untagged-rect scoping that makes `nw_hud_corners` safe to experiment with on tag titles. + +--- + +## G1 — Sub-pixel vertex precision + perspective-correct textures (issue #92) + +PS1 polygon jitter ("wobble", "bouncing lines") and warped floor/wall textures +have one root cause each, both in the fixed-point geometry pipeline: + +- **Jitter.** The GTE computes the projected screen position in 16.16, then + saturates it to an integer pixel when it pushes SXY. The fraction is thrown + away. A slowly moving mesh therefore snaps its vertices between whole pixels + and the model shimmers. +- **Texture warp.** The GPU interpolates UV *affinely* across a triangle, with + no 1/z term, so a large floor or wall polygon's texture swims as the camera + moves. + +Both are addressed as **opt-in, visual-only** enhancements. The PS1-visible GTE +SXY FIFO stays integer and fully faithful — a game's own post-projection +screen-bounds culls and any SXY readback see exactly what hardware produces. +Nothing about guest state changes; the correction lives entirely on the host +render path. + +### Configuration + +```toml +[video] +geometry_correction = true # sub-pixel vertex precision (kills the wobble) +perspective_texturing = true # perspective-correct UVs on world polygons +supersampling = 2 # REQUIRED for geometry_correction to be visible +``` + +Both default **false** (the faithful floor). They are independent — a title may +want stable geometry without changing texture mapping. Settable per-game in +`game.toml` and per-player in `settings.toml` (the player's file wins, and a +launcher save round-trips both keys rather than dropping them). + +`geometry_correction` needs `supersampling >= 2`: at native resolution the +corrected position rounds back to the pixel it started on, so there is nothing +to see. The runtime prints a note at startup when it is on at scale 1. + +### How it works + +The recompiler emits GTE commands as calls to a single runtime entry point +(`gte_execute`), which both the compiled backend and the dirty-RAM interpreter +share — so unlike an interpreter/dynarec emulator there is no dispatch hook to +add, just one funnel to instrument. + +1. **`runtime/src/gte.cpp`** — RTPS/RTPT keep the discarded 16.16 fraction in a + side cache keyed by the packed SXY word it rounded to (`geom_note`). + Saturated (off-screen) projections are rejected: they carry no usable + sub-pixel information. +2. **SWC2 provenance** — the recompiler, strict translator, dirty-RAM interp, + overlay ABI (v14) and fallback interp all call + `gte_precision_store_word(addr, reg)` when a projection register is stored to + guest RAM, recording *which RAM address* a projection landed at. Perspective + texturing only fires when all three of a triangle's position words came from + such a store at that exact DMA packet address — which preserves the + association through ordering-table reordering and rejects CPU-built UI and + 2D sprites outright. A plain `sw` to a tracked address invalidates it. +3. **`runtime/src/gpu.c`** — `prepare_precise_triangle()` / + `prepare_texture_triangle()` look the packet up per triangle and hand the + result to the renderer facade as sideband state for the next draw + (`gr_set_precise_triangle` / `gr_set_perspective_triangle`). +4. **All three renderers consume it.** Software uses the fractional positions + in its supersampled mirror; OpenGL and Vulkan take them as float vertex + positions directly. For perspective UVs both GPU backends carry a per-vertex + `a_q` weight and emit clip coordinates pre-multiplied by `w = 1/q`, so the + hardware's own perspective divide interpolates a `smooth` UV varying while + the affine `noperspective` one stays available. **`a_q == 0` (the default) + makes `w` exactly 1.0 and selects the affine varying — the pre-feature + pipeline, unchanged.** + +Save states and speculative native-validation passes drop host-only provenance +(`gte_precision_timeline_invalidate`, `gte_precision_speculative_begin/end`) so +a rewind can never resurrect a stale projection. + +### Validation story + +By construction this feature *diverges* from stock hardware output, so the +Beetle oracle cannot be the judge of the corrected frame. What the oracle still +pins is the part that must not move: **with both flags off the output is +byte-identical to the pre-feature build**, and guest-visible GTE state is +identical either way (the SXY FIFO is untouched in both). That reduces +validation to (a) an off/off pixel-identity check against the oracle, and +(b) human A/B of the on/off frames on a 3D title. + +`gte_geometry_correction_hits()` and `gpu_texture_correction_hits()` report how +many vertices/triangles were actually corrected — the "is this doing anything +on this title" counter, and the thing to check first when a title shows no +visible change. + +### Provenance + +The GTE side cache, SWC2 provenance tracking, GP0 triangle preparation and the +software-renderer consumption path were contributed by **Kareem Olim (kem0x)** +in [PR #14](https://github.com/mstan/psxrecomp/pull/14) and parked in commit +`2ceaf5a` (see `docs/internal/upstream/kem0x-pr14-projection-perspective.md`), +disabled pending generic setters. This work adds the opt-in configuration, the +renderer-facade seam, and OpenGL + Vulkan support. + +### Status / next + +- **Done:** config plumbing (game.toml + settings.toml + launcher seed + round-trip), renderer-facade sideband, software / OpenGL / Vulkan consumption, + `[video]` plumbing unit test. +- **Open:** per-title A/B validation. Ape Escape is the obvious first 3D + subject (Tomba 1/2 and MMX5/6 are largely 2D, where neither knob does much). +- **Open:** launcher (recomp-ui) toggles. The keys round-trip through + `settings.toml` today, but there is no UI row yet — that lives in the + recomp-ui repo. diff --git a/README.md b/README.md index 9a63dddf..6b6b81c0 100644 --- a/README.md +++ b/README.md @@ -375,6 +375,25 @@ See [`docs/STRING_TRANSLATION.md`](docs/STRING_TRANSLATION.md); *Tsumu Light* | **Vulkan** | **Experimental.** Built when the SDK is present, opt-in at runtime; falls back to OpenGL if unavailable. | | **Software** | CPU rasterizer — the reference look, and the most portable fallback. | +### Geometry enhancements (opt-in) + +Two optional fixes for the PS1's fixed-point geometry pipeline, off by default +so the faithful look stays the baseline. Set them in `game.toml` or the +player's `settings.toml`: + +```toml +[video] +geometry_correction = true # sub-pixel vertex precision — removes polygon + # jitter/wobble. Needs supersampling >= 2. +perspective_texturing = true # perspective-correct UVs — removes the texture + # warp on large floor/wall polygons. +supersampling = 2 +``` + +Both are visual-only: the GTE's guest-visible screen coordinates stay integer +and fully faithful, so game logic and culling are unaffected. Supported on all +three renderers. See [`ENHANCEMENTS.md`](ENHANCEMENTS.md) §G1. + ## How to use PSXRecomp PSXRecomp takes a PlayStation disc image and creates a recompilation project diff --git a/docs/internal/upstream/kem0x-pr14-projection-perspective.md b/docs/internal/upstream/kem0x-pr14-projection-perspective.md index ecd27289..f8cfe3e1 100644 --- a/docs/internal/upstream/kem0x-pr14-projection-perspective.md +++ b/docs/internal/upstream/kem0x-pr14-projection-perspective.md @@ -20,3 +20,19 @@ Excluded: all Web host exports and every title-specific state, action, address, or presentation hook from the source commit. The separate 60 Hz presentation experiment is parked on its own branch. Authorship is retained in the commit trailer. + +## UNPARKED + +Both are now reachable enhancements, wired up in response to +[issue #92](https://github.com/mstan/psxrecomp/issues/92): + +- `[video] geometry_correction` and `[video] perspective_texturing` call the + generic setters this branch left dangling (`gte_geometry_correction_set`, + `gpu_texture_correction_set`), from `game.toml` or `settings.toml`; +- the software-renderer-only consumption path became a `GpuRenderBackend` + sideband (`gr_set_precise_triangle` / `gr_set_perspective_triangle`), and + OpenGL and Vulkan now consume it too — the parked code only reached the + software rasterizer, while the default renderer is OpenGL. + +See `ENHANCEMENTS.md` §G1 for the shipped design. This file stays as the record +of what was imported and what was deliberately left out. diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index c4bc903e..c2e089ff 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -316,6 +316,22 @@ if(BUILD_TESTING) add_test(NAME recompiler_patch_cxx17_header_test COMMAND recompiler_patch_cxx17_header_test) + # [video] geometry_correction / perspective_texturing opt-in plumbing. + add_executable(video_enhancement_settings_test + tests/video_enhancement_settings_test.cpp + src/config_loader.cpp + src/ps1_exe_parser.cpp + ) + target_include_directories(video_enhancement_settings_test PRIVATE + include + src + lib/fmt/include + lib/toml11 + ) + target_link_libraries(video_enhancement_settings_test PRIVATE fmt) + add_test(NAME video_enhancement_settings_test + COMMAND video_enhancement_settings_test) + # Needs bios/SCPH1001.toml — run from the framework root. add_test(NAME bios_address_model_test COMMAND bios_address_model_test diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 246483fe..56830123 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -523,6 +523,14 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (video.contains("offer_vulkan")) { rt.video_offer_vulkan = toml::find(video, "offer_vulkan"); } + if (video.contains("geometry_correction")) { + rt.video_geometry_correction = + toml::find(video, "geometry_correction"); + } + if (video.contains("perspective_texturing")) { + rt.video_perspective_texturing = + toml::find(video, "perspective_texturing"); + } if (video.contains("crt_filter")) { const auto mode = toml::find(video, "crt_filter"); if (mode == "raw") rt.video_screen_kind = 0; @@ -2040,6 +2048,14 @@ UserSettings load_user_settings(const fs::path& path) { if (m == "nearest") { s.texture_filter = 0; s.has_texture_filter = true; } else if (m == "bilinear") { s.texture_filter = 1; s.has_texture_filter = true; } }); + if (v.contains("geometry_correction")) try_get([&]{ + s.geometry_correction = toml::find(v, "geometry_correction"); + s.has_geometry_correction = true; + }); + if (v.contains("perspective_texturing")) try_get([&]{ + s.perspective_texturing = toml::find(v, "perspective_texturing"); + s.has_perspective_texturing = true; + }); if (v.contains("crt_filter")) try_get([&]{ const auto m = toml::find(v, "crt_filter"); if (m == "raw") { s.screen_kind = 0; s.has_screen_kind = true; } @@ -2263,6 +2279,12 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { f << "antialiasing = " << (s.antialiasing ? "true" : "false") << "\n"; if (s.has_texture_filter) f << "texture_filtering = \"" << (s.texture_filter ? "bilinear" : "nearest") << "\"\n"; + if (s.has_geometry_correction) + f << "geometry_correction = " + << (s.geometry_correction ? "true" : "false") << "\n"; + if (s.has_perspective_texturing) + f << "perspective_texturing = " + << (s.perspective_texturing ? "true" : "false") << "\n"; if (s.has_screen_kind) { const char* k = s.screen_kind == 1 ? "crt" : s.screen_kind == 2 ? "composite" diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 4761b920..03d017ef 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -329,6 +329,29 @@ struct RuntimeConfig { // fallback. Stored as VIDEO_RENDERER_*. int video_renderer = DEFAULT_VIDEO_RENDERER; + // geometry_correction: sub-pixel vertex precision (the PGXP-style fix for + // PS1 polygon jitter/wobble). The GTE projects in 16.16 and then throws the + // fraction away when it saturates SXY to integer screen pixels; vertices of + // a moving mesh therefore snap between whole pixels and the model appears to + // shimmer. With this on, the GTE keeps the discarded fraction in a side + // cache and the rasterizer places the vertex between native pixels. + // + // The PS1-visible SXY FIFO stays integer and fully faithful — the game's own + // screen-bounds culls and any SXY readback see exactly what hardware would + // produce. This is visual-only. Default off (faithful floor). + // + // Only observable at [video] supersampling >= 2: at native resolution the + // corrected position rounds back to the same pixel it started on. + bool video_geometry_correction = false; + + // perspective_texturing: perspective-correct UV interpolation for textured + // world polygons (the PS1 GPU interpolates UV affinely, which warps textures + // on large floor/wall polygons as the camera moves). Uses the exact SWC2 + // projection provenance — a polygon only qualifies when every one of its + // position words was written to that DMA packet address by a projection + // store — so CPU-built UI and 2D sprites are never touched. Default off. + bool video_perspective_texturing = false; + // offer_vulkan: expose the experimental Vulkan renderer in the launcher. // Defaults false even for Vulkan-enabled builds; developers must opt in per // game once visuals are validated. @@ -969,6 +992,10 @@ struct UserSettings { bool has_window_width = false; int window_width = 1280; // -> 1280x960 bool has_antialiasing = false; bool antialiasing = true; bool has_texture_filter = false; int texture_filter = 0; // 0=nearest,1=bilinear + // Sub-pixel vertex precision / perspective-correct UVs (see RuntimeConfig). + // Both default off — the faithful floor — and are player-selectable. + bool has_geometry_correction = false; bool geometry_correction = false; + bool has_perspective_texturing = false; bool perspective_texturing = false; bool has_screen_kind = false; int screen_kind = 0; // 0..3 (ScreenKind) bool has_auto_skip_fmv = false; bool auto_skip_fmv = false; // skip FMVs // Turbo through in-game load screens: while the CD data stream is active, run diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp new file mode 100644 index 00000000..8a1e9161 --- /dev/null +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -0,0 +1,159 @@ +/* video_enhancement_settings_test — [video] geometry_correction / + * perspective_texturing plumbing. + * + * These two knobs are the opt-in for the sub-pixel vertex precision and + * perspective-correct UV enhancements (psxrecomp issue #92). The underlying + * GTE/GPU machinery has its own unit coverage in the runtime suite; what this + * test pins is the part that decides whether it is ever switched on: + * + * 1. both default OFF (the faithful floor) when game.toml says nothing; + * 2. game.toml [video] turns them on; + * 3. settings.toml (the player's file) parses them; + * 4. save_user_settings round-trips them — a launcher save must not silently + * drop a hand-edited key, which would look like "the setting does nothing". + */ +#include "config_loader.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static int failures = 0; + +static void check(bool cond, const char* what) { + if (!cond) { + std::fprintf(stderr, "FAIL: %s\n", what); + ++failures; + } +} + +static fs::path write_temp(const std::string& name, const std::string& body) { + fs::path p = fs::temp_directory_path() / name; + std::ofstream f(p, std::ios::binary | std::ios::trunc); + f << body; + return p; +} + +/* game.toml needs the fields load_game_config() requires; keep this to the + * documented minimum plus whichever [video] body the case under test needs. + * + * [runtime] is required even when empty: parse_runtime_block() early-returns + * on a config with no [runtime] table, so a game.toml carrying [video] alone + * would silently ignore every video key. Every shipped game.toml has both. */ +static fs::path write_game_toml(const std::string& name, + const std::string& video_block) { + return write_temp(name, + "[game]\n" + "name = \"probe\"\n" + "exe = \"probe.exe\"\n" + "load_address = \"0x80010000\"\n" + "entry_pc = \"0x80010000\"\n" + "text_size = \"0x1000\"\n" + "[recompiler]\n" + "seeds = \"seeds.json\"\n" + "[runtime]\n" + + video_block); +} + +static void test_defaults_off() { + fs::path p = write_game_toml("psxrecomp_pgxp_default.toml", ""); + auto gc = PSXRecompV4::load_game_config(p); + check(!gc.runtime.video_geometry_correction, + "geometry_correction defaults off (faithful floor)"); + check(!gc.runtime.video_perspective_texturing, + "perspective_texturing defaults off (faithful floor)"); + fs::remove(p); +} + +static void test_game_toml_opt_in() { + fs::path p = write_game_toml("psxrecomp_pgxp_on.toml", + "[video]\n" + "geometry_correction = true\n" + "perspective_texturing = true\n"); + auto gc = PSXRecompV4::load_game_config(p); + check(gc.runtime.video_geometry_correction, + "[video] geometry_correction = true is honoured"); + check(gc.runtime.video_perspective_texturing, + "[video] perspective_texturing = true is honoured"); + fs::remove(p); +} + +/* The two knobs are independent: a title may want stable geometry without + * changing texture mapping (or the reverse) — so a single flag would be wrong. */ +static void test_knobs_independent() { + fs::path p = write_game_toml("psxrecomp_pgxp_geom_only.toml", + "[video]\n" + "geometry_correction = true\n"); + auto gc = PSXRecompV4::load_game_config(p); + check(gc.runtime.video_geometry_correction, + "geometry_correction alone turns on"); + check(!gc.runtime.video_perspective_texturing, + "geometry_correction alone leaves perspective_texturing off"); + fs::remove(p); +} + +static void test_user_settings_read() { + fs::path p = write_temp("psxrecomp_pgxp_settings.toml", + "[video]\n" + "geometry_correction = true\n" + "perspective_texturing = false\n"); + auto us = PSXRecompV4::load_user_settings(p); + check(!us.parse_error, "settings.toml parses"); + check(us.has_geometry_correction && us.geometry_correction, + "settings.toml geometry_correction = true read"); + check(us.has_perspective_texturing && !us.perspective_texturing, + "settings.toml perspective_texturing = false read (explicit off)"); + fs::remove(p); +} + +/* An absent key must stay absent, so layering leaves the game.toml value alone + * instead of forcing it off. */ +static void test_user_settings_absent_key() { + fs::path p = write_temp("psxrecomp_pgxp_settings_empty.toml", + "[video]\n" + "supersampling = 2\n"); + auto us = PSXRecompV4::load_user_settings(p); + check(!us.has_geometry_correction, + "absent geometry_correction leaves has_* false"); + check(!us.has_perspective_texturing, + "absent perspective_texturing leaves has_* false"); + fs::remove(p); +} + +static void test_user_settings_round_trip() { + PSXRecompV4::UserSettings out; + out.geometry_correction = true; out.has_geometry_correction = true; + out.perspective_texturing = true; out.has_perspective_texturing = true; + + fs::path p = fs::temp_directory_path() / "psxrecomp_pgxp_roundtrip.toml"; + check(PSXRecompV4::save_user_settings(p, out), "save_user_settings writes"); + + auto back = PSXRecompV4::load_user_settings(p); + check(!back.parse_error, "written settings.toml re-parses"); + check(back.has_geometry_correction && back.geometry_correction, + "geometry_correction survives a save/load round trip"); + check(back.has_perspective_texturing && back.perspective_texturing, + "perspective_texturing survives a save/load round trip"); + fs::remove(p); +} + +int main() { + test_defaults_off(); + test_game_toml_opt_in(); + test_knobs_independent(); + test_user_settings_read(); + test_user_settings_absent_key(); + test_user_settings_round_trip(); + + if (failures) { + std::fprintf(stderr, "video_enhancement_settings_test: %d failure(s)\n", + failures); + return 1; + } + std::printf("PASS: [video] geometry_correction / perspective_texturing " + "plumbing\n"); + return 0; +} diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index f010238d..6831510e 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -205,6 +205,15 @@ extern void gte_precision_timeline_invalidate(void); extern void gte_precision_speculative_begin(void); extern void gte_precision_speculative_end(void); extern void gte_precision_store_word(uint32_t addr, uint8_t reg); +/* Sub-pixel vertex precision ([video] geometry_correction). Enables the side + * cache that retains the 16.16 projection fraction the GTE discards when it + * saturates SXY to integer screen pixels. Guest-visible GTE state is + * unchanged — this is a visual-only enhancement, default off. */ +extern void gte_geometry_correction_set(int enabled); +extern int gte_geometry_correction_enabled(void); +/* Corrected vertices consumed since the last gte_geometry_correction_set — + * the "is this actually doing anything on this title" counter. */ +extern uint32_t gte_geometry_correction_hits(void); /* ============================================================================ * Dispatch call contract (Bug D / wild-return family fix) diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index bc075bac..aece192e 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -283,6 +283,13 @@ int psx_ws_is_cull_w_imm(uint32_t imm); * never opted in must never have its live code pattern-scanned and rewritten. */ void gpu_ws_set_auto_hooks(int cull_on, int backdrop_on); int psx_ws_auto_cull_on(void); +/* Perspective-correct UV interpolation for textured world polygons + * ([video] perspective_texturing). Also turns on the SWC2 RAM-provenance + * tracking it needs, so a polygon only qualifies when every position word in + * its DMA packet was written by a GTE projection store. Default off. */ +void gpu_texture_correction_set(int enabled); +/* Triangles drawn with perspective-correct UVs since startup. */ +uint32_t gpu_texture_correction_hits(void); /* GTE-activity gameplay detector ([widescreen] gte_game_mode) for 3D titles * with no sprite-tag helper: gte.cpp notes every RTPS/RTPT projection; a frame * that projects enough vertices is stamped as gameplay. */ diff --git a/runtime/include/gpu_render.h b/runtime/include/gpu_render.h index 4953270a..ace31f11 100644 --- a/runtime/include/gpu_render.h +++ b/runtime/include/gpu_render.h @@ -39,6 +39,22 @@ void gr_set_mask_bits(int set_bit, int check_bit); void gr_set_texture_window(uint32_t raw); void gr_set_color_modulation(int r, int g, int b, int raw_texture); +/* Sub-pixel vertex override for the NEXT triangle ([video] geometry_correction). + * x/y are signed 16.16 screen coordinates carrying the projection fraction the + * GTE discarded; the integer positions still passed to gr_draw_*_triangle + * remain authoritative for anything that must stay faithful (dirty-rect + * tracking, the software path's native VRAM write). enabled == 0 clears the + * override. Backends that do not implement it ignore the call, so the integer + * path is what they draw. The override is consumed by the next triangle. */ +void gr_set_precise_triangle(int enabled, + int32_t x0, int32_t y0, + int32_t x1, int32_t y1, + int32_t x2, int32_t y2); +/* Perspective-correct UV weights for the NEXT triangle ([video] + * perspective_texturing). q[i] is the normalized 1/z homogeneous weight at + * vertex i. enabled == 0 restores the PS1's affine UV interpolation. */ +void gr_set_perspective_triangle(int enabled, float q0, float q1, float q2); + /* Primitives */ void gr_fill_rect(int x, int y, int w, int h, uint16_t color); void gr_copy_rect(int src_x, int src_y, int dst_x, int dst_y, int w, int h); @@ -119,6 +135,13 @@ typedef struct GpuRenderBackend { void (*set_mask_bits)(int set_bit, int check_bit); void (*set_texture_window)(uint32_t raw); void (*set_color_modulation)(int r, int g, int b, int raw_texture); + /* Optional (NULL = unsupported, facade no-ops): sub-pixel vertex override + * and perspective UV weights for the next triangle. See gr_* above. */ + void (*set_precise_triangle)(int enabled, + int32_t x0, int32_t y0, + int32_t x1, int32_t y1, + int32_t x2, int32_t y2); + void (*set_perspective_triangle)(int enabled, float q0, float q1, float q2); void (*fill_rect)(int x, int y, int w, int h, uint16_t color); void (*copy_rect)(int src_x, int src_y, int dst_x, int dst_y, int w, int h); void (*draw_flat_triangle)(int x0, int y0, int x1, int y1, int x2, int y2, diff --git a/runtime/shaders/geo_tex.frag b/runtime/shaders/geo_tex.frag index 65bdaddb..78948905 100644 --- a/runtime/shaders/geo_tex.frag +++ b/runtime/shaders/geo_tex.frag @@ -11,6 +11,8 @@ layout(location = 3) flat in ivec2 v_clut; /* CLUT base, VRAM px */ layout(location = 4) flat in int v_depth; /* 0=4bit 1=8bit 2=15bit */ layout(location = 5) flat in int v_raw; /* 1 = no colour modulation */ layout(location = 6) flat in ivec4 v_limits; /* prim uv bounds (inclusive) */ +layout(location = 7) smooth in vec2 v_uv_p; /* perspective-correct UV */ +layout(location = 8) flat in int v_persp; /* 1 = use v_uv_p, else affine */ layout(set = 0, binding = 0) uniform usampler2D u_vram; @@ -52,8 +54,12 @@ vec3 col5(int raw) { } void main() { int stp; vec3 rgb; + /* v_persp is 0 for every prim unless [video] perspective_texturing is on + * AND this prim's packet carried full GTE projection provenance, so the + * default is the PS1's affine (noperspective) mapping. */ + vec2 uv = (v_persp != 0) ? v_uv_p : v_uv; if (pc.u_filter == 0) { - int raw = fetch_texel(int(floor(v_uv.x)), int(floor(v_uv.y))); + int raw = fetch_texel(int(floor(uv.x)), int(floor(uv.y))); if (raw == 0) discard; rgb = col5(raw); stp = (raw >> 15) & 1; @@ -62,8 +68,8 @@ void main() { * (cutout + STP authority), the neighbours lie toward the sub-texel * offset and clamp to v_limits, and each texel's weight is gated by its * opacity with the colour renormalised. */ - int iu = int(floor(v_uv.x)), iv = int(floor(v_uv.y)); - float fx = v_uv.x - float(iu) - 0.5, fy = v_uv.y - float(iv) - 0.5; + int iu = int(floor(uv.x)), iv = int(floor(uv.y)); + float fx = uv.x - float(iu) - 0.5, fy = uv.y - float(iv) - 0.5; int sx = fx < 0.0 ? -1 : 1, sy = fy < 0.0 ? -1 : 1; fx = abs(fx); fy = abs(fy); int c00 = fetch_texel(iu, iv); diff --git a/runtime/shaders/geo_tex.vert b/runtime/shaders/geo_tex.vert index 7abb78eb..d0c17863 100644 --- a/runtime/shaders/geo_tex.vert +++ b/runtime/shaders/geo_tex.vert @@ -13,6 +13,10 @@ layout(location = 4) in vec2 a_clut; layout(location = 5) in float a_depth; layout(location = 6) in float a_raw; layout(location = 7) in vec4 a_limits; +/* Perspective weight ([video] perspective_texturing). 0 = affine, the + * PS1-faithful default: w below is then exactly 1.0 and the fragment shader + * reads the noperspective varying, i.e. the pre-feature pipeline unchanged. */ +layout(location = 8) in float a_q; layout(push_constant) uniform PC { float u_shift; @@ -31,14 +35,22 @@ layout(location = 3) flat out ivec2 v_clut; layout(location = 4) flat out int v_depth; layout(location = 5) flat out int v_raw; layout(location = 6) flat out ivec4 v_limits; +layout(location = 7) smooth out vec2 v_uv_p; /* perspective-correct UV */ +layout(location = 8) flat out int v_persp; void main() { - v_uv = a_uv; v_col = a_col; + v_uv = a_uv; v_uv_p = a_uv; v_col = a_col; + v_persp = (a_q > 0.0) ? 1 : 0; v_tpage = ivec2(a_tpage + 0.5); v_clut = ivec2(a_clut + 0.5); v_depth = int(a_depth + 0.5); v_raw = int(a_raw + 0.5); v_limits = ivec4(floor(a_limits + 0.5)); - gl_Position = vec4((a_pos.x + pc.u_shift + pc.u_xoff) / pc.u_xhalf - 1.0, - (a_pos.y + pc.u_shift) / 256.0 - 1.0, 0.0, 1.0); + /* Perspective-correct UV rides the standard w divide: emit clip coords + * pre-multiplied by w = 1/q so post-divide NDC is unchanged while the + * rasterizer interpolates v_uv_p hyperbolically. a_q == 0 => w == 1.0. */ + float w = (a_q > 0.0) ? (1.0 / a_q) : 1.0; + vec2 ndc = vec2((a_pos.x + pc.u_shift + pc.u_xoff) / pc.u_xhalf - 1.0, + (a_pos.y + pc.u_shift) / 256.0 - 1.0); + gl_Position = vec4(ndc * w, 0.0, w); } diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index b322c596..c6cdab12 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -2795,16 +2795,16 @@ static void prepare_precise_triangle(uint32_t w0, uint32_t w1, uint32_t w2, uint32_t words[3] = { w0, w1, w2 }; int32_t fx[3], fy[3]; const int geometry_enabled = gte_geometry_correction_enabled(); - sw_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); + gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); if (!geometry_enabled) { - sw_set_precise_triangle(0, 0,0, 0,0, 0,0); + gr_set_precise_triangle(0, 0,0, 0,0, 0,0); return; } for (int i = 0; i < 3; i++) { int32_t raw_x, raw_y; parse_vertex(words[i], &raw_x, &raw_y); if (!gte_geometry_correction_lookup(words[i], &fx[i], &fy[i])) { - sw_set_precise_triangle(0, 0,0, 0,0, 0,0); + gr_set_precise_triangle(0, 0,0, 0,0, 0,0); return; } fx[i] = (int32_t)((int64_t)fx[i] + @@ -2812,14 +2812,14 @@ static void prepare_precise_triangle(uint32_t w0, uint32_t w1, uint32_t w2, fy[i] = (int32_t)((int64_t)fy[i] + (int64_t)(vy[i] - raw_y) * 65536); } - sw_set_precise_triangle(1, fx[0],fy[0], fx[1],fy[1], fx[2],fy[2]); + gr_set_precise_triangle(1, fx[0],fy[0], fx[1],fy[1], fx[2],fy[2]); } /* Enable perspective UVs only when every position word came from an exact * SWC2 projection store at that same DMA packet address. This preserves the * association through ordering-table reordering and rejects CPU-built UI. */ static void prepare_texture_triangle(int i0, int i1, int i2) { - sw_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); + gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); if (!s_texture_correction_enabled || gp0_cmd_source_addr == 0xFFFFFFFFu) return; int indices[3] = { i0, i1, i2 }; @@ -2835,7 +2835,7 @@ static void prepare_texture_triangle(int i0, int i1, int i2) { if (q[1] > qmax) qmax = q[1]; if (q[2] > qmax) qmax = q[2]; if (qmax <= 0.0f) return; - sw_set_perspective_triangle(1, q[0] / qmax, q[1] / qmax, q[2] / qmax); + gr_set_perspective_triangle(1, q[0] / qmax, q[1] / qmax, q[2] / qmax); } /* Write a single pixel to VRAM with draw area clipping and mask bit handling */ diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index ec8bc5f7..a6302ef2 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -355,11 +355,25 @@ static GLuint s_scratch_tex = 0, s_scratch_fbo = 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) - * — per-prim texture state in flat attributes so prims batch (see flush_tex_batch). */ -#define TEXV 19 + * semi(1) q(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 static GLuint s_blit_prog = 0, s_blit_vao = 0, s_blit_vbo = 0; static GLuint s_pack_prog = 0, s_stencil_prog = 0, s_empty_vao = 0; +/* Sub-pixel vertex override + perspective UV weights for the next triangle + * ([video] geometry_correction / perspective_texturing; see gpu_render.h). + * gpu.c sets these immediately before the matching gr_draw_*_triangle and they + * are consumed by it. All-zero == the faithful integer/affine path. */ +static int s_pc_valid = 0; /* sub-pixel positions present */ +static float s_pc_x[3], s_pc_y[3]; /* native VRAM px, fractional */ +static int s_pq_valid = 0; /* perspective weights present */ +static float s_pq[3]; + /* TEX program uniforms. */ static GLint s_uVram = -1, s_uTpage = -1, s_uClut = -1, s_uDepth = -1; static GLint s_uRaw = -1, s_uSemipass = -1, s_uSemimode = -1; @@ -816,15 +830,19 @@ static const char *TEX_VS = "layout(location=6) in float a_raw;\n" "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" "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" "uniform float u_xscale; /* native-wide 2D-backdrop x-stretch; 1 canonical */\n" "uniform float u_xcenter;/* stretch centre in VRAM px; 0 canonical */\n" "noperspective out vec2 v_uv; noperspective out vec4 v_col;\n" + "smooth out vec2 v_uv_p; /* perspective-correct UV (used when v_persp!=0) */\n" + "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" - "void main(){ v_uv = a_uv; v_col = a_col;\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" @@ -837,10 +855,17 @@ static const char *TEX_VS = " float l = u_xcenter - h, r = u_xcenter + h;\n" " if (xb < l) xb = l + (xb-l)*s; else if (xb > r) xb = r + (xb-r)*s;\n" " } else xb = (xb - u_xcenter)*u_xscale + u_xcenter;\n" - " gl_Position = vec4((xb+u_shift+u_xoff)/u_xhalf - 1.0, (a_pos.y+u_shift)/256.0 - 1.0, 0.0, 1.0); }\n"; + " /* Perspective-correct UV rides the standard w divide: emit clip coords\n" + " * pre-multiplied by w = 1/q so the post-divide NDC is unchanged while\n" + " * the rasterizer interpolates the smooth varying hyperbolically. With\n" + " * a_q == 0 (feature off) w is exactly 1.0 and this is the old expression. */\n" + " float w = (a_q > 0.0) ? (1.0 / a_q) : 1.0;\n" + " vec2 ndc = vec2((xb+u_shift+u_xoff)/u_xhalf - 1.0, (a_pos.y+u_shift)/256.0 - 1.0);\n" + " gl_Position = vec4(ndc * w, 0.0, w); }\n"; static const char *TEX_FS = "#version 330\n" "noperspective in vec2 v_uv; noperspective in vec4 v_col;\n" + "smooth in vec2 v_uv_p; flat in int v_persp;\n" "out vec4 frag; out vec4 blend_factor;\n" "flat in ivec2 v_tpage; /* texture page base, VRAM px */\n" "flat in ivec2 v_clut; /* CLUT base, VRAM px */\n" @@ -880,8 +905,12 @@ static const char *TEX_FS = "}\n" "void main(){\n" " int stp; vec3 rgb;\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" " if (u_filter == 0) {\n" - " int raw = fetch_texel(int(floor(v_uv.x)), int(floor(v_uv.y)));\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" @@ -892,8 +921,8 @@ static const char *TEX_FS = " * its opacity with the colour renormalised — so prim edges and\n" " * cutout borders keep their colour instead of dissolving into the\n" " * transparent (black) neighbour and discarding whole edge columns. */\n" - " int iu = int(floor(v_uv.x)), iv = int(floor(v_uv.y));\n" - " float fx = v_uv.x - float(iu) - 0.5, fy = v_uv.y - float(iv) - 0.5;\n" + " int iu = int(floor(uv.x)), iv = int(floor(uv.y));\n" + " float fx = uv.x - float(iu) - 0.5, fy = uv.y - float(iv) - 0.5;\n" " int sx = fx < 0.0 ? -1 : 1, sy = fy < 0.0 ? -1 : 1;\n" " fx = abs(fx); fy = abs(fy);\n" " int c00 = fetch_texel(iu, iv);\n" @@ -1291,6 +1320,11 @@ static void mark_prim_dirty(const int *xs, const int *ys, int n, int textured) { if (xs[i] < x0) x0 = xs[i]; if (xs[i] > x1) x1 = xs[i]; if (ys[i] < y0) y0 = ys[i]; if (ys[i] > y1) y1 = ys[i]; } + /* A sub-pixel-corrected vertex lies in [int, int+1) of the integer position + * it was rounded from, so the corrected prim can touch one more pixel on + * each max edge than the integer bbox covers. Widen before clipping so the + * pack/readback dirty rect never trails the drawn area. */ + if (s_pc_valid) { x1 += 1; y1 += 1; } s_bdg_prims++; /* dbg: prims seen this frame (gate is now per-prim, see bd_prim_gate) */ if (s_ptrace_n < PTRACE_CAP) { PrimRec *p = &s_ptrace[s_ptrace_n++]; @@ -1647,6 +1681,8 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, flush_tex_batch(); /* flat prim: drain textured draws first (order + program) */ flush_cpu_upload(); /* also drains flat batch if an upload was pending */ mark_prim_dirty(xs, ys, n, 0 /* flat */); + /* Sub-pixel positions only describe a 3-vertex projected triangle. */ + const int precise = s_pc_valid && mode == GL_TRIANGLES && n == 3; /* Lines stay immediate (rare); tris batch for MotK 0x68 starfields. */ if (mode != GL_TRIANGLES || n < 3) { @@ -1654,8 +1690,8 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, float verts[3 * 6]; float mask_a = s_mask_set ? 1.0f : 0.0f; for (int i = 0; i < n; i++) { - verts[i*6+0] = (float)xs[i]; - verts[i*6+1] = (float)ys[i]; + verts[i*6+0] = precise ? s_pc_x[i] : (float)xs[i]; + verts[i*6+1] = precise ? s_pc_y[i] : (float)ys[i]; verts[i*6+2] = ((cs[i] & 0x1F) << 3) / 255.0f; verts[i*6+3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; verts[i*6+4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; @@ -1697,8 +1733,8 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, float mask_a = s_mask_set ? 1.0f : 0.0f; for (int i = 0; i < n; i++) { float *v = &s_fb[s_fb_n * 6]; - v[0] = (float)xs[i]; - v[1] = (float)ys[i]; + v[0] = precise ? s_pc_x[i] : (float)xs[i]; + v[1] = precise ? s_pc_y[i] : (float)ys[i]; v[2] = ((cs[i] & 0x1F) << 3) / 255.0f; v[3] = (((cs[i] >> 5) & 0x1F) << 3) / 255.0f; v[4] = (((cs[i] >> 10) & 0x1F) << 3) / 255.0f; @@ -1804,7 +1840,8 @@ static void gpu_textured_triangle(const int *xs, const int *ys, } float *vp = &s_tb[s_tb_n * TEXV]; for (int i = 0; i < 3; i++, vp += TEXV) { - vp[0] = (float)xs[i]; vp[1] = (float)ys[i]; + vp[0] = s_pc_valid ? s_pc_x[i] : (float)xs[i]; + vp[1] = s_pc_valid ? s_pc_y[i] : (float)ys[i]; vp[2] = (float)us[i]; vp[3] = (float)vs[i]; vp[4] = col[i*3+0]; vp[5] = col[i*3+1]; vp[6] = col[i*3+2]; vp[7] = 1.0f; vp[8] = (float)base_x; vp[9] = (float)base_y; /* a_tpage */ @@ -1813,6 +1850,7 @@ static void gpu_textured_triangle(const int *xs, const int *ys, vp[14] = (float)lim[0]; vp[15] = (float)lim[1]; /* a_limits */ 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 */ } s_tb_n += 3; if (isolate) flush_tex_batch(); /* draw this semi prim alone, in submission order */ @@ -2050,6 +2088,27 @@ static void glb_set_texture_window(uint32_t r) { sw_set_texture_window(r); } static void glb_set_color_modulation(int r,int g,int b,int raw) { s_mod_r=r; s_mod_g=g; s_mod_b=b; s_mod_raw=raw; sw_set_color_modulation(r,g,b,raw); } +/* Sub-pixel / perspective overrides for the next triangle. Forwarded to the + * software rasterizer too: pre-context draws (s_raster_ok == 0) still go there, + * and the GL backend delegates whole prim classes to it. */ +static void glb_set_precise_triangle(int enabled, + int32_t x0,int32_t y0, int32_t x1,int32_t y1, + int32_t x2,int32_t y2) { + s_pc_valid = enabled ? 1 : 0; + if (s_pc_valid) { + /* 16.16 native VRAM px -> float. The GL pipeline is float end-to-end, + * so the fraction survives to the rasterizer at any internal scale. */ + s_pc_x[0] = (float)x0 / 65536.0f; s_pc_y[0] = (float)y0 / 65536.0f; + s_pc_x[1] = (float)x1 / 65536.0f; s_pc_y[1] = (float)y1 / 65536.0f; + s_pc_x[2] = (float)x2 / 65536.0f; s_pc_y[2] = (float)y2 / 65536.0f; + } + sw_set_precise_triangle(enabled, x0,y0, x1,y1, x2,y2); +} +static void glb_set_perspective_triangle(int enabled, float q0, float q1, float q2) { + s_pq_valid = (enabled && q0 > 0.0f && q1 > 0.0f && q2 > 0.0f) ? 1 : 0; + s_pq[0] = q0; s_pq[1] = q1; s_pq[2] = q2; + sw_set_perspective_triangle(enabled, q0, q1, q2); +} static void glb_set_draw_area(int x1,int y1,int x2,int y2) { flush_flat_batch(); flush_tex_batch(); s_area_x1=x1; s_area_y1=y1; s_area_x2=x2; s_area_y2=y2; sw_set_draw_area(x1,y1,x2,y2); } static void glb_get_draw_area(int *x1,int *y1,int *x2,int *y2) { sw_get_draw_area(x1,y1,x2,y2); } static void glb_set_draw_offset(int x,int y) { flush_flat_batch(); flush_tex_batch(); s_off_x=x; s_off_y=y; sw_set_draw_offset(x,y); } @@ -2057,13 +2116,19 @@ static void glb_set_draw_offset(int x,int y) { flush_flat_batch(); flush_tex_bat /* Pre-context draws (s_raster_ok == 0) fall back to the software rasterizer * over CPU VRAM; the initial full-VRAM upload at context init folds them in. * Post-init, the GPU pipeline is all-or-nothing — no per-prim fallback. */ +/* The sub-pixel / perspective override describes exactly one triangle; drop it + * once that triangle has been submitted so a later prim can never inherit it. */ +static inline void precise_consumed(void) { s_pc_valid = 0; s_pq_valid = 0; } + static void glb_draw_flat_triangle(int x0,int y0,int x1,int y1,int x2,int y2,uint16_t col) { if (!s_raster_ok) { sw_draw_flat_triangle(x0,y0,x1,y1,x2,y2,col); return; } gpu_triangle(x0,y0,col, x1,y1,col, x2,y2,col, s_semi_en?s_semi_mode:-1); + precise_consumed(); } static void glb_draw_gouraud_triangle(int x0,int y0,uint16_t c0,int x1,int y1,uint16_t c1,int x2,int y2,uint16_t c2) { if (!s_raster_ok) { sw_draw_gouraud_triangle(x0,y0,c0,x1,y1,c1,x2,y2,c2); return; } gpu_triangle(x0,y0,c0, x1,y1,c1, x2,y2,c2, s_semi_en?s_semi_mode:-1); + precise_consumed(); } static void glb_fill_rect(int x,int y,int w,int h,uint16_t c){ if (!s_raster_ok) { sw_fill_rect(x,y,w,h,c); return; } @@ -2079,6 +2144,7 @@ static void glb_draw_textured_triangle(int x0,int y0,int u0,int v0,int x1,int y1 float mr=s_mod_r/255.0f, mg=s_mod_g/255.0f, mb=s_mod_b/255.0f; float col[9]={mr,mg,mb, mr,mg,mb, mr,mg,mb}; gpu_textured_triangle(xs,ys,us,vs,col,tp,cx,cy,s_mod_raw, s_semi_en?s_semi_mode:-1, NULL); + precise_consumed(); } static void glb_draw_shaded_textured_triangle(int x0,int y0,int u0,int v0,uint32_t c0,int x1,int y1,int u1,int v1,uint32_t c1,int x2,int y2,int u2,int v2,uint32_t c2,uint16_t cx,uint16_t cy,uint16_t tp,int raw){ if (!s_raster_ok) { sw_draw_shaded_textured_triangle(x0,y0,u0,v0,c0,x1,y1,u1,v1,c1,x2,y2,u2,v2,c2,cx,cy,tp,raw); return; } @@ -2086,6 +2152,7 @@ static void glb_draw_shaded_textured_triangle(int x0,int y0,int u0,int v0,uint32 uint32_t cc[3]={c0,c1,c2}; float col[9]; for (int i=0;i<3;i++){ col[i*3+0]=(cc[i]&0xFF)/255.0f; col[i*3+1]=((cc[i]>>8)&0xFF)/255.0f; col[i*3+2]=((cc[i]>>16)&0xFF)/255.0f; } gpu_textured_triangle(xs,ys,us,vs,col,tp,cx,cy,raw, s_semi_en?s_semi_mode:-1, NULL); + precise_consumed(); } static void glb_draw_flat_rect(int x,int y,int w,int h,uint16_t c){ if (!s_raster_ok) { sw_draw_flat_rect(x,y,w,h,c); return; } @@ -2392,6 +2459,7 @@ static int init_gpu_raster(void) { p_glVertexAttribPointer(6, 1, GL_FLOAT, GL_FALSE, st, (void*)(13*sizeof(float))); p_glEnableVertexAttribArray(6); /* raw */ 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_glGenVertexArrays(1, &s_blit_vao); @@ -3702,6 +3770,8 @@ static const GpuRenderBackend GL_BACKEND = { .set_texture_filter = glb_set_texture_filter, .texture_filter = glb_texture_filter, .set_semi_transparency = glb_set_semi_transparency, .set_mask_bits = glb_set_mask_bits, .set_texture_window = glb_set_texture_window, .set_color_modulation = glb_set_color_modulation, + .set_precise_triangle = glb_set_precise_triangle, + .set_perspective_triangle = glb_set_perspective_triangle, .fill_rect = glb_fill_rect, .copy_rect = glb_copy_rect, .draw_flat_triangle = glb_draw_flat_triangle, .draw_gouraud_triangle = glb_draw_gouraud_triangle, .draw_textured_triangle = glb_draw_textured_triangle, diff --git a/runtime/src/gpu_render.c b/runtime/src/gpu_render.c index cae37953..c833f491 100644 --- a/runtime/src/gpu_render.c +++ b/runtime/src/gpu_render.c @@ -25,6 +25,8 @@ static const GpuRenderBackend SW_BACKEND = { .set_mask_bits = sw_set_mask_bits, .set_texture_window = sw_set_texture_window, .set_color_modulation = sw_set_color_modulation, + .set_precise_triangle = sw_set_precise_triangle, + .set_perspective_triangle = sw_set_perspective_triangle, .fill_rect = sw_fill_rect, .copy_rect = sw_copy_rect, .draw_flat_triangle = sw_draw_flat_triangle, @@ -101,6 +103,15 @@ void gr_set_semi_transparency(int e, int m) { g_b->set_semi_transparenc void gr_set_mask_bits(int s, int c) { g_b->set_mask_bits(s, c); } void gr_set_texture_window(uint32_t raw) { g_b->set_texture_window(raw); } void gr_set_color_modulation(int r, int g, int b, int raw) { g_b->set_color_modulation(r, g, b, raw); } +void gr_set_precise_triangle(int enabled, int32_t x0, int32_t y0, int32_t x1, int32_t y1, + int32_t x2, int32_t y2) { + if (g_b->set_precise_triangle) + g_b->set_precise_triangle(enabled, x0, y0, x1, y1, x2, y2); +} +void gr_set_perspective_triangle(int enabled, float q0, float q1, float q2) { + if (g_b->set_perspective_triangle) + g_b->set_perspective_triangle(enabled, q0, q1, q2); +} void gr_fill_rect(int x, int y, int w, int h, uint16_t c) { g_b->fill_rect(x, y, w, h, c); } void gr_copy_rect(int sx, int sy, int dx, int dy, int w, int h) { g_b->copy_rect(sx, sy, dx, dy, w, h); } void gr_draw_flat_triangle(int x0, int y0, int x1, int y1, int x2, int y2, uint16_t c) { diff --git a/runtime/src/gpu_vk_renderer.c b/runtime/src/gpu_vk_renderer.c index 5e9b4d6f..1aca7374 100644 --- a/runtime/src/gpu_vk_renderer.c +++ b/runtime/src/gpu_vk_renderer.c @@ -401,8 +401,19 @@ static int s_geo_mirror_suppress = 0; /* open batch: skip the wide * (full-screen overlay rect draws its * own full-width wide pass instead) */ -/* Textured batch: 18 floats/vert (pos,uv,col,tpage,clut,depth,raw,limits). */ -#define TEXV 18 +/* Sub-pixel vertex override + perspective UV weights for the next triangle + * ([video] geometry_correction / perspective_texturing; see gpu_render.h). + * gpu.c sets these immediately before the matching gr_draw_*_triangle and they + * are consumed by it. All-zero == the faithful integer/affine path. */ +static int s_pc_valid = 0; /* sub-pixel positions present */ +static float s_pc_x[3], s_pc_y[3]; /* native VRAM px, fractional */ +static int s_pq_valid = 0; /* perspective weights present */ +static float s_pq[3]; + +/* Textured batch: 19 floats/vert (pos,uv,col,tpage,clut,depth,raw,limits,q). + * q is the perspective weight ([video] perspective_texturing); 0 = affine, + * which the vertex shader turns into w == 1.0 — the faithful default. */ +#define TEXV 19 typedef struct { float v[TEXV]; } TexVert; #define VK_TBUF_VERTS 24576 /* multiple of 3 */ static VkBuffer s_tbuf; @@ -1224,7 +1235,7 @@ static VkPipeline get_pipeline(int prog, int topo, int blend, int stencil, int c VkShaderModule vs, fs; VkPipelineLayout layout; VkVertexInputBindingDescription bind = {0}; - VkVertexInputAttributeDescription attrs[8]; uint32_t nattr; + VkVertexInputAttributeDescription attrs[9]; uint32_t nattr; if (prog == 0) { /* GEO */ vs = s_mod_geo_v; fs = s_mod_geo_f; layout = s_pl_geo; bind = (VkVertexInputBindingDescription){ 0, sizeof(Vert), VK_VERTEX_INPUT_RATE_VERTEX }; @@ -1242,7 +1253,8 @@ static VkPipeline get_pipeline(int prog, int topo, int blend, int stencil, int c attrs[5] = (VkVertexInputAttributeDescription){ 5, 0, VK_FORMAT_R32_SFLOAT, 48 }; attrs[6] = (VkVertexInputAttributeDescription){ 6, 0, VK_FORMAT_R32_SFLOAT, 52 }; attrs[7] = (VkVertexInputAttributeDescription){ 7, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 56 }; - nattr = 8; + attrs[8] = (VkVertexInputAttributeDescription){ 8, 0, VK_FORMAT_R32_SFLOAT, 72 }; /* a_q */ + nattr = 9; } else { /* BLIT (vertex-less: rect from push constant) */ vs = s_mod_blit_v; fs = s_mod_blit_f; layout = s_pl_blit; nattr = 0; @@ -2563,15 +2575,46 @@ static void vkb_set_color_modulation(int r, int g, int b, int raw) { s_mod_r = r; s_mod_g = g; s_mod_b = b; s_mod_raw = raw ? 1 : 0; } +/* Sub-pixel / perspective overrides for the next triangle. */ +static void vkb_set_precise_triangle(int enabled, + int32_t x0,int32_t y0, int32_t x1,int32_t y1, + int32_t x2,int32_t y2) { + s_pc_valid = enabled ? 1 : 0; + if (s_pc_valid) { + /* 16.16 native VRAM px -> float; the VK pipeline is float end-to-end. */ + s_pc_x[0] = (float)x0 / 65536.0f; s_pc_y[0] = (float)y0 / 65536.0f; + s_pc_x[1] = (float)x1 / 65536.0f; s_pc_y[1] = (float)y1 / 65536.0f; + s_pc_x[2] = (float)x2 / 65536.0f; s_pc_y[2] = (float)y2 / 65536.0f; + } +} +static void vkb_set_perspective_triangle(int enabled, float q0, float q1, float q2) { + s_pq_valid = (enabled && q0 > 0.0f && q1 > 0.0f && q2 > 0.0f) ? 1 : 0; + s_pq[0] = q0; s_pq[1] = q1; s_pq[2] = q2; + /* The corrected-triangle tally lives in the software rasterizer and backs + * gpu_texture_correction_hits() — the "is this doing anything on this + * title" counter. Forward so it reads the same on every backend. */ + sw_set_perspective_triangle(enabled, q0, q1, q2); +} +/* The override describes exactly one triangle; drop it once submitted so a + * later prim can never inherit it. */ +static inline void precise_consumed(void) { s_pc_valid = 0; s_pq_valid = 0; } + +/* Position a vertex: the sub-pixel value when geometry_correction supplied one + * for this triangle, else the integer GP0 position. */ +#define PCX(i, v) (s_pc_valid ? s_pc_x[i] : (float)(v)) +#define PCY(i, v) (s_pc_valid ? s_pc_y[i] : (float)(v)) + static void vkb_draw_flat_triangle(int x0,int y0,int x1,int y1,int x2,int y2,uint16_t c){ geo_prim_begin(); float col[3]; col555(c, col); - tri3((float)x0,(float)y0,col, (float)x1,(float)y1,col, (float)x2,(float)y2,col); + tri3(PCX(0,x0),PCY(0,y0),col, PCX(1,x1),PCY(1,y1),col, PCX(2,x2),PCY(2,y2),col); + precise_consumed(); } static void vkb_draw_gouraud_triangle(int x0,int y0,uint16_t c0,int x1,int y1,uint16_t c1,int x2,int y2,uint16_t c2){ geo_prim_begin(); float a[3],b[3],cc[3]; col555(c0,a); col555(c1,b); col555(c2,cc); - tri3((float)x0,(float)y0,a, (float)x1,(float)y1,b, (float)x2,(float)y2,cc); + tri3(PCX(0,x0),PCY(0,y0),a, PCX(1,x1),PCY(1,y1),b, PCX(2,x2),PCY(2,y2),cc); + precise_consumed(); } /* Full-screen-overlay wide pass (pause gray-filter / load fade): draw a flat * rect covering the FULL wide width [0, wide_w) x [y, y+h) directly into the @@ -2788,7 +2831,8 @@ static void gpu_textured_triangle(const int *xs, const int *ys, const int *us, c int bx0 = xs[0], bx1 = xs[0], by0 = ys[0], by1 = ys[0]; for (int i = 0; i < 3; i++) { float *vp = s_tmap[s_tbase + s_tcount + i].v; - vp[0] = (float)xs[i]; vp[1] = (float)ys[i]; + vp[0] = s_pc_valid ? s_pc_x[i] : (float)xs[i]; + vp[1] = s_pc_valid ? s_pc_y[i] : (float)ys[i]; vp[2] = (float)us[i]; vp[3] = (float)vs[i]; vp[4] = col[i*3+0]; vp[5] = col[i*3+1]; vp[6] = col[i*3+2]; vp[7] = 1.0f; vp[8] = (float)base_x; vp[9] = (float)base_y; @@ -2796,10 +2840,14 @@ static void gpu_textured_triangle(const int *xs, const int *ys, const int *us, c vp[12] = (float)depth; vp[13] = (float)rawtex; vp[14] = (float)lim[0]; vp[15] = (float)lim[1]; vp[16] = (float)lim[2]; vp[17] = (float)lim[3]; + vp[18] = s_pq_valid ? s_pq[i] : 0.0f; /* a_q; 0 = affine */ if (xs[i] < bx0) bx0 = xs[i]; if (xs[i] > bx1) bx1 = xs[i]; if (ys[i] < by0) by0 = ys[i]; if (ys[i] > by1) by1 = ys[i]; } s_tcount += 3; + /* A sub-pixel-corrected vertex lies in [int, int+1) of the integer position + * it was rounded from, so widen the readback rect by one pixel. */ + if (s_pc_valid) { bx1 += 1; by1 += 1; } /* mark dirty for later readback/pack (clamped to draw area) */ if (bx0 < s_da_x1) bx0 = s_da_x1; if (by0 < s_da_y1) by0 = s_da_y1; if (bx1 > s_da_x2) bx1 = s_da_x2; if (by1 > s_da_y2) by1 = s_da_y2; @@ -2832,6 +2880,7 @@ static void vkb_draw_textured_triangle(int x0,int y0,int u0,int v0,int x1,int y1 float mr=s_mod_r/255.0f, mg=s_mod_g/255.0f, mb=s_mod_b/255.0f; float col[9]={mr,mg,mb, mr,mg,mb, mr,mg,mb}; gpu_textured_triangle(xs,ys,us,vs,col,tp,cx,cy,s_mod_raw, s_semi_en?s_semi_mode:-1, NULL); + precise_consumed(); } static void vkb_draw_shaded_textured_triangle(int x0,int y0,int u0,int v0,uint32_t c0, int x1,int y1,int u1,int v1,uint32_t c1, @@ -2842,6 +2891,7 @@ static void vkb_draw_shaded_textured_triangle(int x0,int y0,int u0,int v0,uint32 float col[9]; col888(c0, &col[0]); col888(c1, &col[3]); col888(c2, &col[6]); gpu_textured_triangle(xs,ys,us,vs,col,tp,cx,cy,raw, s_semi_en?s_semi_mode:-1, NULL); + precise_consumed(); } static void vkb_draw_textured_rect(int x,int y,int w,int h,int u,int v,uint16_t cx,uint16_t cy,uint16_t tp){ gpu_textured_rect(x,y,w,h, u,v, u+w,v+h, cx,cy,tp, s_semi_en?s_semi_mode:-1); @@ -3058,6 +3108,8 @@ static const GpuRenderBackend VK_BACKEND = { .set_mask_bits = vkb_set_mask_bits, .set_texture_window = vkb_set_texture_window, .set_color_modulation = vkb_set_color_modulation, + .set_precise_triangle = vkb_set_precise_triangle, + .set_perspective_triangle = vkb_set_perspective_triangle, .fill_rect = vkb_fill_rect, .copy_rect = vkb_copy_rect, .draw_flat_triangle = vkb_draw_flat_triangle, diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 98ba063e..379fd649 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -514,6 +514,11 @@ extern "C" EMSCRIPTEN_KEEPALIVE void psx_web_set_smooth_60fps(int enabled) { static int g_video_scale = 1; /* internal-resolution SSAA factor */ static bool g_video_aa = true; /* linear present filtering */ static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ +/* Sub-pixel vertex precision + perspective-correct UVs (PGXP-style). Visual + * only: the PS1-visible GTE SXY FIFO stays integer, so guest-side culling and + * SXY readback are untouched. Default off = the faithful floor. */ +static int g_video_geometry_correction = 0; +static int g_video_perspective_texturing = 0; static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) * fullscreen, 2 exclusive fullscreen */ @@ -5147,6 +5152,10 @@ int main(int argc, char** argv) { g_video_scale = gc.runtime.video_supersampling; g_video_aa = gc.runtime.video_antialiasing; g_video_texfilter = gc.runtime.video_texture_filter; + g_video_geometry_correction = + gc.runtime.video_geometry_correction ? 1 : 0; + g_video_perspective_texturing = + gc.runtime.video_perspective_texturing ? 1 : 0; g_video_renderer = gc.runtime.video_renderer; g_video_screen = gc.runtime.video_screen_kind; g_video_aspect_num = gc.runtime.video_aspect_num; @@ -5639,6 +5648,10 @@ int main(int argc, char** argv) { if (us.has_window_width) g_video_win_w = us.window_width; if (us.has_antialiasing) g_video_aa = us.antialiasing; if (us.has_texture_filter) g_video_texfilter = us.texture_filter; + if (us.has_geometry_correction) + g_video_geometry_correction = us.geometry_correction ? 1 : 0; + if (us.has_perspective_texturing) + g_video_perspective_texturing = us.perspective_texturing ? 1 : 0; if (us.has_screen_kind) g_video_screen = us.screen_kind; if (us.has_auto_skip_fmv) g_auto_skip_fmv = us.auto_skip_fmv ? 1 : 0; if (us.has_turbo_loads) g_turbo_loads_enabled = us.turbo_loads ? 1 : 0; @@ -5931,6 +5944,12 @@ int main(int argc, char** argv) { seed.supersampling = g_video_scale; seed.has_supersampling = true; seed.antialiasing = g_video_aa; seed.has_antialiasing = true; seed.texture_filter = g_video_texfilter; seed.has_texture_filter = true; + /* Seeded (and marked present) so a launcher save round-trips the + * player's hand-edited value instead of dropping the key. */ + seed.geometry_correction = (g_video_geometry_correction != 0); + seed.has_geometry_correction = true; + seed.perspective_texturing = (g_video_perspective_texturing != 0); + seed.has_perspective_texturing = true; seed.screen_kind = g_video_screen; seed.has_screen_kind = true; seed.auto_skip_fmv = (g_auto_skip_fmv != 0); seed.has_auto_skip_fmv = skip_fmv_offered; @@ -6356,6 +6375,8 @@ int main(int argc, char** argv) { g_video_scale = seed.supersampling; g_video_aa = seed.antialiasing; g_video_texfilter = seed.texture_filter; + g_video_geometry_correction = seed.geometry_correction ? 1 : 0; + g_video_perspective_texturing = seed.perspective_texturing ? 1 : 0; g_video_screen = seed.screen_kind; g_auto_skip_fmv = skip_fmv_offered && seed.auto_skip_fmv ? 1 : 0; g_turbo_loads_enabled = @@ -6541,8 +6562,26 @@ session_reboot: if (g_video_scale < 1) g_video_scale = 1; if (g_video_scale > SW_MAX_INTERNAL_SCALE) g_video_scale = SW_MAX_INTERNAL_SCALE; gr_set_scale(g_video_scale); + /* The scale we asked the backend for. Read it before the reflection below: + * the GL backend's gr_scale() reports its REAL internal scale, which is + * still 0/1 until the GL context comes up later, so g_video_scale does not + * hold the effective factor at this point. */ + const int requested_scale = g_video_scale; g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ gr_set_texture_filter(g_video_texfilter); + /* Sub-pixel vertex precision + perspective-correct UVs. Both default off; + * with both off every setter below leaves the tracking caches disabled and + * the draw path is the faithful integer one, unchanged. */ + gte_geometry_correction_set(g_video_geometry_correction); + gpu_texture_correction_set(g_video_perspective_texturing); + if (g_video_geometry_correction || g_video_perspective_texturing) { + std::fprintf(stdout, + "psxrecomp: geometry correction %s, perspective texturing %s%s\n", + g_video_geometry_correction ? "on" : "off", + g_video_perspective_texturing ? "on" : "off", + (g_video_geometry_correction && requested_scale < 2) + ? " (needs [video] supersampling >= 2 to be visible)" : ""); + } /* Display aspect. Identity at the default 4:3. The present letterbox uses * this aspect; native-wide fills it with a genuinely wider frame (no * stretch), squash mode stretches the 4:3 frame into it. */ diff --git a/tools/embed_spirv.py b/tools/embed_spirv.py index 6b3d494a..d9493c06 100644 --- a/tools/embed_spirv.py +++ b/tools/embed_spirv.py @@ -10,7 +10,7 @@ file extension. Usage: embed_spirv.py --glslc --out shader1.vert shader2.frag ... """ -import argparse, os, subprocess, sys, struct +import argparse, os, subprocess, sys, struct, tempfile STAGE = {".vert": "vert", ".frag": "frag", ".comp": "comp"} @@ -32,9 +32,19 @@ def main(): "#define PSX_VK_SHADERS_SPV_H", "#include ", ""] - for sh in a.shaders: + # Scratch dir for the intermediate .spv files. It MUST NOT be the shader + # source dir: every runtime target (psx-runtime, psx-oracle, each game exe) + # runs this script over the SAME shaders, and under `ninja -j N` two of them + # overlap. Writing ".spv" next to the source made them race — one + # process removed the file the other had just written, and the loser died + # with FileNotFoundError on a shader it had compiled successfully. Keeping + # the scratch per-invocation makes concurrent runs independent, and the + # TemporaryDirectory also stops a crash from leaving .spv litter in the + # source tree. + with tempfile.TemporaryDirectory(prefix="psx_spirv_") as scratch: + for sh in a.shaders: name, stage = ident(sh) - spv = sh + ".spv" + spv = os.path.join(scratch, os.path.basename(sh) + ".spv") cmd = [a.glslc, "--target-env=vulkan1.1", "-O", sh, "-o", spv] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: