mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
424 lines
16 KiB
Lua
424 lines
16 KiB
Lua
-- Rendering pipelines: the engine side of the render_pipelines registry.
|
|
--
|
|
-- A pipeline is a display mode a mod owns. It may replace the overworld's
|
|
-- world pass with geometry of its own (drawWorld) and/or post-process the
|
|
-- finished composite (present). Everything else about being a display mode
|
|
-- -- the OFF/1/2/3 ladder, the options row, the hotkey, persistence, the
|
|
-- free-roam gate, and never letting a mod's error take the frame down -- is
|
|
-- engine plumbing and lives here, so a renderer mod writes the two draw
|
|
-- functions and declares the rest.
|
|
--
|
|
-- The two halves compose independently and in priority order: the highest
|
|
-- priority eligible drawWorld renders the world, then every eligible
|
|
-- present folds over whatever came out (the world pipeline's canvas, or the
|
|
-- vanilla flat/tilt composite when none ran). A present that is switched
|
|
-- off returns its input, so a full ladder of them costs nothing at level 0.
|
|
--
|
|
-- Nothing here reaches collision, movement, triggers or scripts: like
|
|
-- survey zoom and tilt, a pipeline is purely presentational, which is why
|
|
-- its level rides in save.options rather than the save proper.
|
|
--
|
|
-- Spec: docs/modding.md (rendering pipelines)
|
|
|
|
local Data = require("src.core.Data")
|
|
local Logger = require("src.core.Logger")
|
|
local Runtime = require("src.mods.Runtime")
|
|
local Zoom = require("src.render.Zoom")
|
|
|
|
local Pipelines = {}
|
|
|
|
-- id -> level. Levels live here rather than on the records because the
|
|
-- records are merged content: frozen after load, and shared with whatever
|
|
-- else reads Data.
|
|
local levels = {}
|
|
|
|
-- ids whose callbacks have already thrown, so a pipeline that fails every
|
|
-- frame reports once instead of filling the log at 60Hz
|
|
local broken = {}
|
|
|
|
Pipelines.DEFAULT_LEVELS = { "OFF", "ON" }
|
|
|
|
-- The merged dataset the records live in. The boot singleton is the
|
|
-- default -- Game.data is that very table -- and install() lets a headless
|
|
-- caller (the SDK harness, a tool) point this at a dataset of its own.
|
|
local source = Data
|
|
|
|
function Pipelines.install(data)
|
|
source = data or Data
|
|
Pipelines.reset()
|
|
end
|
|
|
|
-- ------- catalog
|
|
|
|
-- Every registered pipeline as { id = ..., def = ... }, ordered by priority
|
|
-- (descending, ties by id) so selection, the options rows and the present
|
|
-- fold all walk the same sequence.
|
|
--
|
|
-- Memoized on the namespace table's identity. Content freezes at the merge
|
|
-- boundary, so the answer cannot change for a given table -- and this is
|
|
-- read several times per frame by update(), worldPipeline() and the
|
|
-- endFrame present check, which is no place to allocate and sort. An empty
|
|
-- list is cached too, so a mod-free boot pays one table for the process.
|
|
local listCache, listSource = nil, nil
|
|
|
|
function Pipelines.list()
|
|
local defs = source and source.render_pipelines
|
|
if defs == listSource and listCache then return listCache end
|
|
local out = {}
|
|
if type(defs) == "table" then
|
|
for id, def in pairs(defs) do
|
|
-- the merge writes provenance under _owners; skip the bookkeeping
|
|
-- keys rather than treating them as pipelines
|
|
if type(id) == "string" and id:sub(1, 1) ~= "_" and type(def) == "table" then
|
|
out[#out + 1] = { id = id, def = def }
|
|
end
|
|
end
|
|
table.sort(out, function(a, b)
|
|
local pa, pb = a.def.priority or 0, b.def.priority or 0
|
|
if pa ~= pb then return pa > pb end
|
|
return a.id < b.id
|
|
end)
|
|
end
|
|
listCache, listSource = out, defs
|
|
return out
|
|
end
|
|
|
|
function Pipelines.get(id)
|
|
local defs = source and source.render_pipelines
|
|
local def = type(defs) == "table" and defs[id] or nil
|
|
return type(def) == "table" and def or nil
|
|
end
|
|
|
|
-- the mod that registered a pipeline, so a runtime failure lands in the
|
|
-- feed the mod manager shows instead of only in the console
|
|
local function ownerOf(id)
|
|
local defs = source and source.render_pipelines
|
|
local owners = type(defs) == "table" and defs._owners or nil
|
|
return owners and owners[id] or nil
|
|
end
|
|
|
|
-- Run one of a pipeline's callbacks under pcall. A mod that throws mid-
|
|
-- frame must not take the frame with it: the pipeline is marked broken,
|
|
-- attributed once, and treated as absent from then on -- which degrades to
|
|
-- the vanilla 2D path rather than a black screen.
|
|
local function guard(id, fn, ...)
|
|
if broken[id] then return nil end
|
|
local ok, result = pcall(fn, ...)
|
|
if ok then return result end
|
|
broken[id] = true
|
|
Logger.error("render pipeline %s failed: %s -- disabled for this session",
|
|
id, tostring(result))
|
|
Runtime.reportError(ownerOf(id), "render pipeline failed: " .. tostring(result))
|
|
return nil
|
|
end
|
|
|
|
-- Whether a callback's return is a real Canvas we can composite. A mod that
|
|
-- forgets a return, or hands back a shade string / flag / number, must be
|
|
-- ignored rather than trusted -- draw() on a non-canvas takes the frame down.
|
|
-- Real LOVE canvases are userdata answering typeOf("Canvas"); the headless
|
|
-- test stub (tests/love_stub) fakes them as tables carrying the Canvas method
|
|
-- shape (love.graphics.newCanvas), so accept either and nothing else.
|
|
local function isCanvas(v)
|
|
if type(v) == "userdata" then
|
|
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
|
end
|
|
if type(v) == "table" then
|
|
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Dispatch a mod render callback with its GPU state fenced off: push("all")
|
|
-- before and pop() after, so a callback that returns cleanly but leaves a
|
|
-- shader bound, the canvas redirected, or blend/colour changed cannot corrupt
|
|
-- the engine composite that follows. guard() catches a callback that throws;
|
|
-- this catches one that dirties state. A pipeline already retired skips the
|
|
-- push/pop entirely, so the stack stays balanced.
|
|
local function guardRender(id, fn, ...)
|
|
if broken[id] then return nil end
|
|
love.graphics.push("all")
|
|
local out = guard(id, fn, ...)
|
|
love.graphics.pop()
|
|
return out
|
|
end
|
|
|
|
-- ------- levels
|
|
|
|
function Pipelines.levelLabels(id)
|
|
local def = Pipelines.get(id)
|
|
local labels = def and def.levels
|
|
if type(labels) ~= "table" or labels[1] == nil then
|
|
return Pipelines.DEFAULT_LEVELS
|
|
end
|
|
return labels
|
|
end
|
|
|
|
-- highest selectable level: one less than the label count, so a two-label
|
|
-- ladder is a plain OFF/ON toggle
|
|
function Pipelines.maxLevel(id)
|
|
return #Pipelines.levelLabels(id) - 1
|
|
end
|
|
|
|
function Pipelines.level(id)
|
|
return levels[id] or 0
|
|
end
|
|
|
|
function Pipelines.levelLabel(id, level)
|
|
local labels = Pipelines.levelLabels(id)
|
|
return labels[(level or Pipelines.level(id)) + 1] or labels[1] or "OFF"
|
|
end
|
|
|
|
-- A world pipeline and the engine's own tilt mode are two answers to the
|
|
-- same question, so switching one on switches the other off -- the rule
|
|
-- tilt and survey zoom already follow between themselves. Present-only
|
|
-- pipelines (post-processes) compose with tilt and are left alone.
|
|
local function excludeTilt(id, level)
|
|
local def = Pipelines.get(id)
|
|
if not (def and def.drawWorld) or level <= 0 then return end
|
|
local Tilt = require("src.render.Tilt")
|
|
if Tilt.level > 0 then Tilt.setLevel(0) end
|
|
-- one world pipeline at a time, for the same reason
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
if entry.id ~= id and entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
|
|
levels[entry.id] = 0
|
|
end
|
|
end
|
|
end
|
|
|
|
function Pipelines.setLevel(id, level)
|
|
if not Pipelines.get(id) then return 0 end
|
|
level = math.floor(tonumber(level) or 0)
|
|
if level < 0 then level = 0 end
|
|
local max = Pipelines.maxLevel(id)
|
|
if level > max then level = max end
|
|
levels[id] = level
|
|
excludeTilt(id, level)
|
|
return level
|
|
end
|
|
|
|
-- Advance the ladder and wrap to OFF, the shape every display hotkey walks.
|
|
function Pipelines.cycle(id, dir)
|
|
local max = Pipelines.maxLevel(id)
|
|
if max < 1 then return 0 end
|
|
local span = max + 1
|
|
local target = (Pipelines.level(id) + (dir or 1)) % span
|
|
if target < 0 then target = target + span end
|
|
return Pipelines.setLevel(id, target)
|
|
end
|
|
|
|
-- Turning a world pipeline on must switch tilt off in the save too, not
|
|
-- just in the live module, or the next boot restores both. Call sites hand
|
|
-- over the options table so this stays the one place that rule lives.
|
|
function Pipelines.syncOptions(opts)
|
|
if type(opts) ~= "table" then return end
|
|
local bucket = opts.pipelines
|
|
if type(bucket) ~= "table" then
|
|
bucket = {}
|
|
opts.pipelines = bucket
|
|
end
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
bucket[entry.id] = Pipelines.level(entry.id)
|
|
if entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
|
|
opts.tilt = 0
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Restore levels from a loaded options table. A pipeline whose mod is gone
|
|
-- keeps its stored level untouched in the bucket (so re-enabling the mod
|
|
-- restores the mode) but contributes nothing while absent.
|
|
function Pipelines.applyOptions(opts)
|
|
local bucket = type(opts) == "table" and opts.pipelines or nil
|
|
levels = {}
|
|
broken = {}
|
|
local world = nil
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
local stored = type(bucket) == "table" and bucket[entry.id] or 0
|
|
local level = math.floor(tonumber(stored) or 0)
|
|
if level < 0 then level = 0 end
|
|
local max = Pipelines.maxLevel(entry.id)
|
|
if level > max then level = max end
|
|
-- list() is priority order, so the first world pipeline with a stored
|
|
-- level is the one that wins; the rest restore to OFF rather than
|
|
-- sitting on a level that can never render
|
|
if entry.def.drawWorld and level > 0 then
|
|
if world then level = 0 else world = entry.id end
|
|
end
|
|
levels[entry.id] = level
|
|
end
|
|
-- a restored world pipeline and tilt are two answers to the same
|
|
-- question; the pipeline wins, as it does at every place that sets one
|
|
if world then require("src.render.Tilt").setLevel(0) end
|
|
end
|
|
|
|
function Pipelines.reset()
|
|
levels = {}
|
|
broken = {}
|
|
end
|
|
|
|
-- ------- per-frame
|
|
|
|
-- Presentational tweens run on real frame time, like Tilt's. Every
|
|
-- pipeline ticks, not just the active ones: a mode easing back OUT still
|
|
-- has an angle to retire.
|
|
function Pipelines.update(dt)
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
if entry.def.update then
|
|
guardRender(entry.id, entry.def.update, dt, Pipelines.level(entry.id))
|
|
end
|
|
end
|
|
end
|
|
|
|
-- A pipeline may run this frame when it is switched on, has not thrown, and
|
|
-- its hardware gate says yes. `available` is consulted every frame rather
|
|
-- than cached: a driver that loses its depth canvas on a resize has to be
|
|
-- able to change its mind.
|
|
--
|
|
-- Deliberately NOT gated on the state stack. `gate` governs whether the
|
|
-- player may CHANGE the mode, never whether it draws -- a display mode that
|
|
-- stopped rendering during a warp, a scripted cutscene or an open menu
|
|
-- would flash the flat 2D world for those frames every time the player
|
|
-- walked through a door. Once a mode is on it renders until it is off.
|
|
function Pipelines.eligible(id)
|
|
local def = Pipelines.get(id)
|
|
if not def or broken[id] then return false end
|
|
if Pipelines.level(id) <= 0 then return false end
|
|
if def.available and guard(id, def.available) ~= true then return false end
|
|
return true
|
|
end
|
|
|
|
-- Whether the player may cycle this mode right now: the free-roam gate,
|
|
-- which keeps a hotkey press from switching modes mid-warp or mid-cutscene.
|
|
-- Input only -- see eligible() for why the draw path does not consult it.
|
|
function Pipelines.canToggle(id, top, overworld)
|
|
local def = Pipelines.get(id)
|
|
if not def then return false end
|
|
local gate = def.gate or Zoom.gateOK
|
|
return guard(id, gate, top, overworld) == true
|
|
end
|
|
|
|
-- The pipeline that owns the world pass right now, or nil for the vanilla
|
|
-- flat/tilt draw. Highest priority wins; the exclusion rules above mean
|
|
-- there is normally only one candidate anyway.
|
|
function Pipelines.worldPipeline()
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
if entry.def.drawWorld and Pipelines.eligible(entry.id) then
|
|
return entry.id, entry.def
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Render the world through `id`. Returns the canvas to composite, or nil
|
|
-- when the pipeline declined this frame (nothing to draw, a transient
|
|
-- failure), which the caller treats as "fall back to the 2D path".
|
|
function Pipelines.drawWorld(id, ctx)
|
|
local def = Pipelines.get(id)
|
|
if not (def and def.drawWorld) then return nil end
|
|
return guardRender(id, def.drawWorld, ctx)
|
|
end
|
|
|
|
-- Fold every eligible world post-process over a pipeline's world image,
|
|
-- before the UI composites on top. This is where a depth-of-field or a
|
|
-- colour grade belongs when it must leave the dialog boxes and menus crisp;
|
|
-- `present` below is the whole-frame counterpart. Only reachable once some
|
|
-- pipeline rendered the world, so it is gated on the overworld state the
|
|
-- same way drawWorld is.
|
|
function Pipelines.worldPresent(canvas, ctx)
|
|
if canvas == nil then return nil end
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
if entry.def.worldPresent and Pipelines.eligible(entry.id) then
|
|
local out = guardRender(entry.id, entry.def.worldPresent, canvas, ctx)
|
|
-- accept only a real Canvas: a pass that returns a non-canvas (a
|
|
-- forgotten return, a shade string) is ignored, not folded in
|
|
if isCanvas(out) then canvas = out end
|
|
end
|
|
end
|
|
return canvas
|
|
end
|
|
|
|
-- Fold every eligible post-process over the finished frame. Present
|
|
-- pipelines are not gated on the overworld state -- a CRT curve or a colour
|
|
-- grade applies to menus and battles too -- so eligibility here is just
|
|
-- "switched on and available". A pass that returns a non-canvas is
|
|
-- ignored rather than trusted, so a mod cannot blank the screen by
|
|
-- forgetting a return.
|
|
function Pipelines.present(canvas, ctx)
|
|
if canvas == nil then return nil end
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
if entry.def.present and Pipelines.eligible(entry.id) then
|
|
local out = guardRender(entry.id, entry.def.present, canvas, ctx)
|
|
-- accept only a real Canvas: a pass that returns a non-canvas is
|
|
-- ignored (docstring above), so a mod cannot blank or crash the frame
|
|
-- by forgetting a return or handing back a truthy non-canvas
|
|
if isCanvas(out) then canvas = out end
|
|
end
|
|
end
|
|
return canvas
|
|
end
|
|
|
|
-- true when any present-only pass wants to run, so the composite path can
|
|
-- skip allocating a target it would not use
|
|
function Pipelines.wantsPresent()
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
if entry.def.present and Pipelines.eligible(entry.id) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- ------- input and UI
|
|
|
|
-- Cycle whichever pipeline claims `key`. Returns the id when one did, so
|
|
-- the caller knows the key was consumed. Checked after the engine's own
|
|
-- display hotkeys, so a mod can never shadow one.
|
|
function Pipelines.hotkey(key, top, overworld)
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
if entry.def.hotkey == key then
|
|
-- the gate belongs here and nowhere else: it stops the player
|
|
-- flipping modes mid-warp or mid-cutscene, and has no say over
|
|
-- whether an already-on mode draws
|
|
if Pipelines.canToggle(entry.id, top, overworld) then
|
|
Pipelines.cycle(entry.id)
|
|
return entry.id
|
|
end
|
|
return nil
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Options rows for every registered pipeline, in the same priority order,
|
|
-- in the descriptor shape src/ui/OptionRows.lua renders.
|
|
function Pipelines.rows(game)
|
|
local rows = {}
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
local id = entry.id
|
|
rows[#rows + 1] = {
|
|
id = "pipeline:" .. id,
|
|
label = entry.def.label or id:upper(),
|
|
value = function() return Pipelines.levelLabel(id) end,
|
|
step = function(g, dir)
|
|
Pipelines.cycle(id, dir)
|
|
local opts = g and g.save and g.save.options
|
|
if opts then
|
|
Pipelines.syncOptions(opts)
|
|
-- the exclusion above may have switched tilt off; keep the live
|
|
-- module in step with the option it just wrote
|
|
require("src.render.Tilt").setLevel(opts.tilt or 0)
|
|
end
|
|
return true
|
|
end,
|
|
}
|
|
end
|
|
return rows
|
|
end
|
|
|
|
-- Drop every pipeline's GPU objects (window resize, hot reload).
|
|
function Pipelines.invalidate()
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
if entry.def.invalidate then guardRender(entry.id, entry.def.invalidate) end
|
|
end
|
|
end
|
|
|
|
return Pipelines
|