doodle/pkg/level/chunker.go
Noah Petherbridge db5760ee83 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.
2022-04-09 14:41:24 -07:00

350 lines
8.7 KiB
Go

package level
import (
"encoding/json"
"fmt"
"math"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/go/render"
)
// Chunker is the data structure that manages the chunks of a level, and
// provides the API to interact with the pixels using their absolute coordinates
// while abstracting away the underlying details.
type Chunker struct {
Size int `json:"size"`
Chunks ChunkMap `json:"chunks"`
}
// NewChunker creates a new chunk manager with a given chunk size.
func NewChunker(size int) *Chunker {
return &Chunker{
Size: size,
Chunks: ChunkMap{},
}
}
// Inflate iterates over the pixels in the (loaded) chunks and expands any
// Sparse Swatches (which have only their palette index, from the file format
// on disk) to connect references to the swatches in the palette.
func (c *Chunker) Inflate(pal *Palette) error {
for coord, chunk := range c.Chunks {
chunk.Point = coord
chunk.Size = c.Size
chunk.Inflate(pal)
}
return nil
}
// IterViewport returns a channel to iterate every point that exists within
// the viewport rect.
func (c *Chunker) IterViewport(viewport render.Rect) <-chan Pixel {
pipe := make(chan Pixel)
go func() {
// Get the chunk box coordinates.
var (
topLeft = c.ChunkCoordinate(render.NewPoint(viewport.X, viewport.Y))
bottomRight = c.ChunkCoordinate(render.Point{
X: viewport.X + viewport.W,
Y: viewport.Y + viewport.H,
})
)
for cx := topLeft.X; cx <= bottomRight.X; cx++ {
for cy := topLeft.Y; cy <= bottomRight.Y; cy++ {
if chunk, ok := c.GetChunk(render.NewPoint(cx, cy)); ok {
for px := range chunk.Iter() {
// Verify this pixel is also in range.
if px.Point().Inside(viewport) {
pipe <- px
}
}
}
}
}
close(pipe)
}()
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 {
pipe := make(chan render.Point)
go func() {
sent := make(map[render.Point]interface{})
for x := viewport.X; x < viewport.W; x += (c.Size / 4) {
for y := viewport.Y; y < viewport.H; y += (c.Size / 4) {
// Constrain this chunksize step to a point within the bounds
// of the viewport. This can yield partial chunks on the edges
// of the viewport.
point := render.NewPoint(x, y)
if point.X < viewport.X {
point.X = viewport.X
} else if point.X > viewport.X+viewport.W {
point.X = viewport.X + viewport.W
}
if point.Y < viewport.Y {
point.Y = viewport.Y
} else if point.Y > viewport.Y+viewport.H {
point.Y = viewport.Y + viewport.H
}
// Translate to a chunk coordinate, dedupe and send it.
coord := c.ChunkCoordinate(render.NewPoint(x, y))
if _, ok := sent[coord]; ok {
continue
}
sent[coord] = nil
if _, ok := c.GetChunk(coord); ok {
pipe <- coord
}
}
}
close(pipe)
}()
return pipe
}
// IterPixels returns a channel to iterate over every pixel in the entire
// chunker.
func (c *Chunker) IterPixels() <-chan Pixel {
pipe := make(chan Pixel)
go func() {
for _, chunk := range c.Chunks {
for px := range chunk.Iter() {
pipe <- px
}
}
close(pipe)
}()
return pipe
}
// WorldSize returns the bounding coordinates that the Chunker has chunks to
// manage: the lowest pixels from the lowest chunks to the highest pixels of
// the highest chunks.
func (c *Chunker) WorldSize() render.Rect {
chunkLowest, chunkHighest := c.Bounds()
return render.Rect{
X: chunkLowest.X * c.Size,
Y: chunkLowest.Y * c.Size,
W: (chunkHighest.X * c.Size) + (c.Size - 1),
H: (chunkHighest.Y * c.Size) + (c.Size - 1),
}
}
// WorldSizePositive returns the WorldSize anchored to 0,0 with only positive
// coordinates.
func (c *Chunker) WorldSizePositive() render.Rect {
S := c.WorldSize()
return render.Rect{
X: 0,
Y: 0,
W: int(math.Abs(float64(S.X))) + S.W,
H: int(math.Abs(float64(S.Y))) + S.H,
}
}
// Bounds returns the boundary points of the lowest and highest chunk which
// have any data in them.
func (c *Chunker) Bounds() (low, high render.Point) {
for coord := range c.Chunks {
if coord.X < low.X {
low.X = coord.X
}
if coord.Y < low.Y {
low.Y = coord.Y
}
if coord.X > high.X {
high.X = coord.X
}
if coord.Y > high.Y {
high.Y = coord.Y
}
}
return low, high
}
// GetChunk gets a chunk at a certain position. Returns false if not found.
func (c *Chunker) GetChunk(p render.Point) (*Chunk, bool) {
chunk, ok := c.Chunks[p]
return chunk, ok
}
// Redraw marks every chunk as dirty and invalidates all their texture caches,
// forcing the drawing to re-generate from scratch.
func (c *Chunker) Redraw() {
for _, chunk := range c.Chunks {
chunk.SetDirty()
}
}
// Prerender visits every chunk and fetches its texture, in order to pre-load
// the whole drawing for smooth gameplay rather than chunks lazy rendering as
// they enter the screen.
func (c *Chunker) Prerender() {
for _, chunk := range c.Chunks {
_ = chunk.CachedBitmap(render.Invisible)
}
}
// PrerenderN will pre-render the texture for N number of chunks and then
// yield back to the caller. Returns the number of chunks that still need
// textures rendered; zero when the last chunk has been prerendered.
func (c *Chunker) PrerenderN(n int) (remaining int) {
var (
total int // total no. of chunks available
totalRendered int // no. of chunks with textures
modified int // number modified this call
)
for _, chunk := range c.Chunks {
total++
if chunk.bitmap != nil {
totalRendered++
continue
}
if modified < n {
_ = chunk.CachedBitmap(render.Invisible)
totalRendered++
modified++
}
}
remaining = total - totalRendered
return
}
// Get a pixel at the given coordinate. Returns the Palette entry for that
// pixel or else returns an error if not found.
func (c *Chunker) Get(p render.Point) (*Swatch, error) {
// Compute the chunk coordinate.
coord := c.ChunkCoordinate(p)
if chunk, ok := c.Chunks[coord]; ok {
return chunk.Get(p)
}
return nil, fmt.Errorf("no chunk %s exists for point %s", coord, p)
}
// Set a pixel at the given coordinate.
func (c *Chunker) Set(p render.Point, sw *Swatch) error {
coord := c.ChunkCoordinate(p)
chunk, ok := c.Chunks[coord]
if !ok {
chunk = NewChunk()
c.Chunks[coord] = chunk
chunk.Point = coord
chunk.Size = c.Size
}
return chunk.Set(p, sw)
}
// SetRect sets a rectangle of pixels to a color all at once.
func (c *Chunker) SetRect(r render.Rect, sw *Swatch) error {
var (
xMin = r.X
yMin = r.Y
xMax = r.X + r.W
yMax = r.Y + r.H
)
for x := xMin; x < xMax; x++ {
for y := yMin; y < yMax; y++ {
c.Set(render.NewPoint(x, y), sw)
}
}
return nil
}
// Delete a pixel at the given coordinate.
func (c *Chunker) Delete(p render.Point) error {
coord := c.ChunkCoordinate(p)
defer c.pruneChunk(coord)
if chunk, ok := c.Chunks[coord]; ok {
return chunk.Delete(p)
}
return fmt.Errorf("no chunk %s exists for point %s", coord, p)
}
// DeleteRect deletes a rectangle of pixels between two points.
// The rect is a relative one with a width and height, and the X,Y values are
// an absolute world coordinate.
func (c *Chunker) DeleteRect(r render.Rect) error {
var (
xMin = r.X
yMin = r.Y
xMax = r.X + r.W
yMax = r.Y + r.H
)
for x := xMin; x < xMax; x++ {
for y := yMin; y < yMax; y++ {
c.Delete(render.NewPoint(x, y))
}
}
return nil
}
// pruneChunk will remove an empty chunk from the chunk map, called after
// delete operations.
func (c *Chunker) pruneChunk(coord render.Point) {
if chunk, ok := c.Chunks[coord]; ok {
if chunk.Len() == 0 {
log.Info("Chunker.pruneChunk: %s has become empty", coord)
delete(c.Chunks, coord)
}
}
}
// ChunkCoordinate computes a chunk coordinate from an absolute coordinate.
func (c *Chunker) ChunkCoordinate(abs render.Point) render.Point {
if c.Size == 0 {
return render.Point{}
}
size := float64(c.Size)
return render.NewPoint(
int(math.Floor(float64(abs.X)/size)),
int(math.Floor(float64(abs.Y)/size)),
)
}
// ChunkMap maps a chunk coordinate to its chunk data.
type ChunkMap map[render.Point]*Chunk
// MarshalJSON to convert the chunk map to JSON. This is needed for writing so
// the JSON encoder knows how to serializes a `map[Point]*Chunk` but the inverse
// is not necessary to implement.
func (c ChunkMap) MarshalJSON() ([]byte, error) {
dict := map[string]*Chunk{}
for point, chunk := range c {
dict[point.String()] = chunk
}
out, err := json.Marshal(dict)
return out, err
}