graphics and stutters part 1

This commit is contained in:
bryanthaboi
2026-07-23 07:59:09 -04:00
parent 09b12a7cf0
commit 7a85862602
14 changed files with 480 additions and 144 deletions
+72 -5
View File
@@ -2,6 +2,13 @@
-- data, cached by map id. The cache is keyed so a mod that patches one
-- map record after boot (or the dev-mode hot reload) can drop just that
-- entry instead of every map's SpriteBatches.
--
-- The resident set is trimmed LRU (trim) so exploring a large world never
-- holds every visited map's GPU objects at once. Maps are built on demand
-- and cheaply: a map's tile layer draws windowed to the camera (see
-- TileRenderer), so there is no per-map batch to construct up front and thus
-- nothing to stream -- OverworldState:rebuildNeighbors just loads each
-- neighbor directly.
local Assets = require("src.render.Assets")
local Map = require("src.world.Map")
@@ -10,9 +17,20 @@ local TileRenderer = require("src.render.TileRenderer")
local MapLoader = {}
local cache = {}
-- mapId -> monotonic access stamp, for LRU eviction
local lru = {}
local accessSeq = 0
-- max map renderers kept resident. Comfortably above the largest
-- current+neighbor set (~15 in dense overworld), so protected maps are
-- never the ones evicted; this only caps the lingering trail behind you.
local RESIDENT_CAP = 32
function MapLoader.load(data, mapId)
if cache[mapId] then return cache[mapId] end
local function touch(mapId)
accessSeq = accessSeq + 1
lru[mapId] = accessSeq
end
local function build(data, mapId)
local def = data.maps[mapId]
assert(def, "unknown map: " .. tostring(mapId) ..
" (not in the maps registry)")
@@ -25,26 +43,75 @@ function MapLoader.load(data, mapId)
local map = Map.new(def, tilesetDef)
map.renderer = TileRenderer.new(map, data)
cache[mapId] = map
touch(mapId)
return map
end
function MapLoader.load(data, mapId)
local m = cache[mapId]
if m then touch(mapId); return m end
return build(data, mapId)
end
-- the live instance for a map id, or nil when it has not been loaded;
-- callers that must not build a map (invalidation, tests) use this
function MapLoader.cached(mapId)
return cache[mapId]
local m = cache[mapId]
if m then touch(mapId) end
return m
end
-- evict one resident map, releasing its renderer's GPU objects. Callers
-- must ensure the map is not the current map and not drawn as a connected
-- strip (nothing live may hold its renderer) -- MapLoader.trim guarantees
-- this via its `protected` set.
function MapLoader.evict(mapId)
local m = cache[mapId]
if not m then return false end
cache[mapId] = nil
lru[mapId] = nil
local r = m.renderer
if r and r.release then pcall(r.release, r) end
return true
end
-- keep the resident renderer set bounded. `protected` (mapId -> true) is
-- never evicted (the current map and everything drawn as a connected
-- strip); the rest is trimmed least-recently-used down to RESIDENT_CAP.
function MapLoader.trim(protected)
local n = 0
for _ in pairs(cache) do n = n + 1 end
if n <= RESIDENT_CAP then return end
local ids = {}
for id in pairs(cache) do
if not (protected and protected[id]) then ids[#ids + 1] = id end
end
table.sort(ids, function(a, b) return (lru[a] or 0) < (lru[b] or 0) end)
local over = n - RESIDENT_CAP
for _, id in ipairs(ids) do
if over <= 0 then break end
MapLoader.evict(id)
over = over - 1
end
end
-- drop one map so the next load re-reads its record and rebuilds its
-- renderer. Callers holding the old instance keep it -- OverworldState
-- re-points self.map itself (WorldAPI:invalidateMap).
-- renderer. Deliberately does NOT release the old renderer: callers
-- holding the old instance keep drawing it until they re-point themselves
-- (OverworldState re-points self.map / self.neighbors via setMap /
-- rebuildNeighbors), so releasing here would free a batch still in use.
-- The orphaned instance is reclaimed by GC; MapLoader.evict is the path
-- that releases eagerly, and it only runs on maps nothing live holds.
function MapLoader.invalidate(mapId)
local had = cache[mapId] ~= nil
cache[mapId] = nil
lru[mapId] = nil
return had
end
function MapLoader.invalidateAll()
cache = {}
lru = {}
end
-- kept as the pre-v2 name
+33 -9
View File
@@ -357,20 +357,32 @@ end
-- widened to everything the current view size can show so a full
-- zoom-out never runs past the rendered set. Re-run whenever the view
-- grows (zoom/resize), not only on setMap.
--
-- Neighbors are built eagerly here. A TileRenderer is now a light object --
-- the tile layer draws windowed to the camera, so nothing per-map is
-- constructed up front (see TileRenderer) -- so there is no build cost to
-- amortize and no prefetch race to lose at a seam. That is what the old
-- one-per-frame streaming queue existed to hide, and it is gone.
function OverworldState:rebuildNeighbors()
local mapId = self.map.id
self.neighbors = {}
local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS
local vw, vh = Game.renderer:worldViewSize()
self.neighborViewW, self.neighborViewH = vw, vh
-- resident set the eviction pass must never touch: the current map plus
-- every drawn neighbor
local keep = { [mapId] = true }
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, mapId,
hops,
math.floor(vw / 2) + 64,
math.floor(vh / 2) + 64)) do
table.insert(self.neighbors,
{ map = MapLoader.load(Game.data, n.id),
ox = n.ox, oy = n.oy })
keep[n.id] = true
local m = MapLoader.load(Game.data, n.id)
table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy })
end
-- bound resident memory: drop maps behind us that are neither current nor
-- a drawn neighbor, releasing their window batch / border image / atlas
MapLoader.trim(keep)
-- visual-only NPCs on connected maps (survey zoom): same spawn filter
-- as a real map entry, but they never join self.entities -- no sight
@@ -434,7 +446,9 @@ function OverworldState:paletteNameFor(map)
return Runtime.call("map.palette", samePalette, name, map)
end
-- UI-pass palette (text boxes and menus tint with the current map)
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
-- resolves every name to the one global red BG palette inside PaletteFX.pal,
-- so this needs no mode-specific branch.
function OverworldState:sgbPalettes()
local PaletteFX = require("src.render.PaletteFX")
return PaletteFX.wholeNamed(Game.data, self:paletteNameFor(self.map))
@@ -3346,9 +3360,9 @@ function OverworldState:drawWorld()
-- reorder (no draws), so they run once for both paths.
local tilt = Tilt.active()
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
self.map.renderer:draw(cam.x, bgY)
self.map.renderer:draw(cam.x, bgY, vw, vh)
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy)
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
end
-- per-billboard SGB palette source; only needed (and only paid for) when
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
@@ -3492,11 +3506,21 @@ function OverworldState:drawWorld()
end)
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
-- picks question/happy crops instead
local rect = bubble.bubbles and bubble.bubbles[self.emote.bubble or 1]
local bi = self.emote.bubble or 1
local rect = bubble.bubbles and bubble.bubbles[bi]
if ok and img and rect then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, love.graphics.newQuad(rect.x, rect.y,
rect.w, rect.h, img:getDimensions()), ex, ey)
-- one Quad per bubble crop, cached: this draws every frame the "!"
-- (or the emote-command crops) is up, so a fresh Quad here churned
-- the GC. The bubble set is small and fixed, so the cache is bounded.
self.emoteQuads = self.emoteQuads or {}
local q = self.emoteQuads[bi]
if not q then
q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
img:getDimensions())
self.emoteQuads[bi] = q
end
love.graphics.draw(img, q, ex, ey)
drawn = true
end
end