From 93d04b4ebdcd8b0fb89d42f415b6047b5bf56e7d Mon Sep 17 00:00:00 2001 From: Allen Ray Date: Thu, 15 Aug 2024 12:17:52 -0400 Subject: [PATCH] Adding a texture atlas extension working on groups finishing groups and adding tests refactoring entries more fixes and doc finished --- ext/README.md | 13 +- ext/atlas/README.md | 278 ++++++++++++++++++++++++++++++++++++++++ ext/atlas/atlas.go | 256 ++++++++++++++++++++++++++++++++++++ ext/atlas/entry.go | 94 ++++++++++++++ ext/atlas/group.go | 233 +++++++++++++++++++++++++++++++++ ext/atlas/group_test.go | 66 ++++++++++ ext/atlas/help.go | 93 ++++++++++++++ ext/atlas/slice.go | 29 +++++ ext/atlas/texture.go | 45 +++++++ go.mod | 1 + go.sum | 2 + 11 files changed, 1104 insertions(+), 6 deletions(-) create mode 100644 ext/atlas/README.md create mode 100644 ext/atlas/atlas.go create mode 100644 ext/atlas/entry.go create mode 100644 ext/atlas/group.go create mode 100644 ext/atlas/group_test.go create mode 100644 ext/atlas/help.go create mode 100644 ext/atlas/slice.go create mode 100644 ext/atlas/texture.go diff --git a/ext/README.md b/ext/README.md index 1cbcfab..2f770b5 100644 --- a/ext/README.md +++ b/ext/README.md @@ -8,16 +8,17 @@ to pushing Pixel to the next level. ## Extension List +* [atlas](atlas/README.md) - Texture atlasing for more efficient rendering. * [gameloop](gameloop/README.md) - An extension that allows you to run a game loop in Pixel. * [imdraw](imdraw/README.md) - An extension that allows you to draw primitives in Pixel. * [text](text/README.md) - An extension that allows you to draw text in Pixel. -## Creating a Plugin +## Creating an Extension -Plugins are just a collection of files that are placed in the `plugins` directory. The directory name -is the name of the plugin. The plugin directory must contain a `README.md` file that describes the plugin. -Make sure to provide some example code in the **README** so that users are aware of how to use the plugin. +Extensions are just a collection of files that are placed in the `ext` directory. The directory name +is the name of the extension. The extension directory must contain a `README.md` file that describes the extension. +Make sure to provide some example code in the **README** so that users are aware of how to use the extension. -You are encouraged to create an example of your plugin in the [pixel-examples](https://github.com/gopxl/pixel-examples). This will allow users -to see your plugin in action and how to use it. +You are encouraged to create an example of your extension in the [pixel-examples](https://github.com/gopxl/pixel-examples). This will allow users +to see your extension in action and how to use it. diff --git a/ext/atlas/README.md b/ext/atlas/README.md new file mode 100644 index 0000000..4090b60 --- /dev/null +++ b/ext/atlas/README.md @@ -0,0 +1,278 @@ +# The Atlas Extension + +This extension provides an implementation of a texture atlas. A texture atlas groups individual textures together into a single texture that is sent to the GPU. This lessens the time spent switching GPU textures which can be expensive. + +## Usage + +You create an atlas by just declaring a variable: + +```go +import "github.com/gopxl/pixel/v2/ext/atlas" + +var textures atlas.Atlas +``` + +No initialization needed! You're ready to start adding textures into the atlas. + +### Adding Textures + +There are a few supported ways to add textures to the atlas: +1. Image Data +2. Loading from a file +3. Loading from an embedded file + +Each of these methods returns an `atlas.TextureId`, you'll need to keep track of this value as it's how you draw that individual texture. +The `atlas` package doesn't not enforce how you store them. So you could store them statically in variables: + +```go +var ( + textures atlas.Atlas + texture1 = textures.AddFile("1.png") + texture2 = textures.AddFile("2.png") +) +``` + +Or you could dump them into a map with names to lookup: + +```go +func run() { + var textures atlas.Atlas + + textureMap := make(map[string]atlas.TextureId) + + textureMap["1"] = textures.AddFile("1.png") + textureMap["2"] = textures.AddFile("2.png") +} +``` + +Or whatever other way you can come up with! + +##### Adding Image Data + +If you've already loaded an image and want to copy it into the atlas you can use this method. + +```go +func run() { + var textures atlas.Atlas + + f, err := os.Open("1.png") + defer f.Close() + + i, err := png.Decode(f) + + texture1 := textures.AddImage(i) +} +``` + +##### Adding an Image File + +The atlas also has convience methods to load a file directly from the path. + +```go +func run() { + var textures atlas.Atlas + + texture1 := textures.AddFile("1.png") +} +``` + +##### Adding an Embedded Image + +The atlas also supports Go's embedded file system. + +```go +// go:embed 1.png +var embedded embed.FS + +func run() { + var textures atlas.Atlas + + texture1 := textures.AddEmbed(embedded, "1.png") +} +``` + +#### Sliced Textures + +Sometimes, you already have a texture that contains multiple sprites of equal size in it; atlas can load these directly, cutting the single texture into multiple textures to easily use. + +Instead of an `atlas.TextureId`, creating a sliced texture returns an `atlas.SliceId`. This allows you to access the frames of the sliced texture; we'll show this in more detail later. + +Each of the `atlas.Slice` methods take an additional `pixel.Vec`. This is the sub-image size and it tells the atlas how to slice up the texture. + +Once you have a `atlas.SliceId`, you can use that to get all of the textures that were added to the atlas + +```go +var textures atlas.Atlas + +sliced1 := textures.SliceFile("sheet.png", pixel.V(8, 8)) +walk0 := sliced1.Frame(0) +walk1 := sliced1.Frame(1) +``` + +**Note:** If you attempt to index a non-existant frame, a panic will be raised. + +##### Slicing Image Data + +```go +func run() { + var textures atlas.Atlas + + f, err := os.Open("sheet1.png") + defer f.Close() + + i, err := png.Decode(f) + + sliced1 := textures.SliceImage(f, pixel.V(8, 8)) +} +``` + +##### Slicing Image File + +```go +func run() { + var textures atlas.Atlas + + sliced1 := textures.SliceFile("sheet1.png", pixel.V(8, 8)) +} +``` + +##### Slicing an Embedded Image + +```go +// go:embed sheet1.png +var embedded embed.FS + +func run() { + var textures atlas.Atlas + + sheet1 := textures.SliceEmbed(embedded, "sheet1.png", pixel.V(8, 8)) +} +``` + +### Packing the Atlas + +Once you've added all of the textures to the atlas you wish, it needs to be packed. + +This sorts the added textures by size to minimize the atlas texture. It then creates as many `pixel.PictureData` textures as it needs to store everything, then copies all of the added images to those textures. + +```go +var textures atlas.Atlas + +// ... use the Add* and/or Slice* methods to add textures + +textures.Pack() +``` + +### Drawing Atlas Textures + +#### Drawing TextureId + +When you have an `atlas.TextureId`, you can draw it to a `pixel.Target` normally. + +```go +var textures atlas.Atlas + +sprite1 := textures.AddFile("1.png") + +textures.Pack() + +sprite1.Draw(win, pixel.IM.Moved(win.Bounds().Center())) +``` + +As you can see, it draws just like the `pixel.Sprite` does, but much more efficiently because this limits the amount of texture changing on the GPU. + +#### Drawing SliceId + +Drawing an `atlas.SliceId` is slightly different because you need to specify which frame of the sliced texture to draw. + +```go +var textures atlas.Atlas + +sliced1 := textures.SliceFile("1.png", pixel.V(8, 8)) + +textures.Pack() + +sliced1.Draw(win, pixel.IM.Moved(win.Bounds().Center()), 1) +``` + +This will draw the second (0-indexed) image in the sliced texture. The index is calculated left to right, top to bottom. + +-- +OR +-- + +You could expand out the `atlas.SliceId` into the `atlas.TextureId` that make it up. + +```go +var textures atlas.Atlas + +sliced1 := textures.SliceFile("sheet.png") +walk0 := sliced1.Frame(0) +walk1 := sliced1.Frame(1) + +walk0.Draw(win, pixel.IM.Moved(win.Bounds().Center())) +``` + +### Groups + +Groups are a construct that allow logical grouping of textures for a couple of reasons: + +1. You want to have some static textures that are always in the atlas. +2. You want to be able to remove level-specific textures without having to re-add the other textures. +3. You want to use another library that wants to adds its own textures to the atlas without having to worry about its textures being removed out from under it. + +You can create a group: + +```go +var textures atlas.Atlas + +group1 := textures.MakeGroup() +``` + +Or you could use the default group that comes with the atlas (if you've been following along at home, you've been unknowingly been using this: `atlas.Atlas.Add*` and `atlas.Atlas.Slice*` use the default group). + +```go +var textures atlas.Atlas + +group1 := textures.DefaultGroup() +``` + +Groups share the same `Add*` and `Slice*` methods as are on the `atlas.Atlas`. + +### Clearing Textures + +You can remove all of the textures in an atlas with: + +```go +var textures atlas.Atlas + +// Add some textures to the atlas + +// ... + +// Actually, we don't want them anymore +textures.Clear() +``` + +**Note:** You don't need to call `atlas.Atlas.Pack()` after clearing textures, `atlas.Atlas.Clear()` does this automatically. + +#### Clearing Groups + +The main feature of groups is being able to remove them from the atlas. + +```go +var textures atlas.Atlas + +keepThisAroundGroup := textures.MakeGroup() +imGoingToDeleteThisSoon := textures.MakeGroup() + +// Add some textures to the groups + +// ... + +// Actually, we don't want them anymore +textures.Clear(imGoingToDeleteThisSoon) + +// Atlas still has all textures added to `keepThisAroundGroup` +``` + diff --git a/ext/atlas/atlas.go b/ext/atlas/atlas.go new file mode 100644 index 0000000..5f3d836 --- /dev/null +++ b/ext/atlas/atlas.go @@ -0,0 +1,256 @@ +package atlas + +import ( + "embed" + "fmt" + "image" + "image/draw" + "image/png" + "log" + "os" + "path" + "sort" + + "github.com/gopxl/pixel/v2" + "github.com/pkg/errors" +) + +const ( + MaxTextureSize = 8192 +) + +type loc struct { + index int + rect image.Rectangle +} + +type spaces []image.Rectangle + +type sheet struct { + size image.Rectangle + spaces spaces +} + +type Atlas struct { + adding []iEntry + internal []*pixel.PictureData + clean bool + idMap map[uint32]loc + id uint32 + defaultGroup Group +} + +// Dump writes out the internal textures to disk as PNG files. +func (a *Atlas) Dump(dir string) { + if !a.clean { + panic("Atlas is dirty, call atlas.Pack() first") + } + + // TODO improve dump so that the resulting file(s) can be directly loaded into the atlas from disk. + // Could be something like a zip file with the images and a file with the locations of the individual frames. + for i, t := range a.internal { + f, err := os.Create(path.Join(dir, fmt.Sprintf("%v.png", i))) + if err != nil { + log.Println(i, err) + continue + } + defer f.Close() + + if err := png.Encode(f, t.Image()); err != nil { + log.Println(i, err) + continue + } + } +} + +// Textures returns a copy of all of the internal packed textures. +func (a *Atlas) Textures() []*pixel.PictureData { + if !a.clean { + panic("Atlas is dirty, call atlas.Pack() first") + } + + data := make([]*pixel.PictureData, len(a.internal)) + + for i := range a.internal { + data[i] = pixel.PictureDataFromPicture(a.internal[i]) + } + + return data +} + +// Images returns a copy of all of the internal packed textures as image.Image. +func (a *Atlas) Images() []image.Image { + if !a.clean { + panic("Atlas is dirty, call atlas.Pack() first") + } + + images := make([]image.Image, len(a.internal)) + + for i := range a.internal { + images[i] = a.internal[i].Image() + } + + return images +} + +// AddImage loads an image to the atlas. +func (a *Atlas) AddImage(img image.Image) (id TextureId) { + return a.DefaultGroup().AddImage(img) +} + +// AddEmbed loads an embed.FS image to the atlas. +func (a *Atlas) AddEmbed(fs embed.FS, path string) (id TextureId) { + return a.DefaultGroup().AddEmbed(fs, path) +} + +// AddFile loads an image file to the atlas. +func (a *Atlas) AddFile(path string) (id TextureId) { + return a.DefaultGroup().AddFile(path) +} + +// SliceImage evenly divides the given image into cells of the given size. +func (a *Atlas) SliceImage(img image.Image, cellSize pixel.Vec) (id SliceId) { + return a.DefaultGroup().SliceImage(img, cellSize) +} + +// Slice loads an image and evenly divides it into cells of the given size. +func (a *Atlas) SliceFile(path string, cellSize pixel.Vec) (id SliceId) { + return a.DefaultGroup().SliceFile(path, cellSize) +} + +// SliceEmbed loads an embeded image and evenly divides it into cells of the given size. +func (a *Atlas) SliceEmbed(fs embed.FS, path string, cellSize pixel.Vec) (id SliceId) { + return a.DefaultGroup().SliceEmbed(fs, path, cellSize) +} + +// Pack takes all of the added textures and adds them to the atlas largest to smallest, +// trying to waste as little space as possible. After this call, the textures added +// to the atlas can be used. +func (a *Atlas) Pack() { + // If there's nothing to do, don't do anything + if a.clean || len(a.adding) == 0 { + return + } + + // reset internal stuff + a.internal = a.internal[:0] + if a.idMap == nil { + a.idMap = make(map[uint32]loc) + } else { + clear(a.idMap) + } + + sort.Slice(a.adding, func(i, j int) bool { + return area(a.adding[i].Bounds()) >= area(a.adding[j].Bounds()) + }) + + sheets := make([]sheet, 1) + for i := range sheets { + sheets[i] = sheet{ + spaces: []image.Rectangle{image.Rect(0, 0, MaxTextureSize, MaxTextureSize)}, + } + } + + for _, add := range a.adding { + bw, bh := add.Bounds().Dx(), add.Bounds().Dy() + + found := image.Rectangle{} + foundI := -1 + + Loop: + for i := range sheets { + for j := range sheets[i].spaces { + found, sheets[i].spaces = split(sheets[i].spaces, j, bw, bh) + if found.Empty() { + continue + } + sort.Slice(sheets[i].spaces, func(a, b int) bool { + return area(sheets[i].spaces[a]) < area(sheets[i].spaces[b]) + }) + foundI = i + break Loop + } + } + + if foundI == -1 { + foundI = len(sheets) + sheets = append(sheets, sheet{}) + found, sheets[foundI].spaces = split([]image.Rectangle{image.Rect(0, 0, MaxTextureSize, MaxTextureSize)}, 0, bw, bh) + } + + // Increase the size of the Atlas so we can allocate the minimum-sized + // texture later. + if found.Min.X == 0 { + sheets[foundI].size.Max.Y += found.Dy() + } + if found.Min.Y == 0 { + sheets[foundI].size.Max.X += found.Dx() + } + + switch add := add.(type) { + case iSliceEntry: + // If we have a frame, that means we just added a sprite sheet to the sprite sheet + // so we need to add id entries for each of the sprites + id := add.Id() + for y := 0; y < add.Bounds().Dy(); y += add.Frame().Y { + for x := 0; x < add.Bounds().Dx(); x += add.Frame().X { + a.idMap[id] = loc{ + index: foundI, + rect: rect(found.Min.X+x, found.Min.Y+y, add.Frame().X, add.Frame().Y), + } + id++ + } + } + default: + // Found a spot, add it to the map + a.idMap[add.Id()] = loc{ + index: foundI, + rect: found, + } + } + } + + // Create internal textures + sprites := make([]*image.RGBA, len(sheets)) + for i := range sheets { + if !sheets[i].size.Empty() { + sprites[i] = image.NewRGBA(sheets[i].size) + } + } + + // Copy individual sprite data into internal textures + for _, add := range a.adding { + var ( + err error + sprite image.Image + s = a.idMap[add.Id()] + ) + + switch add := add.(type) { + case iImageEntry: + sprite = add.Data() + case iEmbedEntry: + sprite, err = loadEmbedSprite(add.FS(), add.Path()) + err = errors.Wrapf(err, "failed to load embed sprite: %v", add.Path()) + case iFileEntry: + sprite, err = loadSprite(add.Path()) + err = errors.Wrapf(err, "failed to load sprite file: %v", add.Path()) + } + if err != nil { + panic(err) + } + draw.Draw(sprites[s.index], rect(s.rect.Min.X, s.rect.Min.Y, add.Bounds().Dx(), add.Bounds().Dy()), sprite, image.Point{}, draw.Src) + } + + // Make the internal Textures + a.internal = make([]*pixel.PictureData, len(sprites)) + for i, sprite := range sprites { + data := pixel.PictureDataFromImage(sprite) + a.internal[i] = data + } + + a.adding = nil + a.clean = true + + return +} diff --git a/ext/atlas/entry.go b/ext/atlas/entry.go new file mode 100644 index 0000000..1b5b663 --- /dev/null +++ b/ext/atlas/entry.go @@ -0,0 +1,94 @@ +package atlas + +import ( + "embed" + "image" +) + +type iEntry interface { + Bounds() image.Rectangle + Id() uint32 +} + +type entry struct { + id uint32 + bounds image.Rectangle +} + +func (e entry) Id() uint32 { + return e.id +} + +func (e entry) Bounds() image.Rectangle { + return e.bounds +} + +type iEmbedEntry interface { + iFileEntry + FS() embed.FS +} + +type embedEntry struct { + fileEntry + fs embed.FS +} + +func (e embedEntry) FS() embed.FS { + return e.fs +} + +type iImageEntry interface { + iEntry + Data() image.Image +} + +type imageEntry struct { + entry + data image.Image +} + +func (i imageEntry) Data() image.Image { + return i.data +} + +type iFileEntry interface { + iEntry + Path() string +} + +type fileEntry struct { + entry + path string +} + +func (f fileEntry) Path() string { + return f.path +} + +type iSliceEntry interface { + iEntry + Frame() image.Point +} + +type sliceEntry struct { + frame image.Point +} + +func (s sliceEntry) Frame() image.Point { + return s.frame +} + +type sliceImageEntry struct { + sliceEntry + imageEntry +} + +type sliceFileEntry struct { + sliceEntry + fileEntry +} + +type sliceEmbedEntry struct { + sliceEntry + embedEntry +} diff --git a/ext/atlas/group.go b/ext/atlas/group.go new file mode 100644 index 0000000..a56ee5f --- /dev/null +++ b/ext/atlas/group.go @@ -0,0 +1,233 @@ +package atlas + +import ( + "embed" + "fmt" + "image" + + "github.com/gopxl/pixel/v2" + "golang.org/x/exp/maps" +) + +type Group struct { + atlas *Atlas + textures []TextureId + slices []SliceId +} + +// MakeGroup creates a new group of textures. +func (a *Atlas) MakeGroup() Group { + return Group{ + atlas: a, + } +} + +// DefaultGroup returns the default group of the atlas. +func (a *Atlas) DefaultGroup() *Group { + if a.defaultGroup.atlas == nil { + a.defaultGroup.atlas = a + } + return &a.defaultGroup +} + +// Clear removes the given texture groups from the atlas. +// If no groups are given, all textures are removed. +func (a *Atlas) Clear(groups ...Group) { + if len(groups) == 0 { + maps.Clear(a.idMap) + } + + for _, group := range groups { + for _, texture := range group.textures { + delete(a.idMap, texture.id) + } + for _, slice := range group.slices { + for i := uint32(0); i < slice.len; i++ { + delete(a.idMap, slice.start.id+i) + } + } + } + + images := make([]*image.RGBA, len(a.internal)) + for i, data := range a.internal { + images[i] = data.Image() + } + + for id, loc := range a.idMap { + bounds := image.Rect(0, 0, loc.rect.Dx(), loc.rect.Dy()) + rgba := image.NewRGBA(bounds) + i := images[loc.index] + + for y := 0; y < bounds.Dy(); y++ { + for x := 0; x < bounds.Dx(); x++ { + rgba.Set(x, y, i.At(loc.rect.Min.X+x, loc.rect.Min.Y+y)) + } + } + + entry := imageEntry{data: rgba} + entry.id = id + entry.bounds = bounds + + a.adding = append(a.adding, entry) + } + + a.clean = false + + a.Pack() +} + +func (g *Group) addEntry(entry iEntry) (id TextureId) { + if bw, bh := entry.Bounds().Dx(), entry.Bounds().Dy(); bw > MaxTextureSize || bh > MaxTextureSize { + panic(fmt.Errorf("Texture is larger (%v, %v) than the maximum allowed texture (%v, %v)", bw, bh, MaxTextureSize, MaxTextureSize)) + } + + id = TextureId{id: g.atlas.id, atlas: g.atlas} + g.textures = append(g.textures, id) + switch entry := entry.(type) { + case iSliceEntry: + g.atlas.id += uint32((entry.Bounds().Dx() / entry.Frame().X) * (entry.Bounds().Dy() / entry.Frame().Y)) + default: + g.atlas.id++ + } + g.atlas.adding = append(g.atlas.adding, entry) + g.atlas.clean = false + return +} + +// AddImage loads an image to the atlas. +func (g *Group) AddImage(img image.Image) (id TextureId) { + e := imageEntry{ + entry: entry{ + id: g.atlas.id, + bounds: img.Bounds(), + }, + data: img, + } + return g.addEntry(e) +} + +// AddEmbed loads an embed.FS image to the atlas. +func (g *Group) AddEmbed(fs embed.FS, path string) (id TextureId) { + img, err := loadEmbedSprite(fs, path) + if err != nil { + panic(err) + } + e := embedEntry{ + fileEntry: fileEntry{ + entry: entry{ + id: g.atlas.id, + bounds: img.Bounds(), + }, + path: path, + }, + fs: fs, + } + return g.addEntry(e) +} + +// AddFile loads an image file to the atlas. +func (g *Group) AddFile(path string) (id TextureId) { + img, err := loadSprite(path) + if err != nil { + panic(err) + } + e := fileEntry{ + entry: entry{ + id: g.atlas.id, + bounds: img.Bounds(), + }, + path: path, + } + return g.addEntry(e) +} + +// SliceImage evenly divides the given image into cells of the given size. +func (g *Group) SliceImage(img image.Image, cellSize pixel.Vec) (id SliceId) { + frame := image.Pt(int(cellSize.X), int(cellSize.Y)) + bounds := img.Bounds() + if bounds.Dx()%frame.X != 0 || bounds.Dy()%frame.Y != 0 { + panic(fmt.Sprintf("Texture size (%v,%v) must be multiple of cellSize (%v,%v)", bounds.Dx(), bounds.Dy(), cellSize.X, cellSize.Y)) + } + + e := sliceImageEntry{ + imageEntry: imageEntry{ + entry: entry{ + id: g.atlas.id, + bounds: bounds, + }, + data: img, + }, + sliceEntry: sliceEntry{ + frame: frame, + }, + } + return SliceId{ + start: g.addEntry(e), + len: uint32((bounds.Dx() / frame.X) * (bounds.Dy() / frame.Y)), + } +} + +// SliceFile loads an image and evenly divides it into cells of the given size. +func (g *Group) SliceFile(path string, cellSize pixel.Vec) (id SliceId) { + frame := image.Pt(int(cellSize.X), int(cellSize.Y)) + img, err := loadSprite(path) + if err != nil { + panic(err) + } + bounds := img.Bounds() + if bounds.Dx()%frame.X != 0 || bounds.Dy()%frame.Y != 0 { + panic(fmt.Sprintf("Texture size (%v,%v) must be multiple of cellSize (%v,%v)", bounds.Dx(), bounds.Dy(), cellSize.X, cellSize.Y)) + } + + e := sliceFileEntry{ + fileEntry: fileEntry{ + entry: entry{ + id: g.atlas.id, + bounds: bounds, + }, + path: path, + }, + sliceEntry: sliceEntry{ + frame: frame, + }, + } + + return SliceId{ + start: g.addEntry(e), + len: uint32((bounds.Dx() / frame.X) * (bounds.Dy() / frame.Y)), + } +} + +// SliceEmbed loads an embeded image and evenly divides it into cells of the given size. +func (g *Group) SliceEmbed(fs embed.FS, path string, cellSize pixel.Vec) (id SliceId) { + img, err := loadEmbedSprite(fs, path) + if err != nil { + panic(err) + } + frame := image.Pt(int(cellSize.X), int(cellSize.Y)) + bounds := img.Bounds() + if bounds.Dx()%frame.X != 0 || bounds.Dy()%frame.Y != 0 { + panic(fmt.Sprintf("Texture size (%v,%v) must be multiple of cellSize (%v,%v)", bounds.Dx(), bounds.Dy(), cellSize.X, cellSize.Y)) + } + + e := sliceEmbedEntry{ + embedEntry: embedEntry{ + fileEntry: fileEntry{ + entry: entry{ + id: g.atlas.id, + bounds: bounds, + }, + path: path, + }, + fs: fs, + }, + sliceEntry: sliceEntry{ + frame: frame, + }, + } + + return SliceId{ + start: g.addEntry(e), + len: uint32((bounds.Dx() / frame.X) * (bounds.Dy() / frame.Y)), + } +} diff --git a/ext/atlas/group_test.go b/ext/atlas/group_test.go new file mode 100644 index 0000000..3f1d677 --- /dev/null +++ b/ext/atlas/group_test.go @@ -0,0 +1,66 @@ +package atlas + +import ( + "image" + "image/color" + "testing" + + "github.com/stretchr/testify/require" +) + +func generateImageGradient(bounds image.Rectangle, cTop, cBottom color.Color) *image.RGBA { + img := image.NewRGBA(bounds) + for y := 0; y < bounds.Dy(); y++ { + for x := 0; x < bounds.Dx(); x++ { + img.Set(x, y, color.RGBA{ + R: uint8(float64(cTop.(color.RGBA).R)*(1-float64(y-bounds.Min.Y)/float64(bounds.Dy())) + float64(cBottom.(color.RGBA).R)*float64(y-bounds.Min.Y)/float64(bounds.Dy())), + G: uint8(float64(cTop.(color.RGBA).G)*(1-float64(y-bounds.Min.Y)/float64(bounds.Dy())) + float64(cBottom.(color.RGBA).G)*float64(y-bounds.Min.Y)/float64(bounds.Dy())), + B: uint8(float64(cTop.(color.RGBA).B)*(1-float64(y-bounds.Min.Y)/float64(bounds.Dy())) + float64(cBottom.(color.RGBA).B)*float64(y-bounds.Min.Y)/float64(bounds.Dy())), + A: uint8(float64(cTop.(color.RGBA).A)*(1-float64(y-bounds.Min.Y)/float64(bounds.Dy())) + float64(cBottom.(color.RGBA).A)*float64(y-bounds.Min.Y)/float64(bounds.Dy())), + }) + } + } + + return img +} + +func TestAtlas_Clear(t *testing.T) { + i1 := generateImageGradient(image.Rect(0, 0, 10, 10), color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}) + i2 := generateImageGradient(image.Rect(0, 0, 10, 10), color.RGBA{0, 0, 255, 255}, color.RGBA{255, 255, 0, 255}) + + a := Atlas{} + g1 := a.MakeGroup() + + // Add our two images to the atlas + s1 := a.AddImage(i1) + g1.AddImage(i2) + + a.Pack() + + // Remove one of the images through its group + a.Clear(g1) + + // Now the atlas texture should be the same as the first image + tex := a.internal[a.idMap[s1.id].index].Image() + require.Equal(t, i1.Bounds(), tex.Bounds()) + + for i := range i1.Pix { + require.Equal(t, i1.Pix[i], tex.Pix[i]) + } +} + +func TestAtlas_ClearAll(t *testing.T) { + i1 := generateImageGradient(image.Rect(0, 0, 10, 10), color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}) + i2 := generateImageGradient(image.Rect(0, 0, 10, 10), color.RGBA{0, 0, 255, 255}, color.RGBA{255, 255, 0, 255}) + + a := Atlas{} + + // Add our two images to the atlas + a.AddImage(i1) + a.AddImage(i2) + + a.Pack() + + // Remove all of the images + a.Clear() +} diff --git a/ext/atlas/help.go b/ext/atlas/help.go new file mode 100644 index 0000000..8c5f3e9 --- /dev/null +++ b/ext/atlas/help.go @@ -0,0 +1,93 @@ +package atlas + +import ( + "embed" + "image" + "os" + + // need the following to automatically register for image.decode + _ "image/jpeg" + _ "image/png" + + "github.com/gopxl/pixel/v2" + "golang.org/x/exp/constraints" +) + +func area(r image.Rectangle) int { + return r.Dx() * r.Dy() +} + +func rect(x, y, w, h int) image.Rectangle { + return image.Rect(x, y, x+w, y+h) +} + +func pixelRect[T constraints.Integer | constraints.Float](minX, minY, maxX, maxY T) pixel.Rect { + return pixel.R(float64(minX), float64(minY), float64(maxX), float64(maxY)) +} + +func image2PixelRect(r image.Rectangle) pixel.Rect { + return pixel.R(float64(r.Min.X), float64(r.Min.Y), float64(r.Max.X), float64(r.Max.Y)) +} + +func loadEmbedSprite(fs embed.FS, file string) (i image.Image, err error) { + f, err := fs.Open(file) + if err != nil { + return + } + defer f.Close() + + i, _, err = image.Decode(f) + return +} + +func loadSprite(file string) (i image.Image, err error) { + f, err := os.Open(file) + if err != nil { + return + } + defer f.Close() + + i, _, err = image.Decode(f) + return +} + +// split is the actual algorithm for splitting a given space (by j in spcs) to fit the given width and height. +// Will return an empty rectangle if a space wasn't available +// This function is based on this project (https://github.com/TeamHypersomnia/rectpack2D) +func split(spcs spaces, j int, bw, bh int) (found image.Rectangle, newSpcs spaces) { + sp := spcs[j] + spw, sph := sp.Dx(), sp.Dy() + switch { + // Perfect match + case bw == spw && bh == sph: + found = sp + spcs = append(spcs[:j], spcs[j+1:]...) + // Perfect width, split height + case bw == spw && bh < sph: + h := sph - bh + found = rect(sp.Min.X, sp.Min.Y, spw, bh) + spcs = append(spcs[:j], spcs[j+1:]...) + spcs = append(spcs, rect(sp.Min.X, sp.Min.Y+bh, spw, h)) + // Perfect height, split width + case bw < spw && bh == sph: + w := spw - bw + found = rect(sp.Min.X, sp.Min.Y, bw, sph) + spcs = append(spcs[:j], spcs[j+1:]...) + spcs = append(spcs, rect(sp.Min.X+bw, sp.Min.Y, w, sph)) + // Split both + case bw < spw && bh < sph: + w := spw - bw + h := sph - bh + found = rect(sp.Min.X, sp.Min.Y, bw, bh) + var r1, r2 image.Rectangle + + // Maximize the leftover size + r1 = rect(sp.Min.X+bw, sp.Min.Y, w, bh) + r2 = rect(sp.Min.X, sp.Min.Y+bh, spw, h) + + spcs = append(spcs[:j], spcs[j+1:]...) + spcs = append(spcs, r1, r2) + } + newSpcs = spcs + return +} diff --git a/ext/atlas/slice.go b/ext/atlas/slice.go new file mode 100644 index 0000000..b14f7c9 --- /dev/null +++ b/ext/atlas/slice.go @@ -0,0 +1,29 @@ +package atlas + +import "github.com/gopxl/pixel/v2" + +// A SliceId represents a texture in the atlas added by Atlas.Slice. +// This differs from a TextureId in that it's meant to be drawn with a frame offset (a sub image). +type SliceId struct { + start TextureId + len uint32 +} + +// Frame returns a TextureId representing the given frame of the slice +func (s SliceId) Frame(frame uint32) TextureId { + if frame >= s.len { + panic("slice frame out of bounds") + } + return TextureId{id: s.start.id + frame, atlas: s.start.atlas} +} + +// Bounds returns the bounds of the slice in the atlas. +func (s SliceId) Bounds(frame uint32) pixel.Rect { + return s.Frame(frame).Bounds() +} + +// Draw draws the slice in the atlas to the target with the given matrix. +func (s SliceId) Draw(t pixel.Target, m pixel.Matrix, frame uint32) { + f := s.Frame(frame) + f.Draw(t, m) +} diff --git a/ext/atlas/texture.go b/ext/atlas/texture.go new file mode 100644 index 0000000..4344ea1 --- /dev/null +++ b/ext/atlas/texture.go @@ -0,0 +1,45 @@ +package atlas + +import ( + "fmt" + + "github.com/gopxl/pixel/v2" +) + +// TextureId is a reference to a texture in an atlas. +type TextureId struct { + id uint32 + atlas *Atlas + sprite *pixel.Sprite +} + +// Bounds returns the bounds of the texture in the atlas. +func (t TextureId) Bounds() pixel.Rect { + s, has := t.atlas.idMap[t.id] + if !has { + panic(fmt.Sprintf("id: %v does not exit in atlas", t.id)) + } + return pixelRect(0, 0, s.rect.Dx(), s.rect.Dy()) +} + +// Draw draws the texture in the atlas to the target with the given matrix. +func (t *TextureId) Draw(target pixel.Target, m pixel.Matrix) { + if !t.atlas.clean { + panic("Packer is dirty, call atlas.Pack() first") + } + + l, has := t.atlas.idMap[t.id] + if !has { + panic(fmt.Sprintf("id [%v] does not exist in packer", t.id)) + } + + if t.sprite == nil { + r := image2PixelRect(l.rect) + c := t.atlas.internal[l.index].Bounds().Center() + m := pixel.IM.ScaledXY(c, pixel.V(1, -1)) + r.Min = m.Project(r.Min) + r.Max = m.Project(r.Max) + t.sprite = pixel.NewSprite(t.atlas.internal[l.index], r) + } + t.sprite.Draw(target, m) +} diff --git a/go.mod b/go.mod index cf66be6..6a3a7f7 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/gopxl/mainthread/v2 v2.0.0 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.8.4 + golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa golang.org/x/image v0.18.0 ) diff --git a/go.sum b/go.sum index d7bfb20..7f1296e 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= +golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=