-- Voxel world mode: the texture terrain samples. -- -- Voxel terrain is textured from the tileset atlas, so a map's colors have -- to live IN that atlas. Two of the three color paths already do: -- -- RED++ TileRenderer.new bakes a fully recolored per-map atlas -- (getGbcAtlas) and hands it over as renderer.image -- nothing -- to do here, true GBC terrain color comes through untouched. -- trueColor a mod's full-color atlas is already its own colors. -- -- The SGB modes are the gap. There the atlas is raw 4-shade grayscale and -- the color normally arrives as a screen-space shade-remap pass over -- rectangular zones (PaletteFX) -- which has no meaning once the ground is -- geometry rather than a rectangle. So bake instead: one atlas copy per -- (atlas, palette), remapped through the same cutoffs the shader uses. -- -- A map has exactly one world palette, so this is a handful of 128x48 -- images for a whole session, built once and cached. -- -- ANIMATED TILES (water, flowers) are the other thing this file owns. The -- 2D path animates them by OVERDRAWING the animated cells on top of the -- static tile layer each frame, which a single static mesh has no -- equivalent of -- the geometry samples one texture and that is that. So -- animate the texture: rewrite the animated tile's slot in a private copy -- of the atlas whenever the step advances, and every instance of that tile -- across the whole mesh moves at once. Which is what the Game Boy does in -- the first place (home/vcopy.asm rewrites the tile's VRAM bytes); the 2D -- overdraw is the port's workaround, not the original. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... local Assets = require("src.render.Assets") local TileRenderer = require("src.render.TileRenderer") local PaletteFX = require("src.render.PaletteFX") local TerrainAtlas = {} local cache = {} local cacheData = {} -- the pixels behind the atlases we baked ourselves local animated = {} -- key -> one map's private, mutable animated atlas -- false = given up on; nil = not built (or retrying) local attempts = {} -- key -> consecutive failures, for the retry budget -- A failure that might not repeat -- a driver refusing one readback, an -- asset briefly unreadable, a patch that threw once mid-reload -- must not -- cost the animation for the rest of the session. It used to: the key was -- condemned to `false` on the first miss and nothing ever rebuilt it, so -- water stopped moving and stayed stopped until a hot reload. -- -- Retry a few times, then give up for good so a genuinely broken atlas is -- not rebuilt on every frame forever. local MAX_ATTEMPTS = 3 local function attemptFailed(key) local n = (attempts[key] or 0) + 1 attempts[key] = n if n >= MAX_ATTEMPTS then return false end -- condemn it return nil -- rebuild next frame end local function paletteKey(colors) local parts = {} for i = 1, 4 do local c = colors[i] parts[i] = c and (c[1] .. "," .. c[2] .. "," .. c[3]) or "-" end return table.concat(parts, ";") end -- The atlas image `map`'s terrain should sample, given the 4-color world -- palette it sits under (nil to leave the atlas as-is). Falls back to the -- renderer's own image whenever a bake is impossible -- headless, or no -- pixel access -- which just means grayscale terrain rather than no -- terrain. -- The static atlas for `map` under `colors`: the answer this file gave -- before animation existed, and the base every animated frame is patched -- over. Returns the image and, when we baked it ourselves, its pixels. local function staticAtlas(map, colors) local renderer = map.renderer local base = renderer and renderer.image if not base then return nil end -- already true color: RED++'s baked per-map atlas, or a mod's own art if not colors or renderer.gbcAtlas or map.tileset.trueColor then return base, false end if not (love.image and love.image.newImageData) then return base, false end local path = map.tileset.image local key = path .. "#" .. paletteKey(colors) if cache[key] ~= nil then return cache[key] or base, cacheData[key] end local data local ok, img = pcall(function() local src = Assets.imageData(path) local w, h = src:getDimensions() local out = love.image.newImageData(w, h) for y = 0, h - 1 do for x = 0, w - 1 do local r, g, b, a = src:getPixel(x, y) r, g, b, a = TileRenderer.recolorSample(r, g, b, a, colors) out:setPixel(x, y, r, g, b, a) end end local image = love.graphics.newImage(out) image:setFilter("nearest", "nearest") data = out return image end) cache[key] = ok and img or false cacheData[key] = (ok and data) or false return cache[key] or base, cacheData[key] end -- ------------------------------------------------------------ animation -- -- The four GB shades as the ORIGINAL art carries them, by the same cutoffs -- TileRenderer.recolorSample splits on -- so a shade learned here and a -- shade recolored there are the same shade. local function shadeOf(r) if r > 0.83 then return 1 end if r > 0.5 then return 2 end if r > 0.17 then return 3 end return 4 end -- How this atlas recolored one tile, learned by reading the tile's slot in -- the raw art and in the finished atlas side by side: shade -> the colour -- it became. -- -- Learned rather than recomputed because the two recolour paths do not -- share a rule -- SGB bakes one world palette over everything, RED++ picks -- a palette group per tile GRAPHIC -- and a flower frame arrives as its own -- little grayscale file that never went through either. Asking "what -- happened to the tile I am replacing" gets the right answer from both -- without this file knowing which one ran. local function learnShades(raw, baked, tile, perRow) local sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8 local map = {} for y = 0, 7 do for x = 0, 7 do local k = shadeOf(raw:getPixel(sx + x, sy + y)) if not map[k] then local r, g, b, a = baked:getPixel(sx + x, sy + y) map[k] = { r, g, b, a } end end end return map end local DIRS4 = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } -- One animated entry's tile slot, written into `out` at step `step`. local function patch(out, entry, spec, step) local perRow, tile = entry.perRow, spec.tile local dx, dy = (tile % perRow) * 8, math.floor(tile / perRow) * 8 if spec.kind == "hshift" then -- the water rotate (the asm's rrca/rlca run): the tile's own pixels, -- rolled sideways. Read from the UNANIMATED base, or each step would -- compound on the last one's shift. local o = spec.offsets[step % #spec.offsets + 1] for y = 0, 7 do for x = 0, 7 do local r, g, b, a = entry.base:getPixel(dx + x, dy + y) out:setPixel(dx + (x + o) % 8, dy + y, r, g, b, a) end end elseif spec.kind == "frames" then local path = spec.images[spec.sequence[step % #spec.sequence + 1]] if not path then return end local ok, frame = pcall(Assets.imageData, path) if not ok or not frame then return end local shades = entry.shades and entry.shades[tile] -- a `cut` tile is the flower billboard's slot (Structures' -- buildFlowers): only the frame's darkest tones AND what they -- enclose stay opaque -- the round-scenery hull's rule, flooding -- the frame border through every non-dark pixel so the pale petal -- insides survive with the outline. The true background is keyed -- to alpha; nothing else samples this slot (the ground under a -- flower is synthesized), and the shader's discard is what lets -- the billboard's silhouette change per frame under geometry that -- never moves. local mask = nil if entry.cut and entry.cut[tile] then local dark, reach, stack = {}, {}, {} for y = 0, 7 do for x = 0, 7 do local r, _, _, a = frame:getPixel(x, y) if a > 0 and shadeOf(r) >= 3 then dark[y * 8 + x] = true end end end for i = 0, 7 do for _, s in ipairs({ i, 56 + i, i * 8, i * 8 + 7 }) do if not dark[s] and not reach[s] then reach[s] = true stack[#stack + 1] = s end end end while #stack > 0 do local p = table.remove(stack) local px, py = p % 8, math.floor(p / 8) for _, d in ipairs(DIRS4) do local nx, ny = px + d[1], py + d[2] if nx >= 0 and nx < 8 and ny >= 0 and ny < 8 then local ni = ny * 8 + nx if not dark[ni] and not reach[ni] then reach[ni] = true stack[#stack + 1] = ni end end end end mask = {} for i = 0, 63 do mask[i] = dark[i] or not reach[i] end end for y = 0, 7 do for x = 0, 7 do local r, g, b, a = frame:getPixel(x, y) if mask and not mask[y * 8 + x] then out:setPixel(dx + x, dy + y, 0, 0, 0, 0) else local col = shades and shades[shadeOf(r)] if col and a > 0 then r, g, b = col[1], col[2], col[3] end out:setPixel(dx + x, dy + y, r, g, b, a) end end end end end -- The animation specs this file can serve: the two that rewrite a tile's -- pixels. "toggle" (the spinner-puzzle blur) is a whole-atlas swap gated on -- a gameplay state, and is left to the 2D path -- voxel mode does not draw -- the spinner rooms' tile layer any differently for it. local function specsFor(tileset) local declared = tileset.animatedTiles or TileRenderer.defaultAnimatedTiles(tileset) local out = nil for _, spec in ipairs(declared or {}) do local usable = spec.tile and ((spec.kind == "hshift" and spec.offsets and #spec.offsets > 0) or (spec.kind == "frames" and spec.images and spec.sequence and #spec.sequence > 0)) if usable then out = out or {} out[#out + 1] = spec end end return out end -- Pixels back off a texture the engine built on the GPU and kept no copy -- of. LOVE 11 hands out no ImageData for an Image, so the only route is a -- round trip: draw it 1:1 into a canvas and read that back. -- -- This runs inside the world pass, with the pipeline's own canvas bound, so -- the previous target is captured and put back rather than unbound -- the -- usual setCanvas() would drop the rest of the frame on the floor. One -- readback per map, cached with the entry it feeds; the atlas is a couple -- of hundred pixels square, so the GPU sync costs far less than the mesh -- build it happens alongside. Every step is guarded: a driver that refuses -- canvas readback costs the animation and nothing else. local function readback(image) if not (image and love.graphics and love.graphics.newCanvas and love.graphics.getCanvas) then return nil end local prev = love.graphics.getCanvas() local ok, data = pcall(function() local w, h = image:getDimensions() local canvas = love.graphics.newCanvas(w, h) love.graphics.setCanvas(canvas) love.graphics.clear(0, 0, 0, 0) -- straight copy: no blending against the cleared target, no tint from -- whatever colour the pass left set, or the atlas comes back wrong love.graphics.setBlendMode("replace", "premultiplied") love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(image, 0, 0) love.graphics.setBlendMode("alpha", "alphamultiply") -- LOVE refuses newImageData on the currently-active canvas, so the -- previous target has to come back BEFORE the read, not just after love.graphics.setCanvas(prev) local out = canvas:newImageData() if canvas.release then canvas:release() end return out end) pcall(love.graphics.setCanvas, prev) return ok and data or nil end -- RED++'s per-map atlas, rebuilt on the CPU. -- -- This is the case that has no pixels anywhere: `getGbcAtlas` bakes one -- ImageData per map, hands the texture to the renderer and drops the -- pixels on the floor. Without them the animated tiles cannot be patched, -- which is why water and flowers stood still under RED++ and nowhere else. -- -- The readback below can recover them from the texture, but it is at the -- mercy of whether the driver will read a canvas back, and it costs a GPU -- sync mid-frame. Everything the engine baked FROM is public, so bake it -- again instead: the raw art, the per-tile palette group, the group's -- colours, and the same recolorSample cutoffs. Deterministic, no driver -- involved, and it can be tested without a GPU. -- -- It does mirror engine logic and could drift from getGbcAtlas if that -- changes -- the readback stays behind it as the exact-but-fragile route. local function gbcPixels(map) local renderer, tileset = map.renderer, map.tileset local data = renderer and renderer.data if not (data and tileset and love.image and love.image.newImageData) then return nil end local ok, out = pcall(function() local groupColors = PaletteFX.worldGroupColors(data, tileset.id, map.id, nil) if not groupColors then return nil end local src = Assets.imageData(tileset.image) local iw, ih = src:getDimensions() local perRow = tileset.tilesPerRow or 16 local total = (iw / 8) * (ih / 8) local dst = love.image.newImageData(iw, ih) local function bake(from, to, colors) local sxo, syo = (from % perRow) * 8, math.floor(from / perRow) * 8 local dxo, dyo = (to % perRow) * 8, math.floor(to / perRow) * 8 for py = 0, 7 do for px = 0, 7 do local r, g, b, a = src:getPixel(sxo + px, syo + py) r, g, b, a = TileRenderer.recolorSample(r, g, b, a, colors) dst:setPixel(dxo + px, dyo + py, r, g, b, a) end end end local tileColors = {} for t = 0, total - 1 do local colors = tileColors[t] if colors == nil then local group = PaletteFX.worldGroupAt(tileset.id, map.id, t) colors = (group and groupColors[group + 1]) or false tileColors[t] = colors end bake(t, t, colors) end -- duplicate-tile aliases: the same graphic baked into a spare slot -- under a second palette group, so cells drawing the alias colour apart for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[map.id] or {}) do if al.alias < total then bake(al.tile, al.alias, groupColors[al.group + 1]) end end return dst end) return ok and out or nil end -- The pixels behind the atlas texture the engine is drawing with, for the -- frames where we did not bake one ourselves (staticAtlas returns `false` -- for its own bake whenever the palette is absent, RED++ already baked, or -- the tileset is trueColor). -- -- TileRenderer.atlasImageData is the engine's own accessor for exactly this -- and is preferred wherever the build offers it -- but like the sibling -- clock TileRenderer.animFrame it is an OPTIONAL seam, and a build without -- it has to cost us the animation, not the whole render pipeline. Reading -- it unguarded is what took the pass down for the session. -- -- Without the seam the pixels are still recoverable, by two different -- routes. An atlas neither we nor RED++ replaced is the tileset art itself, -- so the art on disk IS what it was built from. RED++'s per-map bake exists -- only on the GPU -- getGbcAtlas throws its ImageData away once the texture -- is made -- so that one has to come back off the texture (readback below). local function rendererPixels(map) local renderer = map.renderer if not renderer then return nil end if TileRenderer.atlasImageData then local ok, data = pcall(TileRenderer.atlasImageData, renderer) if ok and data then return data end end if renderer.gbcAtlas then return gbcPixels(map) or readback(renderer.image) end local ok, data = pcall(Assets.imageData, map.tileset.image) return ok and data or nil end -- The engine's tile-animation clock: TileRenderer's 60Hz counter, by -- whatever route this build offers. -- -- It matters that this is the ENGINE's number and not one of our own. The -- 2D tile layer and this texture animate the same water off the same -- counter, so toggling voxel mode mid-cycle continues the animation instead -- of restarting or jumping it. A clock of our own would free-run against -- the one the flat path is drawing from. -- -- 1. TileRenderer.animFrame(), where the build exports it. -- 2. else the counter itself, off tick()'s upvalues. It is a plain local -- in that module, so this is exact and live -- the same number, not an -- approximation of it. Reading engine internals is what this mod's -- "engine_internals" permission is declared for, and this one is -- read-only and entirely optional. -- 3. else wall time in 60Hz steps. Free-running, but the water moves, -- which beats a frozen pond. Derived from absolute time rather than -- accumulated deltas because animate() is called once per map in the -- neighbourhood, so a per-call accumulator would run several times -- too fast. local clockUpvalue = nil -- nil = not looked for yet, false = absent local function findClockUpvalue() if not (debug and debug.getupvalue) then return false end if type(TileRenderer.tick) ~= "function" then return false end for i = 1, 32 do local ok, name, value = pcall(debug.getupvalue, TileRenderer.tick, i) if not (ok and name) then break end if name == "animFrame" and type(value) == "number" then return i end end return false end local function animFrame() if TileRenderer.animFrame then local ok, f = pcall(TileRenderer.animFrame) if ok and type(f) == "number" then return f end end if clockUpvalue == nil then clockUpvalue = findClockUpvalue() end if clockUpvalue then local ok, _, value = pcall(debug.getupvalue, TileRenderer.tick, clockUpvalue) if ok and type(value) == "number" then return value end end if love.timer and love.timer.getTime then return math.floor(love.timer.getTime() * 60) end return 0 end TerrainAtlas._animFrame = animFrame -- named for the suite -- false = this can never work and asking again is waste; nil = it did not -- work THIS time and might next. The caller latches the first and retries -- the second (see attemptFailed). local function newEntry(map, base, baked) local tileset = map.tileset local specs = specsFor(tileset) if not specs then return false end -- nothing on this tileset animates if not (love.image and love.image.newImageData and base.replacePixels) then return false -- no pixel access on this machine end -- the pixels the atlas texture was built from: our own SGB bake when we -- made one, else whatever the engine's renderer is drawing with. A -- readback can fail for one frame and work the next, so this is a -- retryable miss rather than a verdict. local src = baked or rendererPixels(map) if not src then return nil end local ok, entry = pcall(function() local w, h = src:getDimensions() -- A PRIVATE copy, always. The base may be the engine's own atlas, and -- the 2D path draws the static tile from it and overdraws the animated -- cell on top -- rewriting the slot underneath would animate the tile -- twice over there and corrupt every still frame of it. local data = love.image.newImageData(w, h) data:paste(src, 0, 0, 0, 0, w, h) local image = love.graphics.newImage(data) image:setFilter("nearest", "nearest") local e = { base = src, data = data, image = image, specs = specs, perRow = tileset.tilesPerRow or 16, step = nil } -- the frame files are raw grayscale; learn what the atlas did to the -- tile each one stands in for, so they land on the same colours local recolored = baked or (map.renderer and map.renderer.gbcAtlas) if recolored and not tileset.trueColor then local raw = Assets.imageData(tileset.image) e.shades = {} for _, spec in ipairs(specs) do if spec.kind == "frames" then e.shades[spec.tile] = learnShades(raw, src, spec.tile, e.perRow) end end end -- a frame-animated tile that resolved to the `flower` class stands as -- a 1px billboard (Structures.buildFlowers), and its slot in THIS -- copy carries only each frame's dark tones with the rest keyed to -- alpha -- see patch(). Gated on the resolved shape so a profile that -- pins the tile to something solid keeps a fully opaque slot, and on -- the tileset art being readable: without pixels Structures cannot -- have stood the billboard up (or synthesized the ground), and an -- alpha-keyed slot under an ordinary flat quad is a hole in the world. for _, spec in ipairs(specs) do if spec.kind == "frames" then local okCut, isFlower = pcall(function() local okA, art = pcall(Assets.imageData, tileset.image) if not (okA and art and art.getPixel) then return false end local sh = V.require("TileShape").forMap(map)[spec.tile] return sh ~= nil and sh.class == "flower" end) if okCut and isFlower then e.cut = e.cut or {} e.cut[spec.tile] = true end end end return e end) return (ok and entry) or false end -- The atlas for this frame: the static one when nothing on this tileset -- animates (or anything at all goes wrong), else a private copy with the -- animated slots rewritten to the current step. Repatched only when the -- step actually changes -- three times a second, ~130 pixels of work. function TerrainAtlas.animate(map, colors, base, baked) -- RED++ bakes a per-MAP atlas, so its animated copy is per map too and -- has to be evicted with the meshes (setLive below); the shared paths key -- on the tileset and palette alone, which is bounded by how many of those -- exist at all. local perMap = map.renderer and map.renderer.gbcAtlas and map.id or nil local key = map.tileset.image .. "#a#" .. paletteKey(colors or {}) .. (perMap or "") local entry = animated[key] if entry == nil then entry = newEntry(map, base, baked) if entry then entry.mapId = perMap animated[key] = entry else -- false from newEntry is a verdict (nothing animates on this tileset, -- no pixel access at all); nil is a miss that may not repeat animated[key] = (entry == false) and false or attemptFailed(key) end end if not entry then return nil end local frame = animFrame() -- one number for the whole entry: every spec's own step, folded together, -- so a repatch happens when ANY of them turns over local step = 0 for i, spec in ipairs(entry.specs) do local n = spec.kind == "hshift" and #spec.offsets or #spec.sequence step = step + (math.floor(frame / (spec.period or 20)) % n) * (16 ^ i) end if step ~= entry.step then entry.step = step local ok = pcall(function() for _, spec in ipairs(entry.specs) do local n = spec.kind == "hshift" and #spec.offsets or #spec.sequence patch(entry.data, entry, spec, math.floor(frame / (spec.period or 20)) % n) end entry.image:replacePixels(entry.data) end) if not ok then -- drop the entry rather than condemning the key: the next frame -- rebuilds and tries again, and attemptFailed gives up eventually animated[key] = attemptFailed(key) return nil end end -- Only a frame that got all the way here counts as healthy. Clearing the -- budget on a successful BUILD instead would never let it run out: an -- entry that builds fine and fails on upload would rebuild every frame, -- forever, which is worse than either animating or giving up. if attempts[key] then attempts[key] = nil end return entry.image end function TerrainAtlas.forMap(map, colors) local base, baked = staticAtlas(map, colors) if not base then return nil end return TerrainAtlas.animate(map, colors, base, baked) or base end -- The image a character model should texture from under an SGB palette. -- -- In the 2D SGB modes, sprites are colorized by the screen-space -- shade-remap shader at blit time -- the sheet itself stays grayscale. The -- voxel canvas composites 1:1 with no shader pass, so a model textured -- straight from the sheet renders in raw DMG grays (a black-and-gray -- character standing in a colored room). Bake instead, exactly like the -- terrain above: one recolored sheet per (sheet, palette), remapped -- through the same cutoffs the shader uses, alpha preserved so OBJ color -- 0 stays transparent. Falls back to nil (caller keeps its texture) when -- pixels are unreachable. function TerrainAtlas.forSprite(path, colors) if not (colors and love.image and love.image.newImageData) then return nil end local key = "spr:" .. path .. "#" .. paletteKey(colors) if cache[key] ~= nil then return cache[key] or nil end local ok, img = pcall(function() local src = Assets.imageData(path) local w, h = src:getDimensions() local out = love.image.newImageData(w, h) for y = 0, h - 1 do for x = 0, w - 1 do local r, g, b, a = src:getPixel(x, y) r, g, b, a = TileRenderer.recolorSample(r, g, b, a, colors) out:setPixel(x, y, r, g, b, a) end end local image = love.graphics.newImage(out) image:setFilter("nearest", "nearest") return image end) cache[key] = ok and img or false return cache[key] or nil end -- Release the animated copies of maps outside `live` (a set of map ids), -- the same neighbourhood ChunkMesher bounds its meshes to and called from -- the same place. Only the per-map RED++ copies are held this way; the rest -- are keyed by tileset and palette, of which a session sees a handful. -- Without this a cross-region trek accumulates one atlas and one texture -- per map ever entered, and each pins the engine's own baked ImageData -- alive behind it. function TerrainAtlas.setLive(live) for key, entry in pairs(animated) do if entry and entry.mapId and not live[entry.mapId] then if entry.image and entry.image.release then pcall(entry.image.release, entry.image) end animated[key] = nil end end end function TerrainAtlas.invalidate() cache = {} cacheData = {} attempts = {} for _, entry in pairs(animated) do if entry and entry.image and entry.image.release then pcall(entry.image.release, entry.image) end end animated = {} end Assets.register(TerrainAtlas.invalidate) return TerrainAtlas