From d10eae526bbc2f4e3a6230faeb451ee914c17f3b Mon Sep 17 00:00:00 2001 From: Shane McGovern <45065431+Nexhas28@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:13:28 +0100 Subject: [PATCH] Pack Ruby/Sapphire font sheets into one atlas FireRed draws each string from a packed fg+shadow atlas so LOVE's autobatcher keeps it in one draw call. Ruby/Sapphire's native faces (rs/font_faces.lua) skipped that and drew every glyph from three textures (shadow, fg, embedded colours), flushing the batch on every layer of every glyph. Stack all three sheets in one texture and draw from it. --- src/ui/game3/frlg_font.lua | 17 +++- src/ui/game3/rs/font_faces.lua | 44 ++++++++++- tests/game3_rs_font_atlas_test.lua | 122 +++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 tests/game3_rs_font_atlas_test.lua diff --git a/src/ui/game3/frlg_font.lua b/src/ui/game3/frlg_font.lua index 8d5f61de..97e1a818 100644 --- a/src/ui/game3/frlg_font.lua +++ b/src/ui/game3/frlg_font.lua @@ -1448,12 +1448,17 @@ function FrlgFont.draw(text, x, y, opts) set_col(curCol.bg) love.graphics.rectangle("fill", dx, dy, adv, pitch) end - local aq = gat and gat.fg[aid] + local aq, ash = gat and gat.fg[aid], gat and gat.sh[aid] + -- R/S native faces pack fg, shadow and embedded colours into one + -- texture (rs/font_faces.lua), keyed by the source quad + local nq = nativeFace and nativeFace.atlas and nativeFace.atlas.of[q] + local aimg = gat and gat.image + if nq then aq, ash, aimg = nq.fg, nq.sh, nativeFace.atlas.image end -- Draw shadow if gsh and curCol.shadow and (not curCol.shadow[4] or curCol.shadow[4] > 0) then set_col(curCol.shadow) if aq then - love.graphics.draw(gat.image, gat.sh[aid], dx, dy) + love.graphics.draw(aimg, ash, dx, dy) else love.graphics.draw(gsh, q, dx, dy) end @@ -1462,14 +1467,18 @@ function FrlgFont.draw(text, x, y, opts) if curCol.fg and (not curCol.fg[4] or curCol.fg[4] > 0) then set_col(curCol.fg) if aq then - love.graphics.draw(gat.image, aq, dx, dy) + love.graphics.draw(aimg, aq, dx, dy) else love.graphics.draw(gfg, q, dx, dy) end end if nativeFace and nativeFace.fixed then love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(nativeFace.fixed, q, dx, dy) + if nq and nq.fixed then + love.graphics.draw(aimg, nq.fixed, dx, dy) + else + love.graphics.draw(nativeFace.fixed, q, dx, dy) + end end end penX = penX + japanese_step(id, adv, minW, jpn, activeOpts, useSmall) diff --git a/src/ui/game3/rs/font_faces.lua b/src/ui/game3/rs/font_faces.lua index 1a120a1a..9076b0a9 100644 --- a/src/ui/game3/rs/font_faces.lua +++ b/src/ui/game3/rs/font_faces.lua @@ -5,6 +5,44 @@ local function read(path) return Cache.readActive(path) end +-- Stack the fg, shadow and embedded-colour sheets in one texture so a +-- string's glyphs share it and LOVE's autobatcher keeps the run in one draw +-- call, as frlg_font's pack_atlas does for the FRLG sheets. `of` maps each +-- source quad to its { fg, sh, fixed } quads in the atlas; pixels are copied +-- verbatim. nil (draw from the separate sheets) when the data is missing. +local ATLAS_GAP = 16 +function Faces.pack(fgData, shData, fixedData, quads, boundaryQuads) + if not (fgData and shData and love and love.image and love.graphics) then return nil end + local ok, atlas = pcall(function() + local w, h = fgData:getDimensions() + local sw, shh = shData:getDimensions() + if sw ~= w or shh ~= h then return nil end + local fmt = fgData:getFormat() + if shData:getFormat() ~= fmt or (fixedData and fixedData:getFormat() ~= fmt) then return nil end + local off = h + ATLAS_GAP + local data = love.image.newImageData(w, (fixedData and 3 or 2) * off - ATLAS_GAP, fmt) + data:paste(fgData, 0, 0, 0, 0, w, h) + data:paste(shData, 0, off, 0, 0, w, h) + if fixedData then data:paste(fixedData, 0, 2 * off, 0, 0, w, h) end + local img = love.graphics.newImage(data) + img:setFilter("nearest", "nearest") + local aw, ah = img:getDimensions() + local of = {} + local function add(q) + local qx, qy, qw, qh = q:getViewport() + of[q] = { + fg = love.graphics.newQuad(qx, qy, qw, qh, aw, ah), + sh = love.graphics.newQuad(qx, qy + off, qw, qh, aw, ah), + fixed = fixedData and love.graphics.newQuad(qx, qy + 2 * off, qw, qh, aw, ah) or nil, + } + end + for _, q in pairs(quads) do add(q) end + for _, q in pairs(boundaryQuads) do add(q) end + return { image = img, of = of } + end) + return ok and atlas or nil +end + -- pokeruby/src/text.c:2676 function Faces.load(spec, name, language, loadImage, loadTable, palette, textMode) local id = assert(spec.faces[name], "unknown RS font face").id @@ -33,9 +71,10 @@ function Faces.load(spec, name, language, loadImage, loadTable, palette, textMod fixed[p] = "\0\0\0\0" end end - local fixedImage + local fixedImage, fixedData if present then - fixedImage = love.graphics.newImage(love.image.newImageData(info.width, info.height, "rgba8", table.concat(fixed))) + fixedData = love.image.newImageData(info.width, info.height, "rgba8", table.concat(fixed)) + fixedImage = love.graphics.newImage(fixedData) fixedImage:setFilter("nearest", "nearest") end local quads, boundaryQuads = {}, {} @@ -53,6 +92,7 @@ function Faces.load(spec, name, language, loadImage, loadTable, palette, textMod fg = fg, sh = sh, fixed = fixedImage, quads = quads, boundaryQuads = boundaryQuads, widths = widths, height = 16, pitch = 16, letterSpacing = 0, glyphs = info.glyphs, fgData = fgData, shData = shData, + atlas = Faces.pack(fgData, shData, fixedData, quads, boundaryQuads), } end diff --git a/tests/game3_rs_font_atlas_test.lua b/tests/game3_rs_font_atlas_test.lua new file mode 100644 index 00000000..6af196a5 --- /dev/null +++ b/tests/game3_rs_font_atlas_test.lua @@ -0,0 +1,122 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +-- Minimal LOVE stand-ins: ImageData with paste, Images, Quads with viewports. +local Data = {} +Data.__index = Data +local function newData(w, h, fmt) + return setmetatable({ w = w, h = h, fmt = fmt or "rgba8", px = {} }, Data) +end +function Data:getDimensions() return self.w, self.h end +function Data:getFormat() return self.fmt end +function Data:paste(src, dx, dy, sx, sy, sw, sh) + for y = 0, sh - 1 do + for x = 0, sw - 1 do + self.px[(dy + y) * self.w + dx + x] = src.px[(sy + y) * src.w + sx + x] + end + end +end + +local Image = {} +Image.__index = Image +function Image:getDimensions() return self.data.w, self.data.h end +function Image:setFilter() end + +local Quad = {} +Quad.__index = Quad +function Quad:getViewport() return self.x, self.y, self.w, self.h end + +local draws = {} +_G.love = { + image = { newImageData = function(w, h, fmt) return newData(w, h, fmt) end }, + graphics = { + newImage = function(data) return setmetatable({ data = data }, Image) end, + newQuad = function(x, y, w, h) return setmetatable({ x = x, y = y, w = w, h = h }, Quad) end, + draw = function(img, q) draws[#draws + 1] = { img = img, q = q } end, + setColor = function() end, + rectangle = function() end, + }, +} + +local Faces = require("src.ui.game3.rs.font_faces") + +local function sheet(w, h, tag) + local d = newData(w, h) + for i = 0, w * h - 1 do d.px[i] = tag .. i end + return d +end + +local W, H = 32, 32 +local fg, sh, fixed = sheet(W, H, "f"), sheet(W, H, "s"), sheet(W, H, "x") +local q0 = love.graphics.newQuad(0, 0, 8, 16) +local q1 = love.graphics.newQuad(16, 0, 6, 16) +local b1 = love.graphics.newQuad(16, 0, 3, 16) + +local atlas = Faces.pack(fg, sh, fixed, { [0] = q0, [1] = q1 }, { [1] = b1 }) +check(atlas ~= nil, "pack builds an atlas from fg, shadow and embedded-colour sheets") +local off = H + 16 +local img = atlas.image.data +eq(img.h, 3 * off - 16, "three layers stacked with a gap between them") +eq(img.px[0], "f0", "fg layer copied verbatim at the top") +eq(img.px[off * W + 5], "s5", "shadow layer copied verbatim below the fg") +eq(img.px[2 * off * W + 7], "x7", "embedded-colour layer copied verbatim at the bottom") + +local function vp(q) return { q:getViewport() } end +local e = atlas.of[q1] +check(e ~= nil and atlas.of[b1] ~= nil and atlas.of[q0] ~= nil, "every normal and boundary quad is mapped") +local f, s, x = vp(e.fg), vp(e.sh), vp(e.fixed) +check(f[1] == 16 and f[2] == 0 and f[3] == 6 and f[4] == 16, "fg quad keeps the source viewport") +check(s[1] == 16 and s[2] == off and s[3] == 6, "shadow quad is offset by one layer") +check(x[1] == 16 and x[2] == 2 * off and x[3] == 6, "embedded-colour quad is offset by two layers") +eq(vp(atlas.of[b1].fg)[3], 3, "boundary quads keep their 3-px width") + +local two = Faces.pack(fg, sh, nil, { [0] = q0 }, {}) +check(two and two.image.data.h == 2 * off - 16 and two.of[q0].fixed == nil, + "a font without embedded colours packs two layers") +check(Faces.pack(fg, sheet(16, 16, "s"), nil, { [0] = q0 }, {}) == nil, + "mismatched sheets fall back to separate textures") +local other = sheet(W, H, "s") +other.fmt = "r8" +check(Faces.pack(fg, other, nil, { [0] = q0 }, {}) == nil, + "mismatched formats fall back to separate textures") + +-- Every layer of every glyph in a string draws from the one atlas texture. +local FrlgFont = require("src.ui.game3.frlg_font") +local saved = { + spec = FrlgFont._spec, ver = FrlgFont._syncVersion, faces = FrlgFont._faces, +} +local Runtime = package.loaded["src.core.game3.runtime"] +local GV = require("src.core.GameVersion") +local v = (Runtime and type(Runtime.session) == "table" and Runtime.session.version) or GV.current or "" +FrlgFont._syncVersion, FrlgFont._spec = v, { nativeLayout = "rs" } +local face = { + name = "normal", nativeIndexed = true, + fg = love.graphics.newImage(fg), sh = love.graphics.newImage(sh), fixed = love.graphics.newImage(fixed), + quads = {}, boundaryQuads = {}, widths = {}, height = 16, pitch = 16, letterSpacing = 0, +} +for id = 0, 255 do + face.quads[id] = love.graphics.newQuad(0, 0, 8, 16) + face.widths[id] = 6 +end +face.atlas = Faces.pack(fg, sh, fixed, face.quads, face.boundaryQuads) +FrlgFont._faces = { normal = face } + +local colors = { fg = { 1, 1, 1, 1 }, shadow = { 0, 0, 0, 1 } } +FrlgFont.draw("AB", 0, 0, { colors = colors }) +check(#draws == 6, "two glyphs draw shadow, fg and embedded colours: " .. #draws) +local allAtlas = true +for _, d in ipairs(draws) do + if d.img ~= face.atlas.image then allAtlas = false end +end +check(allAtlas, "every glyph layer draws from the packed atlas (one texture, batchable)") + +draws = {} +face.atlas = nil +FrlgFont.draw("A", 0, 0, { colors = colors }) +check(#draws == 3 and draws[1].img == face.sh and draws[2].img == face.fg and draws[3].img == face.fixed, + "without an atlas the separate sheets still draw") + +FrlgFont._spec, FrlgFont._syncVersion, FrlgFont._faces = saved.spec, saved.ver, saved.faces +T.finish("game3_rs_font_atlas_test")