mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 08:51:27 +02:00
470 lines
17 KiB
Lua
470 lines
17 KiB
Lua
-- Draws a 4-shade GB image through a GBC palette.
|
|
--
|
|
-- Every 2bpp sheet the importer writes is grayscale: shade 0 is white
|
|
-- (or transparent), shade 3 is black (ImageWriter.SHADES). A GBC game picks
|
|
-- four real colors per tile instead, so this recovers the shade index from the
|
|
-- red channel and substitutes the palette entry -- one shader for map tiles,
|
|
-- OW sprites, battle pics and menu chrome alike.
|
|
--
|
|
-- Shade recovery is exact rather than approximate: the source values are
|
|
-- 1, 2/3, 1/3, 0, so rounding (1 - r) * 3 lands on 0..3 with a half-step of
|
|
-- headroom either side, which survives texture filtering set to nearest.
|
|
--
|
|
-- Alpha passes through untouched, so a sheet written with transparent shade 0
|
|
-- (OW sprites, the font) keeps its cutout.
|
|
|
|
-- COLOR (the port's own display option, Gold's answer to the Gen 1 COLORS
|
|
-- row). Every colour in the Gen 2 port arrives here, because a CGB game's
|
|
-- colour IS its palettes -- so one substitution at this seam recolours the
|
|
-- whole game without a single screen knowing about it:
|
|
--
|
|
-- DMG the original grey Game Boy. Every palette collapses to the
|
|
-- four hardware shades, and the sheets that are drawn straight
|
|
-- (chrome, text) are already those shades, so the screen is
|
|
-- uniformly monochrome.
|
|
-- CLASSIC the DMG pea-soup green. Rendered as DMG and then run through
|
|
-- the green ramp as a final full-screen pass, which is what
|
|
-- GbcPalette.presentColors is for -- a shade map is exactly what
|
|
-- this shader already does, so it needs no second shader.
|
|
|
|
local GbcPalette = {}
|
|
|
|
GbcPalette.MODES = { "gbc", "dmg", "classic" }
|
|
GbcPalette.MODE_LABELS = { gbc = "GEN 2", dmg = "DMG", classic = "CLASSIC",
|
|
custom = "GBC" }
|
|
GbcPalette.mode = "gbc"
|
|
|
|
GbcPalette.CUSTOM_MODE = "custom"
|
|
|
|
-- rBGP's four shades as the hardware shows them.
|
|
local DMG_SHADES = {
|
|
{ 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 },
|
|
}
|
|
-- #9BBC0F / #8BAC0F / #306230 / #0F380F, the same ramp Gen 1's CLASSIC uses.
|
|
local CLASSIC_SHADES = {
|
|
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
|
|
}
|
|
|
|
local SHADER_SOURCE = [[
|
|
extern vec3 pal0;
|
|
extern vec3 pal1;
|
|
extern vec3 pal2;
|
|
extern vec3 pal3;
|
|
|
|
vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) {
|
|
vec4 px = Texel(tex, uv);
|
|
float shade = floor((1.0 - px.r) * 3.0 + 0.5);
|
|
vec3 rgb = pal0;
|
|
if (shade > 2.5) {
|
|
rgb = pal3;
|
|
} else if (shade > 1.5) {
|
|
rgb = pal2;
|
|
} else if (shade > 0.5) {
|
|
rgb = pal1;
|
|
}
|
|
return vec4(rgb, px.a) * tint;
|
|
}
|
|
]]
|
|
|
|
-- rBGP, the DMG background palette register, as a remap of an ALREADY DRAWN
|
|
-- texture.
|
|
--
|
|
-- A frame this port has finished drawing holds CGB colours and no shade index
|
|
-- any more, so the remap has to run backwards: match the pixel to the palette
|
|
-- entry that produced it, then substitute whatever the rBGP byte sends that
|
|
-- entry to. With one palette that is exact, because the four entries are the
|
|
-- only colours the texture can hold; a composited frame is only as exact as
|
|
-- GbcPalette.remapTable's dedupe (see the `ambiguous` count there).
|
|
--
|
|
-- `remapTol` is a SQUARED distance and it is what keeps this pass off pixels
|
|
-- that were never a palette colour -- a letterboxed border, a linear-filtered
|
|
-- resample -- rather than snapping them to the nearest entry. Palette colours
|
|
-- land on exact 1/255 steps through a nearest-filtered canvas, so the default
|
|
-- is a couple of steps of headroom and nothing like the gap between two
|
|
-- entries of the same palette.
|
|
local REMAP_SOURCE = [[
|
|
extern int remapCount;
|
|
extern float remapTol;
|
|
extern vec3 remapSrc[32];
|
|
extern vec3 remapDst[32];
|
|
|
|
vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) {
|
|
vec4 px = Texel(tex, uv);
|
|
vec3 mapped = px.rgb;
|
|
float best = remapTol;
|
|
for (int i = 0; i < 32; i++) {
|
|
if (i >= remapCount) { break; }
|
|
vec3 d = px.rgb - remapSrc[i];
|
|
float dist = dot(d, d);
|
|
if (dist < best) {
|
|
best = dist;
|
|
// Indexed by the LOOP variable and never by a value carried out of the
|
|
// loop: GLSL ES 1.00 only allows a constant-index-expression into a
|
|
// uniform array, which a loop counter is and `best`'s winner is not.
|
|
mapped = remapDst[i];
|
|
}
|
|
}
|
|
return vec4(mapped, px.a) * tint;
|
|
}
|
|
]]
|
|
|
|
-- The compiled-in array length above. A map's eight BG palettes are 32
|
|
-- colours before dedupe, which is the worst case this has to hold.
|
|
GbcPalette.REMAP_MAX = 32
|
|
-- Squared RGB distance, in 0..1 units: three 8-bit steps.
|
|
GbcPalette.REMAP_TOLERANCE = (3 / 255) ^ 2
|
|
|
|
local shader = nil
|
|
local failed = false
|
|
local remapShader = nil
|
|
local remapFailed = false
|
|
|
|
-- nil (and a one-shot warning) if shaders are unavailable, so callers can fall
|
|
-- back to the plain grayscale draw instead of crashing a whole boot.
|
|
function GbcPalette.shader()
|
|
if shader or failed then return shader end
|
|
if not (love and love.graphics and love.graphics.newShader) then
|
|
failed = true
|
|
return nil
|
|
end
|
|
local ok, result = pcall(love.graphics.newShader, SHADER_SOURCE)
|
|
if not ok then
|
|
failed = true
|
|
return nil
|
|
end
|
|
shader = result
|
|
return shader
|
|
end
|
|
|
|
-- The same contract as GbcPalette.shader for the backwards pass: nil rather
|
|
-- than an error, so a caller can fall back to its own approximation.
|
|
function GbcPalette.remapShader()
|
|
if remapShader or remapFailed then return remapShader end
|
|
if not (love and love.graphics and love.graphics.newShader) then
|
|
remapFailed = true
|
|
return nil
|
|
end
|
|
local ok, result = pcall(love.graphics.newShader, REMAP_SOURCE)
|
|
if not ok then
|
|
remapFailed = true
|
|
return nil
|
|
end
|
|
remapShader = result
|
|
return remapShader
|
|
end
|
|
|
|
function GbcPalette.available()
|
|
return GbcPalette.shader() ~= nil
|
|
end
|
|
|
|
local function channel(colors, index)
|
|
local c = colors and colors[index]
|
|
if not c then return 0, 0, 0 end
|
|
return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255
|
|
end
|
|
|
|
GbcPalette.customRamp = nil
|
|
|
|
function GbcPalette.setCustomRamp(ramp)
|
|
local prev = GbcPalette.customRamp
|
|
GbcPalette.customRamp = ramp
|
|
if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then
|
|
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
|
|
end
|
|
end
|
|
|
|
-- What a palette actually draws as under the current COLOR mode. Anything
|
|
-- that reads a colour out of a palette directly -- a canvas cleared to BG
|
|
-- colour 0, say -- has to go through this too, or the backdrop would keep its
|
|
-- cart colour while everything on top of it went grey.
|
|
function GbcPalette.resolve(colors)
|
|
if GbcPalette.customRamp then return GbcPalette.customRamp end
|
|
if GbcPalette.mode == "gbc" then return colors end
|
|
return DMG_SHADES
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
-- rBGP as data
|
|
--------------------------------------------------------------------------
|
|
|
|
-- %11100100. The `dc` macro emits colour 3 FIRST, so `dc 3, 2, 1, 0` packs to
|
|
-- $e4 and reads back as "colour i shows shade i": the identity.
|
|
GbcPalette.BGP_IDENTITY = 0xe4
|
|
|
|
-- The byte's four 2-bit fields, colour 0 in the low bits, returned 1-based so
|
|
-- shades[i + 1] is the shade colour i shows.
|
|
function GbcPalette.bgpShades(byte)
|
|
byte = byte or GbcPalette.BGP_IDENTITY
|
|
local shades = {}
|
|
for index = 0, 3 do
|
|
shades[index + 1] = math.floor(byte / (4 ^ index)) % 4
|
|
end
|
|
return shades
|
|
end
|
|
|
|
-- CopyPals (home/palettes.asm), which is the whole of what DmgToCgbBGPals does
|
|
-- on a CGB: a palette's four entries are REORDERED by the rBGP byte, so a pixel
|
|
-- drawn as colour i comes back as colour bgp(i) OF ITS OWN PALETTE.
|
|
--
|
|
-- This is why a brightness veil can never be right and this table can: $f9
|
|
-- (`dc 3, 3, 2, 1`) means "one step darker along this palette's own ramp", and
|
|
-- no two palettes have the same ramp. Returns `colors` itself for the identity
|
|
-- so the common case allocates nothing.
|
|
function GbcPalette.remap(colors, byte)
|
|
if not colors then return nil end
|
|
if not byte or byte == GbcPalette.BGP_IDENTITY then return colors end
|
|
local shades = GbcPalette.bgpShades(byte)
|
|
local out = {}
|
|
for index = 1, 4 do
|
|
out[index] = colors[shades[index] + 1] or colors[4]
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- The rBGP byte every subsequent GbcPalette.use / .with / .color folds in, or
|
|
-- nil for the identity.
|
|
--
|
|
-- This is the FORWARD half of the register and the exact one: a screen that is
|
|
-- still being drawn can take the permutation on its palettes before they ever
|
|
-- reach the shader, which is bit for bit DmgToCgbBGPals. Only a frame that is
|
|
-- already baked (World's map canvas) needs the backwards pass above.
|
|
--
|
|
-- GbcPalette.useRaw deliberately does NOT fold it in: the CLASSIC present pass
|
|
-- goes through useRaw and is not a BG palette, so a byte left standing there
|
|
-- would permute the green ramp itself.
|
|
GbcPalette.bgp = nil
|
|
|
|
-- Set the active byte, returning the previous one so a caller can restore it.
|
|
-- The identity is stored as nil, so `setBgp($e4)` is the same as clearing it.
|
|
function GbcPalette.setBgp(byte)
|
|
local previous = GbcPalette.bgp
|
|
if byte == GbcPalette.BGP_IDENTITY then byte = nil end
|
|
GbcPalette.bgp = byte
|
|
return previous
|
|
end
|
|
|
|
-- One colour out of a palette, mode and active rBGP byte applied. `index` is
|
|
-- 1-based.
|
|
function GbcPalette.color(colors, index)
|
|
local resolved = GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp)
|
|
if resolved and resolved[index] then return resolved[index] end
|
|
return GbcPalette.remap(DMG_SHADES, GbcPalette.bgp)[index]
|
|
end
|
|
|
|
-- The palette the finished frame is presented through, or nil when the frame
|
|
-- is already the right colour. Only CLASSIC needs one: the scene under it has
|
|
-- rendered in the four DMG shades, so mapping those to the green ramp is the
|
|
-- same shade substitution every other call here makes.
|
|
function GbcPalette.presentColors()
|
|
if GbcPalette.mode ~= "classic" then return nil end
|
|
return CLASSIC_SHADES
|
|
end
|
|
|
|
function GbcPalette.setMode(mode)
|
|
if mode == GbcPalette.CUSTOM_MODE then
|
|
GbcPalette.mode = mode
|
|
return mode
|
|
end
|
|
for _, name in ipairs(GbcPalette.MODES) do
|
|
if name == mode then
|
|
GbcPalette.mode = mode
|
|
return mode
|
|
end
|
|
end
|
|
GbcPalette.mode = "gbc"
|
|
return GbcPalette.mode
|
|
end
|
|
|
|
function GbcPalette.modeLabel(mode)
|
|
return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GEN 2"
|
|
end
|
|
|
|
-- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so
|
|
-- the OPTION screen's left press walks the ladder the other way.
|
|
function GbcPalette.cycle(delta)
|
|
local at = 1
|
|
for index, name in ipairs(GbcPalette.MODES) do
|
|
if name == GbcPalette.mode then at = index break end
|
|
end
|
|
local count = #GbcPalette.MODES
|
|
at = (at - 1 + (delta or 1)) % count + 1
|
|
GbcPalette.mode = GbcPalette.MODES[at]
|
|
GbcPalette.setCustomRamp(nil)
|
|
return GbcPalette.mode
|
|
end
|
|
|
|
function GbcPalette.applyOptions(opts)
|
|
local mode = GbcPalette.setMode(opts and opts.color or "gbc")
|
|
local id = opts and opts.palette
|
|
if id and id ~= "" then
|
|
local ok, Palette = pcall(require, "src.render.Palette")
|
|
GbcPalette.setCustomRamp(ok and Palette.ramp(id) or nil)
|
|
else
|
|
GbcPalette.setCustomRamp(nil)
|
|
end
|
|
return mode
|
|
end
|
|
|
|
-- Point the shader at one 4-color palette. Colors are 0-255 triples, matching
|
|
-- palettes.lua. Returns false when there is no shader to configure.
|
|
--
|
|
-- Mode first, then rBGP: on the hardware the register indexes whatever the
|
|
-- palette buffer holds, and in DMG/CLASSIC mode that buffer IS the four grey
|
|
-- shades, so remapping the resolved palette is what the DMG itself does.
|
|
function GbcPalette.use(colors)
|
|
return GbcPalette.useRaw(
|
|
GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp))
|
|
end
|
|
|
|
-- The same, ignoring the COLOR mode. The present pass needs it: it IS the
|
|
-- mode, so running its own palette back through resolve would flatten the
|
|
-- green ramp to grey and the mode would do nothing.
|
|
function GbcPalette.useRaw(colors)
|
|
local sh = GbcPalette.shader()
|
|
if not sh then return false end
|
|
for i = 0, 3 do
|
|
local r, g, b = channel(colors, i + 1)
|
|
sh:send("pal" .. i, { r, g, b })
|
|
end
|
|
love.graphics.setShader(sh)
|
|
return true
|
|
end
|
|
|
|
function GbcPalette.clear()
|
|
if love and love.graphics then love.graphics.setShader() end
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
-- The backwards pass: remapping a frame that is already drawn
|
|
--------------------------------------------------------------------------
|
|
|
|
local function colorKey(c)
|
|
return math.floor(c[1] or 0) .. "," .. math.floor(c[2] or 0)
|
|
.. "," .. math.floor(c[3] or 0)
|
|
end
|
|
|
|
-- Source and destination colour lists for REMAP_SOURCE, deduplicated.
|
|
--
|
|
-- `bgPalettes` is a LIST OF PALETTES the rBGP byte reaches: DmgToCgbBGPals
|
|
-- pushes one byte through all eight BG palettes at once, so they all take the
|
|
-- same permutation. `objPalettes` is the list it does NOT reach -- OBJ colours
|
|
-- go through the separate DmgToCgbObjPals, which the flash never calls -- and
|
|
-- they are here mapping to THEMSELVES, so a sprite's colours are recognised and
|
|
-- left alone instead of being matched onto a BG entry and swept along.
|
|
--
|
|
-- Returns src, dst (0..1 triples, both padded to REMAP_MAX) and two counts:
|
|
-- `count`, the live length, and `ambiguous`.
|
|
--
|
|
-- `ambiguous` is the exact limit of this whole pass, and it is worth naming: a
|
|
-- colour that is entry 1 of one palette and entry 2 of another has two right
|
|
-- answers, and a composited frame threw away which one this pixel was. The
|
|
-- first writer wins, which is why BG palettes are walked first and in slot
|
|
-- order -- the reading that matches "the whole picture flashes". Nothing here
|
|
-- is approximate when `ambiguous` is 0.
|
|
--
|
|
-- A map's eight BG palettes are 32 entries, which is REMAP_MAX exactly, so a
|
|
-- map whose palettes share nothing at all fills the array and the OBJ list is
|
|
-- dropped. BG is walked first for that reason too: losing the sprite guard
|
|
-- costs a few sprite pixels, losing a BG palette would cost the effect.
|
|
function GbcPalette.remapTable(bgPalettes, byte, objPalettes)
|
|
local src, dst = {}, {}
|
|
local seen = {}
|
|
local ambiguous = 0
|
|
|
|
local function add(colors, mapped)
|
|
if not (colors and mapped) then return end
|
|
for index = 1, 4 do
|
|
local from, to = colors[index], mapped[index]
|
|
if from and to and #src < GbcPalette.REMAP_MAX then
|
|
local key = colorKey(from)
|
|
local at = seen[key]
|
|
if at then
|
|
-- Same colour, different destination: the pixel cannot say which
|
|
-- palette drew it, so the first answer stands and this is counted.
|
|
if colorKey(dst[at]) ~= colorKey(to) then ambiguous = ambiguous + 1 end
|
|
else
|
|
src[#src + 1] = { from[1], from[2], from[3] }
|
|
dst[#dst + 1] = { to[1], to[2], to[3] }
|
|
seen[key] = #src
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, colors in ipairs(bgPalettes or {}) do
|
|
local resolved = GbcPalette.resolve(colors)
|
|
add(resolved, GbcPalette.remap(resolved, byte))
|
|
end
|
|
for _, colors in ipairs(objPalettes or {}) do
|
|
local resolved = GbcPalette.resolve(colors)
|
|
add(resolved, resolved)
|
|
end
|
|
|
|
local count = #src
|
|
-- Shader:send fills the whole declared array, so the tail is padded with a
|
|
-- copy of the first entry; `count` keeps the loop off it either way.
|
|
for index = count + 1, GbcPalette.REMAP_MAX do
|
|
src[index] = src[1] and { src[1][1], src[1][2], src[1][3] } or { 0, 0, 0 }
|
|
dst[index] = dst[1] and { dst[1][1], dst[1][2], dst[1][3] } or { 0, 0, 0 }
|
|
end
|
|
return src, dst, count, ambiguous
|
|
end
|
|
|
|
-- Bind the remap shader for a draw of an already-rendered texture. Returns
|
|
-- false when there is no shader or no palette, so a caller can fall back to
|
|
-- whatever approximation it had before; on success it also returns the
|
|
-- `ambiguous` count, which is 0 when the pass is exact.
|
|
function GbcPalette.useRemap(bgPalettes, byte, objPalettes)
|
|
local sh = GbcPalette.remapShader()
|
|
if not sh then return false end
|
|
local src, dst, count, ambiguous =
|
|
GbcPalette.remapTable(bgPalettes, byte, objPalettes)
|
|
if count == 0 then return false end
|
|
local sendSrc, sendDst = {}, {}
|
|
for index = 1, GbcPalette.REMAP_MAX do
|
|
sendSrc[index] = { src[index][1] / 255, src[index][2] / 255,
|
|
src[index][3] / 255 }
|
|
sendDst[index] = { dst[index][1] / 255, dst[index][2] / 255,
|
|
dst[index][3] / 255 }
|
|
end
|
|
-- pcall rather than an assert: a driver that will not take a 32-entry vec3
|
|
-- array should drop the effect, not take the battle down with it.
|
|
local ok = pcall(function()
|
|
sh:send("remapCount", count)
|
|
sh:send("remapTol", GbcPalette.REMAP_TOLERANCE)
|
|
sh:send("remapSrc", unpack(sendSrc))
|
|
sh:send("remapDst", unpack(sendDst))
|
|
end)
|
|
if not ok then
|
|
remapFailed = true
|
|
remapShader = nil
|
|
return false
|
|
end
|
|
love.graphics.setShader(sh)
|
|
return true, ambiguous
|
|
end
|
|
|
|
-- Run `body` with `colors` active, restoring whatever shader was set before.
|
|
-- Nested use is safe: the previous shader is captured, not assumed to be nil.
|
|
function GbcPalette.with(colors, body)
|
|
local previous = love and love.graphics and love.graphics.getShader
|
|
and love.graphics.getShader() or nil
|
|
local applied = GbcPalette.use(colors)
|
|
local ok, err = pcall(body)
|
|
if love and love.graphics then love.graphics.setShader(previous) end
|
|
if not ok then error(err, 0) end
|
|
return applied
|
|
end
|
|
|
|
function GbcPalette.withRaw(colors, body)
|
|
local previous = love and love.graphics and love.graphics.getShader
|
|
and love.graphics.getShader() or nil
|
|
local applied = GbcPalette.useRaw(colors)
|
|
local ok, err = pcall(body)
|
|
if love and love.graphics then love.graphics.setShader(previous) end
|
|
if not ok then error(err, 0) end
|
|
return applied
|
|
end
|
|
|
|
return GbcPalette
|