diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 14da14ef..69f2bd3f 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -564,16 +564,24 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target) return true end +-- LÖVE 11 truncates scissor arguments to framebuffer pixels; the half-pixel +-- bias keeps values divided back through a fractional DPI scale from landing +-- one short. LÖVE 12 passes fractional arguments through and rounds in the +-- graphics backend instead, where that bias shifts each origin by one pixel +-- and extends its far edge by two (#673). +local SCISSOR_PIXEL_BIAS = 0.5 +if love and love.getVersion and select(1, love.getVersion()) >= 12 then + SCISSOR_PIXEL_BIAS = 0 +end + -- Clamp a scissor rect to the viewport box, then round it outward to whole --- framebuffer pixels. love.graphics.setScissor truncates x, y, w and h to --- pixels independently, so a rect with fractional unit edges (Android's --- non-integer DPI puts fitScale/dpi in Sx/Sy) loses up to a pixel per side --- and two adjacent SGB zones stop sharing an edge: the letterbox clear shows --- through as a horizontal seam at every zone boundary (#373). Rounding --- outward makes neighbours overlap by at most one row instead -- the overlap --- redraws the same canvas pixels one palette later, and past the canvas edge --- there is nothing to draw. The half pixel keeps LOVE's truncation on the --- snapped edge rather than one short of it. +-- framebuffer pixels. On LÖVE 11, x, y, w and h are truncated independently, +-- so a rect with fractional unit edges (Android's non-integer DPI puts +-- fitScale/dpi in Sx/Sy) loses up to a pixel per side and two adjacent SGB +-- zones stop sharing an edge: the letterbox clear shows through as a seam at +-- every zone boundary (#373). Rounding outward makes neighbours overlap by +-- at most one row instead; SCISSOR_PIXEL_BIAS preserves that result across the +-- LÖVE 11 and 12 conversion rules. local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY) local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph) x, y = math.max(x, ox), math.max(y, oy) @@ -581,9 +589,10 @@ local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY) dpiX, dpiY = dpiX or 1, dpiY or 1 local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY) local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY) - love.graphics.setScissor((px1 + 0.5) / dpiX, (py1 + 0.5) / dpiY, - (px2 - px1 + 0.5) / dpiX, - (py2 - py1 + 0.5) / dpiY) + local b = SCISSOR_PIXEL_BIAS + love.graphics.setScissor((px1 + b) / dpiX, (py1 + b) / dpiY, + (px2 - px1 + b) / dpiX, + (py2 - py1 + b) / dpiY) return true end diff --git a/tests/engine/title_zone_seams.lua b/tests/engine/title_zone_seams.lua index da454142..edee845f 100644 --- a/tests/engine/title_zone_seams.lua +++ b/tests/engine/title_zone_seams.lua @@ -15,21 +15,41 @@ local check, eq, same = T.check, T.eq, T.same -- endFrame's blit closure, which needs canvases and a compiled shader. The -- source is loaded directly so the rect arithmetic can be exercised with no -- GPU, the same way parity_picker_pointer_grab reads RomImporter (#254). -local scissorClamped, captured -do +local captured +local function loadScissor(loveMajor) local f = io.open("src/render/Renderer.lua", "rb") check(f ~= nil, "Renderer source is readable") local src = f and f:read("*a") or "" if f then f:close() end + local bias = src:match("\nlocal SCISSOR_PIXEL_BIAS = 0%.5.-\nend\n") + check(bias ~= nil, "scissor bias is still version-gated") local body = src:match("\nlocal function scissorClamped.-\nend\n") check(body ~= nil, "scissorClamped is still a single local function") - local fakeLove = { graphics = { setScissor = function(x, y, w, h) - captured = { x = x, y = y, w = w, h = h } - end } } - local chunk = assert(loadstring("local love = ...\n" .. (body or "") + local fakeLove = { + getVersion = function() return loveMajor, 0, 0 end, + graphics = { setScissor = function(x, y, w, h) + captured = { x = x, y = y, w = w, h = h } + end }, + } + local chunk = assert(loadstring("local love = ...\n" .. (bias or "") + .. (body or "") .. "\nreturn scissorClamped")) - scissorClamped = chunk(fakeLove) - check(type(scissorClamped) == "function", "scissorClamped loads standalone") + local scissor = chunk(fakeLove) + check(type(scissor) == "function", "scissorClamped loads standalone") + return scissor +end +local scissorClamped = loadScissor(11) + +-- LÖVE 12 changed setScissor from truncating Lua integers to accepting floats +-- and rounding in the backend. Its arguments must therefore describe the +-- already-snapped rectangle exactly, without LÖVE 11's half-pixel nudge. +do + local scissor12 = loadScissor(12) + captured = nil + check(scissor12(50, 60, 10, 20, 0, 0, 100, 100, 2, 2), + "LÖVE 12 integer test rect draws") + same(captured, { x = 50, y = 60, w = 10, h = 20 }, + "LÖVE 12 receives an unbiased snapped scissor") end -- The title's three SGB zones in canvas pixels (PaletteFX.zone turns the