Lazily load sprite image data

This cuts memory use significantly, since many sprites in an object are
never used. We can get savings over time by evicting sprites when they
go out of scope, but that's, well, out of scope.

To achieve this, I introduce an assetstore package that is in charge of
loading things from the filesystem. This also allows some lingering
case-sensitivity issues to be handled cleanly.

I'd hoped that creating fewer ebiten.Image instances would help CPU
usage, but that doesn't seem to be the case.
This commit is contained in:
2020-03-19 22:24:21 +00:00
parent 34d12edc2a
commit 5fccf97f4b
8 changed files with 440 additions and 152 deletions

View File

@@ -0,0 +1,125 @@
package assetstore
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
)
const (
RootDir = "" // Used in the entryMap for entries pertaining to the root dir
)
type entryMap map[string]map[string]string
// type AssetStore is responsible for lazily loading game data when it is
// required. Applications shouldn't need to do anything except set one of these
// up, pointing at the game dir root, to access all assets.
//
// Assets should be loaded on-demand to keep memory costs as low as possible.
// Cross-platform differences such as filename case sensitivity are also dealt
// with here.
//
// We assume the directory is read-only. You can run Refresh() if you make a
// change.
type AssetStore struct {
RootDir string
// Case-insensitive file lookup.
// {"":{"anim":"Anim", "obj":"Obj", ...}, "anim":{ "warhammer.ani":"WarHammer.ani" }, ...}
entries entryMap
// These members are used to store things we've already loaded
maps map[string]*Map
objs map[string]*Object
sets map[string]*Set
}
// New returns a new AssetStore
func New(dir string) (*AssetStore, error) {
store := &AssetStore{
RootDir: dir,
}
// fill entryMap
if err := store.Refresh(); err != nil {
return nil, err
}
return store, nil
}
func (a *AssetStore) Refresh() error {
rootEntries, err := processDir(a.RootDir)
if err != nil {
return fmt.Errorf("failed to process %v: %v", a.RootDir, err)
}
newEntryMap := make(entryMap, len(rootEntries))
newEntryMap[RootDir] = rootEntries
for lower, natural := range rootEntries {
path := filepath.Join(a.RootDir, natural)
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("Failed to stat %v: %v", path, err)
}
if fi.IsDir() {
entries, err := processDir(path)
if err != nil {
return fmt.Errorf("Failed to process %v: %v", path, err)
}
newEntryMap[lower] = entries
}
}
// Refresh
a.entries = newEntryMap
a.maps = make(map[string]*Map)
a.objs = make(map[string]*Object)
a.sets = make(map[string]*Set)
return nil
}
func (a *AssetStore) lookup(name, ext string, dirs ...string) (string, error) {
filename := canonical(name + "." + ext)
for _, dir := range dirs {
dir = canonical(dir)
if base, ok := a.entries[dir]; ok {
if file, ok := base[filename]; ok {
actualDir := a.entries[RootDir][dir]
return filepath.Join(a.RootDir, actualDir, file), nil
}
}
}
return "", os.ErrNotExist
}
func canonical(s string) string {
return strings.ToLower(s)
}
func processDir(dir string) (map[string]string, error) {
entries, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
}
out := make(map[string]string, len(entries))
for _, entry := range entries {
if entry.Name() == "." || entry.Name() == ".." {
continue
}
out[canonical(entry.Name())] = entry.Name()
}
return out, nil
}

View File

@@ -0,0 +1,89 @@
package assetstore
import (
"github.com/hajimehoshi/ebiten"
"image"
"code.ur.gs/lupine/ordoor/internal/maps"
)
type Map struct {
assets *AssetStore
set *Set
Rect image.Rectangle
raw *maps.GameMap
}
// Map loads a game map with the given name (e.g. "Chapter01")
func (a *AssetStore) Map(name string) (*Map, error) {
name = canonical(name)
if m, ok := a.maps[name]; ok {
return m, nil
}
mapFile, err := a.lookup(name, "map", "Maps", "MultiMaps")
if err != nil {
return nil, err
}
txtFile, err := a.lookup(name, "txt", "Maps", "MultiMaps")
if err != nil {
return nil, err
}
raw, err := maps.LoadGameMapByFiles(mapFile, txtFile)
if err != nil {
return nil, err
}
// The set for a map is small and frequently referenced, so load it here
set, err := a.Set(raw.MapSetName())
if err != nil {
return nil, err
}
m := &Map{
Rect: image.Rect(
int(raw.MinWidth),
int(raw.MinLength),
int(raw.MaxWidth),
int(raw.MaxLength),
),
assets: a,
raw: raw,
set: set,
}
a.maps[canonical(name)] = m
return m, nil
}
// ImagesForCell returns the sprites needed to correctly render this cell.
// They should be rendered from first to last to get the correct ordering
func (m *Map) ImagesForCell(x, y, z int) ([]*ebiten.Image, error) {
cell := m.raw.At(x, y, z)
images := make([]*ebiten.Image, 0, 4)
for _, ref := range []maps.ObjRef{cell.Surface, cell.Center, cell.Left, cell.Right} {
if !ref.IsActive() {
continue
}
obj, err := m.set.Object(ref.Index())
if err != nil {
return nil, err
}
img, err := obj.Image(ref.Sprite())
if err != nil {
return nil, err
}
images = append(images, img)
}
return images, nil
}

View File

@@ -0,0 +1,64 @@
package assetstore
import (
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/data"
)
type Object struct {
assets *AssetStore
images []*ebiten.Image
raw *data.Object
}
func (a *AssetStore) Object(name string) (*Object, error) {
name = canonical(name)
if obj, ok := a.objs[name]; ok {
return obj, nil
}
filename, err := a.lookup(name, "obj", "Obj")
if err != nil {
return nil, err
}
raw, err := data.LoadObjectLazily(filename)
if err != nil {
return nil, err
}
obj := &Object{
assets: a,
images: make([]*ebiten.Image, int(raw.NumSprites)),
raw: raw,
}
a.objs[name] = obj
return obj, nil
}
// Filled lazily
func (o *Object) Image(idx int) (*ebiten.Image, error) {
if img := o.images[idx]; img != nil {
return img, nil
}
if o.raw.Sprites[idx] == nil {
if err := o.raw.LoadSprite(idx); err != nil {
return nil, err
}
}
stdImg := o.raw.Sprites[idx].ToImage()
img, err := ebiten.NewImageFromImage(stdImg, ebiten.FilterDefault)
if err != nil {
return nil, err
}
o.images[idx] = img
return img, nil
}

View File

@@ -0,0 +1,50 @@
package assetstore
import (
"errors"
"code.ur.gs/lupine/ordoor/internal/sets"
)
var (
ErrOutOfBounds = errors.New("Out of bounds")
)
type Set struct {
assets *AssetStore
raw *sets.MapSet
}
func (s *Set) Object(idx int) (*Object, error) {
if idx < 0 || idx >= len(s.raw.Palette) {
return nil, ErrOutOfBounds
}
return s.assets.Object(s.raw.Palette[idx])
}
func (a *AssetStore) Set(name string) (*Set, error) {
name = canonical(name)
if set, ok := a.sets[name]; ok {
return set, nil
}
filename, err := a.lookup(name, "set", "Sets")
if err != nil {
return nil, err
}
raw, err := sets.LoadSet(filename)
if err != nil {
return nil, err
}
set := &Set{
assets: a,
raw: raw,
}
a.sets[name] = set
return set, nil
}