Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Texpacker #18

Merged
merged 12 commits into from
Dec 22, 2017
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ emulator
build
.vscode
docs/html
*.swp
5 changes: 4 additions & 1 deletion tools/build.cmd
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
go get github.com/adinfinit/texpack/maxrect

go build -o bin/objconv.exe ./objconv
go build -o bin/bento.exe ./bento
go build -o bin/gcpacker.exe ./gcpacker
go build -o bin/texconv.exe ./texconv
go build -o bin/tevasm.exe ./tevasm
go build -o bin/tevasm.exe ./tevasm
go build -o bin/texpacker.exe ./texpacker
5 changes: 4 additions & 1 deletion tools/build.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
go get github.com/adinfinit/texpack/maxrect

go build -o bin/objconv ./objconv
go build -o bin/bento ./bento
go build -o bin/gcpacker ./gcpacker
go build -o bin/texconv ./texconv
go build -o bin/tevasm ./tevasm
go build -o bin/tevasm ./tevasm
go build -o bin/texpacker ./texpacker
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add the "go get" command on top of both build files, just like tools-extra/build.*

56 changes: 56 additions & 0 deletions tools/texpacker/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package main

import (
"encoding/binary"
"fmt"
"hash/fnv"
)

// 4 bytes
type Vector2 struct {
X, Y uint16
}

// 8 bytes
type Rect struct {
Start, Size Vector2
}

type FileHash uint32

// 4 + 4 = 8 bytes
type Header struct {
ParentTexture FileHash
EntryCount int
}

// An Entry is a single texture's path + coordinates. 4 + 8 = 12 bytes
type Entry struct {
TexPath FileHash
Coords Rect
}

var hash = fnv.New32()

func ToFileHash(s string) FileHash {
hash.Reset()
_, err := fmt.Fprintf(hash, s)
checkErr(err, "Failed to hash string %s", s)
return FileHash(hash.Sum32())
}

func (f FileHash) Bytes() []byte {
out := make([]byte, 4)
binary.BigEndian.PutUint32(out[0:], uint32(f))
return out
}

func (e Entry) Bytes() []byte {
out := make([]byte, 12)
binary.BigEndian.PutUint32(out[0:], uint32(e.TexPath))
binary.BigEndian.PutUint16(out[4:], e.Coords.Start.X)
binary.BigEndian.PutUint16(out[6:], e.Coords.Start.Y)
binary.BigEndian.PutUint16(out[8:], e.Coords.Size.X)
binary.BigEndian.PutUint16(out[10:], e.Coords.Size.Y)
return out
}
64 changes: 64 additions & 0 deletions tools/texpacker/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"flag"
"fmt"
"image"
"os"
"path/filepath"

// Image formats
_ "image/gif"
_ "image/jpeg"
_ "image/png"
)

func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [flags] <file1> [<file2> ...]\n", os.Args[0])
flag.PrintDefaults()
fmt.Fprint(os.Stderr, "\nSupported input formats:\n")
for _, format := range []string{"JPEG", "GIF", "PNG"} {
fmt.Fprintf(os.Stderr, " %s\n", format)
}
}
outpath := flag.String("o", "out.png", "Output file")
maxW := flag.Int("maxwidth", 1<<16, "Max width of output texture in pixels")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up for discussion, do we wanna support non-square atlasses? if not, only needs 1 flag ;)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No reason to, I think.

maxH := flag.Int("maxheight", 1<<16, "Max height of output texture in pixels")
cwd, err := os.Getwd()
checkErr(err, "Couldn't get working directory")
stripPfx := flag.String("prefix", cwd, "Prefix to strip from file paths for hashing")
flag.Parse()

if flag.NArg() < 1 {
fmt.Fprintf(os.Stderr, "[FAIL] No input files were specified\n")
os.Exit(1)
}

// All positional arguments are input files
inputFiles := flag.Args()

maxBounds := image.Point{*maxW, *maxH}
absoutpath, err := filepath.Abs(*outpath)
checkErr(err, "Error converting outpath %s to absolute", *outpath)
packer := NewTexPacker(absoutpath, TexPackerOptions{
MaxBounds: maxBounds,
StripPrefix: *stripPfx,
})
// Read images from input
for _, path := range inputFiles {
// Convert file path to absolute
abspath, err := filepath.Abs(path)
checkErr(err, "Error converting file path %s to absolute", path)
packer.Add(abspath)
}
checkErr(packer.Save(), "Failed to save packed texture")
}

func checkErr(err error, msg string, args ...interface{}) {
if err != nil {
fmt.Fprintf(os.Stderr, "[FAIL] "+msg+":\n ", args...)
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}
226 changes: 226 additions & 0 deletions tools/texpacker/packer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package main

import (
"encoding/binary"
"errors"
"fmt"
"image"
"image/draw"
"image/png"
"io"
"os"
"path/filepath"

"github.com/adinfinit/texpack/maxrect"
)

type TexPackerOptions struct {
MaxBounds image.Point
StripPrefix string
}

type TexPacker struct {
outfile string
options TexPackerOptions
images []imageInfo
}

type imageInfo struct {
Image image.Image
Path string
Coords Rect
}

func NewTexPacker(outfile string, options TexPackerOptions) *TexPacker {
return &TexPacker{
outfile: outfile,
options: options,
}
}

// Add specifies a new texture to be packed.
func (packer *TexPacker) Add(texpath string) error {
input, err := os.Open(texpath)
checkErr(err, "Cannot open input file: "+texpath)
defer input.Close()

img, fmt, err := image.Decode(input)
if err != nil {
return err
}
if fmt != "png" && fmt != "jpeg" && fmt != "gif" {
return errors.New("Unsupported input format: " + fmt)
}
// Strip path prefix
relpath, err := filepath.Rel(packer.options.StripPrefix, texpath)
checkErr(err, "Error converting absolute path %s to relative (with base %s)", texpath, packer.options.StripPrefix)
packer.images = append(packer.images, imageInfo{
Image: img,
Path: relpath,
})
return nil
}

// Save packs all the given textures into one and writes the result into `output`
func (packer *TexPacker) Save() error {
imageOut, headerOut, err := packer.getOutputs()
if err != nil {
return err
}
defer imageOut.Close()
defer headerOut.Close()

outtex, err := packer.pack()
if err != nil {
return err
}
if err = packer.writeHeader(headerOut); err != nil {
return err
}
return png.Encode(imageOut, outtex)
}

// getOutput opens the output files and returns their handle
func (packer *TexPacker) getOutputs() (imageOut io.WriteCloser, headerOut io.WriteCloser, err error) {
// Get output writer
imageOut, err = os.Create(packer.outfile)
if err != nil {
return
}
headerOut, err = os.Create(packer.outfile + ".atlas")
return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this returns an error, TexPacker.Save will not close the imageOut

}

// pack runs the maxrect algorithms on the input textures, then writes the result
// in the output texture, which is returned
func (packer *TexPacker) pack() (image.Image, error) {

points := getImageSizes(packer.images)

outsize, bounds, ok := minimizeFit(packer.options.MaxBounds, points)
if !ok {
return nil, errors.New("Couldn't pack all images in given bounds!")
}

// Create and write packed texture
outtex := image.NewRGBA(image.Rect(0, 0, outsize.X, outsize.Y))
for i, imginfo := range packer.images {
srcbounds := bounds[i]
r := image.Rectangle{srcbounds.Min, srcbounds.Min.Add(imginfo.Image.Bounds().Size())}

// Copy whole imginfo.Image to rectangle `r` in outtex
draw.Draw(outtex, r, imginfo.Image, image.Point{0, 0}, draw.Src)

packer.images[i].Coords = Rect{
Start: Vector2{uint16(srcbounds.Min.X), uint16(srcbounds.Min.Y)},
Size: Vector2{uint16(srcbounds.Dx()), uint16(srcbounds.Dy())},
}

// No need to keep this anymore
packer.images[i].Image = nil
}

return outtex, nil
}

// writeHeader outputs binary metadata on the packed textures to the given Writer.
func (packer *TexPacker) writeHeader(output io.Writer) error {

nEntries := len(packer.images)

// Output file format is:
// ParentTexture Hash [4B]
// Entry Count [4B]
// Entry0 [12B]
// ...

// Used to check hash collisions
hashes := make(map[FileHash]string, nEntries)

// Parent Texture Hash
relpath, err := filepath.Rel(packer.options.StripPrefix, packer.outfile)
checkErr(err, "Error converting absolute path %s to relative (with base %s)", packer.outfile, packer.options.StripPrefix)
ptHash := ToFileHash(relpath)
hashes[ptHash] = relpath
if _, err := output.Write(ptHash.Bytes()); err != nil {
return err
}

// Entry Count
countbuf := make([]byte, 4)
binary.BigEndian.PutUint32(countbuf, uint32(nEntries))
if _, err := output.Write(countbuf); err != nil {
return err
}

// Entries
for _, imginfo := range packer.images {
hash := ToFileHash(imginfo.Path)
if orig, collides := hashes[hash]; collides {
return fmt.Errorf("Hash conflict detected between the following files:\n [%8x] %s\n [%8x] %s", hash, orig, hash, imginfo.Path)
}
hashes[hash] = imginfo.Path
entry := Entry{
TexPath: hash,
Coords: imginfo.Coords,
}
if _, err := output.Write(entry.Bytes()); err != nil {
return err
}
}

return nil
}

func getImageSizes(images []imageInfo) []image.Point {
points := make([]image.Point, len(images))
for i, imginfo := range images {
points[i] = imginfo.Image.Bounds().Size()
}
return points
}

// Taken from https://github.com/adinfinit/texpack/blob/master/pack/fit.go
func minimizeFit(maxContextSize image.Point, sizes []image.Point) (contextSize image.Point, rects []image.Rectangle, ok bool) {

try := func(size image.Point) ([]image.Rectangle, bool) {
context := maxrect.New(size)
return context.Adds(sizes...)
}

contextSize = maxContextSize
rects, ok = try(contextSize)
if !ok {
return
}

shrunk, shrinkX, shrinkY := true, true, true
for shrunk {
shrunk = false
if shrinkX {
trySize := image.Point{contextSize.X - 128, contextSize.Y}
tryRects, tryOk := try(trySize)
if tryOk {
contextSize = trySize
rects = tryRects
shrunk = true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will still cause it to try shrinkY, is that ok?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's probably a typo in the function I copy-pasted, it should be shrinkY = false in the shrinkY branch.

} else {
shrinkX = false
}
}

if shrinkY {
trySize := image.Point{contextSize.X, contextSize.Y - 128}
tryRects, tryOk := try(trySize)
if tryOk {
contextSize = trySize
rects = tryRects
shrunk = true
} else {
shrinkY = false
}
}
}

return
}