LoadUnloadChunk for Memory Optimization

Instead of the loadscreen eager-loading ALL level chunks to Go Images, only
load the chunks within the "LoadingViewport" - which is the on-screen
Viewport plus a margin of chunks off the screen edges.

During gameplay, every few ticks, reevaluate which chunks are inside or
outside the LoadingViewport; for chunks outside, free their SDL2 textures
and free their cached bitmaps to keep overall memory usage down. The
AzulianTag-Forest level now stays under 200 Textures at any given time
and the loadscreen goes faster as it doesn't have to load every chunk's
images up front.

The LoadUnloadChunk feature can be turned on/off with feature flags. If
disabled the old behavior is restored: loadscreen loads all images and
the LoadUnloadChunks function is not run.

Other changes:

* loadscreen: do not free textures in the Hide() function as this runs on
  a different goroutine and may break. The 4 wallpaper textures are OK
  to keep in memory anyway, the loadscreen is reused often!
* Free more leaked textures: on the Inventory frame and when an actor
  calls Self.Destroy()
* Stop leaking goroutines in the PubSub feature of the doodad script
  engine; scripting.Supervisor.Teardown() sends a stop signal to all
  scripts to clean up neatly. Canvas.Destroy() tears down its scripting
  supervisor automatically.
pull/84/head
Noah 2022-04-10 12:39:27 -07:00
parent d694fcc7c2
commit c5353df211
14 changed files with 222 additions and 23 deletions

4
go.mod
View File

@ -5,7 +5,7 @@ go 1.16
require (
git.kirsle.net/go/audio v0.0.0-20201121073642-65068820cbc0
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
git.kirsle.net/go/render v0.0.0-20220409212556-df69b8e52508
git.kirsle.net/go/render v0.0.0-20220410192720-c0c2d05619bc
git.kirsle.net/go/ui v0.0.0-20220409211920-3b653e503c5a
github.com/aichaos/rivescript-go v0.3.1
github.com/dgrijalva/jwt-go v3.2.0+incompatible
@ -14,7 +14,7 @@ require (
github.com/fsnotify/fsnotify v1.4.9
github.com/gen2brain/dlgs v0.0.0-20211108104213-bade24837f0b
github.com/google/uuid v1.3.0
github.com/gopherjs/gopherjs v0.0.0-20220221023154-0b2280d3ff96 // indirect
github.com/gopherjs/gopherjs v0.0.0-20220410123724-9e86199038b0 // indirect
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f
github.com/robertkrimen/otto v0.0.0-20211024170158-b87d35c0b86f // indirect
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect

8
go.sum
View File

@ -42,8 +42,8 @@ git.kirsle.net/go/audio v0.0.0-20201121073642-65068820cbc0/go.mod h1:RD2Kyiy2lga
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b h1:TDxEEWOJqMzsu9JW8/QgmT1lgQ9WD2KWlb2lKN/Ql2o=
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b/go.mod h1:jl+Qr58W3Op7OCxIYIT+b42jq8xFncJXzPufhrvza7Y=
git.kirsle.net/go/render v0.0.0-20211231003948-9e640ab5c3da/go.mod h1:ss7pvZbGWrMaDuZwyUTjV9+T0AJwAkxZZHwMFsvHrkk=
git.kirsle.net/go/render v0.0.0-20220409212556-df69b8e52508 h1:cL5XdHJgU81XFfsyXUO+6NxLcZ11IMaV/ZhiC5799cA=
git.kirsle.net/go/render v0.0.0-20220409212556-df69b8e52508/go.mod h1:ss7pvZbGWrMaDuZwyUTjV9+T0AJwAkxZZHwMFsvHrkk=
git.kirsle.net/go/render v0.0.0-20220410192720-c0c2d05619bc h1:tYbgVPFoADy9yQaH7+MeHXDATqTkSHoFXvZLcr9lwyc=
git.kirsle.net/go/render v0.0.0-20220410192720-c0c2d05619bc/go.mod h1:ss7pvZbGWrMaDuZwyUTjV9+T0AJwAkxZZHwMFsvHrkk=
git.kirsle.net/go/ui v0.0.0-20220409211920-3b653e503c5a h1:7pmqBcIfxOxCMCvnCS0XWM9NeXbzKTSQfiJc2IKJW+k=
git.kirsle.net/go/ui v0.0.0-20220409211920-3b653e503c5a/go.mod h1:cWD/tl2OUj0jWUQxW7BcapxiOFmnCPkrfzJt2tOiD1E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@ -169,8 +169,8 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20220221023154-0b2280d3ff96 h1:QJq7UBOuoynsywLk+aC75rC2Cbi2+lQRDaLaizhA+fA=
github.com/gopherjs/gopherjs v0.0.0-20220221023154-0b2280d3ff96/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gopherjs/gopherjs v0.0.0-20220410123724-9e86199038b0 h1:fWY+zXdWhvWndXqnMj4SyC/vi8sK508OjhGCtMzsA9M=
github.com/gopherjs/gopherjs v0.0.0-20220410123724-9e86199038b0/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=

View File

@ -20,6 +20,12 @@ var Feature = feature{
// Reassign an existing level's palette to a different builtin.
ChangePalette: true,
// LoadUnloadChunk feature to better optimize memory. Set it to false and the
// loadscreen will eager load all chunk bitmaps (stable, but uses a lot of
// memory), set true and the Canvas will load/unload bitmaps + free SDL textures
// for chunks falling outside the LoadingViewport (new, maybe unstable).
LoadUnloadChunk: true,
}
// FeaturesOn turns on all feature flags, from CLI --experimental option.
@ -33,4 +39,5 @@ type feature struct {
ChangePalette bool
EmbeddableDoodads bool
ViewportWindow bool
LoadUnloadChunk bool
}

View File

@ -113,6 +113,10 @@ var (
// `boolProp eager-render false` and the loadscreen will go quicker cuz it won't
// load the whole entire level. Maybe useful to explore memory issues.
EagerRenderLevelChunks = true
// Number of chunks margin outside the Canvas Viewport for the LoadingViewport.
LoadingViewportMarginChunks = 2
CanvasLoadUnloadModuloTicks uint64 = 4
)
// Edit Mode Values

View File

@ -44,9 +44,10 @@ type EditorScene struct {
ActiveLayer int // which layer (of a doodad) is being edited now?
// Custom debug overlay values.
debTool *string
debSwatch *string
debWorldIndex *string
debTool *string
debSwatch *string
debWorldIndex *string
debLoadingViewport *string
// Last saved filename by the user.
filename string
@ -65,10 +66,12 @@ func (s *EditorScene) Setup(d *Doodle) error {
s.debTool = new(string)
s.debSwatch = new(string)
s.debWorldIndex = new(string)
s.debLoadingViewport = new(string)
customDebugLabels = []debugLabel{
{"Pixel:", s.debWorldIndex},
{"Tool:", s.debTool},
{"Swatch:", s.debSwatch},
{"Chunks:", s.debLoadingViewport},
}
// Initialize autosave time.
@ -278,11 +281,16 @@ func (s *EditorScene) Loop(d *Doodle, ev *event.State) error {
*s.debTool = s.UI.Canvas.Tool.String()
*s.debSwatch = "???"
*s.debWorldIndex = s.UI.Canvas.WorldIndexAt(s.UI.cursor).String()
*s.debLoadingViewport = "???"
// Safely...
if s.UI.Canvas.Palette != nil && s.UI.Canvas.Palette.ActiveSwatch != nil {
*s.debSwatch = s.UI.Canvas.Palette.ActiveSwatch.Name
}
if s.UI.Canvas != nil {
inside, outside := s.UI.Canvas.LoadUnloadMetrics()
*s.debLoadingViewport = fmt.Sprintf("%d in %d out", inside, outside)
}
// Has the window been resized?
if ev.WindowResized {

View File

@ -61,8 +61,7 @@ func (d *Doodle) DrawDebugOverlay() {
// 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)
texCount = fmt.Sprintf("%d", sdl.CountTextures())
}
var (
@ -73,7 +72,7 @@ func (d *Doodle) DrawDebugOverlay() {
"FPS:",
"Scene:",
"Mouse:",
"Textures:",
"Tex:",
}
values = []string{
fmt.Sprintf("%d %s", fpsCurrent, framesSkipped),

View File

@ -220,6 +220,10 @@ func (c *Chunk) ToBitmap(mask render.Color) image.Image {
func (c *Chunk) Teardown() int {
var freed int
if c.bitmap != nil {
c.bitmap = nil
}
if c.texture != nil {
c.texture.Free()
c.texture = nil

View File

@ -87,9 +87,15 @@ func Resized() {
}
}
// Hide the loading screen.
/*
Hide the loading screen.
NOTICE: the loadscreen is hidden on an async goroutine and it is NOT SAFE to clean up
textures used by the wallpaper images, but this is OK because the loadscreen uses the
same wallpaper every time and is called many times during gameplay, it can hold its
textures.
*/
func Hide() {
canvas.Destroy() // cleanup wallpaper textures
visible = false
}
@ -225,6 +231,11 @@ func Loop(windowSize render.Rect, e render.Engine) {
// loading screen and will set the Progress percent based on the total number
// of chunks vs. chunks remaining to pre-cache bitmaps from.
func PreloadAllChunkBitmaps(chunker *level.Chunker) {
// If we're using the smarter (experimental) chunk loader, return.
if balance.Feature.LoadUnloadChunk {
return
}
loadChunksTarget := len(chunker.Chunks)
// Skipping the eager rendering of chunks?

View File

@ -62,6 +62,7 @@ type PlayScene struct {
debViewport *string
debScroll *string
debWorldIndex *string
debLoadUnload *string
// Player character
Player *uix.Actor
@ -141,11 +142,13 @@ func (s *PlayScene) setupAsync(d *Doodle) error {
s.debViewport = new(string)
s.debScroll = new(string)
s.debWorldIndex = new(string)
s.debLoadUnload = new(string)
customDebugLabels = []debugLabel{
{"Pixel:", s.debWorldIndex},
{"Player:", s.debPosition},
{"Viewport:", s.debViewport},
{"Scroll:", s.debScroll},
{"Chunks:", s.debLoadUnload},
}
// Initialize the "Edit Map" button.
@ -656,6 +659,8 @@ func (s *PlayScene) Loop(d *Doodle, ev *event.State) error {
*s.debPosition = s.Player.Position().String() + " vel " + s.Player.Velocity().String()
*s.debViewport = s.drawing.Viewport().String()
*s.debScroll = s.drawing.Scroll.String()
inside, outside := s.drawing.LoadUnloadMetrics()
*s.debLoadUnload = fmt.Sprintf("%d in %d out", inside, outside)
// Update the timer.
s.timerLabel.Text = savegame.FormatDuration(time.Since(s.startTime))
@ -912,5 +917,11 @@ func (s *PlayScene) Destroy() error {
// their bitmaps cached and will regen the textures as needed.
s.drawing.Destroy()
// Free inventory doodad textures.
for _, can := range s.invenDoodads {
log.Info("Destroy inventory doodad: %s", can)
can.Destroy()
}
return nil
}

View File

@ -31,19 +31,26 @@ func RegisterPublishHooks(s *Supervisor, vm *VM) {
}
}()
for msg := range vm.Inbound {
vm.muSubscribe.Lock()
// Watch the Inbound channel for PubSub messages and the stop channel for Teardown.
for {
select {
case <-vm.stop:
log.Info("JavaScript VM %s stopping PubSub goroutine", vm.Name)
return
case msg := <-vm.Inbound:
vm.muSubscribe.Lock()
if _, ok := vm.subscribe[msg.Name]; ok {
for _, callback := range vm.subscribe[msg.Name] {
log.Debug("PubSub: %s receives from %s: %s", vm.Name, msg.SenderID, msg.Name)
if function, ok := goja.AssertFunction(callback); ok {
function(goja.Undefined(), msg.Args...)
if _, ok := vm.subscribe[msg.Name]; ok {
for _, callback := range vm.subscribe[msg.Name] {
log.Debug("PubSub: %s receives from %s: %s", vm.Name, msg.SenderID, msg.Name)
if function, ok := goja.AssertFunction(callback); ok {
function(goja.Undefined(), msg.Args...)
}
}
}
}
vm.muSubscribe.Unlock()
vm.muSubscribe.Unlock()
}
}
}()

View File

@ -29,6 +29,14 @@ func NewSupervisor() *Supervisor {
}
}
// Teardown the supervisor to clean up goroutines.
func (s *Supervisor) Teardown() {
log.Info("scripting.Teardown(): stop all (%d) scripts", len(s.scripts))
for _, vm := range s.scripts {
vm.stop <- true
}
}
// Loop the supervisor to invoke timer events in any running scripts.
func (s *Supervisor) Loop() error {
now := time.Now()

View File

@ -25,6 +25,7 @@ type VM struct {
// messages.
Inbound chan Message
Outbound []chan Message
stop chan bool
subscribe map[string][]goja.Value // Subscribed message handlers by name.
muSubscribe sync.RWMutex
@ -45,6 +46,7 @@ func NewVM(name string) *VM {
// Pub/sub structs.
Inbound: make(chan Message),
Outbound: []chan Message{},
stop: make(chan bool, 1),
subscribe: map[string][]goja.Value{},
}
vm.Events = NewEvents(vm.vm)

View File

@ -113,6 +113,10 @@ type Canvas struct {
scrollStartAt render.Point // Cursor point at beginning of pan
scrollWasAt render.Point // copy of Scroll at beginning of pan
scrollLastDelta render.Point // multitouch spam
// LoadUnloadChunks metrics for the debug overlay.
loadUnloadInside int
loadUnloadOutside int
}
// NewCanvas initializes a Canvas widget.
@ -177,6 +181,10 @@ func (w *Canvas) Destroy() {
log.Debug("%s.Destroy(): freed %d wallpaper textures", w, freed)
}
}
if w.scripting != nil {
w.scripting.Teardown()
}
}
// Load initializes the Canvas using an existing Palette and Grid.
@ -263,10 +271,14 @@ func (w *Canvas) Loop(ev *event.State) error {
}
_ = w.loopConstrainScroll()
// Every so often, eager-load/unload chunk bitmaps to save on memory.
w.LoadUnloadChunks()
// Remove any actors that were destroyed the previous tick.
var newActors []*Actor
for _, a := range w.actors {
if a.flagDestroy {
a.Canvas.Destroy()
continue
}
newActors = append(newActors, a)
@ -289,6 +301,7 @@ func (w *Canvas) Loop(ev *event.State) error {
return w.loopEditable(ev)
}
}
return nil
}
@ -330,6 +343,40 @@ func (w *Canvas) ViewportRelative() render.Rect {
}
}
// LoadingViewport is the viewport of chunks that ought to be preloaded and
// ready to display soon. It is the Viewport of chunks on screen + a margin
// of neighboring chunks outside the screen.
//
// For memory optimization, chunks falling inside this viewport have their
// Go image.Image rendered and cached ready to convert to an SDL2 Texture
// when they come on screen. Chunks outside of the LoadingViewport can be
// unloaded (textures and images freed) to keep memory consumption on large
// levels under control.
func (w *Canvas) LoadingViewport() render.Rect {
var (
chunkSize int
vp = w.Viewport()
margin = balance.LoadingViewportMarginChunks
)
// This function is meant for levels only, but..
if w.level != nil {
chunkSize = w.level.Chunker.Size
} else if w.doodad != nil {
chunkSize = w.doodad.ChunkSize()
} else {
chunkSize = balance.ChunkSize
log.Error("Canvas.LoadingViewport: no drawing to get chunk size from, default to %d", chunkSize)
}
return render.Rect{
X: vp.X - chunkSize*margin,
Y: vp.Y - chunkSize*margin,
W: vp.W + chunkSize*margin,
H: vp.H + chunkSize*margin,
}
}
// WorldIndexAt returns the World Index that corresponds to a Screen Pixel
// on the screen. If the screen pixel is the mouse coordinate (relative to
// the application window) this will return the World Index of the pixel below

91
pkg/uix/canvas_memory.go Normal file
View File

@ -0,0 +1,91 @@
package uix
import (
"runtime"
"sync"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/shmem"
"git.kirsle.net/go/render"
)
// Memory optimization features of the Canvas.
/*
LoadUnloadChunks optimizes memory for (level) canvases by warming up chunk images
that fall within the LoadingViewport and freeing chunks that are outside of it.
*/
func (w *Canvas) LoadUnloadChunks() {
if w.level == nil || shmem.Tick%balance.CanvasLoadUnloadModuloTicks != 0 || !balance.Feature.LoadUnloadChunk {
return
}
var (
vp = w.LoadingViewport()
chunks = make(chan render.Point)
chunksInside = map[render.Point]interface{}{}
chunksTeardown = []*level.Chunk{}
cores = runtime.NumCPU()
wg sync.WaitGroup
// Collect metrics for the debug overlay.
resultInside int
resultOutside int
)
// Collect the chunks that are inside the viewport so we know which ones are not.
for chunk := range w.level.Chunker.IterViewportChunks(vp) {
chunksInside[chunk] = nil
}
// Spawn background goroutines to process the chunks quickly.
for i := 0; i < cores; i++ {
wg.Add(1)
go func(i int) {
for coord := range chunks {
if chunk, ok := w.level.Chunker.GetChunk(coord); ok {
chunk := chunk
if _, ok := chunksInside[coord]; ok {
// Preload its bitmap image.
_ = chunk.CachedBitmap(render.Invisible)
resultInside++
} else {
// Unload its bitmap and texture.
chunksTeardown = append(chunksTeardown, chunk)
resultOutside++
}
}
}
wg.Done()
}(i)
}
for chunk := range w.level.Chunker.IterChunks() {
chunks <- chunk
}
close(chunks)
wg.Wait()
// Tear down the SDL2 textures of chunks to free.
for i, chunk := range chunksTeardown {
if chunk == nil {
log.Error("LoadUnloadChunks: chunksTeardown#%d was nil??", i)
continue
}
chunk.Teardown()
}
// Export the metrics for the debug overlay.
w.loadUnloadInside = resultInside
w.loadUnloadOutside = resultOutside
}
// LoadUnloadMetrics returns the canvas's stored metrics from the LoadUnloadChunks
// function, for the debug overlay.
func (w *Canvas) LoadUnloadMetrics() (inside, outside int) {
return w.loadUnloadInside, w.loadUnloadOutside
}