From 3448fae3f596799d83439c9c848385ce3968943d Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 27 Jul 2026 09:27:24 -0400 Subject: [PATCH] Android SCALING fixes (#298) * android fixes for mods and saves * perhaps this is the true scaling android issue fix --- src/battle/BattleState.lua | 7 ++++-- src/core/Game.lua | 14 ++++++++++++ src/render/PixelCanvas.lua | 45 +++++++++++++++++++++++++++++++++++++ src/render/Renderer.lua | 24 +++++++++++++------- src/render/TileRenderer.lua | 5 ++++- tests/run_tests.lua | 34 ++++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 src/render/PixelCanvas.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c517e0b3..51ad400d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3876,8 +3876,11 @@ function BattleState:colorMode() if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas and love.image and PaletteFX.pack(self.data) and PaletteFX.shader() then - local ok1, bg = pcall(g.newCanvas, 160, 144) - local ok2, wv = pcall(g.newCanvas, 160, 144) + -- 160x144 real pixels, not DPI units, or the colored battle background + -- resamples against the UI canvas on mobile (#208; PixelCanvas.lua) + local PixelCanvas = require("src.render.PixelCanvas") + local ok1, bg = pcall(PixelCanvas.new, 160, 144) + local ok2, wv = pcall(PixelCanvas.new, 160, 144) if ok1 and ok2 and bg and wv then self.bgCanvas, self.waveCanvas = bg, wv ready = true diff --git a/src/core/Game.lua b/src/core/Game.lua index 8049f32c..195915a5 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -96,6 +96,20 @@ function Game:load() end Logger.info("game loaded") + -- Scaling bug reports (#87, #208) are unanswerable without these three + -- numbers: LOVE units, drawable pixels, and the integer physical pixels + -- per GB pixel the renderer settled on. Cheap, once, and it turns "it + -- looks stretched" into something reproducible. + if love.graphics and love.graphics.getDimensions then + local ww, wh = love.graphics.getDimensions() + local pw, ph = ww, wh + if love.graphics.getPixelDimensions then + pw, ph = love.graphics.getPixelDimensions() + end + Logger.info(string.format( + "display: %dx%d units, %dx%d px, fit scale %d px/GB px", + ww, wh, pw, ph, Renderer:fitScale())) + end end -- the merged field.boot: spawn, names, money and the naming presets a diff --git a/src/render/PixelCanvas.lua b/src/render/PixelCanvas.lua new file mode 100644 index 00000000..861f5c27 --- /dev/null +++ b/src/render/PixelCanvas.lua @@ -0,0 +1,45 @@ +-- Render targets measured in real framebuffer pixels. +-- +-- love.graphics.newCanvas defaults its `dpiscale` to +-- love.graphics.getDPIScale(), so on a highdpi surface the canvas texture is +-- NOT the size it was asked for: newCanvas(160, 144) on a device reporting a +-- DPI scale of 2.755 allocates a 441x397 texture, and the 160x144 GB scene +-- then renders into it at 2.755 texels per GB pixel. conf.lua sets +-- t.window.highdpi on Android/iOS (required for Retina), and Android's +-- DisplayMetrics.density is routinely non-integer (1.5, 2.75, ...), so this +-- is the normal mobile case, not an edge case. +-- +-- That fractional render breaks the whole premise of Renderer's integer +-- scale pipeline. Renderer:fitScale() picks a whole number of physical +-- pixels per GB pixel (7 on 1080p) and the composite blit lands on it +-- exactly -- but if the *source* already holds 2.755 texels per GB pixel, +-- some GB pixels are 2 texels wide and some are 3, and the nearest-neighbour +-- upscale turns them into 5 / 7 / 8 physical pixels instead of a uniform 7. +-- Measured on a 1080p density-2.755 fixture, only 43 of 160 columns came out +-- the right width. Fonts show it worst: their strokes are single pixels. +-- That is issue #208 ("some are noticeably stretched, especially fonts"). +-- +-- Forcing dpiscale = 1 makes canvas texels the pixels the renderer already +-- believes it is drawing. getWidth()/getHeight() are unaffected (they always +-- reported the requested size), so no geometry anywhere changes -- only the +-- resolution of the target. Desktop is unchanged; dpiscale is already 1 +-- there because conf.lua sets highdpi on mobile only. +-- +-- Exception: a canvas that is deliberately sized in LOVE *units* and blitted +-- back at unit scale 1 (Renderer's presentCanvas) must keep the screen's DPI +-- scale so its texture still covers the framebuffer; it is not built with +-- this helper. + +local PixelCanvas = {} + +-- One framebuffer pixel per w/h unit, always. `filter` is applied only when +-- given, so callers that relied on LOVE's default ("linear") keep it. +function PixelCanvas.new(w, h, filter) + local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 }) + if filter and canvas and canvas.setFilter then + canvas:setFilter(filter, filter) + end + return canvas +end + +return PixelCanvas diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index fc471d5e..78026bdc 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -10,6 +10,7 @@ local Zoom = require("src.render.Zoom") local Tilt = require("src.render.Tilt") local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") +local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") local Renderer = {} @@ -79,8 +80,10 @@ local function displayMetrics() end function Renderer:init() - self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT) - self.canvas:setFilter("nearest", "nearest") + -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua + -- (#208). Every canvas below is sized in framebuffer pixels for the same + -- reason -- worldViewSize() already works in drawable pixels. + self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest") self.worldCanvas = nil self.worldActive = false -- tilt mode only: a transparent overlay canvas the size of the world @@ -231,8 +234,7 @@ function Renderer:beginWorldPass() -- the view size every frame, so without this the superseded canvases -- pile up in VRAM until a GC finalizer happens to run if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end - self.worldCanvas = love.graphics.newCanvas(vw, vh) - self.worldCanvas:setFilter("nearest", "nearest") + self.worldCanvas = PixelCanvas.new(vw, vh, "nearest") end self.worldActive = true PaletteFX.setPass("world") @@ -260,8 +262,7 @@ function Renderer:beginUprightPass() if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw or self.uprightCanvas:getHeight() ~= ch then if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end - self.uprightCanvas = love.graphics.newCanvas(cw, ch) - self.uprightCanvas:setFilter("nearest", "nearest") + self.uprightCanvas = PixelCanvas.new(cw, ch, "nearest") end self.uprightActive = true PaletteFX.setPass(nil) @@ -355,8 +356,7 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target) if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw or self.tiltCanvas:getHeight() ~= wvh then if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end - self.tiltCanvas = love.graphics.newCanvas(wvw, wvh) - self.tiltCanvas:setFilter("linear", "linear") + self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear") end love.graphics.setCanvas(self.tiltCanvas) @@ -465,6 +465,14 @@ function Renderer:endFrame(zones, worldZones) if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww or self.presentCanvas:getHeight() ~= wh then + -- The one canvas NOT built through PixelCanvas: it is sized in LOVE + -- units and blitted back at unit scale 1 (and handed to mod present + -- passes as ww x wh), so it has to keep the screen's DPI scale for its + -- texture to cover the framebuffer. Everything composited into it is + -- already native-resolution now, so #208's fractional source is gone; + -- what remains here is the dpiX vs dpiY truncation gap (well under 1%, + -- one seam across the window) that a single scalar dpiscale cannot + -- express. self.presentCanvas = love.graphics.newCanvas(ww, wh) self.presentCanvas:setFilter("linear", "linear") end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 232af1f0..935831d0 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -542,7 +542,10 @@ end local function bakeBorderFill(self, block) local border = self.map.tileset.blocks[block + 1] if not border then return end - local canvas = love.graphics.newCanvas(32, 32) + -- 32x32 real pixels: a DPI-scaled canvas would bake the border block at a + -- fractional texel size and the repeat-wrapped image would then tile at + -- non-square pixels (#208, see src/render/PixelCanvas.lua) + local canvas = require("src.render.PixelCanvas").new(32, 32) love.graphics.push("all") love.graphics.setCanvas(canvas) love.graphics.clear(1, 1, 1, 1) diff --git a/tests/run_tests.lua b/tests/run_tests.lua index f7f0bf9f..d205b93f 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1917,6 +1917,40 @@ do check(math.abs(physX - 7) < 1e-9 and math.abs(physY - 7) < 1e-9, "#208 swapped-aspect still yields square 7x7 physical GB pixels") + -- #208 part two: getting the draw scale right is useless if the SOURCE is + -- fractional. love.graphics.newCanvas defaults dpiscale to + -- love.graphics.getDPIScale(), so on mobile (conf.lua sets highdpi) a + -- newCanvas(160, 144) is really a 441x397 texture holding 2.755 texels per + -- GB pixel; the integer blit then lands those on 5 / 7 / 8 physical pixels + -- instead of a uniform 7. Every render target must be pixel-exact. + Zoom.reset() + g.getDimensions = function() return 698, 392 end + g.getPixelDimensions = function() return 1920, 1080 end + g.getDPIScale = function() return 1080 / 392 end + -- one table, not a fistful of locals: this chunk is close to Lua's + -- 200-local ceiling and a few more here overflow it + local probe = { made = {}, newCanvas = g.newCanvas, + ui = Renderer.canvas, world = Renderer.worldCanvas } + g.newCanvas = function(w, h, settings) + probe.made[#probe.made + 1] = + { w = w, h = h, dpiscale = settings and settings.dpiscale } + return probe.newCanvas(w, h) + end + Renderer:init() + Renderer:beginWorldPass() + love.graphics.setCanvas() + g.newCanvas = probe.newCanvas + Renderer.canvas, Renderer.worldCanvas = probe.ui, probe.world + Renderer.worldActive = false + check(#probe.made >= 2, "#208 init + world pass allocated their canvases") + for _, c in ipairs(probe.made) do + eq(c.dpiscale, 1, + ("#208 canvas %dx%d is pixel-exact (dpiscale 1, not the screen's)") + :format(c.w, c.h)) + if c.w == 160 and c.h == 144 then probe.sawUi = true end + end + check(probe.sawUi, "#208 the UI canvas is requested at exactly 160x144") + -- missing pixel API falls back to getDimensions (headless / old stub) g.getPixelDimensions = nil g.getDPIScale = nil