C# to Go: a beginner's journey (part 5)
It's time to clean up GoRender and establish a solid base for adding all the lighting, shading and multiple output targets a full-featured sprite renderer needs. One thing I've noticed is my "iterate and stabilise" mode has rather less "stabilise" than I claim when I'm writing, so a new language is a good time to start breaking that habit.
Going back through the previous parts, there are four main areas which could do with some attention. In descending order of priority:
- The mess which is the current
main
package. - The potential slow performance of checking which range a colour is in.
- The somewhat clunky
GetSpritesheets
function. - The number of places a function returns
x,y
pairs which are never used except to immediately create a bounds rectangle.
I'm going to start with the most important, not least because I want to add some more functionality to make this a useful command line tool that doesn't require recompiling for every minor change in output. While this is a good intention, trying to add functionality and refactor at the same time is a fast track to disaster: I should be able to refactor without changing any existing tests, as I'm not changing any behaviour.
(This will trivially be the case for my main
package, as it currently has no tests! Another good reason not to start playing around with new functionality until it's stable.)
Cleaning up the main package
The main package currently has a single method with four responsibilities:
- Read a palette file
- Read a voxel file
- Get the spritesheet
- Save a PNG file
There is some very obvious repetition between the file operations. I think this will become even more obvious if I extract these to methods. With a little bit of tidying, main.go
now looks like this:
package main
import (
"colour"
"fmt"
"image/png"
"os"
"spritesheet"
"voxelobject"
"voxelobject/vox"
)
func main() {
if len(os.Args) < 2 {
s := fmt.Sprintf("no command line argument given for voxel file source")
panic(s)
}
palette, err := getPalette("files/ttd_palette.json")
if err != nil {
panic(err)
}
object, err := getVoxelObject(os.Args[1])
if err != nil {
panic(err)
}
sheets := spritesheet.GetSpritesheets(object, palette, 64.0, 8)
sheet, ok := sheets["32bpp"]
if !ok {
panic("no 32bpp sprite sheet available")
}
err = savePngFile("output.png", sheet)
if err != nil {
panic(err)
}
}
func savePngFile(filename string, sheet spritesheet.Spritesheet) (err error) {
imgFile, err := os.Create(filename)
if err != nil {
return fmt.Errorf("could not open output image file: %s", err)
}
if err := png.Encode(imgFile, sheet.Image); err != nil {
imgFile.Close()
return fmt.Errorf("error writing image file: %s", err)
}
if err := imgFile.Close(); err != nil {
return fmt.Errorf("error closing image file: %s", err)
}
return
}
func getVoxelObject(filename string) (object voxelobject.RawVoxelObject, err error) {
voxFile, err := os.Open(filename)
if err != nil {
return object, fmt.Errorf("could not open voxel file: %s", err)
}
object, err = vox.GetRawVoxels(voxFile)
if err != nil {
voxFile.Close()
return object, fmt.Errorf("could not read voxel file: %s", err)
}
if err := voxFile.Close(); err != nil {
return object, fmt.Errorf("error closing voxel file: %s", err)
}
return
}
func getPalette(filename string) (palette colour.Palette, err error) {
paletteFile, err := os.Open(filename)
if err != nil {
return palette, fmt.Errorf("could not open palette file: %s", err)
}
palette = colour.GetPaletteFromJson(paletteFile)
if err = paletteFile.Close(); err != nil {
return palette, fmt.Errorf("error closing palette file: %s", err)
}
return
}
GoLand condenses the verbosity of that error handling down so it's a bit more readable, but even from the raw code we can see that reading data from files follows an almost identical structure every time. This feels like something we can solve with an interface.
Reading files
I'm going to create a fileutils
package to keep this logic in. As it's going to be difficult to test this without doing some kind of I/O (normally something to avoid in tests, but I take the pragmatic approach that having a test and some slight impurity is better than having no test at all).
Go has a nice convention for this situation - if I create a testdata
subfolder in my package, anything in it will be treated as test data which doesn't need to be included in the build:
As I already have the code, I'm going to start with that and make it use an interface instead of calling methods directly. The code looks like this:
package fileutils
import (
"fmt"
"io"
"os"
)
type FileInstantiator interface {
GetFromReader(r io.Reader) error
}
func InstantiateFromFile(filename string, o FileInstantiator) (err error) {
file, err := os.Open(filename)
if err != nil {
return fmt.Errorf("could not open file %s: %s", filename, err)
}
err = o.GetFromReader(file)
if err != nil {
file.Close()
return fmt.Errorf("could not read file %s: %s", filename, err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("could not close file %s: %s", filename, err)
}
return
}
To test it, I need a simple implementation of that interface and some sample data. This is happily concise, and my first version of fileutils_test.go
looks like this:
package fileutils
import (
"io"
"io/ioutil"
"testing"
)
type testData struct {
value string
}
func (d *testData) GetFromReader(r io.Reader) error {
bytes, err := ioutil.ReadAll(r)
if err != nil {
return err
}
d.value = string(bytes)
return nil
}
func TestInstantiateFromFile(t *testing.T) {
var f testData
InstantiateFromFile("testdata/sample.txt", &f)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if f.value != "hello world" {
t.Errorf("expected 'hello world' but was '%s'", f.value)
}
}
I should also add a test for the unhappy path where a file doesn't exist:
func TestInstantiateFromFile_FileNotExists(t *testing.T) {
const expectedError = "could not open file testdata/not_exists.txt: open testdata/not_exists.txt: The system cannot find the file specified."
var f testData
if err := InstantiateFromFile("testdata/not_exists.txt", &f); err.Error() != expectedError {
t.Errorf("unexpected error: %v", err)
}
}
Testing my error message reveals the Go default error is verbose enough without me needing to add context, so I clean that up to only expect the default:
const expectedError = "open testdata/not_exists.txt: The system cannot find the file specified."
And now InstantiateFromFile
is super clean:
func InstantiateFromFile(filename string, o FileInstantiator) (err error) {
file, err := os.Open(filename)
if err != nil {
return
}
err = o.GetFromReader(file)
if err != nil {
file.Close()
return
}
if err = file.Close(); err != nil {
return
}
return
}
I can now go ahead and add GetFromReader
methods to both the palette and raw voxel object packages. I've also modified GetPaletteFromJson
so it now returns any errors encountered during the process, as we can cleanly bubble them up now.
The palette is easy:
func (p *Palette) GetFromReader(handle io.Reader) (err error) {
*p, err = GetPaletteFromJson(handle)
return err
}
When I try the same for the voxel object, I hit a new problem. The file reading takes place in vox
but RawVoxelObject
comes from a different package. I have an implicit circular dependency: Go will prevent me from making this explicit, so I need to sort it out.
There are two points of coupling between vox
and voxelobject
- the use of the RawVoxelObject
struct and the Point
/PointWithColour
structs. But now I have a geometry
package those might be a more natural fit there.
The next challenge is the dependency for RawVoxelObject
. But this is highlighting a problem: my internal raw object is tightly coupled to a file format. So while raw objects and MagicaVoxel objects are currently both three-dimensional byte arrays, that might not always be the case. So vox/reader.go
gets a new type definition:
type MagicaVoxelObject [][][]byte
The next back-dependency to deal with is MakeRawVoxelObject
. I could duplicate that code, but populating a 3D byte array feels like something we might do quite often, so I'll move that over to its own utility package, byteutils
:
package byteutils
import (
"geometry"
)
func Make3DByteSlice(size geometry.Point) [][][]byte {
result := make([][][]byte, size.X)
for x := range result {
result[x] = make([][]byte, size.Y)
for y := range result[x] {
result[x][y] = make([]byte, size.Z)
}
}
return result
}
I add a test for this because it's now a new unit which could evolve separately from the voxel packages. Things are now cleaned up, and I can implement GetFromReader
in vox
:
func (v *MagicaVoxelObject) GetFromReader(handle io.Reader) (err error) {
*v, err = GetMagicaVoxelObject(handle)
return
}
This will actually return a MagicaVoxelObject
which I'll need to convert, but Go makes this easy. My code in main.go
for those two functions now looks like this:
func getVoxelObject(filename string) (object voxelobject.RawVoxelObject, err error) {
var mv vox.MagicaVoxelObject
err = fileutils.InstantiateFromFile(filename, &mv)
return voxelobject.RawVoxelObject(mv), err
}
func getPalette(filename string) (palette colour.Palette, err error) {
err = fileutils.InstantiateFromFile(filename, &palette)
return
}
I can now proceed to do the same thing for the PNG saving routine. What's interesting here is saving is actually a responsibility of the spritesheet
, so I'm going to move some of the logic there. However the core file handling will go in fileutils
, to abstract those details away. Let's do that now.
Writing files
First the interface:
type FileWriter interface {
OutputToWriter(w io.Writer) error
}
I know enough about how this should work from the reader version that I can proceed straight to writing the test:
func (d *testData) OutputToWriter(w io.Writer) (err error) {
_, err = w.Write([]byte(d.value))
return
}
func TestWriteToFile(t *testing.T) {
const expected = "hello world"
td := testData{expected}
filename := "testdata/" + strconv.Itoa(int(rand.Uint64())) + ".txt"
if err := WriteToFile(filename, &td); err != nil {
t.Errorf("unexpected error writing: %v", err)
}
var f testData
if err := InstantiateFromFile(filename, &f); err != nil {
t.Errorf("unexpected error reading: %v", err)
}
if f.value != expected {
t.Errorf("expected '%s', got '%s'", expected, f.value)
}
if err := os.Remove(filename); err != nil {
t.Errorf("unexpected error deleting: %v", err)
}
}
I use a random filename here so my test can't be thrown by an aborted run leaving a stale file on the disk. The test is red as expected, so I can now implement my file writing function:
func WriteToFile(filename string, o FileWriter) (err error) {
file, err := os.Create(filename)
if err != nil {
return
}
err = o.OutputToWriter(file)
if err != nil {
file.Close()
return
}
if err = file.Close(); err != nil {
return
}
return
}
The test passes, but I am annoyed that only two lines are different between writing and reading.
I refactor this into three files -
fileutils.go
gains an interface with functions for the two things our reader and writer do differently, and the common parts are placed in a single doFileIO
method:
package fileutils
import (
"os"
)
type fileIOHandler interface {
GetFileHandle(filename string) (*os.File, error)
DoIO(f *os.File) error
}
func InstantiateFromFile(filename string, o FileReader) (err error) {
r := reader{o}
err = doFileIO(filename, r)
return
}
func WriteToFile(filename string, o FileWriter) (err error) {
w := writer{o}
err = doFileIO(filename, w)
return
}
func doFileIO(filename string, handler fileIOHandler) (err error) {
file, err := handler.GetFileHandle(filename)
if err != nil {
return
}
err = handler.DoIO(file)
if err != nil {
file.Close()
return
}
if err = file.Close(); err != nil {
return
}
return
}
reader.go
contains the definition for the reader:
package fileutils
import (
"io"
"os"
)
type FileReader interface {
GetFromReader(r io.Reader) error
}
type reader struct {
fileReader FileReader
}
func (r reader) GetFileHandle(filename string) (f *os.File, err error) {
f, err = os.Open(filename)
return
}
func (r reader) DoIO(f *os.File) (err error) {
err = r.fileReader.GetFromReader(f)
return
}
writer.go
contains the definition for the writer:
package fileutils
import (
"io"
"os"
)
type FileWriter interface {
OutputToWriter(w io.Writer) error
}
type writer struct {
fileWriter FileWriter
}
func (w writer) GetFileHandle(filename string) (f *os.File, err error) {
f, err = os.Create(filename)
return
}
func (w writer) DoIO(f *os.File) (err error) {
err = w.fileWriter.OutputToWriter(f)
return
}
The result is more lines than the original, but I now have no repetition between two methods, and if I introduce more error handling then both input and output will benefit from it. Plus I've learnt more about how to make a Go function generic using interfaces, which at this stage is possibly more valuable than any application of DRY principles.
Moving PNG logic to spritesheet
Now we can save to a file in the same way as we load from one, I can move the PNG save logic to spritesheet.
Saving a single spritesheet's image is nice and clean with our new approach:
func (s Spritesheet) OutputToWriter(w io.Writer) (err error) {
err = png.Encode(w, s.Image)
return
}
And to save a whole batch of spritesheets, we just need to iterate them. In this case i
will be the key of the map, which means our string-based approach will work quite nicely:
func (sheets Spritesheets) SaveAll(baseFilename string) (err error){
for i, sheet := range sheets {
filename := baseFilename + "_" + i + ".png"
if err = fileutils.WriteToFile(filename, &sheet); err != nil {
return
}
}
return
}
I'm happy with this as it's short and clear as to what's going on, not muddying the intent with details of error handling and how files are to be saved. How does main()
look after this change is applied?
func main() {
if len(os.Args) < 2 {
s := fmt.Sprintf("no command line argument given for voxel file source")
panic(s)
}
palette, err := getPalette("files/ttd_palette.json")
if err != nil {
panic(err)
}
object, err := getVoxelObject(os.Args[1])
if err != nil {
panic(err)
}
sheets := spritesheet.GetSpritesheets(object, palette, 2.0, 8)
if err := sheets.SaveAll("output"); err != nil {
panic(err)
}
}
This is starting to look a lot cleaner! Of course the acid test is to compile and run the code, which now produces output_32bpp.png
:
Great! Now I have my code refactored I can start adding the new features I want.
New features for main
I'd like to add proper support for command-line flags to my program, so I don't have to recompile every time I want to play with a new scale or number of sprites. This will also include some new functionality, and get rid of the brittle command line argument system I currently have. The flags I'd like to introduce are:
-input
: the voxel file to process-output
: the base filename of the output files. Default to the input filename minus the.vox
extension.-scale
: the scale. Default to 1.-num_sprites
: how many sprites (rotations) to render. Default to 8.-time
: output timing statistics to stdout for rudimentary profiling
Command line flags are one of the useful things built in to Go's standard library (https://golang.org/pkg/flag/) so I don't need too much work to implement this. My main()
function now looks like this:
func main() {
scale := flag.Float64("scale", 1.0, "scale to render sprites at")
inputFilename := flag.String("input", "", "voxel file to process")
outputFilename := flag.String("output", "", "base file name of output PNG files, bit depth will be appended")
numSprites := flag.Int("num_sprites", 8, "number of sprite rotations to render")
flag.Parse()
if *inputFilename == "" {
flag.Usage()
return
}
if *outputFilename == "" {
*outputFilename = "output"
}
palette, err := getPalette("files/ttd_palette.json")
if err != nil {
panic(err)
}
object, err := getVoxelObject(*inputFilename)
if err != nil {
panic(err)
}
sheets := spritesheet.GetSpritesheets(object, palette, *scale, *numSprites)
if err := sheets.SaveAll(*outputFilename); err != nil {
panic(err)
}
}
I'm not implementing time
yet and output filename needs some logic, but this is good enough to run. Without any arguments I get the following:
D:\projects\gorender>go build cmd/renderobject && renderobject.exe
Usage of renderobject.exe:
-input string
voxel file to process
-num_sprites int
number of sprite rotations to render (default 8)
-output string
base file name of output PNG files, bit depth will be appended
-scale float
scale to render sprites at (default 1)
And running with an argument things work as normal:
D:\projects\gorender>go build cmd/renderobject && renderobject.exe -input files/bus.vox
D:\projects\gorender>
I'm still saving output_32bpp.png
though, so it's time to implement the correct base name handling. This feels like a fileutils
responsibility, so let's create a test over there:
func TestGetBaseFilename(t *testing.T) {
testCases := []struct{
input, expected string
}{
{"test", "test"},
{"test.png", "test"},
{"files/test.png", "files/test"},
{"test.a.b.c", "test.a.b"},
}
for _, testCase := range testCases {
if result := GetBaseFilename(testCase.input); result != testCase.expected {
t.Errorf("input %s got %s, expected %s", testCase.input, result, testCase.expected)
}
}
}
Implementing this doesn't require much in the way of logic. Note the way we treat a string as just another slice rather than needing explicit substring operators:
func GetBaseFilename(filename string) string {
lastExtension := strings.LastIndex(filename, ".")
if lastExtension != -1 {
return filename[:lastExtension]
}
return filename
}
I can now update my code for handling an empty output filename:
if *outputFilename == "" {
*outputFilename = fileutils.GetBaseFilename(*inputFilename)
} else {
*outputFilename = fileutils.GetBaseFilename(*outputFilename)
}
Now with the same command line arguments I save the output to files/bus_32bpp.png
.
Let's try using all of the available arguments:
D:\projects\gorender>renderobject.exe -input files/bus.vox -output test.png -scale 3.0 -num_sprites 3
As expected, I have a file test_32bpp.png
containing the following:
This is almost too easy so let's move on to implementing the -time
flag.
Implementing -time
I'm hoping Go can provide me with some kind of high-resolution timer in the standard library, so I'll go looking for that now. After a brief search the time package (https://golang.org/pkg/time/) looks promising, especially given the introduction talks about it deliberately separating out monotonic clock values for doing measurement. It looks like I can set up something simple using time.Now()
and time.Since()
.
I'll add the flag and the initialisation to main()
:
outputTime := flag.Bool("time", false, "output basic profiling information")
startTime := time.Now()
Then at the end of the function:
if *outputTime {
fmt.Printf("Time taken: %d ms", time.Since(startTime).Milliseconds())
}
This is all we need to output a simple time metric:
D:\projects\gorender>go build cmd/renderobject && renderobject.exe -input files/bus.vox -scale 4.0 -time
Time taken: 60 ms
D:\projects\gorender>
This now gives me a good starting point to test improving the performance of the colour range lookup.
Improving colour range lookup
When I implemented colour ranges, I did something expedient that will also be very slow if we have a lot of palette ranges:
for _, rng := range p.Ranges {
if index >= rng.Start && index <= rng.End {
// ... range logic
}
}
As I'll only ever have 256 or fewer palette entries, it's not going to take up much space to allow each of them to specify a range:
type PaletteEntry struct {
R, G, B byte
Range *PaletteRange
}
This means I can't have overlapping ranges, but I'm happy with that as I don't want the complexity of conflicting behaviour. In fact, I can explicitly test for this when I create a palette object from JSON:
func GetPaletteFromJson(handle io.Reader) (p Palette, err error) {
data, err := ioutil.ReadAll(handle)
if err != nil {
return Palette{}, err
}
if err := json.Unmarshal(data, &p); err != nil {
return Palette{}, err
}
if err := p.SetRanges(p.Ranges); err != nil {
return Palette{}, err
}
return
}
func (p *Palette) SetRanges(ranges []PaletteRange) (err error) {
p.Ranges = ranges
for i := range p.Entries {
p.Entries[i].Range = nil
}
for i, r := range ranges {
for j := r.Start; j <= r.End; j++ {
if p.Entries[j].Range != nil {
return fmt.Errorf("range %d overlaps colour %d", i, j)
}
p.Entries[j].Range = &ranges[i]
}
}
return nil
}
Note I have to use &ranges[i]
to generate the pointer - r
gets overwritten with the new iterated value on each iteration. This is subtly different to say C# where a new object would be created each time. I should test the new unhappy path I've now introduced by checking for duplicates, so here's the new test:
func TestPalette_GetFromReader_DetectsDuplicateRanges(t *testing.T) {
const json = "{\"entries\": [[0,0,0],[255,255,255],[255,127,0]], \"ranges\": [{\"start\": 0, \"end\": 1},{\"start\": 1, \"end\": 2}]}"
_, err := GetPaletteFromJson(strings.NewReader(json))
if err == nil || err.Error() != "range 1 overlaps colour 1" {
t.Errorf("encountered unexpected error: %v", err)
}
}
I can now replace my loop with a simple check:
if entry.Range != nil {
if entry.Range.IsPrimaryCompanyColour || entry.Range.IsSecondaryCompanyColour {
y := (19595*uint32(entry.R) + 38470*uint32(entry.G) + 7471*uint32(entry.B) + 1<<15) >> 8
return y, y, y
}
}
Now for a disappointment... at the scales we're rendering, this makes no difference to performance. Especially with only two ranges, it's lost in the noise.
There are two things I can do here. Firstly, I need to update my palette file to contain all of the Transport Tycoon palette ranges - I didn't before as I knew I had that slow loop to contend with.
Even with all the ranges added, the results aren't conclusive:
D:\projects\gorender>renderobject_old.exe -input files/bus.vox -scale 4.0 -time
Time taken: 66 ms
D:\projects\gorender>renderobject.exe -input files/bus.vox -scale 4.0 -time
Time taken: 63 ms
There's a simple solution here, though - increase the scale and the number of sprites:
D:\projects\gorender>renderobject_old.exe -input files/bus.vox -scale 16.0 -num_sprites 36 -time
Time taken: 3906 ms
D:\projects\gorender>renderobject.exe -input files/bus.vox -scale 16.0 -num_sprites 36 -time
Time taken: 3787 ms
The difference isn't huge (around 3% improvement) but it's still an improvement and we also get some additional file checking logic from the change.
I also look at whether having an in-place add mechanism for vectors will help with raycaster performance, but this ends up slower than the existing code so I don't bother.
Clean up GetSpritesheets
This is a small refactoring job - not too much to comment here, just extracting some functions and creating structures where they have too many parameters. The end result looks like this:
func GetSpritesheets(def Definition) Spritesheets {
sheets := make(Spritesheets)
w := int(float64(spriteSpacing * def.NumSprites) * def.Scale)
h := int(float64(totalHeight) * def.Scale)
bounds := image.Rectangle{Max: image.Point{X: w, Y: h}}
sheets["32bpp"] = Spritesheet{Image: getSpritesheetImage(def, bounds)}
return sheets
}
func getSpritesheetImage(def Definition, bounds image.Rectangle) (img image.Image) {
img = imageutils.GetUniformImage(bounds, color.White)
angleStep := 360 / float64(def.NumSprites)
for i := 0; i < def.NumSprites; i++ {
angle := int(float64(i)*angleStep)
spr := getSprite(def, angle)
compositor.Composite(spr, img, image.Point{X: int(float64(i*spriteSpacing) * def.Scale)}, spr.Bounds())
}
return
}
func getSprite(def Definition, angle int) (spr image.Image) {
sw, sh := getSpriteSizeForAngle(angle, def.Scale)
rect := image.Rectangle{Max: image.Point{X: sw, Y: sh}}
if def.Object.Invalid() {
spr = sprite.GetUniformSprite(rect)
} else {
spr = sprite.GetRaycastSprite(def.Object, def.Palette, rect, angle)
}
return
}
I also clean up a small bug where the rounding error from using integer angles mounts up across spritesheets with lots of sprites where the number of sprites is not a factor of 360. There is an argument for using floating point angles throughout but I'll leave this as it is for now.
Bounds vs. Points
The last bit of refactoring pressure is my feeling that I use a function which returns x,y
but those values are always used to create a bounds rectangle. Technically this is true... but only because getSpriteSizeForAngle
is used exactly once.
Nevertheless, this may as well be turned into a rectangle:
func getSpriteSizeForAngle(angle int, scale float64) image.Rectangle {
// ...skip logic...
return image.Rectangle{Max: image.Point{X: int(fx * scale), Y: int(fy * scale)}}
}
And getSprite
now gets one line shorter:
rect := getSpriteSizeForAngle(angle, def.Scale)
Refactoring main (again)
The new functionality has made my main()
function start to look a little unwieldy again, so I'm going to clean that up and add some short versions of each command line flag:
type Flags struct {
Scale float64
InputFilename, OutputFilename string
NumSprites int
OutputTime bool
}
var flags Flags
func init() {
// Long format
flag.Float64Var(&flags.Scale, "scale", 1.0, "scale to render sprites at")
flag.StringVar(&flags.InputFilename, "input", "", "voxel file to process")
flag.StringVar(&flags.OutputFilename, "output", "", "base file name of output PNG files, bit depth will be appended")
flag.IntVar(&flags.NumSprites, "num_sprites", 8, "number of sprite rotations to render")
flag.BoolVar(&flags.OutputTime, "time", false, "output basic profiling information")
// Short format
flag.Float64Var(&flags.Scale, "s", 1.0, "scale to render sprites at")
flag.StringVar(&flags.InputFilename, "i", "", "voxel file to process")
flag.StringVar(&flags.OutputFilename, "o", "", "base file name of output PNG files, bit depth will be appended")
flag.IntVar(&flags.NumSprites, "n", 8, "number of sprite rotations to render")
flag.BoolVar(&flags.OutputTime, "t", false, "output basic profiling information")
}
func main() {
if err := setupFlags(); err != nil {
return
}
startTime := time.Now()
palette, err := getPalette("files/ttd_palette.json")
if err != nil {
panic(err)
}
object, err := getVoxelObject(flags.InputFilename)
if err != nil {
panic(err)
}
def := spritesheet.Definition{
Object: object,
Palette: palette,
Scale: flags.Scale,
NumSprites: flags.NumSprites,
}
sheets := spritesheet.GetSpritesheets(def)
if err := sheets.SaveAll(flags.OutputFilename); err != nil {
panic(err)
}
if flags.OutputTime {
fmt.Printf("Time taken: %d ms", time.Since(startTime).Milliseconds())
}
}
func setupFlags() error {
flag.Parse()
if flags.InputFilename == "" {
flag.Usage()
return fmt.Errorf("input flag not set")
}
if flags.OutputFilename == "" {
flags.OutputFilename = fileutils.GetBaseFilename(flags.InputFilename)
} else {
flags.OutputFilename = fileutils.GetBaseFilename(flags.OutputFilename)
}
return nil
}
While this is getting a bit longer, everything in here is still concerned with handling command line flags and getting files, so I feel it's justified to stay in the main
package for now.
All of my tests still work, so it's time to run the ultimate test:
D:\projects\gorender>go build cmd/renderobject && renderobject.exe -input files/bus.vox -scale 2.0 -output output -time
Time taken: 22 ms
The result is exactly as we'd expect:
I now have a clean starting point to implement the rest of Transrender's functionality, and more importantly feel confident working in Go to do so.
If you're interested to see how GoRender takes shape (and spot where finding bugs took somewhat longer than the articles might suggest), the GitHub repository is here: https://github.com/mattkimber/gorender. Alternatively you can read earlier parts of this series and other things I may write from time to time about Go development by using this category link: https://mattkimber.co.uk/tag/go/