diff --git a/docs/ENHANCEMENTS.md b/docs/ENHANCEMENTS.md index 0cb52b8d..33a537e1 100644 --- a/docs/ENHANCEMENTS.md +++ b/docs/ENHANCEMENTS.md @@ -1209,7 +1209,7 @@ pixel. LWL at byte 3 / LWR at byte 0 (and SWL / SWR likewise) move the whole word and copy its shadow. Result in the Fossil Field scene: every ground triangle dataflow-precise, `tri_mixed` from ~10% of triangles to 474 of 2.05M, seams gone (`test_pgxp` pins the sequence). -### G1.14 — PGXP renderer: depth buffer, perspective-correct colour (2026-10-06) +### G1.14 — PGXP renderer: depth buffer, perspective-correct colour, seam expansion (2026-10-06) **Audit against the references.** Read from DuckStation's published source (`github.com/stenzek/duckstation`, CC BY-NC-ND 4.0, so behaviour only, no @@ -1232,7 +1232,7 @@ vertex depth, `src/core/settings.cpp` defaults). GooseStation | Tolerance | `PGXPTolerance` -1 | `pgxp_tolerance` | same | | Depth buffer | `PGXPDepthBuffer`: per-vertex w as depth, LEQUAL, only for polygons whose w differ (3D) and opaque unless `PGXPTransparentDepthTest`; cleared on drawing-area change and when average z rises by `PGXPDepthThreshold` (4096) | no | **yes**, `pgxp_depth_buffer` | | 2D polygons | sprite mode for non-3D precise polygons; `PGXPDisableOn2DPolygons` draws invalid-w polygons native | unproven vertices native per vertex; precise axis-aligned quads bypass the rect path (G1.11) | same; 2D never tests/writes depth | -| T-junction / seam handling | none for polygons (line expansion only) | none | same | +| T-junction / seam handling | none for polygons (line expansion only) | none | **seam expansion**, `pgxp_seam` | **Depth buffer.** gpu.c passes each triangle's SZ when all three vertices are dataflow-precise (`gr_set_depth_triangle`). The GL backend draws an @@ -1270,6 +1270,26 @@ differ by 801 / 92 / 25 / 0 px, none in the tachometer area. **Perspective-correct colour.** Gouraud colour on 3D triangles interpolates with 1/SZ (textured triangles with perspective UVs share their w). +**Seam expansion.** At internal scale > 1 a vertex that lies on its +neighbour's edge only to the PS1's precision leaves a hairline onto the +background (T-junctions of R4's subdivided near polygons; tunnel walls). +Opaque 3D triangles move each edge outward (mitred, limited at sharp +corners): `fine` = 1 output px (`PSX_PGXP_SEAM_PX` tunes it), `wide` = half a +native px. UVs, colour, q and SZ are extrapolated with the barycentric +coordinates of the new corners (perspective-correct where the attribute is), +so textures do not slide. `wide` closes larger gaps but smears edge texels +and thickens silhouettes (beam undersides at the R4 tunnel entrance); `fine` +is the recommended setting. + +**R4 (hook flavor, 10x headless-opengl, Helter Skelter tunnel, savestates +at the entrance and inside).** Thin-feature pixel counts (features narrower +than half a native pixel against both neighbours, HUD excluded): +entrance base 1524 / PGXP 3560 / +depth+colour 2070 / +seam fine 1410-1518; +inside base 2323 / PGXP 539 / +depth+colour 540 / +seam fine 541-547. The +remaining counts are mostly texture detail; the visible base cracks along +the tunnel walls are gone with PGXP and the residual short ones with `fine`. +Dataflow 99.86%, mixed triangles 0, 0 dispatch / segment misses; guest pace +equal with the features on and off. Diagnostic: `PSX_PGXP_TRI_LOG=` logs every triangle (depth mode, x y SZ) while `.on` exists. diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 99e6c4fb..c3a5ac3f 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -802,6 +802,13 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.video_pgxp_color_correction = toml::find(video, "pgxp_color_correction"); if (video.contains("pgxp_depth_threshold")) rt.video_pgxp_depth_threshold = toml::find(video, "pgxp_depth_threshold"); + if (video.contains("pgxp_seam")) { + const auto m = toml::find(video, "pgxp_seam"); + if (m == "off") rt.video_pgxp_seam = 0; + else if (m == "fine") rt.video_pgxp_seam = 1; + else if (m == "wide") rt.video_pgxp_seam = 2; + else throw std::runtime_error("[video] pgxp_seam must be \"off\", \"fine\" or \"wide\""); + } if (video.contains("pgxp_mod_only")) { rt.video_pgxp_mod_only = toml::find(video, "pgxp_mod_only"); } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 10c62113..08365cf3 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -550,8 +550,11 @@ struct RuntimeConfig { // or write), cleared per drawing area, after fills and when the average // SZ jumps back by pgxp_depth_threshold (SZ units, default 4096). // pgxp_color_correction: perspective-correct Gouraud colour. + // pgxp_seam: expand opaque 3D polygons to close T-junction cracks above + // 1x ("off", "fine" = 1 output px, "wide" = half a native px). bool video_pgxp_depth_buffer = false; bool video_pgxp_color_correction = false; + int video_pgxp_seam = 0; double video_pgxp_depth_threshold = 4096.0; // pgxp_mod_only: the title ships PGXP through the psx.enhancement.pgxp diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index 2a6ec00c..9f191403 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -423,7 +423,7 @@ static void test_pgxp_title_keys() { check(!gc.runtime.video_pgxp_mod_only, "pgxp_mod_only defaults OFF (unchanged behaviour)"); check(!gc.runtime.video_pgxp_depth_buffer && !gc.runtime.video_pgxp_color_correction && - gc.runtime.video_pgxp_depth_threshold == 4096.0, + gc.runtime.video_pgxp_seam == 0 && gc.runtime.video_pgxp_depth_threshold == 4096.0, "PGXP renderer features default OFF (G1.14)"); fs::remove(p); @@ -431,13 +431,23 @@ static void test_pgxp_title_keys() { "[video]\n" "pgxp_depth_buffer = true\n" "pgxp_color_correction = true\n" + "pgxp_seam = \"wide\"\n" "pgxp_depth_threshold = 2048.0\n"); gc = PSXRecompV4::load_game_config(p); check(gc.runtime.video_pgxp_depth_buffer && gc.runtime.video_pgxp_color_correction && - gc.runtime.video_pgxp_depth_threshold == 2048.0, + gc.runtime.video_pgxp_seam == 2 && gc.runtime.video_pgxp_depth_threshold == 2048.0, "PGXP renderer keys are honoured"); fs::remove(p); + p = write_game_toml("psxrecomp_pgxp_render_bad.toml", + "[video]\n" + "pgxp_seam = \"huge\"\n"); + { + bool bad = false; + try { (void)PSXRecompV4::load_game_config(p); } catch (const std::exception&) { bad = true; } + check(bad, "pgxp_seam must be off, fine or wide"); + } + fs::remove(p); p = write_game_toml("psxrecomp_pgxp_keys_dataflow.toml", "[video]\n" diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 84d0af6b..5eacff61 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -267,6 +267,7 @@ float gl_renderer_get_post_gamma(void); void gl_renderer_set_wide_fast(int on); /* PGXP renderer features (docs/ENHANCEMENTS.md G1.14), all off by default: * depth buffer for opaque 3D polygons, perspective-correct Gouraud colour, + * seam expansion (0 off, 1 = 1 output px above 1x, 2 = 0.5 native px), * and the depth clear threshold in SZ units (DuckStation's default 4096). */ /* Nonzero when a PGXP renderer feature is on: only then does gpu.c send * gr_set_depth_triangle (no extra command per triangle otherwise). Any thread. */ @@ -275,8 +276,11 @@ void gl_renderer_set_pgxp_depth(int on); int gl_renderer_get_pgxp_depth(void); void gl_renderer_set_pgxp_color_perspective(int on); int gl_renderer_get_pgxp_color_perspective(void); +void gl_renderer_set_pgxp_seam(int mode); +int gl_renderer_get_pgxp_seam(void); void gl_renderer_set_pgxp_depth_threshold(float sz); -void gl_renderer_pgxp_render_stats(uint64_t *depth_tris, uint64_t *depth_clears); +void gl_renderer_pgxp_render_stats(uint64_t *depth_tris, uint64_t *depth_clears, + uint64_t *seam_tris); /* Internal-resolution scale state of the live GL context (0 before init). */ typedef struct GlScaleInfo { diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 6072a7d7..23a3159d 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -741,13 +741,14 @@ static float s_pq[3]; static int s_pz_valid = 0; static float s_pz[3]; /* PGXP renderer features (docs/ENHANCEMENTS.md G1.14), all off by default: - * depth buffer for opaque 3D polygons and perspective-correct Gouraud - * colour. */ -static int s_pgxp_depth = 0, s_pgxp_cpersp = 0; + * depth buffer for opaque 3D polygons, perspective-correct Gouraud colour, + * and the seam expansion of opaque 3D polygons (0 off, 1 = 1 output px + * above 1x, 2 = half a native px). */ +static int s_pgxp_depth = 0, s_pgxp_cpersp = 0, s_pgxp_seam = 0; static float s_pgxp_depth_threshold = 4096.0f; /* SZ units, as DuckStation */ static int s_depth_need_clear = 1, s_depth_used = 0; static float s_depth_last_avg = 0.0f; -static uint64_t s_depth_clears = 0, s_depth_tris = 0; +static uint64_t s_depth_clears = 0, s_depth_tris = 0, s_seam_tris = 0; /* TEX program uniforms. */ static GLint s_uVram = -1, s_uTpage = -1, s_uClut = -1, s_uDepth = -1; @@ -2936,7 +2937,7 @@ int gl_renderer_get_wide_fast(void) { return s_wide_fast; } * the middle of a replayed frame; they are safe at session_reboot. */ static int s_pgxp_render_wanted = 0; /* any PGXP renderer feature on */ static void pgxp_render_wanted_update(void) { - __atomic_store_n(&s_pgxp_render_wanted, (s_pgxp_depth || s_pgxp_cpersp) ? 1 : 0, __ATOMIC_RELEASE); + __atomic_store_n(&s_pgxp_render_wanted, (s_pgxp_depth || s_pgxp_cpersp || s_pgxp_seam) ? 1 : 0, __ATOMIC_RELEASE); } int gl_renderer_pgxp_render_wanted(void) { return __atomic_load_n(&s_pgxp_render_wanted, __ATOMIC_ACQUIRE); @@ -2955,12 +2956,20 @@ void gl_renderer_set_pgxp_color_perspective(int on) { pgxp_render_wanted_update(); } int gl_renderer_get_pgxp_color_perspective(void) { return s_pgxp_cpersp; } +void gl_renderer_set_pgxp_seam(int mode) { + GL_RT_SYNC("set_pgxp_seam"); + s_pgxp_seam = mode < 0 ? 0 : mode > 2 ? 2 : mode; + pgxp_render_wanted_update(); +} +int gl_renderer_get_pgxp_seam(void) { return s_pgxp_seam; } void gl_renderer_set_pgxp_depth_threshold(float sz) { GL_RT_SYNC("set_pgxp_depth_threshold"); s_pgxp_depth_threshold = sz; } -void gl_renderer_pgxp_render_stats(uint64_t *depth_tris, uint64_t *depth_clears) { +void gl_renderer_pgxp_render_stats(uint64_t *depth_tris, uint64_t *depth_clears, + uint64_t *seam_tris) { GL_RT_SYNC("pgxp_render_stats"); if (depth_tris) *depth_tris = s_depth_tris; if (depth_clears) *depth_clears = s_depth_clears; + if (seam_tris) *seam_tris = s_seam_tris; } static int wide_fast_center_valid(void) { /* An explicitly stretched sky differs inside the canonical viewport too. @@ -3790,7 +3799,7 @@ static void line_to_quad(const float *v, float *q) { /* Flat / gouraud triangles and lines share the GEO program. mode: GL_TRIANGLES * or GL_LINES; verts are (x, y, r, g, b, a) tuples with colors as 1555. */ -/* ---- PGXP depth buffer and perspective colour (G1.14) ------------ ------- +/* ---- PGXP depth buffer, perspective colour, seam expansion (G1.14) ------- * A triangle is "3D" here when gpu.c proved all three vertices from GTE * dataflow shadows: sub-pixel positions (s_pc_valid) and their SZ * (s_pz_valid). Everything else (2D, HUD, sprites, CPU-built or unproven @@ -3878,6 +3887,95 @@ static void depth_before_tri(void) { s_depth_tris++; } +/* Seam expansion of an opaque 3D triangle. At internal scale > 1 a vertex + * that sits on a neighbour's edge only to within the PS1's precision (R4's + * subdivided near polygons, T-junctions) leaves a hairline crack onto the + * background. Each edge moves outward by e native px (mitred corners, + * limited at sharp angles); B[k][i] are the barycentric coordinates of new + * corner k in the original triangle, so attributes extrapolate exactly: + * affine ones as sum B a, perspective ones as sum B q a / sum B q, depth as + * 1/sz' = sum B / sz. The depth buffer resolves the overlap. */ +static float pgxp_seam_width(void) { + static float fine_px = -1.0f; /* PSX_PGXP_SEAM_PX: "fine" width in output px */ + if (fine_px < 0.0f) { + const char *e = getenv("PSX_PGXP_SEAM_PX"); + fine_px = e && *e ? (float)atof(e) : 1.0f; + if (fine_px < 0.0f) fine_px = 0.0f; + } + if (s_pgxp_seam == 1) return s_out_scale > 1 ? fine_px / (float)s_out_scale : 0.0f; + if (s_pgxp_seam == 2) return 0.5f; + return 0.0f; +} +static int seam_expand(float x[3], float y[3], float e, float B[3][3]) { + if (e <= 0.0f) return 0; + const float area = (x[1] - x[0]) * (y[2] - y[0]) - (y[1] - y[0]) * (x[2] - x[0]); + if (fabsf(area) < 1e-4f) return 0; + const float sgn = area > 0.0f ? 1.0f : -1.0f; + float nx[3], ny[3]; /* outward unit normal of edge k (k -> k+1) */ + for (int k = 0; k < 3; k++) { + const int k1 = (k + 1) % 3; + const float ex = x[k1] - x[k], ey = y[k1] - y[k], l = sqrtf(ex * ex + ey * ey); + if (l < 1e-5f) return 0; + nx[k] = sgn * ey / l; ny[k] = -sgn * ex / l; + } + float ox[3], oy[3]; + for (int k = 0; k < 3; k++) { + const int kp = (k + 2) % 3; /* edges kp and k meet at corner k */ + float mx = nx[kp] + nx[k], my = ny[kp] + ny[k]; + const float d = 1.0f + nx[kp] * nx[k] + ny[kp] * ny[k]; + if (d < 0.25f) { /* sharp corner: limit the mitre to 2e */ + const float l = sqrtf(mx * mx + my * my); + if (l > 1e-6f) { mx *= 2.0f / l; my *= 2.0f / l; } + } else { mx /= d; my /= d; } + ox[k] = x[k] + e * mx; oy[k] = y[k] + e * my; + } + const float inv = 1.0f / area; + for (int k = 0; k < 3; k++) { + for (int i = 0; i < 3; i++) { + const int i1 = (i + 1) % 3, i2 = (i + 2) % 3; + B[k][i] = ((x[i2] - x[i1]) * (oy[k] - y[i1]) - (y[i2] - y[i1]) * (ox[k] - x[i1])) * inv; + } + } + for (int k = 0; k < 3; k++) { x[k] = ox[k]; y[k] = oy[k]; } + s_seam_tris++; + return 1; +} +/* Extrapolate n attributes (stride apart) of 3 vertices with B. q != NULL: + * perspective-correct with weights q (left unchanged; see seam_q). */ +static void seam_attr(const float B[3][3], float *a, int stride, int n, const float *q) { + float src[3][8]; + for (int i = 0; i < 3; i++) for (int j = 0; j < n; j++) src[i][j] = a[i * stride + j]; + for (int k = 0; k < 3; k++) { + float Q = 0.0f; + if (q) for (int i = 0; i < 3; i++) Q += B[k][i] * q[i]; + for (int j = 0; j < n; j++) { + float v = 0.0f; + if (q && Q > 1e-12f) { + for (int i = 0; i < 3; i++) v += B[k][i] * q[i] * src[i][j]; + v /= Q; + } else { + for (int i = 0; i < 3; i++) v += B[k][i] * src[i][j]; + } + a[k * stride + j] = v; + } + } +} +static void seam_q(const float B[3][3], float q[3]) { + float o[3]; + for (int k = 0; k < 3; k++) { o[k] = 0.0f; for (int i = 0; i < 3; i++) o[k] += B[k][i] * q[i]; } + for (int k = 0; k < 3; k++) q[k] = o[k] > 1e-9f ? o[k] : q[k]; +} +static void seam_sz(const float B[3][3], float z[3]) { + float o[3]; + for (int k = 0; k < 3; k++) { + float iz = 0.0f; + for (int i = 0; i < 3; i++) iz += B[k][i] / z[i]; + o[k] = iz > 1e-9f ? 1.0f / iz : z[k]; + if (o[k] < 1.0f) o[k] = 1.0f; + if (o[k] > 65535.0f) o[k] = 65535.0f; + } + for (int k = 0; k < 3; k++) z[k] = o[k]; +} static void gpu_geometry(GLenum mode, const int *xs, const int *ys, const uint16_t *cs, int n, int semi) { @@ -4069,6 +4167,16 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, } if (n == 3 && pgxp_tri_is_3d()) { float z[3] = { s_pz[0], s_pz[1], s_pz[2] }; + if (semi < 0 && s_pgxp_seam) { + float x[3], y[3], B[3][3]; + for (int i = 0; i < 3; i++) { x[i] = v0[i * 6]; y[i] = v0[i * 6 + 1]; } + if (seam_expand(x, y, pgxp_seam_width(), B)) { + float qc[3] = { 1.0f / z[0], 1.0f / z[1], 1.0f / z[2] }; + seam_attr(B, v0 + 2, 6, 3, s_pgxp_cpersp ? qc : NULL); + seam_sz(B, z); + for (int i = 0; i < 3; i++) { v0[i * 6] = x[i]; v0[i * 6 + 1] = y[i]; } + } + } for (int i = 0; i < 3; i++) v0[i * 6 + 5] = mask_a + 2.0f * pgxp_vertex_code(z[i]); } @@ -4238,6 +4346,22 @@ static void gpu_textured_triangle(const int *xs, const int *ys, if (pgxp_tri_is_3d()) { float *t0 = &s_tb[s_tb_n * TEXV]; float z[3] = { s_pz[0], s_pz[1], s_pz[2] }; + if (semi < 0 && s_pgxp_seam) { + float x[3], y[3], B[3][3]; + for (int i = 0; i < 3; i++) { x[i] = t0[i * TEXV]; y[i] = t0[i * TEXV + 1]; } + if (seam_expand(x, y, pgxp_seam_width(), B)) { + float q[3] = { t0[19], t0[TEXV + 19], t0[2 * TEXV + 19] }; + float qc[3] = { 1.0f / z[0], 1.0f / z[1], 1.0f / z[2] }; + seam_attr(B, t0 + 2, TEXV, 2, s_pq_valid ? q : NULL); /* uv */ + seam_attr(B, t0 + 4, TEXV, 3, s_pgxp_cpersp ? (s_pq_valid ? q : qc) : NULL); + if (s_pq_valid) { + seam_q(B, q); + for (int i = 0; i < 3; i++) t0[i * TEXV + 19] = q[i]; + } + seam_sz(B, z); + for (int i = 0; i < 3; i++) { t0[i * TEXV] = x[i]; t0[i * TEXV + 1] = y[i]; } + } + } for (int i = 0; i < 3; i++) { const float code = pgxp_vertex_code(z[i]); if (code > 0.0f) t0[i * TEXV + 7] = -code; /* a_col.a (TEX_VS) */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 8f755f96..d84cbbf2 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1276,9 +1276,10 @@ static float g_video_pgxp_tolerance = 0.5f; * Defaults keep the historical behaviour. game.toml [video] only. */ 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 override. */ +/* PGXP renderer features (G1.14); PSX_PGXP_DEPTH / _COLOR / _SEAM override. */ static int g_video_pgxp_depth_buffer = 0; static int g_video_pgxp_color_correction = 0; +static int g_video_pgxp_seam = 0; static float g_video_pgxp_depth_threshold = 4096.0f; /* [video] pgxp_mod_only (G1.12): the title ships PGXP through the * psx.enhancement.pgxp mod, which is then the one switch -- the [video] @@ -15792,6 +15793,7 @@ int main(int argc, char** argv) { 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_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; g_video_renderer = gc.runtime.video_renderer; g_video_screen = gc.runtime.video_screen_kind; @@ -18157,8 +18159,10 @@ session_reboot: /* PGXP renderer features (G1.14): [video] keys, env overrides. */ if (const char* e = std::getenv("PSX_PGXP_DEPTH")) g_video_pgxp_depth_buffer = (*e && *e != '0'); 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); 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); gl_renderer_set_pgxp_depth_threshold(g_video_pgxp_depth_threshold); /* [video] texture_window_batching A/B (same image, fewer GL draws). */