C# to Go: a beginner's journey (part 2)

I'm back on the Go trail, and I have two options for what to do next:

  1. Improve my existing code with better knowledge of the IO libraries.
  2. Port across some more Transrender functionality

There's a simple decision here, which is that I already have working code. Even if I think it can be improved, I'm not going to deliver much value for myself polishing it. I'm likely to learn more about Go while working on new code, which means when I do refactor and clean up the voxel loader I'll be more effective than if I do it now.

What I want is to get to something valuable as early as possible. Transrender's purpose is to produce sprite sheets in dimetric projection, looking something like this:

There's a lot of lighting and shading code to turn a blocky voxel object into that relatively smooth-looking bus, and I could easily get sidetracked into perfecting those so I'm going to set an initial MVP for GoRender: load a MagicaVoxel file and produce a simple flat-shaded version of that sprite sheet.

I have a number of prerequisites here:

  • Load the data (done!)
  • Do raycasting for each rendering angle
  • Save the raycast images to a file

Writing the raycaster is fun (if at times frustrating) but without being able to see the results it's not going to be an easy task. So the next step is to be able to output some sort of bitmap, even if it doesn't contain any sprites.

Transrender's bitmap handling is a bit of a mess, mainly because it went through a lot of changes and I forgot the "stabilise" part of "iterate and stabilise". The theory is that the BitmapGeometry class tells both the raycaster how big an individual sprite should be rendered, and also the overall bitmap output how big all sprites are and where they're located. In reality this ended up a lot messier, for two reasons:

  • Transrender's original "painter" renderer was a bit lax about outputting where it was supposed to.
  • Sprites are rendered internally at higher resolution and anti-aliased down to preserve details.

The first of these got resolved with a new raycasting renderer, but the code still has vestigial signs of a geometry class that doesn't quite trust what its renderer will do. On top of that the internal RenderScale is applied rather inconsistently, and I have a suspicion it is implicitly hard-coded in some places.

GoRender should improve on this. I'm going to identify three responsibilities:

  • Spritesheet is responsible for outputting the overall spritesheet.
  • Sprite is responsible for a single sprite at a chosen resolution.
  • Compositor composites sprites to a spritesheet, handling any scaling.

For our pre-MVP, a sprite can be a simple rectangle. The compositor will not handle any scaling. I'm also going to ignore details like masks and 8-bit paletted output which can come later.

Sprite

Go's image library throws up some new considerations. Like many image libraries, it has a built-in Rectangle (and indeed Point) structure which is used to specify image bounds. Less familiar are the fun and games of colour comparison. Go's color library is somewhat bare-bones so the best option I have to test colour equality is to extract the r, g and b values and compare them.

With this in mind, my test for the first version of Sprite is still relatively simple:

package sprite

import (
	"image"
	"testing"
)

func TestGetSprite(t *testing.T) {
	rect := image.Rectangle{Max: image.Point{X: 2, Y: 2}}
	img := GetSprite(rect, 0)

	if img.Bounds() != rect {
		t.Errorf("Image bounds %v not equal to expected %v", img.Bounds(), rect)
	}

	for x := rect.Min.X; x < rect.Max.X; x++ {
		for y := rect.Min.Y; y < rect.Max.Y; y++ {
			r, g, b, _ := img.At(x, y).RGBA()

			if r != 0 || g != 0 || b != 0 {
				t.Errorf("Non-black pixel %v at %d,%d", img.At(x, y),x,y)
			}
		}
	}
}

And the implementation is simpler still:

package sprite

import (
	"image"
	"image/color"
)

func GetSprite(bounds image.Rectangle, angle int) image.Image {
	img := image.NewRGBA(bounds)

	for x := bounds.Min.X; x < bounds.Max.X; x++ {
		for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
			img.Set(x, y, color.Black)
		}
	}

	return img
}

Compared to the C# version of Transrender, there's a notable difference. In the
C# version, there is the concept of a "projection" which is derived from the original painter's algorithm renderer, which didn't natively understand angles and so had 8 different projections corresponding to each of the 45 degree angles in the spritesheet.

For the Go version, I can be more flexible and just pass the angle I wish to render from. This signature may change once we start handling voxel objects but I can change this later, as handling voxels will also invalidate my tests.

Compositor

I'm now going to go one step up and write the compositor. This has a single method with the following signature:

func Composite(src image.Image, dst image.Image, loc image.Point, size image.Rectangle) error {

}

In future I'll want to handle the scaling between the larger sprites and the smaller destination images on the spritesheet, but for now all we want to do is be able to composite one image to another.

(An aside as I write a test: I'm really enjoying GoLand's tab-completion when creating test code. I rarely have to type more than the first three characters of "Test" before it's suggesting the method to test, and completing the empty method with parameters.)

The test looks like this:

package compositor

import (
	"image"
	"image/color"
	"testing"
)

func TestComposite(t *testing.T) {
	r1 := image.Rectangle{Max: image.Point{X: 1, Y: 1}}
	r2 := image.Rectangle{Max: image.Point{X: 2, Y: 2}}
	img1 := makeImage(r1, color.Black)
	img2 := makeImage(r2, color.White)

	err := Composite(img1, img2, image.Point{X: 1}, r1)

	if err != nil {
		t.Errorf("could not convert image to writable format: %s", err)
	}

	testColorAt(img2, 0, 0, 65535,65535,65535,t)
	testColorAt(img2, 1, 0, 0,0,0,t)
	testColorAt(img2, 1, 1, 65535,65535,65535,t)

}

func testColorAt(img image.Image, x int, y int, r uint32, g uint32, b uint32, t *testing.T) {
	ir,ig,ib,_ := img.At(x,y).RGBA()
	if ir != r || ig != g || ib != b {
		t.Errorf("Pixel at [%d,%d] is [%d,%d,%d] (expected [%d,%d,%d])", x,y,ir,ig,ib,r,g,b)
	}

}

func makeImage(bounds image.Rectangle, c color.Color) image.Image {
	image := image.NewRGBA(bounds)

	for x := bounds.Min.X; x < bounds.Max.X; x++ {
		for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
			image.Set(x, y, c)
		}
	}

	return image
}

That makeImage function looks suspiciously similar to the one I created for Sprite, and testColorAt feels like it could be used for that test. But let's get to green code before we start refactoring.

Go offers a useful package for compositing images which is image/draw (https://golang.org/pkg/image/draw/). This also highlights a few details of the image library:

  • image.Image is read-only and doesn't implement the Set() method, which is needed by image/draw to write to the destination image.
  • src can take something called image.Uniform which is an infinite slice of a single colour. This suggests I can simplify my makeImage function (glad I didn't refactor it yet!)

Other minor details easy to get thrown by include the RGBA image type being 16 bits per channel, and the rectangle in image/draw referring to the location in the destination image. But after a little wrangling, we have this first implementation for the compositor:

package compositor

import (
	"fmt"
	"image"
	"image/draw"
)

func Composite(src image.Image, dst image.Image, loc image.Point, size image.Rectangle) error {
	writableDst, ok := dst.(draw.Image)
	if !ok {
		return fmt.Errorf("could not convert destination image to writable image")
	}

	rect := image.Rectangle{Min: loc, Max: src.Bounds().Add(loc).Max}
	draw.Draw(writableDst, rect, src, image.Point{}, draw.Src)

	return nil
}

This is working, so now let's go back and clean up that image wrangling code. I think it's time to introduce some utility packages.

Diversion: imageutils

Since my sprite.GetSprite function already has a test for whether it correctly fills an image, I can adapt that to test my utility function. Normally I'd accept I already have coverage as part of the call chain, but I know I'm going to change what GetSprite does in future so for now having a duplicated test is a lesser evil.

I'll spare you the test code as it's identical, but my function currently looks like this (and fails the test, as expected!):

func GetUniformImage(bounds image.Rectangle, colour color.Color) image.Image {
	return nil
}

I'm going to start by copying across the code from sprite.go and refactoring GetSprite to the following:

func GetSprite(bounds image.Rectangle, angle int) image.Image {
	return imageutils.GetUniformImage(bounds, color.Black)
}

I can also remove makeImage from my compositor tests, and replace it with a call to GetUniformImage. Note that I haven't yet changed any of the code which generates the uniform image! The cycle is red, green and then refactor, so I need to make sure I have working code first:

D:\projects\gorender\src>go test ./...
ok      compositor      0.290s
ok      sprite  0.428s
ok      utils/imageutils        0.180s
?       voxelobject     [no test files]
ok      voxelobject/vox (cached)

Now I feel confident refactoring my GetUniformImage function, which becomes three concise lines:

func GetUniformImage(bounds image.Rectangle, colour color.Color) image.Image {
	img := image.NewRGBA(bounds)
	draw.Draw(img, bounds, &image.Uniform{C: colour}, image.Point{}, draw.Src)
	return img
}

Next I can move the logic for testColorAt to my imageutils package and write a test for it, which doesn't do a lot for the compositor tests but does let me simplify the TestGetSprite function quite a bit:

func TestGetSprite(t *testing.T) {
	rect := image.Rectangle{Max: image.Point{X: 2, Y: 2}}
	img := GetSprite(rect, 0)

	if img.Bounds() != rect {
		t.Errorf("Image bounds %v not equal to expected %v", img.Bounds(), rect)
	}

	for x := rect.Min.X; x < rect.Max.X; x++ {
		for y := rect.Min.Y; y < rect.Max.Y; y++ {
			if !imageutils.IsColourEqual(img, x, y, 0, 0, 0) {
				t.Errorf("Non-black pixel %v at 1,1", img.At(1, 1))
			}
		}
	}
}

With these in place I can now use them to write the tests for the last of my three packages, the spritesheet.

Spritesheet

Transrender's spritesheets have a number of variables which determine what gets output. They are:

  • The voxel object being rendered.
  • The scale - what resolution the output sprites are. A scale of 1.0 corresponds to an OpenTTD sprite for zoom level 2x. I'm going to change this in GoRender to a more logical 1.0 = 1x setup.
  • The colour depth - whether this is a 32bpp sprite or an 8bpp one. This is very messy in Transrender as it was originally designed only for 8bpp sprites with 32bpp support hacked on.

Transrender is also locked to generating only 8 rotations in a fixed dimetric projection. The latter is reasonable, but the former might be nice to make more flexible.

One performance bugbear of Transrender is that the different colour depths get rendered separately, meaning multiple passes through the (expensive) rendering pipeline. It would therefore be nice if the spritesheet handled producing all the formats we want in a single pass.

To start with, I'm going to use the following:

package spritesheet

import (
	"image"
	"voxelobject"
)

type Spritesheet struct {
	Image image.Image
}

type Spritesheets map[string]Spritesheet

func GetSpritesheets(object voxelobject.RawVoxelObject, scale float32, numSprites int) Spritesheets {
	return nil
}

The aim is to return a map of all spritesheets we can generate for this scale. I could introduce constants for these rather than using a string, but that feels like a relatively easy refactor once I know more about how I use this function.

Here we run into a problem because while we know the shape of the thing we want to test, we don't know enough about what it returns to validate whether it's working. Going back to our spritesheets, at scale=0.5 Transrender will produce something like this:

We currently have no renderer, but we will know the output to spritesheet is working if we get this:

We want to make our test agnostic to whether the composited sprite is a black square or a bus, because changing behaviour in Sprite shouldn't break the test for Spritesheet so long as that does what it's supposed to do correctly. So we need to test whether a given rectangle of the output spritesheet is equal to the image produced by the sprite renderer (and that all other pixels are set to white).

What's missing here is the geometry. Here we run into an interesting problem because Transrender's basic geometry is hard-coded to the 8 sprite angles it renders. We have the following rules:

  • Sprites 0 and 4 (the | direction) are 24x26
  • Sprites 2 and 6 (the - direction) are 32x24
  • All other sprites (the / and \ directions) are 26x26

To a certain extent these are the only hard points we need to care about as OpenTTD doesn't have any intermediate angles and we would probably be in the situation of needing to tune things based on the output.

In Transrender we have separate width and height methods but with Go's multiple return values this can be one function. Let's write the test with what we know:

func TestGetSpriteSizeForAngle(t *testing.T) {
	testCases := []struct{
		angle int
		expectedX int
		expectedY int
	}{
		{0, 24, 26},
		{45, 26, 26},
		{90, 32, 24},
		{135, 26, 26},
		{180, 24, 26},
		{225, 26, 26},
		{270, 32, 24},
		{315, 26, 26},
	}

	for _, testCase := range testCases {
		x, y := getSpriteSizeForAngle(testCase.angle, 1.0)
		if x != testCase.expectedX || y != testCase.expectedY {
			t.Errorf("output for angle %d was [%d,%d] (expected [%d,%d]", testCase.angle, x, y, testCase.expectedX, testCase.expectedY)
		}
	}
}

I'm now going to implement that function very simply:

func getSpriteSizeForAngle(angle int, scale float32) (x, y int) {
	var fx,fy float32

	switch {
	case angle == 0 || angle == 180:
		fx,fy = 24,26
	case angle == 90 || angle == 270:
		fx,fy = 32,24
	default:
		fx,fy = 26,26
	}

	return int(fx * scale), int(fy * scale)
}

The methods for sprite spacing and total height in Transrender are now so simple they can become constants, which means I can start writing my test. I'm going to start simply, by testing that I get an image of the expected dimensions returned in the map:

func TestGetSpritesheets(t *testing.T) {
	sheets := GetSpritesheets(voxelobject.RawVoxelObject{}, 1.0, 1)
	sheet, ok := sheets["32bpp"]

	if !ok {
		t.Fatalf("no 32bpp spritesheet present in result")
	}

	expectedRect := image.Rectangle{Max: image.Point{X: spriteSpacing, Y: totalHeight}}

	if sheet.Image.Bounds() != expectedRect {
		t.Errorf("spritesheet size %v did not match expected size %v", sheet.Image.Bounds(), expectedRect)
	}
}

I've learnt some new Go concepts here, which are:

  • How to see if a value is present in the map using a second ok return value
  • Immediately ending a test with t.Fatalf() when it's clear we can't continue

I'm now going to get that test to pass - this is a bit messy but I want to get to something fully functional before I start cleaning up:

func GetSpritesheets(object voxelobject.RawVoxelObject, scale float32, numSprites int) Spritesheets {
	w := int(float32(spriteSpacing*numSprites) * scale)
	h := int(float32(totalHeight*numSprites) * scale)
	bounds := image.Rectangle{Max: image.Point{X: w, Y: h}}

	img := image.NewRGBA(bounds)

	sheets := make(Spritesheets)
	sheets["32bpp"] = Spritesheet{Image: img}

	return sheets
}

Initialising a map is simple, we just call the make function. I now return an image with geometry that satisfies my test, but we're not testing to see if a sprite gets correctly composited onto the spritesheet. This requires adding some new functionality to imageutils. I want a function to check if a rectangle of some image is equal to some other sub-image, which will look something like this:

func IsImageEqualToSubImage(img image.Image, sub image.Image, bounds image.Rectangle) bool {

}

The test for this is relatively simple, note that I don't use the compositor as I may add more complex logic to that in the future which risks breaking the test:

func TestIsImageEqualToSubImage(t *testing.T) {
	rect1 := image.Rectangle{Max: image.Point{X: 3, Y: 3}}
	rect2 := image.Rectangle{Max: image.Point{X: 1, Y: 1}}
	img1 := GetUniformImage(rect1, color.Black)
	img2 := GetUniformImage(rect2, color.White)

	writableImg, ok := img1.(draw.Image)
	if !ok {
		t.Fatalf("could not write to image")
	}

	draw.Draw(writableImg, rect2, img2, image.Point{}, draw.Src)

	if !IsImageEqualToSubImage(img2, img1, rect2) {
		t.Errorf("Expected equality at %v but was not equal", rect2)
	}

	if IsImageEqualToSubImage(img2, img1, rect2.Add(image.Point{X: 1})) {
		t.Errorf("Expected equality at %v but was not equal", rect2.Add(image.Point{X: 1}))
	}
}

This is now failing, so time to implement the function. For this we won't use the existing IsColourEqual function as we also want to check the alpha values match:

func IsImageEqualToSubImage(img image.Image, sub image.Image, bounds image.Rectangle) bool {
	for x := bounds.Min.X; x < bounds.Max.X; x++ {
		for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
			sx, sy := x-bounds.Min.X, y-bounds.Min.Y

			r, g, b, a := img.At(x, y).RGBA()
			rs, gs, bs, as := sub.At(sx, sy).RGBA()

			if r != rs || g != gs || b != bs || a != as {
				return false
			}
		}
	}
	return true
}

This test now passes so I can go back to my spritesheet test and add some code to check that the output on the spritesheet matches what I'd expect from a composited sprite:

func TestGetSpritesheets(t *testing.T) {
	expectedRect := image.Rectangle{Max: image.Point{X: spriteSpacing, Y: totalHeight}}
	spriteRect := getTestSpriteRectangle(0, 1.0)
	expectedImg := getTestSpriteImage(spriteRect)

	sheets := GetSpritesheets(voxelobject.RawVoxelObject{}, 1.0, 1)
	sheet, ok := sheets["32bpp"]

	if !ok {
		t.Fatalf("no 32bpp spritesheet present in result")
	}

	if sheet.Image.Bounds() != expectedRect {
		t.Errorf("spritesheet size %v did not match expected size %v", sheet.Image.Bounds(), expectedRect)
	}

	if !imageutils.IsImageEqualToSubImage(sheet.Image, expectedImg, spriteRect) {
		t.Errorf("sprite at %v not equal to composited output", spriteRect)
	}

	if !imageutils.IsColourEqual(sheet.Image, spriteSpacing - 1, 0, 65535, 65535, 65535) {
		t.Errorf("blank area of spritesheet not set to white")
	}
}

func getTestSpriteRectangle(angle int, scale float32) image.Rectangle {
	x,y := getSpriteSizeForAngle(angle, scale)
	rect := image.Rectangle{Max: image.Point{X: x, Y: y}}
	return rect
}

func getTestSpriteImage(rect image.Rectangle) image.Image {
	spr := sprite.GetSprite(rect, 0)
	img := image.NewRGBA(rect)
	compositor.Composite(spr, img, image.Point{}, rect)
	return img
}

I'm now back to a failing test, so need to implement the extra logic in GetSpritesheets. This is relatively concise and we end up with the following function:

func GetSpritesheets(object voxelobject.RawVoxelObject, scale float32, numSprites int) Spritesheets {
	w := int(float32(spriteSpacing*numSprites) * scale)
	h := int(float32(totalHeight) * scale)
	bounds := image.Rectangle{Max: image.Point{X: w, Y: h}}

	img := imageutils.GetUniformImage(bounds, color.White)
	angleStep := 360 / numSprites

	for i := 0; i < numSprites; i++ {
		angle := i * angleStep
		sw, sh := getSpriteSizeForAngle(angle, scale)
		rect := image.Rectangle{Max: image.Point{X: sw, Y: sh}}
		spr := sprite.GetSprite(rect, angle)
		compositor.Composite(spr, img, image.Point{X: i * spriteSpacing}, rect)
	}

	sheets := make(Spritesheets)
	sheets["32bpp"] = Spritesheet{Image: img}

	return sheets
}

I'm starting to feel some refactoring pressure around the number of places where I get some sort of x,y co-ordinate pair then immediately turn them into a rectangle. Worth keeping a note of and refactoring if I find myself doing that every time I call getSpriteSizeForAngle().

That aside, we now have the pre-MVP I wanted, which is to output a black rectangle of the correct size at each sprite position. But so far I only have a bunch of unit tests to show for it. It's time to produce some useful output!

Making an executable

I'm going to create a /cmd subfolder for all of my executables - this means I can separate out the main library from the command-line application. In Go convention, an executable is created from a main package, and you can reference a different one when you run go build to allow for multiple executables to be built from a single project.

To start with, I'm going to create a simple command line tool which outputs a .png file - since we only produce black squares it doesn't need to worry (yet) about specifying an object on the command line.

Here is the source for /cmd/renderobject/main.go - that PNG saving logic looks like something which should become a library function to keep this package small, but I can refactor this later.

package main

import (
	"fmt"
	"image/png"
	"os"
	"spritesheet"
	"voxelobject"
)

func main() {
	sheets := spritesheet.GetSpritesheets(voxelobject.RawVoxelObject{},1.0,8)
	sheet, ok := sheets["32bpp"]

	if !ok {
		panic("no 32bpp sprite sheet available")
	}

	file, err := os.Create("output.png")

	if err != nil {
		s := fmt.Sprintf("could not open output file: %s", err)
		panic(s)
	}

	if err := png.Encode(file, sheet.Image); err != nil {
		file.Close()
		s := fmt.Sprintf("error writing file: %s", err)
		panic(s)
	}

	if err := file.Close(); err != nil {
		s := fmt.Sprintf("error closing file: %s", err)
		panic(s)
	}
}

Panicking if anything goes wrong maybe isn't the cleanest thing to do, but it'll do for now. This can be cleaned up once I separate the concerns of saving a PNG file and how to report errors to the user of the command line tool.

Go's PNG library makes life very easy here, you could almost miss that if err := png.Encode(file, sheet.Image) statement. If I didn't do any error checking this would be a short method.

Building it is simple:

D:\projects\gorender\src>go build cmd/renderobject

I now have a renderobject.exe executable, and upon running it I get the following output.png created:

Success! I only had one bug which was that my original code had multiplied both the width and the height of the spritesheet by the number of sprites, and because my test only has 1 sprite I didn't catch it. An easy and obvious fix. This is where I claw back all that time writing tests because I don't have a while bunch of "why did it do that" problems to deal with the way I had with the C# Transrender.

It's still a bit early to compare code complexity but it's starting to feel like the structure of GoRender is resulting in something a lot simpler. The real test of this will come when I start implementing the raycast renderer and having to deal with palettes and output across multiple bit depths.