gpu gl/vk: fix mirrored-2D uv sampling (MMX6 sprite slivers)

Modern-GPU center-sampled interpolation vs the PS1 DDA's top-left-corner
latch: the sample-grid shift (u_shift = 0.5/S - 1/64) makes floor(uv) land
on the exact PS1 texel for FORWARD axis-aligned mappings, but MIRRORED ones
(u or v decreasing along their screen axis - X/Y-flipped 2D sprites, e.g.
right-facing MMX entity quads whose cels are stored facing left) interpolate
1/64 short of each integer and floor one texel LOW. That painted the cel's
never-sampled edge column/row (opaque body texels) as detached 1px slivers
at quad seams: stray sprite-colored lines right of X when facing right, a
pink line under the rolling wheel mechaniloids. The old tri_uv_limits
comment explicitly 'accepted' this.

Fix = the Beetle-PSX / parallel-psx Calc_UVOffsets_Adjust_Verts model:
bump the prim's uv by +1 along the decreasing axis (tri_uv_mirror_offset,
applied in the GL and VK poly paths before limits are derived). This also
puts the max-uv vertex back on the exclusive raster edge, so the existing
unconditional max back-off in tri_uv_limits is correct for both directions.
Forward mappings are untouched (no regression surface for the original
Tomba seam fix). The software renderer's native DDA truncates exactly like
the PS1 and needs no compensation; its limits function only feeds the
bilinear neighbour clamp (comment clarified).

Verified on MMX6 attract demo (deterministic boot): pre-fix GL showed
detached X-palette 1px columns at quad seam x=173/175/179/181 (frames
~2088/2250) while software was clean; post-fix GL matches software and
user-confirmed clean live. Residual detector hits classified via GP0 ring
dumps as legit charge-spark effect tiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-07-09 15:40:32 -07:00
parent 010a281b0c
commit e86099f46e
3 changed files with 84 additions and 8 deletions
+47 -4
View File
@@ -1329,13 +1329,46 @@ static void gpu_line(int x0,int y0,uint16_t c0,int x1,int y1,uint16_t c1,int sem
gpu_geometry(GL_LINES, xs, ys, cs, 2, semi);
}
/* Mirrored-2D uv compensation, the Beetle-PSX / parallel-psx
* Calc_UVOffsets_Adjust_Verts model. Modern GPUs interpolate attributes at
* fragment CENTERS; the PS1 DDA latches the interpolant at the pixel's
* top-left corner. The sample-grid shift (u_shift = 0.5/S - 1/64, see
* gl_renderer pipeline init) makes floor(uv) land on the exact PS1 texel for
* FORWARD axis-aligned mappings, but a MIRRORED one — u or v DECREASING
* along its screen axis, i.e. X/Y-flipped 2D sprites such as right-facing
* MMX entity quads (cels stored facing left) — interpolates 1/64 SHORT of
* each integer and floors one texel low. That painted the cel's never-
* sampled edge column/row (opaque body texels) as a detached 1px sliver at
* quad seams (MMX6 sprite slivers; pink line under rolling wheels).
* Compensate exactly like Beetle: bump the prim's uv by +1 along the
* decreasing axis. This also puts the max-uv vertex back on the exclusive
* raster edge, so tri_uv_limits' unconditional max back-off is correct for
* both directions. Diagonal (3D-ish) mappings are left untouched; like
* Beetle, a rare 3D poly that happens to be axis-aligned accepts a
* one-texel shift in exchange for correct 2D sprites. The derivatives are
* area2-scaled, so area2's sign (winding) normalizes their direction. */
static void tri_uv_mirror_offset(const int *xs, const int *ys, int *us, int *vs) {
long dudx = -(long)(ys[1]-ys[0])*us[2] - (long)(ys[2]-ys[1])*us[0] - (long)(ys[0]-ys[2])*us[1];
long dvdx = -(long)(ys[1]-ys[0])*vs[2] - (long)(ys[2]-ys[1])*vs[0] - (long)(ys[0]-ys[2])*vs[1];
long dudy = (long)(xs[1]-xs[0])*us[2] + (long)(xs[2]-xs[1])*us[0] + (long)(xs[0]-xs[2])*us[1];
long dvdy = (long)(xs[1]-xs[0])*vs[2] + (long)(xs[2]-xs[1])*vs[0] + (long)(xs[0]-xs[2])*vs[1];
long area2 = (long)(xs[1]-xs[0])*(ys[2]-ys[0]) - (long)(xs[2]-xs[0])*(ys[1]-ys[0]);
if (area2 == 0) return;
long du = dudx == 0 ? dudy : (dudy == 0 ? dudx : 0);
long dv = dvdx == 0 ? dvdy : (dvdy == 0 ? dvdx : 0);
if (area2 < 0) { du = -du; dv = -dv; }
if (du < 0) { us[0]++; us[1]++; us[2]++; }
if (dv < 0) { vs[0]++; vs[1]++; vs[2]++; }
}
/* Per-prim uv sampling bounds (inclusive), Beetle-PSX model: filtered
* neighbours (and S>1 interpolation overshoot) clamp to these so a sample
* never reads outside the prim's own texture rect. For axis-aligned (2D)
* uv mappings — any zero uv derivative — the max-uv vertex is an exclusive
* edge whose texel the PS1 DDA never samples, so back it off by one. If the
* uv range crosses a 256 wrap boundary the prim relies on page wrapping and
* the bounds widen to the full page (clamp disabled). */
* edge whose texel the PS1 DDA never samples (mirrored mappings included,
* AFTER tri_uv_mirror_offset has bumped their uv), so back it off by one.
* If the uv range crosses a 256 wrap boundary the prim relies on page
* wrapping and the bounds widen to the full page (clamp disabled). */
static void tri_uv_limits(const int *xs, const int *ys,
const int *us, const int *vs, int lim[4]) {
int lo_u = us[0], hi_u = us[0], lo_v = vs[0], hi_v = vs[0];
@@ -1368,7 +1401,17 @@ static void gpu_textured_triangle(const int *xs, const int *ys,
uint16_t clut_x, uint16_t clut_y, int rawtex,
int semi, const int *lim) {
int lim_buf[4];
if (!lim) { tri_uv_limits(xs, ys, us, vs, lim_buf); lim = lim_buf; }
int uv_buf[6];
if (!lim) {
/* Poly path: apply the mirrored-2D uv compensation first (rect prims
* are always forward-mapped and pass their own precomputed lim). */
int *mu = uv_buf, *mv = uv_buf + 3;
for (int i = 0; i < 3; i++) { mu[i] = us[i]; mv[i] = vs[i]; }
tri_uv_mirror_offset(xs, ys, mu, mv);
us = mu; vs = mv;
tri_uv_limits(xs, ys, us, vs, lim_buf);
lim = lim_buf;
}
s_scene_prims_tex++;
int base_x = (texpage & 0xF) * 64;
int base_y = ((texpage >> 4) & 1) * 256;
+4 -2
View File
@@ -422,8 +422,10 @@ static uint16_t texel_fetch_bilinear(float fu, float fv, uint16_t texpage,
/* uv sampling bounds for a textured triangle (see g_uv_lim): min/max of the
* vertex uvs; for axis-aligned (2D) mappings — any zero uv derivative — the
* max-uv vertex is an exclusive edge whose texel the DDA never samples, so
* back it off by one. Crossing a 256 boundary means page wrap: widen to the
* full page (clamp disabled). */
* back it off by one. (The software DDA truncates exactly like the PS1, so
* unlike the GL/VK backends no mirrored-2D uv compensation is needed here —
* these bounds only feed the bilinear-filter neighbour clamp.) Crossing a
* 256 boundary means page wrap: widen to the full page (clamp disabled). */
static void sw_tri_uv_limits(const int *xs, const int *ys,
const int *us, const int *vs) {
int lo_u = us[0], hi_u = us[0], lo_v = vs[0], hi_v = vs[0];
+33 -2
View File
@@ -2515,8 +2515,29 @@ static void flush_tex_batch(void) {
s_gpu_dirty = 1;
}
/* Mirrored-2D uv compensation — same Beetle-PSX Calc_UVOffsets model as the
* GL backend (see gpu_gl_renderer.c tri_uv_mirror_offset for the full
* rationale): a mirrored axis-aligned mapping (u or v decreasing along its
* screen axis — X/Y-flipped 2D sprites) floors one texel low under
* center-sampled interpolation; bump its uv by +1 along the decreasing axis. */
static void tri_uv_mirror_offset(const int *xs, const int *ys, int *us, int *vs) {
long dudx = -(long)(ys[1]-ys[0])*us[2] - (long)(ys[2]-ys[1])*us[0] - (long)(ys[0]-ys[2])*us[1];
long dvdx = -(long)(ys[1]-ys[0])*vs[2] - (long)(ys[2]-ys[1])*vs[0] - (long)(ys[0]-ys[2])*vs[1];
long dudy = (long)(xs[1]-xs[0])*us[2] + (long)(xs[2]-xs[1])*us[0] + (long)(xs[0]-xs[2])*us[1];
long dvdy = (long)(xs[1]-xs[0])*vs[2] + (long)(xs[2]-xs[1])*vs[0] + (long)(xs[0]-xs[2])*vs[1];
long area2 = (long)(xs[1]-xs[0])*(ys[2]-ys[0]) - (long)(xs[2]-xs[0])*(ys[1]-ys[0]);
if (area2 == 0) return;
long du = dudx == 0 ? dudy : (dudy == 0 ? dudx : 0);
long dv = dvdx == 0 ? dvdy : (dvdy == 0 ? dvdx : 0);
if (area2 < 0) { du = -du; dv = -dv; }
if (du < 0) { us[0]++; us[1]++; us[2]++; }
if (dv < 0) { vs[0]++; vs[1]++; vs[2]++; }
}
/* Per-prim uv sampling bounds (inclusive), Beetle-PSX model — see the GL
* backend's tri_uv_limits. */
* backend's tri_uv_limits (mirrored mappings are compensated by
* tri_uv_mirror_offset BEFORE this runs, so the unconditional max back-off
* is correct for both directions). */
static void tri_uv_limits(const int *xs, const int *ys,
const int *us, const int *vs, int lim[4]) {
int lo_u = us[0], hi_u = us[0], lo_v = vs[0], hi_v = vs[0];
@@ -2546,7 +2567,17 @@ static void gpu_textured_triangle(const int *xs, const int *ys, const int *us, c
int semi, const int *lim) {
if (!s_ready) return;
int lim_buf[4];
if (!lim) { tri_uv_limits(xs, ys, us, vs, lim_buf); lim = lim_buf; }
int uv_buf[6];
if (!lim) {
/* Poly path: apply the mirrored-2D uv compensation first (rect prims
* are always forward-mapped and pass their own precomputed lim). */
int *mu = uv_buf, *mv = uv_buf + 3;
for (int i = 0; i < 3; i++) { mu[i] = us[i]; mv[i] = vs[i]; }
tri_uv_mirror_offset(xs, ys, mu, mv);
us = mu; vs = mv;
tri_uv_limits(xs, ys, us, vs, lim_buf);
lim = lim_buf;
}
int base_x = (texpage & 0xF) * 64;
int base_y = ((texpage >> 4) & 1) * 256;
int depth = (texpage >> 7) & 3; if (depth > 2) depth = 2;