mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
726 lines
32 KiB
Lua
726 lines
32 KiB
Lua
-- Two-pass renderer. The UI pass is the classic 160x144 Game Boy canvas
|
|
-- drawn at the integer window fit scale S, letterboxed in the window.
|
|
-- The world pass (overworld survey zoom) is a variable-size canvas that
|
|
-- fills the *entire* window at the effective integer scale s', so black
|
|
-- letterbox voids become more map, not empty bars. Both use nearest-
|
|
-- neighbor filtering.
|
|
-- Spec: docs/new-features.md (survey zoom)
|
|
|
|
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 = {}
|
|
|
|
-- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every
|
|
-- screen is laid out in; uiWidth/uiHeight are the surface actually
|
|
-- allocated this frame, which a state may widen through setUISize (the
|
|
-- widescreen battle layout asks for 304x144). Anything drawing a normal
|
|
-- 160x144 screen can keep reading WIDTH/HEIGHT.
|
|
Renderer.WIDTH = 160
|
|
Renderer.HEIGHT = 144
|
|
Renderer.MAX_UI_WIDTH = 640
|
|
Renderer.MAX_UI_HEIGHT = 576
|
|
|
|
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
|
|
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
|
|
-- tables carrying the Canvas method shape. A mod pipeline handing back a
|
|
-- non-canvas must be rejected before it reaches love.graphics.draw, which
|
|
-- would otherwise take the frame down with it.
|
|
local function isCanvas(v)
|
|
if type(v) == "userdata" then
|
|
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
|
|
end
|
|
if type(v) == "table" then
|
|
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Tilt mode: the upright billboard canvas is grown by this many world
|
|
-- pixels on every side beyond the ground world view, so a structure or
|
|
-- sprite standing near a view edge still draws in full instead of being
|
|
-- clipped where the ground canvas ends (a receding tree wall at the top of
|
|
-- the view rises above row 0; a fence at the bottom-left drops below/left).
|
|
-- endFrame composites the padded canvas back with a matching offset.
|
|
Renderer.UPRIGHT_MARGIN = 160
|
|
|
|
-- 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
|
|
-- framebuffer pixels → shimmer, uneven / non-square "pixels", and movement
|
|
-- judder (issue #87). Always derive the crisp integer scale from the
|
|
-- *drawable* pixel size (the window framebuffer we present into -- not a
|
|
-- combined multi-display metric), then draw with (pixels / axisDpi) so the
|
|
-- GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
|
--
|
|
-- LOVE's projection is anisotropic: 1 unit in X covers pw/ww framebuffer
|
|
-- pixels and 1 unit in Y covers ph/wh. Those ratios match on a normal
|
|
-- highdpi surface, but diverge when unit sizes are truncated independently
|
|
-- (`(int)(pixels/density)`) or when a dual-screen / forced-rotation device
|
|
-- reports mismatched unit vs drawable aspects (AYN Thor, issue #208).
|
|
-- love.graphics.getDPIScale() is only ph/wh, so using it (or pw/ww alone)
|
|
-- for both axes makes the other axis land on a fractional, stretched count.
|
|
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
|
|
-- on BOTH axes (square).
|
|
local function displayMetrics()
|
|
local ww, wh = love.graphics.getDimensions()
|
|
local pw, ph = ww, wh
|
|
if love.graphics.getPixelDimensions then
|
|
pw, ph = love.graphics.getPixelDimensions()
|
|
end
|
|
local dpiX, dpiY = 1, 1
|
|
if ww > 0 and pw > 0 then dpiX = pw / ww end
|
|
if wh > 0 and ph > 0 then dpiY = ph / wh end
|
|
-- No pixel API (headless / old stub): fall back to getDPIScale, else 1.
|
|
if (dpiX == 1 and dpiY == 1) and not love.graphics.getPixelDimensions
|
|
and love.graphics.getDPIScale then
|
|
local d = love.graphics.getDPIScale()
|
|
if d and d > 1e-6 then dpiX, dpiY = d, d end
|
|
end
|
|
if dpiX < 1e-6 then dpiX = 1 end
|
|
if dpiY < 1e-6 then dpiY = 1 end
|
|
return ww, wh, pw, ph, dpiX, dpiY
|
|
end
|
|
|
|
function Renderer:init()
|
|
-- 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.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
|
|
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
|
|
self.worldCanvas = nil
|
|
self.worldActive = false
|
|
-- tilt mode only: a transparent overlay canvas the size of the world
|
|
-- canvas that receives the upright billboard pass (sprites + standing
|
|
-- FX, drawn at their projected ground anchors). It composites flat over
|
|
-- the projected ground in endFrame; never touched while tilt is off.
|
|
self.uprightCanvas = nil
|
|
self.uprightActive = false
|
|
-- a render pipeline's finished world image, already at window resolution
|
|
-- (see src/render/Pipelines.lua). nil is "no pipeline rendered this
|
|
-- frame", which is every vanilla frame.
|
|
self.worldOverride = nil
|
|
end
|
|
|
|
-- Hand endFrame a pipeline's world image to composite instead of the world
|
|
-- canvas. Cleared every frame, so a pipeline that declines one frame falls
|
|
-- straight back to the 2D path rather than showing a stale image.
|
|
function Renderer:setWorldOverride(canvas)
|
|
-- Defensive: a pipeline that hands back a non-canvas (forgotten return, a
|
|
-- truthy sentinel) must not reach the worldOverride blit in endFrame, where
|
|
-- love.graphics.draw on it would crash the frame. Reject it and fall back
|
|
-- to the 2D path rather than trust it.
|
|
if canvas ~= nil and not isCanvas(canvas) then canvas = nil end
|
|
self.worldOverride = canvas
|
|
end
|
|
|
|
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
|
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
|
|
-- units via / dpiX and / dpiY when drawing.
|
|
function Renderer:fitScale()
|
|
local _, _, pw, ph = displayMetrics()
|
|
local w, h = self:uiSize()
|
|
return math.max(1, math.floor(math.min(pw / w, ph / h)))
|
|
end
|
|
|
|
-- the native-pixel UI surface in use right now
|
|
function Renderer:uiSize()
|
|
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
|
|
end
|
|
|
|
-- Ask for a UI surface of w x h native pixels; the canvas is reallocated
|
|
-- only when the size actually changes, so the classic path never rebuilds
|
|
-- it. Sizes are resolved before any state draws (Game:draw) and bounded on
|
|
-- both ends -- never smaller than the Game Boy screen every layout assumes,
|
|
-- never large enough for a bad request to allocate an unbounded canvas.
|
|
function Renderer:setUISize(w, h)
|
|
if type(w) ~= "number" or type(h) ~= "number"
|
|
or w < self.WIDTH or h < self.HEIGHT
|
|
or w > self.MAX_UI_WIDTH or h > self.MAX_UI_HEIGHT then
|
|
w, h = self.WIDTH, self.HEIGHT
|
|
end
|
|
w, h = math.floor(w), math.floor(h)
|
|
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
|
|
if self.canvas and self.canvas.release then self.canvas:release() end
|
|
self.uiWidth, self.uiHeight = w, h
|
|
self.canvas = PixelCanvas.new(w, h, "nearest")
|
|
end
|
|
|
|
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
|
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
|
|
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
|
|
-- its projection (fitScale() == drawScaleX() * dpiX == drawScaleY() * dpiY).
|
|
-- Exposed for #208's regression (square pixels when dpiX ≠ dpiY).
|
|
function Renderer:drawScaleX()
|
|
local _, _, _, _, dpiX = displayMetrics()
|
|
return self:fitScale() / dpiX
|
|
end
|
|
|
|
function Renderer:drawScaleY()
|
|
local _, _, _, _, _, dpiY = displayMetrics()
|
|
return self:fitScale() / dpiY
|
|
end
|
|
|
|
-- Back-compat alias: uniform surfaces have drawScaleX == drawScaleY.
|
|
function Renderer:drawScale()
|
|
return self:drawScaleX()
|
|
end
|
|
|
|
-- world-pass canvas size in world pixels: enough to fill the drawable at s'.
|
|
-- Sized from framebuffer pixels (not unit dims) so anisotropic dpiX/dpiY
|
|
-- cannot over/under-cover the window. In tilt mode the canvas grows (both
|
|
-- dimensions, by Tilt.viewGrowth) so the projected ground plane still covers
|
|
-- the whole window with no background peeking at the receded top/bottom
|
|
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
|
-- tilt is inactive).
|
|
function Renderer:worldViewSize()
|
|
local _, _, pw, ph = displayMetrics()
|
|
local sp = Zoom.scale(self:fitScale())
|
|
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
|
|
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
|
|
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
|
if vw % 2 ~= 0 then vw = vw + 1 end
|
|
if vh % 2 ~= 0 then vh = vh + 1 end
|
|
if Tilt.active() then
|
|
local g = Tilt.viewGrowth()
|
|
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
|
|
end
|
|
return vw, vh
|
|
end
|
|
|
|
-- transparent: the world pass shows through (UI pass draws overlays only)
|
|
function Renderer:beginFrame(transparent)
|
|
self.worldActive = false
|
|
self.uprightActive = false
|
|
self.worldOverride = nil
|
|
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
|
-- a popped transition cannot leave a sticky black veil
|
|
self.worldFadeAlpha = nil
|
|
-- battle-transition cascade outside the 160x144 wipe (BattleTransition)
|
|
self.battleCascadeProg = nil
|
|
-- last frame's trueColor rects and sprite redraws go before anything
|
|
-- draws this one
|
|
PaletteFX.clearTrueColor()
|
|
PaletteFX.clearSpriteRedraws()
|
|
-- rBGP is a per-frame register here: the state that draws a dark map
|
|
-- re-arms it while it draws (#322), so nothing inherits last frame's
|
|
PaletteFX.setShadeMap(nil)
|
|
PaletteFX.setPass("ui")
|
|
love.graphics.setCanvas(self.canvas)
|
|
if transparent then
|
|
love.graphics.clear(0, 0, 0, 0)
|
|
else
|
|
love.graphics.clear(1, 1, 1, 1)
|
|
end
|
|
end
|
|
|
|
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
|
|
-- into the surrounding window, matching BattleTransition wipe progress.
|
|
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
|
|
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
|
|
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
|
if not prog or prog <= 0 then return end
|
|
Sy = Sy or Sx
|
|
local TILE_W, TILE_H = 8 * Sx, 8 * Sy
|
|
if TILE_W < 1 then TILE_W = 1 end
|
|
if TILE_H < 1 then TILE_H = 1 end
|
|
local cols = math.ceil(ww / TILE_W)
|
|
local rows = math.ceil(wh / TILE_H)
|
|
local cx, cy = ox + vpw / 2, oy + vph / 2
|
|
local order = {}
|
|
for row = 0, rows - 1 do
|
|
for col = 0, cols - 1 do
|
|
local x, y = col * TILE_W, row * TILE_H
|
|
-- any tile with area outside the letterbox participates
|
|
if x < ox or y < oy or x + TILE_W > ox + vpw or y + TILE_H > oy + vph then
|
|
local dist = math.max(math.abs(x + TILE_W / 2 - cx),
|
|
math.abs(y + TILE_H / 2 - cy))
|
|
order[#order + 1] = { x, y, dist }
|
|
end
|
|
end
|
|
end
|
|
if #order == 0 then return end
|
|
table.sort(order, function(a, b)
|
|
if a[3] ~= b[3] then return a[3] < b[3] end
|
|
if a[2] ~= b[2] then return a[2] < b[2] end
|
|
return a[1] < b[1]
|
|
end)
|
|
local n = math.floor(#order * math.min(1, prog) + 1e-6)
|
|
if prog >= 1 then n = #order end
|
|
love.graphics.setColor(0, 0, 0, 1)
|
|
love.graphics.setScissor(0, 0, ww, wh)
|
|
for i = 1, n do
|
|
local t = order[i]
|
|
love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H)
|
|
end
|
|
love.graphics.setScissor()
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
|
|
function Renderer:beginWorldPass()
|
|
local vw, vh = self:worldViewSize()
|
|
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
|
|
or self.worldCanvas:getHeight() ~= vh then
|
|
-- free the old canvas before replacing it: a zoom/tilt tween changes
|
|
-- 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 = PixelCanvas.new(vw, vh, "nearest")
|
|
end
|
|
self.worldActive = true
|
|
PaletteFX.setPass("world")
|
|
love.graphics.setCanvas(self.worldCanvas)
|
|
love.graphics.clear(1, 1, 1, 1)
|
|
end
|
|
|
|
function Renderer:endWorldPass()
|
|
PaletteFX.setPass("ui")
|
|
love.graphics.setCanvas(self.canvas)
|
|
end
|
|
|
|
-- Tilt mode's upright pass: standing things (sprites, tall-grass feet
|
|
-- overdraw, screen-anchored FX) draw here instead of into the ground
|
|
-- world canvas, each already projected to its ground anchor and colorized
|
|
-- with its map's SGB palette (see OverworldController:billboard). The
|
|
-- canvas is transparent so the projected ground shows through the gaps;
|
|
-- endFrame blits it flat over the projected ground. Sized/filtered like
|
|
-- the world canvas but kept separate so the ground can be projected as a
|
|
-- plane while these stay upright. Only entered while Tilt.active().
|
|
function Renderer:beginUprightPass()
|
|
local vw, vh = self:worldViewSize()
|
|
local M = self.UPRIGHT_MARGIN
|
|
local cw, ch = vw + 2 * M, vh + 2 * M
|
|
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 = PixelCanvas.new(cw, ch, "nearest")
|
|
end
|
|
self.uprightActive = true
|
|
PaletteFX.setPass(nil)
|
|
love.graphics.setCanvas(self.uprightCanvas)
|
|
love.graphics.clear(0, 0, 0, 0)
|
|
-- shift the whole pass into the padded canvas so billboards keep drawing
|
|
-- in flat world-canvas coordinates (0..vw, 0..vh) while the margin catches
|
|
-- anything that overhangs an edge; endFrame undoes it with the same offset
|
|
love.graphics.push()
|
|
love.graphics.translate(M, M)
|
|
end
|
|
|
|
-- return to the ground world canvas (the world pass owns it until draw()
|
|
-- calls endWorldPass)
|
|
function Renderer:endUprightPass()
|
|
PaletteFX.setPass("world")
|
|
love.graphics.pop()
|
|
love.graphics.setCanvas(self.worldCanvas)
|
|
end
|
|
|
|
-- Perspective mesh shader for tilt mode. The mesh already carries CPU-
|
|
-- projected 2D corner positions (from Tilt.groundPoint), so the vertex
|
|
-- stage does no projection; instead it passes each corner's depthScale as
|
|
-- the per-vertex "q" and pre-multiplies the texture coords by it. The
|
|
-- fragment divides back, which reconstructs perspective-correct texture
|
|
-- interpolation across the whole quad (no affine-warp seams) using the
|
|
-- exact same projection the billboards will anchor to. false = headless /
|
|
-- no shader support, in which case the renderer stays on the flat blit.
|
|
local TILT_SHADER = [[
|
|
varying float vScale;
|
|
#ifdef VERTEX
|
|
attribute float VertexScale;
|
|
vec4 position(mat4 transform_projection, vec4 vertex_position) {
|
|
vScale = VertexScale;
|
|
VaryingTexCoord = vec4(VertexTexCoord.xy * VertexScale, 0.0, 1.0);
|
|
return transform_projection * vertex_position;
|
|
}
|
|
#endif
|
|
#ifdef PIXEL
|
|
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
|
return Texel(tex, tc / vScale) * color;
|
|
}
|
|
#endif
|
|
]]
|
|
|
|
function Renderer:tiltShader()
|
|
if self._tiltShader == nil then
|
|
local ok, sh = pcall(love.graphics.newShader, TILT_SHADER)
|
|
self._tiltShader = ok and sh or false
|
|
end
|
|
return self._tiltShader or nil
|
|
end
|
|
|
|
-- Dynamic 4-vertex ground quad; positions/depthScale are refreshed each
|
|
-- frame from Tilt.meshCorners. The custom VertexScale attribute rides the
|
|
-- perspective "q" through to the shader above.
|
|
function Renderer:tiltMesh()
|
|
if self._tiltMesh == nil then
|
|
local format = {
|
|
{ "VertexPosition", "float", 2 },
|
|
{ "VertexTexCoord", "float", 2 },
|
|
{ "VertexScale", "float", 1 },
|
|
}
|
|
local ok, mesh = pcall(love.graphics.newMesh, format, 4, "fan", "dynamic")
|
|
self._tiltMesh = ok and mesh or false
|
|
end
|
|
return self._tiltMesh or nil
|
|
end
|
|
|
|
-- Draw the world pass through the tilt projection. Two steps: (1) a
|
|
-- canvas-to-canvas palette pre-pass that bakes the SGB world zones into a
|
|
-- colorized ground canvas in flat space (a perspective transform breaks
|
|
-- the rectangular scissors endFrame normally uses), then (2) project that
|
|
-- canvas onto the tilted plane via the perspective mesh, scaled/centred
|
|
-- exactly like the flat world blit. `target` is the canvas to project
|
|
-- into (nil = default framebuffer; presentCanvas when CRT is on).
|
|
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
|
|
-- back to the flat blit unchanged.
|
|
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
|
local shader = self:tiltShader()
|
|
local mesh = self:tiltMesh()
|
|
if not (shader and mesh) then return false end
|
|
sy = sy or sx
|
|
local wvw = self.worldCanvas:getWidth()
|
|
local wvh = self.worldCanvas:getHeight()
|
|
|
|
-- colorized ground canvas, resized to match the world canvas. Linear
|
|
-- sampling softens the pixel shimmer the perspective warp would cause
|
|
-- (the flat path keeps nearest). TODO(tilt): optionally render this at
|
|
-- 2x for extra crispness.
|
|
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 = PixelCanvas.new(wvw, wvh, "linear")
|
|
end
|
|
|
|
love.graphics.setCanvas(self.tiltCanvas)
|
|
love.graphics.clear(1, 1, 1, 1)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
local zoneShader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
|
if zoneShader then
|
|
love.graphics.setShader(zoneShader)
|
|
-- same trueColor sentinel the flat blit honors (14 §trueColor)
|
|
local bare = false
|
|
for _, z in ipairs(zoneList) do
|
|
local plain = z.colors == false
|
|
if plain ~= bare then
|
|
bare = plain
|
|
love.graphics.setShader(not plain and zoneShader or nil)
|
|
end
|
|
if not plain then PaletteFX.sendColors(zoneShader, z.colors) end
|
|
local x, y = math.max(0, z.x), math.max(0, z.y)
|
|
local x2, y2 = math.min(wvw, z.x + z.w), math.min(wvh, z.y + z.h)
|
|
if x2 > x and y2 > y then
|
|
love.graphics.setScissor(x, y, x2 - x, y2 - y)
|
|
love.graphics.draw(self.worldCanvas, 0, 0)
|
|
end
|
|
end
|
|
love.graphics.setScissor()
|
|
love.graphics.setShader()
|
|
else
|
|
love.graphics.draw(self.worldCanvas, 0, 0)
|
|
end
|
|
|
|
-- project onto the tilted plane into the present target (or screen)
|
|
love.graphics.setCanvas(target)
|
|
mesh:setTexture(self.tiltCanvas)
|
|
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
|
|
love.graphics.push()
|
|
love.graphics.translate(wox, woy)
|
|
love.graphics.scale(sx, sy)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setShader(shader)
|
|
love.graphics.draw(mesh)
|
|
love.graphics.setShader()
|
|
love.graphics.pop()
|
|
return true
|
|
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.
|
|
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)
|
|
if x2 <= x or y2 <= y then return false end
|
|
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)
|
|
return true
|
|
end
|
|
|
|
-- Splice the pass's trueColor rects (reported by the renderers that drew
|
|
-- a record carrying the flag) onto the end of its zone list, so each one
|
|
-- re-blits its region with no shader over the colorized pass. An absent
|
|
-- or empty zone list is left alone: that already draws the whole canvas
|
|
-- unshaded, which is what the rects were asking for.
|
|
local function withTrueColor(zoneList, pass)
|
|
local rects = PaletteFX.trueColorRects(pass)
|
|
if not (rects[1] and zoneList and zoneList[1]) then return zoneList end
|
|
local merged = {}
|
|
for i = 1, #zoneList do merged[i] = zoneList[i] end
|
|
for i = 1, #rects do merged[#merged + 1] = rects[i] end
|
|
return merged
|
|
end
|
|
|
|
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
|
-- 160x144 UI space, applied to the UI pass. worldZones: optional
|
|
-- regions in world-canvas pixels (overworld survey zoom colors each
|
|
-- visible map area separately), applied to the world pass; the world
|
|
-- pass falls back to the UI zones when absent. Each zone is drawn
|
|
-- scissored through the shade-remap shader, later zones on top.
|
|
-- When GBC FX is active the composite is drawn into presentCanvas and
|
|
-- presented through the GBC FX shader as a final pass.
|
|
function Renderer:endFrame(zones, worldZones)
|
|
love.graphics.setCanvas()
|
|
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
|
|
-- Sp = integer framebuffer pixels per GB pixel;
|
|
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
|
local Sp = self:fitScale()
|
|
local Sx, Sy = Sp / dpiX, Sp / dpiY
|
|
local uiw, uih = self:uiSize()
|
|
local vpw, vph = uiw * Sx, uih * Sy
|
|
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
|
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
|
|
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
|
|
local GBCFX = require("src.render.GBCFX")
|
|
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
|
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
|
zones = PaletteFX.ensureZones(zones)
|
|
if worldZones then worldZones = PaletteFX.ensureZones(worldZones) end
|
|
-- the UI rects are in 160x144 canvas space and the world rects in world-
|
|
-- canvas pixels, matching the zone list each is appended to. A world
|
|
-- pass with no world zones falls back to the UI list, whose coordinate
|
|
-- space the world rects are not in, so they are dropped there.
|
|
zones = withTrueColor(zones, "ui")
|
|
worldZones = withTrueColor(worldZones, "world")
|
|
|
|
-- A post-process pipeline needs the whole composite in a canvas for the
|
|
-- same reason GBC FX does, so either one alone is enough to take the
|
|
-- present path; with neither, the frame draws straight to the screen
|
|
-- exactly as it always did.
|
|
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
|
|
local present = nil
|
|
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
|
|
present = self.presentCanvas
|
|
love.graphics.setCanvas(present)
|
|
end
|
|
-- Default letterbox is black. Battle (and any state that opts in via
|
|
-- letterboxWhite) fills the voids with the display mode's paper shade, so
|
|
-- the bars match the canvas they frame instead of showing black. Not a
|
|
-- literal white: the battle canvas is colorized, and in SGB mode its paper
|
|
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
|
|
-- a visibly brighter border.
|
|
local clearR, clearG, clearB = 0, 0, 0
|
|
if not self.worldActive then
|
|
local ok, Game = pcall(require, "src.core.Game")
|
|
local stack = ok and Game and Game.stack
|
|
local base = stack and stack.visibleBase and stack:visibleBase()
|
|
local state = base and stack.states and stack.states[base]
|
|
if state and state.letterboxWhite then
|
|
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
|
end
|
|
end
|
|
love.graphics.setColor(clearR, clearG, clearB, 1)
|
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
-- render.letterbox: SGB borders / custom void art in the bars around the
|
|
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
|
-- canvas so the playfield sits on top of the border.
|
|
if Runtime.wantsHook("render.letterbox") then
|
|
Runtime.call("render.letterbox", function() end, {
|
|
ww = ww, wh = wh, pw = pw, ph = ph,
|
|
ox = ox, oy = oy, vpw = vpw, vph = vph,
|
|
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
|
worldActive = self.worldActive and true or false,
|
|
})
|
|
end
|
|
|
|
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
|
|
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
|
-- convert zone coords (canvas-space) into screen units.
|
|
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
|
bx, by, boxX, boxY, boxW, boxH)
|
|
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
|
if not shader then
|
|
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
|
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
|
love.graphics.setScissor()
|
|
return
|
|
end
|
|
love.graphics.setShader(shader)
|
|
-- a colors == false zone is the trueColor opt-out: its rect draws with
|
|
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
|
|
-- list never toggles and issues exactly the calls it always did.
|
|
local bare = false
|
|
for _, z in ipairs(zoneList) do
|
|
local plain = z.colors == false
|
|
if plain ~= bare then
|
|
bare = plain
|
|
love.graphics.setShader(not plain and shader or nil)
|
|
end
|
|
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
|
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
|
z.w * zoneSx, z.h * zoneSy,
|
|
boxX, boxY, boxW, boxH, dpiX, dpiY) then
|
|
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
|
end
|
|
end
|
|
love.graphics.setScissor()
|
|
love.graphics.setShader()
|
|
end
|
|
|
|
if self.worldOverride then
|
|
-- A render pipeline already produced the whole world -- terrain,
|
|
-- characters and its own FX overlay -- as one window-resolution image,
|
|
-- so it composites with a straight 1:1 blit and the world canvas is
|
|
-- skipped entirely (nothing drew into it). The UI blit below still
|
|
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setScissor(0, 0, ww, wh)
|
|
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
|
|
love.graphics.setScissor()
|
|
-- the screen-space overlays the flat path draws over its composite
|
|
local fade = self.worldFadeAlpha
|
|
if fade and fade > 0 then
|
|
love.graphics.setColor(0, 0, 0, fade)
|
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
if self.battleCascadeProg then
|
|
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
|
end
|
|
elseif self.worldActive then
|
|
local sp = Zoom.scale(Sp)
|
|
local sx, sy = sp / dpiX, sp / dpiY
|
|
local wvw = self.worldCanvas:getWidth()
|
|
local wvh = self.worldCanvas:getHeight()
|
|
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
|
|
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
|
|
-- Tilt mode projects the ground world pass through the perspective mesh
|
|
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
|
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
|
-- projection is unavailable (headless / no shader); then the ground
|
|
-- falls through to the flat blit, keeping the flat frame byte-for-byte
|
|
-- identical to today.
|
|
local projected =
|
|
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
|
|
if not projected then
|
|
if worldZones then
|
|
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
|
|
else
|
|
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
|
|
end
|
|
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
|
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
|
-- feet-overdraw entries carry `colors` and re-colorize through the
|
|
-- color-0-keyed shade-remap shader so they keep hiding sprite feet.
|
|
local redraws = PaletteFX.spriteRedraws()
|
|
if redraws[1] then
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setScissor(0, 0, ww, wh)
|
|
local activeShader = nil
|
|
for _, r in ipairs(redraws) do
|
|
local wanted = r.colors
|
|
and (r.keyed and PaletteFX.keyedShader() or PaletteFX.shader())
|
|
or nil
|
|
if wanted ~= activeShader then
|
|
activeShader = wanted
|
|
love.graphics.setShader(wanted)
|
|
end
|
|
if wanted then PaletteFX.sendColors(wanted, r.colors) end
|
|
if r.quad then
|
|
love.graphics.draw(r.image, r.quad, wox + r.x * sx, woy + r.y * sy,
|
|
0, sx * r.sx, sy)
|
|
else
|
|
love.graphics.draw(r.image, wox + r.x * sx, woy + r.y * sy,
|
|
0, sx * r.sx, sy)
|
|
end
|
|
end
|
|
if activeShader then love.graphics.setShader() end
|
|
love.graphics.setScissor()
|
|
end
|
|
end
|
|
-- Composite the tilt upright pass over the ground (projected or, in the
|
|
-- rare no-shader fallback, flat). It already carries its billboards'
|
|
-- projected positions and per-sprite SGB colorization on a transparent
|
|
-- canvas, so it just needs the same centred integer-scale blit the flat
|
|
-- world pass uses -- no zone scissoring. uprightActive is only ever
|
|
-- set in tilt mode, so flat frames skip this and stay identical.
|
|
if self.uprightActive then
|
|
local M = self.UPRIGHT_MARGIN
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setScissor(0, 0, ww, wh)
|
|
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
|
|
love.graphics.setScissor()
|
|
end
|
|
-- Screen-space warp fade (Transition) over the full world composite so
|
|
-- survey zoom / tilt edges darken with the center, not only the 160x144
|
|
-- UI letterbox. Drawn before the UI blit so menus above a fade still
|
|
-- composite normally if one is ever stacked that way.
|
|
local fade = self.worldFadeAlpha
|
|
if fade and fade > 0 then
|
|
love.graphics.setColor(0, 0, 0, fade)
|
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
-- Battle transition: cascade black blocks into the area outside the
|
|
-- classic 160x144 wipe square (world still shows through until filled).
|
|
if self.battleCascadeProg then
|
|
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
|
end
|
|
end
|
|
-- UI stays in the classic centered GB letterbox
|
|
blit(self.canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph)
|
|
|
|
if present then
|
|
love.graphics.setCanvas()
|
|
-- Post-process pipelines run over the finished composite -- world, UI
|
|
-- and all -- and before GBC FX, so a blur or colour grade is what the
|
|
-- LCD grid is then drawn over rather than something that smears the
|
|
-- grid itself. Each pass hands back a canvas; with none registered
|
|
-- this returns `present` unchanged and the frame is byte-identical.
|
|
local composed = Pipelines.present(present,
|
|
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
|
|
if GBCFX.active() then
|
|
-- shader grid/shadow math is in framebuffer pixels
|
|
GBCFX.present(composed, Sp)
|
|
else
|
|
-- the present canvas only existed for the post-process, so put the
|
|
-- result on the screen at the same 1:1 unit mapping it was built at
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.draw(composed, 0, 0)
|
|
end
|
|
end
|
|
self.worldActive = false
|
|
self.uprightActive = false
|
|
self.worldOverride = nil
|
|
PaletteFX.setPass(nil)
|
|
end
|
|
|
|
return Renderer
|