mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
1905261c5b
CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754
1054 lines
40 KiB
Lua
1054 lines
40 KiB
Lua
-- ShaderFX: runtime libretro slang-shader presets. Translated ahead of time
|
|
-- into a cached Lua artifact, then run as a LOVE canvas pass-graph over the
|
|
-- finished frame. Two independent slots, "main" and "secondary", chain in
|
|
-- that order. See docs/shaderfx.md.
|
|
|
|
local ShaderFX = {}
|
|
|
|
local Json = require("src.link.Json")
|
|
local SaveData = require("src.core.SaveData")
|
|
local Performance = require("src.core.Performance")
|
|
|
|
-- ------- preset discovery: the local drop-in folder
|
|
|
|
-- A real OS path, not a love.filesystem mount: the native bridge and the LUT
|
|
-- loads both read it directly.
|
|
function ShaderFX.presetDir()
|
|
local base = (SaveData.portableBaseDir and SaveData.portableBaseDir())
|
|
or (love.filesystem and love.filesystem.getSaveDirectory and love.filesystem.getSaveDirectory())
|
|
if not base then return nil end
|
|
local sep = package.config:sub(1, 1)
|
|
return base .. sep .. "shaders"
|
|
end
|
|
|
|
function ShaderFX.list()
|
|
local dir = ShaderFX.presetDir()
|
|
if not dir then return {} end
|
|
love.filesystem.createDirectory("shaders")
|
|
local out = {}
|
|
local function scan(relPath)
|
|
local items = love.filesystem.getDirectoryItems(relPath)
|
|
for _, name in ipairs(items) do
|
|
local rel = relPath .. "/" .. name
|
|
local info = love.filesystem.getInfo(rel)
|
|
if info and info.type == "directory" then
|
|
scan(rel)
|
|
elseif name:match("%.slangp$") then
|
|
local entry = { name = name, relPath = rel, fullPath = dir .. rel:gsub("^shaders", "") }
|
|
entry.converted = ShaderFX.isConverted(entry)
|
|
out[#out + 1] = entry
|
|
end
|
|
end
|
|
end
|
|
local ok, err = pcall(scan, "shaders")
|
|
if not ok then
|
|
require("src.core.Logger").error("ShaderFX.list: scan failed: %s", tostring(err))
|
|
return {}
|
|
end
|
|
table.sort(out, function(a, b) return a.name < b.name end)
|
|
return out
|
|
end
|
|
|
|
function ShaderFX.artifactPath(entry)
|
|
return (entry.fullPath:gsub("%.slangp$", ".lua"))
|
|
end
|
|
|
|
-- A real OS path outside any love.filesystem mount, so io.open, not getInfo.
|
|
function ShaderFX.isConverted(entry)
|
|
local f = io.open(ShaderFX.artifactPath(entry), "rb")
|
|
if not f then return false end
|
|
f:close()
|
|
return true
|
|
end
|
|
|
|
-- ------- buildbot download: the second acquisition path, alongside the
|
|
-- local drop-in folder above. Downloaded presets arrive unconverted.
|
|
ShaderFX.BUILDBOT_URL = "https://buildbot.libretro.com/assets/frontend/shaders_slang.zip"
|
|
|
|
local DOWNLOAD_ZIP_REL = "shaderfx_buildbot.zip"
|
|
local DOWNLOAD_MOUNT = "shaderfx_buildbot_mount"
|
|
-- The buildbot's own ETag, replayed as If-None-Match so a repeat download
|
|
-- that changed nothing costs one small request instead of the full transfer.
|
|
local DOWNLOAD_ETAG_REL = "shaderfx_buildbot.etag"
|
|
|
|
-- Returns a Fetch job id; poll it with ShaderFX.downloadStatus(). A status of
|
|
-- "ok" with .notModified true means nothing was re-downloaded.
|
|
function ShaderFX.downloadPresets()
|
|
return require("src.net.Fetch").download(ShaderFX.BUILDBOT_URL, DOWNLOAD_ZIP_REL,
|
|
{ etagRel = DOWNLOAD_ETAG_REL })
|
|
end
|
|
|
|
function ShaderFX.downloadStatus(jobId)
|
|
return require("src.net.Fetch").poll(jobId)
|
|
end
|
|
|
|
-- Collapses "." and ".." segments; Lua has no stdlib equivalent.
|
|
local function normalizePath(path)
|
|
local parts = {}
|
|
for part in path:gmatch("[^/]+") do
|
|
if part == ".." then
|
|
if #parts > 0 and parts[#parts] ~= ".." then
|
|
parts[#parts] = nil
|
|
else
|
|
parts[#parts + 1] = ".."
|
|
end
|
|
elseif part ~= "." and part ~= "" then
|
|
parts[#parts + 1] = part
|
|
end
|
|
end
|
|
return table.concat(parts, "/")
|
|
end
|
|
|
|
local function dirname(path)
|
|
return path:match("^(.*)/[^/]+$") or ""
|
|
end
|
|
|
|
-- .png (a LUT) is a real dependency but never itself a source of references.
|
|
local TEXT_EXT = { slang = true, slangp = true, inc = true, h = true }
|
|
local REF_EXT = { slang = true, slangp = true, inc = true, h = true, png = true }
|
|
|
|
-- Quoted assignments are matched first: the closing quote is an unambiguous
|
|
-- delimiter, so a path with spaces or parens survives (real packs ship them).
|
|
local function extractRefs(text)
|
|
local refs, seen = {}, {}
|
|
local function addPathRef(path)
|
|
local ext = path:match("%.(%a+)$")
|
|
if ext and REF_EXT[ext:lower()] and not seen[path] then
|
|
seen[path] = true
|
|
refs[#refs + 1] = path
|
|
end
|
|
end
|
|
for line in text:gmatch("[^\r\n]+") do
|
|
local quoted = line:match('=%s*"([^"]+)"')
|
|
if quoted then
|
|
addPathRef(quoted)
|
|
else
|
|
local bare = line:match('=%s*([%.%w/_%-]+)')
|
|
if bare then addPathRef(bare) end
|
|
end
|
|
end
|
|
for path in text:gmatch('#include%s+"([^"]+)"') do
|
|
if not seen[path] then seen[path] = true; refs[#refs + 1] = path end
|
|
end
|
|
for path in text:gmatch('#reference%s+"([^"]+)"') do
|
|
if not seen[path] then seen[path] = true; refs[#refs + 1] = path end
|
|
end
|
|
return refs
|
|
end
|
|
|
|
local function findAll(dir, pattern)
|
|
local out = {}
|
|
local function scan(rel)
|
|
for _, name in ipairs(love.filesystem.getDirectoryItems(rel)) do
|
|
local sub = rel .. "/" .. name
|
|
local info = love.filesystem.getInfo(sub)
|
|
if info and info.type == "directory" then
|
|
scan(sub)
|
|
elseif name:match(pattern) then
|
|
out[#out + 1] = sub
|
|
end
|
|
end
|
|
end
|
|
scan(dir)
|
|
return out
|
|
end
|
|
|
|
-- Curated shortlist of handheld/'s own 78 presets -- a temporary trim, expect
|
|
-- this list to change (see docs/shaderfx.md).
|
|
local KEPT_PRESETS = {
|
|
["gameboy.slangp"] = true,
|
|
["gameboy-advance-dot-matrix.slangp"] = true,
|
|
["gameboy-color-dot-matrix.slangp"] = true,
|
|
["gameboy-color-dot-matrix-white-bg.slangp"] = true,
|
|
["gameboy-dark-mode.slangp"] = true,
|
|
["gameboy-light.slangp"] = true,
|
|
["gameboy-light-mode.slangp"] = true,
|
|
["gameboy-pocket.slangp"] = true,
|
|
["gameboy-pocket-high-contrast.slangp"] = true,
|
|
["gb-palette-dmg.slangp"] = true,
|
|
["gb-palette-light.slangp"] = true,
|
|
["gb-palette-pocket.slangp"] = true,
|
|
["sameboy-lcd.slangp"] = true,
|
|
["dot.slangp"] = true,
|
|
["lcd1x.slangp"] = true,
|
|
["lcd1x_nds.slangp"] = true,
|
|
["lcd1x_psp.slangp"] = true,
|
|
["lcd3x.slangp"] = true,
|
|
["simpletex_lcd.slangp"] = true,
|
|
["simpletex_lcd_720p.slangp"] = true,
|
|
["simpletex_lcd-4k.slangp"] = true,
|
|
["zfast-lcd.slangp"] = true,
|
|
["bevel.slangp"] = true,
|
|
["sunlight_shimmer.slangp"] = true,
|
|
["pixel_transparency.slangp"] = true,
|
|
["retro-v3.slangp"] = true,
|
|
}
|
|
|
|
-- BFS over the real file-level dependency closure of every KEPT_PRESETS entry
|
|
-- inside the mounted zip, copied preserving each file's zip-relative path.
|
|
local function extractClosure(mountRoot)
|
|
local queue = {}
|
|
for _, s in ipairs(findAll(mountRoot .. "/handheld", "%.slangp$")) do
|
|
if KEPT_PRESETS[s:match("([^/]+)$")] then queue[#queue + 1] = s end
|
|
end
|
|
local closure = {}
|
|
for _, s in ipairs(queue) do closure[s] = true end
|
|
|
|
local head = 1
|
|
while queue[head] do
|
|
local rel = queue[head]
|
|
head = head + 1
|
|
local zipRel = rel:sub(#mountRoot + 2) -- strip "<mountRoot>/"
|
|
local ext = zipRel:match("%.(%a+)$")
|
|
if ext and TEXT_EXT[ext:lower()] then
|
|
local text = love.filesystem.read(rel)
|
|
if text then
|
|
local base = dirname(zipRel)
|
|
for _, ref in ipairs(extractRefs(text)) do
|
|
local resolved = normalizePath(base == "" and ref or (base .. "/" .. ref))
|
|
local mounted = mountRoot .. "/" .. resolved
|
|
if not closure[mounted] and love.filesystem.getInfo(mounted) then
|
|
closure[mounted] = true
|
|
queue[#queue + 1] = mounted
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- love.filesystem.write does not create intermediate directories.
|
|
local madeDirs = {}
|
|
local function ensureDir(destPath)
|
|
local dir = dirname(destPath)
|
|
if dir == "" or madeDirs[dir] then return end
|
|
madeDirs[dir] = true
|
|
love.filesystem.createDirectory(dir)
|
|
end
|
|
|
|
local copied = 0
|
|
for rel in pairs(closure) do
|
|
local destPath = "shaders/" .. rel:sub(#mountRoot + 2)
|
|
local bytes = love.filesystem.read(rel)
|
|
if bytes then
|
|
ensureDir(destPath)
|
|
if love.filesystem.write(destPath, bytes) then copied = copied + 1 end
|
|
end
|
|
end
|
|
return copied
|
|
end
|
|
|
|
-- Returns the number of files copied, or nil + an error string; a third
|
|
-- `unchanged` return means the ETag matched and no zip was ever written.
|
|
function ShaderFX.installDownloaded(notModified)
|
|
if notModified then
|
|
return 0, nil, true
|
|
end
|
|
love.filesystem.createDirectory("shaders")
|
|
if not love.filesystem.mount(DOWNLOAD_ZIP_REL, DOWNLOAD_MOUNT) then
|
|
return nil, "could not open the downloaded archive"
|
|
end
|
|
local ok, copiedOrErr = pcall(extractClosure, DOWNLOAD_MOUNT)
|
|
-- unmount() takes the ARCHIVE path passed to mount(), not the mountpoint.
|
|
love.filesystem.unmount(DOWNLOAD_ZIP_REL)
|
|
love.filesystem.remove(DOWNLOAD_ZIP_REL)
|
|
if not ok then return nil, tostring(copiedOrErr) end
|
|
return copiedOrErr
|
|
end
|
|
|
|
-- ------- the native bridge (translation only, called by convert())
|
|
|
|
-- Where the vendored Rust bridge crate builds to, relative to the source dir.
|
|
ShaderFX.BRIDGE_DIR = "tools/shaderfx-bridge"
|
|
|
|
-- Candidate names per OS, same shape as src/net/Gen1Tls.lua's libNames.
|
|
local function libNames()
|
|
local osName = (love and love.system and love.system.getOS
|
|
and love.system.getOS()) or ""
|
|
if osName == "Windows" then
|
|
return { "librashader_bridge.dll" }
|
|
elseif osName == "OS X" then
|
|
return { "liblibrashader_bridge.dylib", "librashader_bridge.dylib" }
|
|
end
|
|
-- Android resolves the bare name through jniLibs, so try it last.
|
|
return { "liblibrashader_bridge.so", "librashader_bridge.so" }
|
|
end
|
|
|
|
-- getSource is the game directory (where the vendored crate sits); the base
|
|
-- directory is its parent (where a packaged build puts the library).
|
|
local function sourceDirs()
|
|
local out, seen = {}, {}
|
|
local function add(dir)
|
|
if type(dir) == "string" and dir ~= "" and not seen[dir] then
|
|
seen[dir] = true
|
|
out[#out + 1] = dir
|
|
end
|
|
end
|
|
local fs = love and love.filesystem
|
|
if fs then
|
|
if fs.getSource then add(fs.getSource()) end
|
|
if fs.getSourceBaseDirectory then add(fs.getSourceBaseDirectory()) end
|
|
if fs.getWorkingDirectory then add(fs.getWorkingDirectory()) end
|
|
end
|
|
if type(arg) == "table" and type(arg[0]) == "string" then
|
|
add(arg[0]:match("^(.*)[/\\]"))
|
|
end
|
|
add(".")
|
|
return out
|
|
end
|
|
|
|
local function saveDir()
|
|
if love and love.filesystem and love.filesystem.getSaveDirectory then
|
|
local dir = love.filesystem.getSaveDirectory()
|
|
if type(dir) == "string" and dir ~= "" then return dir end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function fileReadable(path)
|
|
local f = io.open(path, "rb")
|
|
if not f then return false end
|
|
f:close()
|
|
return true
|
|
end
|
|
|
|
-- Every place the bridge may sit, most specific first.
|
|
local function libCandidates()
|
|
local out = {}
|
|
local override = os.getenv("LIBRASHADER_BRIDGE_DLL")
|
|
if override and override ~= "" then out[#out + 1] = override end
|
|
local dirs, save = sourceDirs(), saveDir()
|
|
for _, name in ipairs(libNames()) do
|
|
for _, dir in ipairs(dirs) do
|
|
out[#out + 1] = dir .. "/" .. name
|
|
out[#out + 1] = dir .. "/" .. ShaderFX.BRIDGE_DIR .. "/target/release/" .. name
|
|
end
|
|
if save then out[#out + 1] = save .. "/" .. name end
|
|
out[#out + 1] = name
|
|
end
|
|
return out
|
|
end
|
|
|
|
local lib
|
|
local libError
|
|
|
|
local function ensureLib()
|
|
if lib then return lib end
|
|
if libError then return nil, libError end
|
|
local okFfi, ffi = pcall(require, "ffi")
|
|
if not okFfi or type(ffi) ~= "table" then
|
|
libError = "this build has no ffi, so presets cannot be converted here"
|
|
return nil, libError
|
|
end
|
|
pcall(ffi.cdef, [[
|
|
char* librashader_translate_preset(const char* preset_path, int es);
|
|
void librashader_free_string(char* s);
|
|
]])
|
|
local tried = {}
|
|
for _, path in ipairs(libCandidates()) do
|
|
-- A bare name goes to the system loader; a path only when a file is there.
|
|
local bare = not path:find("[/\\]")
|
|
if bare or fileReadable(path) then
|
|
local ok, loaded = pcall(ffi.load, path)
|
|
if ok then lib = loaded; return lib end
|
|
end
|
|
tried[#tried + 1] = path
|
|
end
|
|
libError = "librashader bridge not found; looked in " .. table.concat(tried, ", ")
|
|
return nil, libError
|
|
end
|
|
|
|
-- True when a preset can be converted on this machine. Activating an
|
|
-- already-converted preset never needs the bridge.
|
|
function ShaderFX.canConvert()
|
|
local l = ensureLib()
|
|
return l ~= nil
|
|
end
|
|
|
|
function ShaderFX.bridgeError()
|
|
return libError
|
|
end
|
|
|
|
-- `es`: true for GLSL ES 1.00 (mobile), false for GLSL 1.20 (desktop). Only
|
|
-- ever called from ShaderFX.convert().
|
|
function ShaderFX.translate(fullPath, es)
|
|
local ffi = require("ffi")
|
|
local ok, l = pcall(ensureLib)
|
|
if not ok then return nil, "ffi.load failed: " .. tostring(l) end
|
|
local ptr = l.librashader_translate_preset(fullPath, es and 1 or 0)
|
|
if ptr == nil then return nil, "librashader_translate_preset returned NULL" end
|
|
local json = ffi.string(ptr)
|
|
l.librashader_free_string(ptr)
|
|
local preset, decodeErr = Json.decode(json)
|
|
if not preset then return nil, "JSON decode failed: " .. tostring(decodeErr) end
|
|
if preset.error then return nil, "translate_preset reported an error: " .. tostring(preset.error) end
|
|
if preset.pass_count ~= #preset.passes then
|
|
return nil, ("pass_count metadata (%s) disagrees with passes array length (%d)")
|
|
:format(tostring(preset.pass_count), #preset.passes)
|
|
end
|
|
return preset
|
|
end
|
|
|
|
-- love.graphics.newImage cannot open an absolute path outside LOVE's own
|
|
-- mounts, which is where a preset's LUTs live, so read the bytes by hand.
|
|
function ShaderFX.loadImageFromPath(path)
|
|
local f, err = io.open(path, "rb")
|
|
if not f then return nil, "io.open failed: " .. tostring(err) end
|
|
local bytes = f:read("*a")
|
|
f:close()
|
|
local byteData = love.data.newByteData(bytes)
|
|
local imgData = love.image.newImageData(byteData)
|
|
return love.graphics.newImage(imgData)
|
|
end
|
|
|
|
-- librashader's wrap_mode names are not LOVE's; ES3-legal set only.
|
|
local function loveWrapMode(w)
|
|
if w == "clamp_to_border" then return "clampzero"
|
|
elseif w == "clamp_to_edge" then return "clamp"
|
|
elseif w == "repeat" then return "repeat"
|
|
elseif w == "mirrored_repeat" then return "mirroredrepeat"
|
|
else error("loveWrapMode: unknown wrap_mode " .. tostring(w)) end
|
|
end
|
|
|
|
-- Called once per activate(). A LUT that fails to load is logged and left nil;
|
|
-- runPass's own sampler assert is the fail-loud point, not this.
|
|
local function loadLuts(preset)
|
|
local byName = {}
|
|
for _, t in ipairs(preset.textures) do
|
|
local img, err = ShaderFX.loadImageFromPath(t.path)
|
|
if img then
|
|
img:setFilter(t.filter_mode, t.filter_mode)
|
|
img:setWrap(loveWrapMode(t.wrap_mode))
|
|
else
|
|
require("src.core.Logger").error("ShaderFX: LUT %s (%s) failed to load: %s",
|
|
t.name, tostring(t.path), tostring(err))
|
|
end
|
|
byName[t.name] = img
|
|
end
|
|
return byName
|
|
end
|
|
|
|
-- ------- the pass-graph runtime
|
|
|
|
local Fixup = require("src.render.ShaderFixup")
|
|
local Sensors = require("src.core.Sensors")
|
|
local ShaderSourcePatches = require("src.render.ShaderSourcePatches")
|
|
|
|
-- Glsl100Es (mobile) vs Glsl120 (desktop). Convert and render always run on
|
|
-- the same device, so the dialect emitted and the one validated always agree.
|
|
local function defaultEs()
|
|
if not love or not love.system or not love.system.getOS then return false end
|
|
local osName = love.system.getOS()
|
|
return osName == "Android" or osName == "iOS"
|
|
end
|
|
|
|
local function roundDim(x) return math.max(1, math.floor(x + 0.5)) end
|
|
|
|
local function resolveScale(scaling, viewportDim, inputDim, originalDim)
|
|
local st = scaling.scale_type
|
|
if st == "absolute" then return roundDim(scaling.factor)
|
|
elseif st == "viewport" then return roundDim(viewportDim * scaling.factor)
|
|
elseif st == "source" then return roundDim(inputDim * scaling.factor)
|
|
elseif st == "original" then return roundDim(originalDim * scaling.factor)
|
|
else error("resolveScale: unknown scale_type " .. tostring(st)) end
|
|
end
|
|
|
|
local function sizeVec(dims) return { dims.w, dims.h, 1 / dims.w, 1 / dims.h } end
|
|
|
|
-- One instance per loaded preset, never module-global, so switching presets
|
|
-- cannot leak a previous preset's canvases or dims.
|
|
local function newChainState(preset)
|
|
local state = { preset = preset, outputDims = {}, ALL_DEFAULTS = {},
|
|
shaderCache = {}, canvasCache = {} }
|
|
for _, pass in ipairs(preset.passes) do
|
|
for _, p in ipairs(pass.parameters) do state.ALL_DEFAULTS[p.id] = p.initial end
|
|
end
|
|
for _, o in ipairs(preset.parameter_overrides) do state.ALL_DEFAULTS[o.name] = o.value end
|
|
return state
|
|
end
|
|
|
|
local function passInputDims(state, i, original)
|
|
return (i == 0) and original or assert(state.outputDims[i - 1],
|
|
("pass%d: input dims requested before pass%d ran"):format(i, i - 1))
|
|
end
|
|
|
|
local function computePassDims(state, i, pass, viewport, original)
|
|
local inputDims = passInputDims(state, i, original)
|
|
return {
|
|
w = resolveScale(pass.scale_x, viewport.w, inputDims.w, original.w),
|
|
h = resolveScale(pass.scale_y, viewport.h, inputDims.h, original.h),
|
|
}
|
|
end
|
|
|
|
-- love.sensor.SensorType each built-in motion semantic reads from.
|
|
-- AccelerometerRest is librashader's calibration reference, not a real kind.
|
|
local SENSOR_KIND = { Accelerometer = "accelerometer", Gyroscope = "gyroscope" }
|
|
|
|
-- Yaw integration: deliberately a decaying spring, not a true heading --
|
|
-- gyro-only integration drifts without a magnetometer. Tuned on-device.
|
|
local YAW_SHADER_NAME = "sunlight_shimmer.slangp" -- the only twist-reactive preset
|
|
local YAW_GAIN = 0.6
|
|
local YAW_TWIST_CLAMP = 2
|
|
local YAW_DECAY_PER_SEC = 2.0 -- higher = snaps back to neutral faster
|
|
|
|
-- Throttled on-device diagnostic (~1/sec per slot), tagged for logcat.
|
|
local debugLastLog = {}
|
|
local function debugThrottle(key)
|
|
local now = love.timer and love.timer.getTime() or os.clock()
|
|
if not debugLastLog[key] or now - debugLastLog[key] >= 1.0 then
|
|
debugLastLog[key] = now
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function updateYawTwist(state, dt)
|
|
if not state or state.entryName ~= YAW_SHADER_NAME then return end
|
|
local gx, yawRate, gz = Sensors.read("gyroscope")
|
|
local twist = (state.yawTwist or 0) + yawRate * dt * YAW_GAIN
|
|
twist = twist * math.exp(-YAW_DECAY_PER_SEC * dt)
|
|
twist = math.max(-YAW_TWIST_CLAMP, math.min(YAW_TWIST_CLAMP, twist))
|
|
state.yawTwist = twist
|
|
if debugThrottle("yaw:" .. tostring(state.entryName)) then
|
|
require("src.core.Logger").info(
|
|
"[DEBUG-tfxsns2] yaw entry=%s gyro=(%.3f,%.3f,%.3f) dt=%.4f twist=%.4f",
|
|
tostring(state.entryName), gx, yawRate, gz, dt, twist)
|
|
end
|
|
end
|
|
|
|
local function sizeTable(state, i, dims, sizeUniforms, viewport, original)
|
|
local values = {
|
|
OutputSize = sizeVec(dims),
|
|
SourceSize = sizeVec(passInputDims(state, i, original)),
|
|
OriginalSize = sizeVec(original),
|
|
}
|
|
for _, entry in ipairs(sizeUniforms) do
|
|
if entry.kind == "unique" and entry.semantic == "Output" then
|
|
values[entry.name] = sizeVec(dims)
|
|
elseif entry.kind == "unique" and SENSOR_KIND[entry.semantic] then
|
|
local x, y, z = Sensors.read(SENSOR_KIND[entry.semantic])
|
|
local dbgRawX, dbgRawY, dbgRawZ = x, y, z
|
|
if entry.semantic == "Accelerometer" then
|
|
-- Calibrate against the pose captured at activate(), then swap gravity
|
|
-- onto the axis getOrientedTilt ignores (this engine rests upright).
|
|
if state.accelRest then
|
|
x = x - state.accelRest[1]
|
|
y = y - state.accelRest[2]
|
|
z = z - state.accelRest[3]
|
|
end
|
|
y, z = z, y
|
|
-- Keep getOrientedTilt's normalize denominator stable, but only against
|
|
-- a live reading: an all-zero raw read is Sensors.lua's no-hardware sentinel.
|
|
if state.accelRest and (dbgRawX ~= 0 or dbgRawY ~= 0 or dbgRawZ ~= 0) then
|
|
z = 9.8
|
|
end
|
|
if state.entryName == YAW_SHADER_NAME and state.yawTwist then
|
|
-- The only already-compiled channel this preset's stock math reads.
|
|
x = x + state.yawTwist
|
|
end
|
|
end
|
|
values[entry.name] = { x, y, z }
|
|
if entry.semantic == "Accelerometer" then
|
|
-- Test seam: the values that actually reached the packed uniform.
|
|
state.lastAccelPacked = { x, y, z }
|
|
if debugThrottle("accel:" .. tostring(state.entryName)) then
|
|
local d = state.ALL_DEFAULTS or {}
|
|
require("src.core.Logger").info(
|
|
"[DEBUG-tfxsns2] accel entry=%s raw=(%.3f,%.3f,%.3f) packed=(%.3f,%.3f,%.3f) mag=%.3f twist=%s " ..
|
|
"ACCEL_ENABLE=%s SHADOW_MOTION=%s YAW_ENABLE=%s SENSITIVITY=%s",
|
|
tostring(state.entryName), dbgRawX, dbgRawY, dbgRawZ, x, y, z,
|
|
math.sqrt(x * x + y * y + z * z), tostring(state.yawTwist),
|
|
tostring(d.PT_ACCEL_ENABLE), tostring(d.PT_SHADOW_MOTION),
|
|
tostring(d.PT_YAW_ENABLE), tostring(d.PT_ACCEL_SENSITIVITY))
|
|
end
|
|
end
|
|
elseif entry.kind == "unique" and entry.semantic == "AccelerometerRest" then
|
|
values[entry.name] = { 0, 0, 0 }
|
|
elseif entry.kind == "unique" and entry.semantic == "Rotation" then
|
|
-- retroarch_get_rotation(): the CONTENT's rotation, not the device's.
|
|
-- Nothing here rotates GB content, so 0 is correct, not a placeholder.
|
|
values[entry.name] = 0
|
|
elseif entry.kind == "texture" then
|
|
if entry.semantic == "Original" then
|
|
values[entry.name] = sizeVec(original)
|
|
elseif entry.semantic == "Source" then
|
|
values[entry.name] = sizeVec(passInputDims(state, i, original))
|
|
elseif entry.semantic == "OriginalHistory" then
|
|
-- Steady-state fallback: every history slot reads the current frame.
|
|
values[entry.name] = sizeVec(original)
|
|
elseif entry.semantic == "PassOutput" then
|
|
values[entry.name] = sizeVec(assert(state.outputDims[entry.index],
|
|
("pass%d: %s references pass%d's output, not yet computed")
|
|
:format(i, entry.name, entry.index)))
|
|
elseif entry.semantic == "PassFeedback" or entry.semantic == "User" then
|
|
error(("pass%d: %s is a %s size uniform -- not yet supported")
|
|
:format(i, entry.name, entry.semantic))
|
|
end
|
|
end
|
|
end
|
|
return values
|
|
end
|
|
|
|
-- Runs pass `i`, drawing `srcImg` into a freshly sized canvas. `lutByName`
|
|
-- resolves a User-semantic sampler by the name the translation reported.
|
|
local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport, original)
|
|
local dims = computePassDims(state, i, pass, viewport, original)
|
|
state.outputDims[i] = dims
|
|
|
|
-- Compiled once per (state, pass): a pass's GLSL and manifest depend only on
|
|
-- the preset, never on per-frame input. fragManifest is cached alongside it.
|
|
local cached = state.shaderCache[i]
|
|
local shader, fragManifest
|
|
if cached then
|
|
shader, fragManifest = cached.shader, cached.fragManifest
|
|
else
|
|
local fixedFragBody
|
|
fixedFragBody, fragManifest = Fixup.fragment(pass.fragment)
|
|
local fixedVert, vertManifest = Fixup.vertex(pass.vertex)
|
|
assert(#fragManifest == #vertManifest, ("pass%d: PUSH struct member count differs"):format(i))
|
|
|
|
local validateErrs = {}
|
|
local isEs = defaultEs()
|
|
for headIdx, head in ipairs(Fixup.PREC_HEADS) do
|
|
local fixedFrag = head .. fixedFragBody
|
|
local okShader, err = love.graphics.validateShader(isEs, fixedFrag, fixedVert)
|
|
if okShader then
|
|
shader = love.graphics.newShader(fixedFrag, fixedVert)
|
|
break
|
|
else
|
|
validateErrs[#validateErrs + 1] = ("variant %d: %s"):format(headIdx, tostring(err))
|
|
end
|
|
end
|
|
if not shader then
|
|
require("src.core.Logger").error("ShaderFX: pass%d shader failed to validate -- %s",
|
|
i, table.concat(validateErrs, " | "))
|
|
end
|
|
assert(shader, ("pass%d: no PREC_HEADS variant validated (%s)")
|
|
:format(i, table.concat(validateErrs, " | ")))
|
|
state.shaderCache[i] = { shader = shader, fragManifest = fragManifest }
|
|
end
|
|
|
|
for _, s in ipairs(pass.samplers) do
|
|
local img
|
|
if s.semantic == "Source" then img = (i == 0) and frameSource or outputs[i - 1]
|
|
elseif s.semantic == "Original" then img = frameSource
|
|
elseif s.semantic == "OriginalHistory" then img = frameSource
|
|
elseif s.semantic == "PassOutput" then img = outputs[s.index]
|
|
elseif s.semantic == "User" then img = lutByName and lutByName[s.user_name] end
|
|
assert(img, ("pass%d: no binding resolved for sampler %s (semantic=%s)")
|
|
:format(i, s.name, tostring(s.semantic)))
|
|
pcall(shader.send, shader, "LIBRA_TEXTURE_" .. s.name, img)
|
|
end
|
|
|
|
local values = sizeTable(state, i, dims, pass.size_uniforms, viewport, original)
|
|
for name, value in pairs(state.ALL_DEFAULTS) do values[name] = value end
|
|
local packed = Fixup.packValues(fragManifest, values)
|
|
for name, value in pairs(packed) do pcall(shader.send, shader, name, value) end
|
|
|
|
local srcImg = (i == 0) and frameSource or outputs[i - 1]
|
|
local srcDims = passInputDims(state, i, original)
|
|
local drawScaleX, drawScaleY = dims.w / srcDims.w, dims.h / srcDims.h
|
|
|
|
-- Reallocated only when this pass's resolved size actually changes.
|
|
local cc = state.canvasCache[i]
|
|
local canvas
|
|
if cc and cc.w == dims.w and cc.h == dims.h then
|
|
canvas = cc.canvas
|
|
else
|
|
canvas = love.graphics.newCanvas(dims.w, dims.h)
|
|
canvas:setFilter("nearest", "nearest")
|
|
state.canvasCache[i] = { canvas = canvas, w = dims.w, h = dims.h }
|
|
end
|
|
|
|
love.graphics.push("all")
|
|
love.graphics.setCanvas(canvas)
|
|
love.graphics.clear(0, 0, 0, 0)
|
|
love.graphics.setShader(shader)
|
|
if i == state.preset.pass_count - 1 then
|
|
love.graphics.setBlendMode("replace", "premultiplied")
|
|
else
|
|
love.graphics.setBlendMode("replace")
|
|
end
|
|
love.graphics.draw(srcImg, 0, 0, 0, drawScaleX, drawScaleY)
|
|
love.graphics.pop()
|
|
|
|
outputs[i] = canvas
|
|
end
|
|
|
|
-- Runs the whole chain once over `frameSource` (sized `original`), with
|
|
-- `viewport` the size "viewport" scale_type passes resolve against.
|
|
function ShaderFX.runChain(state, frameSource, lutByName, viewport, original)
|
|
local outputs = {}
|
|
state.outputDims = {}
|
|
for i = 0, state.preset.pass_count - 1 do
|
|
runPass(state, i, state.preset.passes[i + 1], outputs, frameSource, lutByName, viewport, original)
|
|
end
|
|
return outputs[state.preset.pass_count - 1]
|
|
end
|
|
|
|
-- ------- public, minimal load/render entry points
|
|
|
|
-- Loads one entry from its cached Lua artifact; never calls the bridge.
|
|
-- Returns a chain state, or nil + an error when it has not been converted.
|
|
function ShaderFX.load(entry)
|
|
local path = ShaderFX.artifactPath(entry)
|
|
local chunk, err = loadfile(path)
|
|
if not chunk then
|
|
return nil, "no cached artifact, convert this preset first: " .. tostring(err)
|
|
end
|
|
local ok, preset = pcall(chunk)
|
|
if not ok then return nil, "cached artifact failed to run: " .. tostring(preset) end
|
|
if type(preset) ~= "table" then return nil, "cached artifact did not return a table" end
|
|
local state = newChainState(preset)
|
|
state.entryName = entry.name -- see updateYawTwist/sizeTable's Accelerometer branch
|
|
return state
|
|
end
|
|
|
|
-- Every #pragma parameter the entry's artifact declares, deduped by id, each
|
|
-- with its currently-effective default. Reads the artifact only.
|
|
function ShaderFX.listParams(entry)
|
|
local state, err = ShaderFX.load(entry)
|
|
if not state then return nil, err end
|
|
local order, byId = {}, {}
|
|
for _, pass in ipairs(state.preset.passes) do
|
|
for _, p in ipairs(pass.parameters) do
|
|
if not byId[p.id] then
|
|
byId[p.id] = p
|
|
order[#order + 1] = p.id
|
|
end
|
|
end
|
|
end
|
|
local out = {}
|
|
for _, id in ipairs(order) do
|
|
local p = byId[id]
|
|
-- "!"-prefixed descriptions are engine-owned, not a player preference, so
|
|
-- they never become an editable row. No preset uses this today.
|
|
if not (p.description and p.description:sub(1, 1) == "!") then
|
|
out[#out + 1] = {
|
|
id = p.id, description = p.description,
|
|
minimum = p.minimum, maximum = p.maximum, step = p.step,
|
|
initial = state.ALL_DEFAULTS[p.id],
|
|
}
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- ------- active-preset tracking and the real endFrame render entry point
|
|
|
|
-- ------- AOT convert: the one function that calls the bridge, writing its
|
|
-- decoded result to disk as a plain Lua artifact.
|
|
|
|
-- #t counts a trailing nil as absent, so walk every key instead.
|
|
local function arrayLen(t)
|
|
local n = 0
|
|
for _ in pairs(t) do n = n + 1 end
|
|
for i = 1, n do
|
|
if t[i] == nil then return nil end
|
|
end
|
|
return n
|
|
end
|
|
|
|
-- Serializes the string/number/boolean/nested-table shape Json.decode
|
|
-- produces; array-shaped tables (arrayLen) serialize positionally.
|
|
local function serializeLua(v, buf)
|
|
local t = type(v)
|
|
if t == "string" then
|
|
buf[#buf + 1] = string.format("%q", v)
|
|
elseif t == "number" or t == "boolean" then
|
|
buf[#buf + 1] = tostring(v)
|
|
elseif t == "table" then
|
|
buf[#buf + 1] = "{"
|
|
local n = arrayLen(v)
|
|
if n then
|
|
for i = 1, n do
|
|
serializeLua(v[i], buf)
|
|
buf[#buf + 1] = ","
|
|
end
|
|
else
|
|
for k, item in pairs(v) do
|
|
assert(type(k) == "string", "serializeLua: non-string, non-array-index table key")
|
|
buf[#buf + 1] = "[" .. string.format("%q", k) .. "]="
|
|
serializeLua(item, buf)
|
|
buf[#buf + 1] = ","
|
|
end
|
|
end
|
|
buf[#buf + 1] = "}"
|
|
else
|
|
error("serializeLua: unsupported value type " .. t)
|
|
end
|
|
end
|
|
|
|
-- Translates `entry` via the bridge and writes ShaderFX.artifactPath(entry).
|
|
-- Sets entry.converted on success; an existing artifact survives a failure.
|
|
function ShaderFX.convert(entry, es)
|
|
ShaderSourcePatches.apply(entry)
|
|
local preset, err = ShaderFX.translate(entry.fullPath, es == nil and defaultEs() or es)
|
|
if not preset then return false, err end
|
|
local buf = {}
|
|
serializeLua(preset, buf)
|
|
local path = ShaderFX.artifactPath(entry)
|
|
local f, ferr = io.open(path, "wb")
|
|
if not f then return false, "io.open failed: " .. tostring(ferr) end
|
|
f:write("return ")
|
|
f:write(table.concat(buf))
|
|
f:write("\n")
|
|
f:close()
|
|
entry.converted = true
|
|
return true
|
|
end
|
|
|
|
-- Two independent slots. "shaderfx" stays main's option key, so existing
|
|
-- saves keep meaning what they already meant.
|
|
ShaderFX.SLOTS = { "main", "secondary" }
|
|
ShaderFX.OPTION_KEY = { main = "shaderfx", secondary = "shaderfxSecondary" }
|
|
|
|
local slots = { main = {}, secondary = {} } -- slots[s] = { state=, entry= }
|
|
local autoActivateTried = false -- see applyOptions()/tryAutoActivateFromEnv() below
|
|
|
|
-- Loads `entry`'s cached artifact into `slot` and makes it that slot's active
|
|
-- preset. `paramOverrides` layers a player's pragma edits over ALL_DEFAULTS.
|
|
function ShaderFX.activate(slot, entry, paramOverrides)
|
|
assert(slots[slot], "ShaderFX.activate: unknown slot " .. tostring(slot))
|
|
local state, err = ShaderFX.load(entry)
|
|
if not state then return false, err end
|
|
if paramOverrides then
|
|
for id, value in pairs(paramOverrides) do state.ALL_DEFAULTS[id] = value end
|
|
end
|
|
state.luts = loadLuts(state.preset)
|
|
-- Snapshot whatever pose the player is actually holding right now as neutral,
|
|
-- subtracted from every later reading. Recalibrates on every activate().
|
|
state.accelRest = { Sensors.read("accelerometer") }
|
|
require("src.core.Logger").info(
|
|
"[DEBUG-tfxsns2] activate entry=%s accelRest=(%.3f,%.3f,%.3f)",
|
|
tostring(entry and entry.name), state.accelRest[1], state.accelRest[2], state.accelRest[3])
|
|
slots[slot].state, slots[slot].entry = state, entry
|
|
return true
|
|
end
|
|
|
|
-- `slot` nil deactivates both.
|
|
function ShaderFX.deactivate(slot)
|
|
if slot then
|
|
slots[slot].state, slots[slot].entry = nil, nil
|
|
else
|
|
for _, s in pairs(slots) do s.state, s.entry = nil, nil end
|
|
end
|
|
end
|
|
|
|
-- `slot` nil answers "is anything active".
|
|
function ShaderFX.active(slot)
|
|
if slot then return slots[slot].state ~= nil end
|
|
return slots.main.state ~= nil or slots.secondary.state ~= nil
|
|
end
|
|
|
|
function ShaderFX.activeEntry(slot)
|
|
return slots[slot] and slots[slot].entry
|
|
end
|
|
|
|
-- Finds a ShaderFX.list() entry by its `name` field, or nil (e.g. the preset
|
|
-- was deleted from the drop-in folder since it was selected).
|
|
function ShaderFX.findEntry(name)
|
|
if not name or name == "" then return nil end
|
|
for _, entry in ipairs(ShaderFX.list()) do
|
|
if entry.name == name then return entry end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Under AUTO, a tier that caps shaderfx off would force-deactivate a persisted
|
|
-- preset choice every boot; pin "high" instead. Never touches an explicit
|
|
-- performance choice, and never un-escalates on deactivate.
|
|
local function escalatePerformanceIfNeeded(opts)
|
|
if not opts then return false end
|
|
local wantsShaderfx = false
|
|
for _, slot in ipairs(ShaderFX.SLOTS) do
|
|
local want = opts[ShaderFX.OPTION_KEY[slot]]
|
|
if want and want ~= "" then wantsShaderfx = true end
|
|
end
|
|
if not wantsShaderfx then return false end
|
|
if opts.performance ~= nil and opts.performance ~= "auto" then return false end
|
|
local Performance = require("src.core.Performance")
|
|
if Performance.caps("auto").shaderfx then return false end
|
|
opts.performance = "high"
|
|
return true
|
|
end
|
|
|
|
function ShaderFX.applyOptions(opts)
|
|
autoActivateTried = true
|
|
local cleared = false
|
|
for _, slot in ipairs(ShaderFX.SLOTS) do
|
|
local key = ShaderFX.OPTION_KEY[slot]
|
|
local want = opts and opts[key]
|
|
if not want or want == "" then
|
|
ShaderFX.deactivate(slot)
|
|
else
|
|
local entry = ShaderFX.findEntry(want)
|
|
if not entry then
|
|
if opts then opts[key] = nil end
|
|
ShaderFX.deactivate(slot)
|
|
cleared = true
|
|
else
|
|
-- isConverted() is existence-only with no staleness check, so a saved
|
|
-- choice reconverts here too -- at boot/options-save, never per frame.
|
|
local convOk, convErr = ShaderFX.convert(entry)
|
|
if not convOk then
|
|
require("src.core.Logger").error("ShaderFX.applyOptions: reconvert failed for %s (%s): %s",
|
|
want, slot, tostring(convErr))
|
|
end
|
|
local paramOverrides = opts.shaderfxParams and opts.shaderfxParams[entry.name]
|
|
local ok, err = ShaderFX.activate(slot, entry, paramOverrides)
|
|
if not ok then
|
|
require("src.core.Logger").error("ShaderFX.applyOptions: %s (%s) failed to activate: %s",
|
|
want, slot, tostring(err))
|
|
if opts then opts[key] = nil end
|
|
ShaderFX.deactivate(slot)
|
|
cleared = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if escalatePerformanceIfNeeded(opts) then cleared = true end
|
|
return cleared
|
|
end
|
|
|
|
-- Stand-in from before the real OPTIONS row existed; only still fires for
|
|
-- harnesses that call ShaderFX.render() without ever calling applyOptions().
|
|
local function tryAutoActivateFromEnv()
|
|
autoActivateTried = true
|
|
local want = os.getenv("POKEPORT_SHADERFX")
|
|
if not want or want == "" then return end
|
|
local entry = ShaderFX.findEntry(want)
|
|
if not entry then
|
|
require("src.core.Logger").error("ShaderFX: POKEPORT_SHADERFX=%s not found under %s",
|
|
want, tostring(ShaderFX.presetDir()))
|
|
return
|
|
end
|
|
local ok, err = ShaderFX.activate("main", entry)
|
|
if not ok then
|
|
require("src.core.Logger").error("ShaderFX: POKEPORT_SHADERFX=%s failed to activate: %s",
|
|
want, tostring(err))
|
|
end
|
|
end
|
|
|
|
-- Crops the playfield rect out of `canvas` at `renderScale`. The output canvas
|
|
-- is cached module-wide and reallocated only on a real size change.
|
|
local cropCanvasCache
|
|
local function cropToGbSource(canvas, rect, srcW, srcH, renderScale)
|
|
-- Cheap insurance against a read-after-write hazard between this draw and
|
|
-- whatever last rendered into `canvas`.
|
|
love.graphics.flushBatch()
|
|
local outW, outH = roundDim(srcW * renderScale), roundDim(srcH * renderScale)
|
|
local quad = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
|
|
canvas:getPixelWidth(), canvas:getPixelHeight())
|
|
local out
|
|
if cropCanvasCache and cropCanvasCache.w == outW and cropCanvasCache.h == outH then
|
|
out = cropCanvasCache.canvas
|
|
else
|
|
out = love.graphics.newCanvas(outW, outH)
|
|
out:setFilter("nearest", "nearest")
|
|
cropCanvasCache = { canvas = out, w = outW, h = outH }
|
|
end
|
|
local invScale = renderScale / rect.scale
|
|
love.graphics.push("all")
|
|
love.graphics.setCanvas(out)
|
|
love.graphics.clear(0, 0, 0, 0)
|
|
love.graphics.setBlendMode("replace")
|
|
-- push("all") saves the caller's draw color but does not reset it: a menu can
|
|
-- leave it black, which would multiply the whole crop to black.
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.draw(canvas, quad, 0, 0, 0, invScale, invScale)
|
|
love.graphics.pop()
|
|
return out
|
|
end
|
|
|
|
-- The tier's SHADER FX chain-resolution multiplier; 1.0 when the tier's cap is
|
|
-- not a number.
|
|
local function chainRenderScale()
|
|
local caps = Performance.CAPS[Performance.tier]
|
|
return (caps and tonumber(caps.shaderfx)) or 1.0
|
|
end
|
|
|
|
-- Renderer.lua's endFrame entry point. `canvas` is the finished window-size
|
|
-- composite; `rect` is this frame's real game rect in PHYSICAL framebuffer
|
|
-- pixels and `source` the content size it frames. See docs/shaderfx.md.
|
|
function ShaderFX.render(canvas, rect, source, dpiX, dpiY)
|
|
if not autoActivateTried then tryAutoActivateFromEnv() end
|
|
if not slots.main.state and not slots.secondary.state then
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.draw(canvas, 0, 0)
|
|
return
|
|
end
|
|
|
|
dpiX, dpiY = tonumber(dpiX) or 1, tonumber(dpiY) or 1
|
|
-- Same physical location as `rect`, in the logical units LOVE draws in.
|
|
local uRect = {
|
|
x = math.floor(rect.x / dpiX), y = math.floor(rect.y / dpiY),
|
|
w = math.floor(rect.w / dpiX), h = math.floor(rect.h / dpiY),
|
|
}
|
|
-- Runs the chain at a reduced internal resolution on tiers that ask for one.
|
|
local scale = chainRenderScale()
|
|
local viewport = { w = roundDim(uRect.w * scale), h = roundDim(uRect.h * scale) }
|
|
|
|
-- exposed for tests only (tests/drivers/gold_shaderfx_zoom_sizing_test.lua)
|
|
ShaderFX._lastRect, ShaderFX._lastSource = rect, source
|
|
|
|
local dt = love.timer and love.timer.getDelta() or 0
|
|
updateYawTwist(slots.main.state, dt)
|
|
updateYawTwist(slots.secondary.state, dt)
|
|
-- Test seam: the live per-frame integration, before it rides into the shader.
|
|
ShaderFX._lastYawTwist = {
|
|
main = slots.main.state and slots.main.state.yawTwist,
|
|
secondary = slots.secondary.state and slots.secondary.state.yawTwist,
|
|
}
|
|
|
|
local ok, chainOut, chainPreset = pcall(function()
|
|
local frameSource = cropToGbSource(canvas, rect, source.w, source.h, scale)
|
|
-- exposed for tests only (gold_shaderfx_menu_black_crop_test.lua)
|
|
ShaderFX._lastCrop = frameSource
|
|
local dims = { w = frameSource:getWidth(), h = frameSource:getHeight() }
|
|
local img, preset = frameSource, nil
|
|
if slots.main.state then
|
|
img = ShaderFX.runChain(slots.main.state, img, slots.main.state.luts, viewport, dims)
|
|
dims = { w = img:getWidth(), h = img:getHeight() }
|
|
preset = slots.main.state.preset
|
|
end
|
|
if slots.secondary.state then
|
|
img = ShaderFX.runChain(slots.secondary.state, img, slots.secondary.state.luts, viewport, dims)
|
|
dims = { w = img:getWidth(), h = img:getHeight() }
|
|
preset = slots.secondary.state.preset
|
|
end
|
|
return img, preset
|
|
end)
|
|
|
|
if not ok or not chainOut then
|
|
require("src.core.Logger").error(
|
|
"ShaderFX.render chain failed ok=%s chainOut=%s err=%s",
|
|
tostring(ok), tostring(chainOut), tostring(not ok and chainOut or nil))
|
|
end
|
|
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setBlendMode("replace")
|
|
love.graphics.draw(canvas, 0, 0)
|
|
if ok and chainOut then
|
|
-- A chain's final pass need not land on the viewport size (most presets end
|
|
-- at scale_type="source"), so stretch, using that pass's own filter.
|
|
local cw, ch = chainOut:getWidth(), chainOut:getHeight()
|
|
local lastPass = chainPreset.passes[chainPreset.pass_count]
|
|
local mode = (lastPass.filter == "linear") and "linear" or "nearest"
|
|
chainOut:setFilter(mode, mode)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setBlendMode("alpha")
|
|
love.graphics.draw(chainOut, uRect.x, uRect.y, 0, uRect.w / cw, uRect.h / ch)
|
|
elseif not ok then
|
|
require("src.core.Logger").error("ShaderFX.render: chain failed, showing unprocessed frame: %s",
|
|
tostring(chainOut))
|
|
end
|
|
love.graphics.setBlendMode("alpha")
|
|
-- Test seam; set only after the real chain ran.
|
|
ShaderFX._lastAccelPacked = {
|
|
main = slots.main.state and slots.main.state.lastAccelPacked,
|
|
secondary = slots.secondary.state and slots.secondary.state.lastAccelPacked,
|
|
}
|
|
end
|
|
|
|
return ShaderFX
|