The Pixels a Sprite Skips
The 98 walker pixels
The renderer fills rectangles, draws lines, refuses to write outside its clip rectangle, and reduces a finished frame to a number a test can assert. Everything it has produced is made of solid blocks, which is enough for ground, water and a ridge, then fails the moment something has to stand on top of them.
A picture of a creature is a small rectangle of pixels, most of which are not the creature. A sprite is a rectangle of pixels in which every pixel carries its own instruction: alpha 0 means leave the framebuffer alone, alpha 255 means overwrite it, and anything in between means mix the two, in arithmetic wide enough to hold the product.
Try it with what the client already has. A walker is roughly twelve pixels by twelve, so draw one as a filled rectangle in a creature green. What lands on the frame is a brick: green where the animal is, green in the gap between its legs, green in the four corners where there is nothing at all, and the pond it was standing at the edge of disappears behind a square.
Trim the rectangle and the brick gets smaller. It stays a brick.
In the sixteen-by-sixteen cell this chapter builds, 98 pixels are the walker and the other 158 are whatever happens to be behind it: soil, water, another creature, sky. A sprite's colors are only half of what it carries. The other half is the list of pixels it must leave alone.
Chapter 12 packed a byte for exactly this and then left it idle. Alpha rides in the top eight bits of every color, does nothing to a lamp, and was introduced as the number that says what should happen when this color is drawn on top of another one. Nothing has been drawn on top of anything yet, so nothing has read it; every pixel of the scene carries alpha 255, and the PNG encoder noticed and dropped the channel from the file as pointless.
Four things follow from the rule: a program that generates a sprite sheet, a loader that
reads it back, one drawing operation called Blit, and the arithmetic for the
third case. The blend is worked out on paper before it is written down, because a blend
that comes out one unit light is invisible to a person and obvious to a hash.
The 328-byte atlas
The obvious arrangement is one file per picture: walker-stand.png,
walker-step.png, loaded by name. Nothing about it is wrong and almost no
game does it. Count the files. A creature with four facings and four frames of walk is
sixteen pictures; a second creature is sixteen more; props, tiles and portraits run
into the hundreds. Each one is an open, a decode and an allocation, and each carries a
PNG's fixed overhead of signature, header chunk, checksums and its own compression
dictionary. The four small pictures here cost 601 bytes as four files and
328 bytes as one image.
So games pack. One image holds many pictures in a grid of cells, and a frame stops
being a file and becomes a rectangle: column times cell size across, row times cell
size down. The type for that exists already. Rect was built for clipping
in chapter 13, it is half-open so cells tile without sharing a pixel, and it describes
a region of a sheet as happily as a region of the screen. One decode, one allocation,
one contiguous run of memory, and every frame inside it addressed by arithmetic.
Which leaves the awkward part: there is no art. Nobody has drawn a walker, the tools for drawing one are their own chapter, and a loader with nothing to load cannot be tested. So the first sheet is generated. The stand-in is a character mask, one character per pixel against a color key, with a space for a pixel that is not there. It is programmer art and it looks like programmer art. It is also the right stand-in, because the loader wants four bytes per pixel and a cell size, and holds no opinion about whether a person or a loop put them there.
// cmd/mkatlas/main.go
package main
import (
"fmt"
"os"
"theworld/internal/render"
)
// Cell is the size of every frame on the sheet.
const Cell = 16
// The stand-in palette. ' ' is the pixel that is not there.
var key = map[byte]render.Color{
' ': 0x00000000, // nothing at all: alpha 0
'o': 0xFF2B3524, // outline
'b': 0xFF6E8F4A, // body
'l': 0xFF9DBF6B, // belly
'e': 0xFFF2EEDC, // eye
}
// shade is the shadow's color: a cool near-black, one quarter there.
const shade render.Color = 0x40101418
// stand is one walker, drawn as characters so the transparent pixels
// are the ones you can see through in the source too.
var stand = []string{
" ",
" ",
" oooo ",
" oobbbbo ",
" oooooobbebbo ",
" obbbbbbbbbbbo ",
" obllbbbbbbbbbo ",
" obllllbbbbbbo ",
" obbllbbbbbbo ",
" obbbbbbbbbo ",
" bb bb ",
" bb bb ",
" oo oo ",
" ",
" ",
" ",
}
// step is the same walker mid-stride: two frames of an animation share
// everything except the rows that move.
var step = withLegs(stand, []string{
" bb bb ",
" bb bb ",
" oo oo ",
})
// withLegs copies a mask and replaces rows 10 through 12.
func withLegs(mask []string, legs []string) []string {
out := append([]string(nil), mask...)
copy(out[10:13], legs)
return out
}
// cmd/mkatlas/main.go — the mask becomes pixels
// paint writes one character mask into the sheet at a cell origin.
func paint(sheet *render.Buffer, col int, mask []string) {
if len(mask) != Cell {
fmt.Fprintf(os.Stderr, "mkatlas: cell %d has %d rows, want %d\n", col, len(mask), Cell)
os.Exit(1)
}
for y, row := range mask {
if len(row) != Cell {
fmt.Fprintf(os.Stderr, "mkatlas: cell %d row %d is %d characters, want %d\n", col, y, len(row), Cell)
os.Exit(1)
}
for x := 0; x < Cell; x++ {
c, ok := key[row[x]]
if !ok {
fmt.Fprintf(os.Stderr, "mkatlas: cell %d row %d: no color for %q\n", col, y, row[x])
os.Exit(1)
}
sheet.Set(col*Cell+x, y, c)
}
}
}
// shadow draws a squat oval across the bottom of a cell, every pixel of
// it one quarter opaque.
func shadow(sheet *render.Buffer, col int) {
for _, r := range []render.Rect{{X0: 4, Y0: 12, X1: 12, Y1: 13},
{X0: 2, Y0: 13, X1: 14, Y1: 14},
{X0: 4, Y0: 14, X1: 12, Y1: 15}} {
for x := r.X0; x < r.X1; x++ {
sheet.Set(col*Cell+x, r.Y0, shade)
}
}
}
// testCard fills a cell with sixteen columns of one color at sixteen
// alphas, 0 on the left and 255 on the right: an asset that exists to
// be measured.
func testCard(sheet *render.Buffer, col int) {
for x := 0; x < Cell; x++ {
a := uint8(x * 17)
for y := 0; y < Cell; y++ {
sheet.Set(col*Cell+x, y, render.RGBA(a, 0xE8, 0xD8, 0xA0))
}
}
}
func main() {
sheet := render.NewBuffer(4*Cell, Cell)
paint(sheet, 0, stand)
paint(sheet, 1, step)
shadow(sheet, 2)
testCard(sheet, 3)
const path = "assets/atlas.png"
if err := sheet.SaveSheet(path); err != nil {
fmt.Fprintln(os.Stderr, "mkatlas:", err)
os.Exit(1)
}
fi, err := os.Stat(path)
if err != nil {
fmt.Fprintln(os.Stderr, "mkatlas:", err)
os.Exit(1)
}
fmt.Printf("wrote %s: %dx%d, %d cells of %d, %d bytes\n",
path, sheet.W, sheet.H, sheet.W/Cell, Cell, fi.Size())
fmt.Printf("pixels sha256 %s\n", sheet.Hash())
fmt.Print(render.AlphaMap(sheet, sheet.Bounds()))
}
$ go run ./cmd/mkatlas
wrote assets/atlas.png: 64x16, 4 cells of 16, 328 bytes
pixels sha256 32b9647db215d2efe2da7a7ee45ac09cda38bac87273bc043f9db185dc88f96b
.................................................01123344566778#
.................................................01123344566778#
..........####............####...................01123344566778#
........#######.........#######..................01123344566778#
...############....############..................01123344566778#
..#############...#############..................01123344566778#
.##############..##############..................01123344566778#
.#############...#############...................01123344566778#
.############....############....................01123344566778#
..###########.....###########....................01123344566778#
...##....##.......##.......##....................01123344566778#
...##....##.......##.......##....................01123344566778#
...##....##.......##.......##.......22222222.....01123344566778#
..................................222222222222...01123344566778#
....................................22222222.....01123344566778#
.................................................01123344566778#
That block of characters is AlphaMap, which prints one character per
pixel from the alpha channel alone: . for a pixel that is not there,
# for a solid one, and a digit for everything in between. It is the
same debugging trick Preview plays with color, aimed at the byte this
chapter is about, and it shows the sheet's whole design in sixteen lines. Two solid
silhouettes with different legs. A shadow of 28 pixels, every one of them a
2, which is alpha 64 of a possible 255. A test card whose sixteen
columns climb from nothing on the left to solid on the right, seventeen units of
alpha at a time.
The test card is there to be measured. Art is judged by eye, which leaves a blend bug room to hide inside a picture nobody has seen before; sixteen known alphas of one color over a known background produce sixteen numbers you can predict on paper.
The run above went out through SaveSheet, which has not been written yet.
It is a dozen lines, and one decision inside it is the reason the sheet does not leave
the program through the SavePNG chapter 14 built.
// internal/render/sprite.go
// SaveSheet writes a buffer as an 8-bit RGBA PNG whose channels are
// straight, not scaled by alpha: the form a sprite sheet is authored
// and read in.
func (b *Buffer) SaveSheet(path string) error {
m := image.NewNRGBA(image.Rect(0, 0, b.W, b.H))
for y := range b.H {
for x := range b.W {
c := b.At(x, y)
i := m.PixOffset(x, y)
m.Pix[i], m.Pix[i+1], m.Pix[i+2], m.Pix[i+3] = c.R(), c.G(), c.B(), c.A()
}
}
f, err := os.Create(path)
if err != nil {
return err
}
if err := png.Encode(f, m); err != nil {
f.Close()
return fmt.Errorf("encode %s: %w", path, err)
}
return f.Close()
}
$ xxd -l 32 assets/atlas.png
00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452 .PNG........IHDR
00000010: 0000 0040 0000 0010 0806 0000 00a6 e779 ...@...........y
SavePNG hands pixels to the encoder as color.RGBA, a type
whose three color channels are defined to be scaled by alpha already. Every pixel of
a frame is opaque, and for an opaque pixel the scaled and unscaled forms are the same
three numbers, so the distinction has cost nothing so far. Half the cells on this
sheet are not opaque. image.NRGBA is the standard library's name for the
other convention, four bytes per pixel with the colors untouched, which is what the
mask wrote and what the file has to carry.
The header says the file agrees. Width 00000040 is 64, height
00000010 is 16, then bit depth 8 and color type 6. The scene's PNG came
out color type 2, three channels and no alpha, because the encoder inspected the
pixels and found nothing to keep. This one is type 6, and that single byte is the
file admitting it has holes in it.
The loader knows three things about assets/atlas.png: it is an 8-bit
RGBA PNG, its cells are 16 pixels square, and columns are counted from the left. Any
file meeting those terms drops into place, whatever produced it. The masks above are
a placeholder for drawn art, and nothing downstream can tell the difference when a
hand-drawn sheet follows the same terms.
The copy loop that skips
A sheet is a buffer plus one number, the cell size, and a frame is arithmetic on that number. The copy itself is two nested loops with a question asked once per pixel. Every run on this page is headless: the client can open a window, and it can also draw one frame, hash it and write a PNG without one, and that second path is the one a test and a container take.
// internal/render/sprite.go
// Sheet is one decoded image plus the size of the square cells its
// frames are cut on. Frames are rectangles into it, not files.
type Sheet struct {
Pix *Buffer
Cell int
}
// Frame is the source rectangle of one cell, counted left to right and
// top to bottom, in the same half-open convention every other rectangle
// in this package uses.
func (s *Sheet) Frame(col, row int) Rect {
return Rect{
X0: col * s.Cell,
Y0: row * s.Cell,
X1: (col + 1) * s.Cell,
Y1: (row + 1) * s.Cell,
}
}
// Blit copies the source rectangle src of a sheet onto the buffer with
// its top-left corner at (dx,dy).
func (b *Buffer) Blit(s *Sheet, src Rect, dx, dy int) {
// A frame that hangs off the sheet is cut to the sheet, and the
// destination corner moves with it.
cut := src.Intersect(s.Pix.Bounds())
dx, dy = dx+(cut.X0-src.X0), dy+(cut.Y0-src.Y0)
src = cut
dst := Rect{dx, dy, dx + (src.X1 - src.X0), dy + (src.Y1 - src.Y0)}.Intersect(b.clip)
if dst.Empty() {
return
}
// Whatever the clip took off the destination comes off the source
// too: this pair of offsets carries a destination pixel back to the
// sheet pixel it came from.
ox, oy := src.X0-dx, src.Y0-dy
for y := dst.Y0; y < dst.Y1; y++ {
for x := dst.X0; x < dst.X1; x++ {
c := s.Pix.Pix[s.Pix.index(x+ox, y+oy)]
if c.A() == 0 {
continue // this pixel is not there
}
b.Pix[b.index(x, y)] = c
}
}
}
// cmd/worldc/main.go — load the sheet, count what is in one cell, draw
pix, err := render.LoadPNG("assets/atlas.png")
if err != nil {
fmt.Fprintln(os.Stderr, "worldc:", err)
os.Exit(1)
}
sheet := &render.Sheet{Pix: pix, Cell: 16}
fmt.Printf("worldc %s sheet %dx%d, %d cells of %d, sha256 %s\n",
version, sheet.Pix.W, sheet.Pix.H, sheet.Pix.W/sheet.Cell, sheet.Cell, sheet.Pix.Hash())
// How much of one cell is actually there.
stand := sheet.Frame(render.CellStand, 0)
there := 0
for y := stand.Y0; y < stand.Y1; y++ {
for x := stand.X0; x < stand.X1; x++ {
if sheet.Pix.At(x, y).A() != 0 {
there++
}
}
}
fmt.Printf("cell %d: %d pixels, %d of them carry color\n", render.CellStand, 16*16, there)
before := render.NewBuffer(render.SceneW, render.SceneH)
render.Scene(before)
b := render.NewBuffer(render.SceneW, render.SceneH)
render.Scene(b)
b.Blit(sheet, stand, 40, 58)
b.Blit(sheet, sheet.Frame(render.CellStep, 0), 92, 66)
b.Blit(sheet, stand, -6, 74) // half of it is off the west edge
fmt.Printf("blitting three cells changed %d pixels of the frame\n", render.Diff(before, b).Count)
fmt.Printf("frame %dx%d sha256 %s\n", b.W, b.H, b.Hash())
$ go run ./cmd/worldc -shot walkers.png
worldc 0.0.1 sheet 64x16, 4 cells of 16, sha256 befd50436a855f41cb45c7ff45d804b5f5e73956e650b1db3aa167e9a9f45e54
cell 0: 256 pixels, 98 of them carry color
blitting three cells changed 262 pixels of the frame
frame 160x96 sha256 560c9e0bad113c4410441a0fd25f2319c50414fcf0b0295d11eddf1ba6936857
wrote walkers.png
Open walkers.png. Two creatures stand on the ground, one of them at the
north edge of the pond with water plainly visible between its front and back legs,
and a third is walking in from the west with only its head and front legs on the
frame. None of them sits in a box. The 158 transparent pixels of each cell did
nothing, and doing nothing is the entire feature.
The count on the third line is the claim, and it is checkable. Two full cells at 98
colored pixels each is 196. The third blit starts at column −6, so its first
six columns fall off the west edge and ten survive, and those ten columns hold 66
colored pixels. 98 + 98 + 66 = 262, exactly what Diff reports against
the same scene drawn without sprites. No blitted pixel landed on ground that already
happened to be its color.
Follow the clipped one through the code, because it is where blitting differs from
filling. The destination rectangle is {-6 74 10 90}, and intersecting it
with the clip gives {0 74 10 90}, six columns narrower on the left. The
source has to lose the same six columns from the same side, and
ox = src.X0 - dx is what does it: with src.X0 at 0 and
dx at −6, ox is 6, so destination column 0 reads sheet
column 6 and the walker's rear half is never read at all. That is chapter 13's rule
applied to two rectangles at once. Clip the largest unit you can name in advance,
then carry the same adjustment across to wherever the pixels are coming from.
Now point that loop at the other two cells and it produces nonsense twice over. Every pixel of the shadow oval carries alpha 64, and 64 is not 0, so all 28 of them get copied: a slab of near-black under the creature's feet where the ground should be showing through, darker. Worse, the copy puts alpha 64 into the framebuffer, which every operation so far has kept opaque, so the frame heading for the window now has holes nobody asked for. A copy loop knows two answers. This pixel needs a third.
Mixing two colors in whole numbers
Take the two pixels the client is about to combine. The ground is soil,
0xFF6B5A3E: red 107, green 90, blue 62. The shadow pixel above it is
0x40101418: alpha 64, red 16, green 20, blue 24. Alpha 64 out of 255
says the shadow is a little over a quarter present, so the answer should be mostly
soil with some near-black mixed into it. "Mostly" is not a number. Here is the
number.
Each channel is a weighted average of the two. The source's weight is a, the destination gets whatever is left of 255, and dividing the total by 255 brings the result back into one byte:
out = (src·a + dst·(255 − a)) / 255
| channel | src·a | dst·(255−a) | sum | ÷ 255 | byte |
|---|---|---|---|---|---|
| red | 16·64 = 1024 | 107·191 = 20437 | 21461 | 84.16 | 84 |
| green | 20·64 = 1280 | 90·191 = 17190 | 18470 | 72.43 | 72 |
| blue | 24·64 = 1536 | 62·191 = 11842 | 13378 | 52.46 | 52 |
84, 72 and 52 pack to 0xFF544834, and the alpha is 255 because the
destination was opaque and a quarter of a shadow does not make it less so. Every
product in that table exceeds a byte, which is chapter 12's lesson arriving on
schedule: 107·191 is 20,437, and a channel-sized variable would have wrapped it into
garbage long before the division. Integer division then throws the fraction away, so
84.16 becomes 84 and each channel lands at or a fraction below the exact answer.
The divisor is the part that gets fumbled. Dividing by 255 is awkward and dividing by
256 is a shift, so the tempting version is
(src·a + dst·(255 − a)) >> 8. Test it where the answer is not a
matter of taste. At alpha 255 the mix has to return the source untouched, because the
sprite is solid there: 232·255 divided by 255 is 232, and the shifted form gives 231.
At alpha 0 it has to return the destination untouched: 107 stays 107 with the
division and becomes 106 with the shift. There is a third property the shift also
fails, and it is the one to remember. Mixing a color with itself must return that
color, at every alpha, since (v·a + v·(255 − a))/255 is v·255/255. Divide by 256
instead and any flat area loses a unit under every sprite drawn over it.
// internal/render/sprite.go
// Over mixes one source pixel into one destination pixel, source-over:
// each channel is (src*a + dst*(255-a)) / 255. The destination is a
// framebuffer pixel, always opaque, so the answer is opaque too.
func Over(src, dst Color) Color {
a := int(src.A())
inv := 255 - a
r := (int(src.R())*a + int(dst.R())*inv) / 255
g := (int(src.G())*a + int(dst.G())*inv) / 255
b := (int(src.B())*a + int(dst.B())*inv) / 255
return RGBA(255, uint8(r), uint8(g), uint8(b))
}
// internal/render/sprite.go — Blit's inner loop, with the case it was missing
c := s.Pix.Pix[s.Pix.index(x+ox, y+oy)]
switch c.A() {
case 0: // not there: leave the framebuffer alone
case 255: // solid: overwrite
b.Pix[b.index(x, y)] = c
default: // partly there: mix
i := b.index(x, y)
b.Pix[i] = Over(c, b.Pix[i])
}
// cmd/worldc/main.go — print the hand calculation, then draw everything
shadowPx := sheet.Pix.At(render.CellShade*16+8, 13)
cardPx := sheet.Pix.At(render.CellCard*16+8, 4)
soil, sky := b.At(98, 79), b.At(128, 12)
fmt.Printf("shadow %08X over soil %08X -> %08X\n",
uint32(shadowPx), uint32(soil), uint32(render.Over(shadowPx, soil)))
fmt.Printf("card %08X over sky %08X -> %08X\n",
uint32(cardPx), uint32(sky), uint32(render.Over(cardPx, sky)))
render.SpriteScene(b, sheet)
fmt.Printf("frame %dx%d sha256 %s\n", b.W, b.H, b.Hash())
fmt.Printf("the frame holds %08X at (98,79) and %08X at (128,12)\n",
uint32(b.At(98, 79)), uint32(b.At(128, 12)))
// internal/render/scene.go — the standing composition, with sprites on it
// SpriteScene is Scene with the sheet drawn over it: two creatures with
// shadows, a third walking off the west edge, and the test card in the
// sky, in that order.
func SpriteScene(b *Buffer, s *Sheet) {
Scene(b)
stand, step := s.Frame(CellStand, 0), s.Frame(CellStep, 0)
shade, card := s.Frame(CellShade, 0), s.Frame(CellCard, 0)
b.Blit(s, shade, 40, 58)
b.Blit(s, stand, 40, 58)
b.Blit(s, shade, 92, 66)
b.Blit(s, step, 92, 66)
b.Blit(s, stand, -6, 74) // half of it is off the west edge
b.Blit(s, card, 120, 8)
}
$ go run ./cmd/worldc -shot dark.png
worldc 0.0.1 sheet 64x16, 4 cells of 16, sha256 befd50436a855f41cb45c7ff45d804b5f5e73956e650b1db3aa167e9a9f45e54
shadow 40040506 over soil FF6B5A3E -> FF51442F
card 887C7355 over sky FF6E8CA0 -> FF757E78
frame 160x96 sha256 119f1222fdc2003de1c564185cfecc9a2cfa95a992ad5bc2a4801e92f9d941e7
the frame holds FF51442F at (98,79) and FF757E78 at (128,12)
wrote dark.png
The hand calculation said 0xFF544834 and the program says
0xFF51442F. Open the file and the test card, which should climb evenly
from sky blue to pale, sags through a muddy grey in its middle columns before
snapping to the right color in its last one. Something is wrong, and the interlude
was checked twice.
The client's own output names the culprit on its second line, ahead of any picture.
mkatlas wrote that shadow pixel as 0x40101418, and the
sheet sitting in memory says 0x40040506. Alpha came through and the
three colors did not: 16 became 4, 20 became 5, 24 became 6. Each one is its
original multiplied by 64 and divided by 255, which is to say multiplied by its own
alpha. The test card's pixel tells the same story at a louder volume:
0x88E8D8A0 arrived as 0x887C7355, 232 scaled down to 124
by an alpha of 136.
The culprit is the loader, and it was the reasonable choice. LoadPNG
reads a pixel with img.At(x, y).RGBA(), and that method is documented to
return alpha-premultiplied values: colors already scaled by their alpha. Chapter 14
said as much and added that it holds as long as the buffer stays opaque. Every buffer
was opaque then. The sheet is the first thing in this program that is not, so the
scaling has been happening since the first line of stage 3 and nothing could tell:
the walkers are solid, and scaling a solid pixel by 255/255 changes nothing.
Then Over multiplies by alpha a second time. Follow the shadow's red
through both hits: 16 becomes 4 in the loader, and the mix computes
(4·64 + 107·191)/255 = 81 where the honest answer is 84. On a dark shadow that is
three units, invisible. On the test card at alpha 136 the same double scaling turns
175 into 117, and 58 units is a picture anyone can see is wrong. Two multiplications
where the arithmetic wanted one, and the size of the error tracks how bright and how
translucent the source is.
// internal/render/sprite.go
// LoadSheet reads a sprite sheet, taking its four bytes per pixel
// exactly as they were authored.
func LoadSheet(path string, cell int) (*Sheet, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
n, ok := img.(*image.NRGBA)
if !ok {
return nil, fmt.Errorf("%s decoded as %T, want *image.NRGBA: save the sheet as 8-bit RGBA", path, img)
}
b := NewBuffer(n.Rect.Dx(), n.Rect.Dy())
for y := range b.H {
for x := range b.W {
i := n.PixOffset(n.Rect.Min.X+x, n.Rect.Min.Y+y)
b.Pix[b.index(x, y)] = RGBA(n.Pix[i+3], n.Pix[i], n.Pix[i+1], n.Pix[i+2])
}
}
if b.W%cell != 0 || b.H%cell != 0 {
return nil, fmt.Errorf("%s is %dx%d, which does not divide into %d-pixel cells", path, b.W, b.H, cell)
}
return &Sheet{Pix: b, Cell: cell}, nil
}
// cmd/worldc/main.go — one line different
sheet, err := render.LoadSheet("assets/atlas.png", 16)
if err != nil {
fmt.Fprintln(os.Stderr, "worldc:", err)
os.Exit(1)
}
$ go run ./cmd/worldc -shot frame.png
worldc 0.0.1 sheet 64x16, 4 cells of 16, sha256 32b9647db215d2efe2da7a7ee45ac09cda38bac87273bc043f9db185dc88f96b
shadow 40101418 over soil FF6B5A3E -> FF544834
card 88E8D8A0 over sky FF6E8CA0 -> FFAFB4A0
frame 160x96 sha256 eb3c5619ec6474601ee8d8d80c22af448a17b6f6f87e712846d1afaefc236a83
the frame holds FF544834 at (98,79) and FFAFB4A0 at (128,12)
wrote frame.png
0xFF544834, the three numbers the table produced, and the last line
confirms that the pixel sitting in the framebuffer at (98,79) is the one
Over promised. The sheet's own hash changed too, from
befd5043… to 32b9647d…, and the second of those is the
number mkatlas printed when it wrote the file. Two programs now agree
about what is in that PNG.
The card line is the more interesting proof. Blue was 160 in the sheet and 160 in the
sky, and it comes back 160: mixing a color with itself returned that color, which is
the property the shift would have broken. Red climbed from 110 to 175 and green from
140 to 180, both a little over half way to the card's own color, because alpha 136
out of 255 is a little over half. Open frame.png and the card is a clean
ramp from sky to pale, with the ridge line behind it faintly visible through its
middle columns.
dark.png is still on disk, so measure how far the bug reached, using the
comparison the client grew in chapter 14:
$ go run ./cmd/worldc -diff frame.png dark.png
frame.png sha256 eb3c5619ec6474601ee8d8d80c22af448a17b6f6f87e712846d1afaefc236a83
dark.png sha256 119f1222fdc2003de1c564185cfecc9a2cfa95a992ad5bc2a4801e92f9d941e7
276 of 15360 pixels differ, first at (121,8): ff7691a0 became ff678396
276 pixels of 15,360, and their addresses finish the story. 224 of them are in the
test card: fourteen of its sixteen columns, all but the two ends. 25 and 27 more are
the two shadows, a little short of 28 each because the creatures' feet were drawn
over them afterwards. Outside those three rectangles nothing differs at all. Both
creatures are identical in both files, the card's alpha-0 column is identical, and so
is its alpha-255 column. The bug could only ever touch a pixel that went through the
default branch, which is exactly the set of pixels whose color the
loader had scaled.
Figure 16.1: the instruction travels with the pixel it applies to, so the loop needs no list of which parts of a sprite are solid.
Alpha 0, alpha 255 and flat color
The blend is arithmetic, so it can be asserted directly instead of through a picture. The test pins three claims: alpha 0 changes nothing, alpha 255 delivers the source exactly, and a color mixed with itself comes back unaltered at every alpha. The third is 256 assertions and costs nothing to run.
// internal/render/sprite_test.go
// sheetPath is the stand-in atlas, written by cmd/mkatlas.
var sheetPath = filepath.Join("..", "..", "assets", "atlas.png")
// frameHash is the SHA-256 of the pixels SpriteScene draws over Scene.
const frameHash = "eb3c5619ec6474601ee8d8d80c22af448a17b6f6f87e712846d1afaefc236a83"
// TestOverKeepsItsEnds pins the three properties the mix must have for a
// sprite to look like the art it was drawn from.
func TestOverKeepsItsEnds(t *testing.T) {
dst := Soil
src := RGBA(255, 0xE8, 0xD8, 0xA0)
if got := Over(RGBA(0, 0xE8, 0xD8, 0xA0), dst); got != dst {
t.Errorf("alpha 0 moved the destination: %08X, want %08X", uint32(got), uint32(dst))
}
if got := Over(src, dst); got != src {
t.Errorf("alpha 255 did not deliver the source: %08X, want %08X", uint32(got), uint32(src))
}
// Mixing a color with itself has to return that color at every alpha,
// or flat areas crawl darker every time something is drawn over them.
flat := RGBA(255, 90, 90, 90)
for a := 0; a <= 255; a++ {
if got := Over(RGBA(uint8(a), 90, 90, 90), flat); got != flat {
t.Fatalf("alpha %d mixed %08X with itself and got %08X", a, uint32(flat), uint32(got))
}
}
}
// TestSheetIsStraight checks the sheet arrives with the bytes mkatlas
// wrote, not with its colors already scaled by alpha.
func TestSheetIsStraight(t *testing.T) {
s, err := LoadSheet(sheetPath, 16)
if err != nil {
t.Fatal(err)
}
if got, want := s.Pix.At(CellShade*16+8, 13), Color(0x40101418); got != want {
t.Errorf("shadow pixel is %08X, want %08X: the loader scaled it", uint32(got), uint32(want))
}
if got, want := s.Pix.At(CellCard*16+8, 4), Color(0x88E8D8A0); got != want {
t.Errorf("card pixel is %08X, want %08X: the loader scaled it", uint32(got), uint32(want))
}
}
func TestSpriteSceneMatchesItsHash(t *testing.T) {
s, err := LoadSheet(sheetPath, 16)
if err != nil {
t.Fatal(err)
}
b := NewBuffer(SceneW, SceneH)
SpriteScene(b, s)
got := b.Hash()
if got == frameHash {
return
}
gotPath := filepath.Join("testdata", "sprites.got.png")
if err := b.SavePNG(gotPath); err != nil {
t.Fatal(err)
}
t.Fatalf("pixels hash %s\n want %s\n wrote %s", got, frameHash, gotPath)
}
$ go test -count=1 -v ./internal/render/
=== RUN TestOverKeepsItsEnds
--- PASS: TestOverKeepsItsEnds (0.00s)
=== RUN TestSheetIsStraight
--- PASS: TestSheetIsStraight (0.00s)
=== RUN TestSpriteSceneMatchesItsHash
--- PASS: TestSpriteSceneMatchesItsHash (0.00s)
PASS
ok theworld/internal/render 0.003s
Now change the three divisions in Over to >> 8 and run
the first test alone, to see whether an argument made on paper is one a machine can
hold you to:
$ go test -count=1 -run TestOverKeepsItsEnds ./internal/render/
--- FAIL: TestOverKeepsItsEnds (0.00s)
sprite_test.go:21: alpha 0 moved the destination: FF6A593D, want FF6B5A3E
sprite_test.go:24: alpha 255 did not deliver the source: FFE7D79F, want FFE8D8A0
sprite_test.go:31: alpha 0 mixed FF5A5A5A with itself and got FF595959
FAIL
FAIL theworld/internal/render 0.002s
FAIL
Every channel of every line is exactly one short. That is a change no screenshot would ever have surfaced, and it would have quietly put a one-unit seam between every sprite and the flat color beside it.
The alpha byte on each pixel
Blit contains no knowledge of creatures. It does not consult a list of
which parts of a frame are solid, it holds no mask, and it has never been told where
the legs are. Each pixel arrives carrying the instruction for its own handling, and the
loop is a three-way branch on that byte. Add a cell with soft edges, a translucent
pane, a puff of dust: the same loop draws all of them, because the new art arrives
with its own alphas and the loop asks each pixel the same question. Data
steering a loop that stays fixed is the cheapest kind of extensibility there is, and it
is why the alpha byte was packed into every color four chapters ago instead of being
bolted on here.
The clipping repeats chapter 13's rule with one addition. There, the check was made
against the largest unit the operation could name in advance: once for a whole
rectangle, once per pixel only when the extent was discovered as the drawing went. A
blit knows its rectangle up front, so the intersection happens once. What is new is
that two rectangles have to stay in step, and the offsets ox, oy keep them
there. Every pixel the clip took off the destination is a pixel never read from the
source, and the ones that survive read exactly the sheet cells they would have read
unclipped: a sprite half off the screen shows the same half it would have shown with
the window a little wider.
The failure generalizes furthest. A PNG's four bytes per pixel are unscaled by
definition, and the instant they are decoded nothing carries that fact any further:
not the type Color, not color.Color, not a debugger. Both
conventions are four bytes, both look correct under inspection, and both come out of
the standard library under names one keystroke apart. Which one a number obeys is an
agreement between the code that wrote it and the code that reads it, exactly like the
alpha-red-green-blue byte order chapter 12 settled on, and agreements at seams are
where the ugly bugs live. When two pieces of code both do defensible arithmetic and the
picture still comes out wrong, stop reading the arithmetic and start asking what each
side believes the numbers already mean.
One last thing the run confirmed: the double scaling had been in the program for two stages and drew perfect frames the whole time, because the only sprites on the sheet were solid. Bugs that need a specific input to show themselves are patient. The test card exists to be that input on purpose, and it is why the sheet ships with a cell that is not art at all.
Checkpoint
- Name the three cases
Blitbranches on, and say what each one writes to the framebuffer. - Given a sheet pixel and the pixel under it, compute the mix on paper, channel by channel, and predict the eight hex digits the client will print.
- Argue why the divisor is 255 and name the three properties that break at 256, including the one that only shows up when a color is mixed with itself.
- Given a blit clipped at an edge, work out
oxand say which sheet column a given destination column read. - Tell a premultiplied color from a straight one by comparing its channels against its alpha, and say which standard library types speak which convention.
- Regenerate the sheet, hash it, and assert a whole composed frame against a constant that a person put in the source on purpose.
Exercise 1: one sheet or four files. Write a short program
that cuts each of the four cells into its own 16-by-16 PNG through
SaveSheet. Add up the four file sizes and compare them against the
sheet's.
195, 205, 107 and 94 bytes, which is 601 against the sheet's 328. The pixels are identical, so the whole difference is per-file overhead: four signatures, four header chunks, four sets of checksummed chunk frames, and four compression runs that each start from nothing instead of sharing one dictionary. Four frames is the smallest case anyone would bother with and the sheet already wins by 45 percent. Then count the startup work: one open, one decode and one allocation against four of each.
Exercise 2: draw it in the wrong order. In
SpriteScene, swap the two calls at (92,66) so the walker is blitted
before its shadow. Predict how many pixels change, then write the result to
swapped.png and compare it against the frame you already have.
One.
$ go run ./cmd/worldc -diff frame.png swapped.png
frame.png sha256 eb3c5619ec6474601ee8d8d80c22af448a17b6f6f87e712846d1afaefc236a83
swapped.png sha256 45c00022ef54b07b5f683321863871d02d416db1baf41687aea92d39aa5b88c9
1 of 15360 pixels differ, first at (103,78): ff2b3524 became ff242c20
The shadow oval is three rows tall and the walker's feet reach into the top row of it at exactly one column, so a single outline pixel gets the shadow mixed over it and comes out darker. The count is not the lesson. Painter's order is part of the drawing, whatever is blitted last wins the pixels it shares, and here the overlap happened to be one pixel wide. Give the creature a wider stance, or the shadow a larger oval, and the same one-line swap lays a haze across the whole animal.
Exercise 3: a heavier shadow. Change shade in
mkatlas to 0x80101418, alpha 128. Before regenerating,
work out by hand what the pixel at (98,79) becomes.
Alpha 128 leaves 127 for the ground. Red is (16·128 + 107·127)/255 =
15637/255 = 61; green is (2560 + 11430)/255 = 54; blue is (3072 + 7874)/255 = 42.
So 0xFF3D362A. Rebuild the sheet, then run the client:
$ go run ./cmd/worldc
worldc 0.0.1 sheet 64x16, 4 cells of 16, sha256 73bba5f3bd0d2671c1a7fd29ca675c46b51bb8f253cf006a518f40f2ed87ff88
shadow 80101418 over soil FF6B5A3E -> FF3D362A
card 88E8D8A0 over sky FF6E8CA0 -> FFAFB4A0
frame 160x96 sha256 584d21049dbd1a5a7bbe276ce1ee115a11efe87f420e007d698d2c7caf864fda
the frame holds FF3D362A at (98,79) and FFAFB4A0 at (128,12)
The card is untouched, since only the shadow's alpha changed, and the frame hash
moved because one asset did. TestSpriteSceneMatchesItsHash fails now
and is right to: the composed picture is not the one the constant was written
against. Put the alpha back to 64, or keep the heavier shadow, look at the
picture, and change the constant on purpose.