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.
This commit is contained in:
Shane McGovern
2026-10-12 12:13:28 +01:00
parent 54602c2897
commit d10eae526b
3 changed files with 177 additions and 6 deletions
+13 -4
View File
@@ -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)
+42 -2
View File
@@ -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
+122
View File
@@ -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")