From ef7ca8b85fe6102e03385b4a83271cb0c086602d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Wed, 21 Oct 2026 14:50:57 +0200 Subject: [PATCH] GPU: Move sprites on pixel centers back half a pixel above 1x (compat SnapHalfPixelSprites) The PS1 emulator draws sprites with their corners on pixel centers (x.5) and their texture coordinates on texel centers. At 1x that covers whole pixels, but above 1x it covers half a pixel more on the right and bottom and reads the neighbouring texels, which shows as seams between tiles. With compat.ini's SnapHalfPixelSprites, such sprites and their texture coordinates are moved back half a pixel, which draws the same at 1x. Texture coordinates are also nudged a 64th of a texel in, as a scaled sprite can sample right on a texel edge, where interpolation can fall short of the texel the GE reads (the emulator's video unpacking does). Set for PS1 games (POPS00000). Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/Compatibility.cpp | 1 + Core/Compatibility.h | 1 + GPU/Common/SoftwareTransformCommon.cpp | 34 ++++++++++++++++++++++++++ assets/compat.ini | 6 +++++ 4 files changed, 42 insertions(+) diff --git a/Core/Compatibility.cpp b/Core/Compatibility.cpp index 1ae81a7026..9d9bc2098f 100644 --- a/Core/Compatibility.cpp +++ b/Core/Compatibility.cpp @@ -170,6 +170,7 @@ void Compatibility::CheckSettings(IniFile &iniFile, const std::string &gameID) { CheckSetting(iniFile, gameID, "ReadbackShadow", &flags_.ReadbackShadow); CheckSetting(iniFile, gameID, "ColorPickingDrawSyncReadback", &flags_.ColorPickingDrawSyncReadback); CheckSetting(iniFile, gameID, "SeparateLogicOpDraws", &flags_.SeparateLogicOpDraws); + CheckSetting(iniFile, gameID, "SnapHalfPixelSprites", &flags_.SnapHalfPixelSprites); CheckSetting(iniFile, gameID, "FixedFramebuffer", &flags_.FixedFramebuffer); CheckSetting(iniFile, gameID, "NativeResolutionFramebuffer", &flags_.NativeResolutionFramebuffer); } diff --git a/Core/Compatibility.h b/Core/Compatibility.h index dcacbe3fea..a700a736aa 100644 --- a/Core/Compatibility.h +++ b/Core/Compatibility.h @@ -136,6 +136,7 @@ struct CompatFlags { bool ReadbackShadow; bool ColorPickingDrawSyncReadback; bool SeparateLogicOpDraws; + bool SnapHalfPixelSprites; CompatFramebuffer FixedFramebuffer; uint32_t NativeResolutionFramebuffer; }; diff --git a/GPU/Common/SoftwareTransformCommon.cpp b/GPU/Common/SoftwareTransformCommon.cpp index b16ffbb6dc..745c03c250 100644 --- a/GPU/Common/SoftwareTransformCommon.cpp +++ b/GPU/Common/SoftwareTransformCommon.cpp @@ -1009,6 +1009,20 @@ static SoftwareTransformAction ProjectClipAndExpand(SoftwareTransformParams &par return SW_DRAW_INDEXED; } +// A sprite with its corners on pixel centers (x.5), as the PS1 emulator draws them. Above 1x it covers +// half a pixel more on the right and bottom and reads its neighbours' texels (seams between tiles). +// Moved back half a pixel, with its texture coordinates, it draws the same at 1x. +static bool IsHalfPixelSprite(const TransformedVertex &tl, const TransformedVertex &br) { + auto onCenter = [](float f) { + const float frac = f - floorf(f); + return frac > 0.49f && frac < 0.51f; + }; + if (!onCenter(tl.x) || !onCenter(tl.y) || !onCenter(br.x) || !onCenter(br.y)) { + return false; + } + return br.x > tl.x && br.y > tl.y; +} + static bool ExpandRectangles(int vertexCount, int &numDecodedVerts, int vertsSize, u16 *&inds, int indsSize, const TransformedVertex *transformed, TransformedVertex *transformedExpanded, int *drawIndexCount, bool throughmode, bool *pixelMappedExactly) { // Before we start, do a sanity check - does the output fit? // The expanded indices are written after the input ones, at inds + vertexCount. @@ -1160,6 +1174,26 @@ static bool ExpandRectangles(int vertexCount, int &numDecodedVerts, int vertsSiz trans[3].v = (transVtxBR.v - spriteBorderFixB) * vScale; trans[3].z = z; + if (PSP_CoreParameter().compat.flags().SnapHalfPixelSprites && gstate.isTextureMapEnabled()) { + // In texels for through mode (scaled below), else normalized. + const float texW = throughmode ? 1.0f : (float)gstate.getTextureWidth(0); + const float texH = throughmode ? 1.0f : (float)gstate.getTextureHeight(0); + if (IsHalfPixelSprite(transVtxTL, transVtxBR)) { + // Half a pixel in texture coordinates. Plus a nudge in: a scaled sprite can sample right on a + // texel edge, where interpolation here can fall short of the texel the GE reads. + const float du = 0.5f * (transVtxBR.u - transVtxTL.u) / (transVtxBR.x - transVtxTL.x) * (throughmode ? uScale : 1.0f); + const float dv = 0.5f * (transVtxBR.v - transVtxTL.v) / (transVtxBR.y - transVtxTL.y) * (throughmode ? vScale : 1.0f); + const float nudgeU = (1.0f / 64.0f) / texW * (throughmode ? uScale : 1.0f); + const float nudgeV = (1.0f / 64.0f) / texH * (throughmode ? vScale : 1.0f); + for (int c = 0; c < 4; c++) { + trans[c].x -= 0.5f; + trans[c].y -= 0.5f; + trans[c].u += nudgeU - du; + trans[c].v += nudgeV - dv; + } + } + } + // That's the four corners. Now process UV rotation. // TODO: Should we apply the sprite border fix before or after rotation? Likely after, right? RotateUV(trans); diff --git a/assets/compat.ini b/assets/compat.ini index 684a82682a..67f4143157 100644 --- a/assets/compat.ini +++ b/assets/compat.ini @@ -2270,3 +2270,9 @@ POPS00000 = 0x04100000 # draw's rectangles one by one), so each sees the ones before in the framebuffer copy. Costs a copy per draw. # The PS1 emulator puts each 24-bit video frame together by ORing passes into the same pixels. POPS00000 = true + +[SnapHalfPixelSprites] +# Textured sprites with their corners on pixel centers (x.5) and their texture coordinates on texel centers +# are moved back half a pixel, which draws the same at 1x. Above 1x they otherwise cover half a pixel more +# and read their neighbours' texels: seams between tiles. The PS1 emulator draws its sprites like that. +POPS00000 = true