Optimize memory by freeing up SDL2 textures

* Added to the F3 Debug Overlay is a "Texture:" label that counts the number
  of textures currently loaded by the (SDL2) render engine.
* Added Teardown() functions to Level, Doodad and the Chunker they both use
  to free up SDL2 textures for all their cached graphics.
* The Canvas.Destroy() function now cleans up all textures that the Canvas
  is responsible for: calling the Teardown() of the Level or Doodad, calling
  Destroy() on all level actors, and cleaning up Wallpaper textures.
* The Destroy() method of the game's various Scenes will properly Destroy()
  their canvases to clean up when transitioning to another scene. The
  MainScene, MenuScene, EditorScene and PlayScene.
* Fix the sprites package to actually cache the ui.Image widgets. The game
  has very few sprites so no need to free them just yet.

Some tricky places that were leaking textures have been cleaned up:

* Canvas.InstallActors() destroys the canvases of existing actors before it
  reinitializes the list and installs the replacements.
* The DraggableActor when the user is dragging an actor around their level
  cleans up the blueprint masked drag/drop actor before nulling it out.

Misc changes:

* The player character cheats during Play Mode will immediately swap out the
  player character on the current level.
* Properly call the Close() function instead of Hide() to dismiss popup
  windows. The Close() function itself calls Hide() but also triggers
  WindowClose event handlers. The Doodad Dropper subscribes to its close
  event to free textures for all its doodad canvases.
pull/84/head
Noah 2022-04-09 14:41:24 -07:00
parent dbd79ad972
commit db5760ee83
20 changed files with 272 additions and 33 deletions

View File

@ -19,6 +19,9 @@ func (c Command) cheatCommand(d *Doodle) bool {
// Some cheats only work in Play Mode.
playScene, isPlay := d.Scene.(*PlayScene)
// If a character cheat is used during Play Mode, replace the player NOW.
var setPlayerCharacter bool
// Cheat codes
switch c.Raw {
case balance.CheatUncapFPS:
@ -117,22 +120,27 @@ func (c Command) cheatCommand(d *Doodle) bool {
case balance.CheatPlayAsBird:
balance.PlayerCharacterDoodad = "bird-red.doodad"
setPlayerCharacter = true
d.Flash("Set default player character to Bird (red)")
case balance.CheatPlayAsBoy:
balance.PlayerCharacterDoodad = "boy.doodad"
setPlayerCharacter = true
d.Flash("Set default player character to Boy")
case balance.CheatPlayAsAzuBlue:
balance.PlayerCharacterDoodad = "azu-blu.doodad"
setPlayerCharacter = true
d.Flash("Set default player character to Blue Azulian")
case balance.CheatPlayAsThief:
balance.PlayerCharacterDoodad = "thief.doodad"
setPlayerCharacter = true
d.Flash("Set default player character to Thief")
case balance.CheatPlayAsAnvil:
balance.PlayerCharacterDoodad = "anvil.doodad"
setPlayerCharacter = true
d.Flash("Set default player character to the Anvil")
case balance.CheatGodMode:
@ -173,5 +181,10 @@ func (c Command) cheatCommand(d *Doodle) bool {
return false
}
// If we're setting the player character and in Play Mode, do it.
if setPlayerCharacter && isPlay {
playScene.SetPlayerCharacter(balance.PlayerCharacterDoodad)
}
return true
}

View File

@ -52,6 +52,29 @@ func New(size int) *Doodad {
}
}
// Teardown cleans up texture cache memory when the doodad is no longer needed by the game.
func (d *Doodad) Teardown() {
var (
chunks int
textures int
)
for _, layer := range d.Layers {
for coord := range layer.Chunker.IterChunks() {
if chunk, ok := layer.Chunker.GetChunk(coord); ok {
freed := chunk.Teardown()
chunks++
textures += freed
}
}
}
// Debug log if any textures were actually freed.
if textures > 0 {
log.Debug("Teardown doodad (%s): Freed %d textures across %d chunks", d.Title, textures, chunks)
}
}
// Tag gets a value from the doodad's tags.
func (d *Doodad) Tag(name string) string {
if v, ok := d.Tags[name]; ok {

View File

@ -584,5 +584,9 @@ func (s *EditorScene) SaveDoodad(filename string) error {
// Destroy the scene.
func (s *EditorScene) Destroy() error {
// Free SDL2 textures. Note: if they are switching to the Editor, the chunks still have
// their bitmaps cached and will regen the textures as needed.
s.UI.Teardown()
return nil
}

View File

@ -147,6 +147,12 @@ func NewEditorUI(d *Doodle, s *EditorScene) *EditorUI {
return u
}
// Teardown the UI manager and free all the SDL2 textures under its control.
func (u *EditorUI) Teardown() {
log.Debug("EditorUI.Teardown()")
u.Canvas.Destroy()
}
// FinishSetup runs the Setup tasks that must be postponed til the end, such
// as rendering the Palette window so that it can accurately show the palette
// loaded from a level.
@ -396,10 +402,10 @@ func (u *EditorUI) SetupCanvas(d *Doodle) *uix.Canvas {
}
// Handle the Canvas deleting our actors in edit mode.
drawing.OnDeleteActors = func(actors []*level.Actor) {
drawing.OnDeleteActors = func(actors []*uix.Actor) {
if u.Scene.Level != nil {
for _, actor := range actors {
u.Scene.Level.Actors.Remove(actor)
u.Scene.Level.Actors.Remove(actor.Actor)
}
u.Scene.Level.PruneLinks()
drawing.InstallActors(u.Scene.Level.Actors)
@ -446,6 +452,7 @@ func (u *EditorUI) SetupCanvas(d *Doodle) *uix.Canvas {
// The actor has been dropped so null it out.
defer func() {
u.DraggableActor.Teardown()
u.DraggableActor = nil
}()

View File

@ -20,6 +20,12 @@ type DraggableActor struct {
actor *level.Actor // if a level actor
}
// Teardown the DraggableActor and free its textures.
func (da *DraggableActor) Teardown() {
log.Debug("Teardown DraggableActor")
da.canvas.Destroy()
}
// startDragActor begins the drag event for a Doodad onto a level.
// actor may be nil (if you drag a new doodad from the palette) or otherwise
// is an existing actor from the level.

View File

@ -27,7 +27,7 @@ Functions to manage popup windows in the Editor Mode, such as:
// Opens the "Layers" window (for editing doodads)
func (u *EditorUI) OpenLayersWindow() {
u.layersWindow.Hide()
u.layersWindow.Close()
u.layersWindow = nil
u.SetupPopups(u.d)
u.layersWindow.Show()
@ -36,7 +36,7 @@ func (u *EditorUI) OpenLayersWindow() {
// OpenPaletteWindow opens the Palette Editor window.
func (u *EditorUI) OpenPaletteWindow() {
// TODO: recompute the window so the actual loaded level palette gets in
u.paletteEditor.Hide()
u.paletteEditor.Close()
u.paletteEditor = nil
u.SetupPopups(u.d)
u.paletteEditor.Show()
@ -83,12 +83,12 @@ func (u *EditorUI) OpenPublishWindow() {
u.d.Flash("Saved level: %s", u.Scene.filename)
},
OnCancel: func() {
u.publishWindow.Hide()
u.publishWindow.Close()
},
})
u.ConfigureWindow(u.d, u.publishWindow)
u.publishWindow.Hide()
u.publishWindow.Close()
// u.publishWindow = nil
u.SetupPopups(u.d)
u.publishWindow.Show()
@ -96,7 +96,7 @@ func (u *EditorUI) OpenPublishWindow() {
// OpenPublishWindow opens the FileSystem window.
func (u *EditorUI) OpenFileSystemWindow() {
u.filesystemWindow.Hide()
u.filesystemWindow.Close()
u.filesystemWindow = nil
u.SetupPopups(u.d)
u.filesystemWindow.Show()
@ -115,7 +115,7 @@ func (u *EditorUI) ConfigureWindow(d *Doodle, window *ui.Window) {
Y: (d.height / 2) - (size.H / 2),
})
window.Hide()
window.Close()
}
// SetupPopups preloads popup windows like the DoodadDropper.
@ -126,19 +126,19 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
Supervisor: u.Supervisor,
Engine: d.Engine,
OnCancel: func() {
u.licenseWindow.Hide()
u.licenseWindow.Close()
},
}
cfg.OnLicensed = func() {
// License status has changed, reload the window!
if u.licenseWindow != nil {
u.licenseWindow.Hide()
u.licenseWindow.Close()
}
u.licenseWindow = windows.MakeLicenseWindow(d.width, d.height, cfg)
}
cfg.OnLicensed()
u.licenseWindow.Hide()
u.licenseWindow.Close()
}
// Doodad Dropper.
@ -149,7 +149,7 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
OnStartDragActor: u.startDragActor,
OnCancel: func() {
u.doodadWindow.Hide()
u.doodadWindow.Close()
},
})
u.ConfigureWindow(d, u.doodadWindow)
@ -185,6 +185,7 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
log.Info("OnChangePageTypeAndWallpaper called: %+v, %+v", pageType, wallpaper)
scene.Level.PageType = pageType
scene.Level.Wallpaper = wallpaper
u.Canvas.Destroy() // clean up old textures
u.Canvas.LoadLevel(scene.Level)
},
OnReload: func() {
@ -192,7 +193,7 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
scene.Reset()
},
OnCancel: func() {
u.levelSettingsWindow.Hide()
u.levelSettingsWindow.Close()
},
})
u.ConfigureWindow(d, u.levelSettingsWindow)
@ -210,7 +211,7 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
// Rebuild the window. TODO: hacky af.
cfg.OnRefresh = func() {
u.doodadPropertiesWindow.Hide()
u.doodadPropertiesWindow.Close()
u.doodadPropertiesWindow = nil
u.SetupPopups(u.d)
u.doodadPropertiesWindow.Show()
@ -262,7 +263,7 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
return true
},
OnCancel: func() {
u.filesystemWindow.Hide()
u.filesystemWindow.Close()
},
})
u.ConfigureWindow(d, u.filesystemWindow)
@ -313,13 +314,13 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
log.Info("Added new palette color: %+v", sw)
// Awkward but... reload this very same window.
u.paletteEditor.Hide()
u.paletteEditor.Close()
u.paletteEditor = nil
u.SetupPopups(d)
u.paletteEditor.Show()
},
OnCancel: func() {
u.paletteEditor.Hide()
u.paletteEditor.Close()
},
})
u.ConfigureWindow(d, u.paletteEditor)
@ -347,7 +348,7 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
// Awkward but... reload this very same window.
// Otherwise, the window doesn't update to show the new
// layer having been added.
u.layersWindow.Hide()
u.layersWindow.Close()
u.layersWindow = nil
u.SetupPopups(d)
u.layersWindow.Show()
@ -364,7 +365,7 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
// Awkward but... reload this very same window.
// Otherwise, the window doesn't update to show the new
// layer having been added.
u.layersWindow.Hide()
u.layersWindow.Close()
u.layersWindow = nil
u.SetupPopups(d)
u.layersWindow.Show()
@ -380,7 +381,7 @@ func (u *EditorUI) SetupPopups(d *Doodle) {
u.Scene.ActiveLayer = index
},
OnCancel: func() {
u.layersWindow.Hide()
u.layersWindow.Close()
},
})
u.ConfigureWindow(d, u.layersWindow)

View File

@ -9,6 +9,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/drawtool"
"git.kirsle.net/apps/doodle/pkg/uix"
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/sdl"
"git.kirsle.net/go/ui"
)
@ -57,6 +58,13 @@ func (d *Doodle) DrawDebugOverlay() {
framesSkipped = "uncapped"
}
// Get the size of cached SDL2 textures at the render engine level.
var texCount = "n/a"
if sdl, ok := d.Engine.(*sdl.Renderer); ok {
gotex, sdltex := sdl.CountTextures()
texCount = fmt.Sprintf("%d img, %d sdl", gotex, sdltex)
}
var (
darken = balance.DebugStrokeDarken
Yoffset = 20 // leave room for the menu bar
@ -65,11 +73,13 @@ func (d *Doodle) DrawDebugOverlay() {
"FPS:",
"Scene:",
"Mouse:",
"Textures:",
}
values = []string{
fmt.Sprintf("%d %s", fpsCurrent, framesSkipped),
d.Scene.Name(),
fmt.Sprintf("%d,%d", d.event.CursorX, d.event.CursorY),
texCount,
}
)

View File

@ -215,6 +215,26 @@ func (c *Chunk) ToBitmap(mask render.Color) image.Image {
return img
}
// Teardown the chunk and free (SDL2) texture memory in ways Go can not by itself.
// Returns the number of textures freed.
func (c *Chunk) Teardown() int {
var freed int
if c.texture != nil {
c.texture.Free()
c.texture = nil
freed++
}
if c.textureMasked != nil {
c.textureMasked.Free()
c.textureMasked = nil
freed++
}
return freed
}
// Set proxies to the accessor and flags the texture as dirty.
func (c *Chunk) Set(p render.Point, sw *Swatch) error {
c.dirty = true

View File

@ -68,6 +68,18 @@ func (c *Chunker) IterViewport(viewport render.Rect) <-chan Pixel {
return pipe
}
// IterChunks returns a channel to iterate over all chunks in the drawing.
func (c *Chunker) IterChunks() <-chan render.Point {
pipe := make(chan render.Point)
go func() {
for point := range c.Chunks {
pipe <- point
}
close(pipe)
}()
return pipe
}
// IterViewportChunks returns a channel to iterate over the Chunk objects that
// appear within the viewport rect, instead of the pixels in each chunk.
func (c *Chunker) IterViewportChunks(viewport render.Rect) <-chan render.Point {

View File

@ -8,6 +8,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/drawtool"
"git.kirsle.net/apps/doodle/pkg/enum"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/go/render"
)
@ -86,6 +87,25 @@ func New() *Level {
}
}
// Teardown the level when the game is done with it. This frees up SDL2 cached
// texture chunks and reclaims memory in ways the Go garbage collector can not.
func (m *Level) Teardown() {
var (
chunks int
textures int
)
for coord := range m.Chunker.IterChunks() {
if chunk, ok := m.Chunker.GetChunk(coord); ok {
freed := chunk.Teardown()
chunks++
textures += freed
}
}
log.Debug("Teardown level (%s): Freed %d textures across %d level chunks", m.Title, textures, chunks)
}
// Pixel associates a coordinate with a palette index.
type Pixel struct {
X int `json:"x"`

View File

@ -648,5 +648,7 @@ func (s *MainScene) Draw(d *Doodle) error {
// Destroy the scene.
func (s *MainScene) Destroy() error {
log.Debug("MainScene.Destroy(): clean up the demo level canvas")
s.canvas.Destroy()
return nil
}

View File

@ -154,6 +154,7 @@ func (s *MenuScene) setupNewWindow(d *Doodle) error {
Engine: d.Engine,
OnChangePageTypeAndWallpaper: func(pageType level.PageType, wallpaper string) {
log.Info("OnChangePageTypeAndWallpaper called: %+v, %+v", pageType, wallpaper)
s.canvas.Destroy() // clean up old textures
s.configureCanvas(pageType, wallpaper)
},
OnCreateNewLevel: func(lvl *level.Level) {
@ -238,5 +239,8 @@ func (s *MenuScene) Draw(d *Doodle) error {
// Destroy the scene.
func (s *MenuScene) Destroy() error {
// Free (wallpaper) textures.
s.canvas.Destroy()
return nil
}

View File

@ -88,6 +88,7 @@ func Resized() {
// Hide the loading screen.
func Hide() {
canvas.Destroy() // cleanup wallpaper textures
visible = false
}

View File

@ -908,5 +908,9 @@ func (s *PlayScene) LoadLevel(filename string) error {
// Destroy the scene.
func (s *PlayScene) Destroy() error {
// Free SDL2 textures. Note: if they are switching to the Editor, the chunks still have
// their bitmaps cached and will regen the textures as needed.
s.drawing.Destroy()
return nil
}

View File

@ -47,7 +47,12 @@ func LoadImage(e render.Engine, filename string) (*ui.Image, error) {
return nil, err
}
return ui.ImageFromImage(img)
if image, err := ui.ImageFromImage(img); err == nil {
cache[filename] = image
return image, nil
} else {
return nil, err
}
}
// WASM: try the file over HTTP ajax request.
@ -62,7 +67,12 @@ func LoadImage(e render.Engine, filename string) (*ui.Image, error) {
return nil, err
}
return ui.ImageFromImage(img)
if image, err := ui.ImageFromImage(img); err == nil {
cache[filename] = image
return image, nil
} else {
return nil, err
}
}
// Then try the file system.
@ -79,7 +89,12 @@ func LoadImage(e render.Engine, filename string) (*ui.Image, error) {
return nil, err
}
return ui.ImageFromImage(img)
if image, err := ui.ImageFromImage(img); err == nil {
cache[filename] = image
return image, nil
} else {
return nil, err
}
}
return nil, errors.New("no such sprite found")

View File

@ -79,7 +79,7 @@ type Canvas struct {
// When the Canvas wants to delete Actors, but ultimately it is upstream
// that controls the actors. Upstream should delete them and then reinstall
// the actor list from scratch.
OnDeleteActors func([]*level.Actor)
OnDeleteActors func([]*Actor)
OnDragStart func(*level.Actor)
// -- WHEN Canvas.Tool is "Link" --
@ -150,10 +150,34 @@ func NewCanvas(size int, editable bool) *Canvas {
return w
}
// Destroy the canvas.
//
// TODO: Not implemented, here to satisfy ui.Widget, should free up textures tho.
func (w *Canvas) Destroy() {}
/*
Destroy the canvas.
This function satisfies the ui.Widget interface but it also calls Teardown() methods
on the level or doodad as well as any level actors, which frees up SDL2 texture memory.
Note: the rest of the data can be garbage collected by Go normally, the textures are
able to regenerate themselves again if needed.
*/
func (w *Canvas) Destroy() {
if w.level != nil {
w.level.Teardown()
}
if w.doodad != nil {
w.doodad.Teardown()
}
for _, actor := range w.actors {
actor.Canvas.Destroy()
}
if w.wallpaper.WP != nil {
if freed := w.wallpaper.WP.Free(); freed > 0 {
log.Debug("%s.Destroy(): freed %d wallpaper textures", w, freed)
}
}
}
// Load initializes the Canvas using an existing Palette and Grid.
func (w *Canvas) Load(p *level.Palette, g *level.Chunker) {

View File

@ -27,6 +27,11 @@ func (w *Canvas) InstallActors(actors level.ActorMap) error {
}
sort.Strings(actorIDs)
// In case we are replacing the actors, free up all their textures first!
for _, actor := range w.actors {
actor.Canvas.Destroy()
}
w.actors = make([]*Actor, 0)
for _, id := range actorIDs {
var actor = actors[id]

View File

@ -460,7 +460,7 @@ func (w *Canvas) loopEditable(ev *event.State) error {
// log.Debug("ActorTool, cursor=%s WP=%s zoom=%d P=%s", cursor, WP, w.Zoom, ui.AbsolutePosition(w))
var deleteActors = []*level.Actor{}
var deleteActors = []*Actor{}
for _, actor := range w.actors {
// Compute the bounding box on screen where this doodad
@ -493,13 +493,13 @@ func (w *Canvas) loopEditable(ev *event.State) error {
if keybind.LeftClick(ev) {
// Pop this canvas out for the drag/drop.
if w.OnDragStart != nil {
deleteActors = append(deleteActors, actor.Actor)
deleteActors = append(deleteActors, actor)
w.OnDragStart(actor.Actor)
}
break
} else if ev.Button3 {
// Right click to delete an actor.
deleteActors = append(deleteActors, actor.Actor)
deleteActors = append(deleteActors, actor)
}
} else {
actor.Canvas.SetBorderSize(0)

View File

@ -11,6 +11,49 @@ import (
"git.kirsle.net/go/render"
)
/*
Free all SDL2 textures from memory.
The Canvas widget will free wallpaper textures in its Destroy method. Note that
if the wallpaper was still somehow in use, the textures will be regenerated the
next time a method like CornerTexture() asks for one.
Returns the number of textures freed (up to 4) or -1 if wallpaper was not ready.
*/
func (wp *Wallpaper) Free() int {
if !wp.ready {
return -1
}
var freed int
if wp.tex.corner != nil {
wp.tex.corner.Free()
wp.tex.corner = nil
freed++
}
if wp.tex.top != nil {
wp.tex.top.Free()
wp.tex.top = nil
freed++
}
if wp.tex.left != nil {
wp.tex.left.Free()
wp.tex.left = nil
freed++
}
if wp.tex.repeat != nil {
wp.tex.repeat.Free()
wp.tex.repeat = nil
freed++
}
return freed
}
// CornerTexture returns the Texture.
func (wp *Wallpaper) CornerTexture(e render.Engine) (render.Texturer, error) {
if !wp.ready {

View File

@ -41,6 +41,11 @@ func NewDoodadDropper(config DoodadDropper) *ui.Window {
// size of the doodad window
width = buttonSize * columns
height = (buttonSize * rows) + 64 // account for button borders :(
// Collect the Canvas widgets that all the doodads will be drawn in.
// When the doodad window is closed or torn down, we can free up
// the SDL2 textures and avoid a slow memory leak.
canvases = []*uix.Canvas{}
)
// Get all the doodads.
@ -79,6 +84,17 @@ func NewDoodadDropper(config DoodadDropper) *ui.Window {
Background: render.Grey,
})
// When the window is closed, clear canvas textures. Note: we still cache
// bitmap images in memory, those would be garbage collected by Go and SDL2
// textures can always be regenerated.
window.Handle(ui.CloseWindow, func(ed ui.EventData) error {
log.Debug("Doodad Dropper: window closed, free %d canvas textures", len(canvases))
for _, can := range canvases {
can.Destroy()
}
return nil
})
tabFrame := ui.NewTabFrame("Category Tabs")
window.Pack(tabFrame, ui.Pack{
Side: ui.N,
@ -103,7 +119,8 @@ func NewDoodadDropper(config DoodadDropper) *ui.Window {
Text: category.Name,
Font: balance.TabFont,
}))
makeDoodadTab(config, tab1, render.NewRect(width-4, height-60), category.ID, items)
cans := makeDoodadTab(config, tab1, render.NewRect(width-4, height-60), category.ID, items)
canvases = append(canvases, cans...)
}
tabFrame.Supervise(config.Supervisor)
@ -113,7 +130,7 @@ func NewDoodadDropper(config DoodadDropper) *ui.Window {
}
// Function to generate the TabFrame frame of the Doodads window.
func makeDoodadTab(config DoodadDropper, frame *ui.Frame, size render.Rect, category string, available []*doodads.Doodad) {
func makeDoodadTab(config DoodadDropper, frame *ui.Frame, size render.Rect, category string, available []*doodads.Doodad) []*uix.Canvas {
var (
buttonSize = balance.DoodadButtonSize
columns = balance.DoodadDropperCols
@ -128,6 +145,9 @@ func makeDoodadTab(config DoodadDropper, frame *ui.Frame, size render.Rect, cate
pages int
perPage = 20
maxPageButtons = 10
// Collect the created Canvas widgets so we can free SDL2 textures later.
canvases = []*uix.Canvas{}
)
frame.Resize(size)
@ -238,6 +258,9 @@ func makeDoodadTab(config DoodadDropper, frame *ui.Frame, size render.Rect, cate
can.SetBackground(balance.DoodadButtonBackground)
can.LoadDoodad(doodad)
// Keep the canvas to free textures later.
canvases = append(canvases, can)
btn := ui.NewButton(doodad.Title, can)
btn.Resize(render.NewRect(
buttonSize-2, // TODO: without the -2 the button border
@ -404,4 +427,6 @@ func makeDoodadTab(config DoodadDropper, frame *ui.Frame, size render.Rect, cate
})
}
}
return canvases
}