Keep the field effects upright when a render pipeline owns the world

endFrame composites a world override mirrored on LOVE 12, because a
pipeline's own projection pre-flips Y. ctx.drawFx puts the standing
effects into that same canvas as ordinary 2D, so the mirror inverts them
instead of righting them: a trainer's "!" draws upside down, below its
feet, on the mirrored half of the screen.

The condition is now Renderer.mirrorsWorldOverride, named because it is
not only the blit's business, and ctx.drawFx runs its block under the
matching mirror about the bound canvas. project() is left alone: it
already answers in screen rows.
This commit is contained in:
Colson Rice
2026-09-23 11:44:45 -04:00
parent c8b64177f6
commit 833bee64b2
3 changed files with 177 additions and 23 deletions
+11 -2
View File
@@ -53,6 +53,16 @@ end
-- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160
-- Whether endFrame composites a world override MIRRORED. A pipeline owns its
-- own clip convention and the ones in the wild pre-flip Y for LOVE 11, which
-- LOVE 12 turns into one flip too many. Named because it is not only the
-- blit's business: see ctx.drawFx in src/world/OverworldController.lua.
function Renderer.mirrorsWorldOverride()
local sys = love.system
if not (sys and sys.getOS and sys.getOS() == "iOS") then return false end
return (love.getVersion()) >= 12
end
-- LOVE units + framebuffer pixels + per-axis unit→pixel ratios.
-- Android's DisplayMetrics.density is often non-integer (1.5, 2.75, …).
-- Integer scaling in units then maps each GB pixel to a fractional number of
@@ -1056,8 +1066,7 @@ function Renderer:endFrame(zones, worldZones)
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(vux, vuy, vuw, vuh)
local loveMajor = love.getVersion()
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
if Renderer.mirrorsWorldOverride() then
love.graphics.draw(self.worldOverride, vux, vuy + vuh, 0, 1 / dpiX, -1 / dpiY)
else
love.graphics.draw(self.worldOverride, vux, vuy, 0, 1 / dpiX, 1 / dpiY)
+51 -21
View File
@@ -18,6 +18,7 @@ local NPC = require("src.world.NPC")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Player = require("src.world.Player")
local Renderer = require("src.render.Renderer")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local ScriptRunner = require("src.script.ScriptRunner")
@@ -33,6 +34,33 @@ local Strings = require("src.core.Strings")
-- isOverworld marks the live world state for WorldAPI's stack scan
local OverworldState = { isOpaque = true, isOverworld = true }
-- The pipeline's own target, not the playfield: a pipeline with a render
-- scale draws smaller and lets endFrame scale it up.
local function overrideCanvasHeight()
local bound = love.graphics.getCanvas()
if bound and not bound.getHeight then bound = bound[1] end -- several bound
if bound and not bound.getHeight then bound = bound.canvas end -- one face of one
return bound and bound.getHeight and bound:getHeight()
end
-- Run a block of field FX the way the composite expects them. Where endFrame
-- mirrors the override, these are the one part of that canvas the mirror would
-- invert rather than right: the pipeline's pass went in pre-flipped, these are
-- ordinary 2D. project() already answers in screen rows, so mirroring the
-- block about the bound canvas is the whole correction, and project is left
-- alone -- fixing it too would move each anchor twice. Exposed for tests.
local function withOverrideMirror(draw)
local height = Renderer.mirrorsWorldOverride() and overrideCanvasHeight()
if not height then return draw() end
love.graphics.push()
love.graphics.translate(0, height)
love.graphics.scale(1, -1)
draw()
love.graphics.pop()
end
OverworldState.withOverrideMirror = withOverrideMirror
local Game -- set on enter (avoids circular require at load time)
local mapScripts -- registry of hand-ported map scripts
@@ -6214,27 +6242,29 @@ function OverworldState:drawWorld()
love.graphics.pop()
if shader then love.graphics.setShader() end
end
-- ground-hugging effects sit on the cell they belong to
if self.dustAnim then
local da = self.dustAnim
at(fxDust, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0))
end
if self.cutAnim then
at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
end
if self.healAnim then
at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16)
end
-- standing effects anchor at the foot of whoever they belong to
if self.emote and self.emote.npc then
at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16)
end
if self.flyAnim then
at(fxBird, self.player.px + 8, self.player.py + 16)
end
if self.fishing then
at(fxRod, self.player.px + 8, self.player.py + 16)
end
withOverrideMirror(function()
-- ground-hugging effects sit on the cell they belong to
if self.dustAnim then
local da = self.dustAnim
at(fxDust, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0))
end
if self.cutAnim then
at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
end
if self.healAnim then
at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16)
end
-- standing effects anchor at the foot of whoever they belong to
if self.emote and self.emote.npc then
at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16)
end
if self.flyAnim then
at(fxBird, self.player.px + 8, self.player.py + 16)
end
if self.fishing then
at(fxRod, self.player.px + 8, self.player.py + 16)
end
end)
end
override = Pipelines.drawWorld(pipelineId, ctx)
-- world post-processes (a miniature-diorama blur, a colour grade) fold
+115
View File
@@ -0,0 +1,115 @@
-- The field FX a render pipeline composites, where endFrame mirrors the
-- override. The pipeline's pass goes in pre-flipped and comes back level;
-- ctx.drawFx puts these in as ordinary 2D, so without the matching mirror the
-- trainer "!" lands upside down and below its feet.
--
-- The checks ask where a pixel is SEEN: they run the arithmetic `at` does and
-- then apply the composite by hand, so a mirror about the wrong axis, the
-- wrong height or in the wrong order fails here.
-- luajit tests/engine/world_pipeline_fx_mirror.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Renderer = require("src.render.Renderer")
local Overworld = require("src.world.OverworldController")
-- ------------------------------------------------- a transform we can read
local g = love.graphics
local real = { push = g.push, pop = g.pop, translate = g.translate,
scale = g.scale, getCanvas = g.getCanvas }
local stack
local function reset() stack = { { oy = 0, sy = 1 } } end
local function top() return stack[#stack] end
g.push = function() local t = top(); stack[#stack + 1] = { oy = t.oy, sy = t.sy } end
g.pop = function() if #stack > 1 then stack[#stack] = nil end end
g.translate = function(_, dy) local t = top(); t.oy = t.oy + t.sy * dy end
g.scale = function(_, ky) local t = top(); t.sy = t.sy * (ky or 1) end
local CANVAS_H = 400
g.getCanvas = function() return { getHeight = function() return CANVAS_H end } end
local osName, major = "iOS", 12
love.system = love.system or {}
local realOS, realVersion = love.system.getOS, love.getVersion
love.system.getOS = function() return osName end
love.getVersion = function() return major, 0, 0, "" end
reset()
-- ------------------------------------------------- which hosts mirror
T.check(Renderer.mirrorsWorldOverride(), "LOVE 12 on iOS mirrors the override")
major = 11
T.check(not Renderer.mirrorsWorldOverride(), "LOVE 11 stores a canvas the old way")
major, osName = 12, "Android"
T.check(not Renderer.mirrorsWorldOverride(), "and no other host reaches that branch")
osName = "iOS"
-- ------------------------------------------------- the geometry
local CAM_X, CAM_Y = 100, 200
local NPC_X, NPC_Y = 148, 264 -- the foot `at` anchors to
local ROW, SCALE = 120, 2 -- where the pipeline projects that foot
local function project() return 260, ROW end
-- src/world/OverworldController.lua's `at`, for the row a drawn point reaches
local function at(dy)
local sx, sy = project()
local fy = NPC_Y - CAM_Y
g.push()
g.scale(SCALE, SCALE)
g.translate(sx / SCALE, sy / SCALE - fy)
local t = top()
local row = t.oy + t.sy * (fy + dy)
g.pop()
return row
end
-- endFrame draws the override from the bottom edge with a negative Y scale
local function seen(row) return CANVAS_H - row end
-- fxEmote draws the bubble 36 world pixels above the foot it anchors to
reset()
T.eq(seen(at(0)), CANVAS_H - ROW, "unmirrored, the anchor is seen at the mirrored row")
T.check(seen(at(-36)) > seen(at(0)), "and the bubble that belongs above is seen below")
reset()
Overworld.withOverrideMirror(function()
T.eq(seen(at(0)), ROW, "mirrored, the anchor is seen where the pipeline put it")
T.eq(seen(at(-36)), ROW - 36 * SCALE, "and the bubble sits above it, upright")
end)
T.eq(#stack, 1, "the transform stack is left balanced")
-- ------------------------------------------------- what it leaves alone
-- no mirror in the composite either, so the drawn row IS the row seen
osName = "Android"
reset()
Overworld.withOverrideMirror(function()
T.eq(at(0), ROW, "a host that composites straight draws them untouched")
end)
osName = "iOS"
-- a pipeline at a reduced render scale mirrors about ITS canvas, not the playfield
CANVAS_H = 240
reset()
Overworld.withOverrideMirror(function()
T.eq(seen(at(0)), ROW, "a reduced render scale mirrors about its own canvas")
end)
CANVAS_H = 400
reset()
g.getCanvas = function() return nil end
Overworld.withOverrideMirror(function()
T.eq(seen(at(0)), CANVAS_H - ROW, "with nothing bound the effects still draw")
end)
T.eq(#stack, 1, "and no transform is pushed for them")
for name, fn in pairs(real) do g[name] = fn end
love.system.getOS, love.getVersion = realOS, realVersion
T.finish("world pipeline fx mirror")